- 跨平台
- 移动开发
- 开发工具
【免费下载链接】WinObjC
Objective-C for Windows
导读
本文基于 Windows Bridge for iOS(WinObjC)仓库中的设计规范文档 docs/Foundation/NSInvocation.md 及其配套源码,深入剖析NSInvocation在 x86 与 ARM 两种平台上的运行时参数布局(argument layout)与函数派发(dispatch)实现。NSInvocation 将一次 Objective-C 方法调用抽象为可装箱、可查询、可转发的对象,其核心难点在于:仅凭方法签名(method signature)就须在运行时按平台调用约定拼装出完整的实参帧(argument frame)。读完本文,你将理解 WinObjC 中_NSInvocationCallFrame内部接口的设计、allocation extent分配体系、x86/ARM 汇编 thunk 的工作方式,以及forwardInvocation:如何借助这一布局完成消息转发。
1. NSInvocation 是什么:方法调用的对象化表示
NSInvocation 是 Foundation 中一个将"方法调用"封装为对象的核心类。一次调用包含三个要素:
- target(消息接收者);
- selector(被调用的方法);
- 一组参数(arguments),可在运行时被写入(install)或读出(interrogate)。
对应的公开接口定义在 include/Foundation/NSInvocation.h 中:
+ (NSInvocation*)invocationWithMethodSignature:(NSMethodSignature*)signature; @property SEL selector; @property (assign) id target; - (void)setArgument:(void*)buffer atIndex:(NSInteger)index; - (void)getArgument:(void*)buffer atIndex:(NSInteger)index; @property (readonly) BOOL argumentsRetained; - (void)retainArguments; - (void)setReturnValue:(void*)buffer; - (void)getReturnValue:(void*)buffer; - (void)invoke; - (void)invokeWithTarget:(id)anObject; @property (readonly, retain) NSMethodSignature* methodSignature;在 Frameworks/Foundation/NSInvocation.mm 中,setArgument:atIndex:与getArgument:atIndex:均会先校验索引范围,越界时抛出NSInvalidArgumentException;随后把读写委托给平台相关的_NSInvocationCallFrame(storeArgument/loadArgument)。setTarget:与setSelector:本质上是setArgument:atIndex:在索引 0(self)与索引 1(_cmd)上的便捷封装;invoke则根据签名是否要求结构体返回,在objc_msgSend与objc_msgSend_stret之间做出选择后调用_callFrame->execute(pfn, _returnValue)。
之所以"难以实现",是因为 Objective-C 方法在底层就是普通 C 函数:按平台默认调用约定(Win32 上是__cdecl,ARM 上是 AAPCS)接收2 + n个参数,前两个固定为self与_cmd。因此NSInvocation 必须在运行时、仅凭方法签名,把参数"摆"成目标函数期望的真实调用帧。
2. 核心挑战:运行时按签名布局参数
2.1 方法实现即 C 函数
设计规范 docs/Foundation/NSInvocation.md 明确指出:Objective-C 方法实现按标准 C 函数调用约定接收2 + n个参数,前两个永远是self与_cmd,其余参数按平台默认调用约定排布——Win32 使用__cdecl,ARM 使用 AAPCS(Procedure Call Standard for the ARM Architecture)。
2.2 结构体返回(struct return)的特殊处理
当方法的返回值是超过平台定义长度上限的结构体时,编译器会为方法实现隐藏插入第一个参数:一个结构体返回指针(struct return pointer,缩写 stret)。self与_cmd因此整体上移一个机器字,为 stret 腾出位置;被调函数自动把返回值写入该指针指向的内存。这意味着调用方在布局参数前必须先回答一个问题:这个方法是否属于 stret 类型?
在 Frameworks/Foundation/NSInvocation.mm 的invoke中可以看到这一分支的直接体现:
void* pfn = &objc_msgSend; // Methods that require struct returns need the three-argument version of objc_msgSend. if (_callFrame->getRequiresStructReturn()) { // void objc_msgSend_stret(void* outPointer, id self, SEL _cmd, {args...}); pfn = &objc_msgSend_stret; } _callFrame->execute(pfn, _returnValue);2.3 设计应对:_NSInvocationCallFrame
架构决策是引入一个平台相关的内部接口_NSInvocationCallFrame,它只承担三件事:
- 判定方法是否要求特殊的结构体返回处理(
getRequiresStructReturn); - 存储一个参数(
storeArgument); - 加载一个参数(
loadArgument)。
该接口的 C++ 定义见 Frameworks/Foundation/_NSInvocation.x86.h(ARM 下编译时替换为 _NSInvocation.arm.h),核心成员包括参数缓冲区_buffer、_returnLength、不透明的平台返回类型_returnType、结构体返回标志_structReturn以及参数分配区间向量_allocationExtents。因此平台支持被拆成两个离散模块:
_NSInvocationCallFrame的符合性实现(负责布局参数);- 一段汇编 thunk(负责把内部表示变成真正的函数调用)。
两个模块的成对实现在仓库中清晰可见:
| 平台 | 布局实现(C++/ObjC++) | 派发实现(汇编) | 共享头文件 |
|---|---|---|---|
| x86 | Frameworks/Foundation/_NSInvocation.x86.mm | Frameworks/Foundation/_NSInvocation.x86.asm | Frameworks/Foundation/_NSInvocation.x86.h |
| ARM | Frameworks/Foundation/_NSInvocation.arm.mm | Frameworks/Foundation/_NSInvocation.arm.asm | Frameworks/Foundation/_NSInvocation.arm.h |
公共的分配区间结构与转发辅助声明集中在 Frameworks/Foundation/NSInvocationInternal.h:
struct _NSInvocationAllocationExtent { std::ptrdiff_t offset; size_t length; }; extern "C" bool _NSInvocationTypeEncodingMandatesStructReturn(const char* typeEncoding); extern "C" void _NSInvocation_ForwardFrame( void* stret, id self, SEL sel, void* frame, /* out */ _NSInvocationForwardReturnInfo* bridgeOut);3. 两套平台参数布局规则
3.1 x86:全栈式布局(较简单的一侧)
设计文档给出的 x86 规则可以归纳为三点:
- 每个参数都按栈地址递增的顺序压栈;
- 宽度不足 4 字节的参数:先做符号扩展或零扩展,填充成一个 32 位双字后入栈;
- 宽度超过 4 字节的参数:向上取整为偶数个 32 位双字后入栈;
- 返回值约定:≤ 4 字节经
eax返回,4~8 字节经eax+edx返回,更大的结构体通过隐藏的 stret 参数返回。
在 Frameworks/Foundation/_NSInvocation.x86.mm 中,storeArgument对_C_CHR/_C_SHT(有符号 char/short)做符号扩展,对_C_UCHR/_C_USHT(无符号 char/short)做零扩展,再按机器字宽度写入_buffer。值得注意的细节是:BOOL 在 Win32 上因 Windows.h 的存在是 4 字节宽,因此_getReturnType中_C_BOOL会与_C_INT等一起落入RETURN_TYPE_INT32。
x86 的返回值类型分派同样集中:_getReturnType把编码映射为SINT8/UINT8/SINT16/UINT16/INT32/INT64/POINTER/FLOAT/DOUBLE/STRUCT/NONE等类型;1、2、4、8 字节的结构体会降级用寄存器返回,其余结构体才走 stret 路径(_structReturn = true并额外分配一个"^v"指针参数)。
3.2 ARM:寄存器 + 浮点寄存器 + 栈的混合布局
ARM 一侧规则复杂得多,设计文档给出了要点:
- 非浮点、≤ 4 字节的参数:符号/零扩展后优先放入
R0–R3,寄存器耗尽才上栈; - 8 字节、需 8 字节对齐的非浮点参数:优先放入
R0,R1或R2,R3,不可用则整体上栈; - 单精度浮点:优先放入
S0–S15,耗尽则上栈; - 双精度浮点:优先放入
D0–D7,不可用则按 8 字节对齐上栈;每个Dx占用两个Sx寄存器; - 1–4 个单/双精度值组成的聚合体:分配到可用的浮点寄存器;
- 其他 > 4 字节的聚合体:分配剩余寄存器并拆分上栈;
- 返回值约定:≤ 4 字节用
R0,4~8 字节用R0+R1,单精度用S0,双精度用D0,≤ 4 个 float/double 的聚合用D0-D4,更大的结构体走隐藏 stret 参数。
在 Frameworks/Foundation/_NSInvocation.arm.mm 中,这些规则被落成一组分配函数:
_allocateMachineWords(count, alignment):通用寄存器分配。代码注释把情况归纳为三类——(1) 字数为 n、全部塞进剩余寄存器;(2) 栈尚未使用但寄存器放不下,填满寄存器后剩余溢出到栈;(3) 栈已被使用,则整批参数直接放栈并把寄存器全部标记为已用。同时处理 8 字节对齐:当需要alignof(uint64_t)对齐而当前寄存器下标为奇数时,丢弃该寄存器(goto redo)再继续;_allocateFloats(count):单精度寄存器分配,要求同一参数内多个 float 必须连续;_allocateDoubles(count):双精度分配,从偶数下标Sx开始,一个 double 同时标记两个单精度寄存器已用;放不下时禁用全部剩余浮点寄存器并转栈;_allocateArgument:按类型编码分发到上述分配器,并处理_C_STRUCT_B(结构体编码)的特殊情况。
聚合体的同构判定是 ARM 实现的点睛之笔:uniformTypeFromStructSpecifier递归地解析{name=mmm}形式的结构体编码(如{CGSize=ff}、{CGRect={CGSize=ff}{CGPoint=ff}}),剥离可选的字段名引号("x"f"y"f),尝试推导整个聚合体的"统一类型"(homogenous type)。若所有成员(含嵌套结构体)同为f(float)或d(double)且尺寸 ≤ 4 个对应标量,就按浮点聚合分配到 VFP 寄存器返回(RETURN_TYPE_VFP_HOMOGENOUS);否则按通用机器字处理。tests/unittests/Foundation/NSInvocationTests.mm(tests/unittests/Foundation/NSInvocationTests.mm)中定义了UniformAggregateF1..F4、UniformAggregateD1..D4等测试结构体,专门覆盖"同构浮点聚合体"的返回与传参场景,验证了这一判定逻辑。
4. Allocation Extent:可逆的统一参数竞技场
为了同时驾驭 ARM 上"寄存器 / 浮点寄存器 / 栈"三类存储,设计上引入allocation extent(分配区间)系统:把整组参数分配进一个大竞技场(arena),按存储类别细分;每个 extent 只含offset(偏移)与 length(长度)两个字段。
4.1 ARM 竞技场示例
设计文档给出了- (void)hello:(int)arg1 world:(double)arg2在 ARM 上的布局(含隐式self、_cmd):
| FLOATING POINT REGISTERS | GENERAL PURPOSE REGS | STACK | +------------------------------------------------------------------------+ | arg2 | | self | sel | arg1 | + +------------------------------------------------------------------------+ | | | | | | 0 64 68 72 Extents: self: {64, 4} sel: {68, 4} arg1: {64, 2} arg2: { 0, 8}注意这里arg1: {64, 2}的 length 是 2(两个寄存器字宽),而arg2占据浮点区起始处的 8 字节双精度槽。
4.2 x86 竞技场示例
同一方法在 x86 上则是纯栈布局:
| STACK | +------------------------------------------------------------------------+ | self | sel | arg1 | arg2 | | +------------------------------------------------------------------------+ | | | | 0 4 8 12 Extents: self: { 0, 4} sel: { 4, 4} arg1: { 8, 4} arg2: {12, 4}4.3 从源码看竞技场的物理构造
在 x86 实现中,构造函数先按objc_aligned_size把每个参数向上取整到机器字(nWords = max(1, objc_aligned_size(type) / sizeof(uintptr_t))),逐参数追加 offset 形成_allocationExtents,最后calloc一块_offset大小的_buffer。ARM 实现则维护gprUsage/spUsage两个 bitset 与stackBytes计数器,分配出的 extent 的绝对偏移 = 浮点区长度 + 寄存器区长度 + 栈区长度;由于寄存器区紧邻栈区,任何单一参数总能落在一个连续 extent 内,这保证了汇编 thunk 可以最高效地派发调用。
execute阶段(见 x86 与 ARM 两个.mm文件)都是:alloca出一块 16 字节对齐的临时帧 →memcpy竞技场内容 → 若是 stret 类型则把返回指针写入本地副本的_structReturnExtent位置 → 填充x86Frame/armFrame元信息(返回类型、返回长度、返回指针、函数指针)→ 调用汇编入口。
5. 汇编 Thunk:把竞技场变成一次真实调用
5.1 ARM 派发:一条指令批量装载寄存器
ARM 竞技场头部是 8 个双宽度浮点槽(对应D0–D7)。汇编 thunk(Frameworks/Foundation/_NSInvocation.arm.asm)先用VLDM.64 r0, {d0-d7}一次性把 8 个浮点寄存器装好并前移竞技场指针(若只用了 1 个双精度寄存器,则退化为单条VLDR.64 d0, [r0]以节省带宽);随后用LDMIA sp!, {r0-r3}消费 4 个通用寄存器槽;此时竞技场剩下的只有栈区,thunk 把栈指针临时重定向到这里再调用函数——每个参数都正好落在目标方法期望的位置上。
5.2 x86 派发:栈指针直接指向竞技场
x86 则简单得多:@_CallFrameInternal@8(__fastcall,参数为x86Frame*与新栈指针)把esp直接指向参数包,mov eax, [ebp+24]取出函数指针后call eax。调用返回后按returnType跳入跳转表(JUMPTABLE),依据返回类型把eax/edx/x87 浮点栈中的结果解包写入返回指针:例如FLOAT用fstp DWORD PTR [ecx],DOUBLE用fstp QWORD PTR [ecx],SINT8/UINT8/SINT16/UINT16做movsx/movzx扩展后写回,INT64同时写eax与edx。汇编中明确注释:跳转表顺序必须与 _NSInvocation.x86.mm 中的 return_type 枚举顺序保持一致,这是两模块之间的隐性契约。
5.3 结构体返回的统一出口
结构体返回值不走寄存器解包。x86 汇编中RETURN_TYPE_STRUCT与RETURN_TYPE_NONE合并落到LOCAL_ret,因为 stret 情况早已在 C 侧处理完毕;_NSInvocation_ForwardingBridge(带 stret 的转发入口)在进入_NSInvocation_ForwardFrame之前,会把 stret 指针作为帧的一部分压栈调整好偏移。
6. 布局可逆性:forwardInvocation:的基石
6.1 为什么布局必须"可逆"
设计文档的 Rationale 部分点明了整套布局方案的收益与动机:
- 拷贝次数更少:只要 NSInvocation 内部内存布局与真实参数帧一致,参数通常可以直接按原样使用,或在派发开始时一次性拷入单一缓冲区;
- 更重要的是布局可逆:
forwardInvocation:是 Foundation 中强大的消息转发机制——一次失败的消息派发会被打包成 NSInvocation,之后可以被保存到栈上、用作回调、被查询信息,甚至直接丢弃。
当 NSInvocation 的内存布局与平台偏好的参数布局高度吻合时,forwardInvocation:就能以一次拷贝完成从"半求值的方法派发现场"(参数散落在栈、寄存器等任意位置)到"装箱后的 NSInvocation"的迁移。
6.2 转发桥的实现证据
转发链路在 Frameworks/Foundation/NSInvocation.mm 末尾的_NSInvocation_ForwardFrame中落地:
- 通过
methodSignatureForSelector:获取方法签名;若为空,则回退到sel_getType_np/sel_copyTypedSelectors_np从类型化 selector 提取,再以signatureWithObjCTypes:构造签名; - 签名仍不可得时记录
TraceError并调用doesNotRecognizeSelector:(随后兜底填充{nullptr, 0}输出); - 用
_initWithMethodSignature:copyInFrame:从平台帧拷贝竞技场,创建 NSInvocation; - 调用
[self forwardInvocation:invocation],让用户代码接管; - 若存在 stret(仅结构体返回入口会提供),把
getReturnValue:的结果写回 stret;最后通过_NSInvocationForwardReturnInfo(含returnValue与不透明平台返回类型)把返回值交还给汇编层解包。
与之配套,Frameworks/Foundation/_NSInvocation.x86.asm与_NSInvocation.arm.asm各自实现了_NSInvocation_ForwardingBridge(带 stret)与_NSInvocation_ForwardingBridgeNoStret(无 stret)两个入口:它们在真正转发前把调用现场(x86 的栈参数、ARM 的r0-r3与d0-d7)原样保存成 NSInvocation 期望的帧格式,BLX _NSInvocation_ForwardFrame之后再按返回类型表(ARM 用TBB [ip, r3]表跳转)把返回值装回寄存器/浮点寄存器返回给原调用者。ARM 版本还通过PROLOG_VPUSH {d0-d7}保存 VFP 寄存器、PROLOG_STACK_ALLOC 12为bridgeOut预留空间,并在_NSInvocation_ForwardingBridgeNoStret中把self/_cmd上移一位、r0填nullptr以模拟 stret 参数位。
7. 工程配套:参数保留与测试覆盖
7.1retainArguments与对象生命周期
setArgument:atIndex:、retainArguments、dealloc三处构成了参数生命周期管理的闭环(Frameworks/Foundation/NSInvocation.mm):
- 未开启
retainArguments时,参数按原样拷贝,不持有对象; - 开启后,
setArgument:atIndex:对@类型先retain新值再release旧值;对*类型用_strdup深拷贝字符串并释放旧值; retainArguments遍历全部参数,对@(id/block)与*(char*)分别执行 retain 与 strdup;dealloc对称地release与free这些保留的参数,并释放非栈上的_returnValue。
小返回值优化同样存在:initWithMethodSignature:中,若提升后的返回长度不超过 16 字节的_smallReturnValueOptimization对齐缓冲区(aligned(16)),返回值就存放在实例内,否则calloc堆内存,dealloc时按需释放——与 Cocoa 的 NSInvocation 行为一致。
7.2 单元测试
Frameworks/Foundation/NSInvocationOperation.mm 提供NSInvocationOperation(把 NSInvocation 包装进 NSOperation 的常见用法,对应头文件 include/Foundation/NSInvocationOperation.h),而参数布局的正确性由两套测试守护:
- tests/unittests/Foundation/NSInvocationTests.mm:覆盖对象参数、整数参数、
UniformAggregateF1..F4/UniformAggregateD1..D4同构浮点聚合体的传参与返回,直接验证 ARM 的 VFP 聚合判定与分配逻辑; - tests/unittests/Foundation/NSInvocationOperationTests.mm:验证 NSInvocationOperation 的封装语义。
8. 总结与设计启示
回顾整份设计与实现,WinObjC 的 NSInvocation 呈现出三个可复用的工程思路:
- 平台差异收敛于一处:所有调用约定的差异都被收进
_NSInvocationCallFrame(布局)+ 汇编 thunk(派发)这对模块,上层 NSInvocation 代码对平台完全透明; - 以"可逆布局"服务高级特性:竞技场/分配区间让参数帧既可用于正向调用(
invoke),也能在消息转发时被一次拷贝还原成对象,从而支撑forwardInvocation:这一动态派发基石; - 类型编码驱动一切:从判断 stret、推断聚合体同构类型,到符号/零扩展与对齐,全部由 Objective-C 类型编码(type encoding)在运行时推导,无需编译器参与。
对于希望在 WinObjC 之上实现 AOP、延迟调用、消息拦截或单元测试替身的开发者而言,这套机制正是它们的地基:一条invocationWithMethodSignature:到invoke的调用链,承载了对象化方法调用的全部语义。
- 跨平台
- 移动开发
- 开发工具
【免费下载链接】WinObjC
Objective-C for Windows
相关推荐
WinObjC核心技术:NSInvocation在Windows平台的实现原理
WinObjC核心技术:NSInvocation在Windows平台的实现原理 NSInvocation是Objective C中一个强大的反射机制,它允许开发
跨平台移动开发开发工具终极指南:Centrifugo消息投递机制深度剖析——确保实时消息零丢失的底层原理
终极指南:Centrifugo消息投递机制深度剖析——确保实时消息零丢失的底层原理 Centrifugo作为一款高性能的实时消息服务器,以其卓越的消息投递可靠性
消息队列后端通信QMQ源码剖析:深入理解消息存储与消费的底层实现
QMQ源码剖析:深入理解消息存储与消费的底层实现 QMQ作为去哪儿网内部广泛使用的消息中间件,自2012年诞生以来在订单场景、报价搜索等高吞吐量业务中发挥着重要
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考