在Linux系统编程中,信号(Signal) 是进程间通信(IPC)中一种非常古老且重要的机制。它本质上是一种异步的事件通知机制。很多时候,我们看到程序崩溃报错"Segmentation fault"或者按 Ctrl+C 终止程序,背后其实都是信号在起作用。

一、 什么是信号?
1.1 基本概念
信号是Linux系统提供的一种向指定进程发送特定事件的方式。
本质 :一种异步的事件通知机制。
- 异步:进程不知道信号什么时候会来,进程正在执行自己的代码,信号随时可能到达。
作用 :告诉进程发生了什么事,而不是告诉进程现在立刻去做什么。
特点:
多种事件可以同时发生,彼此互不影响。
进程收到信号后的常见结果:终止、暂停、忽略等。
1.2 信号的生命周期
信号不是产生后立刻就被处理的,它有一个完整的过程:
-
信号产生 (Generation):由用户、OS或硬件产生。
-
信号保存 (Preservation):信号产生后,如果进程正在处理更重要的事情(如处于内核态),信号需要被暂时"记下来"。
-
信号处理 (Delivery):在合适的时间,进程处理该信号。
思考 :为什么要保存?
答案:因为信号产生是异步的,进程收到信号时可能正忙,无法立即处理,所以必须先在内核中将其保存下来,等到合适的时候再处理。
二、 信号的产生方式
信号的来源非常广泛,主要可以归纳为以下几种:
- 键盘 -- IO硬件
- 异常 --- 硬件 cpu,mmu
- 系统命令 --- 用户
- 系统调用 --- 开发者
- 软件条件 --- 系统(定时器设计)
2.1 通过终端按键产生
这是我们最熟悉的方式,通常用于控制前台进程。
-
Ctrl + C :发送 2 号信号 (SIGINT),默认动作是终止进程。
-
Ctrl + \:发送 3 号信号 (SIGQUIT),默认动作是终止进程并Core Dump。
-
Ctrl + Z:发送 19 号信号 (SIGSTOP),暂停进程。
细节 :键盘产生的信号只能发给前台进程。后台进程无法获取键盘输入,因此无法响应这些快捷键。
2.2 通过系统命令产生
-
kill -l:查看系统支持的所有信号。
-
1-31:普通信号(我们主要研究的对象)。
-
34-64:实时信号。
-
注意:没有0号信号。
-
-
kill -signum pid:向指定进程发送指定信号。
2.3 通过硬件异常产生
这是程序崩溃的主要原因。当硬件检测到错误时,会通知内核,内核再向当前进程发送信号。
-
除0错误:CPU运算单元检测到异常 -> 通知OS -> OS发送 8 号信号 (SIGFPE, Floating Point Exception)。
-
野指针/越界:MMU(内存管理单元)检测到非法访问 -> 通知OS -> OS发送 11 号信号 (SIGSEGV, Segmentation Fault)。
核心原理:
CPU或MMU出现硬件错误。
OS作为硬件管理者,必须知道硬件报错。
OS定位到当前是谁(哪个进程)在使用硬件。
OS向该进程写入特定的信号,进程随之崩溃退出。
2.4 通过软件条件产生
发送信号的方式很多,围绕者用户,硬件,软件各种场景展开,借助OS之手向目标进程"写"信号。软件条件 不满足时也会产生信号。最典型的例子就是管道 (Pipe)。
管道读写异常 (SIGPIPE)
-
场景 :
有一个匿名管道,读端(Reader)把文件描述符关闭了,但是写端(Writer)还在一直尝试往管道里写数据。
-
结果 :
这是一个非法的软件行为(没有人读,写就没有意义)。操作系统会识别到这个软件异常,并向写端进程发送 13号信号 (SIGPIPE)。
-
动作 :
SIGPIPE 的默认动作是终止进程。这也就是为什么在命令行管道中,如果后面的 grep 挂了,前面的 cat 也会跟着挂掉的原因
三、 信号的保存:内核中的位图
问题:信号保存在哪里?怎么保存?
3.1 保存位置
信号是保存在进程的 PCB (Process Control Block) 中的,即Linux下的 task_struct 结构体。
3.2 保存结构:位图 (Bitmap)
在 task_struct 中,有一个类似于 unsigned int sigs 的字段(实际结构更复杂,但逻辑一致)。
-
比特位的位置 :代表信号的编号(如第2位代表2号信号)。
-
比特位的内容 (0或1):代表是否收到该信号。
3.3 谁来修改这个位图?
只有操作系统 (OS) !
无论信号是由键盘、命令还是硬件产生的,最终都必须由OS将对应的比特位由 0 置为 1。
- 结论 :发送信号的本质,就是OS向目标进程的 task_struct 中写入位图的过程。
四、 信号的处理
当信号被保存后,进程会在合适的时候(通常是内核态返回用户态时)检测并处理信号。处理方式有三种:
4.1 默认动作 (Default)
大多数信号的默认动作是终止进程(Terminate)。例如 SIGINT、SIGKILL。
4.2 忽略动作 (Ignore)
进程收到信号后,什么也不做。
- 设置方式:signal(SIGINT, SIG_IGN);
4.3 自定义捕捉 (Catch / Custom Handler)
程序员可以提供一个函数,当信号发生时,让OS调用这个函数。
API 接口:signal
cpp
#include <signal.h>
// sighandler_t 是一个函数指针: void (*)(int)
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signum:要捕捉的目标信号(如 SIGINT)。
handler:回调函数,当信号产生时由OS调用。
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int signo) {
std::cout << "收到一个信号: " << signo << ",但我就是不退出!" << std::endl;
}
int main() {
// 注册信号捕捉,只需要调用一次
signal(SIGINT, handler);
while(true) {
std::cout << "Test signal..." << std::endl;
sleep(1);
}
return 0;
}
- 现象:运行该程序后,按下 Ctrl+C,进程不会退出,而是打印 handler 函数中的内容。
4.4 无法被捕捉的信号
9号信号 (SIGKILL) 是管理员信号,具有至高无上的权力。
-
它不可被自定义捕捉。
-
它不可被忽略。
-
原因:如果所有信号都能被捕捉或忽略,这就可能产生一个永远无法杀死的恶意进程,OS将失去对系统的控制权。
五、 信号分类

