[NSMutableAttributedString initWithData:options:documentAttributes:error] 崩溃分析

背景

项目中使用了 [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 个汇编指令, cmpb.lecmpcompare 的缩写也就是比较的意思,而 b.leBranch if Less than or Equal 的缩写,如果上一条指令 cmp 执行的结果是小于或等于则进行跳转。 一般来说 arm64 上 x0x7 分别会存放方法的前 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> 是将函数的入参保存到寄存器 x0x1 中, <+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 的实例,那么取一个实例地址某一个偏移值的内容,是否可以猜测是读取实例对象中的一个变量的值,我们可以直接在该指令位置打一个断点,查看此时 x21x24 的值。结果如下图:

x21和x24寄存器的值

发现此时的 x21 的确是 _WebSafeForwarder 的一个实例对象,而 x24 的值也很像一个变量的偏移值。由于已经知道是 _WebSafeForwarder 的实例,所以我们直接打印出其内部变量,如下图:

_WebSafeForwarder内部变量

可以发现 _WebSafeForwarder 对象有 4 个变量(忽略 isa),我们一个一个查看变量的偏移值,看是否有何 x24 的值匹配的变量偏移值。

_WebSafeForwarder变量偏移值

可以看出第一个变量 target 的偏移值就是 8,和 x24 寄存器相匹配,所以 objc_msgSend 中的第一个参数,实际上就是 _WebSafeForwardertarget 变量。而objc_msgSend的第二个变量是 SEL,我们也可以打印出来,如下图:

objc_msgSend的第二个变量

综上所诉,实际是在调用 [self.target respondsToSelector:] 时发生崩溃。

这里我们可以有一个大胆的推测,由于从汇编指令中看,对于 self.target 的取值是直接读取偏移值,而且没有调用 objc_loadWeakRetained 方法,所以 _WebSafeForwardertarget 应该不是 weak变量,而是一个 assign变量,所以可猜测是否是由于 target 所指向的地址已经被释放,导致访问 target 时发生了野指针错误。

复现问题

从上面的分析中,我们可以猜测是由于 _WebSafeForwardertarget 所指向的对象已经被释放掉,而 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_WebSafeForwarderforwardInvocation:,在 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用的比较多,现总结一下它的用法:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,539评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,911评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,337评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,723评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,795评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,762评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,742评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,508评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,954评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,247评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,404评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,104评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,736评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,352评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,557评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,371评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,292评论 2 352

推荐阅读更多精彩内容

  • 转至元数据结尾创建: 董潇伟,最新修改于: 十二月 23, 2016 转至元数据起始第一章:isa和Class一....
    40c0490e5268阅读 1,703评论 0 9
  • 一.objc_msgSend函数简介 以前去面试,有人问了这个一个问题 发生了什么?一听这个问题,一脸懵逼。这不就...
    充满活力的早晨阅读 471评论 0 2
  • 本文详细整理了 Cocoa 的 Runtime 系统的知识,它使得 Objective-C 如虎添翼,具备了灵活的...
    lylaut阅读 797评论 0 4
  • 本文转自:杨萧玉博客 本文详细整理了 Cocoa 的 Runtime 系统的知识,它使得 Objective-C ...
    oneofai阅读 207评论 0 0
  • 愿你一生清澈明朗,所求遂所愿
    RachelShi123阅读 138评论 0 0