结合访问Out Parameters出现EXC_BAD_ACCESS的例子,反编译汇编解读__autoreleasing

更新

2016-05-01: 补充了一点本文涉及的汇编知识

前言

本文结合一段访问Out Parameters出现了EXC_BAD_ACCESS错误的代码,通过反编译等手段验证Objective-C中__autoreleasing的一些特点。

2016-05-01更新 - 关于本文的反编译汇编代码

本文的反编译基于MachO 64bits,即System V X86_64,读懂本文需要的最简单Calling convention如下:

函数参数顺序:

1
2
3
4
5
6
7
8
rdi, rsi, rdx, rcx等寄存器
对应到
id objc_msgSend ( id object, SEL cmd, arg1, arg2 ... );
就是
rdi = object # 被调用对象
rsi = cmd # 方法selector
rdx = arg1 # 方法第一个参数
rcx = arg2 # 方法第二个参数

函数返回值:

1
本文只涉及: rax寄存器

详细可参考:

Out Parameters - 指针的指针

所谓Out Parameters,其实就是指针的指针,熟悉C/C++的朋友应该不陌生,通过指针的指针可以改变指针的值,在Objective-C里面很多地方用到了这种方法,来在函数、方法内部改变参数的原始值,如:

1
2
// NSFileManager的方法
- (BOOL)removeItemAtURL:(NSURL *)URL error:(NSError **)error

第二个参数(NSError **)error,就是Out Parameters,调用时传递NSError类型指针的地址即可:

1
2
3
NSError *error = nil;
[[NSFileManager defaultManager] removeItemAtURL:url error:&error];
// 处理error...

出现EXC_BAD_ACCESS的代码示例

假设有如下遍历数组检查数值零的方法:

1
2
3
4
5
6
7
8
9
10
11
- (void)checkZeroInArray:(NSArray <NSNumber *> *)array error:(NSError **)error {
// 遍历
[array enumerateObjectsUsingBlock:^(NSNumber * _Nonnull number, NSUInteger index, BOOL * _Nonnull stop) {
if (number.integerValue == 0) { // 检查
if (error) { // 指针是否有效
*error = [NSError errorWithDomain:@"me.tutuge" code:100 userInfo:nil]; // 创建NSError实例
}
*stop = TRUE; // 停止遍历
}
}];
}

调用的时候如下:

1
2
3
4
5
NSError *error = nil;
[self checkZeroInArray:numbers error:&error]; // 取地址
if (error) {
NSLog(@"Error: %@", error); // EXC_BAD_ACCESS错误
}

可以得出,NSLog(@"Error: %@", error)访问error的时候,error的地址指向的内存空间已经被释放,所以才会出现EXC_BAD_ACCESS错误。

但是为什么会被释放?什么时候被释放的?经过一番查证,发现跟__autoreleasing@autoreleasepool有关。

下面先对__autoreleasing做点研究=。=

__autoreleasing - 变量所有权(ownership)修饰符

先看看__autoreleasing的定义及一些特点。

__autoreleasing是变量所有权修饰符的一种,除了它,还有__strong__weak__unsafe_unretained,详细的说明可以参考:Clang文档-Objective-C Automatic Reference Counting (ARC)

简单来说,就是被__autoreleasing修饰的变量会被加入到当前的autoreleasepool中,可以理解为如下两段分别在ARC和MRC中的代码等价:

1
2
3
4
5
// ARC
id __autoreleasing obj = someObj;

// MRC
id obj = [[someObj retain] autorelease];

再进一步,除开各种影响因素,假设有如下函数:

1
2
3
- (void)funcWithObj:(id)someObj {
id __autoreleasing obj = someObj;
}

用Hopper Disassembler反编译后为如下汇编代码(MachO 64bits):

funcWithObj:反汇编

var_18就是obj变量:

lea rax, qword [ss:rbp+var_18]mov rdi, rax取了var_18的地址放在rdi寄存器中,mov rsi, rdxsomeObj值放到了rsi寄存器中,然后调用id objc_storeStrong(id *object, id value)函数最终将someObj值保存在var_18变量中。

__autoreleasing导致了id objc_retainAutorelease(id value)函数的调用:

call imp___stubs__objc_retainAutorelease,就是对obj变量,也就是var_18,调用了objc_retainAutorelease函数,先retain然后autorelease了一次,其实现大致如下:

1
2
3
id objc_retainAutorelease(id value) {
return objc_autorelease(objc_retain(value));
}

objc_storeStrongobjc_retainAutorelease可参考:Clang文档-Objective-C Automatic Reference Counting (ARC)

可验证,用__autoreleasing修饰的变量会被添加到当前的autoreleasepool中。

方法的Out Parameters参数会自动添加__autoreleasing属性