1. 信号的分类:普通信号 vs 实时信号
图片中展示了两大类信号,虽然看起来只是一堆数字,但底层实现机制完全不同:
1 ~ 31号信号(普通信号)
特点:非可靠信号。
存储结构 :使用 位图 (Bitmap) 保存。在进程的 task_struct 中,用一个 unsigned int(32位)的比特位来标记是否收到了该信号。
缺陷 :因为位图只能标记"有"或"无"(0或1),所以如果同一个信号在短时间内被发送多次,进程还没来得及处理,那么后续的信号就会丢失。
用途:分时操作系统中的常规事件通知(如键盘中断、程序崩溃、终止进程)。
34 ~ 64号信号(实时信号)
特点:可靠信号。
存储结构 :使用 队列 (Queue) 保存。
优势:信号不会丢失。如果发送5次,队列里就挂5个节点,进程会处理5次。
用途:主要用于对实时性要求高的工业控制、底层驱动开发等领域。
注意 :Linux中没有 0号信号,也没有 32、33号信号(这两个通常被NPTL线程库内部保留使用了)。
2. 重点标注信号的"前世今生"(硬件与内核的联动)
图片中用绿色方框和箭头重点指出了 8号 和 11号 信号,这两个信号是程序崩溃(Core Dump)最常见的原因,它们体现了硬件异常如何转化为软件信号的过程。
8) SIGFPE (Floating Point Exception)
字面意思:浮点数异常。
实际触发 :不仅仅是浮点数,整数除以0 也是发这个信号。
底层硬件关系:
CPU内部的 ALU (算术逻辑单元) 执行除法指令时,发现除数为0。
ALU 触发硬件中断(Trap)。
CPU 状态切换到内核态,OS 捕获到这个硬件异常。
OS 识别出是哪个进程正在使用CPU。
OS 向该进程发送 SIGFPE 信号(修改该进程PCB中的位图)。
进程收到信号,默认动作是终止并报错 Floating point exception。
11) SIGSEGV (Segmentation Violation)
字面意思:段违例(即段错误)。
实际触发:野指针、空指针解引用、数组越界、试图修改只读内存(比如修改代码段)。
底层硬件关系:
进程试图通过虚拟地址访问内存。
MMU (内存管理单元) 在进行"虚拟地址 -> 物理地址"转换时,发现映射失败(地址不存在)或者权限检查失败(试图写只读页)。
MMU 触发硬件异常。
OS 捕获异常,确认是哪个进程非法访问。
OS 向该进程发送 SIGSEGV 信号。
进程收到信号,默认动作是终止并报错 Segmentation fault。
3. 其他关键信号的映射关系
除了框出来的两个,图片中还有几个必须掌握的"明星信号":
2) SIGINT
硬件来源:键盘。
触发:用户按下 Ctrl + C。
作用:中断前台进程。
3) SIGQUIT
硬件来源:键盘。
触发:用户按下 Ctrl + \。
作用:退出进程并生成 Core Dump 文件(用于事后调试)。
9) SIGKILL
特殊地位 :管理员信号。
权限 :不可被捕捉,不可被忽略,不可被阻塞。
作用:一击必杀。当进程卡死或中毒,无法响应普通信号时,用它强制杀死。
19) SIGSTOP
特殊地位:用于暂停进程(挂起)。
权限 :同样不可被捕捉,不可被忽略。
触发:用户按下 Ctrl + Z。
17) SIGCHLD
关系:父子进程机制。
触发:当子进程结束(exit)或暂停时,会自动给父进程发这个信号。
作用:父进程收到后,负责回收子进程的僵尸状态(wait/waitpid)。
六、深度问答与细节
Q1: 为什么除0会死循环?
如果在代码中写了 int a = 10/0; 并捕捉了 SIGFPE 信号,且在 handler 中没有退出进程,你会发现 handler 被疯狂调用。
原因:
CPU执行除0指令出错。
OS发送 SIGFPE。
进程处理信号,执行 handler。
handler返回,OS将控制权交还给进程,通过保存的寄存器(EIP/RIP)跳回刚才出错的那行指令继续执行。
CPU再次执行除0,再次报错......形成死循环。
Q2: 进程如何知道自己收到了信号?
进程不需要一直盯着看。OS会在进程从内核态切换回用户态的时候(例如系统调用返回时、时钟中断返回时),顺便检查 task_struct 中的信号位图。如果发现有位为1,就去处理。
Q3: 为什么说信号处理是进程自己做的?
虽然信号是OS写入的,但处理动作(比如执行自定义的 handler 函数)是由进程的主控制流(或者说在该进程的上下文中)执行的。
七、 核心转储 (Core Dump)
7.1 Term 与 Core 的区别
通过 man 7 signal 查看信号手册时,我们会发现信号的处理动作(Action)栏中,有的写的是 Term,有的写的是 Core。

-
Term (Termination):单纯的终止进程。操作系统直接回收资源,进程消失。
-
Core (Core Dump) :终止进程 + 核心转储 。进程不仅退出了,OS还会把进程当前内存中的数据(包括堆栈信息、全局变量等)原样"转储"到磁盘上,生成一个调试文件(通常叫 core 或 core.pid)。
为什么要 Core Dump?
核心转储是为了事后调试 (Post-mortem Debugging)。当服务器程序在半夜运行奔溃时,开发者不在场。有了 core file,我们就可以用 GDB 还原案发现场,定位到程序是在哪一行代码、因为什么原因挂掉的。
7.2 如何开启 Core Dump
在很多生产环境或云服务器中,Core Dump 功能默认是关闭 的。
我们可以使用 ulimit -a 命令查看当前资源限制:

开启方式:使用 ulimit -c 设置允许生成的 core 文件大小。

开启后,当程序触发 SIGFPE(浮点异常)、SIGSEGV(段错误)等信号时,就会在当前目录下生成 core 文件。
7.3 如何验证与调试
-
验证 :编写一个除0错误或野指针的程序,运行后会看到提示:
cpp#include<stdio.h> #include<signal.h> void handler(int sig) { printf("catch a sig : %d\n", sig); } int main() { //signal(SIGFPE, handler); // 8) SIGFPE int a = 10; a/=0; return 0; }
-
调试:使用 GDB 加载 core 文件(core文件如果未生成,可能是Ubuntu 系统中core 文件被 apport 服务接管):
bashgdb 可执行程序 core-file-nameGDB 会直接跳到导致崩溃的那一行代码。
7.4 父进程如何知道子进程发生了 Core Dump?
我们在学习 waitpid 时知道,status 参数是一个输出型参数,它实际上是一个位图。

