值传递?址传递,慎用形参,崩溃修复记录
查询崩溃问题流程
-
拿到崩溃日志
-
查看崩溃线程、崩溃原因
-
查看崩溃函数堆栈
-
确定崩溃调用参数
-
根据控制台日志来具体分析问题
例子1:
- 拿到崩溃日志:
- 查看崩溃线程、崩溃原因
如图,崩溃线程是线程5,崩溃类型是EXC_BREAKPOINT(SIGTRAP),下表是常见的崩溃异常,可以看到EXC_BREAKPOINT(SIGTRAP)是一种调试器相关的,跟踪/断点捕获,多见于异常抛出。
UNIX 信号 | 注释 |
---|---|
SIGSEGV | 访问无效的内存地址。地址存在,但是应用程序无法访问。 |
SIGABRT | 程序崩溃。由 C函数 abort() 初始化。通常意味着系统检测到某些事务出错,例如 assert() 或者 NSAssert() 校验失败。 |
SIGBUS | 访问无效的内存地址。地址不存在,或对齐无效。(The address does not exist, or the alignment is invalid.) |
SIGTRAP | 调试器相关 |
SIGILL | 尝试执行非法的、有缺陷、未知的或者需要权限的指令。 |
Mach 异常 | 描述 | 注释 |
---|---|---|
EXC_BAD_ACCESS | 错误内存访问 | 访问“错误”内存地址。“错误”可能指“地址不存在”或者“应用没有权限访问”。因此通常与 SIGBUS 及 SIGSEGV 相关联。 |
EXC_CRASH | 异常跳出 | 通常与 SIGABRT 相关联,意思是由于检测到代码抛出的未捕获异常而使应用程序异常退出。 |
EXC_BREAKPOINT | 跟踪/断点捕获 | 通用与 SIGTRAP 相关联。可以由你自己的代码或者 NSExceptions 抛出时触发。 |
EXC_GUARD | 违反了受保护资源的防护(Violated Guarded Resource Protection) | 由违背受保护资源防护触发,例如‘某些文件描述符’。 |
EXC_BAD_INSTRUCTION | 非法指令 | 通常与特定非法或未定义指令/操作数相关。 |
EXC_RESOURCE | 资源限制 | 应用由于达到资源消耗限制而退出。 |
00000020 | 十六进制异常类型 | 非 'OS Kernel' 异常。 |
- 查看函数堆栈
如图所示,我们最后崩溃在libobjc.A.dylib的objc_opt_respondsToSelector+48的地方,实际上,这是objc是否响应selector的地方,我们可以查看objc的源码,以下选自objc4-838
// Calls [obj respondsToSelector]
BOOL
objc_opt_respondsToSelector(id obj, SEL sel)
{
#if __OBJC2__
if (slowpath(!obj)) return NO;
Class cls = obj->getIsa();
if (fastpath(!cls->hasCustomCore())) {
return class_respondsToSelector_inst(obj, sel, cls);
}
#endif
return ((BOOL(*)(id, SEL, SEL))objc_msgSend)(obj, @selector(respondsToSelector:), sel);
}
为了弄清楚究竟崩在哪一行,我们需要把它转成汇编
image.png注意,我们最后走到的是+48,这并不代表我们是执行完+48所对应的代码才崩溃的,恰恰是执行上一句代码崩溃,而上一句代码转成汇编后的返回地址是+48,而上一句对应的是
if (slowpath(!obj)) return NO;
也就是说此时objc不存在,结合前面的DDLog打印函数,我们基本可以确定我们打印的对象已经被释放了,但是指针还没有清空,即指针所指向的内存已释放,而指针本身的地址不为null,所以它指向了一块不可访问的内存。我们回到第5行,ResetVTSession,来确定打印的是个啥
image.png-
确定崩溃参数,还记得我们前面说的吗,崩溃的偏移号不是代表我们执行完这一句才崩溃,而是上一句,之所以显示+112偏移地址是因为上一句执行完毕的返回地址是这个,可以很清楚的看到汇编其实已经给我们注释出来了,是
image-20220801151644486.png"ResetVtSession = %@"
调用出现的问题,我们转成正常代码
现在我们确定了引发崩溃的参数 vtSession.
- 现在我们来具体分析一下这个函数的究竟有什么问题,其实我们都不需要具体分析自己的日志就能看出来。
问题出在这里,vtSession = NULL
,这是一句没什么作用的代码,反而很有迷惑性,为什么呢?我们来分析一下这个方法想干什么,先强制编完剩下的帧VTCompressionSessionCompleteFrames
,相当于快速处理完还没处理的内容,然后VTCompressionSessionInvalidate(vtSession)
和CFRelease(vtSession)
,这两步是销毁session,并释放内存,最后再把vtSession置空,看起来perfect,但是不要忘了我们的参数vtSession是值传递!换句话说我们在函数内部的vtSession只是外部调用的值拷贝,就算我们把它置为空,也不影响外部的指针不为空,下次如果有其他线程重新调进来,就会引发崩溃。所以解决方案有两种,一是改为址传递,改为
void MHH264VideoSource::ResetVtSession(VTCompressionSessionRef& vtSession)
二是仍然是值传递,不过外面手动把调用指针置空
// before:
ResetVtSession(this->m_portrait_vtSession);
ResetVtSession(this->m_landscape_vtSession);
// after:
ResetVtSession(this->m_portrait_vtSession);
this->m_portrait_vtSession = nullptr;
ResetVtSession(this->m_landscape_vtSession);
this->m_landscape_vtSession = nullptr;
总结
我们先通过崩溃日志确定崩溃类型和崩溃原因,然后根据崩溃堆栈来具体锁定诱发崩溃的原因,然后再回到SDK层去查看具体引发崩溃的代码和变量,最后我们再根据自己的代码来具体排查为什么会这样。
后话:虽然后来复盘的时候第5步我并没有写根据日志来排查,那是因为最后看了一圈日志最后又查回来到这个函数,发现是这里的问题,我一开始其实没看出来这里的问题,复盘的时候想写简单点,毕竟业务上的设计各家各有不同,但是最基本的程序bug却是类似的。
后记
后面再分享一下我排查的具体操作吧,总体绕了一圈弯路
-
首先查看调用ResetVtSession的地方是ResetVtSession(this->m_landscape_vtSession)时崩溃,表明横屏编码器不存在,但是竖屏编码器能正常释放
-
接着查看调用释放的地方是verifyProcess(),这是一个嗅探机制,旨在查看当前的码流是否正常发送中,如果不正常,就重新创建编码器或刷新关键帧,根据日志判断,当时检测到竖屏码流不能正常发送中,于是重新创建了一个竖屏的编码器,但是横屏的没有创建,所以这一步可以得到一个信息:横屏的编码器压根儿没有(但是不能确定是已经释放了还是根本没创建)
-
接着看上面的日志,发现走到了创建流程,但是到加锁创建的那一步,直接被return掉了,这里可以确定,vtSession并不为空,说明上一次释放并没有把指针置空
- 接着就回到了上面,发现是释放函数的问题。那么为什么之前一直没出过问题呢?因为之前是启动扩展进程,每次都是新创建一个进程,所以everything is new,上一个扩展进程反正已经没了,没置空也不影响,所以一直没啥问题,但是这一次咱们是用主进程采集流并发送的,导致整一个videoSource压根儿已经创建过就不用再创建了,所以这里vt_Session指针没置空就很危险了,不光会导致下一次创建的时候创建不了,而且一旦走到析构就直接咖喱给给了。