崩溃采集SDK原理与集成:信号捕获、堆栈获取、日志上报
崩溃采集SDK是线上稳定性监控的基石。本章从操作系统信号机制出发,逐步拆解“捕获信号 → 获取堆栈 → 格式化上报”三个核心链路,并给出可嵌入生产环境的C/C++实现片段、集成配置与常见陷阱。
3.1 信号捕获原理与注册
Unix/Linux下大部分异常崩溃(SIGSEGV、SIGABRT、SIGBUS、SIGFPE 等)均由内核向进程发送信号,默认动作为终止进程+生成core dump。崩溃采集SDK需要在信号到来时接管控制权,执行用户自定义handler,而非直接终止。
3.1.1 信号分类与对应崩溃
| 信号 | 默认动作 | 典型崩溃原因 |
|---|---|---|
| SIGSEGV | 终止+core | 空指针解引用、访问非法地址 |
| SIGABRT | 终止+core | assert失败、abort()主动调用 |
| SIGBUS | 终止+core | 硬件对齐错误、内存映射失效 |
| SIGFPE | 终止+core | 整数除零、浮点异常 |
| SIGILL | 终止+core | 非法指令、损坏的代码段 |
| SIGPIPE | 终止 | 向已关闭读端的管道写入 |
3.1.2 使用 sigaction 注册信号
避免使用不可靠的 signal(),推荐 sigaction() 以获取更多控制权(SA_SIGINFO 可获取崩溃时的上下文寄存器)。
#include <signal.h>
#include <string.h>
#include <unistd.h>
static void crash_handler(int sig, siginfo_t *info, void *ucontext) {
// 1. 恢复默认处理,防止重复触发导致死循环
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = SIG_DFL;
sigaction(sig, &sa, NULL);
// 2. 将信号号写入自管道或日志缓冲区(异步安全)
// 3. 获取堆栈(见3.2)
// 4. 上报日志(见3.3)
_exit(EXIT_FAILURE); // 安全终止
}
void install_signal_handlers() {
static const int fatal_signals[] = {
SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL, SIGPIPE
};
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = crash_handler;
sa.sa_flags = SA_SIGINFO | SA_RESTART; // SA_SIGINFO 获取ucontext
int i;
for (i = 0; i < 6; ++i) {
sigaction(fatal_signals[i], &sa, NULL);
}
}
重要:信号handler中禁止使用
printf、malloc、free、pthread_mutex_lock 等非异步安全函数,否则可能死锁或二次崩溃。推荐仅用 write() 写到预分配文件描述符。
3.2 堆栈获取:unwind 与 symbolicate
信号handler中需尽快抓取当前调用栈。常用手段:
- glibc backtrace() – 基于帧指针,需编译选项
-fno-omit-frame-pointer。 - libunwind – 不依赖帧指针,支持DWARF CFI,推荐生产环境。
- 从 ucontext 的 PC 寄存器开始扫描 – 获取指令地址后通过
dladdr()查映射。
3.2.1 使用 backtrace + backtrace_symbols 示例
#include <execinfo.h>
#include <stdio.h>
#include <stdlib.h>
#define MAX_STACK_FRAMES 64
void crash_handler(int sig, siginfo_t *info, void *ucontext) {
void *buffer[MAX_STACK_FRAMES];
int frames = backtrace(buffer, MAX_STACK_FRAMES);
// 将地址写入临时文件(异步安全)
const char *path = "/tmp/crash_stack.txt";
FILE *fp = fopen(path, "a");
if (fp) {
fprintf(fp, "Signal %d, frames=%d\n", sig, frames);
char **symbols = backtrace_symbols(buffer, frames);
if (symbols) {
for (int i = 0; i < frames; ++i) {
fprintf(fp, " #%d %s\n", i, symbols[i]);
}
free(symbols); // 注意:free 不是异步安全,此例仅演示
}
fclose(fp);
}
_exit(1);
}
注意:上例中
fprintf、backtrace_symbols、free 都不是异步安全函数,生产环境下请使用 write() + 预分配字符缓冲区 + backtrace() 原始地址,进程重启后离线 symbolicate。
3.2.2 获取寄存器 PC 与调用栈起始地址
借助 ucontext 可精确拿到崩溃指令地址:
#include <ucontext.h>
static uintptr_t get_pc_from_ucontext(void *ucontext) {
ucontext_t *uc = (ucontext_t *)ucontext;
#if defined(__x86_64__)
return uc->uc_mcontext.gregs[REG_RIP];
#elif defined(__i386__)
return uc->uc_mcontext.gregs[REG_EIP];
#elif defined(__aarch64__)
return uc->uc_mcontext.pc;
#elif defined(__arm__)
return uc->uc_mcontext.arm_pc;
#endif
return 0;
}
void crash_handler(int sig, siginfo_t *info, void *ucontext) {
uintptr_t pc = get_pc_from_ucontext(ucontext);
// 将 pc 写入双缓冲区,后续通过 dladdr 解析
write(log_fd, &pc, sizeof(pc));
_exit(1);
}
3.3 日志上报策略
崩溃现场数据(信号、堆栈地址、进程信息)需持久化,待进程下次启动或后台线程上报。
3.3.1 本地缓存格式
为减少信号handler工作量,只写入二进制地址,重启后 symbolicate 并组装JSON:
// crash_data.bin 的二进制格式
struct CrashRecord {
uint32_t magic; // 0xDEADBEEF
uint32_t crc32; // 校验
uint64_t timestamp;
int32_t signal;
uint32_t frame_count;
uint64_t frame_addrs[64]; // 原始指令地址
uint32_t thread_id;
uint8_t reserved[48];
};
3.3.2 启动后解析与上传
void upload_crash_if_any() {
int fd = open("/data/local/tmp/crash_data.bin", O_RDONLY);
if (fd == -1) return;
struct CrashRecord rec;
if (read(fd, &rec, sizeof(rec)) != sizeof(rec)) { close(fd); return; }
close(fd);
// symbolicate: 调用 atos 或 addr2line
char symbolicated[4096];
snprintf(symbolicated, sizeof(symbolicated),
"{\"signal\":%d,\"frames\":[", rec.signal);
for (int i = 0; i < rec.frame_count; ++i) {
// 此处省略 symbolicate 细节,最终拼接地址+符号
}
strcat(symbolicated, "]}");
// HTTP POST 上报,可使用 libcurl 或系统网络API
// curl -X POST -H "Content-Type: application/json" -d '...' https://crash.example.com/upload
system("curl -X POST -d '' ..."); // 示例简化
unlink("/data/local/tmp/crash_data.bin");
}
常见优化与策略:
- 采样率 – 线上可配置1%~100%,低端机型降低上报量。
- 去重 – 根据堆栈hash过滤重复崩溃,减少流量。
- 压缩 – 对JSON做gzip压缩后再上传。
- 延迟上报 – 缓存队列+启动后定时器,避免信号handler中发起网络请求。
常见错误:
- 在信号handler中调用
printf、malloc、dlopen等,导致二次崩溃。 - 忘记
sa_flags=SA_SIGINFO,丢失ucontext,无法获取PC。 - 编译时未加
-fno-omit-frame-pointer,backtrace只有一层。 - 符号表未strip或未上传至服务器,symbolicate失败。
- 上报URL硬编码且无fallback,域名解析失败则丢失崩溃。
3.4 整体工作流程图
3.5 CMake 集成示例
cmake_minimum_required(VERSION 3.16)
project(CrashSDK C)
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fno-omit-frame-pointer -g")
add_library(crashsdk STATIC
src/signal_install.c
src/stack_unwind.c
src/crash_writer.c
src/uploader.c
)
# 如果使用 libunwind
find_library(UNWIND_LIB unwind)
if(UNWIND_LIB)
target_compile_definitions(crashsdk PRIVATE USE_LIBUNWIND)
target_link_libraries(crashsdk PRIVATE ${UNWIND_LIB})
endif()
# 可选:禁用调试符号裁剪
set_target_properties(crashsdk PROPERTIES
LINK_FLAGS "-rdynamic"
)
生产环境建议:
- 在
CMakeLists.txt中通过target_link_options(crashsdk PRIVATE "-rdynamic")保留符号表供运行时解析。 - 使用
--strip-debug单独剥离 .debug 段并上传至符号服务器,减少apk体积。 - Android NDK 下需在
Android.mk设置LOCAL_CFLAGS += -funwind-tables强制展开调用栈。
3.6 常见错误与避坑
| 错误 | 后果 | 正确做法 |
|---|---|---|
信号handler中调用 printf | 死锁/二次崩溃 | 仅用 write 写入预分配buffer |
忘记 SA_SIGINFO 标志 | 无法获取 ucontext,PC丢失 | 设置 sa_flags = SA_SIGINFO |
未加 -fno-omit-frame-pointer | backtrace 只返回1~2帧 | 编译时显式添加该选项 |
| 符号表与线上版本不匹配 | symbolicate 结果错误 | 构建时记录 git commit,自动上传符号 |
| 上报时直接发起网络请求 | 阻塞主线程/信号handler中非法 | 写文件 → 重启后后台线程上传 |
3.7 性能与稳定性优化
- 信号handler极致精简:只做两件事(写地址到缓冲区、_exit)。
- 预分配堆栈缓冲区:避免
malloc在handler中调用。 - 去重与限流:同一条崩溃(堆栈hash相同)一天内只上报一次。
- 采样开关:通过远程配置下发采样率,低流量机型默认5%。
- 备选落盘路径:若默认路径不可写,降级至内存缓冲区 + 下次上报。
- 监控FD泄漏:每次落盘后检查文件描述符是否正常关闭。