-
低7位:表示终止信号。
-
第8位 (Bit 7) :Core Dump 标志位。如果该位为1,说明子进程发生了核心转储。
八、 发送信号的系统调用
除了键盘和硬件异常,我们在代码中也可以通过系统调用主动给进程发送信号。发送信号的本质,依然是向目OS标进程的PCB位图中写入信号。
8.1 kill 函数
不仅仅是命令行命令,kill 也是一个系统调用,可以给任意进程发送任意信号。
cpp
#include <signal.h>
int kill(pid_t pid, int sig);
-
功能:向进程 pid 发送信号 sig。
-
返回值:成功返回0,失败返回-1。
8.2 raise 函数
cpp
#include <signal.h>
int raise(int sig);
-
功能 :给当前进程自己(Caller)发送信号 sig。
-
本质:等价于 kill(getpid(), sig);
8.3 abort 函数
cpp
#include <stdlib.h>
void abort(void);
-
功能:引起程序的异常终止。
-
本质 :给当前进程发送 6号信号 (SIGABRT)。
-
特点:abort() 类似于 exit(),但它是一种更激烈的退出方式,通常会触发 Core Dump(如果开启的话)。
九、alarm 函数与定时器
9.1 alarm 接口
cpp
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
-
功能 :设置一个"闹钟"。告诉内核在 seconds 秒后,向当前进程发送 14号信号 (SIGALRM)。
-
默认动作 :SIGALRM 的默认动作是终止进程。
-
返回值:
-
如果之前有没响的闹钟,返回上一个闹钟的剩余时间。
-
如果之前没有闹钟,返回 0。
-
-
一次性:alarm 设置的闹钟是一次性的,响一次就没了。
-
取消闹钟:调用 alarm(0) 可以取消当前未响的闹钟。
9.2 理解闹钟
这就好比你对操作系统说:"5秒后提醒我。" OS 记下了这个事,5秒一到,OS 就像扔砖头一样扔过来一个 SIGALRM 信号,把你(进程)砸死(终止)。当然,你可以通过 signal(SIGALRM, handler) 自定义捕捉这个信号,实现定时任务。
十、 内核如何管理定时器? (数据结构揭秘)
问题:操作系统中可能运行着成千上万个进程,每个进程都可能调用 alarm 设置闹钟。OS 怎么知道哪个闹钟先响?怎么高效管理这些闹钟?
10.1 先描述,再组织
在内核中,每个闹钟本质上是一个结构体对象(例如 struct timer):
cpp
struct timer {
uint64_t expired_time; // 绝对过期时间 = 当前时间 + seconds
void (*callback)(); // 回调函数/处理动作
// ... 其他属性
};
10.2 数据结构:最小堆 (Min-Heap)
操作系统不需要检查所有的闹钟,它只需要检查那个最快要过期的闹钟。如果最快要过期的都没过期,其他的肯定也没过期。
-
组织方式 :OS 通常使用 最小堆 (Min-Heap) 来组织这些定时器结构体。
- 堆顶元素:永远是过期时间最小(最早触发)的那个闹钟。
-
检查逻辑:OS 只需要周期性地(比如每次时钟中断)检查堆顶元素:
-
if (current_time >= heap[0].expired_time) -> 触发信号,弹出堆顶,继续检查新的堆顶。
-
else -> 没到时间,不用管后面的,直接返回。
-
总结:
alarm 本质是让OS创建一个定时器节点。
OS 使用最小堆高效管理这些节点,保证查找最近超时任务的时间复杂度为
O(1),插入和删除为 )O(logN)。
时间一到,OS 发送 SIGALRM 信号。
十一、 信号的保存:三张表 (Block, Pending, Handler)
11.1 核心概念:阻塞、未决、递达
在深入数据结构之前,我们必须先理清三个非常容易混淆的概念:
-
信号递达 (Handler):实际执行信号处理动作的过程(例如执行默认动作、忽略或调用自定义Handler)。
-
信号未决 (Pending):信号从产生到递达之间的状态。简单来说,就是信号已经产生了,但进程还没来得及(或暂时不愿)处理它。
-
信号阻塞 (Block):进程可以选择阻塞(屏蔽)某个信号。
-
关键点 :被阻塞的信号产生时,会一直保持在未决状态 (Pending),直到进程解除对此信号的阻塞,才执行递达动作。
-
区别 :阻塞 和忽略是完全不同的。
-
阻塞:信号根本没被处理,还在排队(Pending位为1)。
-
忽略:信号已经被处理了(递达了),只是处理的动作是"什么都不做"。
-
-
11.2 内核中的三张表
在进程的 PCB (task_struct) 中,维护了三张核心的表来管理信号:

