15、信号处理(signal.h):signal函数、raise函数、常见信号(SIGINT、SIGSEGV、SIGTERM)。信号处理函数的限制。

信号处理,说白了就是让程序在运行时能响应突发事件。你想想看,一个程序跑得好好的,突然用户按了Ctrl+C,或者内存访问越界了,总得有个机制来处理吧?C标准库里的signal.h就是干这个的。

我个人习惯把信号理解成「软件中断」。它不像硬件中断那么底层,但机制很像——程序正常执行着,信号一来,立马跳转到处理函数,处理完再回来继续跑。嗯,这里要注意:不是所有信号都能被捕获,也不是所有信号处理方式都安全。

核心要点:信号处理是异步事件机制,用于通知进程发生了某种异常或外部请求。

15.1 常见信号:SIGINT、SIGSEGV、SIGTERM

标准C语言定义了几种基本信号,我挑三个最常用的讲讲。这三个我在项目中都遇到过,尤其是SIGSEGV,调试时没少跟它打交道。

信号 默认行为 触发场景
SIGINT 终止进程 用户按下Ctrl+C
SIGSEGV 终止进程(核心转储) 访问非法内存地址
SIGTERM 终止进程 外部进程请求终止(如kill命令)

SIGINT 是最常见的。你写个死循环程序,按Ctrl+C终止它,发的就是SIGINT。我曾经在一个命令行工具里捕获SIGINT,用来做优雅退出——先把临时文件清理掉,再退出程序,而不是直接暴毙。

SIGSEGV 是程序员的噩梦。野指针、数组越界、解引用空指针,都会触发它。默认行为是终止并生成core文件。我记得有一次线上服务突然挂了,查日志发现是SIGSEGV,最后定位到是一个多线程环境下未加锁的共享变量被写坏了指针。嗯,这种问题最难查。

SIGTERM 是系统管理员常用的。用kill命令不带参数时,默认发的就是SIGTERM。它比SIGKILL温和,因为进程可以捕获它做清理工作。我建议所有长期运行的服务都至少捕获SIGTERM,做资源释放和状态保存。

小技巧:SIGKILL(信号9)不能被捕获或忽略,是最后的杀手锏。能用SIGTERM就别用SIGKILL,给程序一个体面退出的机会。

15.2 signal函数:注册信号处理

signal函数用来注册一个信号处理函数。原型很简单:

#include <signal.h>

void (*signal(int sig, void (*func)(int)))(int);

这个声明看着有点绕。说白了,signal接受两个参数:信号编号和处理函数指针,返回的是之前的处理函数指针。我刚开始学C时也被这个声明搞晕过,后来用typedef就好理解了:

typedef void (*sighandler_t)(int);
sighandler_t signal(int sig, sighandler_t handler);

处理函数可以传三种值:

  • SIG_DFL:恢复默认行为
  • SIG_IGN:忽略该信号
  • 自定义函数:自己处理

来看个实际例子。我写过一个日志采集程序,需要捕获SIGINT做清理:

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

volatile sig_atomic_t keep_running = 1;

void handle_sigint(int sig) {
    // 注意:信号处理函数中只能调用异步信号安全函数
    keep_running = 0;
}

int main() {
    signal(SIGINT, handle_sigint);
    
    while (keep_running) {
        printf("程序运行中...\n");
        sleep(1);
    }
    
    printf("收到SIGINT,执行清理...\n");
    // 这里做资源释放
    return 0;
}

注意我用了volatile sig_atomic_t类型。为什么?因为信号处理函数和主程序是异步执行的,编译器可能优化掉对keep_running的读取。加上volatile告诉编译器:这个变量可能被意外修改,每次用都要重新读。sig_atomic_t保证读写是原子的,不会读到一半被中断。

警告:不要用int代替sig_atomic_t。在某些平台上,int的读写不是原子的,可能读到损坏的值。

15.3 raise函数:主动发送信号

raise函数让进程给自己发信号。原型:

int raise(int sig);

成功返回0,失败返回非0。我一般在测试信号处理逻辑时用它,或者在某些错误条件下主动触发SIGABRT来生成core dump:

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

