前言
最近为了复现一个1%概率左右的跨平台层的崩溃,在MSDK的TestApp中集成了一个CrashHandler
来进行压力测试。借着解决问题的机会深入学习一下Unix
的信号机制。并在本文讲述了如何实现一个CrashHandlerDemo
。
什么是信号
信号的定义
在上一次讲Socket编程提到SIGPIPE
时简单了解了一下signal
函数,我们知道可以向进程注册一个指定的信号处理函数,以使进程在收到指定函数时触发该函数。那么信号到底是什么呢,UNIX环境高级编程中定义:信号是软件中断,提供了一种处理异步事件的方法。为什么说是软件的中断,因为信号触发的软件层进程执行的中断。即进程在执行时,收到信号时中断程序的运行执行中断处理函数(如果没有忽略该信号),然后再返回程序继续运行。为什么说是处理异步事件,因为信号是随机出现,进程是无法确定什么时候接收到信号,只能告诉内核在出现该信号时该如何处理,进程不会阻塞以等待信号,而是正常执行,直到信号发送过来。
所以可以理解信号就是一种进程通信手段用于通知进程发生了某个事件。举一个例子,在iOS13上不加蓝牙权限描述时打开App请求蓝牙权限就会崩溃,查看crash log
可以看到:
1 | xception Type: EXC_CRASH (SIGKILL) |
可以看到崩溃的类型SIGKILL
其实就当前进程在请求蓝牙权限时由于没有描述文件,内核发送一个SIGKILL
信号给当前进程。
信号集
Unix
系统中利用sigset_t
(信号集)来表示多个信号,在darwin
系统中由于信号只有31种,因此采用一个uint32_t
来表示sigset_t
,说白了就是利用其不同的bit
位来判断是否包含这个值的信号,系统也提供了一下几个函数进行信号集的处理:
1 | //sigemptyset用于清空信号集,这个宏命令就可以看到实现的原理了哈,很值得学习 |
这里值得学习的一点是C语言的逗号运算符,(*(set) = 0, 0)
第一个0是把指针指向uint32_t
置为0,第二个0表示的是返回值。这种语法糖还是蛮有趣的。
还有以下几个函数来进行信号集的增删改查:
1 | //sigismember用于判断set中是否包含某个信号 |
其实都是一些位域运算的封装,在系统源码中包括Runtime的源码中随处可见位运算的使用,作为源码必定需要足够高的性能,采用位域运算可以大大减少内存占用和处理效率。
信号传递控制
可以利用sigprocmask
控制进程信号屏蔽集:
1 | //改变或检测信号屏蔽字,how描述如何处理。old_set用于检测当前进程的信号屏蔽字 |
被sigprocmsk
阻塞的信号,无论发生多少次都会保存下来,可以通过old_set
获取。但是设置为SIG_UNBLOCK
时就会释放这些阻塞的信号。由于sigset_t
只能通过每个bit保存一种信号的状态,所以无论接收到信号多少次,再次方法只会被传递一次。
sigprocmask
一般只用于单线程的进程,因为它直接修改的进程的公共信号屏蔽集,所以对于多线程中使用需要在使用前进行声明和初始化,以保证数据的安全。
当系统阻塞了一些信号时,这些信号在触发之后就变成了未决的信号, 如何获取当前进程中未决的信号集呢,可以通过sigpending
,我们用以下例子来实践一下信号的控制与未决信号的捕获:
1 | sigset_t new_set, old_set, pending_set; |
运行之后结果如下:
1 | new set is 00000038, old set is:00000000 |
sigpending
成功捕获了未决的信号,如果我们把代码中注释的sigprocmask
释放掉(SIG_UNBLOCK
)会怎么样呢:
1 | new set is 00000038, old set is:00000000 |
程序运行到这里直接崩溃了,因为阻塞的信号被释放了,进程收到了SIGINT
信号,并且进程被干掉了所以后面就没有执行了。通过查看奔溃的frame
可以看到,崩在了sigpromask
之后,那么看一下它到底做了什么:
要看懂这一段代码我们需要找到systemcall.h
,路径为:
1 | /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/usr/include/sys |
systemcall.h
列举了所有系统调用的参数表,可以看到所有的系统调用接口全部用了一个数字来表示,根据苹果的注释可以知道该文件是由xnu
源码中的systemcalls.master
生成的。因此我们下载一下xnu内核源码。利用cat
工具打开systemcalls.master
文件可以看到,这里只截取部分需要的信息如下:
1 | cat /Users/joey.cao/Desktop/Learning/LLVM/darwin-xnu-master/bsd/kern/syscalls.master |
所以我们分析下上图sigpromask
的堆栈,首先把0x2000030
赋值给eax
寄存器。由于BSD
层在Mach
层之上,mach
层占用了前0x2000000
。所以BSD
层的系统调用需要从逻辑地址0x2000000
开始。所以这里实际表明调用的系统调用参数为48(注意这里是16进制),即正好就是SYS_sigpromask
,它的函数原型如下:
1 |
|
此时把参数rcx
寄存器的值传入r10
寄存器,作为调用函数的syscall
的入参。然后可以看到是一个jae
判断函数,判断的是0x7fff51b5a2a0
下面jmp
的返回值,我们查看0x7fff51b5a2a0
这段函数地址,到底做了什么:
1 | (lldb) image lookup --address 0x7fff51b54457 |
正如注解所示,这里jmp
的是cerror_nocancel
。在xnu
源码的errno.c
中可以找到:
1 | //可以看到errno保存在一个全局变量里,所以永远只会保存最新的一次systemcall的结果 |
可以看到,这里cerror_nocancel
把传入信号值并通过转换获取到对应的错误码并赋给了errno
。这也就是为什么当系统出现错误时可以实时的通过errno
获取,然后由于错误进程终止。
信号处理
在早期的POSIX
系统中提供了不可靠的信号机制,而为了向后兼容需要这些旧的信号语义的程序,提供了signal
函数,但是正如之前分析过的signal
的实现来看,有一个非常大的问题就是signal
函数只能传递一个函数指针,那么如果有多个地方调用了signal
函数只有最后一次传入的函数指针才会被保存:
1 | void(*signal(int, void (*)(int)))(int); |
不过在4.4BSD
之后,以及MACOS
和FreeBSD
上其实现遵循了sigaction
的函数定义,在苹果的main page上也可以找到相应的描述,这个是可靠的,因此可以认为在我们开发使用时signal
函数对信号捕获是可靠的。sigaction
方法提供了设置和检测信号的能力,并且能获取对信号已经设置的action
,因此可以保全其它用户对信号捕获的处理:
1 | //signo表示需要检测或者修改的信号 |
sigaction
的定义也是很复杂的,它不仅内部有一个__sigaction_u
来保持信号处理函数指针,还利用sa_mask
来设置屏蔽字,sa_flags
来设置需要捕获的信号:
1 | struct sigaction { |
可以看到__sigaction_u
是一个union
,其实就是为了兼容旧的signal
的处理函数:
1 | /* union for signal handlers */ |
在支持sigaction
的系统中,sigaction
兼容signal
函数,具体的实现可以参考UNIX环境搞基编程
这本书,一句话说就是把上面union
里的__sa_handler
指向对应的signalHandler
。由于这里仅仅只是一个union
两种处理函数是不能共存的,所以切记不要冲突了。
实现一个CrashHandler
异常的捕获
异常也是一种常见的处理机制,例如OC中的NSException
,不仅在系统代码会产生异常,开发者也可以在出错时主动产生异常来获取到错误信息。异常触发实质上是调用NSAssertionHandler
:
1 | - (void)handleFailureInMethod:(SEL)selector object:(id)object file:(NSString *)fileName lineNumber:(NSInteger)line description:(nullable NSString *)format,... NS_FORMAT_FUNCTION(5,6); |
对于异常的捕获很简单,系统已经封装的很完美了,只需要调用NSSetUncaughtExceptionHandler
传入一个按下面模板的函数指针即可:
1 | typedef void NSUncaughtExceptionHandler(NSException *exception); |
特别注意的是,有可能在你注册handler
之前已经有人注册了,所以需要先调用NSGetUncaughtExceptionHandler
来判断是否已经有注册了,如果有则保存下来,等exception
触发时再通知到先前注册的handler
。如果每一个新注册的人都能这样进行异常的传递,那么所有的exception handler
才能都被触发。
1 | if (NSGetUncaughtExceptionHandler()) { |
信号的捕获
信号控制已经了解的足够充分了,但是在进行信号捕获时仍然需要注意一些细节。例如保存上一次的sa_sigaction
,虽然sa_action
已经做到了向前兼容sa_handler
,但是想做的够保险,还是需要根据sa_flag
来判断,以及自己在注册sa_action
时注意更新sa_flag
。
还有一点需要注意的是应该为每个signal
保存不同的sa_sigaction
或sa_handler
,因为你也不知道之前调用者对不同的signal
采用了不同的处理,或者多个用户各自监听了不同的信号。所以最保险的方法就是存储下每个signal
对应的处理函数:
1 | for (NSNumber *signalValue in needCatchedSignals) { |
虽然已经做了很多考虑,但是仍然不能确保其它用户是否规范的使用signal
,只能自求多福吧。。。
Bugly的实现
Bugly是腾讯出的一款用于统计用户崩溃等功能的第三方SDK,可以捕获到非常全的崩溃信息,尤其是崩溃日志非常详细,此次我们主要为了追踪一个已知但是偶现的bug所以还不需要那么齐全的堆栈。不过查看下Bugly
的实现总是好的,首先查看下符号,大致就能推测出一些功能的实现逻辑,对于Signal
进行了捕获处理:
1 | //可以知道有一个BLYCrashSignalHandler的OC类 |
对于exception
也进行了捕获处理:
1 | //封装了一个exception类 |
为了进一步确认它的实现,我们使用了一个非常好用的逆向工具Hopper DIsassembler。只关注一些实现例如上一次的action的通知的逻辑:
先分析一下这一段代码,把_g_BLYPreviousSignalHandlers
保存到rax
寄存器,通过其它的代码可以看到Bugly的是在UIVIewController
分类+ (void)load
的时候就开始注册捕获信号了,这是一个非常早的时机。此时把信号对应的处理函数保存到了_g_BLYPreviousSignalHandlers
这个静态数组中,然后r12
这里是以信号值rcx
按4字节对齐来偏移寻找到信号对应的处理函数。然后调用该函数,并把三个参数传入。可以看到思路和我们基本是一样的。
参考资料
UNIX环境高级编程(https://item.jd.com/12720738.html)
https://en.wikipedia.org/wiki/Signal_(IPC)
进入内核态究竟是什么意思? - 灵剑的回答 - 知乎 https://www.zhihu.com/question/306127044/answer/555327651
https://www.cnblogs.com/bakari/p/5520860.html