当方法参数里面有Out Parameters参数时,就是有指针的指针类型时,编译器会自动为参数加上__autoreleasing属性,如以下两个方法:

1
2
3
4
5
6
7
- (void)generateError1:(NSError **)error {
*error = [NSError new];
}

- (void)generateError2:(NSError * __autoreleasing *)error {
*error = [NSError new];
}

编译时,generateError1:会对参数error自动添加__autoreleasing,然后就跟generateError2:的实现完全一致了。

通过反汇编也可看出两者完全一致:

Out Parameters参数会自动添加__autoreleasing属性

call imp___stubs__objc_msgSend完成后,rax寄存器保存了[NSError new]的对象,然后mov rdi, rax,转移到rdi寄存器,作为objc_autorelease函数的参数被调用,*error被加到了当前的autoreleasepool中。

如果传给Out Parameters参数的变量没有用__autoreleasing修饰,编译器会创建一个临时变量并以__autoreleasing修饰再传入

根据苹果的Transitioning to ARC Release Notes文档可知,如果有如下调用:

1
2
NSError *error; // 默认__strong类型
[self generateError1:&error];

编译器检测到generateError1:方法的Out Parameters类型参数,但是调用时的error又不是__autoreleasing修饰的,就会自动创建一个__autoreleasing修饰的临时变量,用来代替error传入,编译器重写后如下:

1
2
3
NSError * error; // 默认__strong类型
NSError * __autoreleasing tmp = error;
[self generateError1:&tmp];

通过汇编来验证一下:

假如有如下调用:

1
2
3
4
- (void)runTest {
NSError *error = nil;
[self generateError1:&error]; // 就是上面的实现
}

反汇编后,如下:

自动添加__autoreleasing临时变量

代码有点多=。=,从图中汇编可知:var_20就是自动生成的临时变量,var_18是我们定义的error变量。

mov qword [ss:rbp+var_18], 0x0var_18做初始化,也就是赋nil值,然后mov rdi, qword [ss:rbp+var_18]mov qword [ss:rbp+var_20], rdi就是用var_18初始化了var_20临时变量。

lea rdx, qword [ss:rbp+var_20]var_20临时变量的地址存到了rdx寄存器中,作为objc_msgSend实现generateError1:调用时的第三个参数,也就是(NSError **):error参数,完成调用。

调用完成后,通过如下调用,将临时变量var_20的值保存到var_18,即error变量中。

1
2
3
4
lea rdx, qword [ss:rbp+var_18] # var_18为objc_storeStrong第一个参数
mov rsi, qword [ss:rbp+var_20] # var_20为objc_storeStrong第二个参数
mov rdi, rdx
call imp___stubs__objc_storeStrong

开头例子出现EXC_BAD_ACCESS错误的原因

经过上面一番对__autoreleasing的总结,再来看看开头例子的错误原因就比较容易懂了。

enumerateObjectsUsingBlock会在循环内部自动添加autoreleasepool

首先应该明确的就是enumerateObjectsUsingBlock:在用block迭代遍历NSArray的元素时,会自动添加autoreleasepool,对于例子来说,相当于:

1
2
3
4
5
6
7
8
9
// 枚举遍历
[array enumerateObjectsUsingBlock:^(NSNumber * _Nonnull number, NSUInteger index, BOOL * _Nonnull stop) {
if (number.integerValue == 0) {
if (error) {
*error = [NSError errorWithDomain:@"me.tutuge" code:100 userInfo:nil];
}
*stop = TRUE;
}
}];

结合__autoreleasing后,重写为:

1
2
3
4
5
6
7
8
9
10
11
12
NSNumber *number = nil;
BOOL stop = FALSE;

for (NSUInteger index = 0; index < array.count && !stop; index++) {
@autoreleasepool { // 自动添加
number = array[index];
if (error) {
*error = [[NSError errorWithDomain:@"me.tutuge" code:100 userInfo:nil] autorelease]; // 注意这个autorelease !
}
stop = TRUE;
}
}

autorelease对应的函数id objc_autorelease(id value)的官方解释:

If value is null, this call has no effect. Otherwise, it adds the object to the innermost autorelease pool exactly as if the object had been sent the autorelease message.

其中的innermost autorelease pool表示的就是“最内层”的autoreleasepool,对于例子来说就是*error被添加到了循环内的autoreleasepool中,当然,导致的结果就是本次循环结束后,*error也随着一起被释放了。

最终导致了外部访问了已经被释放的*error,出现了EXC_BAD_ACCESS错误。

总结

很多时候不能想当然的写代码=。=,要不然出了问题找都找不到,每个细节都很重要。
嗯,现在读汇编快多了。。。

参考

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器