46、信号处理:信号处理函数中的可重入性问题
信号处理,说白了就是程序运行时的“突发事件”。你正好好跑着代码,突然一个信号来了,CPU 得停下手里的事,跳去执行信号处理函数。这个函数里能做什么、不能做什么,学问很大。
我刚开始写嵌入式程序时,觉得信号处理很简单——不就是收到信号后打印个日志、改个标志位嘛。直到有一次,程序在信号处理函数里调用了 printf,结果莫名其妙地死锁了。查了两天才发现,printf 不是可重入的。嗯,从那以后,我对信号处理函数里的每一行代码都格外小心。
什么是可重入性?
可重入性,简单说就是:一个函数在被调用执行的过程中,如果再次被调用(比如被中断、被信号处理函数调用),它仍然能正确运行,不会出问题。
你想想看,信号处理函数是异步执行的。主程序可能正在调用 malloc 分配内存,信号来了,处理函数里又调用了 malloc。这时候内存管理的数据结构可能处于不一致状态,第二次调用就会把整个堆搞乱。
我个人的习惯是:信号处理函数里,只做最安全的事。能不做的事,坚决不做。
哪些函数是安全的?
POSIX 标准明确列出了“异步信号安全函数”列表。这些函数可以在信号处理函数中安全调用。我挑几个常用的说说:
| 函数 | 说明 | 我是否常用 |
|---|---|---|
write() |
写文件描述符,注意不是 printf |
常用,用来写日志 |
read() |
读文件描述符 | 偶尔用 |
sig_atomic_t |
原子类型变量,读写是安全的 | 几乎每个信号处理函数都用 |
signal() |
设置信号处理方式 | 少用,更推荐 sigaction |
_exit() |
立即退出,不清理资源 | 紧急情况用 |
注意,printf、malloc、free、fopen、fclose 这些统统不在安全列表里。为什么?因为它们内部使用了全局数据结构,而且没有做重入保护。
核心原则:信号处理函数中,只调用“异步信号安全函数”。不确定的函数,一律不要调用。
一个典型的错误示例
我曾经在代码审查中看到过这样的写法:
// 错误示例:千万别这么写
void sigint_handler(int signo) {
printf("Caught SIGINT\n"); // 危险!printf 不可重入
free(ptr); // 危险!free 不可重入
global_flag = 1; // 危险!如果 global_flag 不是 sig_atomic_t
}
这段代码有三个问题:
printf内部有锁,如果主程序正在 printf,信号来了再 printf,可能死锁free操作堆内存,堆管理数据结构可能处于不一致状态global_flag如果不是sig_atomic_t类型,赋值可能不是原子的
正确的做法是什么?
我推荐的做法是:信号处理函数里只做一件事——设置一个标志位。主程序定期检查这个标志位,再做实际处理。
// 正确示例
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
volatile sig_atomic_t g_sigint_received = 0;
void sigint_handler(int signo) {
// 只做最安全的事:设置标志位
g_sigint_received = 1;
}
int main() {
struct sigaction sa;
sa.sa_handler = sigint_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
while (1) {
// 主循环中检查标志位
if (g_sigint_received) {
// 在这里做实际处理,比如清理资源、打印日志
printf("SIGINT received, cleaning up...\n");
g_sigint_received = 0;
}
// 其他工作...
sleep(1);
}
return 0;
}
这个模式的好处很明显:信号处理函数只做原子赋值,绝对安全。主程序在可控的时机处理信号,不会出现重入问题。
小技巧:sig_atomic_t 是整数类型,读写是原子的。但注意,它只保证单次读写的原子性,不保证多次操作的原子性。比如 g_flag++ 就不是原子的,因为涉及读-改-写三步。
信号处理函数中的全局变量
如果你非要在信号处理函数里访问全局变量,记住两点:
- 变量类型必须是
volatile sig_atomic_t - 变量要加
volatile关键字,防止编译器优化
为什么加 volatile?因为编译器可能认为某个变量在循环中不会改变,把它优化到寄存器里。信号处理函数改了内存中的值,但主程序读的是寄存器里的旧值,就出问题了。
我曾经在一个网络服务器项目中遇到过这个问题。主循环检查一个标志位,编译器优化后,标志位一直读寄存器,信号来了也不退出循环。加了 volatile 后问题解决。
信号处理函数的 SVG 流程图
下面这张图展示了信号处理函数中可重入问题的核心逻辑:
警告:千万不要在信号处理函数中调用 longjmp 或 siglongjmp 跳转到主程序。这会导致栈状态不一致,后果比死锁还严重。我曾经见过一个同事这么写,程序崩溃后连 core dump 都生成不了,调试了整整一周。
避坑指南
我总结了几条实战经验,供你参考:
- 信号处理函数越短越好。理想情况下,只写一行:
g_flag = 1; - 不要使用 errno。信号处理函数可能覆盖 errno,导致主程序误判。如果必须用,先保存再恢复。
- 注意信号屏蔽。用
sigaction的sa_mask字段,在处理信号时屏蔽其他信号,减少重入风险。 - 避免在信号处理函数中加锁。如果主程序已经持有锁,信号处理函数再尝试获取同一把锁,就死锁了。
我曾经在一个实时系统中,信号处理函数里调用了 pthread_mutex_lock。主程序在临界区里被信号中断,信号处理函数又去锁同一个 mutex,直接死锁。系统看门狗超时复位,整个设备重启了。从那以后,我定了一条铁律:信号处理函数里,绝对不加锁。
总结
信号处理函数的可重入性问题,说白了就是一句话:别在信号处理函数里做复杂的事。设置一个标志位,让主程序去处理,这是最安全、最可靠的做法。
记住,信号处理函数是异步的,它可能在任何时刻打断主程序。你永远不知道主程序正在调用哪个不可重入函数。所以,保持信号处理函数简单、安全,是每个嵌入式工程师的基本素养。
最后提醒:如果你不确定某个函数是否可重入,那就默认它不可重入。宁可多写几行代码把处理逻辑移到主循环,也不要在信号处理函数里冒险。