10. 信号事件处理:Unix信号基础、信号与事件循环集成、信号安全处理
各位同学,今天我们来聊聊信号。嗯,信号这个东西,在嵌入式开发里就像个不请自来的客人——你永远不知道它什么时候敲门。我早年做网络设备时,就被信号坑过好几次。说白了,信号是Unix/Linux系统里最古老的进程间通信方式之一,但也是最容易出问题的。
10.1 Unix信号基础
信号本质上是一个软件中断。它告诉进程:嘿,有事情发生了!可能是用户按了Ctrl+C,可能是子进程退出了,也可能是定时器到期了。
常见的信号有这么几类:
| 信号名 | 编号 | 默认行为 | 典型触发场景 |
|---|---|---|---|
| SIGINT | 2 | 终止进程 | Ctrl+C |
| SIGTERM | 15 | 终止进程 | kill命令默认信号 |
| SIGKILL | 9 | 强制终止 | 无法捕获或忽略 |
| SIGUSR1 | 10 | 终止进程 | 用户自定义 |
| SIGPIPE | 13 | 终止进程 | 写管道但读端已关闭 |
| SIGALRM | 14 | 终止进程 | alarm()定时器到期 |
这里有个关键点:SIGKILL和SIGSTOP不能被捕获、阻塞或忽略。这是系统的最后手段,你拿它没办法。
核心概念:信号处理函数是异步执行的。这意味着它可能在你的代码执行到任何位置时突然插入。这就是所有麻烦的根源。
10.2 信号与事件循环集成
好,现在问题来了。我们的事件循环(比如epoll、select)在阻塞等待事件,信号来了怎么办?
我见过不少新手这么写:
// 错误示范:在信号处理函数里直接操作事件循环
void sigint_handler(int sig) {
// 直接调用事件循环的停止函数
event_loop_stop(); // 危险!信号处理函数里做这个?
}
为什么危险?因为信号处理函数里能做的事情非常有限。你想想看,信号来的时候,程序可能正拿着某个锁,或者正在malloc分配内存。这时候你再去调用event_loop_stop,里面可能又涉及锁操作——死锁就来了。
正确的做法是什么?我个人的习惯是使用自管道(self-pipe)技巧:
// 自管道技巧:信号处理函数只写一个字节到管道
static int signal_pipe[2];
void sigint_handler(int sig) {
char byte = 1;
write(signal_pipe[1], &byte, 1); // 信号安全:只做write
}
int event_loop_init() {
pipe(signal_pipe); // 创建管道
// 将管道的读端加入epoll
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = signal_pipe[0];
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, signal_pipe[0], &ev);
// 注册信号处理函数
signal(SIGINT, sigint_handler);
signal(SIGTERM, sigint_handler);
}
int event_loop_run() {
while (!stop_flag) {
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == signal_pipe[0]) {
// 在主循环里处理信号
char byte;
read(signal_pipe[0], &byte, 1);
if (byte == 1) {
stop_flag = 1;
printf("收到退出信号,优雅关闭...\n");
}
}
// 处理其他事件...
}
}
}
技巧:自管道的本质是把异步信号事件转化为文件描述符事件。这样你的事件循环就能统一处理所有输入源了。我在多个项目里都用这个模式,稳得很。
10.3 信号安全处理
说到信号安全,我得先讲一个血泪教训。我曾经在一个数据采集项目里,信号处理函数里调用了printf来打日志。结果呢?程序偶尔会莫名其妙地卡死。查了两天才发现——printf内部有锁,信号来的时候主程序正拿着那个锁,信号处理函数又去申请同一个锁,死锁了。
所以,信号处理函数里能做什么?我列个清单:
- 可以安全做的:
- 读写volatile sig_atomic_t类型的变量
- 调用异步信号安全函数(async-signal-safe functions)
- write() 系统调用(注意不是fwrite)
- 绝对不能做的:
- 调用malloc/free
- 调用printf/fprintf等标准I/O函数
- 操作任何全局数据结构(除非用原子操作)
- 调用pthread_mutex_lock等同步原语
这里有个常用的安全模式——延迟处理:
// 安全模式:信号处理函数只设置标志
static volatile sig_atomic_t sig_received = 0;
void signal_handler(int sig) {
sig_received = sig; // 唯一操作:写一个整数
}
int main() {
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
while (1) {
// 在主循环中检查标志
if (sig_received) {
int sig = sig_received;
sig_received = 0; // 清零
// 现在可以安全地做任何事了
printf("捕获到信号 %d,执行清理...\n", sig);
// 关闭文件、释放资源、保存状态...
}
// 正常的事件处理
// ...
}
}
警告:volatile关键字不能少!编译器可能会优化掉对sig_received的读取,导致主循环永远看不到信号。sig_atomic_t保证读写是原子的,在大多数平台上就是int类型。
10.4 信号集与信号屏蔽
有时候,你不想在关键代码段被信号打断。比如你在更新一个链表,信号处理函数如果也操作这个链表,那就乱套了。
这时候可以用信号屏蔽:
// 屏蔽信号:保护临界区
sigset_t old_mask, new_mask;
sigemptyset(&new_mask);
sigaddset(&new_mask, SIGINT);
sigaddset(&new_mask, SIGTERM);
// 进入临界区前屏蔽信号
sigprocmask(SIG_BLOCK, &new_mask, &old_mask);
// 执行临界区代码
update_linked_list();
// 恢复原来的信号掩码
sigprocmask(SIG_SETMASK, &old_mask, NULL);
注意:sigprocmask在多线程程序里要换成pthread_sigmask。嗯,这是另一个坑,我当年就踩过。
10.5 知识体系总览
说了这么多,我画张图帮你理清思路:
这张图把整个流程串起来了。从左到右看:信号来了,处理函数只做最少的操作(写管道或设标志),然后立即返回。主循环在epoll_wait里统一检测,再执行真正的处理逻辑。
核心原则:信号处理函数里做的事情越少越好。最好只写一个volatile变量,或者往管道里写一个字节。所有复杂逻辑都放到主循环里去做。
好了,关于信号事件处理,核心就是这些。记住:信号处理函数不是普通函数,它是异步的、不可重入的、限制重重的。尊重它的特殊性,你的程序才能稳定运行。