第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 shelladb 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虚拟机内部。大致思路是:

  1. 遍历 Runtime::GetThreadList(),拿到所有Java线程的 ManagedThread*
  2. 对每个线程,调用 StackVisitor::WalkStack() 从当前栈帧开始往上回溯
  3. 每一帧记录:方法名、类名、行号、PC地址
  4. 如果是 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触发(输入/广播/服务超时) ② system_server调用Process.sendSignal(pid, SIGQUIT) ③ Signal Catcher线程捕获并处理信号 ④ SuspendAll暂停所有其他线程 ⑤ 采集Java堆栈 ThreadList + StackVisitor ⑤ 采集Native堆栈 libunwind / libcorkscrew ⑥ ResumeAll恢复线程执行 ⑦ 写入 /data/anr/traces_xxx.txt 还可以手动触发: adb shell kill -3 <pid>

总结几个关键点:

  • SIGQUIT信号是ANR链路追踪的“发令枪”,不理解这个信号你就无法理解traces.txt从哪里来
  • Java栈采集依赖ART的虚拟机栈遍历,Native栈采集依赖unwind库,两者完全不同
  • 暂停所有线程是为了让栈快照一致,但可能会引入短暂卡顿——好在ANR场景下本来就已经卡了

避坑指南: 我曾经在解决一个线上ANR时,发现traces.txt里只有主线程的堆栈,其他线程都显示“in native”。后来排查发现是libunwind的unwind能力被SELinux限制,目标线程的栈内存读不到。解决办法是给对应的so加上allow=all的SELinux策略,或者降级使用旧的 libcorkscrew 方案。

嗯,这一章的信息量挺大的。但只要你跟着这个流程走一遍,从信号发送到堆栈写完,以后看到ANR日志就不会再一脸懵了。