信号机制: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做文件缓存时,一定要检查文件大小变化,优雅地处理信号。
信号处理机制简图
下面这张图展示了信号从产生到被处理的完整流程。理解这个流程,你才能知道自己写信号处理函数时踩的坑在哪。
如何写一个靠谱的信号处理函数?
先给你看个典型错误写法:
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的“指纹”。看懂它,你就能快速把问题范围缩小到“空指针”、“内存损坏”、“逻辑主动终止”这几大类里,少走很多弯路。