崩溃采集SDK原理与集成:信号捕获、堆栈获取、日志上报

崩溃采集SDK是线上稳定性监控的基石。本章从操作系统信号机制出发,逐步拆解“捕获信号 → 获取堆栈 → 格式化上报”三个核心链路,并给出可嵌入生产环境的C/C++实现片段、集成配置与常见陷阱。

3.1 信号捕获原理与注册

Unix/Linux下大部分异常崩溃(SIGSEGV、SIGABRT、SIGBUS、SIGFPE 等)均由内核向进程发送信号,默认动作为终止进程+生成core dump。崩溃采集SDK需要在信号到来时接管控制权,执行用户自定义handler,而非直接终止。

3.1.1 信号分类与对应崩溃

信号默认动作典型崩溃原因
SIGSEGV终止+core空指针解引用、访问非法地址
SIGABRT终止+coreassert失败、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 整体工作流程图

崩溃采集SDK核心流程 sigaction 注册 异常信号 (SIGSEGV…) 读取 ucontext / PC backtrace / libunwind write() 落盘 CrashRecord 启动后 symbolicate + HTTP POST 异步安全原则 handler内只调用 write(), _exit 禁止 malloc, printf, 锁 符号解析延迟到进程重启后 集成检查清单 ✓ 编译选项 -fsanitize=address ✓ -fno-omit-frame-pointer ✓ 符号文件 (.debug / .dsym)

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-pointerbacktrace 只返回1~2帧编译时显式添加该选项
符号表与线上版本不匹配symbolicate 结果错误构建时记录 git commit,自动上传符号
上报时直接发起网络请求阻塞主线程/信号handler中非法写文件 → 重启后后台线程上传

3.7 性能与稳定性优化

  • 信号handler极致精简:只做两件事(写地址到缓冲区、_exit)。
  • 预分配堆栈缓冲区:避免 malloc 在handler中调用。
  • 去重与限流:同一条崩溃(堆栈hash相同)一天内只上报一次。
  • 采样开关:通过远程配置下发采样率,低流量机型默认5%。
  • 备选落盘路径:若默认路径不可写,降级至内存缓冲区 + 下次上报。
  • 监控FD泄漏:每次落盘后检查文件描述符是否正常关闭。