-
Block 表(信号屏蔽字):
-
本质:位图 (sigset_t-- unsigned long)。
-
含义 :比特位的位置代表信号编号,内容(0或1)代表该信号是否被阻塞。
-
图解:如果第2位是1,说明2号信号被屏蔽了。
-
-
Pending 表(未决信号集):
-
本质:位图 (sigset_t-- unsigned long)。
-
含义 :比特位的位置代表信号编号,内容(0或1)代表该信号是否已产生且未被处理。
-
图解:如果第2位是1,说明收到了一个2号信号,正在等待处理。
-
-
Handler 表(处理方法表):
-
本质:函数指针数组 (sighandler_t handler[32])。
-
含义:数组下标对应信号编号(下标 = 信号 - 1),数组内容是处理该信号的函数地址。
-
类型:SIG_DFL (默认), SIG_IGN (忽略), 或用户自定义函数地址。
-
11.3 信号处理的逻辑流
当一个信号产生时:
-
OS修改 Pending表,将对应位置1。
-
在合适的时候,OS检查 Block表。
-
如果 Block对应位为1:虽然Pending是1,但被挡住了,无法递达,保持Pending状态。
-
如果 Block对应位为0 :信号可以通过,OS根据 Handler表 找到处理函数,执行递达动作,并将 Pending位清0。
注意:常规信号(1-31)在递达之前产生多次只计一次(因为位图只能存0或1);而实时信号可以排队。
十二、 信号集操作函数
由于 Block 和 Pending 表都是位图结构,依赖于具体的系统实现,OS 不希望用户直接通过位运算操作这些数据。因此,OS 提供了一套专门的数据类型 sigset_t 和对应的操作函数。
12.1 sigset_t 类型
这是一个系统定义的数据类型,用于表示信号集。在用户层,我们可以把它看作是一个黑盒,只能用API去操作它。
12.2 信号集操作 API
cpp
#include <signal.h>
int sigemptyset(sigset_t *set); // 清空(全0)
int sigfillset(sigset_t *set); // 填满(全1,包含所有信号)
int sigaddset(sigset_t *set, int signum); // 添加一个信号(置1)
int sigdelset(sigset_t *set, int signum); // 删除一个信号(置0)
int sigismember(const sigset_t *set, int signum);// 判断是否存在(返回1真,0假)
注意:在使用 sigset_t 变量之前,必须调·
12.3 核心系统调用:sigprocmask
用于读取 或更改进程的 Block 表(信号屏蔽字)。
cpp
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
-
how:操作模式。
-
SIG_BLOCK:mask = mask | set (添加屏蔽)
-
SIG_UNBLOCK:mask = mask & ~set (解除屏蔽)
-
SIG_SETMASK:mask = set (直接覆盖,最常用)
-
-
set:我们设定的新集合。
-
oldset:输出型参数,保存旧的屏蔽字(方便恢复)。
12.4 核心系统调用:sigpending
用于获取当前进程的 Pending 表。
cpp
int sigpending(sigset_t *set);
- set:输出型参数,内核将当前的 pending 表拷贝到这里。
十三、 实验:验证 Block 与 Pending 的关系
我们通过代码来验证以下现象:
-
屏蔽2号信号(Ctrl+C)。
-
不断打印 Pending 表。
-
发送2号信号,观察 Pending 表对应位变为1(但进程不退出)。
-
解除屏蔽,观察信号被递达,Pending 表对应位清0。
13.1 实验代码
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void PrintPending(sigset_t &pending)
{
for (int signo = 31; signo > 0; signo--)
{
if (sigismember(&pending, signo)) std::cout << "1";
else std::cout << "0";
}
std::cout << std::endl;
}
void handler(int signo)
{
std::cout << "------------------------enter handler" << std::endl;
// 验证:在handler执行期间,Pending位是否已经清0?
sigset_t pending;
sigemptyset(&pending);
sigpending(&pending);
PrintPending(pending);
std::cout << "处理完成信号: " << signo << std::endl;
std::cout << "------------------------leave handler" << std::endl;
}
int main()
{
std::cout << "我的pid: " << getpid() << std::endl;
// 0. 捕捉2号信号,方便观察现象
signal(2, handler);
// 1. 屏蔽2号信号
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
sigaddset(&block_set, 2); // 添加2号信号到集合中
// 应用屏蔽字,从现在开始,2号信号被阻塞
sigprocmask(SIG_SETMASK, &block_set, &old_set);
int cnt = 1;
while (true)
{
// 2. 获取并打印当前进程的 pending 表
sigset_t pending;
sigemptyset(&pending);
sigpending(&pending);
PrintPending(pending);
// 3. 20秒后,解除对2号信号的屏蔽
if (cnt == 20)
{
std::cout << "解除对2号信号屏蔽" << std::endl;
// 恢复旧的屏蔽字(old_set中2号没被屏蔽)
// 一旦解除屏蔽,积压的2号信号会立即被递达!
sigprocmask(SIG_SETMASK, &old_set, nullptr);
}
cnt++;
sleep(1);
}
}
13.2 实验现象与结论
-
现象 :

-
程序运行,打印全0的位图。
-
按下 Ctrl+C,位图变为 0...010 (第2位为1),但程序没停止(信号被阻塞,处于未决)。
-
20秒后,打印"解除屏蔽"。
-
立即执行 handler 函数。
-
handler 执行完毕后,位图恢复全0。
-
-
关键结论:
-
解除屏蔽的瞬间,积压的信号会被立即递达。
-
Pending位是在执行 Handler 之前 就已经被清0了(注意看代码中 Handler 内部打印的 Pending 表,2号位已经是0了)。
- 注:如果在 Handler 内部再次发送2号信号,Pending位会再次变1,但这属于下一次处理流程了。
-
十四、 信号捕捉的完整流程:用户态与内核态
问题:信号到底是什么时候被处理的?
答案:从内核态返回用户态的时候,进行信号的检测和处理。
14.1 用户态 vs 内核态
-
用户态 (User Mode):执行用户自己的代码,受限访问(只能访问 [0, 3GB] 地址空间)。
-
内核态 (Kernel Mode):执行操作系统代码(系统调用、异常处理),拥有最高权限(访问 [3GB, 4GB] 以及所有硬件)。
什么情况会进入内核态?
系统调用(如 read, write, fork)。
异常(如缺页异常、除0错误)。
外设中断(如时钟中断、键盘中断)。
14.2 信号处理流程图解

这是一个非常经典的"∞"字形流程:
-
进入内核:用户程序运行中,因为系统调用或中断,陷入内核态。
-
执行内核任务:内核处理完异常或系统调用(比如读完了磁盘数据)。
-
信号检测 :在准备返回用户态之前,内核会检查当前进程的 Pending 表和 Block 表。
-
如果没有待处理信号 -> 直接返回用户态,恢复上下文继续运行。
-
如果有待处理信号 -> 进入步骤4。
-
-
执行 Handler:
-
如果是默认动作(如终止)-> 直接杀掉进程。
-
如果是自定义捕捉 -> 切回用户态 执行 sighandler 函数。
-
为什么要切回用户态? 操作系统不信任用户代码。如果用内核态权限执行用户写的 handler,万一代码里有恶意操作,系统就挂了。
-
-
返回内核:sighandler 执行完毕后,通过特殊的系统调用 sigreturn 再次进入内核。
-
恢复上下文:内核清理现场,最终返回用户态,从主控制流被打断的地方继续执行。
14.3 记忆图解

简记:4个交点对应4次切换
用户 -> 内核 (中断/系统调用)
内核 -> 用户 (执行Handler)
用户 -> 内核 (Handler返回)
内核 -> 用户 (恢复主流程)
十五、 操作系统是怎么运行的?------ 中断驱动
15.1 冯·诺依曼体系与CPU的视角
我们要先回到计算机的本源------冯·诺依曼体系结构 。
操作系统本质上是一个软件,是一堆存储在磁盘上、加载到内存里的二进制代码。CPU 是一个只会执行指令的硬件。
-
问题:CPU 怎么知道什么时候该去执行操作系统的代码(比如读磁盘、发网络包),什么时候执行用户的代码?
-
答案 :中断 (Interrupt)。
15.2 硬件中断
请看下图的硬件中断流程:

