3. Crash 数据采集:信号捕获、异常处理钩子、堆栈回溯、线程状态收集

Crash 数据采集是整个监控平台的数据入口,它决定了后续聚合、归因、可视化的完整性和准确度。本章深入剖析信号捕获、异常处理钩子注册、堆栈回溯以及线程状态收集四个核心模块,覆盖从内核信号触发到原始日志落盘的完整链路,并提供可直接嵌入生产环境的实现方案。

核心目标:在进程崩溃瞬间,以最轻量的方式保留现场,同时保障嵌入式 Crash 处理器自身的稳定性和异步信号安全。
Crash 数据采集管道 Crash 数据采集管道 信号 / 异常 SIGSEGV / SIGABRT / 异常 信号捕获器 sigaction / std::set_terminate 线程挂起 暂停所有线程 堆栈回溯 libunwind / backtrace 线程状态收集 TID / 函数名 / 寄存器 / 线程名称

3.1 信号捕获机制

操作系统在检测到非法内存访问、除零错误、断言失败等致命错误时,会向进程发送信号(如 SIGSEGV、SIGABRT、SIGFPE)。Crash 采集的第一步是注册自定义信号处理器,接管这些信号。

信号触发条件默认行为采集必要性
SIGSEGV非法内存访问(空指针、越界)终止 + core dump必需
SIGABRT调用 abort()、断言失败终止 + core dump必需
SIGFPE整数除零、浮点异常终止 + core dump建议
SIGBUS未对齐内存访问、硬件错误终止 + core dump建议
SIGILL非法指令、损坏的代码段终止 + core dump建议

3.1.1 信号处理器注册

使用 sigaction() 而非 signal(),因为 sigaction 提供更好的跨平台可移植性和对信号标志位的精细控制。

#include <signal.h>
#include <string.h>

void crash_signal_handler(int signo, siginfo_t* info, void* ctx) {
    // 1. 恢复默认信号处理,避免递归
    signal(signo, SIG_DFL);
    // 2. 收集 Crash 信息(堆栈、线程状态等)
    // 3. 输出日志 / 发送指标
    // 4. 重新触发原信号让 OS 终止进程
    raise(signo);
}

void register_signal_handlers() {
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_sigaction = crash_signal_handler;
    sa.sa_flags = SA_SIGINFO | SA_NODEFER;
    sigemptyset(&sa.sa_mask);

    sigaction(SIGSEGV, &sa, nullptr);
    sigaction(SIGABRT, &sa, nullptr);
    sigaction(SIGFPE,  &sa, nullptr);
    sigaction(SIGBUS,  &sa, nullptr);
    sigaction(SIGILL,  &sa, nullptr);
}
异步信号安全:信号处理器中只允许调用异步信号安全函数(如 write()、sigaction()、_exit())。绝不能调用 malloc()、free()、printf() 等非安全函数,否则可能引发死锁或二次崩溃。

3.2 异常处理钩子

除 POSIX 信号外,不同语言运行时提供了异常处理钩子,用于捕获未被 try-catch 处理的异常或 std::terminate 触发点。

钩子类型适用场景注册方式
POSIX 信号处理器C/C++ 原生信号sigaction()
std::terminate_handler未被捕获的 C++ 异常、throw 后无 catchstd::set_terminate()
C++ 异常捕获未捕获异常转发std::set_unexpected()(C++17 前)
Windows SEH结构化异常处理(Windows 平台)SetUnhandledExceptionFilter()
Mach 异常处理macOS / iOS 平台task_set_exception_handlers()
#include <exception>
#include <cstdlib>

void my_terminate_handler() {
    // 1. 获取当前异常指针
    std::exception_ptr exptr = std::current_exception();
    // 2. 尽量提取 what() 字符串
    try {
        if (exptr) std::rethrow_exception(exptr);
    } catch (const std::exception& e) {
        // 收集异常描述
    } catch (...) {
        // 未知异常
    }
    // 3. 输出日志,再次触发 abort 以进入信号处理器
    std::abort();
}

// 在 main 启动时注册
std::set_terminate(my_terminate_handler);

3.3 堆栈回溯

堆栈回溯(Stack Unwinding)是 Crash 分析的核心数据来源。通过遍历调用帧,获取函数地址、偏移量、符号名称,形成可读的调用链。

3.3.1 帧指针 vs DWARF/.eh_frame

方法依赖性能精度适用平台
基于帧指针 (FP)编译时 -fno-omit-frame-pointer极快高(可完全展开)Linux / Android
DWARF / .eh_frame二进制包含异常表较慢(表查找)高(支持优化代码)Linux / macOS
libunwind第三方库适中高跨平台
backtrace()glibc 内置快一般(仅获得地址列表)Linux / Android

3.3.2 使用 libunwind 实现回溯

#include <libunwind.h>
#include <cstdio>

