背景
项目中使用了 [NSMutableAttributedString initWithData:options:documentAttributes:error]
来解析 HTML
字符串,但是在线上检测到了很多该方法崩溃的记录,如下图
定位问题
由于在开发环境一直没办法复现,所以只能直接分析线上的崩溃记录。
范围界定
一般来说,会先查看是否是某个机型或iOS 系统版本的问题,但是该问题几乎涉及到所有的机型,版本号也是从 iOS 8 到 最新的 iOS 12,所以没办法从这 2 方面缩小范围。从上报的崩溃记录上看,崩溃的主要原因是SEGV_ACCERR
,也就是常说的野指针访问。一般来说,发生在访问一个已经被释放的内存地址时,会导致该问题。
堆栈分析
从堆栈上看,都是崩溃在
libobjc.A.dylib objc_msgSend + 16
这一个方法调用中,虽然 objc_msgSend
方法苹果没有开源,但是我们可以通过使用 symbolic breakpoint
来断点该方法,查看其汇编的调用如下(这里涉及到一些汇编知识):
libobjc.A.dylib`objc_msgSend:
-> 0x1c0eccd60 <+0>: cmp x0, #0x0 ; =0x0
0x1c0eccd64 <+4>: b.le 0x1c0eccdcc ; <+108>
0x1c0eccd68 <+8>: ldr x13, [x0]
0x1c0eccd6c <+12>: and x16, x13, #0xffffffff8
0x1c0eccd70 <+16>: ldp x10, x11, [x16, #0x10]
0x1c0eccd74 <+20>: and w12, w1, w11
0x1c0eccd78 <+24>: add x12, x10, x12, lsl #4
0x1c0eccd7c <+28>: ldp x17, x9, [x12]
0x1c0eccd80 <+32>: cmp x9, x1
0x1c0eccd84 <+36>: b.ne 0x1c0eccd8c ; <+44>
由于崩溃发生在偏移值为 <+16> 也就是第 5 行的位置,所以只需要分析前 5 行汇编究竟做了什么就可以了,下面我们一行一行进行分析。
0x1c0eccd60 <+0>: cmp x0, #0x0
0x1c0eccd64 <+4>: b.le 0x1c0eccdcc ; <+108>
这里涉及到 2 个汇编指令, cmp
和 b.le
, cmp
是 compare
的缩写也就是比较的意思,而 b.le
是 Branch if Less than or Equal
的缩写,如果上一条指令 cmp
执行的结果是小于或等于则进行跳转。 一般来说 arm64 上 x0
– x7
分别会存放方法的前 8 个参数,如果参数个数超过了8个,多余的参数会存在栈上,新方法会通过栈来读取。而返回值一般都在 x0
中。
所以上面 2 条汇编指令的大概意思是:将 objc_msgSend
的第一个参数和 0 进行比较,如果值小于或等于 0,则跳转到地址 0x1c0eccdcc
,而 objc_msgSend
的第一个参数其实就是 self
,所以这里是判断 self
是否是 nil
,如果是 nil
就直接跳转到 0x1c0eccdcc
, 0x1c0eccdcc
这里没有写出来,可以理解成 objc_msgSend
的结束位置(不是很恰当)。
0x1c0eccd68 <+8>: ldr x13, [x0]
ldr
是读取指令,是指从存取器中读取加载到寄存器中。所以上面的指令加载寄存器 x0
指向的内容到寄存器 x13
中。如果将 x0
理解成 c 语言中的指针,上面的指令可以理解成 x13 = *x0
;
0x1c0eccd6c <+12>: and x16, x13, #0xffffffff8
and
指令,就是 x16 = x13 & #0xffffffff8
,很简单的算法指令。
0x1c0eccd70 <+16>: ldp x10, x11, [x16, #0x10]
该指令也就是发生崩溃的地方,也是一条加载指令 ldp
,是指 从 x16 + 0x10
指向的地址里面取出 2 个 64 位的数,分别存入 x10
, x11
。在执行该指令的时候,出现了野指针错误,虽然没有具体的源码,但是我们反推 x16
地址的来源来缩小范围,x16
来自于 x13
,而 x13
又来自于 x0
,所以也就是说,是在对 objc_msgSend
第一个参数进行操作时导致产生野指针崩溃。
到这里,objc_msgSend
已经没有什么可以分析的内容了,下一步是分析
1 WebKitLegacy -[_WebSafeForwarder forwardInvocation:] + 132
同样的办法,我们在 [_WebSafeForwarder forwardInvocation:]
打一个断点,直接跳到 <+ 132 >
位置进行分析,其内容如下:
WebKitLegacy`-[_WebSafeForwarder forwardInvocation:]:
-> 0x1cbed13fc <+0>: stp x24, x23, [sp, #-0x40]!
0x1cbed1400 <+4>: stp x22, x21, [sp, #0x10]
0x1cbed1404 <+8>: stp x20, x19, [sp, #0x20]
0x1cbed1408 <+12>: stp x29, x30, [sp, #0x30]
0x1cbed140c <+16>: add x29, sp, #0x30 ; =0x30
0x1cbed1410 <+20>: mov x19, x2
0x1cbed1414 <+24>: mov x21, x0
0x1cbed1418 <+28>: bl 0x1caadcd1c ; WebThreadIsCurrent
0x1cbed141c <+32>: cbz w0, 0x1cbed1448 ; <+76>
0x1cbed1420 <+36>: adrp x8, 145109
0x1cbed1424 <+40>: add x1, x8, #0xe2a ; =0xe2a
0x1cbed1428 <+44>: mov x0, x19
0x1cbed142c <+48>: bl 0x1c8d16378
0x1cbed1430 <+52>: mov x0, x19
0x1cbed1434 <+56>: ldp x29, x30, [sp, #0x30]
0x1cbed1438 <+60>: ldp x20, x19, [sp, #0x20]
0x1cbed143c <+64>: ldp x22, x21, [sp, #0x10]
0x1cbed1440 <+68>: ldp x24, x23, [sp], #0x40
0x1cbed1444 <+72>: b 0x1caadd294 ; WebThreadCallDelegate
0x1cbed1448 <+76>: adrp x8, 187655
0x1cbed144c <+80>: ldrsw x24, [x8, #0x428]
0x1cbed1450 <+84>: ldr x23, [x21, x24]
0x1cbed1454 <+88>: adrp x8, 145010
0x1cbed1458 <+92>: add x20, x8, #0x6e0 ; =0x6e0
0x1cbed145c <+96>: mov x0, x19
0x1cbed1460 <+100>: mov x1, x20
0x1cbed1464 <+104>: bl 0x1c8d16378
0x1cbed1468 <+108>: mov x2, x0
0x1cbed146c <+112>: adrp x8, 145009
0x1cbed1470 <+116>: add x22, x8, #0x5e0 ; =0x5e0
0x1cbed1474 <+120>: mov x0, x23
0x1cbed1478 <+124>: mov x1, x22
0x1cbed147c <+128>: bl 0x1c8d16378
0x1cbed1480 <+132>: cbz w0, 0x1cbed149c ; <+160>
这次我们从后往前分析,由于汇编语言的特性(这里就不详细讲解),虽然崩溃是指向<+132>
,但实际上在调用上一行汇编指令导致的崩溃,所以我们直接从 <+128>
开始分析。
0x1cbed1474 <+120>: mov x0, x23
0x1cbed1478 <+124>: mov x1, x22
0x1cbed147c <+128>: bl 0x1c8d16378
上面 3 行指令实际上是一个函数的调用过程,<+120>
和 <+124>
是将函数的入参保存到寄存器 x0
和 x1
中, <+128>
跳转到指定的地址,也就是调用函数。该函数就是我们上面分析的 objc_msgSend
的函数,由于我们上面已经分析得出,是由于第一个参数,也就是寄存器中的值 x0
出现问题,导致了野指针错误,所以我们直接按照 objc_msgSend
分析思路,分析 x0
的来源,下面列出了 x0
相关的几条指令
WebKitLegacy`-[_WebSafeForwarder forwardInvocation:]:
-> ...
0x1cbed1414 <+24>: mov x21, x0 // 将 x0 的赋值给 x21
.....
0x1cbed1448 <+76>: adrp x8, 187655 // 读取 pc + 187655 地址的内容
0x1cbed144c <+80>: ldrsw x24, [x8, #0x428] // 加载 x8 + 0x428 地址的内容到 x24中
0x1cbed1450 <+84>: ldr x23, [x21, x24] // 加载 x21 + x24 地址的内容到 x23 中
....
0x1cbed1474 <+120>: mov x0, x23 // 将 x23 的值赋值给 x0
0x1cbed1478 <+124>: mov x1, x22
0x1cbed147c <+128>: bl 0x1c8d16378
0x1cbed1480 <+132>: cbz w0, 0x1cbed149c ; <+160>
从上面的精简指令中,我们可以知道,objc_msgSend
的第一个参数来自 x23
, x23
来自 x21
,而 x21
又是来自 x0
, 也就是 forwardInvocation:
方法的第一个参数,实际上就是 _WebSafeForwarder
的实例对象。
这里我们重点分析下 <+84>
这一行指令
0x1cbed1450 <+84>: ldr x23, [x21, x24] // 加载 x21 + x24 地址的内容到 x23 中
该指令是取 x21 + x24
地址的内容,我们已经知道 x21
是 _WebSafeForwarder
的实例,那么取一个实例地址某一个偏移值的内容,是否可以猜测是读取实例对象中的一个变量的值,我们可以直接在该指令位置打一个断点,查看此时 x21
和 x24
的值。结果如下图:
发现此时的 x21
的确是 _WebSafeForwarder
的一个实例对象,而 x24
的值也很像一个变量的偏移值。由于已经知道是 _WebSafeForwarder
的实例,所以我们直接打印出其内部变量,如下图:
可以发现 _WebSafeForwarder
对象有 4 个变量(忽略 isa),我们一个一个查看变量的偏移值,看是否有何 x24
的值匹配的变量偏移值。
可以看出第一个变量 target
的偏移值就是 8,和 x24
寄存器相匹配,所以 objc_msgSend
中的第一个参数,实际上就是 _WebSafeForwarder
的 target
变量。而objc_msgSend
的第二个变量是 SEL
,我们也可以打印出来,如下图:
综上所诉,实际是在调用 [self.target respondsToSelector:]
时发生崩溃。
这里我们可以有一个大胆的推测,由于从汇编指令中看,对于 self.target
的取值是直接读取偏移值,而且没有调用 objc_loadWeakRetained
方法,所以 _WebSafeForwarder
中 target
应该不是 weak
变量,而是一个 assign
变量,所以可猜测是否是由于 target
所指向的地址已经被释放,导致访问 target
时发生了野指针错误。
复现问题
从上面的分析中,我们可以猜测是由于 _WebSafeForwarder
的 target
所指向的对象已经被释放掉,而 target
又没有被设置为 nil
导致程序奔溃,为了验证这个猜想,我们人为的制造一个 crash,将堆栈信息和线上崩溃的堆栈信息进行对比,如果是一致的,就可以确认是该原因导致的,复现代码如下:
CG_INLINE void
SwizzleMethod(Class _originClass, SEL _originSelector, Class _newClass, SEL _newSelector) {
Method oriMethod = class_getInstanceMethod(_originClass, _originSelector);
Method newMethod = class_getInstanceMethod(_newClass, _newSelector);
class_addMethod(_originClass, _newSelector, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
BOOL isAddedMethod = class_addMethod(_originClass, _originSelector, method_getImplementation(newMethod), method_getTypeEncoding(newMethod));
if (isAddedMethod) {
class_replaceMethod(_originClass, _newSelector, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
} else {
method_exchangeImplementations(oriMethod, newMethod);
}
}
@interface NSObject (EPWebSafe_Private)
@end
@implementation NSObject (EPWebSafe)
+ (void)load {
SwizzleMethod(NSClassFromString(@"_WebSafeForwarder"),NSSelectorFromString(@"forwardInvocation:"), self , @selector(safe_forwardInvocation:));
}
- (void)safe_forwardInvocation:(NSInvocation *)arg1 {
if ([NSStringFromSelector(arg1.selector) isEqualToString:@"webView:willCloseFrame:"] && [NSThread isMainThread]) {
@autoreleasepool {
Class delegateClass = NSClassFromString(@"NSHTMLWebDelegate");
id newDelegate = [delegateClass new];
object_setIvarValue(self, "target", newDelegate);
}
}
return [self safe_forwardInvocation:arg1];
}
@end
复现的思路是,hook
的 _WebSafeForwarder
的 forwardInvocation:
,在 safe_forwardInvocation:
中将 target
设置成一个临时变量,采用 @autoreleasepool
是为了模拟 target
指向的对象已经被释放,但是 target
并没有被设置为 nil
的现象。if ([NSStringFromSelector(arg1.selector) isEqualToString:@"webView:willCloseFrame:"] && [NSThread isMainThread])
这个判断条件是由于 forwardInvocation:
会被用于很多种用处,添加上面的判断条件是为了保证 forwardInvocation:
签名调用堆栈和线上的保持一致。运行结果如下图:
通过对比线上的崩溃堆栈,如下图
可以发现崩溃的堆栈信息是一模一样的,所以可以基本可以确定线上的崩溃就是由于该问题引起的。
解决问题
确定问题的根源后,就很好解决了,这里的解决方法参考了同事之前实现的一个防止 iOS8 上面 UIScrollView
delegate 指向内容被释放后,还被调用导致的崩溃。实现思路可以查看链接 优雅解决 iOS 8 UIScrollView delegate EXC_BAD_ACCESS
具体代码如下:
#import "NSObject+EPWebSafe.h"
#define object_getIvarValue(object, name) object_getIvar(object, class_getInstanceVariable([object class], name))
#define object_setIvarValue(object, name, value) object_setIvar(object, class_getInstanceVariable([object class], name), value)
CG_INLINE void
SwizzleMethod(Class _originClass, SEL _originSelector, Class _newClass, SEL _newSelector) {
Method oriMethod = class_getInstanceMethod(_originClass, _originSelector);
Method newMethod = class_getInstanceMethod(_newClass, _newSelector);
BOOL a = class_addMethod(_originClass, _newSelector, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
BOOL isAddedMethod = class_addMethod(_originClass, _originSelector, method_getImplementation(newMethod), method_getTypeEncoding(newMethod));
if (isAddedMethod) {
class_replaceMethod(_originClass, _newSelector, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
} else {
method_exchangeImplementations(oriMethod, newMethod);
}
}
@interface HtmlReleaseDelegateCleaner : NSObject
@property (nonatomic, strong) NSPointerArray *htmlDelegates;
@end
@implementation HtmlReleaseDelegateCleaner
- (void)dealloc {
[self cleanHtmlDelegate];
}
- (void)recordHtmlDelegate:(id)htmlDelegate {
NSUInteger index = [self.htmlDelegates.allObjects indexOfObject:htmlDelegate];
if (index == NSNotFound) {
[self.htmlDelegates addPointer:(__bridge void *)(htmlDelegate)];
}
}
- (void)removeHtmlDelegate:(id )htmlDelegate {
NSUInteger index = [self.htmlDelegates.allObjects indexOfObject:htmlDelegate];
if (index != NSNotFound) {
[self.htmlDelegates removePointerAtIndex:index];
}
}
- (void)cleanHtmlDelegate {
[self.htmlDelegates.allObjects enumerateObjectsUsingBlock:^(id htmlDelegate, NSUInteger idx, BOOL * _Nonnull stop) {
if ([htmlDelegate isKindOfClass:NSClassFromString(@"_WebSafeForwarder")]) {
object_setIvarValue(htmlDelegate, "target", nil);
}
}];
}
- (void)setHtmlDelegates:(NSMutableSet *)htmlDelegates {
objc_setAssociatedObject(self, @selector(htmlDelegates), htmlDelegates, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (NSPointerArray *)htmlDelegates {
NSPointerArray *htmlDelegates = objc_getAssociatedObject(self, _cmd);
if (!htmlDelegates) {
htmlDelegates = [NSPointerArray weakObjectsPointerArray];
[self setHtmlDelegates:htmlDelegates];
}
return htmlDelegates;
}
@end
@interface NSObject (EPWebSafe_Private)
@property (nonatomic, readonly) HtmlReleaseDelegateCleaner *webDelegateCleaner;
@end
@implementation NSObject (EPWebSafe)
- (HtmlReleaseDelegateCleaner *)webDelegateCleaner {
HtmlReleaseDelegateCleaner *cleaner = objc_getAssociatedObject(self, _cmd);
if (!cleaner) {
cleaner = [HtmlReleaseDelegateCleaner new];
objc_setAssociatedObject(self, _cmd, cleaner, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
return cleaner;
}
+ (void)load {
SwizzleMethod(NSClassFromString(@"_WebSafeForwarder"),NSSelectorFromString(@"initWithTarget:defaultTarget:"), self ,@selector(safe_initWithTarget:defaultTarget:));
}
- (id)safe_initWithTarget:(id)arg1 defaultTarget:(id)arg2 {
if ([NSStringFromClass([arg1 class]) isEqualToString:@"NSHTMLWebDelegate"]) {
[[arg1 webDelegateCleaner] recordHtmlDelegate: self];
}
return [self safe_initWithTarget:arg1 defaultTarget:arg2];
}
@end
参考文献
iOS开发同学的arm64汇编入门
优雅解决 iOS 8 UIScrollView delegate EXC_BAD_ACCESS
ARM(CM3)的汇编指令
在ARM汇编中,LDR用的比较多,现总结一下它的用法: