3. Crash 数据采集:信号捕获、异常处理钩子、堆栈回溯、线程状态收集
Crash 数据采集是整个监控平台的数据入口,它决定了后续聚合、归因、可视化的完整性和准确度。本章深入剖析信号捕获、异常处理钩子注册、堆栈回溯以及线程状态收集四个核心模块,覆盖从内核信号触发到原始日志落盘的完整链路,并提供可直接嵌入生产环境的实现方案。
核心目标:在进程崩溃瞬间,以最轻量的方式保留现场,同时保障嵌入式 Crash 处理器自身的稳定性和异步信号安全。
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 后无 catch | std::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 数据聚合与上传”的可靠数据源。