70、信号处理:signal 函数的使用

信号处理,说白了就是让程序在运行时能响应某些“突发事件”。比如用户按了 Ctrl+C,或者系统发来一个终止指令。默认情况下,这些信号会让进程直接退出。但有时候,我们希望在退出前做点清理工作——保存数据、关闭文件、释放资源。这时候,signal 函数就派上用场了。

我刚开始做嵌入式开发时,对信号处理的理解很浅。总觉得这玩意儿是 Linux 应用层才用的,跟单片机没啥关系。后来做项目时发现,很多 RTOS 也提供了类似的机制。嗯,信号处理其实是一种通用的异步事件处理模型,值得好好掌握。

signal 函数的基本用法

signal 函数的原型很简单:

#include <signal.h>

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

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

  • sig:要捕获的信号编号,比如 SIGINT(2,Ctrl+C)、SIGTERM(15,终止信号)。
  • func:信号处理函数的指针。当信号发生时,系统会调用这个函数。
  • 返回值:返回之前的信号处理函数指针,出错时返回 SIG_ERR

说白了,signal 就是给某个信号“注册”一个处理函数。注册之后,信号来了,系统就不走默认流程了,而是调用你写的函数。

一个简单的例子

我们来看一个最基础的用法:捕获 Ctrl+C。

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

void sigint_handler(int signo) {
    printf("\n捕获到 SIGINT 信号 (信号编号: %d)\n", signo);
    printf("按 Ctrl+C 不会退出,试试看\n");
}

int main() {
    // 注册信号处理函数
    if (signal(SIGINT, sigint_handler) == SIG_ERR) {
        perror("signal 注册失败");
        return 1;
    }

    printf("程序运行中... 按 Ctrl+C 试试\n");

    // 让程序一直跑着
    while (1) {
        pause();  // 等待信号
    }

    return 0;
}

运行这段代码,你按 Ctrl+C 不会退出程序,而是打印一条消息。为什么会这样?因为 SIGINT 的默认行为被我们替换成了自定义函数。

小提示:在信号处理函数中,尽量只做简单的事情。比如打印一条消息、设置一个标志位。不要调用 printf 这类不可重入的函数——虽然很多例子都这么写,但严格来说是不安全的。我一般只在调试时这么用。

信号处理函数的限制

这里有个坑,我踩过不止一次。信号处理函数是在中断上下文中执行的,能做的事情非常有限。具体来说:

  • 不要调用不可重入函数:比如 mallocfreeprintffopen 等。这些函数内部可能使用了全局状态,信号处理函数一调用,容易把主程序搞崩。
  • 不要做耗时操作:信号处理函数应该尽快返回。如果卡在里面,其他信号可能被阻塞。
  • 注意全局变量的访问:如果要在信号处理函数和主程序之间共享数据,记得用 volatile sig_atomic_t 类型。这个类型保证读写是原子的,不会被打断。
警告:我曾经在一个项目中,信号处理函数里调用了 free,结果程序跑着跑着就莫名其妙地崩溃了。查了两天才发现是信号处理函数和主线程同时操作了堆内存。从那以后,我信号处理函数里只做两件事:设置标志位,或者写一个简单的管道通知主循环。

信号处理的核心逻辑

下面这张图展示了信号从产生到被处理的完整流程:

信号处理流程 信号产生 (Ctrl+C / kill / 硬件异常) 是否注册了 处理函数? 调用自定义处理函数 (用户注册的 handler) 继续执行 / 退出 执行默认行为 (终止 / 忽略 / 暂停) 进程终止 信号处理流程:信号产生 → 判断是否注册处理函数 → 执行自定义逻辑或默认行为

你想想看,这个流程其实很清晰。信号来了,系统先查一下有没有注册处理函数。有,就调用你的函数;没有,就走默认行为。默认行为对大多数信号来说是终止进程。

常见的信号及其默认行为

