15、信号处理(signal.h):signal函数、raise函数、常见信号(SIGINT、SIGSEGV、SIGTERM)。信号处理函数的限制。
信号处理,说白了就是让程序在运行时能响应突发事件。你想想看,一个程序跑得好好的,突然用户按了Ctrl+C,或者内存访问越界了,总得有个机制来处理吧?C标准库里的signal.h就是干这个的。
我个人习惯把信号理解成「软件中断」。它不像硬件中断那么底层,但机制很像——程序正常执行着,信号一来,立马跳转到处理函数,处理完再回来继续跑。嗯,这里要注意:不是所有信号都能被捕获,也不是所有信号处理方式都安全。
15.1 常见信号:SIGINT、SIGSEGV、SIGTERM
标准C语言定义了几种基本信号,我挑三个最常用的讲讲。这三个我在项目中都遇到过,尤其是SIGSEGV,调试时没少跟它打交道。
| 信号 | 默认行为 | 触发场景 |
|---|---|---|
| SIGINT | 终止进程 | 用户按下Ctrl+C |
| SIGSEGV | 终止进程(核心转储) | 访问非法内存地址 |
| SIGTERM | 终止进程 | 外部进程请求终止(如kill命令) |
SIGINT 是最常见的。你写个死循环程序,按Ctrl+C终止它,发的就是SIGINT。我曾经在一个命令行工具里捕获SIGINT,用来做优雅退出——先把临时文件清理掉,再退出程序,而不是直接暴毙。
SIGSEGV 是程序员的噩梦。野指针、数组越界、解引用空指针,都会触发它。默认行为是终止并生成core文件。我记得有一次线上服务突然挂了,查日志发现是SIGSEGV,最后定位到是一个多线程环境下未加锁的共享变量被写坏了指针。嗯,这种问题最难查。
SIGTERM 是系统管理员常用的。用kill命令不带参数时,默认发的就是SIGTERM。它比SIGKILL温和,因为进程可以捕获它做清理工作。我建议所有长期运行的服务都至少捕获SIGTERM,做资源释放和状态保存。
15.2 signal函数:注册信号处理
signal函数用来注册一个信号处理函数。原型很简单:
#include <signal.h>
void (*signal(int sig, void (*func)(int)))(int);
这个声明看着有点绕。说白了,signal接受两个参数:信号编号和处理函数指针,返回的是之前的处理函数指针。我刚开始学C时也被这个声明搞晕过,后来用typedef就好理解了:
typedef void (*sighandler_t)(int);
sighandler_t signal(int sig, sighandler_t handler);
处理函数可以传三种值:
- SIG_DFL:恢复默认行为
- SIG_IGN:忽略该信号
- 自定义函数:自己处理
来看个实际例子。我写过一个日志采集程序,需要捕获SIGINT做清理:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
volatile sig_atomic_t keep_running = 1;
void handle_sigint(int sig) {
// 注意:信号处理函数中只能调用异步信号安全函数
keep_running = 0;
}
int main() {
signal(SIGINT, handle_sigint);
while (keep_running) {
printf("程序运行中...\n");
sleep(1);
}
printf("收到SIGINT,执行清理...\n");
// 这里做资源释放
return 0;
}
注意我用了volatile sig_atomic_t类型。为什么?因为信号处理函数和主程序是异步执行的,编译器可能优化掉对keep_running的读取。加上volatile告诉编译器:这个变量可能被意外修改,每次用都要重新读。sig_atomic_t保证读写是原子的,不会读到一半被中断。
int代替sig_atomic_t。在某些平台上,int的读写不是原子的,可能读到损坏的值。
15.3 raise函数:主动发送信号
raise函数让进程给自己发信号。原型:
int raise(int sig);
成功返回0,失败返回非0。我一般在测试信号处理逻辑时用它,或者在某些错误条件下主动触发SIGABRT来生成core dump:
#include <signal.h>
#include <stdio.h>
void handle_sigterm(int sig) {
printf("收到SIGTERM,准备退出\n");
// 清理资源...
}
int main() {
signal(SIGTERM, handle_sigterm);
// 模拟某个条件触发退出
if (some_error_condition()) {
printf("发生错误,主动发送SIGTERM\n");
raise(SIGTERM);
}
return 0;
}
你可能会问:为什么不直接调用处理函数?因为raise走的是完整的信号投递流程,包括信号屏蔽和递送。这在多线程环境下尤其重要——信号会递送到当前线程,而不是随便哪个线程。
15.4 信号处理函数的限制
这是最容易踩坑的地方。信号处理函数是在异步上下文中执行的,能做的事情非常有限。我曾经在信号处理函数里调了printf,结果程序莫名其妙死锁了。查了半天才发现问题。
信号处理函数中安全的操作只有这些:
- 读写
volatile sig_atomic_t类型的变量 - 调用异步信号安全函数(async-signal-safe functions)
- 调用
_Exit()或_exit()立即终止
不安全的操作包括:
- 调用
printf、malloc、free等标准库函数 - 操作全局数据结构(除非用原子操作)
- 调用任何可能阻塞的函数
- 使用锁或互斥量
为什么会这样?因为信号可能在任何时刻到达,包括在malloc内部修改堆管理结构的时候。如果你在信号处理函数里又调malloc,就可能破坏堆的内部状态,导致死锁或崩溃。
POSIX标准定义了异步信号安全函数列表,包括write、read、sigaction等。但C标准只保证signal本身和raise是安全的。我个人的做法是:信号处理函数里只写一个volatile sig_atomic_t标志,其他什么都不做。
15.5 知识体系总览
下面这张图总结了信号处理的核心逻辑。我建议你把它记在脑子里,写代码时对照着检查:
15.6 避坑指南与最佳实践
我总结了几条实战经验,都是血泪教训:
- 永远不要在信号处理函数里调printf。 我曾经在调试时加了一行printf,结果程序在信号处理时死锁了。printf内部用了锁,而信号可能正好在锁被持有时到达。
- 用sigaction代替signal。 signal的行为在不同Unix系统上有差异,而sigaction是POSIX标准且行为一致。虽然C标准只定义了signal,但实际项目中我建议用sigaction。
- 信号处理函数要尽量简单。 只设置一个标志变量,然后返回。主循环中检查这个标志,再做实际处理。这样既安全又清晰。
- 注意volatile关键字。 没有volatile,编译器可能把变量优化到寄存器里,信号处理函数改了内存值,主循环却读不到。
- 多线程环境下要小心。 信号默认递送到任意线程,可以用pthread_sigmask控制每个线程的信号屏蔽字。
volatile sig_atomic_t g_signal_received = 0;,信号处理函数里只做g_signal_received = sig;,主循环里检查并处理。这样既安全又灵活。
信号处理是C语言中一个看似简单实则暗藏玄机的领域。掌握了signal和raise的基本用法,理解了信号处理函数的限制,你就能写出健壮的程序。记住:信号处理函数不是普通函数,它是异步上下文中的「特派员」——只传递信息,不处理业务。
公众号:蓝海资料掘金营,微信deep3321