信号处理:信号处理函数中可调用的函数列表、volatile sig_atomic_t、信号的异步安全

信号处理,说白了就是程序在运行过程中,突然被操作系统“拍了一下肩膀”——你得停下手里的活,先去处理一个紧急事件。这个机制在嵌入式系统里尤其常见,比如硬件中断、定时器超时、或者用户按下了Ctrl+C。

但这里有个大坑:信号处理函数是异步执行的。你正在修改一个全局变量,信号突然来了,处理函数里也去改同一个变量——好嘛,数据直接乱掉。这就是典型的“异步安全问题”。

我刚开始做嵌入式开发时,就吃过这个亏。当时一个串口通信程序,主循环里在解析数据包,信号处理函数里只是简单设了个标志位。结果呢?偶尔会出现解析错误,查了两天才发现是int类型赋值不是原子的。嗯,从那以后我再也不敢小看信号处理了。

信号处理函数中能调用哪些函数?

这个问题其实很关键。信号处理函数里能调用的函数,有一个专门的称呼叫“异步信号安全函数”(Async-Signal-Safe Functions)。POSIX标准明确列出了这些函数,比如write()read()_exit()signal()等。

但注意,printf()malloc()free()fprintf()这些常用函数,都不是异步安全的。为什么?因为它们在内部可能使用了全局锁或缓冲区,信号一来,锁还没释放,处理函数又去调用——死锁了。

警告:千万不要在信号处理函数中调用 printf()、malloc()、free() 等非异步安全函数。这是未定义行为的高发区。

我个人的习惯是:信号处理函数里只做两件事——要么设置一个volatile sig_atomic_t标志位,要么调用write()往管道里写一个字节。其他事情,统统交给主循环去处理。

下面这张图展示了信号处理函数与主程序之间的安全交互模式:

信号处理安全交互模式 主程序(主循环) 检查标志位 处理业务逻辑 调用非安全函数 (printf, malloc等) 信号处理函数 仅设置标志位 或调用 write() 禁止调用非安全函数 volatile sig_atomic_t flag 读取 写入

volatile sig_atomic_t:信号安全的“原子哨兵”

你可能会问:那我用普通的int变量做标志位行不行?答案是:不行

原因有两个:

  1. 原子性问题:在32位系统上,读写一个int可能不是单条指令完成的。信号可能在读取中间打断,导致读到半新半旧的值。
  2. 编译器优化问题:编译器可能把变量优化到寄存器里,信号处理函数改了内存,主循环还在读寄存器——永远看不到变化。

volatile sig_atomic_t就是来解决这两个问题的。sig_atomic_t保证读写是原子的,volatile告诉编译器不要优化掉对这个变量的访问。

核心要点:

  • sig_atomic_t:保证原子读写(通常是int类型)
  • volatile:禁止编译器优化,强制每次从内存读取
  • 两者缺一不可

来看一个正确的示例:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t g_flag = 0;

void signal_handler(int sig) {
    // 只做一件事:设置标志位
    g_flag = 1;
}

int main() {
    signal(SIGINT, signal_handler);

    while (1) {
        if (g_flag) {
            // 主循环中处理信号
            write(STDOUT_FILENO, "Signal received\n", 16);
            g_flag = 0;
        }
        // 其他业务逻辑...
        sleep(1);
    }
    return 0;
}

这个模式我用了很多年,从来没出过问题。信号处理函数只设标志,主循环去轮询并处理。简单、可靠、安全。

信号的异步安全:不止是变量的问题

异步安全,说白了就是“信号来了别搞乱我的数据”。除了变量本身,还有几个地方要特别注意:

风险点 说明 规避方法
全局/静态变量 信号处理函数和主程序都可能访问 使用 volatile sig_atomic_t
非重入函数 如 printf、malloc 内部有锁 信号处理中禁止调用
文件描述符操作 多个信号处理可能同时操作同一 fd 使用 write() 等安全函数
信号嵌套 处理一个信号时又来一个 使用 sigprocmask 屏蔽

我记得有一次,一个同事在信号处理函数里调用了fprintf()来打日志。程序跑起来偶尔会卡死,查了半天才发现是fprintf内部的锁被信号打断了。你想想看,信号处理函数里拿着锁,主循环也在等锁——死锁了。

我的建议:

信号处理函数里,能用write()就别用printf()。如果一定要打日志,可以往管道里写一个字节,主循环从管道另一端读取后再做日志输出。这样既安全又灵活。

另外,errno也是个容易忽略的点。信号处理函数可能会修改errno,而主程序在信号返回后检查errno时,可能拿到的是信号处理函数设置的值。所以,如果信号处理函数里调用了可能设置errno的函数(比如write()),记得先保存再恢复。

void signal_handler(int sig) {
    int saved_errno = errno;  // 保存
    // ... 处理逻辑 ...
    errno = saved_errno;      // 恢复
}

这个细节,说实话,很多老手都会忘。我早期的一个项目里,就因为没保存errno,导致主程序里的错误处理逻辑偶尔抽风。后来加上了保存恢复,问题就再没出现过。

最后总结一下:信号处理函数的核心原则就是“少做、做对、做安全”。少做——只设标志位或写管道;做对——用volatile sig_atomic_t;做安全——只调用异步安全函数。记住这三点,信号处理这块基本不会踩坑。


公众号:蓝海资料掘金营,微信deep3321