第3章:SIGQUIT信号详解——系统如何发送SIGQUIT、Java堆栈与Native堆栈的采集过程
你有没有想过,当ANR发生的那一刻,系统到底是怎么拿到那个“卡死”现场的所有调用栈的? 我当年刚接触ANR时,以为就是简单打印个日志。后来追踪源码才发现,背后藏着一整套信号处理机制。说白了,核心就是SIGQUIT信号。
这一章,我们就彻底搞明白两件事:系统怎么送出SIGQUIT,以及信号来了之后,Java和Native的堆栈分别怎么“抓”出来的。
核心知识点速览
- SIGQUIT = 信号3,Linux默认行为是终止进程+生成core dump,但在Android上被俘获用于ANR堆栈采集
- 发送者:system_server中的ANR检测线程、输入事件的watchdog、或开发者手动
kill -3 - 接收者:App进程内的 Signal Catcher 线程(优先级最大)
- 采集策略:Java栈用
ThreadList+StackVisitor,Native栈用libunwind/libcorkscrew - 输出文件:
/data/anr/traces.txt,每个进程存一个独立文件
3.1 SIGQUIT信号:ANR背后的“暗号”
在Android系统里,SIGQUIT的发送方式主要有三种:
| 发送者 | 触发场景 | 实际调用 |
|---|---|---|
| system_server中的AMS | 输入超时、广播超时、服务超时 | Process.sendSignal(pid, SIGNAL_QUIT) |
| Watchdog | 关键系统服务线程卡住超过60秒 | kill(pid, SIGQUIT) |
| 开发者手动 | 通过adb shell | adb shell kill -3 <pid> |
我个人最常用的是第三种方式——线上定位问题时,直接 kill -3 拿到此刻的所有线程堆栈,跟ANR的痕跡完全一致。
一个小技巧: 在调试阶段,如果你不想等ANR对话框弹出,可以直接在日志里观察 signal 3 的打印。看到这个,说明系统正准备抓栈。
3.2 信号的处理流程:从内核到用户态
信号发出去之后,目标进程里的 Signal Catcher 线程 会第一时间收到。这个线程是Zygote启动时就创建的,优先级为 -20(最高),就是为了确保ANR时能尽快干活。
Signal Catcher 的处理函数大致是这样:
// 伪代码示意,基于AOSP核心逻辑
void HandleSigQuit() {
// 1. 暂停所有线程(除自身)
SuspendAll();
// 2. 采集Java线程堆栈
DumpJavaStack();
// 3. 采集Native线程堆栈
DumpNativeStack();
// 4. 恢复所有线程
ResumeAll();
// 5. 写入文件到 /data/anr/
WriteTraceFile();
}
为什么要先暂停所有线程? 你想想看,如果一边采集栈,目标线程还在跑,栈帧可能被覆盖或改变,拿到的数据就不准了。暂停之后,每个线程的寄存器值和栈内存都是“快照”,这样抓出来的堆栈才可信。
注意: 暂停操作本身有风险。如果某个线程持有锁不放,SuspendAll 可能卡住。我曾经遇到过因为线程被内核态的系统调用阻塞,导致Signal Catcher等了一整个IO超时。不过这种情况很少见,大多数场景下暂停是毫秒级的。
3.3 Java堆栈的采集:遍历ThreadList,反射+栈帧快照
Java堆栈的采集逻辑在ART虚拟机内部。大致思路是:
- 遍历
Runtime::GetThreadList(),拿到所有Java线程的ManagedThread* - 对每个线程,调用
StackVisitor::WalkStack()从当前栈帧开始往上回溯 - 每一帧记录:方法名、类名、行号、PC地址
- 如果是
LockOwner状态,额外记录等待的锁信息
代码层面的核心调用链大概是这样:
// ART层核心函数
void DumpJavaStacks() {
ThreadList* list = Runtime::Current()->GetThreadList();
list->ForEachThread([](Thread* t) {
// 获取线程名、状态
std::string thread_name = t->GetThreadName();
ThreadState state = t->GetState();
// 遍历栈帧
StackVisitor visitor(t);
while (visitor.GetFrame()) {
ArtMethod* m = visitor.GetMethod();
std::string descriptor, name, signature;
m->GetDexMethodName(&descriptor, &name, &signature);
uint32_t dex_pc = visitor.GetDexPc();
// 格式化为:at package.Class.method(SourceFile:line)
AppendToTrace(FormatFrame(...));
visitor.NextFrame();
}
});
}
这里有个坑,我印象很深: 某些被优化过的JIT编译方法,在Dex层没有对应的PC值。这时 dex_pc 会是 -1,只能输出机器码地址。后来我们团队在解析traces.txt时做了特殊处理,否则一些热方法的堆栈会显示 <unknown>。
3.4 Native堆栈的采集:unwind + symbol lookup
Native堆栈的采集比Java麻烦一些,因为ART没法直接知道C/C++层的函数调用关系。它依赖 libunwind 或者旧版的 libcorkscrew。
采集流程是:
- 获取上下文: 从线程的
ucontext_t中拿到寄存器值,特别是RSP(栈指针)和PC(程序计数器) - 逐帧回溯: 调用
unwind_step(),通过分析栈上的返回地址逐步向上推 - 符号化: 将PC地址解析成
so文件名+偏移量,如果有map文件或debug信息,还能解析出函数名
关键代码简化版:
void DumpNativeStack(pid_t tid) {
unwind_cursor_t cursor;
unwind_context_t uc;
// 获取当前线程的寄存器上下文
unw_getcontext(&uc);
unw_init_local(&cursor, &uc);
while (unw_step(&cursor) > 0) {
unw_word_t pc, offset;
char func_name[256];
unw_get_reg(&cursor, UNW_REG_IP, &pc);
unw_get_proc_name(&cursor, func_name, sizeof(func_name), &offset);
// 输出 to native traces
AppendToTrace(FormatNativeFrame(func_name, offset, pc));
}
}
我个人在实际项目中发现: 某些厂商修改了libunwind库,导致非root设备上无法读取其他进程的寄存器和栈内存。这时候 DumpNativeStack 可能只输出一个 <unknown>,甚至直接返回空。嗯,遇到这种问题,别慌,优先检查 /proc/<pid>/maps 里so的加载基址对不对。
3.5 采集后的文件:traces.txt 里的秘密
所有堆栈采集完成后,Signal Catcher 线程将结果写入 /data/anr/traces_<进程名>_<时间戳>.txt。典型的内容格式是:
----- pid 12345 at 2025-04-09 10:30:15 -----
Cmd line: com.example.app
DALVIK THREADS:
"main" prio=5 tid=1 Native
| group="main" sCount=0 dsCount=0 flags=1 obj=0x...
| sysTid=12345 nice=0 cgrp=default
| schedstat=( ... )
java.lang.Thread.sleep(Native Method)
at com.example.MainActivity.onCreate(MainActivity.java:25)
...
"Thread-2" prio=5 tid=14 Waiting
at java.lang.Object.wait(Native Method)
- waiting on <0x09d50d90> (a java.lang.Object)
...
----- end 12345 -----
注意看: 每个线程会标明 Waiting、Native、Runnable 等状态,并且Java栈在前,Native栈在 --- native --- 标记之后。不过新版本Android已经把Native栈和Java栈混排列了,读起来更直观。
我的建议: 在分析ANR时,不要只看最顶层的栈帧,一定要关注有没有线程处于 Blocked 或 Waiting on condition 状态,往往是死锁或锁竞争的信号。
3.6 一张流程图串起来
下面这个SVG把整个SIGQUIT信号发送到堆栈采集完毕的链路画得比较清楚,你可以照着这个逻辑去读源码。
总结几个关键点:
- SIGQUIT信号是ANR链路追踪的“发令枪”,不理解这个信号你就无法理解traces.txt从哪里来
- Java栈采集依赖ART的虚拟机栈遍历,Native栈采集依赖unwind库,两者完全不同
- 暂停所有线程是为了让栈快照一致,但可能会引入短暂卡顿——好在ANR场景下本来就已经卡了
避坑指南: 我曾经在解决一个线上ANR时,发现traces.txt里只有主线程的堆栈,其他线程都显示“in native”。后来排查发现是libunwind的unwind能力被SELinux限制,目标线程的栈内存读不到。解决办法是给对应的so加上allow=all的SELinux策略,或者降级使用旧的 libcorkscrew 方案。
嗯,这一章的信息量挺大的。但只要你跟着这个流程走一遍,从信号发送到堆栈写完,以后看到ANR日志就不会再一脸懵了。