void handle_sigterm(int sig) {
    printf("收到SIGTERM,准备退出\n");
    // 清理资源...
}

int main() {
    signal(SIGTERM, handle_sigterm);
    
    // 模拟某个条件触发退出
    if (some_error_condition()) {
        printf("发生错误,主动发送SIGTERM\n");
        raise(SIGTERM);
    }
    
    return 0;
}

你可能会问:为什么不直接调用处理函数?因为raise走的是完整的信号投递流程,包括信号屏蔽和递送。这在多线程环境下尤其重要——信号会递送到当前线程,而不是随便哪个线程。

15.4 信号处理函数的限制

这是最容易踩坑的地方。信号处理函数是在异步上下文中执行的,能做的事情非常有限。我曾经在信号处理函数里调了printf,结果程序莫名其妙死锁了。查了半天才发现问题。

信号处理函数中安全的操作只有这些:

  • 读写volatile sig_atomic_t类型的变量
  • 调用异步信号安全函数(async-signal-safe functions)
  • 调用_Exit()_exit()立即终止

不安全的操作包括:

  • 调用printfmallocfree等标准库函数
  • 操作全局数据结构(除非用原子操作)
  • 调用任何可能阻塞的函数
  • 使用锁或互斥量

为什么会这样?因为信号可能在任何时刻到达,包括在malloc内部修改堆管理结构的时候。如果你在信号处理函数里又调malloc,就可能破坏堆的内部状态,导致死锁或崩溃。

黄金法则:信号处理函数中只做最必要的事——设置一个标志变量,然后立即返回。真正的处理逻辑放到主循环中。

POSIX标准定义了异步信号安全函数列表,包括writereadsigaction等。但C标准只保证signal本身和raise是安全的。我个人的做法是:信号处理函数里只写一个volatile sig_atomic_t标志,其他什么都不做。

15.5 知识体系总览

下面这张图总结了信号处理的核心逻辑。我建议你把它记在脑子里,写代码时对照着检查:

信号处理核心知识体系 常见信号 SIGINT - Ctrl+C SIGSEGV - 段错误 SIGTERM - 终止请求 默认行为:终止进程 signal函数 注册处理函数 SIG_DFL / SIG_IGN 自定义回调 返回旧处理函数指针 raise函数 主动发送信号 测试信号处理逻辑 错误时主动终止 返回0成功,非0失败 信号处理函数限制(核心避坑) ✅ 安全操作: • 读写 volatile sig_atomic_t 变量 • 调用异步信号安全函数(如 write) • 调用 _exit() 立即终止 ❌ 危险操作: • printf、malloc、free 等标准库函数 • 操作全局数据结构(非原子) • 使用锁或互斥量 核心原则:信号处理函数只设标志,不做逻辑

15.6 避坑指南与最佳实践

我总结了几条实战经验,都是血泪教训:

  1. 永远不要在信号处理函数里调printf。 我曾经在调试时加了一行printf,结果程序在信号处理时死锁了。printf内部用了锁,而信号可能正好在锁被持有时到达。
  2. 用sigaction代替signal。 signal的行为在不同Unix系统上有差异,而sigaction是POSIX标准且行为一致。虽然C标准只定义了signal,但实际项目中我建议用sigaction。
  3. 信号处理函数要尽量简单。 只设置一个标志变量,然后返回。主循环中检查这个标志,再做实际处理。这样既安全又清晰。
  4. 注意volatile关键字。 没有volatile,编译器可能把变量优化到寄存器里,信号处理函数改了内存值,主循环却读不到。
  5. 多线程环境下要小心。 信号默认递送到任意线程,可以用pthread_sigmask控制每个线程的信号屏蔽字。
我的习惯:写一个全局的volatile sig_atomic_t g_signal_received = 0;,信号处理函数里只做g_signal_received = sig;,主循环里检查并处理。这样既安全又灵活。

信号处理是C语言中一个看似简单实则暗藏玄机的领域。掌握了signal和raise的基本用法,理解了信号处理函数的限制,你就能写出健壮的程序。记住:信号处理函数不是普通函数,它是异步上下文中的「特派员」——只传递信息,不处理业务。


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