| 导语 ABI(Application Binary Interface)描述了应用程序和OS之间的底层接口。其中,通过查阅调用约定(Calling Convention),我们可以了解到子过程调用是如何传递参数及返回值的,其中的细节包括有参数或返回值传递的位置(寄存器/栈)和使用细节、传参的顺序、调用前后的清理工作等。 目前,主流移动设备CPU主要采用ARM处理器。在做移动客户端开发时,难免遇到需要分析汇编代码的情况,牵涉到过程调用的部分就必须要了解相应平台的ABI。 本文从实际开发中遇到的一个平台相关的crash问题出发,通过代码对比,研究了在x86-64架构和ARM64架构对于不定函数参数传递的方式——特别是iOS系统的不同之处,同时也解答了为什么在调用带不定参数类型的C函数指针时,应该显示将其强转为对应参数类型的函数指针问题。
Crash背景
写业务代码时遇到了一个情况:有一个会被频繁调用的数据处理方法,在其处理逻辑中存在一个开关,每次处数据前需要判断开关是否打开。伪代码见Code 1-1。
为了提高调用速度并免去if判断,实现上采用了缓存方法的IMP指针(函数指针)直接调用的逻辑以绕过ObjectiveC运行时。开发过程在debug、通过企业环境部署时都没有发现问题,但在提交到主干后,被发现在真机debug的环境下,引发了必现crash,堆栈如图所示。(图中代码为简化版本)
快速复习
为便于阅读后续代码,该部分主要帮助理解:
1.OC方法调用会被转化为普通C函数调用
2.IMP
指针就是一个普通的C函数指针,SEL
类型用于标示类的一个方法
3.可以直接拿到一个OC方法实现的IMP
指针
Objective-C的方法调用是通过消息传递的形式,即:
[receiver message:arg]
会被编译器转化为C函数调用
objc_msgSend(receiver, @selector(message:), arg)
其中:
1.@selector(message:)
是一个SEL类型的值,用于标示类中的一个方法(类比C的函数指针来理解)
2.@selector(methodName)
表达式用于获得当前类中methodName
方法的对应SEL
obj_msgSend(recevier, selector, ...)
函数的主要执行流程大致是:
查找并取得recevier
所属类 -> 在类中查找selector
方法的实现的函数体 -> 获得指向这个函数的指针IMP
并调用,同时传递参数
当然,实际实现中还实现了方法缓存、消息转发等重要机制。这里不作赘述,不是重点 😉
上述流程中的IMP指针是普通的C函数指针,原型为id (*IMP)(id, SEL, ...)
,指向方法的实际函数体实现。
通过NSObject
的instanceMethodForSelector:
类方法,我们可以获得指定selector的IMP
指针,因而可以通过直接调用IMP
来绕过objc运行时,从而加速调用过程或实现其他更灵活的操作
Objective-C 中id是一个指针类型,指向任意一个Objective-C对象,相当于void *
NSObject是所有对象的基类
初步分析
初步分析部分可以得到以下结论:
1.引起crash的根本原因并非ARC
2.引起crash的直接原因是调用objc_retain
函数时传入了一个栈上的地址,而这个参数本该是一个对象
3.crash的解决方案是调用IMP指针时,显示将其强转为对应参数列表的函数指针
crash发生在objc_storeStrong
函数中,猜测是ARC(自动引用计数)下导致的问题,尝试将process_blackhole
方法的参数类型修改为void *
或id __unsafe_unretained
后,不发生crash。
但事情没有这么简单,将缓存的IMP指针指向- [TestClass process:]
,该方法对数据进行了处理(意味着使用了参数),继续测试发现,真机debug环境下同样会引起crash。
那么就不能简单地处理这个问题:使用void *或id __unsafe_unretained传递参数,ARC下编译器无法正确管理其的生命周期,后续对象的使用存在严重安全隐患。
ARC复习:
id类型的默认所有权修饰符是id strong,在超出其变量作用域时会被调用release方法
_使用void *或_unsafe_unretained修饰符传递参数相当于直接传递对象指针
分析Objective-C Runtime的源码,objc_storeStrong
的实现见Code 3-1。
逻辑较为简单,即将参数obj
对象retain(引用计数+1)后,存放到参数location
指定的地址,并且对location
中原来存放的对象调用release(引用计数-1)
objc_storeStrong
在方法的函数序(prologue)部分被调用,在方法函数体的执行之前,持有传入的参数(即持有其强引用,避免被释放)。
通过汇编单步调试发现上述crash属于访存错误,objc_retain调用传入了一个堆栈上的地址。这很奇怪,按理说传入的应当是该方法的实参对象——一个堆中的地址,指向一个合法对象。
通过Google,在Stack Overflow上有人遇到了同样的crash:IMP methodForSelector EXC_BAD_ACCESS crash,回答给出的解决方案是显式将IMP强转为函数类型:
经测试,的确解决了crash的问题。
PS.:对比Relase配置下和Debug配置的汇编代码发现,之所以通过企业环境部署时未发生crash是因为- [TestClass process_blackhole]
方法是空实现,Release下经过编译器优化,其函数体仅有一条ret指令。在该方法实现中加入对参数的处理逻辑后,会引起同一个crash. 😛
测试代码
该部分编写了三段测试代码,根据编译出的汇编指令,发现了以下问题:
1.直接调用参数列表含有不定参数的函数指针:
x86-64架构下参数传递都正常,但在ARM64架构下,调用一个参数时,生成的汇编传递的是参数在栈上的地址;调用两个参数时,依次传递了第二个参数和第一个参数的地址。
2.将函数指针强转为与原函数参数列表一致的函数指针类型再调用:
ARM64架构参数传递符合预期
每段测试代码都只展示了关键函数指针调用语句对应的汇编,为便于阅读,关键汇编语句的含义已经注释在末尾。
插曲
为了更好地分析原因,在新工程参照Code 1-1编写了测试代码,但是发生了编译错误
查阅Runtime源码(Code 4-1),发现IMP的指针定义与传统认识有些许出入,被一个名为OBJC_OLD_DISPATCH_PROTOTYPES
的宏控制,未定义时IMP指针指向一个参数列表为void的函数。
查阅资料后发现需要在LLVM编译选项中手动开启Enable Strict Checking of objc_msgSend Calls,路径为:
工程文件 -> Build Settings -> Apple LLVM - Preprocessing -> Enable Strict Checking of objc_msgSend Calls
该开关控制上述宏的定义,关闭后IMP指针则为之前我们所熟悉的id (*IMP)(id, SEL, ...)
类型。
这是一个编译时的检查,Xcode默认开启。开启这个检查后,在调用obj_msgSend前,应手动将obj_msgSend其强转成实际的函数类型(IMP指针同理),也就是上文提到IMP methodForSelector EXC_BAD_ACCESS crash的解决方案。手Q工程中该选项默认已关闭。
为什么现在的编译器会加入这样一个检查?通过后面的分析会有答案。
测试代码1
编写测试代码(Code 4-2)。其中参照IMP类型声明了一个函数指针,最后一个参数为不定参数。
测试结果与预期一致,模拟器环境下代码正常执行,真机环境会crash在internalProcess:
方法入口处
查看编译器生成的汇编代码,定位到语句(*processIMP)(self, processSEL, value);
相关的汇编指令(Assembly 4-1.1 & Assembly 4-1.2)。
模拟器(x86-64)
真机(ARM64)
注:
1.(IMP of internalProcess:)为_- [TestClass internalProcess:]
方法的IMP指针,通过instanceMethodForSelector:
获取后被压栈。
2.retainedValue为- (void)processValue:(id)value
_的参数value被retain后的值。
在本文初步分析 部分有提到,ARC环境下,在方法函数体的实现部分之前,编译器会对参数调用objc_storeStrong以持有传入的参数,存放在栈中
说明
可以看到,模拟器下参数传递正确,而真机下却很奇怪地传递了参数的地址而非本身,造成了本文初步分析 部分提到的访存crash:
objc_retain调用传入了一个堆栈上的地址而非对象。
测试代码2
暂时没有太多头绪,因此查看两个参数传递的情况,编写测试代码Code 4-3。该代码测试执行crash情况与Code 4-2相同。
同样定位到语句(*processIMP)(self, processSEL, valueA, valueB);
相关的汇编指令(Assembly 4-2.1 & Assembly 4-2.2)。
模拟器(x86-64)
真机(ARM64)
说明
这次ARM64架构的传参更加奇怪,传递的分别是第二个参数以及第一个参数的地址
测试代码3
为了结合正确情况的代码分析,编写测试代码Code 4-4,该代码根据函数的实际类型定义了指针,经测试真机和模拟器都能正常执行。
查看真机对应到语句(*processIMP)(self, processSEL, valueA, valueB);
相关的汇编指令(Assembly 4-3)
真机(ARM64)
说明
可以看到这次参数传递符合预期,因此未发生crash
问题分析与结论
结合测试分析、阅读手册可以得到以下关键点:
1.测试代码2的ARM64架构部分,函数调用时传参的行为非常像在通过调用栈传递参数,而根据以往认识,前8个参数(整形/指针)应当依次通过X0-X7
寄存器传递
2.System V ABI手册指出:x86-64对于变参列表会同时使用寄存器和栈传递,整形和指针会先用6个通用寄存器来传递
3.ARM64架构过程调用手册指出:ARM64对于变参列表参数传递也不会作特殊处理,根据15条分配原则依次传参,整形和指针也应先用寄存器传递
4.苹果iOS ABI函数调用手册指出:iOS相比ARM64 ABI有不同之处,其中不定参数函数只将固定参数的参数按照ARM64 ABI处理,而所有的变参则会依次压栈
以下问题真机环境只考虑ARM64(iPhone 5s及以后的设备),也即AArch64执行态的ARMv8-A架构
经过上一部分的代码测试,引起crash的直接原因有了结论:真机调用IMP指针时传递的参数不正确。
但是我们注意到x86-64架构的模拟器一直是正确的,这是为什么呢?
回到关键的测试代码2部分,查看指令片段Assembly 4-2.2可以注意到这三条指令
这不像是在通过寄存器传参,倒很明显地是在通过栈传参——将两个参数从右至左依次压栈。
可是,根据以往Google的了解,ARM64调用约定是:
前8个参数依次通过X0-X7寄存器传递,剩下的参数从右往左依次入栈,由被调用者实现栈平衡,返回值存放在X0 中。
实际情况也的确如此。但从代码来看,却很像是retainedValueA和retainedValueB两个参数在通过压栈的方式传参,难道ARM64调用约定对于不定参数函数传参模式有特殊处理?
事情到这里,只能进入到人民群众喜闻乐见的RTFM环节
RTFM
x86-64
首先了解一下模拟器环境下对于不定参数函数传参的处理,查阅System V Application Binary Interface: AMD64 Architecture Processor Supplement:
根据3.5.7 Variable Argument Lists部分可以了解到:变参列表的参数可能会同时使用寄存器和栈来传递,为了保证可移植性必须使用来处理变参列表,因此va_list
被定义为一个结构体,当中包含了通过寄存器和栈传递参数的信息;
根据3.2.3 Parameter Passing可以了解到:整形和指针是通过6个寄存器(%rdi, %rsi, %rdx, %rcx, %r8 and %r9)来传递的。
因此模拟器环境下的代码,参数通过寄存器被正确传递。
ARM64
查阅Procedure Call Standard for the ARM 64-bit Architecture (AArch64),参数传递规则在5.4 Parameter Passing部分:
5.4.1 Variadic Subroutines部分介绍了不定参数函数,其参数分为两部分,Named arguments
和Anonymous arguments
;
5.4.2 Parameter Passing Rules 部分中可以看到参数会依次经过StageC的15条规则决定分配,并没有对不定参数作特殊处理,既然如此那么参数传递也应如x86-64一样,传递的指针会先填满用于传参的寄存器后再通过栈传参,为什么实际情况却不是如此?
考虑到平台相关的可能性,终于在苹果文档iOS ABI Function Call Guide中的ARM64 Function Calling Conventions小节找到了答案,这里提到了iOS上对于ARM和ARM64架构的ABI有一些不同之处。
在“Divergences from the Generic Procedure Call Standard”部分的“Variadic Functions”说明了:
The iOS ABI for functions that take a variable number of arguments is entirely different from the generic version.
Stages A and B of the generic procedure call standard are performed as usual—in particular …… After that, the fixed arguments are allocated to registers and stack slots as usual in iOS.
…… and each variadic argument is assigned to the appropriate number of 8-byte stack slots
iOS在参数传递时,与ARM64 ABI在Stage A和Stage B是一样的,但在Stage C却大相径庭。iOS只将固定部分的参数按照ABI处理,而变参则会依次压栈。
因此,iOS平台的va_list
实现也异常简单——就是char *
类型,而不需要像x64定义一个复杂结构体。
结论
至此,crash的问题终于有了结论:由于不同CPU体系结构——或者说是不同平台——导致的问题。
在iOS设备上,通过IMP指针直接调用方法时,编译器按照调用不定参数函数的方式传递参数,除了前两个id和SEL参数,其他参数被作为变参列表压栈;被调方法的参数列表是固定的,编译器生成代码时则按照固定函数传参的方式获取实参。两边传参约定的不对称,导致被调方法获取到了错误的参数,引起了crash。如下图所示。
值得注意的是,在正确使用不定参数的情况下不会发生这个问题,<stdargs.h>
会负责处理平台相关的问题。
这也解答了上文中提到的编译器默认开启Enable Strict Checking of objc_msgSend Calls的原因。因此在创建新项目工程或库工程时,不应该关闭这个选项,同时在显式使用obj_msgSend或IMP指针时,请手动将obj_msgSend或IMP指针其强转成实际的函数类型以避免上述仅在真机中会出现的crash问题。
如果你觉得我们的内容还不错,就请转发到朋友圈,和小伙伴一起分享吧~