信号处理:signal/sigaction,信号集,可重入函数

信号处理这个话题,说实话,很多C语言开发者写了三五年代码都不一定真正搞明白。我早年做嵌入式Linux驱动时,就被信号坑过——一个异步信号进来,全局变量被改得乱七八糟,查了两天才定位到问题。从那以后,我对信号处理就格外谨慎了。

信号本质上是一种软件中断。它告诉进程:嘿,有事情发生了!可能是用户按了Ctrl+C,可能是子进程退出了,也可能是定时器到期了。处理信号的方式,直接决定了你的程序是否健壮。

signal vs sigaction:老接口与新接口

先说说最基础的 signal()。这个函数很简单,注册一个信号处理函数就行:

#include <signal.h>

void handler(int sig) {
    printf("收到信号 %d\n", sig);
}

int main() {
    signal(SIGINT, handler);  // 注册Ctrl+C处理
    while(1);
    return 0;
}

看起来挺简单对吧?但问题来了——signal() 的行为在不同Unix系统上不一致。有些系统在处理完信号后会重置为默认行为,有些不会。这就导致了一个经典的竞态条件:

我曾经踩过的坑:在旧版Linux上,signal注册的处理函数执行一次后就被重置了。第二次收到同样的信号,程序直接默认退出。后来我改用 sigaction,再也没出过这种问题。

sigaction() 是POSIX标准推荐的接口。它更强大,也更可控:

#include <signal.h>

void handler(int sig, siginfo_t *info, void *context) {
    printf("信号 %d 来自进程 %d\n", sig, info->si_pid);
}

int main() {
    struct sigaction sa;
    sa.sa_sigaction = handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_SIGINFO;  // 获取额外信息

    sigaction(SIGINT, &sa, NULL);
    while(1);
    return 0;
}

看到区别了吗?sigaction 可以获取信号的来源、发送者的PID、用户态时间等详细信息。我个人习惯,只要项目不是玩具代码,一律用 sigaction

信号集:管理多个信号的利器

信号集说白了就是一个位图,每个bit代表一个信号。你想想看,如果程序要同时屏蔽十几个信号,总不能一个个去设置吧?

信号集的操作有标准API:

sigset_t set;

sigemptyset(&set);      // 清空所有信号
sigfillset(&set);       // 设置所有信号
sigaddset(&set, SIGINT); // 添加SIGINT
sigdelset(&set, SIGINT); // 删除SIGINT
sigismember(&set, SIGINT); // 检查是否包含

实际项目中,信号集最常用的场景是:在处理某个信号时,临时屏蔽其他信号。比如:

struct sigaction sa;
sigset_t block_mask;

sigemptyset(&block_mask);
sigaddset(&block_mask, SIGTERM);
sigaddset(&block_mask, SIGHUP);

sa.sa_mask = block_mask;  // 处理SIGINT时,自动屏蔽SIGTERM和SIGHUP
sa.sa_handler = my_handler;
sigaction(SIGINT, &sa, NULL);

嗯,这里要注意:sa_mask 是在处理当前信号期间临时屏蔽的信号集。处理完当前信号后,这些屏蔽会自动解除。这个机制能有效防止信号嵌套导致的混乱。

可重入函数:信号处理中的雷区

这是信号处理里最容易被忽视的问题。什么叫可重入?说白了,就是一个函数在被中断后,再次进入执行,不会出问题。

我举个例子:

int global_flag = 0;

void handler(int sig) {
    global_flag = 1;  // 危险!可能正在被主程序修改
}

int main() {
    signal(SIGINT, handler);
    while(1) {
        if (global_flag) {
            // 处理逻辑
            global_flag = 0;
        }
    }
}

这段代码有什么问题?如果主程序刚检查完 global_flag == 1,还没来得及处理,信号又来了,处理函数又把 global_flag 改成1。等回到主程序时,逻辑就乱了。

可重入函数的核心原则:

  • 不使用全局变量或静态变量
  • 不调用不可重入的函数(如malloc、printf)
  • 不修改自身的代码段
  • 使用原子操作或锁保护共享数据

POSIX标准明确列出了可重入函数列表。我建议你把这个列表记在脑子里:read()write()open()close()sigaction() 等是安全的。而 malloc()free()printf()fprintf() 这些,千万别在信号处理函数里用。

我的个人经验:信号处理函数里只做一件事——设置一个volatile sig_atomic_t类型的标志位。主程序轮询这个标志位,再做实际处理。这样既简单又安全。

知识体系总览

下面这张图,把信号处理的几个核心概念串起来了:

信号处理知识体系 signal / sigaction • signal:简单但不可靠 • sigaction:POSIX标准 • 支持信号信息获取 • 行为可预测、可控制 信号集管理 • sigset_t 位图结构 • sigemptyset / sigfillset • sigaddset / sigdelset • 信号屏蔽与解除 可重入函数 • 避免全局/静态变量 • 不调用不可重入函数 • 使用原子操作 • volatile sig_atomic_t 实践建议 1. 新项目一律使用 sigaction,不要用 signal 2. 信号处理函数保持极简,只设置标志位 3. 使用 sig_atomic_t 或原子操作保护共享数据 4. 善用 sa_mask 防止信号嵌套

避坑指南:我踩过的几个雷

做嵌入式这些年,信号处理相关的bug我见过不少。挑几个典型的说说:

  • 信号处理函数里调用printf——printf内部用了锁,信号中断时可能死锁。我曾经在调试时加了个printf,结果程序随机崩溃,查了两天。
  • 忽略SIGCHLD信号——子进程退出后变成僵尸进程。我建议所有创建子进程的程序,都注册SIGCHLD处理函数,调用waitpid回收。
  • 信号屏蔽字没处理好——多线程环境下,每个线程有自己的信号屏蔽字。主线程屏蔽了SIGINT,子线程却收到了。这个坑我印象很深。

一句话总结:信号处理不是玄学,是工程。用对接口、管好信号集、保证可重入,你的程序就能稳稳地跑下去。


专注资料整理