货拉拉技术

货拉拉iOS疑难Crash治理-系统键盘语音

一. 背景

我们业务一直存在着通过键盘语音进行语音转文字的一个系统崩溃, 具体崩溃时间点是在键盘语音输入结束之后,取消语音输入 UIDictationConnectioncancelSpeech发生崩溃。

以下是具体的崩溃信息:

Image

Image

Image

该崩溃在苹果论坛上有挺多人反馈过,但都没有给出具体的解决方案。

https://forums.developer.apple.com/forums/thread/728757

https://developer.apple.com/forums/thread/742413

从目前崩溃系统统计来看,只会发生在 iOS16及以上的系统。因此很明显这是一个苹果系统版本升级所引入的崩溃。

二. 原因排查

  1. ## 崩溃类型

首先从崩溃类型是 EXC_BAD_ACCESS(SIGSEGV),我们可以大概确定某个对象野指针导致了这个崩溃。

EXC_BAD_ACCESS: 顾名思义是坏地址异常,而 EXC_BAD_ACCESS异常类型下面一般分为两种具体的信号 SIGSEGV和 SIGBUS

引起 SIGSEGV:

  • 试图对只读区域进行写操作

  • 访问的内存已经释放,也就是访问的内存不存在或者越界

引起 SIGBUS:

  • 未对齐的内存

  • 不存在的物理地址

  • 硬件错误

SIGBUS(BusError)意味着指针对应的虚拟地址是有效地址,但总线不能正常使用该地址。

SIGSEGV(SegmentFault)意味着指针对应的虚拟地址是无效地址。该虚拟地址本身就不存在,或者该虚拟地址超过用户态的访问界限。

EXC_BAD_ACCESS(SIGSEGV)是我们异常崩溃里面最经常见到的,最主要的原因就在于野指针。

野指针: 即当所指向的对象被释放或者收回,但是对该指针没有作任何的修改,以至于该指针仍旧指向已经回收的内存地址,此情况下该指针便称野指针了

  1. ## 还原堆栈

然后我们用相同或相近的系统版本在 release模式上运行调试运行。

为什么这里要用用相同或相近的系统版本:

因为相同或相近的系统版本可以保证,系统函数内部实现逻辑的一致,如果版本差别较大,可能会出现系统内部函数有改动,这样生成的汇编指令也会有变动。

为什么需要在release模式下运行

Image

因为release模式和debug模式,编译选项一般不同,在 debug模式下一般为了辅助调试,编译优化选项一般选择不进行优化,而 release模式为了保证 App运行的效率或者 App包大小,一般默认会开启对应的编译选项优化。

Image

Image

不同的编译优化模式,对生成的汇编指令不一样。因此要尽可能的保证调试时候 App的编译所生成的代码跟线上包一致,这样才能保证生成的汇编指令代码一致。所以建议在 release模式下运行调试。

考虑到 release模式下,一般固定线上环境,不分场景不好复现。也可以在 debug模式下,但要保证项目和相关 pod工程里面的编译优化选项设置为一致。因此可以在 podfile里面加上对应的编译优化代码。

  1. config.build_settings['SWIFT_OPTIMIZATION_LEVEL']='-Osize'

  2. config.build_settings['GCC_OPTIMIZATION_LEVEL']='s'

  1. ## 汇编指令调试解析

从最后一行的 _objc_msgSend+32, 我们直接定位到 _objc_msgSend的偏移指令 32的地方。

