手把手教你如何 Dump Native 线程栈和监听崩溃信号
我在前面的文章 Android 如何解读 Native 崩溃栈信息 中介绍了如何阅读 Native
的崩溃栈信息,今天我们来学习一下如何 Dump 一个 Native
线程中的栈和如何监听 Native
崩溃信号,源码在这里(如果觉得对你有帮助,希望能够得到你的 Star),结合源码来阅读本篇文章理解更容易。
Dump Native 线程栈
在 Linux
中方法的栈帧在运行时时保存在 .eh_frame
的 Section
中,我们需要通过某种方法把当前方法栈帧的调用序列拿到。在程序加载到内存中时 .text
的 Section
中存放了我们的代码指令,我们的进程会根据 ELF
文件的头信息,拿到入口函数在 .text
中的地址,然后从这个地址开始执行,通常这个入口函数就是 main()
函数,然后入口函数在执行过程中还会调用其他的函数,我们假如它在 Addr1
的地址执行了 fun1()
函数的调用,同理,fun1()
中又在 Addr2
中执行了 fun2()
函数的调用,假如我们在 fun2()
中的 Addr3
地址中去 Dump
当前栈信息,那我们从 .eh_frame
拿到的栈信息就是,Addr3
-> Addr2
-> Addr1
,是的,我们拿到的信息就是一个地址信息,我们需要借助 .strtab
来解析这个地址是属于哪个方法,如果不熟悉 .strtab
符号表的同学可以看看我前面的文章。所以我们上面的地址解析后看到的大概就是这样 Addr3 (fun2 + 偏移量)
-> Addr2 (fun1 + 偏移量)
-> Addr1 (main + 偏移量)
,这个偏移量是当前的地址距离方法开始时的地址的偏移。
在 Android
中默认集成了 unwind
库,他就是用来 Dump
线程栈用到的库,我们也大致了解了 Dump
线程栈的原理,我们就来看看源码要如何实现。
首先需要通过 _Unwind_Reason_Code _Unwind_Backtrace(_Unwind_Trace_Fn, void *);
方法来获取栈帧中地址,也就是我上面举例的 Addr1
,Addr2
和 Addr3
。这个方法需要传两个参数,第一个就是处理栈帧信息的函数指针,第二个参数是我们第一个参数中的函数的自定义参数。_Unwind_Trace_Fn
函数指针的定义是:typedef _Unwind_Reason_Code (*_Unwind_Trace_Fn)(struct _Unwind_Context *, void *);
,这个函数指针的第一个参数 _Unwind_Context
中存放了方法栈帧中的很多重要的信息,也包括我们需要的指令的地址,第二个参数就是我们上面说的自定义参数。
C
typedef struct {
void **pcStart;
void **pcEnd;
} DumpStackPcState;
static _Unwind_Reason_Code singleStackPcUnwind(_Unwind_Context *ctx, void *pcState) {
auto* state = static_cast<DumpStackPcState *> (pcState);
uintptr_t pc = _Unwind_GetIP(ctx);
int pcOffset = 0;
#if defined(__arm__)
pcOffset = -4;
#elif defined(__aarch64__)
pcOffset = -4;
#endif
if (pc) {
if (state->pcStart == state->pcEnd) {
return _URC_END_OF_STACK;
} else {
*state->pcStart++ = reinterpret_cast<void*>(pc + pcOffset);
}
}
return _URC_NO_REASON;
}
static void dumpStackPc(DumpStackPcState* state) {
_Unwind_Backtrace(singleStackPcUnwind, state);
}
我使用自定义参数 DumpStackPcState
来存放不同栈帧中的 pc
程序计数器的地址,存放的地址空间是 pcStart
到 pcEnd
,当超过 pcEnd
时就表示达到了设定的最大栈的数量。 以下是它的初始化:
C
void *pcBuffer[result->maxStackSize];
DumpStackPcState s = { pcBuffer, pcBuffer + result->maxStackSize };
我们再来看看我们定义的栈帧处理回调函数 singleStackPcUnwind()
,我们通过 _Unwind_GetIP()
方法去拿 _Unwind_Context
中的 pc
地址 (当然这个 ctx
还有这个栈帧的其他有用的信息,我们的需求只是要 pc
地址),我这里根据不同的 CPU
架构做了一个偏移值,然后把这个值写入到我们的自定义参数中,当超过我们的最大栈数量时返回 _URC_END_OF_STACK
表示停止栈的回溯,如果返回 _URC_NO_REASON
表示继续回溯。
到这里我们就把栈帧的相关的 pc
信息存放在了我们自定义的数据中了,接下来就是要解析 pc
中的信息。
以下就是解析 pc
信息的相关代码:
C
// ...
void* inst_addr_in_mem = pcBuffer[i];
int offset = result->maxSingleStackSize * indexInStack;
char *target_output = result->stacks + offset;
if (dladdr(inst_addr_in_mem, &dl_info)) {
const char *so_file_path = dl_info.dli_fname;
const char *method_name = dl_info.dli_sname;
long text_addr_in_mem = (long)dl_info.dli_fbase;
long method_addr_in_mem = (long)dl_info.dli_saddr;
long inst_addr_in_text = (long)inst_addr_in_mem - text_addr_in_mem;
long inst_offset = (long)inst_addr_in_mem - method_addr_in_mem;
if (!method_name) {
sprintf(target_output, "#%02d pc %016lx %s", indexInStack, inst_addr_in_text, so_file_path);
} else {
sprintf(target_output, "#%02d pc %016lx %s (%s+%ld)", indexInStack, inst_addr_in_text, so_file_path, method_name, inst_offset);
}
} else {
sprintf(target_output, "#%02d", indexInStack);
}
// ...
我们需要通过 dladdr()
方法来解析上面通过 _Unwind_Backtrace
方法拿到的 pc
信息,解析的结果是存放在 Dl_info
中的,这里面的信息需要单独描述一下。
dli_fname
表示我们的程序文件的路径。dli_sname
表示我们的pc
所在的方法所对应的名字,因为方法的符号名字是在符号表中查询的,如果没有符号表,这个值就是空的。dli_fbase
表示.text
加载到内存中的开始的地址。dli_saddr
表示当前调用的方法所对应的地址,和dli_sname
一样,如果没有符号表,这个值也是空的。
我们理解了上面的参数表示的意思后,我们就能计算我们需要的信息了。补充一点 pc
表示当前执行的指令在内存中的地址。
指令在 .text Section 中的相对地址
= pc
- dli_fbase
。
指令在对应方法的偏移量
= pc
- dli_saddr
。
最后我们的 Dump
信息经过格式化处理后写入到 target_output
中。
以下是我测试的数据:
shell
// ...
#04 pc 000000000001768c /data/app/~~OT28_jT5HM9S3y-FvnD3BQ==/com.tans.stacktrace-Xjr_DLGp5lFseeHN9GRGUA==/base.apk!/lib/arm64-v8a/libstacktrace.so (_Z13add5DumpCrashi+16)
#05 pc 00000000000176a4 /data/app/~~OT28_jT5HM9S3y-FvnD3BQ==/com.tans.stacktrace-Xjr_DLGp5lFseeHN9GRGUA==/base.apk!/lib/arm64-v8a/libstacktrace.so (_Z14add10DumpCrashi+20)
#06 pc 00000000000176d4 /data/app/~~OT28_jT5HM9S3y-FvnD3BQ==/com.tans.stacktrace-Xjr_DLGp5lFseeHN9GRGUA==/base.apk!/lib/arm64-v8a/libstacktrace.so (Java_com_tans_stacktrace_MainActivity_testCrash+24)
#07 pc 000000000021a354 /apex/com.android.art/lib64/libart.so
#08 pc 000000000020a2b0 /apex/com.android.art/lib64/libart.so
#09 pc 0000000000209334 /apex/com.android.art/lib64/libart.so
// ...
看上去还是和 Android
默认的崩溃栈看上去差不多,哈哈😄。
监听 Native 崩溃信号
在 Android
中的崩溃信号主要有以下几种:
C
static SigActionInfo sigActionInfos[] = {
{.sig = SIGABRT},
{.sig = SIGBUS},
{.sig = SIGFPE},
{.sig = SIGILL},
{.sig = SIGSEGV},
{.sig = SIGTRAP},
{.sig = SIGSYS},
{.sig = SIGSTKFLT}
};
不同的信号表示不同的意思,大家可以去找找别的文章,我这里就不介绍了。
默认我们拦截信号的处理函数也是工作在原来的函数栈上的,在类似于栈溢出的崩溃时,我们的拦截方法的可能会触发二次的崩溃,所以我们要通过以下的方法来让我们的信号处理函数工作在一个新的栈上。
C
// ...
stack_t newStack;
newStack.ss_flags = 0;
int crashStackSize = 1024 * 128;
newStack.ss_size = crashStackSize;
newStack.ss_sp = malloc(crashStackSize);
sigaltstack(&newStack, nullptr);
// ...
我们新建的栈的大小为 128K。
然后我们要新创建一个新的信号处理:
C
// ...
sigaction_s sigAction{};
sigfillset(&sigAction.sa_mask);
sigAction.sa_flags = SA_RESTART | SA_SIGINFO | SA_ONSTACK;
sigAction.sa_sigaction = sigHandler;
int sigActionInfoSize = sizeof(sigActionInfos) / sizeof(SigActionInfo);
int ret = 1;
for (int i = 0; i < sigActionInfoSize; i ++) {
SigActionInfo info = sigActionInfos[i];
if (sigaction(info.sig, &sigAction, info.oldAct) == 0) {
ret = 0;
}
}
// ...
首先构建一个 sigaction
结构体,通过 sigfillset()
方法清空它的 sa_mask
,sa_sigaction
就是我们的信号处理函数指针,这里介绍一下 sa_flags
参数:
SA_RESTART
在处理信号时,系统处理会暂停,在添加该参数后,处理信号完成后就会继续执行,否者不会继续执行。SA_SIGINFO
在信号处理函数中会添加信号相关的信息。SA_ONSTACK
异常信号的处理函数工作在新的栈上,也就是我上面的设置。
然后后续的代码就是通过 sigaction()
方法来替换原来的信号处理,旧的信号处理存放在 info.oldAct
中,当我们自己的信号处理函数处理完成后,我们再把信号再传递给原来的处理函数(后面会看到这部分代码)。
我这里先直接展示完整的信号处理函数,我再慢慢解释:
C
static void sigHandler(int sig, siginfo_t *sig_info, void *uc) {
// auto * uctx = static_cast<ucontext *>(uc);
auto tid = gettid();
LOGE("Receive native crash: sig=%d, tid=%d", sig, tid);
auto oldAct = findOldAct(sig);
if (hasReceiveSig) {
LOGE("Skip handle signal.");
if (oldAct != nullptr) {
oldAct->sa_sigaction(sig, sig_info, uc);
}
return;
}
hasReceiveSig = true;
pid_t pid = fork();
if (pid == 0) {
// New process
LOGD("Dump process start");
DumpStackResult stackResult;
stackResult.stacks = static_cast<char *>(malloc(
stackResult.maxSingleStackSize * stackResult.maxStackSize));
dumpStack(&stackResult, 1);
printStackResult(&stackResult);
int cacheFd = open(cacheFile, O_WRONLY);
if (cacheFd > 0) {
int writeCount = 0;
writeCount += write(cacheFd, &stackResult.size, sizeof(stackResult.size));
writeCount += write(cacheFd, &stackResult.maxSingleStackSize, sizeof(stackResult.maxSingleStackSize));
writeCount += write(cacheFd, &stackResult.maxStackSize, sizeof(stackResult.maxStackSize));
writeCount += write(cacheFd, stackResult.stacks, stackResult.maxSingleStackSize * stackResult.maxStackSize);
close(cacheFd);
LOGD("Write to cache, writeSize=%d", writeCount);
} else {
LOGE("Open cache file: %s, fail: %d", cacheFile, cacheFd);
}
LOGD("Crash handle thread work finished.");
free(stackResult.stacks);
_Exit(0);
}
if (pid > 0) {
// Current process
LOGD("Waiting dump process: %d", pid);
int status;
waitpid(pid, &status, __WALL);
LOGD("Waiting crash handle thread.");
timeval tv{};
gettimeofday(&tv, nullptr);
long timeInMillis = tv.tv_sec * 1000L + tv.tv_usec / 1000L;
crashTid = tid;
crashSig = sig;
crashSigSub = sig_info->si_code;
crashTime = timeInMillis;
long data = 233;
write(crashNotifyFd, &data, sizeof(long));
if (crashNotifyFd > 0) {
close(crashNotifyFd);
crashNotifyFd = -1;
}
pthread_join(crashHandleThread, nullptr);
LOGD("Dump process finished: %d", status);
if (oldAct != nullptr) {
oldAct->sa_sigaction(sig, sig_info, uc);
}
}
}
我的这个函数中用到了 fork()
来创建了一个子进程,所以我这里有必要对 fork
做一个简单的介绍,fork
是在 Linux
中创建进程的一种常见的方式,fork()
方法也是非常神奇,它会调用一次返回两次,你可能听着有点懵,父进程中 fork()
函数会返回子进程的 pid
,而子进程中的 fork()
返回值为 0,在 fork()
函数调用后子进程也就创建成功了,子进程中他会复用父进程中原来的数据和状态,这部分数据也是父进程和它的每个子进程共享的,当子进程要尝试修改公用的数据时那么就会把这部分数据在写入到自己的进程中内存中,而父进程中的数据不会受影响,子进程中下次去那这个数据也不会从父进程中去拿了,而是拿自己的那部分数据,这这部分数据所占用的内存就被称为 private dirty
(子进程中新申请的内存也是属于 private dirty
),直接翻译过来就是私有脏数据。在 Android
中,我们的进程都是由 Zygoty
fork
而来,Zygoty
中有非常多的共享数据都是由所有的进程共享的,在需要创建新的应用进程时,AMS
会通过 Socket
发送消息给 Zygoty
进程,然后 Zegoty
进程会通过 fork
创建子进程,然后在子进程中会执行 ActivityThread
的 main()
函数,这就是我们应用进程主线程开始的地方。
我们再回到我们上面的代码,为什么我们这里要用 fork
呢?按照网络上的描述,在处理信号的过程中其中的方法栈信息可能被修改,通过 fork
后来用子线程来处理就可以避免这种问题。说实话我也不确定这个描述的准确性,我自己不 fork
貌似得到的数据也是对的。像 xCrash
,Bugly
等等他们也都是 fork
一个子进程来 dump
栈信息,那我们也按照通用的方法来吧。。。。。
我们来看看子进程如何 dump
栈信息:
scss
// ...
if (pid == 0) {
// New process
LOGD("Dump process start");
DumpStackResult stackResult;
stackResult.stacks = static_cast<char *>(malloc(
stackResult.maxSingleStackSize * stackResult.maxStackSize));
dumpStack(&stackResult, 1);
printStackResult(&stackResult);
int cacheFd = open(cacheFile, O_WRONLY);
if (cacheFd > 0) {
int writeCount = 0;
writeCount += write(cacheFd, &stackResult.size, sizeof(stackResult.size));
writeCount += write(cacheFd, &stackResult.maxSingleStackSize, sizeof(stackResult.maxSingleStackSize));
writeCount += write(cacheFd, &stackResult.maxStackSize, sizeof(stackResult.maxStackSize));
writeCount += write(cacheFd, stackResult.stacks, stackResult.maxSingleStackSize * stackResult.maxStackSize);
close(cacheFd);
LOGD("Write to cache, writeSize=%d", writeCount);
} else {
LOGE("Open cache file: %s, fail: %d", cacheFile, cacheFd);
}
LOGD("Crash handle thread work finished.");
free(stackResult.stacks);
_Exit(0);
}
// ...
其实这部分代码就朴实无华了,就用我们上一节描述的方法来 dump
栈信息,然后把这个数据再写入到缓存文件中。
我们再来看看主进程中:
ini
// ...
if (pid > 0) {
// Current process
LOGD("Waiting dump process: %d", pid);
int status;
waitpid(pid, &status, __WALL);
LOGD("Waiting crash handle thread.");
timeval tv{};
gettimeofday(&tv, nullptr);
long timeInMillis = tv.tv_sec * 1000L + tv.tv_usec / 1000L;
crashTid = tid;
crashSig = sig;
crashSigSub = sig_info->si_code;
crashTime = timeInMillis;
long data = 233;
write(crashNotifyFd, &data, sizeof(long));
if (crashNotifyFd > 0) {
close(crashNotifyFd);
crashNotifyFd = -1;
}
pthread_join(crashHandleThread, nullptr);
LOGD("Dump process finished: %d", status);
if (oldAct != nullptr) {
oldAct->sa_sigaction(sig, sig_info, uc);
}
}
// ...
父进程中等待子进程中完成 dump
,然后通知 CrashHandleThread
去处理子进程 dump
好的数据,然后等待 CrashHandleThread
工作完成,然后调用原来旧信号处理函数的方法。这里是通过向 fd
中写入了一个 233
来通知 CrashHandleThread
,在初始化时就会启动 CrashHandleThread
,它在 read
上面的 fd
,当有数据来时就会读取到一个 233
,然后他就处理 dump
数据。
我们再来看看 CrashHandleThread
:
C
static void* crashHandleRoutine(void* args) {
auto *args_t = static_cast<CrashHandleThreadArgs *>(args);
auto *jniEnv = args_t->env;
LOGD("Crash handle thread started.");
long data;
if (crashNotifyFd > 0) {
JavaVMAttachArgs jvmAttachArgs {
.version = JNI_VERSION_1_6,
.name = "CrashHandleThread",
.group = nullptr
};
if (args_t->jvm->AttachCurrentThread(&jniEnv, &jvmAttachArgs) != JNI_OK) {
LOGE("Attach jvm thread fail.");
return nullptr;
}
while (true) {
read(crashNotifyFd, &data, sizeof(data));
LOGD("Crash handle read data: %ld", data);
if (data == 233L) {
LOGD("Crash handle thread receive crash, waiting crash handle thread: %ld", crashHandleThread);
close(crashNotifyFd);
crashNotifyFd = -1;
struct stat cacheStat {};
stat(cacheFile, &cacheStat);
long long cacheSize = cacheStat.st_size;
LOGD("Cache file size: %lld", cacheSize);
int cacheFd = 0;
if (cacheSize > 0) {
cacheFd = open(cacheFile, O_RDONLY);
}
if (cacheFd > 0) {
int crashStackSize, maxSingleStackSize, maxStackSize;
read(cacheFd, &crashStackSize, sizeof(int));
read(cacheFd, &maxSingleStackSize, sizeof(int));
read(cacheFd, &maxStackSize, sizeof(int));
LOGD("CrashStackSize=%d, MaxSingleStackSize=%d, MaxStackSize=%d", crashStackSize, maxSingleStackSize, maxStackSize);
if (crashStackSize > 0 && maxSingleStackSize > 0 && maxStackSize > 0) {
char *stacks = static_cast<char *>(malloc(crashStackSize * maxSingleStackSize));
read(cacheFd, stacks, crashStackSize * maxSingleStackSize);
LOGD("Read stacks: %s", stacks);
CrashData crashData {
.tid = crashTid,
.sig = crashSig,
.sigName = getSigName(crashSig),
.sigSub = crashSigSub,
.sigSubName = getSigSubName(crashSig, crashSigSub),
.time = crashTime,
.stackResult = DumpStackResult {
.size = crashStackSize,
.maxSingleStackSize = maxSingleStackSize,
.maxStackSize = maxStackSize,
.stacks = stacks
}
};
args_t->crashHandle(jniEnv, args_t->obj, &crashData);
free(stacks);
} else {
LOGE("Wrong size.");
}
close(cacheFd);
} else {
LOGE("Read cache file fail: %d", cacheFd);
}
break;
}
}
args_t->jvm->DetachCurrentThread();
} else {
LOGE("Crash handle fail: crash notify fd is invalid.");
}
free(args);
return nullptr;
}
这里要注意因为后续涉及到调用 Java 的方法所以在调用前必须通过 AttachCurrentThread
方法添加到 JVM
中去,其中有用到的对象要通过 NewGlobalRef
方法添加一个强引用。否则会报错。
这里就是通过读取子进程中写入的信息,然后把读取到的值通过 crashHandle
函数指针回调给 Java
层。
这里还有一点特别坑,Java
中的 tid
和 Linux
中的 tid
是不一样的,Java
中的 tid
就是一个 int
值在 ++,我们需要通过读 /proc/[tid]/comm
中去读线程的名字,然后根据线程的名字再去 Java
的线程中去找对应的线程。
下面是我的一个测试崩溃数据:
text
Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid 10485 (TestCrashThread), pid 10030 (com.tans.stacktrace)
#04 pc 000000000001768c /data/app/~~OT28_jT5HM9S3y-FvnD3BQ==/com.tans.stacktrace-Xjr_DLGp5lFseeHN9GRGUA==/base.apk!/lib/arm64-v8a/libstacktrace.so (_Z13add5DumpCrashi+16)
#05 pc 00000000000176a4 /data/app/~~OT28_jT5HM9S3y-FvnD3BQ==/com.tans.stacktrace-Xjr_DLGp5lFseeHN9GRGUA==/base.apk!/lib/arm64-v8a/libstacktrace.so (_Z14add10DumpCrashi+20)
#06 pc 00000000000176d4 /data/app/~~OT28_jT5HM9S3y-FvnD3BQ==/com.tans.stacktrace-Xjr_DLGp5lFseeHN9GRGUA==/base.apk!/lib/arm64-v8a/libstacktrace.so (Java_com_tans_stacktrace_MainActivity_testCrash+24)
#07 pc 000000000021a354 /apex/com.android.art/lib64/libart.so
#08 pc 000000000020a2b0 /apex/com.android.art/lib64/libart.so
#09 pc 0000000000209334 /apex/com.android.art/lib64/libart.so
#10 pc 0000000000209334 /apex/com.android.art/lib64/libart.so
#11 pc 000000000020b074 /apex/com.android.art/lib64/libart.so
#12 pc 000000000021096c /apex/com.android.art/lib64/libart.so
#13 pc 000000000027af68 /apex/com.android.art/lib64/libart.so (_ZN3art9ArtMethod6InvokeEPNS_6ThreadEPjjPNS_6JValueEPKc+184)
#14 pc 0000000000624cac /apex/com.android.art/lib64/libart.so (_ZN3art35InvokeVirtualOrInterfaceWithJValuesIPNS_9ArtMethodEEENS_6JValueERKNS_33ScopedObjectAccessAlreadyRunnableEP8_jobjectT_PK6jvalue+460)
#15 pc 000000000066de60 /apex/com.android.art/lib64/libart.so (_ZN3art6Thread14CreateCallbackEPv+1296)
#16 pc 00000000000eb720 /apex/com.android.runtime/lib64/bionic/libc.so
at com.tans.stacktrace.MainActivity.testCrash(Native Method)
at com.tans.stacktrace.MainActivity.onCreate$lambda$3$lambda$2(MainActivity.kt:49)
at com.tans.stacktrace.MainActivity.$r8$lambda$xuM_bd7L1pB3XG2xgdny9HZIqC0(Unknown Source:0)
at com.tans.stacktrace.MainActivity$$ExternalSyntheticLambda0.run(Unknown Source:2)
at java.lang.Thread.run(Thread.java:1012)
看上去也还行哈,而且我还把 Java
栈也打印了。
最后
我写的这些代码只是供学习使用,如果你想要用于线上环境需谨慎,很多机型都没有测试过,建议使用其他的稳定的开源的库,例如 xCrash
,在你读了我这个简单版的捕获 Native
崩溃的代码后,再去阅读别的捕获 Native
异常库的代码会更容易理解,大家的原理都是差不多的。