本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
引言
在我们日常开发中,crash相关都是我们不可忽视的重要指标之一,无论是Android开发还是IOS开发,涉及到native层的crash都一直是一个非常头疼的问题,通常native层crash由so库产生,如果是自己编写的so库那就还好,可以想办法去解决,但是如果是第三方提供的so,除非crash点比较明确,同时刚好crash是基于plt寻找的话,我们可以采用(PLT hook)去解决。但是如果是内部逻辑错误,我们一般很难去进行改动。本次我们以so库编写者角度出发,探索native库编写者如何去兜底住可控的crash。
不同于java/kotlin层,我们可以通过try catch代码指令去捕获虚拟机运行过程中可能出现的异常,从而能让我们程序能够正常运行,但是java层也有我们捕获不了的异常,比如oom(如果oom刚好是在try catch中产生,那么其实还是能捕获,这里指通常的我们无法全局捕获的异常),究其原因,是因为当前虚拟机的“环境”已经是不足以支撑当前程序继续运行。在native层其实也非常类似!虽然natvie层我们没有try catch指令支撑我们去捕获native异常,但是也有一个异常捕获的做法,我们本次来实现一个native层的“异常捕获”。再者,java的异常以Throwable的方式抛出,同样的,native层发生预期之外的情况时,是以signal的方式产生(信号的类别在signal.h头文件包含),由内核调度将signal发送到指定的异常线程中,由异常程序处理,默认的处理方式就是kill myself,android中会在异常处理程序中dump处墓碑文件,同时也会在debuger中打印相关信息。
聪明的小伙伴可能留意到,笔者特地强调了“可控”crash的概念,比如java中的空指针异常,我们是可以捕获这次异常,从而让程序继续运行,因为它不影响下一次的代码运行。相反的,oom是因为可用内存不足,环境层面上其实是不可控了,因为就算我们能够捕获,也会在下一次内存分配时出现异常。那么native层的“可控”signal有吗?有!就是我们常见的以SIGSEGV表现的异常。与java层相反,大部分signal都是不可控的,比如SIGBUS (通常发生在指针所对应的地址是有效地址,但总线不能正常使用该指针。一般由是未对齐的数据访问所致),这些都是当前内存环境处于异常情况下抛出的signal,代表当前native层环境已经是不可控了。下面我们从SIGSEGV出发,如何实现一个signal版本的“异常捕获”
native 异常捕获
SIGSEGV
SIGSEGV是当一个进程执行了一个无效的内存引用,或发生段错误时发送给它的信号。比如我们常见的野指针,因为引用了一块不属于自己的内存/已释放的内存,从而导致SIGSEGV产生,关于异常处理的笔者在这篇有讲过,这次就不重复补充内容了,下面是一个最简单的流程图
看到这!说不定就会感叹一声“这个异常经过了这么多步骤,我们怎么处理嘛!native怎么净产生了这些让我们摸不着头脑的东西了",这里引用xhook 作者caikelun对段错误的看法
先明确一个观点:不要只从应用层程序开发的角度来看待段错误,段错误不是洪水猛兽,它只是内核与用户进程的一种正常的交流方式。当用户进程访问了无权限或未 mmap 的虚拟内存地址时,内核向用户进程发送 SIGSEGV 信号,来通知用户进程,仅此而已。只要段错误的发生位置是可控的,我们就可以在用户进程中处理它。
是的,只要我们能够知道可能发生段错误的代码,我们就能够进行“捕获处理”,就像java层代码一样,你必先知道某一段代码可能产生“异常”,才会加上try catch不是嘛!下面我们继续补充一些前置知识,从而实现我们native版本的异常捕获/段错误保护
sigsetjmp与siglongjmp
在native c中,提供了这样一个非常强大的手段去“回溯”内存
如果正常代码逻辑执行情况是 代码1(sigsetjmp 保存当前栈执行环境)- 代码2 - 代码3,此时由于代码3触发了siglongjmp,即产生回溯,此时代码内存栈就会重置到代码1的环境,从而有机会直接打破原有的执行逻辑,从而直接执行到代码段4的内容。
我们来看下两个函数的定义,setjmp.h当中
int sigsetjmp(sigjmp_buf __env, int __save_signal_mask);
__noreturn void siglongjmp(sigjmp_buf __env, int __value);
复制代码
sigsetjmp:
- env:sigjmp_buf类型的数据,会保存目前堆栈环境,为后续的回溯提供了环境支持
- save_signal_mask:指示是否将当前进程的信号掩码存储在env中,取值为1/0
这里我们需要解析一下save_signal_mask的定义原因:在我们发送signal传递的时候,在信号处理函数调用之前,内核会自动阻塞当前被处理的信号以防止接下来发生的该类信号中断信号处理函数(比如信号处理正在处理,比如SIGSEGV信号1被处理,此时来了一个SIGSEGV信号2,如果信号处理中断,就会产生逻辑上的死循环,因此内核会掩码的方式阻塞当前的信号2),这使得使用longjmp从信号处理函数返回时,出现是否恢复信号掩码的问题。因此提供出来了siglongjmp方法,如果使用者希望恢复,则设置为1即可,反之不恢复。这里我们会在下面实战中讲到。
siglongjmp:
- env:sigjmp_buf类型的数据,跟sigsetjmp的env对应,如果我们需要从当前栈帧回溯到sigsetjmp记录的栈帧,则env为同一个对象
- value:对应sigjmp_buf的sigsetjmp的返回值,sigsetjmp按照此value定义不同的处理逻辑
sigaction
这个是我们的信号处理器定义函数,因为之前也有详细讲过,这里粗略介绍一下,就是定义某个信号对应的信号处理器,比如
sigaction(SIGSEGV, &sigc, nullptr);
复制代码
我就定义了一个SIGSEGV的信号处理,sigc就是处理函数,第三个参数是旧的信号处理器,这里我们简单设置为nullptr即可,即不需要保存旧的信号处理函数信息。更多函数定义请查看黑科技!让Native Crash 与ANR无处发泄!
实战
首先我们需要制造一个SIGSEGV的异常场景,createCrash函数会在native层发出SIGSEGV,这里采用raise函数调用即可。
void create_crash(){
raise(SIGSEGV);
}
复制代码
此时我们在java层模拟一个场景,就是点击一个TextView的时候发起一次jni调用,该调用就用到了createCrash函数,实现如下:
text.setOnClickListener {
throwNativeCrash()
}
复制代码
// 测试crash
extern "C"
JNIEXPORT void JNICALL
Java_com_example_signal_MainActivity_throwNativeCrash(JNIEnv *env, jobject thiz) {
create_crash();
}
复制代码
果不其然,我们的程序会在点击的时候就自动crash了,接着我们进行我们的native异常捕获。
首先我们需要定义一个sigjmp_buf的全局对象,用于保存当前栈的内容
static sigjmp_buf sigsegv_env;
复制代码
此时就可声明我们需要回溯栈的位置了,我们在throwNativeCrash中补充如下代码
if(sigsetjmp(sigsegv_env, 1))
{
__android_log_print(ANDROID_LOG_INFO, "hello", "%s", "crash 了,但被我抓住了");
}else{
create_crash();
__android_log_print(ANDROID_LOG_INFO, "hello", "%s", "SIGSEGV");
}
复制代码
可以看到,我们在sigsetjmp(sigsegv_env, 1)为false/0 的时候,才执行 createCrash(),因为如果sigsetjmp不是由siglongjmp返回的话,返回值就是0,因此正常流程就是走到createCrash()。那么问题来了,siglongjmp要在哪里调用呢?我们需要的是在createCrash异常的时候进行回溯操作,因为我们需要监听异常的产生,我们注册一个异常监听即可,注册的时机我们可以在JNI_OnLoad中
extern "C" jint JNI_OnLoad(JavaVM *vm, void *reserved) {
struct sigaction sigc;
sigc.sa_handler = sigsegv_handler;
sigemptyset(&sigc.sa_mask);
sigc.sa_flags = SA_SIGINFO;
sigaction(SIGSEGV, &sigc, nullptr);
return JNI_VERSION_1_4;
}
复制代码
此时发生异常为SIGSEGV,就会执行我们的sigsegv_handler函数,在这里我们执行回溯即可
static void sigsegv_handler(int sig) {
siglongjmp(sigsegv_env, 1);
}
复制代码
但是这里还有个问题,就是我们siglongjmp必须要在sigsetjmp设定完成后才能调用,否则会产生未知的内存错误,因此我们必须确保sigsetjmp已经完成设定,因此我们多补充一个flag,类型为sig_atomic_t,保证原子写操作
static sig_atomic_t flag =0;
复制代码
此时sigsegv_handler变成了
static void sigsegv_handler(int sig) {
if(flag){
siglongjmp(sigsegv_env, 1);
}
}
复制代码
flag在调用throwNativeCrash设置为1即可。
就通过这么一个步骤,我们就实现了一个native层的异常捕获!输出log为:
SIGSEGV
crash 了,但被我抓住了
复制代码
本例子的全部源码如下:
static sigjmp_buf sigsegv_env;
static sig_atomic_t flag =0;
void create_crash(){
raise(SIGSEGV);
}
// 测试crash
extern "C"
JNIEXPORT void JNICALL
Java_com_example_signal_MainActivity_throwNativeCrash(JNIEnv *env, jobject thiz) {
flag = 1;
if(sigsetjmp(sigsegv_env, 1))
{
__android_log_print(ANDROID_LOG_INFO, "hello", "%s", "crash 了,但被我抓住了");
}else{
create_crash();
__android_log_print(ANDROID_LOG_INFO, "hello", "%s", "SIGSEGV");
}
}
static void sigsegv_handler(int sig) {
if(flag){
siglongjmp(sigsegv_env, 1);
}
}
extern "C" jint JNI_OnLoad(JavaVM *vm, void *reserved) {
struct sigaction sigc;
sigc.sa_handler = sigsegv_handler;
sigemptyset(&sigc.sa_mask);
sigc.sa_flags = SA_SIGINFO;
sigaction(SIGSEGV, &sigc, nullptr);
return JNI_VERSION_1_4;
}
复制代码
再来一个小实验
到这里,我们就能够明白了sigsetjmp与siglongjmp的使用!接着我们可以思考几个问题,假设create_crash()在别的函数调用,我们能不能采用这个方式去捕获呢?比如我们直接在JNI_OnLoad就把回溯设置好,然后等下一次crash发生后,那么会发生什么呢?
extern "C" jint JNI_OnLoad(JavaVM *vm, void *reserved) {
struct sigaction sigc;
sigc.sa_handler = sigsegv_handler;
sigemptyset(&sigc.sa_mask);
sigc.sa_flags = SA_SIGINFO;
sigaction(SIGSEGV, &sigc, nullptr);
if(sigsetjmp(sigsegv_env, 1))
{
__android_log_print(ANDROID_LOG_INFO, "hello", "%s", "crash 了,但被我抓住了");
}else{
__android_log_print(ANDROID_LOG_INFO, "hello", "%s", "SIGSEGV");
}
return JNI_VERSION_1_4;
}
复制代码
那么执行就会变成这样
此时会怎么样?此时就会一直重复这个流程,形成了死循环,从而crash函数被不断执行下去。那么就会有朋友问了,crash函数被不断执行是什么意思,我们的应用会怎么表现?答案是跟执行一个死循环函数一样,直到anr!那么我们再考虑一下,为什么应用不是因为执行crash函数而导致进程被杀掉呢? 原因就是这个调用,sigsetjmp(sigsegv_env, 1),我们上文留了一个小问题,就是这个1就是代表着恢复原本的信号掩码,即siglongjmp是在信号处理程序执行的,因为信号处理程序由内核设置了针对当前信号的掩码,因此sigsetjmp =1的时候也默认继承了这个掩码,从而间接导致了sigsetjmp执行后的函数不会被当前掩码对应的信号所影响(本例是SIGSEGV),所以才会一直死循环,如果不想以死循环的方式,调用sigsetjmp(sigsegv_env, 0)即可,那么sigsetjmp执行后,会因为下一次的crash函数调用从而自杀掉进程(SIGSEGV默认处理),所以我们这个方法也要小心不要随便乱用,要时刻关注当前栈帧的状态,因为回溯仅是回溯,只是提供了一个更改运行顺序的机会,如果回溯后还能可达旧的crash函数,那么就会造成不可预期的错误。(希望能够手动敲一下这段代码理解一下)
总结
到这里,读者们也可以自己把上述方案封装成一个native版本的trycatch,这里就不再重复了,native层容易引起更多不可预期的crash,所以需要我们so库开发者时刻注意!好啦!本篇到这里就进入结束啦,感谢观看!