本文为稀土技术社区首发签约文章,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产生,关于反常处理的笔者在这篇有讲过,这次就不重复弥补内容了,下面是一个最简单的流程图

Android性能优化 - SIGSEGV的段错误保护实现(基于sigsetjmp)

看到这!说不定就会感叹一声“这个反常经过了这么多过程,咱们怎样处理嘛!native怎样净产生了这些让咱们摸不着头脑的东西了”,这儿引证xhook 作者caikelun对段过错的观念

先明确一个观念:不要只从使用层程序开发的角度来看待段过错,段过错不是祸不单行,它仅仅内核与用户进程的一种正常的沟通方式。当用户进程拜访了无权限或未 mmap 的虚拟内存地址时,内核向用户进程发送 SIGSEGV 信号,来通知用户进程,仅此而已。只需段过错的产生位置是可控的,咱们就能够在用户进程中处理它。

是的,只需咱们能够知道可能产生段过错的代码,咱们就能够进行“捕获处理”,就像java层代码一样,你必先知道某一段代码可能产生“反常”,才会加上try catch不是嘛!下面咱们持续弥补一些前置知识,然后完成咱们native版别的反常捕获/段过错保护

sigsetjmp与siglongjmp

在native c中,供给了这样一个十分强大的手法去“回溯”内存

Android性能优化 - SIGSEGV的段错误保护实现(基于sigsetjmp)

假如正常代码逻辑履行状况是 代码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:

  1. env:sigjmp_buf类型的数据,会保存现在堆栈环境,为后续的回溯供给了环境支撑
  2. save_signal_mask:指示是否将当时进程的信号掩码存储在env中,取值为1/0

这儿咱们需求解析一下save_signal_mask的界说原因:在咱们发送signal传递的时分,在信号处理函数调用之前,内核会主动堵塞当时被处理的信号以避免接下来产生的该类信号中断信号处理函数(比方信号处理正在处理,比方SIGSEGV信号1被处理,此刻来了一个SIGSEGV信号2,假如信号处理中断,就会产生逻辑上的死循环,因而内核会掩码的方式堵塞当时的信号2),这使得运用longjmp从信号处理函数返回时,出现是否康复信号掩码的问题。因而供给出来了siglongjmp方法,假如运用者期望康复,则设置为1即可,反之不康复。这儿咱们会在下面实战中讲到。

siglongjmp:

  1. env:sigjmp_buf类型的数据,跟sigsetjmp的env对应,假如咱们需求从当时栈帧回溯到sigsetjmp记录的栈帧,则env为同一个目标
  2. 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;
}

那么履行就会变成这样

Android性能优化 - SIGSEGV的段错误保护实现(基于sigsetjmp)

此刻会怎样样?此刻就会一向重复这个流程,形成了死循环,然后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库开发者时刻留意!好啦!本篇到这儿就进入结束啦,感谢观看!