10. 信号事件处理:Unix信号基础、信号与事件循环集成、信号安全处理

各位同学,今天我们来聊聊信号。嗯,信号这个东西,在嵌入式开发里就像个不请自来的客人——你永远不知道它什么时候敲门。我早年做网络设备时,就被信号坑过好几次。说白了,信号是Unix/Linux系统里最古老的进程间通信方式之一,但也是最容易出问题的。

10.1 Unix信号基础

信号本质上是一个软件中断。它告诉进程:嘿,有事情发生了!可能是用户按了Ctrl+C,可能是子进程退出了,也可能是定时器到期了。

常见的信号有这么几类:

信号名 编号 默认行为 典型触发场景
SIGINT 2 终止进程 Ctrl+C
SIGTERM 15 终止进程 kill命令默认信号
SIGKILL 9 强制终止 无法捕获或忽略
SIGUSR1 10 终止进程 用户自定义
SIGPIPE 13 终止进程 写管道但读端已关闭
SIGALRM 14 终止进程 alarm()定时器到期

这里有个关键点:SIGKILL和SIGSTOP不能被捕获、阻塞或忽略。这是系统的最后手段,你拿它没办法。

核心概念:信号处理函数是异步执行的。这意味着它可能在你的代码执行到任何位置时突然插入。这就是所有麻烦的根源。

10.2 信号与事件循环集成

好,现在问题来了。我们的事件循环(比如epoll、select)在阻塞等待事件,信号来了怎么办?

我见过不少新手这么写:

// 错误示范:在信号处理函数里直接操作事件循环
void sigint_handler(int sig) {
    // 直接调用事件循环的停止函数
    event_loop_stop();  // 危险!信号处理函数里做这个?
}

为什么危险?因为信号处理函数里能做的事情非常有限。你想想看,信号来的时候,程序可能正拿着某个锁,或者正在malloc分配内存。这时候你再去调用event_loop_stop,里面可能又涉及锁操作——死锁就来了。

正确的做法是什么?我个人的习惯是使用自管道(self-pipe)技巧

// 自管道技巧:信号处理函数只写一个字节到管道
static int signal_pipe[2];

void sigint_handler(int sig) {
    char byte = 1;
    write(signal_pipe[1], &byte, 1);  // 信号安全:只做write
}

int event_loop_init() {
    pipe(signal_pipe);  // 创建管道
    // 将管道的读端加入epoll
    struct epoll_event ev;
    ev.events = EPOLLIN;
    ev.data.fd = signal_pipe[0];
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, signal_pipe[0], &ev);
    
    // 注册信号处理函数
    signal(SIGINT, sigint_handler);
    signal(SIGTERM, sigint_handler);
}

int event_loop_run() {
    while (!stop_flag) {
        int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        for (int i = 0; i < n; i++) {
            if (events[i].data.fd == signal_pipe[0]) {
                // 在主循环里处理信号
                char byte;
                read(signal_pipe[0], &byte, 1);
                if (byte == 1) {
                    stop_flag = 1;
                    printf("收到退出信号,优雅关闭...\n");
                }
            }
            // 处理其他事件...
        }
    }
}

技巧:自管道的本质是把异步信号事件转化为文件描述符事件。这样你的事件循环就能统一处理所有输入源了。我在多个项目里都用这个模式,稳得很。

10.3 信号安全处理

说到信号安全,我得先讲一个血泪教训。我曾经在一个数据采集项目里,信号处理函数里调用了printf来打日志。结果呢?程序偶尔会莫名其妙地卡死。查了两天才发现——printf内部有锁,信号来的时候主程序正拿着那个锁,信号处理函数又去申请同一个锁,死锁了。

所以,信号处理函数里能做什么?我列个清单:

  • 可以安全做的
    • 读写volatile sig_atomic_t类型的变量
    • 调用异步信号安全函数(async-signal-safe functions)
    • write() 系统调用(注意不是fwrite)
  • 绝对不能做的
    • 调用malloc/free
    • 调用printf/fprintf等标准I/O函数
    • 操作任何全局数据结构(除非用原子操作)
    • 调用pthread_mutex_lock等同步原语

这里有个常用的安全模式——延迟处理

// 安全模式:信号处理函数只设置标志
static volatile sig_atomic_t sig_received = 0;

void signal_handler(int sig) {
    sig_received = sig;  // 唯一操作:写一个整数
}

int main() {
    signal(SIGINT, signal_handler);
    signal(SIGTERM, signal_handler);
    
    while (1) {
        // 在主循环中检查标志
        if (sig_received) {
            int sig = sig_received;
            sig_received = 0;  // 清零
            
            // 现在可以安全地做任何事了
            printf("捕获到信号 %d,执行清理...\n", sig);
            // 关闭文件、释放资源、保存状态...
        }
        
        // 正常的事件处理
        // ...
    }
}

警告:volatile关键字不能少!编译器可能会优化掉对sig_received的读取,导致主循环永远看不到信号。sig_atomic_t保证读写是原子的,在大多数平台上就是int类型。

10.4 信号集与信号屏蔽

有时候,你不想在关键代码段被信号打断。比如你在更新一个链表,信号处理函数如果也操作这个链表,那就乱套了。

这时候可以用信号屏蔽:

// 屏蔽信号:保护临界区
sigset_t old_mask, new_mask;
sigemptyset(&new_mask);
sigaddset(&new_mask, SIGINT);
sigaddset(&new_mask, SIGTERM);

// 进入临界区前屏蔽信号
sigprocmask(SIG_BLOCK, &new_mask, &old_mask);

// 执行临界区代码
update_linked_list();

// 恢复原来的信号掩码
sigprocmask(SIG_SETMASK, &old_mask, NULL);

注意:sigprocmask在多线程程序里要换成pthread_sigmask。嗯,这是另一个坑,我当年就踩过。

10.5 知识体系总览

说了这么多,我画张图帮你理清思路:

信号事件处理知识体系 Unix信号基础 • 信号本质:软件中断 • 常见信号:SIGINT/SIGTERM • 默认行为:终止/忽略/暂停 • 不可捕获:SIGKILL/SIGSTOP 事件循环集成 • 自管道技巧(推荐) • 信号→文件描述符转换 • epoll统一事件源 • 主循环中处理信号 信号安全处理 • 异步信号安全函数 • volatile sig_atomic_t • 延迟处理模式 • 信号屏蔽保护临界区 实践流程:信号事件处理完整链路 注册信号处理 信号到来 写管道/设标志 返回 主循环:epoll_wait → 检测管道/标志 → 执行安全处理

这张图把整个流程串起来了。从左到右看:信号来了,处理函数只做最少的操作(写管道或设标志),然后立即返回。主循环在epoll_wait里统一检测,再执行真正的处理逻辑。

核心原则:信号处理函数里做的事情越少越好。最好只写一个volatile变量,或者往管道里写一个字节。所有复杂逻辑都放到主循环里去做。

好了,关于信号事件处理,核心就是这些。记住:信号处理函数不是普通函数,它是异步的、不可重入的、限制重重的。尊重它的特殊性,你的程序才能稳定运行。


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