21、信号处理调试:SIGSEGV、SIGABRT的捕获

说到信号处理,我脑子里第一个蹦出来的就是当年那个通宵排查的野指针问题。那时候刚入行不久,程序跑着跑着就崩了,连个像样的日志都没留下。后来才明白——信号处理,就是给程序崩溃装个黑匣子

今天咱们聊聊两个最要命的信号:SIGSEGV(段错误)和 SIGABRT(异常终止)。你想想看,程序崩了不可怕,可怕的是崩了你不知道它为什么崩。捕获信号,就是让程序在临死前留下遗言。

核心观点:信号处理不是为了阻止崩溃,而是为了在崩溃前收集现场信息,方便事后排查。

21.1 信号是什么?

信号是操作系统发给进程的一种异步通知。说白了,就是内核告诉你:「嘿,你的程序出事了,赶紧处理一下。」

常见的致命信号有这些:

信号 默认行为 典型触发场景
SIGSEGV 终止进程 + core dump 访问非法内存地址(野指针、数组越界)
SIGABRT 终止进程 + core dump 调用 abort()、assert 失败
SIGFPE 终止进程 + core dump 除零操作、浮点异常
SIGILL 终止进程 + core dump 非法指令、损坏的代码段

嗯,这里要注意:不是所有信号都能被捕获SIGKILLSIGSTOP 就不行,这是内核保留的「核武器」。

21.2 捕获 SIGSEGV 和 SIGABRT

捕获信号用 signal() 或者 sigaction()。我个人习惯用 sigaction(),因为它更可靠,行为也更可控。

先看一个最简单的例子:

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>

void signal_handler(int sig) {
    // 注意:信号处理函数中能做的事非常有限
    // 最好只做简单记录,然后退出
    fprintf(stderr, "捕获到信号 %d\n", sig);
    _exit(1);
}

int main() {
    struct sigaction sa;
    sa.sa_handler = signal_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;

    sigaction(SIGSEGV, &sa, NULL);
    sigaction(SIGABRT, &sa, NULL);

    // 故意触发段错误
    int *p = NULL;
    *p = 42;  // 这里会触发 SIGSEGV

    return 0;
}

运行后你会看到输出:捕获到信号 11(SIGSEGV 的编号就是 11)。

警告:信号处理函数中不要调用 printf、malloc 等非异步信号安全的函数。它们内部可能持有锁,会导致死锁。用 write() 或直接写日志文件更安全。

21.3 获取更多崩溃信息

光知道「收到了 SIGSEGV」还不够。我想知道的是:哪行代码?哪个变量?当时的调用栈是什么?

这里就要用到 sigactionSA_SIGINFO 标志了。它能让信号处理函数拿到更多信息:

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <ucontext.h>
#include <execinfo.h>

void signal_handler(int sig, siginfo_t *info, void *context) {
    fprintf(stderr, "信号: %d\n", sig);
    fprintf(stderr, "错误地址: %p\n", info->si_addr);

    // 打印调用栈
    void *buffer[100];
    int n = backtrace(buffer, 100);
    backtrace_symbols_fd(buffer, n, STDERR_FILENO);

    _exit(1);
}

int main() {
    struct sigaction sa;
    sa.sa_sigaction = signal_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_SIGINFO;  // 关键标志

    sigaction(SIGSEGV, &sa, NULL);

    // 触发段错误
    int *p = (int*)0x12345678;
    *p = 42;

    return 0;
}

输出会包含错误地址和调用栈信息。我在项目中遇到过好几次,靠这个调用栈直接定位到了野指针的源头。

小技巧:编译时加上 -g -rdynamic 选项,调用栈信息会更详细,能显示函数名和行号。

21.4 捕获 SIGABRT 的特殊场景

SIGABRT 通常不是意外触发的,而是程序主动调用了 abort() 或者 assert 失败。但捕获它同样有价值——尤其是你想在断言失败时做点清理工作。

我曾经在一个嵌入式项目里,用 SIGABRT 的捕获来做「最后的日志刷出」。因为有些日志是缓冲的,程序一崩就丢了。捕获 SIGABRT 后,先把缓冲区刷到 Flash,再退出。

void abort_handler(int sig) {
    // 刷出日志缓冲区
    log_flush();
    // 保存崩溃现场到 EEPROM
    save_crash_context();
    // 然后退出
    _exit(1);
}