void collect_stack_backtrace() {
    unw_context_t context;
    unw_cursor_t cursor;
    char symbol[256] = {0};

    // 初始化上下文
    unw_getcontext(&context);
    unw_init_local(&cursor, &context);

    int frame_num = 0;
    while (unw_step(&cursor) > 0) {
        unw_word_t ip, offset;
        unw_get_reg(&cursor, UNW_REG_IP, &ip);
        // 符号化
        if (unw_get_proc_name(&cursor, symbol, sizeof(symbol), &offset) == 0) {
            // 输出: frame #N: func+offset [ip]
        } else {
            snprintf(symbol, sizeof(symbol), "--");
        }
        ++frame_num;
        if (frame_num >= 64) break;  // 限制最大帧数
    }
}
部署建议:在生产环境中使用 libunwind 时,建议编译为静态库并开启 UNW_LOCAL_ONLY 宏以裁剪不需要的远程 unwind 支持,减少二进制体积和运行时开销。

3.3.3 符号解析策略

  • 在线符号化:使用 dladdr() 将地址转换为符号名称,延迟较低,但只能拿到动态符号表中的符号。
  • 离线符号化:采集原始地址列表,上传后使用 addr2line 或专用符号服务器解析,适合生产环境脱敏。
  • 混合方案:在 Crash 处理器中同时输出地址和 dladdr 解析结果,在后台用 DWARF 信息补充完整行号。

3.4 线程状态收集

Crash 发生在一个线程中,但分析全貌需要了解其他线程当时正在执行什么代码。线程状态收集包括:线程 ID、线程名称、寄存器上下文、函数调用栈。

3.4.1 主线程 / 关键线程优先

信号处理器中时间窗口极短。建议按照优先级收集:

// 伪代码:线程收集优先级
1. Crash 线程 → 立即收集完整堆栈和寄存器
2. 主线程 → 如果主线程未崩溃,快速采集堆栈(前 16 帧)
3. 后台工作线程 → 仅记录线程名称和当前 PC 值
4. 线程池空闲线程 → 忽略或只计数

3.4.2 线程列表枚举

#include <dirent.h>
#include <cstring>
#include <fstream>

// Linux: 读取 /proc/self/task/ 获取所有线程 TID
std::vector<pid_t> get_thread_tids() {
    std::vector<pid_t> tids;
    DIR* dir = opendir("/proc/self/task");
    if (!dir) return tids;
    struct dirent* entry;
    while ((entry = readdir(dir)) != nullptr) {
        if (entry->d_type == DT_DIR) {
            pid_t tid = atoi(entry->d_name);
            if (tid > 0) tids.push_back(tid);
        }
    }
    closedir(dir);
    return tids;
}

3.4.3 寄存器上下文捕获

#include <ucontext.h>

// 在信号处理器中通过 siginfo_t 的 ctx 参数获得
void capture_registers(void* uctx) {
    ucontext_t* uc = (ucontext_t*)uctx;
    // x86_64 下寄存器
    #if defined(__x86_64__)
        greg_t* regs = uc->uc_mcontext.gregs;
        uint64_t rip = regs[REG_RIP];
        uint64_t rsp = regs[REG_RSP];
        uint64_t rbp = regs[REG_RBP];
        // 打印或序列化
    #elif defined(__aarch64__)
        // 使用 uc->uc_mcontext 中的 arm64 寄存器
    #endif
}
性能优化:线程状态收集全程不允许动态内存分配。预分配固定大小的栈上缓冲区(如 char buf[4096]),使用 snprintf 和 write() 原子落盘。所有指针在访问前判空,避免二次 crash。

3.5 常见错误与避坑

  • 递归信号:信号处理器中再次触发同一信号导致无限递归。解决:一进入处理器立即恢复默认处理。
  • 锁死与死锁:信号可能发生在持有锁的临界区。在处理器中不要尝试获取任何锁。
  • 堆栈溢出:信号处理器栈空间有限(通常 8KB)。将复杂逻辑(如写文件)交给独立信号守护线程。
  • 未捕获的异常:C++ 项目中必须同时设置 std::set_terminate 并调用 abort() 以触发信号链路。
  • 符号名不完整:内联函数、尾调用优化、strip 后的二进制会导致符号缺失。用 --strip-unneeded 保留 unwind 表。

3.6 部署与验证

# CMakeLists.txt 启用 unwind 支持
find_package(Unwind REQUIRED)
target_link_libraries(my_crash_collector PRIVATE unwind::unwind)

# 编译选项(GCC/Clang)
add_compile_options(-fno-omit-frame-pointer -fasynchronous-unwind-tables)
add_link_options(-rdynamic)

# 调试测试:手动触发 SIGSEGV
int* p = nullptr;
*p = 42;  // 应触发信号处理器并打印堆栈
本地验证:编写一个简单的单元测试,在测试二进制内主动触发各类信号(SIGSEGV、SIGABRT、SIGFPE),检查采集日志是否包含正确的函数名、行号和完整调用栈。建议在 Docker 容器中模拟不同 Linux 发行版运行环境。

完成本节后,你将拥有一个能够在崩溃瞬间安全采集信号类型、堆栈轨迹、寄存器上下文和多线程快照的底层采集模块。该模块是第 4 章“Crash 数据聚合与上传”的可靠数据源。