信号 编号 触发条件 默认行为
SIGINT 2 Ctrl+C 终止进程
SIGTERM 15 kill 命令默认发送 终止进程
SIGKILL 9 kill -9 终止进程(不可捕获)
SIGSTOP 19 Ctrl+Z 暂停进程(不可捕获)
SIGSEGV 11 段错误(非法内存访问) 终止进程 + 生成 core dump
SIGPIPE 13 向已关闭的管道写数据 终止进程

注意,SIGKILLSIGSTOP 是不能被捕获或忽略的。这是系统的安全机制——总得留一个“终极手段”来干掉失控的进程。

实际项目中的用法

在嵌入式系统中,信号处理常用于优雅退出。比如一个长期运行的数据采集程序,收到 SIGTERM 时,应该先关闭文件、释放设备、保存最后一批数据,然后再退出。

我习惯这样写:

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

volatile sig_atomic_t g_quit_flag = 0;

void sigterm_handler(int signo) {
    // 只设置标志位,不做其他操作
    g_quit_flag = 1;
}

int main() {
    // 注册 SIGTERM 处理函数
    if (signal(SIGTERM, sigterm_handler) == SIG_ERR) {
        perror("signal");
        exit(1);
    }

    // 也可以同时处理 SIGINT
    if (signal(SIGINT, sigterm_handler) == SIG_ERR) {
        perror("signal");
        exit(1);
    }

    printf("程序启动,PID: %d\n", getpid());
    printf("发送 kill %d 或按 Ctrl+C 可优雅退出\n", getpid());

    // 主循环
    while (!g_quit_flag) {
        // 正常的工作逻辑
        printf("工作中...\n");
        sleep(1);
    }

    // 清理工作
    printf("\n收到退出信号,开始清理...\n");
    // 关闭文件、释放设备、保存数据
    printf("清理完成,再见!\n");

    return 0;
}
关键点:使用 volatile sig_atomic_t 类型的全局标志位。这个类型保证:
  • 读写是原子的,不会被信号处理打断。
  • 编译器不会优化掉对这个变量的访问(volatile 的作用)。
主循环中不断检查这个标志位,一旦被设置,就跳出循环执行清理逻辑。

signal 的局限性

说实话,signal 函数在 POSIX 标准中已经被标记为“过时”了。为什么?因为它有一些历史遗留问题:

  • 行为因系统而异:在 System V 系统中,信号处理函数执行完后,信号的处理方式会被重置为默认行为。你需要重新注册。而在 BSD 系统中则不会。这就导致了可移植性问题。
  • 信号可能被阻塞:在处理一个信号时,如果同一个信号再次到来,可能会被丢失。
  • 不支持信号排队:多个相同的信号到来,只会处理一次。

所以,在实际项目中,我更推荐使用 sigaction 函数。它更可靠,功能也更强大。不过 signal 胜在简单,适合快速原型和教学场景。

我的建议:如果你在写新代码,直接用 sigaction。但如果你在维护老代码,或者需要兼容非常古老的系统,signal 还是能用的。记住一点:signal 是“能用”,sigaction 是“好用”。

避坑指南

最后,分享几个我踩过的坑:

  • 不要在信号处理函数中调用 exit:这会导致一些清理函数(比如 atexit 注册的)无法正常执行。用 _exit 代替。
  • 注意信号处理函数的可重入性:如果你在信号处理函数中修改了全局变量,主程序也在用这个变量,记得加锁。但信号处理函数中加锁又容易死锁——所以最好的办法就是用 sig_atomic_t 类型。
  • 不要忽略 SIGCHLD:如果你 fork 了子进程,忽略 SIGCHLD 会导致子进程变成僵尸进程。要么捕获它,要么用 wait 系列函数回收。
  • SIGPIPE 要小心:在网络编程中,如果对端关闭了连接,你还在写数据,就会收到 SIGPIPE。默认行为是终止进程。我一般会忽略它,或者捕获后做适当处理。

嗯,信号处理这块内容说多不多,说少不少。核心就是理解“异步事件”这个概念,以及如何在受限的环境中安全地响应它。掌握了 signal,再去看 sigaction 就会轻松很多。


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