-
外设就绪:当键盘被按下、网卡收到了数据、或者磁盘读写完毕,这些外部设备(外设)需要 CPU 的关注。
-
发起中断 :外设通过硬件线路向 中断控制器 (如 8259A/APIC) 发送电信号。
-
通知 CPU:中断控制器检查优先级后,向 CPU 的引脚发送一个电信号,告诉 CPU:"别睡了/别算数了,有急事!"。
-
CPU 保护现场 :CPU 收到中断后,会暂停当前正在执行的程序(比如你的 while(1) 循环),将当前的寄存器状态(PC指针、状态寄存器等)压入内核栈中保存,这叫保护现场。
-
查表执行 :CPU 获取中断号,去内存中查一张特殊的表------中断向量表 (IDT, Interrupt Descriptor Table)。
-
这张表里存放的是中断处理程序 (ISR) 的入口地址。
-
重点 :中断向量表是操作系统的一部分,OS启动时就加载进内存了。
-
-
执行中断服务:CPU 跳转到对应的中断服务程序(如键盘驱动、网卡驱动)去执行代码。
-
恢复现场:处理完后,CPU 从栈中恢复之前的寄存器状态,继续执行被打断的程序。
结论 :通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。 操作系统是"躺"在中断处理程序上的代码,由外设推着走。
15.3 深度思考:信号与中断的关系(康师傅 vs 康帅傅)
我们在学习"进程信号"时,一直说信号是异步 的,现在的"硬件中断"也是异步的。它们之间有什么关系呢?
-
计算机发展的历史逻辑:
-
计算机世界先有了硬件中断,用来处理键盘、网卡等突发硬件事件。
-
后来的OS设计者发现,软件进程也需要一种类似的机制,来处理软件层面的突发事件(如进程被杀、除0错误)。
-
于是,设计者模仿 硬件中断,发明了信号机制。
-
-
本质区别:
-
信号 (Signal) :是纯软件的方式,模拟中断完成特定的任务处理。
-
硬件中断 (Interrupt) :是硬件+软件配合的产物,由电流触发。
-
-
形象比喻:康师傅 vs 康帅傅
-
这两者原理极其相似(包装像、名字像、功能都是填饱肚子/处理异步事件)。
-
但是本质完全不同(一个是正版大厂方便面,一个是模仿的山寨品)。
-
信号就是操作系统里的"康帅傅",它借鉴了硬件中断的理念,但在实现上完全依赖于软件(位图、PCB)。
-
十六、 谁在推着操作系统走?------ 时钟中断
如果没有任何外设操作(没按键盘、没联网),操作系统是不是就不工作了?当然不是。因为有一个特殊的硬件------时钟源。
16.1 系统的脉搏:时钟中断
在主板上有一个晶振 或定时器硬件(如 8253 PIT),它会以固定的频率(比如每 1ms 或 10ms)向 CPU 发送中断。这被称为时钟中断。

流程:
时钟震荡,产生中断信号。
CPU 收到中断,暂停当前进程。
查 IDT 表,找到时钟中断处理程序(Linux 0.11 中是 timer_interrupt)。
执行 do_timer() 函数。
16.2 调度器的动力
在 do_timer 中,操作系统会做一件极其重要的事情:扣除当前进程的时间片。
cpp
// 伪代码逻辑
void do_timer(long cpl) {
// ... 增加系统滴答数 ticks ...
if ((--current->counter) > 0) return; // 时间片没用完,继续跑
current->counter = 0;
schedule(); // 时间片用完了,触发进程调度!切换到下一个进程
}
-
为什么 CPU 有主频? 主频越高,单位时间内处理的时钟中断和指令越多,系统响应越快。
-
并发的本质 :正是因为有了时钟中断,操作系统才能强制把 CPU 从一个死循环的进程手里抢回来,分给别的进程。这就是时间片轮转的基础。
💡 现实中的样例:CPU 2.5GHz 意味着什么?
我们买电脑时常看参数:Intel i7 2.5GHz。
2.5GHz:代表 CPU 内部的时钟频率高达 25亿次/秒。CPU 运转极快,执行指令也是纳秒级的。
OS的节拍 (Tick) :操作系统并不会每秒中断 25亿次(那样CPU光处理中断了,干不了活)。使用分频方式,OS 通常将时钟中断设置为 100Hz ~ 1000Hz(即每 10ms 或 1ms 触发一次)。
16.3 操作系统的本质:死循环
如果当前没有任务需要执行,操作系统在干嘛?
它在跑一个特殊的进程------0号进程 (Idle Task)。
cpp
void main(void) {
// ... 初始化 ...
for(;;) pause(); // 这是一个死循环!
}
操作系统本质就是一个死循环。它在 pause() 中挂起 CPU(低功耗模式),等待被下一个中断(键盘、时钟等)唤醒。
十七、 软件触发的中断------软中断与系统调用
我们在前文中提到,外设(如键盘)可以通过硬件线路触发"硬中断"。那么,正在运行的软件代码能不能也触发中断呢?
答案是肯定的。这正是系统调用(System Call)实现的基石。在操作系统中,这被称为软中断 或陷阱(Trap)。
17.1 标准库的封装:程序员眼中的 syscall vs 真实的 syscall
我们平时写代码调用的是 printf,或者 write,这些其实是 C 标准库(glibc)封装好的库函数 ,而不是真正的系统调用。
1. 库函数的封装工作
glibc 是用户程序和 Linux 内核之间的"中间人"。当我们调用 write() 时,glibc 实际上做了以下动作:
-
参数准备:将用户传入的参数(文件描述符、缓冲区、长度)放到特定的 CPU 寄存器中。
-
设置系统调用号:OS 提供了几百个系统调用,每个都有唯一的编号(如 __NR_write 是 4)。glibc 会把这个编号放到 EAX 寄存器中。
-
触发中断 :执行特殊的汇编指令(int 0x80 或 syscall),主动触发软中断,陷入内核。
2. 汇编层面的调用链(伪代码演示)

