函数与信号处理:signal函数的注册,信号处理函数的限制,异步信号安全函数

信号处理,说白了就是操作系统给进程发了个“软中断”。你正在跑一个循环,突然来了个SIGINT(Ctrl+C),程序怎么响应?这就靠signal函数来注册处理逻辑了。

我个人习惯把信号处理比作“紧急电话”。平时程序按部就班执行,信号一来,就像电话铃响了,你得放下手头的事去接。但这个“接电话”有很多规矩,搞不好就会出大问题。

signal函数的注册机制

signal函数的原型很简单:

#include <signal.h>
void (*signal(int sig, void (*func)(int)))(int);

嗯,这个声明看着有点绕。我拆开给你看:

  • sig:要捕获的信号编号,比如SIGINT、SIGTERM
  • func:处理函数的指针,参数是int,返回void
  • 返回值:之前的处理函数指针

实际用的时候,我们通常这样写:

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

void handler(int sig) {
    // 注意:这里不能做太多事
    write(STDOUT_FILENO, "Caught signal!\n", 15);
}

int main() {
    // 注册信号处理函数
    signal(SIGINT, handler);
    
    while(1) {
        pause();  // 等待信号
    }
    return 0;
}

我在项目中遇到过一个问题:有些系统上signal的行为不一致。比如早期的System V,signal注册后处理完一次就恢复成默认行为。而BSD则保持注册状态。所以我现在更推荐用sigaction,它行为明确。

关键点:signal注册的是“信号到来时做什么”,不是“信号到来后恢复什么”。处理函数执行期间,同类型信号可能会被阻塞,也可能不会——这取决于实现。

信号处理函数的限制

这是个大坑。信号处理函数不是在正常的程序流中执行的。它可能发生在任何时刻——你正在malloc分配内存,正在printf输出,甚至正在修改全局链表。

所以,信号处理函数里能做的事非常有限:

  1. 不能调用不可重入函数——比如malloc、free、printf、fprintf
  2. 不能操作全局或静态数据——除非这些数据是volatile sig_atomic_t类型
  3. 不能修改errno——除非你保存和恢复它
  4. 不能调用任何标准I/O函数——因为它们内部有缓冲区,可能正在被主程序使用

为什么会这样?你想想看,如果主程序正在malloc里操作堆链表,信号来了,处理函数里又调了malloc——堆链表就乱套了。这就是不可重入。

我曾经在一个嵌入式项目中,信号处理函数里调了printf来打日志。结果程序偶尔会莫名其妙地卡死。查了两天才发现,printf内部用了全局锁,主程序在printf里被信号打断,处理函数又去抢同一把锁——死锁了。

那信号处理函数里到底能做什么?我总结了几条:

  • 设置一个volatile sig_atomic_t标志变量
  • 调用异步信号安全函数(后面讲)
  • 直接return(啥也不做)
  • 调用_exit()或_Exit()终止程序

看个例子:

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

volatile sig_atomic_t flag = 0;

void handler(int sig) {
    flag = 1;  // 唯一能做的事
}

int main() {
    signal(SIGINT, handler);
    
    while(!flag) {
        // 主循环做自己的事
    }
    
    printf("Got signal, cleaning up...\n");
    // 这里可以安全地清理资源
    return 0;
}

注意flag用了volatile sig_atomic_t。volatile告诉编译器不要优化掉对flag的读写,sig_atomic_t保证读写是原子的。在大多数平台上,sig_atomic_t就是int。

异步信号安全函数

POSIX标准定义了一批“异步信号安全”的函数。这些函数可以在信号处理函数里安全调用。说白了,它们是可重入的,或者内部不依赖全局状态。

我整理了一份常用列表:

类别 函数 说明
文件I/O read, write, open, close 注意:不是stdio的fread/fwrite
进程控制 _exit, _Exit, getpid, kill _exit不会刷新缓冲区
信号操作 signal, sigaction, sigprocmask 但小心递归调用
内存操作 memcpy, memset, memmove 前提是操作的不是共享数据
其他 sleep, alarm, time, gettimeofday sleep在信号处理后可能提前返回

我个人习惯:在信号处理函数里只用write和_exit。write直接调用系统调用,不经过缓冲区,安全可靠。其他操作都通过标志变量交给主循环处理。

看一个实际例子:

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

volatile sig_atomic_t got_signal = 0;

void safe_handler(int sig) {
    const char *msg = "Signal received, will exit\n";
    // write是异步信号安全的
    write(STDOUT_FILENO, msg, strlen(msg));
    got_signal = 1;
}

int main() {
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = safe_handler;
    sigaction(SIGINT, &sa, NULL);
    
    while(!got_signal) {
        // 正常工作
    }
    
    _exit(0);
}

这里我用sigaction代替了signal。sigaction更可控,可以指定sa_flags,比如SA_RESTART让被中断的系统调用自动重启。

避坑指南:errno是个全局变量。如果信号处理函数里调用了可能设置errno的函数(比如write),它会覆盖主程序的errno。所以要么在信号处理函数里保存和恢复errno,要么确保主程序不依赖errno的精确值。

嗯,说到这我想起一个经典问题:信号处理函数里能不能调用longjmp?技术上可以,但非常危险。如果信号是在某个不完整的操作中到达的,longjmp跳走后,那个操作的状态就悬空了。我建议你除非万不得已,别这么干。

最后,我画了一张图来总结信号处理的整个流程:

信号处理流程与安全边界 主程序执行 malloc, printf, 链表操作... 信号到达 SIGINT, SIGTERM... 信号处理函数 只能做有限操作 ✅ 安全操作 write, _exit, sig_atomic_t ❌ 危险操作 malloc, printf, free 返回主程序 从断点处继续执行 信号处理函数执行期间,主程序被暂停,处理完后恢复

这张图把整个流程串起来了。主程序正常执行,信号来了就跳转到处理函数。处理函数里只能走左边的安全路径,不能走右边的危险路径。处理完要么设置标志返回,要么直接_exit。

记住一句话:信号处理函数里,能少做就少做。把复杂逻辑留给主循环去处理。这是我在多年嵌入式开发中总结出的最实用的经验。

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