信号机制:Linux信号处理、SIGSEGV/SIGABRT/SIGBUS详解

信号到底是什么?

信号是Linux系统给进程发的一种异步通知。说白了,就是内核告诉你——“出事了!”

我个人习惯把信号理解为“软中断”。它能让一个正常跑着的程序,突然停下来去处理某个紧急情况。Crash定位里,信号是最直接的线索。你想想看,程序挂了,系统会发什么信号?这信号本身就告诉了你挂的原因。

三个最要命的信号

生产环境中,我遇到最多的Crash信号就是这三个:SIGSEGV、SIGABRT、SIGBUS。它们虽然都导致进程终止,但根源完全不同。

信号 触发原因 默认行为 常见场景
SIGSEGV (11) 访问无效内存地址(段错误) 终止+core dump 空指针解引用、野指针、越界访问
SIGABRT (6) 进程主动调用abort() 终止+core dump assert失败、业务逻辑主动终止
SIGBUS (7) 总线错误(硬件对齐/物理地址失效) 终止+core dump 未对齐内存访问、mmap映射文件被截断

核心结论:SIGSEGV是无权访问,SIGBUS是不能正常访问(对齐问题),SIGABRT是自己认输。搞混了它们,排查方向会完全跑偏。

SIGSEGV:空指针的“老朋友”

我刚开始做C++开发时,几乎每天都能看见它。写个代码忘了判空,或者指针释放后忘了置null,一跑就崩。

// 典型触发代码
int *p = nullptr;
*p = 42; // 触发 SIGSEGV

但有一次我遇到一个奇怪的SIGSEGV:指针非空,赋值却崩了。查了半天才发现是栈溢出导致内存布局被破坏,返回地址被覆盖——函数调用链里藏的雷。所以看到SIGSEGV别只查空指针,还要想想栈保护、数组越界这些玩意。

SIGABRT:程序自己的“坏消息”

SIGABRT通常由abort()触发,最常见的来源就是assert。你想想看,Debug模式下assert失败,直接弹个SIGABRT。

assert(p != nullptr); // p == nullptr 时会 abort()

个人经验:线上环境我建议关掉assert或者替换成自己实现的条件中断。因为assert在Release模式下会消失,曾经有一次我被坑惨了——线下从不崩,线上天天挂。其实就是Release下某个条件没检查,直接空指针了。

另外,第三方库内部也可能直接调用abort()。比如内存分配失败时,某些老库不是返回null而是直接abort。遇到SIGABRT,记得先看callstack,定位到调abort的地方。

SIGBUS:最隐蔽的“好兄弟”

说它隐蔽,是因为很多开发没遇到过。我印象特深的一次:在ARM嵌入式设备上,一个结构体成员没做内存对齐,直接强制类型转换读写,结果崩了个SIGBUS。

// 典型触发:未对齐访问
char buf[8];
int *p = (int*)(buf + 1); // buf+1 不是4字节对齐
*p = 123; // 在ARM上会 SIGBUS

X86上这个代码通常没事,但在ARM、MIPS等RISC架构上必挂。还有mmap映射的文件被截断,你读到了映射范围之外也会产生SIGBUS。嗯,这里要注意:用mmap做文件缓存时,一定要检查文件大小变化,优雅地处理信号。

信号处理机制简图

下面这张图展示了信号从产生到被处理的完整流程。理解这个流程,你才能知道自己写信号处理函数时踩的坑在哪。

信号处理完整流程 信号产生 内核/用户触发 硬件异常 SIGSEGV/SIGBUS 软件主动 SIGABRT/kill 信号传递 挂入目标进程pending队列 信号处理 三种方式可选 默认/忽略/自定义函数 默认行为 终止 + core dump 自定义处理 signal()/sigaction() ⚠️ 信号处理函数注意事项 1. 必须是可重入函数,不能调用malloc/printf等非异步安全函数 2. 推荐只写一个volatile sig_atomic_t标志,主循环检查 3. SIGSEGV/SIGBUS的处理函数如果自身触发异常,会导致无限递归 → 进程直接杀掉 4. 使用sigaction()而非signal(),signal在不同Unix版本行为不一致

如何写一个靠谱的信号处理函数?

先给你看个典型错误写法:

void handler(int sig) {
    printf("收到信号 %d\n", sig);  // 大忌!printf不是异步安全
    // 更别说调malloc了
}

这段代码在绝大多数情况下会“神不知鬼不觉地”死锁或二次崩溃。为什么呢?因为信号处理函数可能发生在malloc内部,你又调用了malloc……死循环了。

我曾经犯过的错:在SIGSEGV的处理函数里想写日志,直接调了fprintf,结果core dump文件里什么都没有,进程反而被内核直接杀掉。从那以后我老老实实只用write(2, buf, len)——这玩意是系统调用,可以安全用。

正确的做法是这样:

volatile sig_atomic_t g_segv_flag = 0;

void signal_handler(int sig) {
    (void)sig;
    g_segv_flag = 1;  // 只设标志
}

int main() {
    struct sigaction sa;
    sa.sa_handler = signal_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_SIGINFO;
    sigaction(SIGSEGV, &sa, NULL);

    // 主循环
    while (1) {
        if (g_segv_flag) {
            // 安全地处理异常,比如记录日志
            write(STDOUT_FILENO, "Got SIGSEGV\n", 12);
            g_segv_flag = 0;
        }
        // ... 正常业务逻辑
    }
}

一点建议:生产环境我很少自己捕获SIGSEGV/SIGBUS。因为一旦出现这些信号,进程状态可能已经不可靠了——栈被破坏、堆溢出。最佳实践是让系统生成core dump,然后用gdb分析。但如果你需要优雅退出、清理资源,可以用SIGTERM、SIGINT这类可捕获信号来做。

信号与Mock回放的关系

你可能会问,Crash定位和Mock回放怎么扯上信号的?其实关系很大:我们在回放过程中,如果遇到了同样的SIGSEGV,说明Mock复现了现场。这时候信号本身就是一个重要的“特征点”。我曾在回放框架里加入信号监听逻辑:如果回放到某个步骤时收到了SIGSEGV,自动记录当前输入和调用栈,然后跳过这个Crash继续跑下一个用例。这样能批量定位问题。

说白了,信号就是Crash的“指纹”。看懂它,你就能快速把问题范围缩小到“空指针”、“内存损坏”、“逻辑主动终止”这几大类里,少走很多弯路。