假设我们在 C 代码中写了:write(1, "hello", 5);
Step 1: 用户态(User Mode) - Glibc 的封装
bash
; C语言调用: write(1, "hello", 5)
; 这里的代码是运行在 Ring 3 的
mov edx, 5 ; 参数3: 长度 len 放入 EDX 寄存器
mov ecx, "hello" ; 参数2: 缓冲区地址 buffer 放入 ECX 寄存器
mov ebx, 1 ; 参数1: 文件描述符 fd 放入 EBX 寄存器
mov eax, 4 ; 【关键】: 将系统调用号(sys_write) 放入 EAX 寄存器
; OS 根据这个 EAX 知道你想干什么
int 0x80 ; 【核心指令】: 触发 0x80 号中断!
; 此时 CPU 动作:
; 1. 检查权限,发现是陷阱门,允许通过
; 2. 切换堆栈(用户栈 -> 内核栈)
; 3. 修改 CS:IP,跳转到内核的中断处理程序
; 4. 特权级 CPL 从 3 变为 0
Step 2: 内核态(Kernel Mode) - 统一入口 system_call
CPU 跳转到了 IDT 中 0x80 号位置登记的地址,通常是 system_call 函数:
bash
; 这里的代码是运行在 Ring 0 (内核态)的
system_call:
SAVE_ALL ; 1. 保存现场(把用户态的寄存器压入内核栈)
cmpl $NR_syscalls, %eax ; 2. 检查 EAX 里的调用号是否合法
jae bad_sys_call ; 如果非法,报错返回
call *sys_call_table(,%eax,4)
; 3. 【查表执行】
; sys_call_table 是一个函数指针数组
; 相当于执行: sys_call_table[EAX](EBX, ECX, EDX)
; 此时,真正的 sys_write 被执行
movl %eax, PT_EAX(%esp) ; 4. 将函数返回值写回栈中保存的 EAX 位置
RESTORE_ALL ; 5. 恢复现场
iret ; 6. 中断返回(从 Ring 0 切回 Ring 3)
17.3 系统调用表 (sys_call_table)
在内核中,有一个非常重要的数组 sys_call_table。它就像一个函数指针数组,下标就是系统调用号,内容就是对应的内核函数地址。
cpp
// 内核源码示意 (伪代码)
typedef void (*syscall_ptr_t)(void);
const syscall_ptr_t sys_call_table[] = {
[0] = sys_restart_syscall,
[1] = sys_exit,
[2] = sys_fork,
[3] = sys_read,
[4] = sys_write, // 对应 EAX = 4
// ...
};
-
系统调用号的本质 :就是这个数组的下标!
-
宏定义:我们在头文件 <unistd.h> 或 <sys/syscall.h> 中看到的 #define __NR_write 4,就是为了配合这个表。
17.4 为什么这叫"软中断"?
-
硬中断 :由硬件(键盘、网卡)通过电流触发,是异步的(随时可能发生)。
-
软中断(陷阱) :由软件执行指令(int 0x80)触发,是同步的(只有执行到这行代码才会发生)。
虽然触发源不同,但它们在 CPU 处理的流程上是殊途同归的:都通过 IDT(中断向量表)进行跳转,都涉及现场保护与恢复,都涉及特权级的切换。
总结:
OS 提供 :系统调用处理函数(如 sys_write)和 系统调用表(sys_call_table)。
标准库 提供:方便的 C 接口(如 write()),负责把参数和调用号搬运到寄存器,并执行 int 0x80 陷入内核。
核心流程:用户调用库函数 -> 库函数填寄存器 -> 触发软中断 -> CPU切入内核 -> 内核查表执行 -> 返回结果。
17.5 异常 (Exception)
除了主动的 int 0x80,还有被动的软中断:
除0错误:CPU ALU 单元运算出错 -> 触发 0号中断(异常)。
缺页异常:MMU 转换地址失败 -> 触发 14号中断(异常)。
野指针:访问越界 -> 触发异常。
本质:这些异常都会被 CPU 捕获,转而执行 OS 预设的中断处理程序。OS 在处理程序中发现是用户进程搞的鬼,就会向该进程发送信号(如 SIGFPE, SIGSEGV),导致进程终止。
总结 :操作系统就是躺在中断处理例程上的代码块! 无论是硬件推着走(硬中断),还是软件主动请求(软中断/系统调用),亦或是程序出错(异常),最终都是中断机制在起作用。
十八、 深入理解用户态与内核态
我们常说"切换到内核态",这到底是什么意思?
18.1 虚拟地址空间的共用
看看这张内存图:

-
[0, 3GB] 用户空间:每个进程独有,存放代码、数据、堆、栈。
-
[3GB, 4GB] 内核空间 :所有进程共享!
-
无论怎么切换进程,地址空间的高 1GB 映射的物理内存都是同一块(操作系统的代码和数据)。
-
结论 :进程在进行系统调用时,并没有切换"操作系统",而是在自己的地址空间的高处,执行操作系统的代码。
-
18.2 谁来保护内核?------ CPU 特权级 (CPL/DPL)
既然内核代码就在进程的 3G-4G 空间里,为什么我在用户态写个指针 char *p = 0xc0000000; *p = 10; 会报错(Segfault)?
这是 硬件(CPU) 的保护机制。
CS 寄存器 :CPU 的代码段寄存器中,最后 2 位表示当前特权级 (CPL, Current Privilege Level)。
0:二进制00,内核态(Ring 0),最高权限,可以执行所有指令,访问所有内存。
3:二进制11,用户态(Ring 3),最低权限,受限访问。
内存页表/段描述符 :每一页内存都有一个属性 (DPL, Descriptor Privilege Level)。内核页面的 DPL 是 0。
访问检查 :
当 CPU 处于 CPL=3 (用户态) 时,试图访问 DPL=0 (内核态) 的地址空间,硬件电路会直接拦截,并触发异常中断。
18.3 状态切换的本质
所谓"从用户态切换到内核态",本质上是:
-
触发中断(硬中断或 int 0x80)。
-
CPU 硬件自动校验:允许通过特定的"中断门"进入 Ring 0。
-
修改 CS 寄存器 :将 CPL 从 3 变为 0。
注意,仅通过一条指令修改CS寄存器,无法将当前特权级从3(用户态)变为0(内核态),也无法因此获得访问DPL=0的内核地址空间的权限。 -
跳转代码:CPU 指令指针 (EIP) 跳到 3G 以上的内核代码处执行。
十九、 更高级的信号捕捉:sigaction
之前我们使用 signal 函数来捕捉信号,它简单易用,但功能相对单一。在POSIX标准中,sigaction 是一个功能更强大、移植性更好的信号捕捉接口。
19.1 函数原型
cpp
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
-
signum: 指定要捕捉的信号编号。
-
act: 输入参数,包含了详细的信号处理动作信息。
-
oldact: 输出参数,用于备份旧的处理动作。
19.2 struct sigaction 结构体
这个结构体是核心,我们看下它的定义:
cpp
struct sigaction {
void (*sa_handler)(int); // 1. 简单的信号处理函数指针
void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号处理(暂不讨论)
sigset_t sa_mask; // 2. 处理该信号期间,需要额外屏蔽的信号集
int sa_flags; // 3. 选项标志,通常设为0
void (*sa_restorer)(void); // 旧版本使用,现已废弃
};
19.3 核心机制:自动屏蔽 (Auto-Masking)
我们在学习信号保存时知道,Block表(屏蔽字)决定了信号是否能递达。sigaction 有一个非常重要的特性:
当某个信号的处理函数(Handler)被调用时,内核会自动将当前该信号加入进程的信号屏蔽字(Block表)。当信号处理函数返回时,自动恢复原来的信号屏蔽字。
为什么?
为了防止信号递归 。如果处理2号信号的过程中,又来了个2号信号,如果允许嵌套调用,可能会导致栈溢出或逻辑混乱。OS通过自动屏蔽,保证了同一时刻,同一个信号的处理函数不会被重复调用(串行化处理)。
此外,sa_mask 字段 的作用是:如果在调用 Handler 期间,你还想顺便屏蔽掉其他信号(比如处理2号时,不想被3号打断),就可以把3号加到 sa_mask 集中。
19.4 流程验证图解
请看下图,描述了 sigaction 介入后的信号处理流程:

