5. 信号处理函数:从入门到避坑

信号处理,说白了就是让进程能响应外部事件的一种机制。我刚开始学Linux编程时,总觉得信号这东西挺玄乎——不就是发个通知嘛,能有多复杂?直到我在一个嵌入式项目中,因为信号处理函数写得不规范,导致程序莫名其妙地崩溃,排查了整整两天……嗯,从那以后我再也不敢小看信号处理了。

5.1 signal函数:最基础的信号处理

signal()函数是POSIX标准中最古老的信号处理接口。它的原型很简单:

#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);

这个声明看着有点绕,我习惯把它拆开理解:

  • signum:要捕获的信号编号,比如SIGINT(2)、SIGTERM(15)
  • handler:处理函数的指针,可以是自定义函数,也可以是SIG_IGN(忽略)或SIG_DFL(默认行为)
  • 返回值:返回之前的信号处理函数指针

来个最简单的例子:

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

void sigint_handler(int sig) {
    write(STDOUT_FILENO, "Caught SIGINT!\n", 15);
}

int main() {
    signal(SIGINT, sigint_handler);
    while(1) {
        pause();
    }
    return 0;
}
注意:signal函数在不同Unix系统上的行为有差异。比如在System V上,信号处理完后会恢复为默认行为;在BSD上则不会。这种不可移植性,是我后来改用sigaction的主要原因。

5.2 sigaction函数:更可靠的替代方案

说实话,sigaction()才是现代Linux编程中推荐的方式。它比signal更强大,也更可控。

#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

struct sigaction结构体长这样:

struct sigaction {
    void     (*sa_handler)(int);      // 信号处理函数
    void     (*sa_sigaction)(int, siginfo_t *, void *); // 带更多信息的处理函数
    sigset_t   sa_mask;               // 处理期间要阻塞的信号集
    int        sa_flags;              // 控制标志
    void     (*sa_restorer)(void);    // 已废弃,不用管
};

我项目中常用的做法是:

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

void handler(int sig) {
    write(STDOUT_FILENO, "Signal caught\n", 14);
}

int main() {
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = handler;
    sigemptyset(&sa.sa_mask);  // 不阻塞额外信号
    sa.sa_flags = 0;          // 使用默认行为

    if (sigaction(SIGINT, &sa, NULL) == -1) {
        perror("sigaction");
        return 1;
    }

    while(1) pause();
    return 0;
}
我的习惯:只要不是写一次性测试代码,我都会用sigaction代替signal。它更安全,而且能精确控制信号处理期间的行为。

5.3 信号处理函数的可重入性

这是信号处理中最容易踩坑的地方。什么叫可重入?简单说就是:一个函数在被调用执行的过程中,又被再次调用(比如被信号处理函数中断后再次调用),还能正确运行。

为什么会这样?你想想看,信号处理函数是异步执行的。当主程序正在执行某个函数时,信号来了,处理函数被调用。如果处理函数里又调用了同一个函数,或者访问了同一个全局变量,那就乱套了。

可重入函数 vs 不可重入函数

可重入函数 不可重入函数
只使用局部变量 使用全局或静态变量
不调用不可重入函数 调用malloc/free等
不操作errno(或先保存再恢复) 使用标准I/O(printf等)
原子操作或使用锁保护 依赖全局状态

我曾经在一个网络服务器项目中,信号处理函数里调用了printf()。结果在高并发下,程序偶尔会输出乱码甚至崩溃。排查后发现,printf内部使用了全局缓冲区,信号处理函数和主程序同时操作缓冲区,数据就乱了。

安全做法:信号处理函数里只做最轻量的事——设置一个volatile sig_atomic_t类型的标志变量,然后立即返回。真正的处理逻辑放到主循环里。
volatile sig_atomic_t g_flag = 0;

void handler(int sig) {
    g_flag = 1;  // 只做这件事
}

int main() {
    signal(SIGINT, handler);
    while(1) {
        if (g_flag) {
            g_flag = 0;
            // 在这里做真正的处理
            printf("Handling signal in main loop\n");
        }
        // 其他工作...
    }
}

5.4 信号的发送:kill/raise/alarm/pause

信号不光能接收,还得会发。常用的发送函数有这几个:

  • kill():向指定进程发送信号
  • raise():向自己发送信号
  • alarm():定时发送SIGALRM信号
  • pause():挂起进程,等待信号
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);   // 向pid进程发送sig信号
int raise(int sig);             // 给自己发信号
unsigned int alarm(unsigned int seconds);  // seconds秒后发SIGALRM
int pause(void);                // 等待信号

