包阅导读总结
1. 货拉拉、iOS、Crash 治理、键盘语音、野指针
2. 本文介绍了货拉拉业务中通过键盘语音进行语音转文字时的系统崩溃问题。经排查,确定为多线程读写导致野指针,解决方案是对相关方法添加锁操作,同时说明了只在特定 iOS 版本出现及添加系统判断和降级开关。
3.
– 背景
– 业务中键盘语音输入结束取消时崩溃,只发生在 iOS16 及以上系统。
– 原因排查
– 崩溃类型为 EXC_BAD_ACCESS (SIGSEGV),大概确定是野指针导致。
– 还原堆栈,在相同或相近系统版本的 release 模式下运行调试。
– 汇编指令调试解析,确定是对象被提前释放导致野指针,查看堆栈倒数第二行分析崩溃原因。
– 解决方案
– 对相关方法添加锁操作,通过 hook 实现,添加系统判断和降级开关。
– 总结
– 介绍了排查键盘语音崩溃原因及治理的过程,欢迎交流讨论。
思维导图:
文章地址:https://juejin.cn/post/7396463744186515465
文章来源:juejin.cn
作者:货拉拉技术
发布时间:2024/7/29 2:13
语言:中文
总字数:5661字
预计阅读时间:23分钟
评分:85分
标签:iOS开发,崩溃分析,系统键盘,语音输入,野指针
以下为原文内容
本内容来源于用户推荐转载,旨在分享知识与观点,如有侵权请联系删除 联系邮箱 media@ilingban.com
一. 背景
我们业务一直存在着通过键盘语音进行语音转文字的一个系统崩溃, 具体崩溃时间点是在键盘语音输入结束之后,取消语音输入UIDictationConnection cancelSpeech
发生崩溃。
以下是具体的崩溃信息:
该崩溃在苹果论坛上有挺多人反馈过,但都没有给出具体的解决方案。
forums.developer.apple.com/forums/thre…
developer.apple.com/forums/thre…
从目前崩溃系统统计来看,只会发生在iOS16
及以上的系统。因此很明显这是一个苹果系统版本升级所引入的崩溃。
二. 原因排查
-
崩溃类型
首先从崩溃类型是EXC_BAD_ACCESS (SIGSEGV)
,我们可以大概确定某个对象野指针导致了这个崩溃。
EXC_BAD_ACCESS
: 顾名思义是坏地址异常,而EXC_BAD_ACCESS
异常类型下面一般分为两种具体的信号SIGSEGV
和SIGBUS
引起
SIGSEGV
:引起
SIGBUS
:
SIGBUS(Bus Error)
意味着指针对应的虚拟地址是有效地址,但总线不能正常使用该地址。
SIGSEGV(Segment Fault)
意味着指针对应的虚拟地址是无效地址。该虚拟地址本身就不存在,或者该虚拟地址超过用户态的访问界限。
EXC_BAD_ACCESS (SIGSEGV)
是我们异常崩溃里面最经常见到的,最主要的原因就在于野指针。野指针: 即当所指向的对象被释放或者收回,但是对该指针没有作任何的修改,以至于该指针仍旧指向已经回收的内存地址,此情况下该指针便称野指针了
-
还原堆栈
然后我们用相同或相近的系统版本在release
模式上运行调试运行。
为什么这里要用用相同或相近的系统版本:
因为相同或相近的系统版本可以保证,系统函数内部实现逻辑的一致,如果版本差别较大,可能会出现系统内部函数有改动,这样生成的汇编指令也会有变动。
为什么需要在release模式下运行
因为release模式和debug模式,编译选项一般不同,在
debug
模式下一般为了辅助调试,编译优化选项一般选择不进行优化,而release
模式为了保证App
运行的效率或者App
包大小,一般默认会开启对应的编译选项优化。
不同的编译优化模式,对生成的汇编指令不一样。因此要尽可能的保证调试时候
App
的编译所生成的代码跟线上包一致,这样才能保证生成的汇编指令代码一致。所以建议在release
模式下运行调试。考虑到
release
模式下,一般固定线上环境,不分场景不好复现。也可以在debug
模式下,但要保证项目和相关pod
工程里面的编译优化选项设置为一致。因此可以在podfile
里面加上对应的编译优化代码。config.build_settings['SWIFT_OPTIMIZATION_LEVEL'] = '-Osize'config.build_settings['GCC_OPTIMIZATION_LEVEL'] = 's'
-
汇编指令调试解析
从最后一行的_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
变量,就访问了非法的内存区域。
推荐文章: [深入iOS系统底层之crash解决方法]
也就是说从最后一行的_objc_msgSend + 32
,我们可以确定是这个对象被提前释放,导致的野指针。
接着我们来看下堆栈的倒数第二行: 查看堆栈-[UIDictationConnection cancelSpeech]_block_invoke + 152
。
由于-[UIDictationConnection cancelSpeech]_block_invoke + 152
并不在崩溃堆栈的最后一行,因为崩溃堆栈层级中的非顶层地址都是函数调用指令的下一条地址也就是LR
的值,所以真实的崩溃指令处是152
再减去4
也就是实际崩溃的地址是148
。
这里为什么查看的是偏移指令
148
因为一般程序崩溃的地址都有3个特征:
a. 崩溃堆栈层级中的非顶层地址都是函数调用指令的下一条地址也就是
LR
的值,所以真实的崩溃指令处是第1步算出的结果再减去4
。而这里-[UIDictationConnection cancelSpeech]_block_invoke + 152
非崩溃堆栈的顶层,所以崩溃的实际地址是偏移指令148
b. 如果崩溃信息出现在最顶层时,一般的崩溃指令都是带有内存访问的指令。假如例如这里的
_objc_msgSend + 32
, 这一条指令0x1abbd51e0 <+32>: ldr x11, [x16, #0x10]
,表示从Class
对象的cache
取出buckets
赋值给X11
寄存器。c . 如果崩溃信息出现在最顶层即无内存访问也无函数调用的指令时,这种崩溃一般是触发了
brk
断点指令,或者产生了其他一些无法可判断的原因了。前者比较好定位,后者就很难了。
注意崩溃堆栈倒数第二行这里:-[UIDictationConnection cancelSpeech]_block_invoke + 152
有一个_block_invoke
,这表明这个汇编代码定位的地方是在[UIDictationConnection cancelSpeech]
方法里面的block
回调里面。
我们可以看到144
指令调用了lastHypothesis
接着我们断点到148
指令0x19919d7f0
然后断点调试跟踪进入函数0x19919d7f0
:
调试过程中,我们打印对应的$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
的所有变量和方法,
@implementation FJFClassInfoPrint+ (void)printClassVarWithClassName:(NSString *)className { unsigned int numIvars Ivar *vars = class_copyIvarList(NSClassFromString(className), &numIvars) //Ivar *vars = class_copyIvarList([UIView class], &numIvars) NSString *key=nil for(int i = 0 Ivar thisIvar = vars[i] key = [NSString stringWithUTF8String:ivar_getName(thisIvar)] NSLog(@"%@ variable name :%@",className, key) key = [NSString stringWithUTF8String:ivar_getTypeEncoding(thisIvar)] NSLog(@"%@ variable type :%@",className, key) } free(vars)}+ (void)printClassMethodWithClassName:(NSString *)className { unsigned int numIvars = 0 Method *meth = class_copyMethodList(NSClassFromString(className), &numIvars) //Method *meth = class_copyMethodList([UIView class], &numIvars) for(int i = 0 Method thisIvar = meth[i] SEL sel = method_getName(thisIvar) const char *name = sel_getName(sel) NSLog(@"%@ method :%s",className, name) } free(meth)}@end
我们可以看到UIDictationController
对象确实存在NSString
类型的lastHypothesis
变量,以及相对应的set、get
方法。
UIDictationController variable name :_lastHypothesisUIDictationController variable type :@"NSString"UIDictationController method :setLastHypothesis:UIDictationController method :lastHypothesis
接下来,通过查看函数0x19919d7f0
指令代码:
0x19919d7f0: adrp x16, -55539 /// adrp 指令用于加载一个地址的高位值。这个地址是相对于当前指令位置的一个偏移量,-55539 表示目标地址距离当前指令的大致距离。x16 寄存器用于存储这个地址的高位值。
0x19919d7f4: add x16, x16, #0xe2c ;objc_claimAutoreleasedReturnValue /// add 指令将上一步得到的高位值与一个偏移量(#0xe2c)相加,以得到完整的目标地址。这个地址指向 objc_claimAutoreleasedReturnValue 函数,该函数用于处理自动释放池中的返回值。
0x19919d7f8: br x16 /// br 指令是一个无条件的分支指令,它根据 x16 寄存器中的地址跳转执行。这里它会跳转到 objc_claimAutoreleasedReturnValue 函数的地址。
0x19919d7fc: brk #0x1 /// brk 指令用于触发一个异常或系统调用。在这里,#0x1 指定了异常的类型。这种指令通常用于调试或错误处理。
0x19919d800: adrp x16, -55523 /// adrp 指令再次用于加载另一个地址的高位值,这个地址是相对于当前指令位置的另一个偏移量,-55523。
0x19919d804: add x16, x16, #0xe1c ; objc_copyStruct /// 同第二步,add 指令将高位值与偏移量(#0xe1c)相加,以得到 objc_copyStruct 函数的完整地址。这个函数用于复制结构体。
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
objc_copyStruct 函数用于创建一个结构体的副本。在Objective-C中,结构体通常用于打包多个值,这些值可以是基本数据类型或者指针。当需要复制一个结构体时,objc_copyStruct 函数会创建一个新的结构体实例,并将其内容设置为原始结构体的内容。
这个函数在处理自定义结构体或者在需要确保数据副本的情况下非常有用。例如,当你有一个包含指针的结构体,并且你希望在不改变原始数据的情况下使用这些指针时,你可以使用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
。
[NSClassFromString(@"UIDictationController") hd_hookMethod:NSSelectorFromString(@"setLastHypothesis:") option:HDHookOptionInstead handle:^(HDInvocation *invocation){ [[invocation.target hdcore_lock] lock] [invocation invoke] [[invocation.target hdcore_lock] unlock] } error:nil] [NSClassFromString(@"UIDictationController") hd_hookMethod:NSSelectorFromString(@"lastHypothesis") option:HDHookOptionInstead handle:^NSString * (HDInvocation *invocation){ [[invocation.target hdcore_lock] lock] __autoreleasing NSString *orgHypothesis [invocation invokeWithReturnValue:&orgHypothesis] orgHypothesis = orgHypothesis.mutableCopy [[invocation.target hdcore_lock] unlock] return orgHypothesis } error:nil]
-
这里的
hd_hookMethod
是我们内部封装的hook
的组件,类似于aspect
框架。 -
hdcore_lock
是封装的给NSObject
对象关联的锁,这里的invocation.target
就代表UIDictationController
对象 -
由于该问题只出现在
iOS16-iOS17
之间的版本,因此添加系统判断和相应的降级开关。
四. 总结
以上主要介绍了,如何通过崩溃类型、崩溃堆栈、偏移指令、然后结合调试汇编指令、确定键盘语音崩溃根本原因,最后进行有效的治理。
若本文有错误之处或者技术上关于其他类型Crash
的讨论交流的,欢迎评论区留言。
五. 推荐
深入iOS系统底层之crash解决方法
深入解构objc_msgSend函数的实现
iOS疑难Crash的寄存器赋值追踪排查技术