int main() {
    signal(SIGABRT, abort_handler);

    // 某个断言失败
    assert(0);  // 会触发 SIGABRT

    return 0;
}
注意:在 SIGABRT 处理函数中,不要调用 abort() 或再次触发 assert,否则会无限递归。

21.5 信号处理的限制与陷阱

信号处理不是万能的。有几个坑我得提醒你:

  • 信号处理函数中不能做复杂操作——比如不能调用 malloc、free、printf、互斥锁等。这些函数不是异步信号安全的。
  • 不能捕获所有信号——SIGKILL 和 SIGSTOP 是抓不住的。
  • 多线程环境要小心——信号默认发给主线程,但 SIGSEGV 会发给触发它的线程。处理不好可能死锁。
  • 信号处理函数执行完后,程序行为不确定——除非你调用 _exit() 或 longjmp() 跳出去。

说白了,信号处理函数就是个「临终遗言」的地方,别指望它做太多事。

21.6 实战:一个完整的崩溃捕获框架

下面是我个人常用的一个框架,整合了信号捕获、调用栈打印、日志刷出等功能:

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <execinfo.h>
#include <ucontext.h>
#include <unistd.h>

#define BACKTRACE_SIZE 100

void crash_handler(int sig, siginfo_t *info, void *context) {
    // 1. 打印信号信息
    write(STDERR_FILENO, "=== CRASH DETECTED ===\n", 23);
    
    char buf[64];
    int len = snprintf(buf, sizeof(buf), "Signal: %d\n", sig);
    write(STDERR_FILENO, buf, len);
    
    len = snprintf(buf, sizeof(buf), "Address: %p\n", info->si_addr);
    write(STDERR_FILENO, buf, len);

    // 2. 打印调用栈
    void *buffer[BACKTRACE_SIZE];
    int n = backtrace(buffer, BACKTRACE_SIZE);
    backtrace_symbols_fd(buffer, n, STDERR_FILENO);

    // 3. 刷出日志(假设有日志系统)
    // log_flush();

    // 4. 退出
    _exit(1);
}

void setup_crash_handler() {
    struct sigaction sa;
    sa.sa_sigaction = crash_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_SIGINFO | SA_NODEFER;

    sigaction(SIGSEGV, &sa, NULL);
    sigaction(SIGABRT, &sa, NULL);
    sigaction(SIGFPE, &sa, NULL);
    sigaction(SIGILL, &sa, NULL);
}

int main() {
    setup_crash_handler();

    // 正常业务逻辑...
    // 如果崩溃了,会打印调用栈

    return 0;
}

这个框架我用了好几年,在好几个项目里都帮了大忙。尤其是嵌入式环境,没有 GDB 的时候,这个就是救命稻草。

21.7 知识体系总览

下面这张图总结了信号处理调试的核心逻辑:

信号处理调试知识体系 信号来源 常见致命信号 捕获机制 触发场景 野指针 / 数组越界 assert失败 / abort() 除零 / 非法指令 信号类型 SIGSEGV (11) SIGABRT (6) SIGFPE / SIGILL 捕获方法 signal() / sigaction() SA_SIGINFO 获取详情 backtrace() 打印调用栈 输出与限制 输出:错误地址 / 调用栈 / 日志刷出 限制:只能做简单操作 / 不能捕获SIGKILL / 多线程需谨慎

21.8 避坑指南

最后,分享几个我踩过的坑:

  • 不要在信号处理函数里用 longjmp() 跳回去继续执行——程序状态已经损坏了,跳回去只会引发更奇怪的问题。
  • 记得在信号处理函数末尾调用 _exit()——而不是 exit()。exit() 会调用 atexit 注册的函数,可能再次触发信号。
  • 多线程程序里,信号处理函数要加锁吗?——不建议。信号处理函数中加锁很容易死锁。最好用无锁队列或者直接 write()。
  • 我曾经在 SIGSEGV 处理函数里调用了 printf——结果程序直接卡死,连错误信息都没打印出来。后来改用 write() 才搞定。

信号处理调试,说白了就是给程序装个「黑匣子」。平时用不上,但一旦出问题,它就是救命稻草。希望今天的内容能帮你少走一些弯路。


公众号:蓝海资料掘金营,微信deep3321