做Linux服务端开发、嵌入式开发或者系统编程,进程信号(Signal)迟早要正面刚。很多人对信号捕捉的理解停留在“用signal()注册一个函数,信号来了就调一下”,但对背后内核态和用户态的切换过程完全没概念。结果就是:信号处理函数里写了printf,跑了几天进程莫名其妙崩了;注册了SIGCHLD却收不到子进程状态;用gdb调试时信号处理函数的执行顺序跟想象完全不一样。这篇文章想把Linux下进程信号的捕捉过程,以及信号处理过程中用户态和内核态的切换关系讲透,给正在搞系统编程的朋友一份可以直接参考的实操笔记。
从“会用到信号”到“真正理解信号”,中间隔着的正是那个完整生命周期:产生、注册、递达、处理,以及围绕它发生的多次状态切换。把这块啃下来,很多线上疑难杂症都能少踩一半。
1. 先从基础说起:信号到底是什么
1.1 信号的本质与分类
信号是操作系统提供的一种软件中断机制,用来异步通知进程发生了某个事件。它不携带数据,只携带一个编号,每个编号代表一种事件。进程收到信号后,可以选择执行默认动作,也可以忽略,还可以调用自定义的处理函数。
在Linux里,信号分为两大阵营:标准信号(编号1到31)和实时信号(编号32到64)。标准信号是传统 Unix 就有的,它们不排队,也就是说,如果同一个标准信号在未决期间反复产生,进程最终只会收到一次。实时信号是POSIX后来补充的,支持排队,支持附带数据,适合需要精确事件计数的场景。
常用信号需要背下来几个:
| 信号 | 编号 | 默认动作 | 典型触发场景 |
|---|---|---|---|
| SIGHUP | 1 | 终止进程 | 终端挂断,或网络断开 |
| SIGINT | 2 | 终止进程 | 终端按 Ctrl+C |
| SIGKILL | 9 | 强制终止 | kill -9,不可捕获不可阻塞 |
| SIGSEGV | 11 | 终止并产生core | 非法内存访问 |
| SIGTERM | 15 | 终止进程 | kill 默认信号 |
| SIGCHLD | 17 | 忽略 | 子进程停止或退出 |
| SIGSTOP | 19 | 暂停进程 | 不可捕获不可阻塞 |
有两个信号比较特殊:SIGKILL和SIGSTOP,它们既不能被阻塞,也不能被捕获,内核直接执行默认动作。这是系统的最后防线,防止有人写个处理函数把kill -9都拦下来,那就真没法关机了。
1.2 信号的一生:产生、注册、递达、处理
理解信号捕捉之前,得先过一遍信号的完整生命周期。一个信号从产生到被处理,通常经过四个阶段。
第一阶段是“产生”。来源很多,比如用户按下Ctrl+C会产生SIGINT,子进程退出会给父进程发SIGCHLD,进程自己访问了非法内存会产生SIGSEGV,还可以用kill()系统调用主动向进程发信号。
第二阶段是“注册”,在Linux里更准确地叫“记录未决”。内核收到信号后,并不会立刻去处理,只是在该进程的task_struct里把对应的pending位图置位。这个位图就是信号的“待办清单”,标记哪些信号已经到达但没有处理。如果信号被进程设置了阻塞,那么它就会一直停留在pending状态,直到解除阻塞。
第三阶段是“递达”,也就是内核决定把信号真正交给进程处理。递达的时机很关键,它并不是信号一产生就立刻递达,而是发生在进程从内核态返回用户态之前。什么情况下会从内核态返回用户态?典型就是系统调用返回、中断处理返回、异常处理返回。换句话说,信号递达是挂在“内核准备回用户空间”这个检查点上的。
第四阶段是“处理”。如果这个信号没有被捕获,就执行默认动作,比如终止进程、停止进程。如果被捕获了,就切换到用户态执行我们注册的信号处理函数。
为什么信号处理要放到用户态去执行?这是核心中的核心,后面专门讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 信号捕捉:应用程序该怎么做
2.1 signal() 与 sigaction() 怎么选
应用程序注册信号处理函数,最原始的方式是调用signal()。它的原型很简单:
c复制#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
一个典型的用法是这样:
c复制#include <stdio.h>
#include <signal.h>
#include <unistd.h>
static void handler(int sig) {
// 注意:这里只做最简单的操作
char msg[] = "catch SIGINT\n";
write(STDOUT_F