libobjc.A.dylib`objc_msgSend:

-> 0x19705c800 <+0>: cmp x0, #0x0 /// 比较寄存器x0(通常用于第一个参数)的值是否为0。这可能是在检查接收消息的对象是否为nil。

0x19705c804 <+4>: b.le 0x19705c8d0 ; <+208> /// 如果上一条指令比较结果小于等于0,则跳转到标签0x19705c8d0执行特殊处理,等于0,则代表该对象为空,小于0,则为负数,代表是一个tagged pointer对象,因为tagged pointer 的在 iOS中会被置位 1作为标记位。

0x19705c808 <+8>: ldr x14, [x0] /// 取出对象(x0)的isa指针赋值给x14寄存器。

0x19705c80c <+12>: and x16, x14, #0x7ffffffffffff8 /// 通过对象isa进行掩码,获得对象的Class对象指针并赋值给X16

0x19705c810 <+16>: mov x10, x0 /// 将接收者对象x0的地址保存到x10寄存器

0x19705c814 <+20>: movk x10, #0x6ae1, lsl #48 /// 将一个特定的16位立即数(0x6ae1)移动到x10的最高有效字节,并向左移动48位。这是为了构造一个特殊的地址,用于访问缓存中的IMP(方法实现指针)。

0x19705c818 <+24>: autda x16, x10 /// 对x10寄存器中的地址进行认证(autda指令是ARM架构的地址认证指令),结果存储在x16寄存器。

0x19705c81c <+28>: mov x15, x16 /// 将认证后的地址保存到x15寄存器。

0x19705c820 <+32>: ldr x11, [x16, #0x10] /// 从类结构体的偏移0x10处加载数据到寄存器x11。这通常是类中方法列表的地址。

0x19705c824 <+36>: tbnz w11, #0x0, 0x19705c880 ; <+128> /// 如果x11中的值不为0,则跳转到指定的地址0x19705c880,这里进一步检查缓存的方法列表是否有效。

汇编代码 _objc_msgSend+32是这一条指令 0x19705c820<+32>:ldr x11,[x16,#0x10], 表示从 Class对象的 cache取出 buckets赋值给 X11寄存器。

从代码中可以看出是在读取 Class对象指针的成员变量 cache时出现了 无效的地址访问异常。但是 Class对象这部分定义数据是存储在进程内存的 数据区段中,并且伴随着整个应用的生命周期而存在,是不可能被释放和销毁的,因此正常情况下是不可能存在非法内存地址访问异常的。

会出现这种问题的原因就是调用方法的 OC实例对象被销毁了,内存被回收后,原先的内存数据被复写另外一个地址,这个地址依然属于堆内存区的范围。

然后访问这个地址,去获取 Class的 cache变量,就访问了非法的内存区域。

Image

推荐文章: [深入iOS系统底层之crash解决方法]

也就是说从最后一行的 _objc_msgSend+32,我们可以确定是这个对象被提前释放,导致的野指针。

接着我们来看下堆栈的倒数第二行: 查看堆栈 -[UIDictationConnectioncancelSpeech]_block_invoke+152。

由于 -[UIDictationConnectioncancelSpeech]_block_invoke+152并不在崩溃堆栈的最后一行,因为崩溃堆栈层级中的非顶层地址都是函数调用指令的下一条地址也就是 LR的值,所以真实的崩溃指令处是 152再减去 4也就是实际崩溃的地址是 148。

这里为什么查看的是偏移指令 148

因为一般程序崩溃的地址都有3个特征:

a. 崩溃堆栈层级中的非顶层地址都是函数调用指令的下一条地址也就是 LR的值,所以真实的崩溃指令处是第1步算出的结果再减去 4。而这里 -[UIDictationConnectioncancelSpeech]_block_invoke+152非崩溃堆栈的顶层,所以崩溃的实际地址是偏移指令 148

b. 如果崩溃信息出现在最顶层时,一般的崩溃指令都是带有内存访问的指令。假如例如这里的 _objc_msgSend+32, 这一条指令 0x1abbd51e0<+32>:ldr x11,[x16,#0x10],表示从 Class对象的 cache取出 buckets赋值给 X11寄存器。

c . 如果崩溃信息出现在最顶层即无内存访问也无函数调用的指令时,这种崩溃一般是触发了 brk断点指令,或者产生了其他一些无法可判断的原因了。前者比较好定位,后者就很难了。

注意崩溃堆栈倒数第二行这里: -[UIDictationConnectioncancelSpeech]_block_invoke+152有一个 _block_invoke,这表明这个汇编代码定位的地方是在 [UIDictationConnectioncancelSpeech]方法里面的 block回调里面。

Image

我们可以看到 144指令调用了 lastHypothesis

接着我们断点到 148指令 0x19919d7f0

然后断点调试跟踪进入函数 0x19919d7f0:

Image

调试过程中,我们打印对应的 $x0,$x1,$x16,我们可以看到 $x0为 nil说明该对象已经为 nil, p(char*)$x1显示方法名称为 lastHypothesis, po $x16,显示对象的类,为 UIDictationController

x0 寄存器中的保存的就是那个被销毁了的对象指针。

x1 寄存器中保存的就是产生崩溃的对象的方法名称的地址。

x13 寄存器中保存的就是对象的isa指针值。

x16 寄存器中保存的就是对象的Class指针对象。

po $x0 来显示对象信息, p (char*)$x1 来显示方法名称

于是我们通过 runtime打印 UIDictationController的所有变量和方法,

  1. #import <objc/runtime.h>

  2. #import "FJFClassInfoPrint.h"

  3. @implementationFJFClassInfoPrint

  4. +(void)printClassVarWithClassName:(NSString*)className {

  5. unsignedint numIvars;//成员变量个数

  6. Ivar*vars = class_copyIvarList(NSClassFromString(className),&numIvars);

  7. //Ivar *vars = class_copyIvarList([UIView class], &numIvars);

  8. NSString*key=nil;

  9. for(int i =0; i < numIvars; i++){

  10. Ivar thisIvar = vars[i];

  11. key =[NSString stringWithUTF8String:ivar_getName(thisIvar)];//获取成员变量的名字

  12. NSLog(@"%@ variable name :%@",className, key);

  13. key =[NSString stringWithUTF8String:ivar_getTypeEncoding(thisIvar)];//获取成员变量的数据类型

  14. NSLog(@"%@ variable type :%@",className, key);

  15. }

  16. free(vars);

  17. }

  18. +(void)printClassMethodWithClassName:(NSString*)className {

  19. unsignedint numIvars =0;

  20. Method*meth = class_copyMethodList(NSClassFromString(className),&numIvars);

  21. //Method *meth = class_copyMethodList([UIView class], &numIvars);

  22. for(int i =0; i < numIvars; i++){

  23. Method thisIvar = meth[i];

  24. SEL sel = method_getName(thisIvar);

  25. constchar*name = sel_getName(sel);

  26. NSLog(@"%@ method :%s",className, name);

  27. }

  28. free(meth);

  29. }

  30. @end

我们可以看到 UIDictationController对象确实存在 NSString类型的 lastHypothesis变量,以及相对应的 set、get方法。

  1. UIDictationController variable name :_lastHypothesis

  2. UIDictationController variable type :@"NSString"

  3. UIDictationController method :setLastHypothesis:

  4. UIDictationController method :lastHypothesis

接下来,通过查看函数 0x19919d7f0指令代码:

0x19919d7f0: adrp x16, -55539 /// adrp 指令用于加载一个地址的高位值。这个地址是相对于当前指令位置的一个偏移量,-55539 表示目标地址距离当前指令的大致距离。x16 寄存器用于存储这个地址的高位值。

0x19919d7f4: add x16, x16, #0xe2c ;objcclaimAutoreleasedReturnValue /// add 指令将上一步得到的高位值与一个偏移量(#0xe2c)相加,以得到完整的目标地址。这个地址指向 objcclaimAutoreleasedReturnValue 函数,该函数用于处理自动释放池中的返回值。

0x19919d7f8: br x16 /// br 指令是一个无条件的分支指令,它根据 x16 寄存器中的地址跳转执行。这里它会跳转到 objc_claimAutoreleasedReturnValue 函数的地址。

0x19919d7fc: brk #0x1 /// brk 指令用于触发一个异常或系统调用。在这里,#0x1 指定了异常的类型。这种指令通常用于调试或错误处理。

0x19919d800: adrp x16, -55523 /// adrp 指令再次用于加载另一个地址的高位值,这个地址是相对于当前指令位置的另一个偏移量,-55523。

0x19919d804: add x16, x16, #0xe1c ; objccopyStruct /// 同第二步,add 指令将高位值与偏移量(#0xe1c)相加,以得到 objccopyStruct 函数的完整地址。这个函数用于复制结构体。

0x19919d808: br x16 /// 同第三步,br 指令跳转到 objc_copyStruct 函数的地址。

0x19919d80c: brk #0x1 /// 同第四步,brk 指令再次触发一个异常或系统调用。

我们可以大概推断出这里首先调用了 lastHypothesis的 getter方法,然后获取到 lastHypothesis对象返回的时候,对 lastHypothesis对象是否需要加入自动释放池做了判断,然后调用了 copy操作。

也就是在对 lastHypothesis判断是否需要加入自动释放池,进行 reatin、 release操作的判断,然后调用了 copy操作,这里出现了崩溃。

objc_claimAutoreleasedReturnValue

objc_claimAutoreleasedReturnValue 函数的主要作用是处理由Objective-C方法返回的对象,特别是那些通过autorelease发送回来的对象。当一个方法返回一个对象时,这个对象通常会被加入到自动释放池(autorelease pool)中。这样做的目的是为了在使用完毕后能够自动释放这些对象,避免内存泄漏。

在ARC环境下,编译器会自动插入retain和release操作来管理对象的引用计数。然而,当一个对象通过autorelease返回时,直接使用retain可能会导致额外的开销,因为对象已经处于自动释放池中。为了避免这种情况,objc_claimAutoreleasedReturnValue 函数会检查当前的返回值是否可以被“认领”(即不需要进行额外的retain操作),如果返回值可以被认领,那么函数会返回对象本身,否则它会执行一个release操作,然后返回对象。

这个函数的使用可以减少不必要的引用计数操作,提高程序的性能。它通过检查线程局部存储(TLS)中的一个标记来决定是否需要执行release操作。如果标记表明当前线程接受优化返回值(即ReturnAtPlus1),则不需要retain;否则,它会执行release操作。

objc_copyStruct

objccopyStruct 函数用于创建一个结构体的副本。在Objective-C中,结构体通常用于打包多个值,这些值可以是基本数据类型或者指针。当需要复制一个结构体时,objccopyStruct 函数会创建一个新的结构体实例,并将其内容设置为原始结构体的内容。

这个函数在处理自定义结构体或者在需要确保数据副本的情况下非常有用。例如,当你有一个包含指针的结构体,并且你希望在不改变原始数据的情况下使用这些指针时,你可以使用objc_copyStruct 来创建一个副本。

objc_copyStruct 函数确保了结构体的每个成员都被正确复制,包括那些可能指向动态分配内存的指针。这对于避免潜在的内存管理问题和确保数据的一致性非常重要。

最后我们结合堆栈崩溃在子线程 29,且崩溃时候访问了 _objc_msgSend,崩溃类型是 SIGSEGV,也就是代表多线程读写导致 UIDictationController的 lastHypothesis时,出现野指针问题。

三. 解决方案

  • 基于以上的分析,我们可以得出原因主要在于,由于多线程同时对 UIDictationController的 lastHypothesis变量对象进行读写操作,导致在设置 lastHypothesis变量的过程, lastHypothesis之前的变量被释放,这时候另一条线程同步读取,导致出现野指针崩溃。

  • 相对应的解决方案,就是对 UIDictationController类的 setLastHypothesis:方法和 lastHypothesis方法,添加锁操作,来保证多线程安全。

  • 而崩溃在于获取 lastHypothesis返回的时候,而通过 hook来对 UIDictationController类的 setLastHypothesis:方法和 lastHypothesis方法,添加锁操作,但锁的范围,并未能涵盖 return返回操作,因此加完锁之后,又因为 lastHypothesis是 NSString类型,因此调用了 mutableCopy操作,保证了 lastHypothesis。

  1. [NSClassFromString(@"UIDictationController") hd_hookMethod:NSSelectorFromString(@"setLastHypothesis:") option:HDHookOptionInstead handle:^(HDInvocation*invocation){

  2. [[invocation.target hdcore_lock]lock];

  3. [invocation invoke];

  4. [[invocation.target hdcore_lock] unlock];

  5. } error:nil];

  6. [NSClassFromString(@"UIDictationController") hd_hookMethod:NSSelectorFromString(@"lastHypothesis") option:HDHookOptionInstead handle:^NSString*(HDInvocation*invocation){

  7. [[invocation.target hdcore_lock]lock];

  8. __autoreleasing NSString*orgHypothesis;

  9. [invocation invokeWithReturnValue:&orgHypothesis];

  10. orgHypothesis = orgHypothesis.mutableCopy;

  11. [[invocation.target hdcore_lock] unlock];

  12. return orgHypothesis;

  13. } error:nil];

  • 这里的 hd_hookMethod是我们内部封装的 hook的组件,类似于 aspect框架。

  • hdcore_lock是封装的给 NSObject对象关联的锁,这里的 invocation.target就代表 UIDictationController对象

  • 由于该问题只出现在 iOS16-iOS17之间的版本,因此添加系统判断和相应的降级开关。

四. 总结

以上主要介绍了,如何通过崩溃类型、崩溃堆栈、偏移指令、然后结合调试汇编指令、确定键盘语音崩溃根本原因,最后进行有效的治理。

若本文有错误之处或者技术上关于其他类型 Crash的讨论交流的,欢迎评论区留言。

五. 推荐

深入iOS系统底层之crash解决方法

深入解构objc_msgSend函数的实现

iOS疑难Crash的寄存器赋值追踪排查技术