-
Entry: 收到信号,进入 Handler。此时 OS 自动将当前信号(如2号)的 Block 位置 1。同时将 sa_mask 指定的信号也 Block 置 1。
-
Execution: 执行 Handler 代码。此时如果再次收到2号信号,它会变成 Pending(未决)状态,不会立即打断当前执行。
-
Leave: Handler 执行完毕,调用 sys_sigreturn 返回内核。
-
Restore: 内核自动将 Block 表恢复到 Entry 之前的状态。之前 Pending 的信号此刻被解除屏蔽,进行下一次递达。
19.5 实验验证:自动屏蔽机制
我们可以通过以下代码来验证这个特性。我们在 handler 函数中打印当前的 Pending 表。如果 sigaction 的自动屏蔽机制生效,那么当我们正在处理 2 号信号时,如果再次发送 2 号信号,它应该会出现在 Pending 表中(位图为 1),而不会立即打断当前的 handler。
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
for (int signo = 31; signo > 0; signo--)
{
if (sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
int main()
{
struct sigaction act, oact;
// 设置处理函数
act.sa_handler = handler;
// 初始化 sa_mask,这表示在处理信号期间,除了当前信号外,不额外屏蔽其他信号
// 如果想额外屏蔽比如3号信号,可以在这里 sigaddset(&act.sa_mask, 3);
// 要有很关键
sigemptyset(&(act.sa_mask));
act.sa_flags = 0;
// 注册 2号信号 (SIGINT) 的处理动作
sigaction(SIGINT, &act, &oact);
std::cout << "进程在运行: " << getpid() << std::endl;
while(true)
{
sleep(1);
}
return 0;
}

二十、 可重入函数
信号捕捉最大的特点是异步。主程序执行到一半,可能随时被切走去执行 Handler,Handler 执行完再切回来。这引发了一个严重的数据安全问题。
20.1 链表插入的灾难
假设我们有一个全局链表,主函数 main 和信号处理函数 handler 都要向这个链表中插入节点 insert()。

-
场景还原:
-
main 函数正在调用 insert(node1)。插入操作分两步:(1) p->next = head; (2) head = p;。
-
刚执行完第(1)步,发生了中断/时钟切片,进程暂停。
-
进程恢复运行时,发现有一个待处理信号,于是进入 handler。
-
handler 中也调用了 insert(node2),并且完整执行了(1)(2)步,将 node2 挂到了链表头。
-
handler 返回,回到 main 的第(2)步继续执行。此时 main 将 head 指向了 node1。
-
-
结果 :
head 指向了 node1,而 node1->next 指向了旧的 head。node2 就这样凭空消失了(内存泄漏)!
20.2 什么是可重入函数?
-
不可重入函数:像上面的 insert 函数,被不同的执行流(main 和 handler)重复调用(重入)时,会导致数据错乱或逻辑错误。
-
特征:
-
使用了全局变量或静态变量。
-
调用了 malloc 或 free(它们内部维护了全局链表)。
-
调用了标准 I/O 库函数(如 printf,内部有全局缓冲区)。
-
-
-
可重入函数:如果一个函数只访问自己的局部变量或参数,不依赖任何全局数据,在多执行流环境下安全运行,则称为可重入函数。
注意 :绝大多数系统库函数和自己写的函数都是不可重入的。在信号处理函数中,应尽量避免调用不可重入函数(如 printf, malloc),虽然我们演示时常用 printf,但在工程实践中这是禁忌。
二十一、 消失的内存可见性:volatile 关键字
在编写信号处理程序时,我们常遇到一个诡异的现象:Handler明明修改了变量,Main函数却"视而不见"。
21.1 实验现象
cpp
#include <stdio.h>
#include <signal.h>
int flag = 0; // 全局变量
void handler(int sig) {
printf("change flag 0 -> 1\n");
flag = 1; // 修改全局变量
}
int main() {
signal(2, handler);
while(!flag); // 等待 flag 变 1
printf("process quit normal\n");
return 0;
}
-
正常编译 (gcc -o sig sig.c):运行程序,按 Ctrl+C,Handler 执行,flag 变 1,while 循环结束,进程正常退出。
-
优化编译 (gcc -o sig sig.c -O2) :运行程序,按 Ctrl+C,Handler 执行打印了日志,但是 while 循环一直在空转,进程不退出!
21.2 原因分析:寄存器 vs 内存
这是编译器优化(Optimization)带来的副作用。

-
编译器的视角:编译器在分析 main 函数时,发现 while(!flag) 里面没有任何代码修改 flag。编译器认为 flag 在 main 的上下文中是一个"常量"。
-
优化动作 :为了提高速度,编译器决定把 flag 的值从内存读到 CPU 寄存器 (Register) 中,以后每次 while 判断都直接读寄存器,不再访问内存(因为读寄存器比读内存快得多)。
-
运行时 :handler 执行时,确实把内存 中的 flag 改成了 1。但是,main 函数的 while 循环还在傻傻地看寄存器里的旧值(0)。
-
结果 :内存改了,寄存器没改,造成了内存不可见。
21.3 解决方案:volatile
C语言提供了 volatile 关键字,意为"易变的"。
cpp
volatile int flag = 0;
-
作用:告诉编译器,这个变量可能会被意想不到的执行流(如信号、硬件中断)修改。
-
强制约束 :禁止编译器将该变量缓存到寄存器中。每次访问该变量,必须直接从内存中读取。
加上 volatile 后,无论开多高的优化等级,程序都能正常退出了。
二十二、 SIGCHLD:优雅地回收子进程
在之前的进程控制章节中,我们知道子进程退出时,如果父进程不管不顾,子进程就会变成僵尸进程(Zombies),造成内存泄漏。为了回收子进程,父进程通常有两种做法:
-
阻塞等待 (wait):父进程必须暂停手中的工作,专心等子进程死,效率极低。
-
非阻塞轮询 (waitpid + WNOHANG):父进程虽然可以做其他事,但需要不断分心去 check 子进程的状态,代码逻辑复杂耦合。
有没有一种办法,能让子进程退出了主动通知父进程,父进程再去回收呢?
22.1 SIGCHLD 机制
其实,Linux 早就设计了这种机制。子进程退出时,会向父进程发送 17) SIGCHLD 信号。
只不过,该信号的默认处理动作是 Ign (忽略),所以我们平时写代码时没感觉到它的存在。
22.2 方法一:自定义捕捉回收 (Handler)
我们可以注册 SIGCHLD 的处理函数,在 Handler 里面调用 wait/waitpid。这样父进程只需专心做自己的事,只有收到信号时才被打断去回收子进程。
⚠️ 核心痛点:多子进程并发退出问题
这里有一个非常经典的面试题/坑点,请看如下场景:
问题:如果父进程 Fork 了 10 个子进程,它们几乎在同一时刻退出,会发生什么?
-
信号丢失问题 :10 个子进程退出,会发送 10 次 SIGCHLD 信号。但是,普通信号(1-31)使用 位图 (Bitmap) 记录,不支持排队。
-
当父进程正在处理第 1 个信号时,剩下的 9 个信号可能几乎同时到达。
-
Pending 位图的第 17 位只是被反复置为 1。
-
结果:父进程可能只收到了 1 次或 2 次信号,如果 Handler 里只 wait 一次,就会导致剩下 8 个子进程变成僵尸。
-
-
阻塞死锁问题:为了解决信号丢失,我们必须在一个 Handler 里把所有退出的子进程都收完(循环 wait)。
-
但如果 10 个子进程中,只有 5 个退出了,剩下 5 个还在跑?
-
如果我们用阻塞式的 waitpid(-1, 0, 0),收完 5 个僵尸后,第 6 次 wait 就会阻塞住,导致 Handler 卡死,父进程的主逻辑也随之卡死。
-
✅ 正确的解决方案:while 循环 + WNOHANG
为了解决上述两个问题,Handler 必须这样写:
-
使用 while 循环:只要还有僵尸,就一直收,一次 Handler 调用清理所有积压的僵尸。
-
使用 WNOHANG:非阻塞等待。如果发现子进程还在运行,不要卡住,直接返回,结束 Handler。
python
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
void handler(int signo)
{
printf("父进程捕捉到信号: %d, 开始回收...\n", signo);
// 必须使用 while 循环 + WNOHANG!
pid_t id;
int status = 0;
// waitpid 返回值含义:
// > 0 : 成功回收了一个子进程,返回其pid
// = 0 : 子进程还在运行,且设置了WNOHANG,不等待,直接返回
// < 0 : 所有子进程都回收完了(没有子进程了),出错返回
while( (id = waitpid(-1, &status, WNOHANG)) > 0)
{
printf("成功回收子进程: %d, 退出码: %d\n", id, WEXITSTATUS(status));
}
printf("本次信号处理结束,已无僵尸进程。\n");
}
int main()
{
// 注册信号处理函数
signal(SIGCHLD, handler);
// 创建 10 个子进程
for(int i = 0; i < 10; i++)
{
pid_t id = fork();
if(id == 0)
{
// 子进程
int n = rand() % 5 + 1; // 随机休眠 1-5秒
printf("我是子进程 %d, 我将运行 %d 秒\n", getpid(), n);
sleep(n);
exit(i); // 退出
}
}
// 父进程主循环
while(1)
{
printf("父进程(PID:%d) 正在安心做自己的事情...\n", getpid());
sleep(1);
}
return 0;
}
22.3 方法二:显式忽略 (Linux 特性)
如果觉得写 Handler 太麻烦,而且父进程其实完全不关心子进程的退出状态(比如不需要知道它是正常退出还是报错),在 Linux 下有一种"偷懒"的奇技淫巧。
代码:
python
// 显式地将 SIGCHLD 的处理动作设置为"忽略"
signal(SIGCHLD, SIG_IGN);
原理与细节(核心差异):
这里有一个非常反直觉的现象:SIGCHLD 的默认动作(SIG_DFL)本身就是忽略,为什么还需要手动设置 SIG_IGN?两者有何不同?
-
默认的忽略 (SIG_DFL -> Action: Ign):
-
这是操作系统默认的行为。虽然对信号的处理动作是"忽略"(即不终止父进程),但内核依然会保留子进程的 PCB(即变成僵尸进程),等待父进程来 wait。
-
含义:"父进程没说不要,我先帮他留着尸体,万一他以后要查呢。"
-
-
显式的忽略 (SIG_IGN):
-
这是用户通过代码明确告知内核的行为。
-
在 Linux 中,内核对 SIGCHLD 做了特殊处理。如果发现应用层将其 handler 设置为了 SIG_IGN,内核在子进程退出时,会自动清理回收资源,不再产生僵尸进程。
-
含义:"父进程明确说了他不要了,直接处理掉吧。"
-
⚠️ 注意事项:
可移植性:这是一个 Linux 系统特有的行为(虽然现在大部分 UNIX 变种都支持,但不保证完全的可移植性)。
wait 失效 :使用此方法后,系统会自动回收子进程。如果父进程随后调用 wait 或 waitpid,会因为找不到任何子进程而立即出错返回 -1(错误码 ECHILD),无法捕获子进程的退出信息。