举个例子,父子进程通信:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>

void child_handler(int sig) {
    write(STDOUT_FILENO, "Child: got signal from parent\n", 30);
}

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程
        signal(SIGUSR1, child_handler);
        pause();  // 等待信号
        _exit(0);
    } else {
        // 父进程
        sleep(1);
        kill(pid, SIGUSR1);  // 向子进程发信号
        wait(NULL);
    }
    return 0;
}
避坑指南:alarm()函数只能设置一个定时器,后一次调用会覆盖前一次。如果需要多个定时器,得用setitimer()或者timer_create()。

5.5 信号集的操作

信号集说白了就是一个位图,用来表示一组信号。Linux提供了几个操作函数:

#include <signal.h>

int sigemptyset(sigset_t *set);    // 清空信号集
int sigfillset(sigset_t *set);     // 填满信号集(包含所有信号)
int sigaddset(sigset_t *set, int signum);   // 添加一个信号
int sigdelset(sigset_t *set, int signum);   // 删除一个信号
int sigismember(const sigset_t *set, int signum); // 判断是否在集中

我习惯这样用:

sigset_t set;
sigemptyset(&set);          // 先清空
sigaddset(&set, SIGINT);    // 添加SIGINT
sigaddset(&set, SIGTERM);   // 添加SIGTERM

if (sigismember(&set, SIGINT)) {
    printf("SIGINT is in the set\n");
}

5.6 sigprocmask:阻塞信号

有时候我们需要临时屏蔽某些信号,等关键操作完成后再放开。这就是sigprocmask()的用武之地。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

how参数有三种:

  • SIG_BLOCK:将set中的信号添加到当前阻塞集
  • SIG_UNBLOCK:从当前阻塞集中移除set中的信号
  • SIG_SETMASK:直接用set替换当前阻塞集

实际场景:

sigset_t new_mask, old_mask;

sigemptyset(&new_mask);
sigaddset(&new_mask, SIGINT);

// 阻塞SIGINT
sigprocmask(SIG_BLOCK, &new_mask, &old_mask);

// 执行关键操作,不会被SIGINT打断
do_critical_work();

// 恢复原来的信号掩码
sigprocmask(SIG_SETMASK, &old_mask, NULL);
注意:阻塞信号不等于忽略信号。被阻塞的信号会排队等待,一旦解除阻塞就会递达。如果你想让信号彻底消失,应该用SIG_IGN。

5.7 sigpending:查看挂起的信号

被阻塞的信号处于"挂起"状态。用sigpending()可以查看当前有哪些信号被挂起:

#include <signal.h>
int sigpending(sigset_t *set);

举个例子:

sigset_t pending_set;

// 先阻塞SIGINT
sigset_t block;
sigemptyset(&block);
sigaddset(&block, SIGINT);
sigprocmask(SIG_BLOCK, &block, NULL);

// 给自己发SIGINT
raise(SIGINT);

// 查看挂起信号
sigpending(&pending_set);
if (sigismember(&pending_set, SIGINT)) {
    printf("SIGINT is pending\n");
}

// 解除阻塞,SIGINT会立即递达
sigprocmask(SIG_UNBLOCK, &block, NULL);

知识体系总览

下面这张图把信号处理的整体脉络梳理清楚了:

信号处理知识体系 信号处理 signal() | sigaction() | 可重入性 信号发送 kill / raise / alarm / pause 信号集操作 sigemptyset / sigfillset / sigaddset 信号控制 sigprocmask / sigpending 发送方式 • kill(pid, sig):向指定进程 • raise(sig):向自己 • alarm(sec):定时SIGALRM • pause():等待信号 信号集函数 • sigemptyset:清空 • sigfillset:填满 • sigaddset:添加 • sigdelset:删除 控制机制 • SIG_BLOCK:阻塞 • SIG_UNBLOCK:解除 • SIG_SETMASK:替换 • sigpending:查挂起 核心原则:信号处理函数中只做最轻量操作,使用volatile sig_atomic_t标志变量

信号处理这块,说白了就是三个核心问题:怎么发、怎么收、怎么控制。发信号用kill/raise/alarm,收信号用signal/sigaction,控制信号用sigprocmask和sigpending。而贯穿始终的,是信号处理函数的可重入性——这是最容易出问题的地方,也是我反复提醒学生要注意的点。

记住一句话:信号处理函数里,能不做的事就别做。实在要做,只设一个标志变量。剩下的,交给主循环去处理。


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