46、信号处理:信号处理函数中的可重入性问题

信号处理,说白了就是程序运行时的“突发事件”。你正好好跑着代码,突然一个信号来了,CPU 得停下手里的事,跳去执行信号处理函数。这个函数里能做什么、不能做什么,学问很大。

我刚开始写嵌入式程序时,觉得信号处理很简单——不就是收到信号后打印个日志、改个标志位嘛。直到有一次,程序在信号处理函数里调用了 printf,结果莫名其妙地死锁了。查了两天才发现,printf 不是可重入的。嗯,从那以后,我对信号处理函数里的每一行代码都格外小心。

什么是可重入性?

可重入性,简单说就是:一个函数在被调用执行的过程中,如果再次被调用(比如被中断、被信号处理函数调用),它仍然能正确运行,不会出问题。

你想想看,信号处理函数是异步执行的。主程序可能正在调用 malloc 分配内存,信号来了,处理函数里又调用了 malloc。这时候内存管理的数据结构可能处于不一致状态,第二次调用就会把整个堆搞乱。

我个人的习惯是:信号处理函数里,只做最安全的事。能不做的事,坚决不做。

哪些函数是安全的?

POSIX 标准明确列出了“异步信号安全函数”列表。这些函数可以在信号处理函数中安全调用。我挑几个常用的说说:

函数 说明 我是否常用
write() 写文件描述符,注意不是 printf 常用,用来写日志
read() 读文件描述符 偶尔用
sig_atomic_t 原子类型变量,读写是安全的 几乎每个信号处理函数都用
signal() 设置信号处理方式 少用,更推荐 sigaction
_exit() 立即退出,不清理资源 紧急情况用

注意,printfmallocfreefopenfclose 这些统统不在安全列表里。为什么?因为它们内部使用了全局数据结构,而且没有做重入保护。

核心原则:信号处理函数中,只调用“异步信号安全函数”。不确定的函数,一律不要调用。

一个典型的错误示例

我曾经在代码审查中看到过这样的写法:

// 错误示例:千万别这么写
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 流程图

下面这张图展示了信号处理函数中可重入问题的核心逻辑:

主程序执行中 调用不可重入函数 (如 printf, malloc) 信号到达 信号处理函数执行 再次调用不可重入函数 数据不一致! 堆损坏 / 死锁 / 崩溃 正确做法 设置 sig_atomic_t 标志 主循环检查 安全处理信号

警告:千万不要在信号处理函数中调用 longjmpsiglongjmp 跳转到主程序。这会导致栈状态不一致,后果比死锁还严重。我曾经见过一个同事这么写,程序崩溃后连 core dump 都生成不了,调试了整整一周。

避坑指南

我总结了几条实战经验,供你参考:

  • 信号处理函数越短越好。理想情况下,只写一行:g_flag = 1;
  • 不要使用 errno。信号处理函数可能覆盖 errno,导致主程序误判。如果必须用,先保存再恢复。
  • 注意信号屏蔽。用 sigactionsa_mask 字段,在处理信号时屏蔽其他信号,减少重入风险。
  • 避免在信号处理函数中加锁。如果主程序已经持有锁,信号处理函数再尝试获取同一把锁,就死锁了。

我曾经在一个实时系统中,信号处理函数里调用了 pthread_mutex_lock。主程序在临界区里被信号中断,信号处理函数又去锁同一个 mutex,直接死锁。系统看门狗超时复位,整个设备重启了。从那以后,我定了一条铁律:信号处理函数里,绝对不加锁

总结

信号处理函数的可重入性问题,说白了就是一句话:别在信号处理函数里做复杂的事。设置一个标志位,让主程序去处理,这是最安全、最可靠的做法。

记住,信号处理函数是异步的,它可能在任何时刻打断主程序。你永远不知道主程序正在调用哪个不可重入函数。所以,保持信号处理函数简单、安全,是每个嵌入式工程师的基本素养。

最后提醒:如果你不确定某个函数是否可重入,那就默认它不可重入。宁可多写几行代码把处理逻辑移到主循环,也不要在信号处理函数里冒险。

专注资料整理