Linux进程信号:从"外卖到了"到内核里的一张位图

🌈 say-fall:个人主页 🚀 专栏:《手把手教你学会C++》 | 《系统深入Linux操作系统》 | 《数据结构与算法》 💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。
📝 前言
前面的文章里,我们已经把进程的"出生"(fork)、"谢幕"(exit / wait)和"换芯"(exec)都捋了一遍。但你一定遇到过这样的场景:写了个死循环程序,在终端里轻轻按下 Ctrl + C,进程就乖乖停了;可有时候按了没反应,得掏出 kill -9 进程号 才能把它干掉。
同一台机器、同一个键盘、同一份代码,为什么结果不一样?
| 现象 | 你可能以为的原因 | 真正发生的事情 |
|---|---|---|
| 按 Ctrl+C 进程退出 | 键盘把程序"关掉"了 | 内核给前台进程发了一个 2 号信号 |
| 按 Ctrl+C 没反应 | 程序卡死了 | 进程把这个信号 忽略 或 阻塞 了 |
| kill -9 一定好用 | 9 号信号比较猛 | 9 号信号 不允许被捕捉、也不允许被阻塞 |
🔍 问题来了: 一个进程正在跑自己的代码,它凭什么能被"外面"的某个东西打断?这个"打断"是怎么送到进程手上的?进程没空处理时,这个通知又存在哪儿?
答案就是------信号(Signal)!它是 Linux 里最古老、也最像"硬件中断"的一种 异步通知机制:内核在进程的 task_struct 里画了一张 位图,谁想通知进程,就在对应的位上写个 1;进程在从内核态返回用户态的那一刻,才回头看看这张位图有没有被点亮。
通过本文,你将掌握:
| 技能 | 应用场景 |
|---|---|
| 信号的编号体系与默认动作 | 看到 Term、Core、Ign 不再发懵 |
| 四种产生信号的方式 | 键盘、命令/系统调用、软件条件、硬件异常 |
| 递达、未决、阻塞三个概念 | 解释"信号为什么发了没生效" |
| 内核中的三张表(block / pending / handler) | 真正看懂信号在 PCB 里的存储形态 |
| sigprocmask / sigpending / sigaction | 屏蔽、查询、捕捉信号的三大件 |
| 可重入函数与 volatile | 写出信号安全的代码,避开 -O2 的坑 |
| 中断、陷阱、异常、系统调用 | 理解操作系统"躺"在中断向量表上运行 |
📌 前置知识: 进程的 fork / wait / exec、虚拟地址空间(0~3G 用户区、3~4G 内核区)、基本的 gcc 与 gdb 使用。
文章目录
- Linux进程信号:从"外卖到了"到内核里的一张位图
-
- [📝 前言](#📝 前言)
- [一、🌂 从一件快递说起:信号到底是什么](#一、🌂 从一件快递说起:信号到底是什么)
-
- [1.1 一个生活里的类比](#1.1 一个生活里的类比)
- [1.2 四条必须先记住的结论](#1.2 四条必须先记住的结论)
- [1.3 信号的一生:产生 → 保存 → 处理](#1.3 信号的一生:产生 → 保存 → 处理)
- [二、🏷️ 信号的身份证:编号、宏与默认动作](#二、🏷️ 信号的身份证:编号、宏与默认动作)
-
- [2.1 怎么查看系统里有哪些信号](#2.1 怎么查看系统里有哪些信号)
- [2.2 每个信号"长什么样"](#2.2 每个信号"长什么样")
- [2.3 常用信号速查表](#2.3 常用信号速查表)
- [三、⌨️ 产生方式一:终端按键(顺带搞懂前台/后台)](#三、⌨️ 产生方式一:终端按键(顺带搞懂前台/后台))
-
- [3.1 一个"什么都干不了"的进程](#3.1 一个"什么都干不了"的进程)
- [3.2 把处理动作换成自己的函数:signal](#3.2 把处理动作换成自己的函数:signal)
- [3.3 三种处理动作的写法](#3.3 三种处理动作的写法)
- [3.4 顺带把前台 / 后台搞明白](#3.4 顺带把前台 / 后台搞明白)
- [四、🔫 产生方式二:命令与系统调用](#四、🔫 产生方式二:命令与系统调用)
-
- [4.1 kill 命令的真相](#4.1 kill 命令的真相)
- [4.2 手写一个 mykill](#4.2 手写一个 mykill)
- [4.3 给自己发信号:raise](#4.3 给自己发信号:raise)
- [4.4 直接异常终止:abort](#4.4 直接异常终止:abort)
- [4.5 发送信号的本质是什么](#4.5 发送信号的本质是什么)
- [五、⏰ 产生方式三:软件条件](#五、⏰ 产生方式三:软件条件)
-
- [5.1 什么是"软件条件"](#5.1 什么是"软件条件")
- [5.2 alarm:给进程装一个闹钟](#5.2 alarm:给进程装一个闹钟)
- [5.3 实验:用闹钟测 IO 的效率](#5.3 实验:用闹钟测 IO 的效率)
- [5.4 pause 与"重复闹钟"](#5.4 pause 与"重复闹钟")
- [5.5 系统闹钟在内核里长什么样](#5.5 系统闹钟在内核里长什么样)
- [六、💥 产生方式四:硬件异常](#六、💥 产生方式四:硬件异常)
-
- [6.1 除零:从 CPU 到 SIGFPE](#6.1 除零:从 CPU 到 SIGFPE)
- [6.2 野指针:从 MMU 到 SIGSEGV](#6.2 野指针:从 MMU 到 SIGSEGV)
- [6.3 为什么信号会"一直重复"](#6.3 为什么信号会"一直重复")
- [6.4 子进程异常退出:status 里藏着信号](#6.4 子进程异常退出:status 里藏着信号)
- [七、🗄️ core dump:崩溃现场的黑匣子](#七、🗄️ core dump:崩溃现场的黑匣子)
-
- [7.1 什么是 core dump](#7.1 什么是 core dump)
- [7.2 为什么默认不产生 core](#7.2 为什么默认不产生 core)
- [7.3 打开 core,用 gdb 定位问题行](#7.3 打开 core,用 gdb 定位问题行)
- [八、📥 信号的保存:内核里的三张表](#八、📥 信号的保存:内核里的三张表)
-
- [8.1 三个必须分清的概念](#8.1 三个必须分清的概念)
- [8.2 内核用三张表描述这一切](#8.2 内核用三张表描述这一切)
- [8.3 忽略 vs 阻塞](#8.3 忽略 vs 阻塞)
- [8.4 普通信号只记一次](#8.4 普通信号只记一次)
- [九、🧰 信号集与屏蔽:sigprocmask / sigpending 实战](#九、🧰 信号集与屏蔽:sigprocmask / sigpending 实战)
-
- [9.1 sigset_t 与五个操作函数](#9.1 sigset_t 与五个操作函数)
- [9.2 位图是怎么算出来的](#9.2 位图是怎么算出来的)
- [9.3 sigprocmask:修改屏蔽字](#9.3 sigprocmask:修改屏蔽字)
- [9.4 sigpending:查询未决信号集](#9.4 sigpending:查询未决信号集)
- [9.5 完整实验:屏蔽 2 号信号,观察 pending](#9.5 完整实验:屏蔽 2 号信号,观察 pending)
- [十、🎯 信号的捕捉:内核态返回用户态的那一瞬间](#十、🎯 信号的捕捉:内核态返回用户态的那一瞬间)
-
- [10.1 一个关键问题:进程什么时候处理信号?](#10.1 一个关键问题:进程什么时候处理信号?)
- [10.2 完整的捕捉流程](#10.2 完整的捕捉流程)
- [10.3 内部函数名对照](#10.3 内部函数名对照)
- [10.4 顺带补一课:用户态与内核态](#10.4 顺带补一课:用户态与内核态)
- [十一、🪝 更现代的捕捉方式:sigaction](#十一、🪝 更现代的捕捉方式:sigaction)
-
- [11.1 为什么 signal 不够用](#11.1 为什么 signal 不够用)
- [11.2 struct sigaction 的字段](#11.2 struct sigaction 的字段)
- [11.3 sa_mask:防止信号"套娃"](#11.3 sa_mask:防止信号"套娃")
- [11.4 signal 与 sigaction 对照](#11.4 signal 与 sigaction 对照)
- [十二、🔁 可重入函数:一个链表节点的冤案](#十二、🔁 可重入函数:一个链表节点的冤案)
-
- [12.1 现场还原](#12.1 现场还原)
- [12.2 时序分析:node2 去哪了](#12.2 时序分析:node2 去哪了)
- [12.3 可重入与不可重入](#12.3 可重入与不可重入)
- [十三、⚡ volatile:为什么 -O2 之后信号改不动 while](#十三、⚡ volatile:为什么 -O2 之后信号改不动 while)
-
- [13.1 现象:flag 明明被改了,循环还在转](#13.1 现象:flag 明明被改了,循环还在转)
- [13.2 根因:内存不可见](#13.2 根因:内存不可见)
- [13.3 解法:volatile](#13.3 解法:volatile)
- [十四、👶 SIGCHLD:把轮询从父进程手里拿走](#十四、👶 SIGCHLD:把轮询从父进程手里拿走)
-
- [14.1 回收子进程的三种姿势](#14.1 回收子进程的三种姿势)
- [14.2 SIGCHLD 的默认动作是"忽略"](#14.2 SIGCHLD 的默认动作是"忽略")
- [14.3 完整示例](#14.3 完整示例)
- [14.4 一个特例:把 SIGCHLD 设成 SIG_IGN](#14.4 一个特例:把 SIGCHLD 设成 SIG_IGN)
- [十五、🧠 拔高:操作系统到底是怎么跑起来的](#十五、🧠 拔高:操作系统到底是怎么跑起来的)
-
- [15.1 硬件中断:外设叫醒 CPU](#15.1 硬件中断:外设叫醒 CPU)
- [15.2 时钟中断:让 OS 自己动起来](#15.2 时钟中断:让 OS 自己动起来)
- [15.3 死循环:OS 的本质](#15.3 死循环:OS 的本质)
- [15.4 软中断:系统调用与异常](#15.4 软中断:系统调用与异常)
- [15.5 陷阱、异常、中断:三个词的区别](#15.5 陷阱、异常、中断:三个词的区别)
- [15.6 一个惊人的事实:内核在每个进程的地址空间里](#15.6 一个惊人的事实:内核在每个进程的地址空间里)
- [十六、📊 全篇总结](#十六、📊 全篇总结)
-
- [16.1 一张图串起全文](#16.1 一张图串起全文)
- [16.2 核心数字速记](#16.2 核心数字速记)
- [16.3 易错点清单](#16.3 易错点清单)
- [十七、🤔 几个思考题](#十七、🤔 几个思考题)
-
- [1️⃣ 进程能对信号做什么处理?什么时候处理?](#1️⃣ 进程能对信号做什么处理?什么时候处理?)
- [2️⃣ 阻塞和忽略到底差在哪?](#2️⃣ 阻塞和忽略到底差在哪?)
- [3️⃣ 为什么按 Ctrl+C 有时管用有时不管用?](#3️⃣ 为什么按 Ctrl+C 有时管用有时不管用?)
- [4️⃣ 为什么除零之后 8 号信号会一直重复捕获?](#4️⃣ 为什么除零之后 8 号信号会一直重复捕获?)
- [5️⃣ 为什么 9 号信号不能被捕捉、也不能被阻塞?](#5️⃣ 为什么 9 号信号不能被捕捉、也不能被阻塞?)
- [6️⃣ 普通信号为什么不能用来计数?](#6️⃣ 普通信号为什么不能用来计数?)
- [7️⃣ 信号处理函数里为什么不能随便调用 printf?](#7️⃣ 信号处理函数里为什么不能随便调用 printf?)
- [8️⃣ 加了 -O2 之后 while(!flag) 死循环,为什么?](#8️⃣ 加了 -O2 之后 while(!flag) 死循环,为什么?)
- [9️⃣ 为什么 SIGCHLD 是回收子进程的最佳姿势?](#9️⃣ 为什么 SIGCHLD 是回收子进程的最佳姿势?)
- [🔟 系统调用为什么"本质上是一次软中断"?](#🔟 系统调用为什么"本质上是一次软中断"?)
- 本节完
一、🌂 从一件快递说起:信号到底是什么
1.1 一个生活里的类比
你在网上下单了一堆东西,接下来会发生这些事:
| 生活场景 | 对应到信号机制 |
|---|---|
| 快递还没到,你就知道到了要去取 | 信号 未产生时,进程就已经知道该怎么处理它 |
| 快递员打电话时你正在打游戏,5 分钟后再下楼 | 信号 不立即处理,挑"合适的时候"处理 |
| 从接到电话到下楼取件,中间有个时间窗口 | 这就是信号的 未决(Pending) 状态 |
| 拿到快递后:拆开用 / 送给女朋友 / 直接扔掉 | 三种处理动作:默认 / 自定义 / 忽略 |
| 你永远猜不到快递员几点打电话 | 信号相对进程的执行流是 异步 的 |
这个类比里最关键的其实是第三行:**"快递到了但还没取"**这个状态是真实存在的。很多同学对信号的理解停在"发一个信号,进程就退出",一旦遇到"信号发了但程序没反应",就彻底卡住了。原因就藏在这个"未决"状态里。
1.2 四条必须先记住的结论
💡 结论一: 识别信号是 内置能力。进程能"看懂" 1~31 号信号,不是因为它聪明,而是内核程序员早就把这张表写死在系统里了。
💡 结论二: 处理信号的方法,在信号产生 之前 就已经准备好了。就像你先知道快递到了要拆箱,而不是快递到了才开始想怎么办。
💡 结论三: 信号 不是立即处理 的。进程可能正在做优先级更高的事,所以信号的产生与处理之间是分离的。
💡 结论四: 能给进程发信号的源头 非常多:键盘、shell 命令、另一个进程、内核自己(定时器、异常)。但最终都由 操作系统 这一个"总代理"来投递。
1.3 信号的一生:产生 → 保存 → 处理
text
产生 保存 处理
┌──────┐ ┌───────────────┐ ┌──────────────┐
│ 键盘 │ │ task_struct │ │ 默认动作 │
│ 命令 │ ──────► │ pending 位图 │ ─────► │ 自定义动作 │
│ 软件 │ 内核 │ blocked 位图 │ 递达 │ 忽略 │
│ 硬件 │ 修改 │ handler 表 │ └──────────────┘
└──────┘ └───────────────┘
三个阶段,对应本文的三条主线:
| 阶段 | 内核在做什么 | 本文对应章节 |
|---|---|---|
| 产生 | 找到目标进程,把 pending 位图的对应位置 1 | 第三 ~ 六章 |
| 保存 | 用 block / pending / handler 三张表记录状态 | 第八 ~ 九章 |
| 处理 | 在合适时机递达,执行默认 / 自定义 / 忽略 | 第十 ~ 十一章 |
⚠️ 一个必须先说清的区分: 很多资料把"未决"和"阻塞"混着讲,其实它们完全是两件事。阻塞是"门锁着,快递进不来",忽略是"快递进来了,我直接扔垃圾桶"。前者发生在递达 之前,后者发生在递达 之后。
二、🏷️ 信号的身份证:编号、宏与默认动作
2.1 怎么查看系统里有哪些信号
bash
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 ... 64) SIGRTMAX
是不是发现 32、33 号"不见了"?它们被 glibc 拿去内部使用了(NPTL 线程库),所以从用户视角看是断档的。
| 区间 | 名称 | 特点 |
|---|---|---|
| 1 ~ 31 | 普通信号(标准信号) | 每个信号在 pending 位图里只占 1 bit,产生多次只记一次 |
| 34 ~ 64 | 实时信号 | 产生多次会 排队,按顺序依次递达 |
本文只讨论 1~31 号普通信号,实时信号留到后面有兴趣再研究。
2.2 每个信号"长什么样"
每个信号在 signal.h 里都有一个宏定义,本质就是一个小整数:
c
#define SIGINT 2 /* Interrupt from keyboard */
#define SIGQUIT 3 /* Quit from keyboard */
#define SIGKILL 9 /* Kill signal */
#define SIGSEGV 11 /* Invalid memory reference */
#define SIGCHLD 17 /* Child stopped or terminated */
想查某个信号的默认动作,最权威的方式是看手册:
bash
$ man 7 signal
手册里会给出这样一张表,第三列就是 默认处理动作:
| 默认动作 | 含义 | 举例 |
|---|---|---|
| Term | 终止进程(不产生 core) | SIGINT、SIGKILL、SIGTERM |
| Core | 终止进程 并产生 core dump | SIGQUIT、SIGSEGV、SIGFPE、SIGABRT |
| Ign | 忽略该信号 | SIGCHLD、SIGURG、SIGWINCH |
| Stop | 停止(挂起)进程 | SIGSTOP、SIGTSTP、SIGTTIN |
| Cont | 继续运行被停止的进程 | SIGCONT |
2.3 常用信号速查表
| 编号 | 名称 | 默认动作 | 触发场景 |
|---|---|---|---|
| 1 | SIGHUP | Term | 终端挂断 / 控制进程退出 |
| 2 | SIGINT | Term | Ctrl + C |
| 3 | SIGQUIT | Core | Ctrl + \ |
| 6 | SIGABRT | Core | abort() 调用 |
| 8 | SIGFPE | Core | 除 0、浮点异常 |
| 9 | SIGKILL | Term | kill -9,不可捕捉、不可阻塞 |
| 11 | SIGSEGV | Core | 野指针、非法内存访问 |
| 13 | SIGPIPE | Term | 向读端已关闭的管道写数据 |
| 14 | SIGALRM | Term | alarm() 闹钟超时 |
| 17 | SIGCHLD | Ign | 子进程停止或终止 |
| 18 | SIGCONT | Cont | 让停止的进程继续 |
| 19 | SIGSTOP | Stop | 强制挂起,不可捕捉 |
| 20 | SIGTSTP | Stop | Ctrl + Z |
⚠️ 9 号和 19 号是"特权信号": 内核为了给管理员留一条绝对能用的后路,规定这两个信号既不能被自定义捕捉,也不能被阻塞。所以
kill -9永远是进程的"最终解释权"。
三、⌨️ 产生方式一:终端按键(顺带搞懂前台/后台)
3.1 一个"什么都干不了"的进程
先写一个死循环,看看它能不能被 Ctrl+C 干掉:
c
// sig.c ------ 一个只会等待信号的进程
#include <stdio.h>
#include <unistd.h>
int main()
{
while (1) {
printf("I am a process, I am waiting signal!\n");
sleep(1);
}
return 0;
}
bash
$ gcc sig.c -o sig
$ ./sig
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C # 按下 Ctrl+C
$ # 进程退出了
这条链路值得拆开看:
| 步骤 | 发生了什么 |
|---|---|
| 1 | shell 用 fork + exec 启动 ./sig,它成为 前台进程 |
| 2 | 你按下 Ctrl+C,键盘产生一个 硬件中断 |
| 3 | 操作系统捕获这个中断,把它 解释成 2 号信号 SIGINT |
| 4 | 内核把 SIGINT 发给前台进程组 |
| 5 | 进程执行 SIGINT 的默认动作:终止 |
💡 注意第 3 步: 键盘自己并不知道"2 号信号"是什么概念,它只是往中断控制器拍了一个电信号。是 OS 把这个硬件事件翻译成了"给前台进程发 SIGINT"。这就是后面第十五章要讲的"中断 → 信号"的映射关系。
3.2 把处理动作换成自己的函数:signal
想让进程收到信号后不退出,就得改它的处理动作。最古老的接口是:
c
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
| 参数 | 含义 |
|---|---|
| signum | 信号编号,如 SIGINT(就是 2) |
| handler | 函数指针,收到信号时 回调 这个函数;也可以是两个特殊宏 |
于是我们可以"劫持" Ctrl+C:
c
// sig_demo.c ------ 捕捉 2 号信号
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo) // 回调函数,参数就是收到的信号编号
{
printf("我是进程 %d,我收到了信号 %d\n", getpid(), signo);
}
int main()
{
printf("我是进程 %d\n", getpid());
signal(SIGINT, handler); // 把 2 号信号的处理动作改成 handler
while (1) {
printf("I am a process, I am waiting signal!\n");
sleep(1);
}
return 0;
}
bash
$ ./sig_demo
我是进程 5000
I am a process, I am waiting signal!
^C我是进程 5000,我收到了信号 2
I am a process, I am waiting signal! # 进程没有退出!
^C我是进程 5000,我收到了信号 2
这一下就解释清楚了前面的疑问:按了 Ctrl+C 进程没退出,是因为进程自己接管了这个信号。
💡 关键提醒:
signal()只是"注册"了动作,并不会主动调用它。如果后续这个信号根本没产生,你写的 handler 一辈子都不会被执行。这跟"设置了闹钟但没到点"是同一个道理。
3.3 三种处理动作的写法
| 动作 | 写法 | 效果 |
|---|---|---|
| 默认动作 | signal(SIGINT, SIG_DFL) |
按系统默认来(2 号是终止) |
| 忽略 | signal(SIGINT, SIG_IGN) |
收到当没收到,Ctrl+C 完全无效 |
| 自定义捕捉 | signal(SIGINT, handler) |
回调你自己的函数 |
SIG_DFL 和 SIG_IGN 到底是什么?看看源码就明白了:
c
#define SIG_DFL ((__sighandler_t) 0) /* Default action */
#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal */
typedef void (*__sighandler_t)(int); // 函数指针类型
它们就是把 0 和 1 强转成函数指针。所以"忽略"和"默认"并不是两个神秘对象,只是两个特殊的"假函数地址",内核看到 0 就用默认动作,看到 1 就丢掉。
3.4 顺带把前台 / 后台搞明白
为什么有的进程按 Ctrl+C 有用,有的没用?因为 键盘只有一个,前台进程也只有一个。
bash
$ ./sig # 前台运行:占用终端,能收到 Ctrl+C
$ ./sig & # 后台运行:shell 不等待它,Ctrl+C 管不到它
| 操作 | 命令 | 说明 |
|---|---|---|
| 查看后台任务 | jobs |
列出当前 shell 的所有后台任务 |
| 提到前台 | fg 任务号 |
把后台任务搬到前台 |
| 让后台继续跑 | bg 任务号 |
把被挂起的任务放到后台继续 |
| 挂起当前前台进程 | Ctrl + Z |
发送 20 号信号 SIGTSTP,进程状态变成 T |
bash
$ ./sig
^Z
[1]+ Stopped ./sig # 被 20 号信号挂起
$ jobs
[1]+ Stopped ./sig
$ bg 1 # 让它到后台继续跑
$ kill -9 %1 # 后台进程只能用 kill 收拾
⚠️ Core 与 Term 的第一处区别: 同样是"终止",2 号 SIGINT 只是退出,而 3 号 SIGQUIT(Ctrl + \)还会额外产生一个 core 文件------把进程崩溃时的内存现场存到磁盘上。这件事我们在第七章专门讲。
四、🔫 产生方式二:命令与系统调用
4.1 kill 命令的真相
平时用 kill 杀进程,其实是在调一个同名函数:
c
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
// 成功返回 0,失败返回 -1 并设置 errno
pid 参数有四种玩法,这个细节面试很爱问:
| pid 取值 | 含义 |
|---|---|
| pid > 0 | 发给指定的那个进程 |
| pid == 0 | 发给 本进程组 里的所有进程 |
| pid == -1 | 发给所有 有权限发送 的进程(init 除外) |
| pid < -1 | 发给进程组 ID 为 -pid 的所有进程 |
kill 命令支持两种写法,完全等价:
bash
$ kill -SIGSEGV 5000 # 用信号名
$ kill -11 5000 # 用信号编号
有意思的是,SIGSEGV 本来是"段错误"的意思,可你给一个完全正常的程序发 11 号信号,它照样会以 Segmentation fault 的方式死掉------因为对进程来说,"收到 11 号"和"真的访问了非法内存"没有任何区别。
4.2 手写一个 mykill
理解了 kill 只是"往目标进程写一个位",自己实现一个就很简单了:
c
// mykill.c ------ 用法: ./mykill -9 5000
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
int main(int argc, char *argv[])
{
if (argc != 3) {
fprintf(stderr, "Usage: %s -signumber pid\n", argv[0]);
return 1;
}
int signo = atoi(argv[1] + 1); // argv[1] 形如 "-9",+1 跳过 '-'
pid_t pid = atoi(argv[2]);
if (kill(pid, signo) == -1) {
perror("kill");
return 1;
}
return 0;
}
4.3 给自己发信号:raise
c
#include <signal.h>
int raise(int sig); // 成功返回 0,失败返回非 0
等价于 kill(getpid(), sig),只能"自残",不能打别人:
c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("捕获到一个信号: %d\n", signo);
}
int main()
{
signal(2, handler);
while (1) {
sleep(1);
raise(2); // 每秒给自己发一次 2 号信号
}
return 0;
}
4.4 直接异常终止:abort
c
#include <stdlib.h>
void abort(void); // 永远不返回
abort() 是给 自己 发 6 号信号 SIGABRT,并且即使你捕捉了 6 号,进程依然会退出:
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void handler(int signo)
{
printf("捕获到一个信号: %d\n", signo);
}
int main()
{
signal(SIGABRT, handler);
while (1) {
sleep(1);
abort(); // 先让你捕获 6 号,然后照样终止
}
return 0;
}
bash
$ ./abort_demo
捕获到一个信号: 6
Aborted # 捕捉了也没用,进程还是死了
💡 设计意图:
abort的语义就是"我必须死",如果允许它被捕获后还能活下来,那库函数里所有"断言失败就中止"的逻辑就形同虚设了。
4.5 发送信号的本质是什么
写到这儿必须点破一层窗户纸:所谓"发信号",内核根本没传什么消息过去,它只是修改了目标进程 PCB 里的一个 bit。
c
// 内核里的 task_struct(简化示意)
struct task_struct {
...
unsigned int sigs; // 位图:第 n 位为 1 表示收到了 n 号信号
...
};
text
31 2 1
┌───────────────────────────────┬──┬──┐
│ 0 0 0 0 ... 0 1 0 0 0 0 0 0 0 │0 │0 │
└───────────────────────────────┴──┴──┘
↑
第 2 位为 1 → 收到 SIGINT
所以整个"发信号"的流程可以浓缩成三步:
| 步骤 | 内核动作 |
|---|---|
| 1 | 根据 pid 找到目标进程的 task_struct |
| 2 | 把 pending 位图 中对应信号的位置 1 |
| 3 | 如果目标进程正在睡眠且该信号不被阻塞,就 唤醒它 |
⚠️ 这也解释了为什么"发信号"这么便宜: 它本质上是一次 内核数据结构的写入,连进程间通信都算不上。真正的"通知"要等进程自己回到用户态时才生效。
五、⏰ 产生方式三:软件条件
5.1 什么是"软件条件"
不是键盘敲出来的,也不是别的进程打过来的,而是 操作系统内部状态满足某个条件 后自己发的信号:
| 软件条件 | 产生的信号 | 典型场景 |
|---|---|---|
| 向"读端已关闭"的管道写数据 | SIGPIPE(13) | 管道通信 |
| 定时器超时 | SIGALRM(14) | alarm() 闹钟 |
| CPU 时间超限 | SIGXCPU(24) | 资源限制 |
| 窗口大小变化 | SIGWINCH(28) | 终端自适应 |
SIGPIPE 在讲管道时已经见过:读端全关了,写端还傻乎乎地 write,内核就用 13 号信号"劝退"它。
5.2 alarm:给进程装一个闹钟
c
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
| 参数 / 返回值 | 含义 |
|---|---|
| seconds | 多少秒后给当前进程发 SIGALRM;传 0 表示取消闹钟 |
| 返回值 | 上一个闹钟剩余的秒数;若之前没有闹钟则返回 0 |
这个返回值特别容易记混。手册里的比喻很形象:你定了 30 分钟的闹钟,睡了 20 分钟被吵醒,想再睡 15 分钟,此时重新调用 alarm(15*60),它返回的就是"上一个闹钟还剩 10 分钟"。
5.3 实验:用闹钟测 IO 的效率
思路:让程序数 1 秒钟的数,用 alarm(1) 打断,看数到了多少。
c
// io_more.c ------ 每次数数都打印(重 IO)
#include <stdio.h>
#include <unistd.h>
int main()
{
int count = 0;
alarm(1); // 1 秒后收到 SIGALRM,默认动作是终止
while (1) {
printf("count: %d\n", count);
count++;
}
return 0;
}
c
// io_less.c ------ 只在最后打印一次(轻 IO)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
int count = 0;
void handler(int signo)
{
printf("count: %d\n", count); // 时间到了才打印
exit(0);
}
int main()
{
signal(SIGALRM, handler);
alarm(1);
while (1) {
count++; // 纯内存自增
}
return 0;
}
bash
$ ./io_more
...
count: 107148
Alarm clock # 默认动作:终止
$ ./io_less
count: 492333713 # 差了 4000 多倍
| 版本 | 1 秒内计数 | 结论 |
|---|---|---|
| 重 IO | 约 10 万 | 时间全耗在 printf 的系统调用上 |
| 轻 IO | 约 4.9 亿 | 绝大部分时间在做纯 CPU 运算 |
💡 顺手复习一下:
printf每调用一次都要经过 用户态 → 内核态 → 用户态 的切换,这个切换成本比一次自增高出几个数量级。这就是"减少 IO、批量输出"的底层原因。
5.4 pause 与"重复闹钟"
alarm 是一次性的:响过一次就自动取消。想周期触发,就得在处理函数里再设一次:
c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("闹钟响了,处理任务...\n");
int n = alarm(1); // 重新设定,返回值是上次剩余时间
printf("上次闹钟剩余: %d 秒\n", n);
}
int main()
{
printf("我的 pid 是: %d\n", getpid());
alarm(1); // 先设 1 秒
signal(SIGALRM, handler);
while (1) {
pause(); // 挂起,直到被信号唤醒
printf("我醒来了...\n");
}
return 0;
}
c
#include <unistd.h>
int pause(void); // 只有"被信号处理函数返回"时才返回 -1,errno = EINTR
| 现象 | 解释 |
|---|---|
| 正常情况每秒打印一次 | alarm(1) 到期 → 唤醒 pause → 处理函数重设闹钟 |
另一个终端 kill -14 pid 也能唤醒 |
14 号就是 SIGALRM,手动发一样有效 |
此时处理函数里 alarm 返回"剩余时间" |
说明原来的闹钟还没到期就被新闹钟顶掉了 |
⚠️ 别把 pause 当成 sleep:
sleep是"睡够时间",pause是"睡到有人叫我"。如果没有任何信号来,pause会 永远睡下去。
5.5 系统闹钟在内核里长什么样
操作系统要有定时能力,就必然要管理一堆定时器。管理手段依旧是那六个字------先描述,再组织:
c
// 内核中的定时器节点(简化)
struct timer_list {
struct list_head entry; // 组织成链表/堆
unsigned long expires; // 到期时间
void (*function)(unsigned long); // 到期后调用的函数
unsigned long data; // 传给函数的参数
};
真实内核用的是 时间轮(timer wheel),为了好理解,你也可以把它想成一个按到期时间排序的 小根堆:每次时钟中断只检查堆顶有没有到期,时间复杂度 O(1) 判断、O(logN) 插入。
六、💥 产生方式四:硬件异常
6.1 除零:从 CPU 到 SIGFPE
c
// div_zero.c ------ 模拟除零
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("catch a sig: %d\n", signo);
}
int main()
{
// signal(SIGFPE, handler); // 先注释掉,看默认行为
sleep(1);
int a = 10;
a /= 0; // 除零
while (1);
return 0;
}
bash
$ ./div_zero
Floating point exception (core dumped) # 默认动作:Core,8 号信号
打开捕捉再看看:
c
signal(SIGFPE, handler);
bash
$ ./div_zero
catch a sig: 8
catch a sig: 8
catch a sig: 8
... # 疯狂重复!
6.2 野指针:从 MMU 到 SIGSEGV
c
// null_ptr.c ------ 模拟野指针
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("catch a sig: %d\n", signo);
}
int main()
{
// signal(SIGSEGV, handler);
sleep(1);
int *p = NULL;
*p = 100; // 解引用空指针
while (1);
return 0;
}
bash
$ ./null_ptr
Segmentation fault (core dumped) # 11 号信号,Core
打开捕捉,同样会看到 11 号被 反复 捕获。
6.3 为什么信号会"一直重复"
这是很多人第一次做实验时最迷惑的地方:我只除了一次零,怎么 8 号信号跟机关枪一样?
| 层面 | 发生了什么 |
|---|---|
| CPU 层 | 运算单元发现除数为 0,EFLAGS 的溢出/异常标志位 被置位 |
| MMU 层 | 访问无映射或无权限的地址,触发 页错误 |
| 内核层 | 状态寄存器(可理解成一张位图)里还留着异常标记,内核一检查就再发一次信号 |
| 进程层 | 你只打印了一行字,没有清理现场,PC 还停在出错指令上 |
结论:只要出错指令没被跳过、出错现场没被修复,内核每次返回用户态都会重新发现这个异常,于是信号无限重发。
💡 正解: 处理这类信号时,处理函数里通常直接
exit(),或者用siglongjmp跳回安全点。指望return回到原地继续跑,只会陷入死循环。
6.4 子进程异常退出:status 里藏着信号
父进程 waitpid 拿到的 status 是个"压缩包",里面同时塞了退出码、终止信号和 core dump 标志:
c
// exit_status.c ------ 解析 status
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
int main()
{
if (fork() == 0) {
sleep(1);
int a = 10;
a /= 0; // 子进程除零
exit(0);
}
int status = 0;
waitpid(-1, &status, 0);
printf("exit signal: %d, core dump: %d\n",
status & 0x7F, // 低 7 位:终止信号
(status >> 7) & 1); // 第 7 位:是否产生了 core
return 0;
}
bash
$ ./exit_status
exit signal: 8, core dump: 1
| 位域 | 含义 |
|---|---|
| 0, 6 低 7 位 | 终止进程的信号编号(0 表示正常退出) |
| 第 7 位 | core dump 标志(1 表示产生了 core 文件) |
| 8, 15 | 正常退出时的退出码 |
💡 建议: 别手写位运算,直接用
WIFEXITED、WEXITSTATUS、WIFSIGNALED、WTERMSIG这些宏,可读性好得多。
七、🗄️ core dump:崩溃现场的黑匣子
7.1 什么是 core dump
Core Dump:进程异常终止时,把 用户空间的全部内存数据 写到磁盘上,生成一个通常叫 core 的文件。
text
进程崩溃 ──► 内存中的代码段/数据段/堆/栈 ──► core 文件(磁盘)
│
事后用 gdb 打开分析
这套事后调试的方法叫 Post-mortem Debug(验尸调试)------程序都死了,还能查到它是怎么死的。
7.2 为什么默认不产生 core
bash
$ ulimit -a
core file size (blocks, -c) 0 # 0 = 禁止产生 core
| 不开启的原因 | 说明 |
|---|---|
| 占用磁盘 | 一个进程的地址空间可能几个 G,崩几次磁盘就满了 |
| 泄露敏感信息 | core 里是完整内存镜像,可能包含密码、密钥 |
| 生产环境不该留现场 | 云服务器以稳定运行为第一要务,不需要调试残留 |
⚠️ 云服务器上 core dump 基本是关的: 想排查线上崩溃,正确做法是接 崩溃日志采集 / coredumpctl,而不是在业务机上狂写 core 文件。
7.3 打开 core,用 gdb 定位问题行
bash
$ ulimit -c 4096 # 允许 core 文件最大 4096 个 block(约 4MB)
$ ulimit -c 0 # 关闭
$ ulimit -c unlimited # 不限大小
注意 ulimit 改的是 当前 shell 进程 的资源限制。因为子进程的 PCB 是从 shell 复制来的,所以在这个 shell 里启动的程序都会继承这个限制。
bash
$ ulimit -c 10240
$ ./null_ptr
Segmentation fault (core dumped) # 这次真的产生了 core
$ ls -l core*
-rw------- 1 user user 380928 Oct 29 10:00 core.5001
$ gdb ./null_ptr core.5001
(gdb) bt
#0 0x00000000004011a6 in main () at null_ptr.c:18 # 直接指到出问题的那一行
💡 效率对比: 没有 core 时,你只能靠"加打印 + 反复复现"猜问题;有了 core,
bt一条命令就能看到崩溃时的完整调用栈。这就是"能不能定位到问题行"的差距。
| 默认动作 | 是否产生 core | 例子 |
|---|---|---|
| Term | 否 | SIGINT(2)、SIGKILL(9)、SIGTERM(15) |
| Core | 是 | SIGQUIT(3)、SIGFPE(8)、SIGSEGV(11)、SIGABRT(6) |
八、📥 信号的保存:内核里的三张表
8.1 三个必须分清的概念
| 概念 | 英文 | 含义 |
|---|---|---|
| 递达 | Delivery | 真正执行信号处理动作的那一刻 |
| 未决 | Pending | 信号已产生、尚未递达的中间状态 |
| 阻塞 | Block | 进程主动"关上门",让某信号暂时不能递达 |
三者的关系可以用一句话概括:
💡 信号产生后先挂在 pending;如果它被 block 了,就一直挂着;解除阻塞后立刻递达。
8.2 内核用三张表描述这一切
task_struct 里跟信号相关的字段大致是这样:
c
struct task_struct {
...
struct sighand_struct *sighand; // ① 处理动作表
sigset_t blocked; // ② 阻塞信号集(信号屏蔽字)
struct sigpending pending; // ③ 未决信号集
...
};
struct sighand_struct {
atomic_t count;
struct k_sigaction action[_NSIG]; // 数组下标就是信号编号
spinlock_t siglock;
};
struct sigpending {
struct list_head list; // 实时信号的队列
sigset_t signal; // 普通信号的未决位图
};
| 表 | 作用 | 通俗理解 |
|---|---|---|
| blocked | 记录哪些信号被屏蔽 | 门锁清单 |
| pending | 记录哪些信号已到未处理 | 门口堆着的快递 |
| sighand→action | 记录每个信号怎么处理 | 处理方案手册 |
text
task_struct
┌───────────────────────────────────────────┐
│ blocked : 0 0 1 0 ... 0 ← 屏蔽字 │
│ pending : 0 1 0 0 ... 0 ← 未决位图 │
│ sighand ──► action[1..64] │
│ action[2] = handler │
│ action[3] = SIG_IGN │
│ action[17]= SIG_DFL │
└───────────────────────────────────────────┘
手册上的经典例子正好用这三张表解释:
| 信号 | 未决 | 阻塞 | 处理动作 | 结果 |
|---|---|---|---|---|
| SIGHUP | 否 | 否 | 默认 | 一旦产生立刻按默认处理 |
| SIGINT | 是 | 是 | 忽略 | 一直挂着,连"忽略"都做不到 |
| SIGQUIT | 否 | 是 | 自定义 | 一旦产生就挂着,等解除阻塞再回调 |
⚠️ 注意 SIGINT 那一行: 处理动作写着"忽略",但因为被阻塞,它连"被忽略"的资格都没有------它压根没递达。这就是"阻塞"和"忽略"最本质的区别。
8.3 忽略 vs 阻塞
| 对比项 | 阻塞(Block) | 忽略(Ignore) |
|---|---|---|
| 发生时机 | 递达 之前 | 递达 之后 |
| 信号是否到达进程 | 一直待在 pending | 已经递达了 |
| 能否反悔 | 能:解除阻塞后照样处理 | 当次已丢弃(普通信号只记一次) |
| 实现方式 | sigprocmask(SIG_BLOCK, ...) |
signal(sig, SIG_IGN) |
| 典型用途 | 保护临界区 | 明确表示"此事与我无关" |
text
阻塞: 产生 ──X(门锁了)──► pending 挂着 ──解除──► 递达 ──► 处理
忽略: 产生 ────────────► 递达 ──► 动作是 SIG_IGN ──► 丢弃
8.4 普通信号只记一次
如果解除阻塞前同一个信号来了很多次,会怎样?
| 信号类型 | 内核行为 |
|---|---|
| 普通信号(1~31) | pending 只有 1 bit,来 100 次也只记 1 次 |
| 实时信号(34~64) | 排队,来几次就记几次,按顺序递达 |
💡 这就是"信号会丢"的根源。 如果你的程序靠信号来计数(比如统计某事件发生次数),普通信号必然丢事件,得改用别的机制。
九、🧰 信号集与屏蔽:sigprocmask / sigpending 实战
9.1 sigset_t 与五个操作函数
sigset_t 本质是一张 位图(Linux 上通常是 1024 bit 的数组,但普通信号只用前 32 位)。标准明确说:不要直接访问它的内部,只能通过函数操作。
c
#include <signal.h>
int sigemptyset(sigset_t *set); // 全部清 0
int sigfillset(sigset_t *set); // 全部置 1
int sigaddset(sigset_t *set, int signo); // 添加某个信号
int sigdelset(sigset_t *set, int signo); // 删除某个信号
int sigismember(const sigset_t *set, int signo); // 判断是否包含
| 函数 | 成功返回 | 失败返回 |
|---|---|---|
| sigemptyset / sigfillset / sigaddset / sigdelset | 0 | -1 |
| sigismember | 1(包含)/ 0(不包含) | -1 |
⚠️ 使用前必须初始化:
sigset_t是栈上的局部变量,里面是随机垃圾。不调用sigemptyset就直接sigaddset,等于在垃圾数据上做或运算,屏蔽了哪些信号完全不可控。
9.2 位图是怎么算出来的
自己实现 sigismember 的核心逻辑,就三行:
c
// 判断第 signo 号信号是否在 set 中
int index = signo / 32; // 第几个 32 位整数(第几个"字")
int pos = signo % 32; // 这个字里的第几位
return (set->bits[index] >> pos) & 1;
💡 为什么要位图? 因为信号的本质就是"有 / 没有"两种状态,用 1 bit 表示最省空间。一个
sigset_t用 32 bit 就能描述 31 个普通信号,判断、设置、清除都是 O(1)。
9.3 sigprocmask:修改屏蔽字
c
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
| how 取值 | 语义 | 等价运算 |
|---|---|---|
| SIG_BLOCK | 把 set 里的信号加入屏蔽 | `mask = mask |
| SIG_UNBLOCK | 把 set 里的信号从屏蔽中移除 | mask = mask & ~set |
| SIG_SETMASK | 直接把 mask 覆盖成 set | mask = set |
oldset 是输出型参数:传非空指针,就能拿到修改前的屏蔽字,方便后续还原。
⚠️ 一条容易忽略的规则: 如果
sigprocmask解除阻塞时,pending 里正好有该信号,那么 在函数返回之前,至少会有一个信号被递达。
9.4 sigpending:查询未决信号集
c
#include <signal.h>
int sigpending(sigset_t *set); // 成功 0,失败 -1
它只能"读",不能"写"。想修改 pending,唯一的办法就是 再发一个信号。
9.5 完整实验:屏蔽 2 号信号,观察 pending
这个实验把前面所有概念串起来了:
c
// pending_demo.c ------ 屏蔽 SIGINT,观察 pending 位图
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
// 从高到低打印,左边是 31 号,右边是 1 号
void PrintPending(const sigset_t *pending)
{
printf("pending: ");
for (int signo = 31; signo >= 1; signo--) {
if (sigismember(pending, signo)) {
printf("1");
} else {
printf("0");
}
}
printf("\n");
}
void handler(int signo)
{
printf("%d 号信号被递达!!!\n", signo);
sigset_t pending;
sigpending(&pending); // 在处理函数里再看一眼 pending
PrintPending(&pending);
}
int main()
{
// ========== 0. 先注册捕捉动作 ==========
signal(SIGINT, handler);
// ========== 1. 构造屏蔽集合 ==========
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
sigaddset(&block_set, SIGINT); // 只想屏蔽 2 号
// ========== 2. 真正写进内核的 blocked 表 ==========
sigprocmask(SIG_BLOCK, &block_set, &old_set);
int cnt = 15;
while (1) {
sigset_t pending;
sigpending(&pending); // 3. 读 pending
PrintPending(&pending); // 4. 打印
if (--cnt == 0) {
printf("解除对 2 号信号的屏蔽!!!\n");
sigprocmask(SIG_SETMASK, &old_set, NULL); // 5. 恢复原屏蔽字
}
sleep(1);
}
return 0;
}
bash
$ ./pending_demo
pending: 0000000000000000000000000000000
pending: 0000000000000000000000000000000
^C # 这里按了 Ctrl+C
pending: 0000000000000000000000000000010 # 第 2 位变成 1,说明未决
pending: 0000000000000000000000000000010
...
解除对 2 号信号的屏蔽!!!
2 号信号被递达!!! # 立刻递达,回调 handler
pending: 0000000000000000000000000000000 # 递达后 pending 被清零
把每一行对上一个概念:
| 阶段 | pending 第 2 位 | 说明 |
|---|---|---|
| 屏蔽前 | 0 | 什么都没发生 |
| 按下 Ctrl+C 后 | 1 | 信号产生,但被阻塞,只能未决 |
| 继续按 Ctrl+C | 仍然是 1 | 普通信号只记一次 |
| 解除阻塞后 | 0 | 已递达,pending 清除 |
💡 补充实验: 屏蔽期间按 Ctrl + \,程序照样立刻死掉------因为 3 号 SIGQUIT 并没有被阻塞。这正好证明了"屏蔽是精确到单个信号的"。
十、🎯 信号的捕捉:内核态返回用户态的那一瞬间
10.1 一个关键问题:进程什么时候处理信号?
信号产生了,pending 也置位了,可进程正在跑自己的 main,它什么时候才"回头看"?
答案:进程从内核态返回用户态之前。
为什么是这个时刻?因为进程只有在 内核态 才有权限读自己的 task_struct。用户态代码根本碰不到内核里的那几张表。
10.2 完整的捕捉流程
假设用户注册了 SIGQUIT 的处理函数 sighandler,此刻正在执行 main:
text
① 用户态执行 main
│
│ 发生中断 / 异常 / 系统调用
▼
② 陷入内核态,处理中断
│
│ 处理完毕,准备返回用户态
▼
③ 内核检查 pending:发现 SIGQUIT 已递达
│
├── 若无信号 ──► 直接恢复 main 的上下文
│
▼
④ 不恢复 main,改为跳到 sighandler 执行(用户态)
│
│ sighandler 返回
▼
⑤ 自动执行系统调用 sigreturn → 再次进入内核态
│
▼
⑥ 没有新信号了 → 恢复 main 的上下文,继续跑
用图看更直观:
text
用户态 内核态
┌────────┐ ┌──────────┐
│ main │ ──中断──► │ do_signal│
│ │ │ 检查 │
│ │ ◄─否────── │ pending │
│ │ └──────────┘
│ │ │是
│sighandler│ ◄──────────────┘
│ │ ──sigreturn──► 内核
└────────┘
这里有三个极其重要的细节:
| 细节 | 说明 |
|---|---|
| 栈不同 | sighandler 用的是 用户栈上的独立空间,但和 main 之间 不存在调用关系 |
| 不是函数调用 | 它们只是"碰巧"在同一个进程里执行的两个独立控制流 |
| 靠 sigreturn 收尾 | handler 返回时要靠 sigreturn 系统调用恢复现场,而不是普通的 ret |
💡 为什么必须进内核才能返回? 因为处理信号时内核可能改过寄存器、栈指针、屏蔽字。这些"账"必须由内核来销,用户态代码没权限也没能力完成。
10.3 内部函数名对照
看内核源码(或者自己用 gdb 跟)时,会看到这些名字:
| 函数 | 作用 |
|---|---|
| do_signal() | 从内核态返回用户态前,检查并处理待递达信号 |
| sighandler() | 用户注册的自定义处理函数 |
| sigreturn() | 处理函数返回后,恢复被中断的上下文 |
把这三个串起来,就是很多人画不出来的那张图:
text
main ──(中断)──► 内核 ──do_signal──► sighandler
▲ │
└────sigreturn────────┘
10.4 顺带补一课:用户态与内核态
信号机制之所以要"卡"在返回用户态的那一刻,是因为 CPU 把权限分成了两个世界:
| 对比项 | 用户态 | 内核态 |
|---|---|---|
| CPU 特权级(CPL) | 3(ring 3) | 0(ring 0) |
| 可访问地址空间 | 0 ~ 3G | 0 ~ 4G |
| 能否执行特权指令 | 不能 | 能 |
| 能否直接操作硬件 | 不能 | 能 |
| 出错后果 | 进程崩,系统仍在 | 整机可能宕机 |
进入内核态的三条路:
| 方式 | 触发者 | 例子 |
|---|---|---|
| 系统调用 | 进程主动 | int 0x80 / syscall |
| 异常 | 进程被动 | 除零、缺页、野指针 |
| 硬件中断 | 外部设备 | 键盘、网卡、时钟 |
切换时 CPU 要做的事:
text
1. 保存用户态现场(寄存器、栈指针等)
2. 提权:CPL 从 3 改成 0
3. 切换栈:从 TSS 里取出 SS0 / ESP0,换成内核栈
4. 执行内核代码
5. 返回前恢复现场,CPL 改回 3,切回用户栈
⚠️ 为什么进内核要换栈? 因为用户栈是 不可信的。用户可以随便改 ESP,如果内核还在用户栈上跑,那用户就能通过操控栈指针来影响内核执行流------这是赤裸裸的提权漏洞。
十一、🪝 更现代的捕捉方式:sigaction
11.1 为什么 signal 不够用
signal() 有两个历史遗留问题:
| 问题 | 说明 |
|---|---|
| 语义不统一 | 早期 System V 上,处理函数执行一次后动作会被重置成默认 |
| 能力太弱 | 无法在处理期间额外屏蔽一批信号,也无法带 siginfo_t |
所以工程代码基本都用 sigaction:
c
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);
| 参数 | 类型 | 含义 |
|---|---|---|
| signo | int | 信号编号 |
| act | 输入 | 新的处理动作(非空则修改) |
| oldact | 输出 | 原来的处理动作(非空则回填) |
11.2 struct sigaction 的字段
c
struct sigaction {
void (*sa_handler)(int); // ① 普通处理函数
void (*sa_sigaction)(int, siginfo_t *, void *); // ② 实时信号处理函数
sigset_t sa_mask; // ③ 处理期间额外屏蔽的信号
int sa_flags; // ④ 行为选项
void (*sa_restorer)(void); // ⑤ 一般不用
};
sa_handler 的三种取值和 signal 完全一致:
| 赋值 | 效果 |
|---|---|
SIG_IGN |
忽略 |
SIG_DFL |
默认动作 |
| 函数指针 | 自定义捕捉(回调) |
11.3 sa_mask:防止信号"套娃"
这是 sigaction 最有价值的设计。规则是:
当某个信号的处理函数被调用时,内核自动把"当前信号"加入进程的信号屏蔽字;处理函数返回后自动恢复。
也就是说,你在处理 2 号信号的过程中,又来了一个 2 号信号,它 不会打断当前的 handler,而是挂起等处理完再说。这天然防止了 递归调用 把栈撑爆。
如果还想额外屏蔽别的信号,往 sa_mask 里加就行:
c
// sigaction_demo.c ------ 处理 SIGINT 期间同时屏蔽 SIGQUIT
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("[handler] 开始处理 %d 号信号\n", signo);
sleep(3); // 故意睡久一点,制造"重入窗口"
printf("[handler] 处理 %d 号信号结束\n", signo);
}
int main()
{
struct sigaction act, oldact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGQUIT); // 处理期间顺便屏蔽 3 号
sigaction(SIGINT, &act, &oldact);
while (1) {
printf("main 正在运行...\n");
sleep(1);
}
return 0;
}
bash
$ ./sigaction_demo
main 正在运行...
^C[handler] 开始处理 2 号信号
^C # 处理期间再按,被屏蔽,挂起
^\ # 3 号也被 sa_mask 挡住了
[handler] 处理 2 号信号结束
[handler] 开始处理 2 号信号 # 前一个处理完,挂起的才被递达
11.4 signal 与 sigaction 对照
| 维度 | signal | sigaction |
|---|---|---|
| 接口年代 | 早期 ANSI C | POSIX 标准 |
| 是否自动屏蔽当前信号 | 依赖实现 | 保证 |
| 能否额外屏蔽信号 | ❌ | ✅ sa_mask |
| 能否拿到发送者信息 | ❌ | ✅ siginfo_t |
| 工程推荐度 | 教学 / 简单场景 | 生产环境首选 |
十二、🔁 可重入函数:一个链表节点的冤案
12.1 现场还原
假设有一个插入函数,往链表头插节点,分两步走:
c
// ⚠️ 这段代码有问题,仅用于演示
node_t *head = NULL;
void insert(node_t *p)
{
p->next = head; // 第 1 步:新节点指向旧头
// ★ 如果在这里被打断......
head = p; // 第 2 步:更新头指针
}
现在 main 正在调用 insert(&node1),刚做完第 1 步就被信号打断,信号处理函数里也调了 insert(&node2):
c
void sighandler(int signo)
{
insert(&node2); // 处理函数里又插了一个节点
}
int main()
{
...
insert(&node1);
...
}
12.2 时序分析:node2 去哪了
| 时刻 | 执行流 | 动作 | head | node1->next | node2->next |
|---|---|---|---|---|---|
| T1 | main | insert(&node1) 第 1 步 |
NULL | NULL | - |
| T2 | --- | 被信号打断 | NULL | NULL | - |
| T3 | handler | insert(&node2) 第 1 步 |
NULL | NULL | NULL |
| T4 | handler | insert(&node2) 第 2 步 |
node2 | NULL | NULL |
| T5 | main | insert(&node1) 第 2 步 |
node1 | NULL | NULL |
结果:
text
最终链表: head ──► node1 ──► NULL
node2: 谁都不指向它 → 内存泄漏
明明两个 insert 都"成功"返回了,链表里却只有一个节点。这就是 重入(Reentrancy) 造成的数据错乱。
12.3 可重入与不可重入
| 类型 | 定义 | 例子 |
|---|---|---|
| 可重入函数 | 只访问 自己的局部变量 / 参数,多流并发调用互不影响 | 纯粹的计算函数 |
| 不可重入函数 | 访问了全局 / 静态数据、堆、标准 IO | 上面那个 insert |
判断标准(满足任意一条即不可重入):
| 条件 | 原因 |
|---|---|
调用了 malloc / free |
堆是用全局链表管理的 |
调用了标准 IO(printf / fgets 等) |
内部有全局缓冲区 |
| 使用了全局或静态变量 | 多流共享同一块内存 |
| 返回了指向静态数据的指针 | 下次调用会覆盖 |
⚠️ 这条规则直接决定 handler 里能写什么: 信号处理函数随时可能打断主流程,所以它里面 不该调用 printf、malloc 这类不可重入函数。真要打印,正确做法是往 handler 里写一个
volatile sig_atomic_t标志,让主流程去打印。
十三、⚡ volatile:为什么 -O2 之后信号改不动 while
13.1 现象:flag 明明被改了,循环还在转
c
// flag_demo.c
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int signo)
{
printf("change flag 0 to 1\n");
flag = 1; // 信号处理函数里修改 flag
}
int main()
{
signal(2, handler);
while (!flag); // 主流程在这里死等
printf("process quit normal\n");
return 0;
}
不优化编译,一切正常:
bash
$ gcc flag_demo.c -o flag_demo
$ ./flag_demo
^Cchange flag 0 to 1
process quit normal # 正常退出
加上 -O2 就出鬼了:
bash
$ gcc flag_demo.c -O2 -o flag_demo
$ ./flag_demo
^Cchange flag 0 to 1
^Cchange flag 0 to 1 # flag 改了,但 while 还在转
^Cchange flag 0 to 1
13.2 根因:内存不可见
text
CPU 寄存器 内存
┌──────────────┐ ┌──────────┐
│ flag = 0 │ ◄──读一次─│ flag = 0 │
│ (一直用这份) │ └──────────┘
└──────────────┘ ▲
│ handler 改的是这里
| 视角 | 看到的东西 |
|---|---|
| 编译器 | main 里没有任何代码修改 flag,那 while(!flag) 的条件恒为真,直接优化成 while(1) |
| CPU | 从寄存器读 flag,永远不会去内存重读 |
| handler | 把 内存 里的 flag 改成了 1,但没人去读 |
| 结果 | 内存不可见(Memory Invisibility) |
这条也是编译器优化的合法行为------它并不知道有另一个"控制流"会偷偷改内存,因为信号对编译器来说完全是不可见的。
13.3 解法:volatile
c
volatile int flag = 0;
bash
$ gcc flag_demo.c -O2 -o flag_demo
$ ./flag_demo
^Cchange flag 0 to 1
process quit normal # 正常退出
volatile 的三个作用:
| 作用 | 说明 |
|---|---|
| 禁止优化 | 每次访问都老老实实去 内存 读 / 写 |
| 保证可见性 | 一个执行流的修改,另一个执行流一定能看到 |
| 不保证原子性 | 它不能替代锁! flag++ 依然可能被打断 |
⚠️ 常见误区: "volatile 能保证线程安全"是彻底的错误。它只解决"看得见"的问题,不解决"改到一半被打断"的问题。多线程场景该用 原子操作 / 互斥量。
十四、👶 SIGCHLD:把轮询从父进程手里拿走
14.1 回收子进程的三种姿势
回忆进程控制那一章,父进程回收子进程有三种做法:
| 做法 | 代码 | 缺点 |
|---|---|---|
| 阻塞等待 | wait(&status) |
父进程被卡住,啥也干不了 |
| 非阻塞轮询 | while (waitpid(-1, &s, WNOHANG) == 0) |
父进程要不停"回头看",浪费 CPU |
| 信号驱动 | signal(SIGCHLD, handler) |
本节主角 |
14.2 SIGCHLD 的默认动作是"忽略"
| 属性 | 值 |
|---|---|
| 编号 | 17 |
| 默认动作 | Ign(Child stopped or terminated) |
| 发送者 | 子进程(终止或停止时) |
| 接收者 | 父进程 |
因为默认是"忽略",很多人做了实验后误以为"子进程退出时父进程收不到通知"。实际上信号 确实发了,只是被默认丢弃了。你只要注册自己的处理函数,就能接住它。
14.3 完整示例
c
// sigchld_demo.c ------ 用 SIGCHLD 自动回收子进程
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void handler(int signo)
{
pid_t id;
// 必须循环回收:一次信号可能对应多个已退出子进程
while ((id = waitpid(-1, NULL, WNOHANG)) > 0) {
printf("回收子进程成功: %d\n", id);
}
printf("handler 结束, pid = %d\n", getpid());
}
int main()
{
signal(SIGCHLD, handler); // 注册捕捉
pid_t cid;
if ((cid = fork()) == 0) {
printf("child: %d\n", getpid());
sleep(3);
exit(1); // 子进程 3 秒后退出
}
while (1) {
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}
bash
$ ./sigchld_demo
child: 5001
father proc is doing some thing!
father proc is doing some thing!
father proc is doing some thing!
回收子进程成功: 5001 # 子进程退出,自动被回收
handler 结束, pid = 5000
三个必须注意的点:
| 要点 | 说明 |
|---|---|
| 必须用循环 | 多个子进程同时退出,普通信号只记 1 次,一次 handler 要回收干净 |
| 必须用 WNOHANG | 否则 waitpid 会阻塞在 handler 里,父进程被"冻住" |
| 回收完要判断 | waitpid 返回 0 表示"还有子进程在跑",返回 -1 表示"没子进程了" |
14.4 一个特例:把 SIGCHLD 设成 SIG_IGN
POSIX 有个著名特例:如果父进程显式地把 SIGCHLD 的处理动作设为 SIG_IGN,那么子进程退出时会被内核 自动回收,连僵尸都不会产生:
c
signal(SIGCHLD, SIG_IGN); // 子进程退出后自动清理,不通知父进程
| 写法 | 子进程退出后 | 是否产生僵尸 |
|---|---|---|
| 默认(Ign) | 变成僵尸,等父进程 wait | 会 |
signal(SIGCHLD, SIG_IGN) |
内核直接回收 | 不会 |
| 自定义 handler + waitpid | handler 里回收 | 不会 |
💡 为什么"默认忽略"和"显式忽略"效果不同? 因为默认的忽略是"信号层面的忽略"(信号丢了,子进程还在),而显式设置
SIG_IGN会被内核特殊处理成"自动回收语义"。这是历史遗留的特例,别指望它在所有 UNIX 上都成立。
十五、🧠 拔高:操作系统到底是怎么跑起来的
信号一路讲下来,其实已经反复触碰一个更大的问题:内核自己是怎么被"叫醒"的?
15.1 硬件中断:外设叫醒 CPU
text
外设就绪 ──► 中断控制器 ──► 通知 CPU ──► 保存现场 ──► 查中断向量表 ──► 执行处理例程 ──► 恢复现场
| 概念 | 说明 |
|---|---|
| 中断向量表 | 一个 函数指针数组,下标是中断号,存的是处理函数地址 |
| 加载时机 | 操作系统启动时就加载到内存 |
| 好处 | OS 不需要轮询外设,外设有事自己"喊" |
Linux 0.11 的 trap_init() 就是在填这张表:
c
void trap_init(void)
{
int i;
set_trap_gate(0, ÷_error); // 0 号:除零
set_trap_gate(1, &debug);
set_trap_gate(2, &nmi);
set_system_gate(3, &int3);
...
set_trap_gate(13, &general_protection);
set_trap_gate(14, &page_fault); // 14 号:缺页
...
set_system_gate(0x80, &system_call);// 系统调用入口
}
💡 注意
set_trap_gate(14, &page_fault): 缺页异常在这里只是一个"表项"。所谓"缺页中断",本质就是 CPU 触发了 14 号异常,跳进这个函数去补页表。
15.2 时钟中断:让 OS 自己动起来
外设可以主动发中断,那有没有一个"自带节奏"的设备?有------可编程定时器。
text
定时器 ──固定频率──► CPU ──► 0x20 号中断 ──► timer_interrupt ──► do_timer ──► schedule ──► switch_to
c
void sched_init(void)
{
...
set_intr_gate(0x20, &timer_interrupt); // 时钟中断
set_system_gate(0x80, &system_call); // 系统调用
}
于是整条链路成立了:
| 环节 | 说明 |
|---|---|
| 时钟以固定频率发中断 | 这个频率就是 CPU 的 主频 的量级来源 |
每次中断调 do_timer |
计时 + 检查当前进程时间片 |
| 时间片用完 | 调 schedule() 切换进程 |
| 结果 | OS 在硬件的推动下 自动调度 |
💡 顺手回答三个老问题:
- 什么是时间片?------ 两次时钟中断之间给进程的那段执行时间。
- 为什么 CPU 有主频?------ 主频决定了时钟中断的频率,也就决定了调度的粒度。
- 为什么主频越高 CPU 越快?------ 单位时间内能完成的指令更多(调度只是其中一个侧面)。
15.3 死循环:OS 的本质
既然一切由中断驱动,那 OS 自己的"主函数"在干什么?
c
void main(void)
{
...
for (;;)
pause(); // 没事就睡,等中断把我叫醒
}
操作系统就是一个死循环------它自己不做任何业务,只是在
pause()里等中断;中断来了就查向量表,跳到对应处理函数干活,干完继续睡。
15.4 软中断:系统调用与异常
外设能触发中断,软件能不能?能,CPU 专门设计了指令:
| 指令 | 架构 | 用途 |
|---|---|---|
| int 0x80 | x86 32 位 | 系统调用入口 |
| syscall | x86-64 | 系统调用入口(更快) |
c
// 用户层看到的
int open(const char *pathname, int flags);
// 实际发生的
mov eax, 2 // 2 是 open 的系统调用号
int 0x80 // 陷入内核
内核侧的处理逻辑:
c
// 1. 取出系统调用号
// 2. 查表跳转
call [_sys_call_table + eax * 4]
c
// 系统调用表:一个函数指针数组,下标就是调用号
fn_ptr sys_call_table[] = {
sys_setup, sys_exit, sys_fork, sys_read, sys_write,
sys_open, sys_close, sys_waitpid, ...
};
| 结论 | 说明 |
|---|---|
| 系统调用号的本质 | 数组下标 |
| 谁提供调用号 | 内核(unistd.h 里的 __NR_open 等宏) |
| 为什么我们没见过 int 0x80 | glibc 把几乎全部系统调用都 封装 成了库函数 |
15.5 陷阱、异常、中断:三个词的区别
| 名称 | 触发源 | 例子 | 同步性 |
|---|---|---|---|
| 中断 | 外部硬件 | 键盘、网卡、时钟 | 异步 |
| 陷阱(Trap) | 软件主动指令 | int 0x80、syscall |
同步 |
| 异常(Exception) | CPU 执行出错 | 除零、缺页、越界 | 同步 |
💡 所以"缺页异常"为什么叫异常? 因为它是 CPU 在执行某条指令时自己发现的错误状态,不是外部设备拍过来的。而"时钟中断"是外部定时器发的,所以叫中断。命名本身就在告诉你触发源。
15.6 一个惊人的事实:内核在每个进程的地址空间里
回顾地址空间那张图:
text
4G ┌──────────────────┐
│ 内核空间 1G │ ← 所有进程共享同一份内核代码/数据
3G ├──────────────────┤
│ 用户空间 3G │ ← 每个进程各有一份
0G └──────────────────┘
| 结论 | 说明 |
|---|---|
| 内核映射在每个进程的 3~4G | 所以进程一进内核态就能"就地"执行内核代码 |
| 内核代码只有一份 | 所有进程 共享,不用切换页表 |
| 用户区各不相同 | 每个进程有独立的用户空间 |
| 切换进程不换内核 | 这就是"无论怎么切进程,都能找到同一个操作系统"的原因 |
⚠️ 那为什么不安全? 因为 CPL 特权级 会拦着:用户态下访问 3~4G 会直接触发异常。地址空间共享,但权限不共享。
十六、📊 全篇总结
16.1 一张图串起全文
text
┌─────────────────────────────┐
产生信号 ───────► │ ① 终端按键 Ctrl+C \\ Z │
(四类来源) │ ② 命令/调用 kill raise abort│
│ ③ 软件条件 alarm pipe │
│ ④ 硬件异常 除零 野指针 │
└──────────────┬──────────────┘
│ 内核改位图
▼
┌─────────────────────────────┐
保存信号 ───────► │ blocked 屏蔽字 │
(三张表) │ pending 未决位图 │
│ sighand handler 表 │
└──────────────┬──────────────┘
│ 返回用户态前检查
▼
┌─────────────────────────────┐
处理信号 ───────► │ ① 默认 ② 忽略 ③ 自定义捕捉 │
(三种动作) │ 自定义:do_signal → handler│
│ → sigreturn │
└─────────────────────────────┘
16.2 核心数字速记
| 数字 | 含义 |
|---|---|
| 1 ~ 31 | 普通信号区间 |
| 34 ~ 64 | 实时信号区间 |
| 2 | SIGINT,Ctrl + C |
| 3 | SIGQUIT,Ctrl + \,产生 core |
| 6 | SIGABRT,abort |
| 8 | SIGFPE,除零 |
| 9 | SIGKILL,不可捕捉不可阻塞 |
| 11 | SIGSEGV,段错误 |
| 14 | SIGALRM,闹钟 |
| 17 | SIGCHLD,子进程状态变化 |
| 20 | SIGTSTP,Ctrl + Z |
| 0x80 | x86 系统调用中断号 |
16.3 易错点清单
| 易错点 | 正确理解 |
|---|---|
| 阻塞 = 忽略 | 错:阻塞在递达前,忽略在递达后 |
| signal 会立即执行处理函数 | 错:只注册,需等信号真正产生 |
| 信号来了就处理 | 错:要在内核态返回用户态时才检查 |
| 普通信号可以计数 | 错:只有 1 bit,多次只记一次 |
| volatile 能保证线程安全 | 错:只保证可见性,不保证原子性 |
| handler 里可以随便 printf | 错:标准 IO 不可重入 |
| 捕捉了 6 号信号 abort 就不会退出 | 错:abort 依然终止进程 |
| SIGCHLD 默认忽略 = 收不到 | 错:信号发了,只是被默认丢弃 |
十七、🤔 几个思考题
学完本文,来试试回答这些问题:
1️⃣ 进程能对信号做什么处理?什么时候处理?
答: 三种动作:默认、忽略、自定义捕捉。
处理时机是 进程从内核态返回用户态之前。因为只有在内核态才有权限访问 task_struct 里的 pending 位图。具体来说,中断 / 异常 / 系统调用处理完、准备返回用户态时,内核会调用 do_signal() 检查 pending,有未阻塞的信号就递达。
💡 这也是为什么"信号不会打断内核代码"------它只会在返回用户态的门口生效。
2️⃣ 阻塞和忽略到底差在哪?
答: 差在 递达的前后。
阻塞是"门锁了",信号一直待在 pending 里,进程连"要不要忽略"都还没决定;忽略是"快递进门了,我直接扔垃圾桶",信号已经递达,只是动作是 SIG_IGN。
所以阻塞期间你可以反悔(解除阻塞后照样处理),忽略则是当场丢弃(普通信号只记一次,丢了就没了)。
3️⃣ 为什么按 Ctrl+C 有时管用有时不管用?
答: 三个可能:
| 原因 | 判断方法 |
|---|---|
| 进程 在后台 | jobs 看有没有 + 标记;前台进程只有一个 |
| 进程 忽略了 2 号 | 代码里搜 SIG_IGN |
| 进程 阻塞了 2 号 | 代码里搜 sigprocmask |
注意:键盘信号只发给 前台进程组,后台进程只能用 kill 收拾。
4️⃣ 为什么除零之后 8 号信号会一直重复捕获?
答: 因为 出错现场没有被清理。
除零时 CPU 的 EFLAGS 里异常标志位置位,PC 还停在出错指令上。你的 handler 只打印了一行字就 return,内核恢复上下文后又回到那条除零指令,于是再次触发异常、再次发信号......形成死循环。
正确做法是在 handler 里 exit() 终止,或者用 siglongjmp 跳回安全点。
5️⃣ 为什么 9 号信号不能被捕捉、也不能被阻塞?
答: 这是内核留的 "后门"。如果 9 号可以被捕捉或阻塞,那一个恶意 / 出 bug 的进程就能让自己"杀不死",管理员将失去对系统的最终控制权。
同理 19 号 SIGSTOP 也不能被捕捉。这两个是内核的"绝对权威"。
6️⃣ 普通信号为什么不能用来计数?
答: 因为 pending 位图里每个信号只有 1 bit。来 100 次 2 号信号,pending 里第 2 位依然只是 1,递达一次就清零,剩下 99 次全部丢失。
要计数得用 实时信号(34~64 号会排队),或者干脆用别的 IPC 机制(管道、消息队列、共享内存)。
7️⃣ 信号处理函数里为什么不能随便调用 printf?
答: 因为 printf 不可重入。它内部用全局缓冲区(stdout 的 FILE 结构),如果主流程正在 printf 时被信号打断,handler 里又调 printf,两个控制流会同时操作同一块缓冲区,导致输出错乱甚至崩溃。
安全做法:handler 里只设置一个 volatile sig_atomic_t 标志,真正的打印交给主流程。
8️⃣ 加了 -O2 之后 while(!flag) 死循环,为什么?
答: 因为 内存不可见。编译器看到 main 里没有任何代码修改 flag,就把 flag 缓存进 寄存器,while(!flag) 被优化成 while(1)。信号处理函数改的是内存,但主流程永远读寄存器。
加 volatile 即可:强制每次都从内存读写。注意 volatile 只保证可见性,不保证原子性。
9️⃣ 为什么 SIGCHLD 是回收子进程的最佳姿势?
答: 因为它把"等待"从父进程手里拿走了。对比:
| 方案 | 父进程状态 |
|---|---|
wait() 阻塞 |
被卡住,不能干活 |
waitpid(WNOHANG) 轮询 |
能干活,但要不停回头看,浪费 CPU |
| SIGCHLD + waitpid | 平时专注干活,子进程退出时被 事件通知 |
注意 handler 里 必须用 while 循环 + WNOHANG,否则多个子进程同时退出时回收不干净,或者把父进程阻塞在 handler 里。
🔟 系统调用为什么"本质上是一次软中断"?
答: 因为用户态没法直接执行内核代码,只能通过 CPU 提供的 陷入指令(int 0x80 / syscall)把控制权交给内核:
text
用户态:mov eax, 2 ; int 0x80
↓
内核态:查 sys_call_table[2] → sys_open
↓
执行完 → 返回值放寄存器 → 返回用户态
整个过程和"键盘触发硬件中断"在机制上是同一套东西------都是 存现场 → 查表 → 跳转执行 → 恢复现场。区别只在于触发源:一个是外设,一个是软件指令。
💡 所以"系统调用号的本质是数组下标"------这句话看着邪门,其实就是全部真相。
本节完
📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!