
🔥承渊政道: 个人主页
❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》 《MySQL数据库学习》 《Python知识内容》 《cpolar知识学习》
✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介:

在Linux系统中,进程信号(Signal)是一种非常重要的异步事件通知机制.我们在终端中按下
Ctrl+C终止程序、使用kill命令结束进程,甚至程序运行过程中出现非法内存访问、除零异常等情况时,背后都离不开信号机制.看似简单的"通知",实际上涉及操作系统内核、进程控制块以及进程执行流程之间的一整套协作机制.很多人在刚接触信号时,往往只记住了SIGINT、SIGKILL、SIGTERM等几个常见信号,却容易忽略更关键的问题:信号究竟是什么?它是如何产生的?进程没有立即处理信号时,信号保存在哪里?所谓阻塞信号又是什么意思?当信号真正递达到进程时,进程又该如何捕捉并执行自定义处理逻辑?这些问题如果只停留在API的使用层面,很容易形成零散的知识点.因此,本篇文章将从 Linux 信号的底层机制出发,按照 "认识信号 → 产生信号 → 保存信号 → 捕捉信号" 的顺序逐步展开,重点分析信号的产生方式、未决信号与阻塞信号的关系、信号递达过程以及信号处理函数的工作机制.通过这一篇内容,我们不仅要学会"怎么发送和捕捉一个信号",更重要的是建立起完整的信号处理模型,真正理解 Linux 内核是如何借助信号完成进程控制、异常处理以及异步事件通知.

目录
- 1.快速认识信号
- 2.产生信号
- 3.保存信号
- 4.捕捉信号
- 5.可重⼊函数
- 6.volatile
- 7.SIGCHLD信号
- 8.拓展阅读
1.快速认识信号
1.1⽣活⻆度的信号
你在⽹上买了很多件商品,在等待不同商品快递的到来.但即便快递没有到来,你也知道快递来临时,你该怎么处理快递.也就是你能"识别快递".当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需10min之后才能去取快递.那么在这10min之内,你并没有下去去取快递,但是你是知道有快递到来了.也就是取快递的⾏为并不是⼀定要⽴即执⾏,可以理解成"在合适的时候去取".在收到通知,再到你拿到快递期间,是有⼀个时间窗⼝的,在这段时间,你并没有拿到快递,但是你知道有⼀个快递已经来了.本质上是你"记住了有⼀个快递要去取".当你时间合适,顺利拿到快递之后,就要开始处理快递了.⽽处理快递⼀般⽅式有三种:
1.执⾏默认动作(幸福的打开快递,使⽤商品)
2.执⾏⾃定义动作(快递是零⻝,你要送给你你的⼥朋友)
3.忽略快递(快递拿上来之后,扔掉床头,继续开⼀把游戏)
快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话.
1.2技术应⽤⻆度的信号
(1)样例
cpp
//testsig.cc
#include <iostream>
#include <unistd.h>
int main()
{
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}

⽤户输⼊命令,在Shell下启动⼀个前台进程.⽤户按下 Ctrl+C ,这个键盘输⼊产⽣⼀个硬件中断,被操作系统获取,解释成信号,发送给⽬标前台进程前台进程因为收到信号,进⽽引起进程退出.
(2)⼀个系统函数
cpp
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
其中最容易卡住的是这一句:
cpp
typedef void (*sighandler_t)(int);
它定义了一个函数指针类型 sighandler_t,这种函数必须满足:
cpp
void 函数名(int);
例如:
cpp
void handler(int signo)
{
cout << "收到信号: " << signo << endl;
}
然后:
cpp
signal(SIGINT, handler);
意思就是:
text
收到 SIGINT
↓
调用 handler(SIGINT)
例如完整代码:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signo)
{
cout << "捕获到信号: " << signo << endl;
}
int main()
{
signal(SIGINT, handler);
while (true)
{
cout << "I am a process, waiting signal!" << endl;
sleep(1);
}
return 0;
}
运行之后按:
text
Ctrl + C
终端会产生 SIGINT,通常编号是 2.由于你已经执行:
cpp
signal(SIGINT, handler);
所以进程不会直接按照 SIGINT 的默认行为退出,而会执行:
cpp
handler(2);
因此可能看到:
text
I am a process, waiting signal!
I am a process, waiting signal!
^C捕获到信号: 2
I am a process, waiting signal!
两个参数
cpp
signal(int signum, sighandler_t handler);
signum 是要处理哪个信号,例如:
cpp
SIGINT
SIGTERM
SIGALRM
SIGCHLD
handler 是收到信号以后怎么办,通常有三种写法:
cpp
signal(SIGINT, handler); // 自定义处理
signal(SIGINT, SIG_DFL); // 恢复默认处理
signal(SIGINT, SIG_IGN); // 忽略信号
所以可以记成:
text
signal(哪个信号, 怎么处理);
例如:
cpp
signal(SIGINT, SIG_IGN);
表示:
收到
SIGINT时忽略它.
还有一点很重要:signal() 的返回值也是:
cpp
sighandler_t
也就是它会返回这个信号原来的处理方式;失败则返回:
cpp
SIG_ERR
所以更完整地理解:
cpp
signal(SIGINT, handler);
就是:
把
SIGINT原来的处理方式替换为handler,同时把旧的处理方式返回给我.
你现在先把这句话记牢即可:
signal()= 修改某个信号的处理动作.
cpp
//testsig.cc
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGINT /*2*/, handler);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}

为什么按了
Ctrl+C,进程却没有退出?因为你用
signal(SIGINT, handler)把SIGINT的默认"终止进程"动作,改成了执行你自己的handler().
关键代码是:
cpp
void handler(int signumber)
{
std::cout << "我是:" << getpid()
<< ",我获得了一个信号:" << signumber
<< std::endl;
}
int main()
{
std::cout << "我是进程:" << getpid() << std::endl;
signal(SIGINT, handler);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
1.为什么这里进程不退出?
正常情况下,没有这句话:
cpp
signal(SIGINT, handler);
你按:
text
Ctrl+C
终端会产生 SIGINT 信号,Linux 中通常是:
text
SIGINT = 2
而 SIGINT 的默认处理动作是:
text
终止进程
所以正常情况是:
text
Ctrl+C
↓
SIGINT(2)
↓
默认处理动作
↓
进程退出
但是你的程序执行了:
cpp
signal(SIGINT, handler);
相当于告诉操作系统:
"以后我收到
SIGINT时,不要按照默认方式把我杀掉,请调用我的handler()."
于是变成:
text
Ctrl+C
↓
产生 SIGINT(2)
↓
操作系统把信号递送给进程
↓
发现该进程注册了 handler
↓
执行 handler(2)
↓
handler 执行结束
↓
返回原来的程序继续运行
而你的 handler() 只是:
cpp
std::cout << ...
它没有调用 exit(),也没有让 while(true) 结束.
因此 handler 执行完以后:
cpp
while (true)
继续运行.
所以看到:
text
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C我是:212569,我获得了一个信号:2
I am a process, I am waiting signal!
I am a process, I am waiting signal!
这正是程序应该出现的结果.
2.这个例子到底说明了什么?
它主要说明:
信号的处理动作可以被进程修改
一个信号来了以后,一般有三种处理方法:
text
收到 signal
│
┌─────────┼─────────┐
↓ ↓ ↓
默认处理 忽略 自定义处理
SIG_DFL SIG_IGN handler
例如:
cpp
signal(SIGINT, SIG_DFL);
恢复默认处理:
text
Ctrl+C → 退出
也可以:
cpp
signal(SIGINT, SIG_IGN);
表示忽略:
text
Ctrl+C → 什么都不做
你的代码:
cpp
signal(SIGINT, handler);
表示:
text
Ctrl+C → 执行 handler()
所以这个实验最重要的结论就是:
信号本身只是通知,收到信号以后到底做什么,可以由进程自己设定.
不过有两个特殊信号例外:
text
SIGKILL
SIGSTOP
这两个不能捕获,也不能忽略.
3."信号处理,是自己处理"是什么意思?
这里容易产生误解.
不是说:
进程自己产生信号、自己通知自己.
而是分成两个阶段:
text
信号产生、递送
↓
主要由操作系统负责
信号对应的处理动作
↓
可以由进程自己定义
程序通过:
cpp
signal(SIGINT, handler);
提前告诉操作系统:
如果将来收到
SIGINT,我要用handler()来处理.
真正收到信号后,操作系统会让当前进程去执行这个函数:
cpp
handler(2);
所以可以说:
操作系统负责"送信号",进程负责"怎么处理信号"。
4.用生活例子理解
假如进程就是你,操作系统就是快递员,信号就是快递,发信号的过程类似给你打电话.
假设:
text
你 = 进程
操作系统 = 快递/电话系统
Signal = 通知
handler = 你接到通知后的处理方式
你平时正在:
text
学习
学习
学习
学习......
对应程序:
cpp
while (true)
{
cout << "I am working..." << endl;
sleep(1);
}
现在有人给你打电话:
text
"有快递到了!"
相当于:
text
SIGINT 到达
默认情况
如果你事先没有特殊说明:
text
电话来了
↓
默认规则
↓
停止当前工作
对应:
text
Ctrl+C
↓
SIGINT
↓
SIG_DFL
↓
进程退出
你的程序现在的情况
你提前告诉"操作系统":
cpp
signal(SIGINT, handler);
相当于说:
"以后电话来了,不要让我直接停止工作.让我接一下电话,处理完继续学习."
于是:
text
你正在学习
↓
电话响了
↓
暂停一下当前工作
↓
接电话
↓
处理电话
↓
挂电话
↓
继续学习
对应程序:
text
main 中执行 while(true)
↓
收到 SIGINT
↓
暂时去执行 handler(2)
↓
handler 执行结束
↓
回到 main
↓
继续 while(true)
这就是为什么你的进程不退出。
5.Ctrl+C 的完整信号处理过程
把现在这个实验完整串起来:
text
① 程序启动
↓
② main() 开始执行
↓
③ signal(SIGINT, handler)
↓
注册 SIGINT 的处理方法
↓
④ 进入 while(true)
↓
⑤ 用户按 Ctrl+C
↓
⑥ 终端检测到 Ctrl+C
↓
⑦ 内核向前台进程发送 SIGINT
↓
⑧ 你的进程收到 SIGINT
↓
⑨ 内核发现你设置了 handler
↓
⑩ 暂停当前正常执行流程
↓
⑪ 执行 handler(2)
↓
打印:
"我获得了一个信号:2"
↓
⑫ handler 返回
↓
⑬ 恢复原来的执行流程
↓
⑭ while(true) 继续执行
这张流程非常重要.
可以把核心压缩成:
text
正常代码
↓
收到信号
↓
暂时执行 handler
↓
handler 返回
↓
继续正常代码
6.如果我就想让它收到 Ctrl+C 后退出呢?
可以自己在 handler 里面退出,例如教学上可以理解为:
cpp
void handler(int signumber)
{
std::cout << "收到信号:" << signumber << std::endl;
exit(0);
}
那么就是:
text
Ctrl+C
↓
SIGINT
↓
handler(2)
↓
exit(0)
↓
进程退出
或者不要注册:
cpp
signal(SIGINT, handler);
那么 SIGINT 使用默认动作:
text
Ctrl+C → SIGINT → 终止
1.3信号概念
在Linux里,信号(Signal)是一种进程之间或内核向进程发送的异步通知机制 .
可以先记成一句话:
信号不是数据,而是"某件事发生了"的通知.
比如:
- 你按
Ctrl+C→ 终端产生SIGINT - 程序访问非法内存 → 内核产生
SIGSEGV - 子进程退出 → 父进程收到
SIGCHLD - 执行
kill -15 PID→ 给目标进程发送SIGTERM
信号到达进程后,进程通常有三种处理方式:
text
默认处理
忽略信号
自定义处理函数 handler
例如:
cpp
signal(SIGINT, handler);
表示:
当进程收到
SIGINT时,不执行默认动作,而是调用handler().
所以前面的程序按 Ctrl+C 后不退出,是因为:
text
Ctrl+C
↓
SIGINT
↓
进程收到信号
↓
执行 handler()
↓
handler 返回
↓
程序继续运行
信号的几个关键特点
-
异步
程序不知道信号什么时候来,它可能正在执行其他代码时突然收到.
-
信号有编号
例如常见的:
SIGINT→ 2SIGKILL→ 9SIGTERM→ 15
-
每个信号都有默认动作
可能是终止、暂停、忽略等.
-
大多数信号可以修改处理方式
可以用
signal()或更推荐的sigaction()注册处理函数. -
SIGKILL和SIGSTOP特殊它们不能被捕获、忽略或自定义处理.
可以把 Linux 信号理解成:
text
进程 = 你
信号 = 电话通知
操作系统内核 = 电话系统
电话来了
↓
你决定怎么处理
↓
挂断 / 忽略 / 接听处理
因此,信号机制的本质就是 Linux 用来通知进程发生了某种事件的一种软件中断机制.
(1)查看信号

每个信号都有⼀个编号和⼀个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义
#define SIGINT 2

编号34以上的是实时信号,本文只讨论编号34以下的信号,不讨论实时信号.这些信号各⾃在什么条件下产⽣,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal

| 编号 | 信号 | 大概含义 | 默认行为 / 常见场景 |
|---|---|---|---|
| 1 | SIGHUP |
终端挂断 | 终止;守护进程常用它重新加载配置 |
| 2 | SIGINT |
中断 | Ctrl+C,默认终止进程 |
| 3 | SIGQUIT |
退出 | **Ctrl+**,默认终止并可能产生 core |
| 4 | SIGILL |
非法指令 | CPU 执行了非法指令,通常 core |
| 5 | SIGTRAP |
跟踪/断点 | 调试器常用,如断点 |
| 6 | SIGABRT |
异常终止 | abort() 产生,通常 core |
| 7 | SIGBUS |
总线错误 | 非法内存访问的一类,通常 core |
| 8 | SIGFPE |
算术异常 | 如整数除 0,通常 core |
| 9 | SIGKILL |
强制杀死 | 不能捕获、不能忽略、不能阻塞 |
| 10 | SIGUSR1 |
用户自定义信号 1 | 程序自己约定用途 |
| 11 | SIGSEGV |
段错误 | 非法访问内存,常见 Segmentation fault |
| 12 | SIGUSR2 |
用户自定义信号 2 | 和 SIGUSR1 类似 |
| 13 | SIGPIPE |
管道破裂 | 向已经关闭的 pipe/socket 写数据 |
| 14 | SIGALRM |
闹钟信号 | alarm() 定时到期 |
| 15 | SIGTERM |
请求终止 | kill PID 默认发送,推荐正常结束进程时使用 |
| 16 | SIGSTKFLT |
栈/协处理器错误 | Linux 特有,现在很少使用 |
| 17 | SIGCHLD |
子进程状态变化 | 子进程退出/停止时通知父进程 |
| 18 | SIGCONT |
继续执行 | 恢复被暂停的进程 |
| 19 | SIGSTOP |
强制暂停 | 不能捕获、不能忽略、不能阻塞 |
| 20 | SIGTSTP |
终端暂停 | Ctrl+Z |
| 21 | SIGTTIN |
后台进程读终端 | 后台进程尝试读控制终端时产生 |
| 22 | SIGTTOU |
后台进程写终端 | 后台进程尝试写控制终端时可能产生 |
| 23 | SIGURG |
紧急数据 | socket 收到紧急数据 |
| 24 | SIGXCPU |
CPU 时间超限 | 超过资源限制 RLIMIT_CPU |
| 25 | SIGXFSZ |
文件大小超限 | 文件超过 RLIMIT_FSIZE |
| 26 | SIGVTALRM |
虚拟定时器 | 进程用户态 CPU 时间计时 |
| 27 | SIGPROF |
性能分析定时器 | profiling / setitimer() |
| 28 | SIGWINCH |
窗口大小改变 | 调整终端窗口大小时产生 |
| 29 | SIGIO |
I/O 事件 | 异步 I/O 就绪通知;也常叫 SIGPOLL |
| 30 | SIGPWR |
电源相关事件 | 电源故障等,较少直接使用 |
| 31 | SIGSYS |
非法系统调用 | 执行了不允许/非法的 syscall |
| 32 | --- | glibc/NPTL 内部保留 | 一般应用程序不要使用 |
| 33 | --- | glibc/NPTL 内部保留 | 一般应用程序不要使用 |
| 34 | SIGRTMIN |
最小实时信号 | 实时信号范围的起点 |
(2)信号处理
Linux 里的信号处理,核心就是:
进程收到一个信号后,决定"怎么处理这个信号".
通常有3种处理方式:
| 处理方式 | 写法 | 含义 |
|---|---|---|
| 默认处理 | SIG_DFL |
使用系统默认动作 |
| 忽略信号 | SIG_IGN |
收到后不处理 |
| 自定义处理 | handler |
收到后执行自己的函数 |
默认处理
如果没有写:
cpp
signal(SIGINT, handler);
那么 SIGINT 使用默认动作:
text
Ctrl+C
↓
SIGINT
↓
默认动作
↓
终止进程
也可以主动恢复默认处理:
cpp
signal(SIGINT, SIG_DFL);
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber
<< std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGINT /*2*/, SIG_DFL);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}

输⼊ctrl+c,进程退出,就是默认动作
忽略信号
例如:
cpp
signal(SIGINT, SIG_IGN);
此时:
text
Ctrl+C
↓
SIGINT
↓
忽略
↓
继续运行
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber
<< std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGINT /*2*/, SIG_IGN); // 设置忽略信号的宏
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}

输⼊ctrl+c毫⽆反应
自定义处理
例如:
cpp
signal(SIGINT, handler);
收到信号后:
cpp
handler(2);
这里的 2 就是 SIGINT 的编号.
所以:
cpp
void handler(int signo)
参数 signo 表示:
这次收到的是哪个信号.
一个 handler 甚至可以处理多个信号:
cpp
signal(SIGINT, handler);
signal(SIGTERM, handler);
void handler(int signo)
{
if (signo == SIGINT)
{
// 处理 Ctrl+C
}
else if (signo == SIGTERM)
{
// 处理终止请求
}
}
2.产生信号
2.1通过终端按键产⽣信号
(1)基本操作
• Ctrl+C(SIGINT)已经验证过,这⾥不再重复.
• Ctrl+(SIGQUIT)可以发送终⽌信号并⽣成core dump⽂件,⽤于事后调试.
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGQUIT /*3*/, handler);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}

我们注释掉11⾏代码再看看!

• Ctrl+Z(SIGTSTP)可以发送停⽌信号,将当前前台进程挂起到后台等.
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGTSTP /*20*/, handler);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}

我们注释掉11⾏代码再看看!

(2)理解操作系统如何得知键盘有数据

(3)理解信号起源
在Linux里,"键盘有数据"这个通知最初是从哪里来的?
最关键的一点是:
Linux 内核不是凭空发现键盘有数据,而是底层硬件控制器通过硬件中断通知CPU,然后 Linux 的中断处理链一路把数据送进 input 子系统.
以现代最常见的 USB 键盘为例,可以先记这条链:
text
用户按键
↓
键盘内部 MCU 生成 HID 数据
↓
USB 控制器 xHCI 收到数据
↓
xHCI 产生硬件中断(通常 MSI/MSI-X)
↓
CPU 进入 Linux 内核中断处理
↓
USB / HID 驱动处理
↓
Linux input subsystem
↓
/dev/input/eventX
↓
libinput / X11 / Wayland / 应用程序
真正的"Linux 信号起源"
如果站在 Linux 内核视角看,起点可以认为是:
text
硬件
↓
IRQ / MSI
↓
CPU
↓
Linux IRQ subsystem
也就是说,之前这一句:
键盘向 CPU 发送硬件中断
对于现代 USB 键盘来说不够精确.更准确应该是:
text
键盘
│ HID 数据
▼
USB Host Controller(xHCI)
│
│ IRQ / MSI
▼
CPU
│
▼
Linux Kernel
真正向 CPU 发中断的通常是 USB Host Controller,而不是 USB 键盘本身。
USB HID 里所谓的 Interrupt Transfer(中断传输) ,名字很容易误导.它实际上仍然是 Host 主动轮询设备 endpoint:
text
Linux/xHCI:
"键盘,有数据吗?"
键盘:
"没有。"
过一小段时间:
Linux/xHCI:
"有数据吗?"
键盘:
"有,A 被按下了。"
xHCI 得到这次传输完成事件以后,再通过 MSI/MSI-X 或传统 IRQ 通知 CPU.
Linux 内核内部继续往下,大致可以理解成:
text
xHCI 中断
↓
USB Core
↓
usbhid
↓
HID Core
↓
Linux Input Core
↓
evdev
↓
/dev/input/event3
例如用户程序执行:
c
read(fd, &event, sizeof(event));
去读取:
text
/dev/input/event3
如果现在没按键,它实际上会睡眠:
text
应用程序
↓
read()
↓
没有数据
↓
睡眠 / 等待
然后:
text
按 A
↓
硬件数据到达
↓
xHCI 中断
↓
HID 驱动
↓
input_event()
↓
evdev 缓冲区加入数据
↓
wake_up_interruptible()
↓
睡眠中的 read() 被唤醒
↓
程序拿到 KEY_A
所以,如果是在理解 Linux"为什么知道键盘有数据",建议脑子里建立这一张最小模型:
text
硬件层
键盘 ──USB──> xHCI 控制器
│
IRQ/MSI
↓
CPU
│
─────────────────┼──────────────── Linux 内核
↓
USB/HID 驱动
↓
input subsystem
↓
evdev
↓
/dev/input/eventX
│
─────────────────┼──────────────── 用户态
↓
应用程序
而如果是老式 PS/2 键盘
text
键盘
↓
i8042 键盘控制器
↓
IRQ 1
↓
CPU
↓
Linux 中断处理
↓
atkbd 驱动
↓
input subsystem
所以你可以这样区分:
PS/2:
键盘数据 → IRQ1 → CPU
USB:
键盘数据 → USB/xHCI → IRQ/MSI → CPU
2.2调⽤系统命令向进程产生信号
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
while (true)
{
sleep(1);
}
}

⾸先在后台执⾏死循环程序,然后⽤kill命令给它发SIGSEGV信号.
639797是sig进程的pid.之所以要再次回⻋才显示Segmentation fault,是因为在639797进程终⽌掉之前已经回到了Shell提示符等待⽤户输⼊下⼀条命令,Shell不希望Segmentation fault 信息和⽤户的输⼊交错在⼀起,所以等⽤户输⼊命令之后才显示.指定发送某种信号的 kill 命令可以有多种写法,上⾯的命令还可以写成 kill -11 639797,11是信号SIGSEGV的编号.以往遇到的段错误都是由⾮法内存访问产⽣的,⽽这个程序本⾝没错,给它发SIGSEGV也能产⽣段错误.
2.3使⽤函数产⽣信号
(1)kill
kill命令是调⽤kill函数实现的.kill函数可以给⼀个指定的进程发送指定的信号.
cpp
NAME
kill - send signal to a process
SYNOPSIS
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
RETURN VALUE
On success (at least one signal was sent), zero is returned. On error,
-1 is returned, and errno is set appropriately.
两个参数非常关键:
bash
kill(pid, sig)
│ │
│ └── 发什么信号
│
└────── 发给谁
样例:实现⾃⼰的kill命令
cpp
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
// mykill -signumber pid
int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;
return 1;
}
int number = std::stoi(argv[1] + 1);
pid_t pid = std::stoi(argv[2]);
int n = kill(pid, number);
return n;
}
(2)raise
raise函数可以给当前进程发送指定的信号(⾃⼰给⾃⼰发信号).
cpp
NAME
raise - send a signal to the caller
SYNOPSIS
#include <signal.h>
int raise(int sig);
RETURN VALUE
raise() returns 0 on success, and nonzero for failure.
样例
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
// 整个代码就只有这⼀处打印
std::cout << "获取了⼀个信号: " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{
signal(2, handler); // 先对2号信号进⾏捕捉
// 每隔1S,⾃⼰给⾃⼰发送2号信号
while (true)
{
sleep(1);
raise(2);
}
}
(3)abort
abort函数使当前进程接收到信号⽽异常终⽌.
cpp
NAME
abort - cause abnormal process termination
SYNOPSIS
#include <stdlib.h>
void abort(void);
RETURN VALUE
The abort() function never returns.
//就像exit函数⼀样,abort函数总是会成功的,所以没有返回值。
cpp
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void handler(int signumber)
{
// 整个代码就只有这⼀处打印
std::cout << "获取了⼀个信号: " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{
signal(SIGABRT, handler);
while (true)
{
sleep(1);
abort();
}
}
把这三个接口串起来就是:
bash
kill(pid, sig)
↓
给指定进程发送信号
raise(sig)
↓
当前进程给自己发送信号
abort()
↓
当前进程触发 SIGABRT
↓
异常终止自己
2.4由软件条件产⽣信号
SIGPIPE是⼀种由软件条件产⽣的信号,在"管道"中已经介绍过了.本文主要介绍alarm函数和SIGALRM信号.
alarm() 和 SIGALRM 要放在一起理解:
alarm()是"定闹钟",SIGALRM是"闹钟到点后Linux内核发给当前进程的信号".
函数原型:
c
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
例如:
c
alarm(5);
意思不是"程序睡眠 5 秒",而是:
text
当前进程调用 alarm(5)
↓
Linux 内核设置一个 5 秒定时器
↓
程序继续正常运行
↓
5 秒到期
↓
内核向该进程发送 SIGALRM
↓
进程处理 SIGALRM
alarm()不会阻塞程序
这一点特别重要.
比如:
cpp
#include <iostream>
#include <unistd.h>
int main()
{
alarm(5);
while (true)
{
std::cout << "running..." << std::endl;
sleep(1);
}
}
大概会看到:
text
running...
running...
running...
running...
running...
Alarm clock
原因是:
text
alarm(5)
↓
定时器开始计时
与此同时:
while(...)
↓
程序继续执行
5 秒后
↓
SIGALRM
↓
SIGALRM 默认动作:终止进程
所以,如果你没有注册 SIGALRM 的处理函数,5秒到了以后,程序通常直接被终止.
自己处理 SIGALRM
例如:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int sig)
{
std::cout << "收到 SIGALRM" << std::endl;
}
int main()
{
signal(SIGALRM, handler);
alarm(3);
while (true)
{
std::cout << "running..." << std::endl;
sleep(1);
}
}
逻辑就是:
text
signal(SIGALRM, handler)
↓
告诉 Linux:
以后收到 SIGALRM 时执行 handler
alarm(3)
↓
让内核设置 3 秒定时器
程序继续运行
↓
3 秒到
↓
内核产生 SIGALRM
↓
handler(SIGALRM)
↓
handler 返回
↓
程序继续运行
大概看到:
text
running...
running...
running...
收到 SIGALRM
running...
running...
...
用 signal() 很直观;实际 Linux 编程通常更推荐 sigaction().另外严格来说,signal handler里不推荐使用 std::cout,这里主要用于帮助理解.
alarm() 只触发一次
这一点也很容易误会:
c
alarm(3);
不是:
text
每隔 3 秒发一次 SIGALRM
而是:
text
3 秒以后
↓
发一次 SIGALRM
↓
结束
如果需要再次触发,可以在 handler 里面重新:
c
void handler(int sig)
{
write(STDOUT_FILENO, "alarm\n", 6);
alarm(3);
}
于是形成:
text
alarm(3)
↓
3 秒
↓
SIGALRM
↓
handler
↓
alarm(3)
↓
3 秒
↓
SIGALRM
↓
...
不过真正需要周期定时器时,一般会考虑 setitimer()、POSIX timer 或 timerfd 等机制.
再次调用 alarm() 会覆盖之前的闹钟
例如:
c
alarm(10);
sleep(2);
alarm(5);
不是同时存在一个 10 秒和一个 5 秒定时器.
第二次:
c
alarm(5);
会取消之前还没到期的 alarm,然后重新设置:
text
alarm(10)
↓
过去 2 秒
↓
理论还剩约 8 秒
alarm(5)
↓
旧 alarm 被取消
↓
重新设置 5 秒
而且 alarm() 的返回值就是:
上一个 alarm 大约还剩多少秒.
所以:
cpp
unsigned int ret = alarm(5);
如果前面没有定时器:
text
ret = 0
如果之前设置了:
c
alarm(10);
sleep(2);
unsigned int ret = alarm(5);
那么 ret 大约是:
text
8
alarm(0) 很有用
c
alarm(0);
意思是:
取消当前还没有到期的 alarm.
比如:
text
alarm(10)
↓
设置 10 秒闹钟
alarm(0)
↓
取消闹钟
因此不会再因为这个 alarm 收到 SIGALRM
所以一句话记忆:
alarm()负责预约时间,Linux 内核负责计时,时间到后由内核产生SIGALRM.
另外,SIGALRM 如果当前被进程屏蔽(blocked),到点后并不会消失,而是先变成 pending,等解除屏蔽后再递达.
(1)基本alarm验证---体会IO效率问题
程序的作⽤是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终⽌.必要的时候,对SIGALRM信号进⾏捕捉.

结论:闹钟会响⼀次,默认终⽌进程.有IO效率低.
(2)设置重复闹钟
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>
#include <functional>
using func_t = std::function<void()>;
int gcount = 0;
std::vector<func_t> gfuncs;
//把信号 更换 成为 硬件中断
void hanlder(int signo)
{
for (auto &f : gfuncs)
{
f();
}
std::cout << "gcount : " << gcount << std::endl;
int n = alarm(1); // 重设闹钟,会返回上⼀次闹钟的剩余时间
std::cout << "剩余时间 : " << n << std::endl;
}
int main()
{
//gfuncs.push_back([](){ std::cout << "我是⼀个内核刷新操作" << std::endl;});
//gfuncs.push_back([](){ std::cout << "我是⼀个检测进程时间⽚的操作,如果时间⽚到了,我会切换进程" << std::endl; });
//gfuncs.push_back([](){ std::cout << "我是⼀个内存管理操作,定期清理操作系统内部的内存碎⽚" << std::endl; });
alarm(1); // ⼀次性的闹钟,超时alarm会⾃动被取消
signal(SIGALRM, hanlder);
while (true)
{
pause();
std::cout << "我醒来了..." << std::endl;
gcount++;
}
}

结论:闹钟设置⼀次,起效⼀次.重复设置的⽅法,如果时间允许,可以测试⼀下alarm(0).
(3)如何理解软件条件
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产⽣机制.这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产⽣的SIGPIPE信号)等.当这些软件条件满⾜时,操作系统会向相关进程发送相应的信号,以通知进程进⾏相应的处理.简⽽⾔之,软件条件是因操作系统内部或外部软件操作⽽触发的信号产⽣.
(4)如何简单快速理解系统闹钟
系统闹钟,其实本质是操作系统必须⾃⾝具有定时功能,并能让⽤户设置这种定时功能,才可能实现闹钟这样的技术.现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织.内核中的定时器数据结构是:
cpp
struct timer_list {
struct list_head entry;
unsigned long expires;
void (*function)(unsigned long);
unsigned long data;
struct tvec_t_base_s *base;
};
我们不在这部分进⾏深究,为了理解它,我们可以看到:定时器超时时间expires和处理⽅法
function.操作系统管理定时器,采⽤的是时间轮的做法,但是我们为了简单理解,可以把它在组织成为"堆结构".
可以把 系统闹钟 alarm() 直接理解成:
让 Linux 内核帮你倒计时,时间到了就给当前进程发一个
SIGALRM信号.
最小模型:
text
alarm(5)
↓
告诉内核:5 秒后提醒我
↓
程序继续运行,不会停下来等
↓
5 秒到
↓
内核发送 SIGALRM
↓
进程处理这个信号
例如:
cpp
alarm(3);
while (true)
{
// 程序继续干自己的事
}
这里 alarm(3) 不是 sleep(3).
区别可以快速记:
text
sleep(3)
↓
我自己停 3 秒
alarm(3)
↓
我继续运行
↓
3 秒后内核提醒我
如果你没有处理 SIGALRM:
cpp
alarm(3);
3 秒后通常:
text
SIGALRM
↓
默认动作
↓
进程终止
如果你自己注册处理函数:
cpp
void handler(int sig)
{
// 闹钟响了
}
signal(SIGALRM, handler);
alarm(3);
就是:
text
设置闹钟
↓
继续运行
↓
3 秒后
↓
SIGALRM
↓
handler()
↓
处理完继续运行
你可以把它类比成手机闹钟:
text
alarm(5) = 设置"5秒后的闹钟"
Linux 内核 = 手机系统负责计时
SIGALRM = 闹铃响了
handler = 你听到闹铃后做什么
再记两个常用点:
cpp
alarm(0); // 取消闹钟
而且一次 alarm() 只响一次,不是每隔几秒自动重复.
一句话背下来就够:
alarm()负责预约,内核负责计时,时间到以后用SIGALRM通知进程.
2.5硬件异常产⽣信号
可以把"硬件异常产生信号"快速理解成:程序执行某条指令时,CPU 发现"这条指令没法正常执行",先产生硬件异常进入Linux 内核;内核判断这个异常属于当前进程的问题后,再给进程发送相应信号.硬件异常被硬件以某种⽅式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号.例如当前进程执⾏了除以0的指令,CPU的运算单元会产⽣异常,内核将这个异常解释为SIGFPE信号发送给进程.再⽐如当前进程访问了⾮法内存地址,MMU会产⽣异常,内核将这个异常解释为SIGSEGV信号发送给进程.
(1)模拟除0
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int sig)
{
const char msg[] = "收到 SIGFPE:发生算术异常\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);
_exit(1);
}
int main()
{
signal(SIGFPE, handler);
volatile int a = 10;
volatile int b = 0;
int c = a / b; // 故意整数除 0
std::cout << c << std::endl;
return 0;
}
在常见的Linux/x86环境下,会经历:
bash
a / b
↓
CPU 执行整数除法指令
↓
发现除数为 0
↓
CPU 产生 Divide Error 异常
↓
进入 Linux 内核
↓
Linux 判断这是当前进程造成的
↓
产生 SIGFPE
↓
handler(SIGFPE)

(2)模拟野指针
cpp
//默认⾏为
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
int main()
{
// signal(SIGSEGV, handler);
sleep(1);
int *p = NULL;
*p = 100;
while (1)
;
return 0;
}

cpp
//捕捉⾏为
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
// 捕获到 SIGSEGV 后直接结束进程
_exit(1);
}
int main()
{
// 捕捉 SIGSEGV 信号
signal(SIGSEGV, handler);
sleep(1);
int *p = NULL;
// 故意访问非法地址,触发 SIGSEGV
*p = 100;
while (1);
return 0;
}

由此可以确认,我们在C/C++当中除零,内存越界等异常,在系统层⾯上,是被当成信号处理的.
📌 注意
通过上⾯的实验,我们可能发现:发现⼀直有8号信号产⽣被我们捕获,这是为什么呢?上⾯我们只提到CPU运算异常后,如何处理后续的流程,实际上操作系统会检查应⽤程序的异常情况,其实在CPU中有⼀些控制和状态寄存器,主要⽤于控制处理器的操作通常由操作系统代码使⽤.状态寄存器可以简单理解为⼀个位图,对应着⼀些状态标记位、溢出标记位.操作系统会检测是否存在异常状态,有异常存在就会调⽤对应的异常处理⽅法.除零异常后,我们并没有清理内存,关闭进程打开的⽂件,切换进程等操作,所以CPU中还保留上下⽂数据以及寄存器内容,除零异常会⼀直存在,就有了我们看到的⼀直发出异常信号的现象.访问⾮法内存其实也是如此,⼤家可以⾃⾏实验.
(3)⼦进程退出core dump

cpp
#include <iostream>
#include <string>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
int main()
{
if (fork() == 0)
{
sleep(1);
int a = 10;
int b = 0;
a /= b;
exit(0);
}
int status = 0;
waitpid(-1, &status, 0);
printf("exit signal: %d, core dump: %d\n", status & 0x7F, (status >> 7) & 1);
return 0;
}


(4)Core Dump
**Core Dump(核心转储)**可以简单理解成:
程序异常死亡时,Linux把它临死前的运行现场保存下来,方便之后用 GDB 分析为什么崩溃.
例如子进程执行:
cpp
int *p = NULL;
*p = 100;
大致流程:
text
非法访问内存
↓
CPU/MMU 产生异常
↓
Linux 产生 SIGSEGV
↓
进程因 SIGSEGV 异常终止
↓
满足条件时生成 Core Dump
↓
保存进程"死亡现场"
Core Dump里有什么?
可以把它想象成程序崩溃瞬间的一张"快照",里面通常包含:
text
Core Dump
├── 寄存器状态
├── 调用栈
├── 部分/全部进程内存
├── 线程状态
├── 当前执行位置
└── 其他调试信息
所以程序崩溃后,你可以用:
bash
gdb ./testsig core
然后查看:
gdb
bt
就能看调用栈,定位程序大概死在哪里.
哪些信号常见会产生 Core Dump?
例如:
text
SIGSEGV 非法内存访问
SIGFPE 除0等算术异常
SIGABRT abort()
SIGILL 非法指令
SIGBUS 总线错误
例如前面写的:
cpp
volatile int a = 10;
volatile int b = 0;
a /= b;
可能形成:
text
除0
↓
SIGFPE
↓
子进程异常终止
↓
Core Dump
父进程怎么知道子进程发生过 Core Dump?
父进程:
cpp
int status;
waitpid(pid, &status, 0);
然后:
cpp
if (WIFSIGNALED(status))
{
printf("signal = %d\n", WTERMSIG(status));
#ifdef WCOREDUMP
printf("core dump = %d\n",
WCOREDUMP(status) ? 1 : 0);
#endif
}
比如:
text
signal = 8
core dump = 1
意思是:
text
8
↓
SIGFPE
core dump = 1
↓
该异常终止带有 Core Dump 标志
你之前写的:
cpp
status & 0x7F
是在取终止信号编号.
而:
cpp
(status >> 7) & 1
是在观察 core dump 标志位.
但 core dump = 1 不等于一定看到 core 文件
这是特别重要的一点.
首先检查:
bash
ulimit -c
如果:
text
0
表示禁止生成 core 文件.
可以临时设置:
bash
ulimit -c unlimited
然后再让程序崩溃.
另外 Linux 还可能通过:
bash
cat /proc/sys/kernel/core_pattern
指定 Core Dump 去哪里.
所以有时看到:
text
Floating point exception (core dumped)
但当前目录找不到:
text
core
并不奇怪.
一句话记:
Signal 告诉你程序为什么死,Core Dump 保存程序死的时候是什么样.
例如:
text
SIGSEGV
↓
"死因:非法内存访问"
Core Dump
↓
"死亡现场:寄存器、调用栈、内存等"
这两个结合起来,就是 Linux 下分析程序崩溃最基本的机制.
3.保存信号
在Linux进程里,"保存信号"通常是指:信号到达后如果暂时不能处理,内核会把它记录为pending(未决信号),相关信息保存在进程/线程的内核数据结构中.
3.1信号其他相关常⻅概念
核心可以分成三部分:
- 未决信号 pending:已经产生、但还没递送处理的信号.
- 阻塞信号集 blocked:当前线程暂时不允许递送哪些信号.
- 信号处理方式 handler/action:收到某个信号后,是默认处理、忽略,还是调用用户注册的处理函数.
在Linux内核中,线程的 task_struct 里有类似这些字段:
c
struct task_struct {
sigset_t blocked; // 当前阻塞的信号集
struct sigpending pending; // 当前线程私有的未决信号
struct signal_struct *signal; // 线程组/进程级信号信息
struct sighand_struct *sighand;// 信号处理函数表
};
其中 sigpending 大致包含:
c
struct sigpending {
struct list_head list;
sigset_t signal;
};
可以把流程理解为:
text
信号产生
↓
内核记录信号
↓
pending 未决信号集
↓
检查 blocked 信号屏蔽字
↓
未被阻塞
↓
按照 sighand 中注册的处理方式递送
例如进程阻塞了 SIGINT:
c
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, NULL);
这时如果收到 SIGINT,它不会立即执行处理函数,而是先被记录为 pending.以后解除阻塞:
c
sigprocmask(SIG_UNBLOCK, &set, NULL);
内核再递送该信号.
还有一个重要区别:
普通信号(1~31)通常不排队. 同一种普通信号在未决期间发生很多次,通常只保存"有这个信号"这一状态.
text
SIGINT 到达 5 次
→ pending 中通常只记 1 个 SIGINT
实时信号 SIGRTMIN ~ SIGRTMAX 可以排队,因此多次到达可以分别保存.
可以直接记成:
Linux 进程的信号状态主要保存在
task_struct及其关联的signal_struct、sighand_struct中;未决信号放在pending,阻塞状态放在blocked,信号处理函数放在sighand.
3.2信号在内核中的表示意图

• 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有⼀个函数指针表示处理动作.信号产⽣时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志.在上图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作.
• SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达.虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞.
• SIGQUIT信号未产⽣过,⼀旦产⽣SIGQUIT信号将被阻塞,它的处理动作是⽤⼾⾃定义函数
sighandler.如果在进程解除对某信号的阻塞之前这种信号产⽣过多次,将如何处理?POSIX.1允许系统递送该信号⼀次或多次.Linux是这样实现的:常规信号在递达之前产⽣多次只计⼀次,⽽实时信号在递达之前产⽣多次可以依次放在⼀个队列⾥.
cpp
// 内核结构 2.6.18
struct task_struct {
...
/* signal handlers */
struct sighand_struct *sighand;
sigset_t blocked
struct sigpending pending;
...
}
struct sighand_struct {
atomic_t count;
struct k_sigaction action[_NSIG]; // #define _NSIG 64
spinlock_t siglock;
};
struct __new_sigaction {
__sighandler_t sa_handler;
unsigned long sa_flags;
void (*sa_restorer)(void); /* Not used by Linux/SPARC */
__new_sigset_t sa_mask;
};
struct k_sigaction {
struct __new_sigaction sa;
void __user *ka_restorer;
};
/* Type of a signal handler. */
typedef void (*__sighandler_t)(int);
struct sigpending {
struct list_head list;
sigset_t signal;
};
这部分代码描述的是 Linux 2.6.18 中进程/线程如何在内核里保存"信号屏蔽、未决信号、信号处理方式".
可以直接对应成:
text
task_struct
│
├── blocked 当前线程屏蔽哪些信号
│
├── pending 当前线程有哪些未决信号
│
└── sighand ───────► 每个信号应该怎么处理
└── action[64]
1.task_struct
c
struct sighand_struct *sighand;
sigset_t blocked;
struct sigpending pending;
blocked:信号屏蔽字.某一位为1,表示对应信号暂时不能递送.pending:未决信号.信号已经到达,但还没被处理.sighand:指向信号处理方式表.
例如:
text
SIGINT:
blocked = 1
pending = 1
表示:SIGINT 已经来了,但是目前被阻塞,所以先保持未决状态.
2.sighand_struct
c
struct sighand_struct {
atomic_t count;
struct k_sigaction action[_NSIG];
spinlock_t siglock;
};
核心是:
c
action[64]
可以理解成一张 64 个信号的处理方式表:
text
action[1] → SIGHUP 怎么处理
action[2] → SIGINT 怎么处理
action[3] → SIGQUIT 怎么处理
...
其中:
count:引用计数,因为线程之间可能共享sighand_structsiglock:自旋锁,保护信号相关数据的并发访问action[]:保存每个信号的处理规则
3.k_sigaction
c
struct k_sigaction {
struct __new_sigaction sa;
void __user *ka_restorer;
};
它保存一个信号具体的处理配置.
其中 sa 最重要:
c
struct __new_sigaction {
__sighandler_t sa_handler;
unsigned long sa_flags;
void (*sa_restorer)(void);
__new_sigset_t sa_mask;
};
分别表示:
sa_handler:信号处理函数sa_flags:信号处理选项,如SA_RESTARTsa_mask:执行 handler 时,还要临时屏蔽哪些信号sa_restorer:信号处理完以后恢复执行环境相关的辅助入口
4.sa_handler 本质就是函数指针
c
typedef void (*__sighandler_t)(int);
等价于:
c
void handler(int signo);
所以:
c
sa_handler
可能保存:
text
SIG_DFL 默认处理
SIG_IGN 忽略信号
handler地址 用户自定义处理函数
5.sigpending
c
struct sigpending {
struct list_head list;
sigset_t signal;
};
用于表示已经产生但还没处理的信号.
其中:
text
signal → 位图,快速判断哪些信号处于 pending
list → 保存排队的信号信息
尤其是实时信号可以排队,因此不仅需要一个位图,还需要链表保存多个信号实例.
一句话记忆
text
blocked → 能不能处理
pending → 信号来没来
sighand → 来了以后怎么处理
所以整个逻辑就是:
text
信号到达
↓
记录到 pending
↓
检查 blocked
↓
没被阻塞
↓
查 sighand->action[signo]
↓
默认 / 忽略 / 执行用户 handler
这正是我前面给的那张图背后对应的内核数据结构.
3.3sigset_t
从上图来看,每个信号只有⼀个bit的未决标志,⾮0即1,不记录该信号产⽣了多少次,阻塞标志也是这样表示的.因此,未决和阻塞标志可以⽤相同的数据类型sigset_t来存储,这个类型可以表示每个信号的"有效"或"⽆效"状态,在阻塞信号集中"有效"和"⽆效"的含义是该信号是否被阻塞,⽽在未决信号集中"有效"和"⽆效"的含义是该信号是否处于未决状态.阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask)这⾥的"屏蔽"应该理解为阻塞⽽不是忽略.(类似权限那⾥的umask)
sigset_t 本质上就是一个 信号位图(bitmap),用一组二进制位表示哪些信号存在于某个集合中.
在Linux 2.6.18中,可以把它概念化为:
c
typedef struct {
unsigned long sig[_NSIG_WORDS];
} sigset_t;
因为:
c
#define _NSIG 64
所以它至少需要 64 个 bit 来表示 64 种信号.
一个位对应一个信号
通常是:
text
bit 0 → SIGHUP (1)
bit 1 → SIGINT (2)
bit 2 → SIGQUIT (3)
bit 3 → SIGILL (4)
...
bit 63 → 信号 64
例如:
text
sigset_t
┌───────────────────────────────┐
│ ... 0 0 0 0 0 1 0 │
└───────────────────────────────┘
↑
bit 1
SIGINT(2)
这个 1 的具体含义取决于 sigset_t 用在哪里.
例如在:
c
struct task_struct {
sigset_t blocked;
struct sigpending pending;
};
blocked
c
sigset_t blocked;
某一位为 1:
对应信号被阻塞,暂时不能递送.
例如:
text
blocked
SIGHUP SIGINT SIGQUIT
0 1 0
↑
SIGINT 被阻塞
pending.signal
c
struct sigpending {
struct list_head list;
sigset_t signal;
};
这里的:
c
sigset_t signal;
某一位为 1:
对应信号已经产生,目前处于未决状态.
例如:
text
pending.signal
SIGHUP SIGINT SIGQUIT
0 1 0
↑
SIGINT 已经到达
如果此时:
text
blocked SIGINT = 1
pending SIGINT = 1
就意味着:
text
SIGINT 已经来了
↓
pending 中记为 1
↓
但是 blocked 中也是 1
↓
暂时不能处理
↓
解除阻塞以后再递送
常见操作也都是在操作这个位图:
c
sigset_t set;
sigemptyset(&set); // 全部清 0
sigfillset(&set); // 全部置 1
sigaddset(&set, SIGINT); // SIGINT 对应位置 1
sigdelset(&set, SIGINT); // SIGINT 对应位清 0
sigismember(&set, SIGINT); // 判断 SIGINT 是否在集合中
所以可以直接记:
`sigset_t = 信号集合的位图表示,一个 bit 对应一个信号.
3.4信号集操作函数
Linux 中常用的 信号集操作函数 主要有这5个,用来操作 sigset_t:
| 函数 | 作用 |
|---|---|
sigemptyset() |
清空信号集,所有位设为 0 |
sigfillset() |
填满信号集,所有信号都加入集合 |
sigaddset() |
向信号集中加入一个信号 |
sigdelset() |
从信号集中删除一个信号 |
sigismember() |
判断某个信号是否在集合中 |
1.sigemptyset
c
int sigemptyset(sigset_t *set);
把信号集清空:
c
sigset_t set;
sigemptyset(&set);
可以理解为:
text
SIGINT SIGQUIT SIGTERM ...
0 0 0
2.sigfillset
c
int sigfillset(sigset_t *set);
把所有信号加入集合:
c
sigfillset(&set);
相当于:
text
SIGINT SIGQUIT SIGTERM ...
1 1 1
3.sigaddset
c
int sigaddset(sigset_t *set, int signo);
把指定信号加入信号集:
c
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGQUIT);
结果类似:
text
SIGHUP SIGINT SIGQUIT
0 1 1
4.sigdelset
c
int sigdelset(sigset_t *set, int signo);
删除某个信号:
c
sigdelset(&set, SIGINT);
就是把对应bit清 0.
5.sigismember
c
int sigismember(const sigset_t *set, int signo);
判断信号是否在信号集中:
c
if (sigismember(&set, SIGINT) == 1) {
printf("SIGINT 在信号集中\n");
}
返回值:
text
1 → 在集合中
0 → 不在集合中
-1 → 出错
一个完整例子
c
#include <signal.h>
#include <stdio.h>
int main(void)
{
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGQUIT);
printf("SIGINT: %d\n", sigismember(&set, SIGINT));
printf("SIGTERM: %d\n", sigismember(&set, SIGTERM));
sigdelset(&set, SIGINT);
printf("SIGINT: %d\n", sigismember(&set, SIGINT));
return 0;
}
可以记成:
text
sigemptyset → 全部清 0
sigfillset → 全部置 1
sigaddset → 指定位设 1
sigdelset → 指定位清 0
sigismember → 检查某个位
注意,这几个函数只是操作 sigset_t 这个信号集合本身,并不会真正改变进程的信号屏蔽状态.
真正设置进程信号屏蔽字,要配合:
c
sigprocmask()
或者在线程程序中使用:
c
pthread_sigmask()
(1)sigprocmask
sigprocmask() 用来查看或修改当前线程的信号屏蔽字(blocked signal set).
函数原型:
c
#include <signal.h>
int sigprocmask(int how,
const sigset_t *set,
sigset_t *oldset);
如果oldset是⾮空指针,则读取进程的当前信号屏蔽字通过oldset参数传出.如果set是⾮空指针,则 更改进程的信 号屏蔽字,参数how指示如何更改.如果oldset和set都是⾮空指针,则先将原来的信号屏蔽字备份到oldset⾥,然后根据set和how参数更改信号屏蔽字.假设当前的信号屏蔽字为mask,下表说明了how参数的可选值.
| 参数 | 含义 |
|---|---|
SIG_BLOCK |
set 包含了我们希望添加到当前信号屏蔽字的信号,相当于 `mask = mask |
SIG_UNBLOCK |
set 包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 mask = mask & ~set |
SIG_SETMASK |
设置当前信号屏蔽字为 set 所指向的值,相当于 mask = set |
如果调⽤sigprocmask解除了对当前若⼲个未决信号的阻塞,则在sigprocmask返回前,⾄少将其中⼀个信号递达.
其中最关键的是 how:
how |
含义 |
|---|---|
SIG_BLOCK |
把 set 中的信号加入屏蔽集 |
SIG_UNBLOCK |
把 set 中的信号从屏蔽集中删除 |
SIG_SETMASK |
直接用 set 替换当前屏蔽集 |
例如屏蔽 SIGINT:
c
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, NULL);
此时可以理解为:
text
blocked:
SIGINT = 1
这时按 Ctrl+C,SIGINT 已经产生,但因为被阻塞:
text
SIGINT 到达
↓
pending = 1
↓
发现 blocked = 1
↓
暂时不递送
解除阻塞:
c
sigprocmask(SIG_UNBLOCK, &set, NULL);
如果此时 SIGINT 已经处于pending,解除阻塞后就可以被递送.
保存并恢复原来的屏蔽字
oldset 可以保存修改前的状态:
c
sigset_t set, oldset;
sigemptyset(&set);
sigaddset(&set, SIGINT);
/* 屏蔽 SIGINT,同时保存原屏蔽字 */
sigprocmask(SIG_BLOCK, &set, &oldset);
/* 临界区代码 */
/* 恢复原屏蔽字 */
sigprocmask(SIG_SETMASK, &oldset, NULL);
这种方式很常见:
text
保存 blocked
↓
临时屏蔽某些信号
↓
执行关键代码
↓
恢复 blocked
如果只是想读取当前屏蔽字:
c
sigset_t cur;
sigprocmask(SIG_BLOCK, NULL, &cur);
当 set == NULL 时,how 实际上会被忽略,只获取当前屏蔽集.
返回值
text
成功 → 0
失败 → -1,并设置 errno
还有两个特殊信号:
text
SIGKILL
SIGSTOP
永远不能被阻塞 ,即使把它们放进 set 也不会生效.
一句话记忆:
sigset_t是"信号集合",而sigprocmask()是把这个集合真正应用到当前执行线程的blocked信号屏蔽字上.
多线程程序中一般使用 pthread_sigmask() 来操作线程信号屏蔽字.
(2)sigpending
sigpending() 用来获取当前线程处于未决(pending)状态的信号集合.
函数原型:
c
#include <signal.h>
int sigpending(sigset_t *set);
调用后,set 中保存当前的未决信号:
c
sigset_t pending;
sigpending(&pending);
然后可以配合 sigismember() 判断某个信号是否未决:
c
if (sigismember(&pending, SIGINT)) {
printf("SIGINT 当前处于 pending 状态\n");
}
例如先阻塞 SIGINT:
c
sigset_t mask, pending;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigprocmask(SIG_BLOCK, &mask, NULL);
此时按下 Ctrl+C:
text
SIGINT 产生
↓
blocked 中 SIGINT = 1
↓
不能立即递送
↓
进入 pending 状态
再调用:
c
sigpending(&pending);
if (sigismember(&pending, SIGINT))
printf("SIGINT is pending\n");
会检测到 SIGINT.
text
sigprocmask()
↓
操作 blocked(信号屏蔽字)
sigpending()
↓
读取 pending(未决信号集)
sigismember()
↓
检查某个信号是否在信号集中
例如:
text
SIGINT
blocked 1 ← 被屏蔽
pending 1 ← 已经产生但还没处理
解除阻塞:
c
sigprocmask(SIG_UNBLOCK, &mask, NULL);
之后 SIGINT 会被递送,处理完成后通常就不再处于pending状态.
一句话记忆:
sigprocmask()管"哪些信号不能处理",sigpending()查"哪些信号已经来了但还没处理".
下⾯⽤刚学的⼏个函数做个实验.程序如下:
cpp
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
void PrintPending(sigset_t &pending)
{
std::cout << "curr process[" << getpid() << "] pending: ";
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
{
std::cout << 1;
}
else
{
std::cout << 0;
}
}
std::cout << "\n";
}
void handler(int signo)
{
std::cout << signo << "号信号被递达!!!" << std::endl;
std::cout << "-------------------------------" << std::endl;
sigset_t pending;
sigpending(&pending);
PrintPending(pending);
std::cout << "-------------------------------" << std::endl;
}
int main()
{
// 0. 捕捉 2 号信号 SIGINT
signal(SIGINT, handler);
// 1. 屏蔽 2 号信号
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
sigaddset(&block_set, SIGINT);
// 1.1 真正修改当前进程的 blocked 信号集
sigprocmask(SIG_BLOCK, &block_set, &old_set);
int cnt = 15; // 注意:必须单独写出来,不能放在 // 后面
while (true)
{
// 2. 获取当前进程的 pending 信号集
sigset_t pending;
sigpending(&pending);
// 3. 打印 pending 信号集
PrintPending(pending);
cnt--;
// 4. 解除对 2 号信号的屏蔽
if (cnt == 0)
{
std::cout << "解除对2号信号的屏蔽!!!" << std::endl;
// 恢复之前的信号屏蔽字
sigprocmask(SIG_SETMASK, &old_set, nullptr);
}
sleep(1);
}
return 0;
}

程序运⾏时,每秒钟把各信号的未决状态打印⼀遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会 使SIGINT信号处于未决状态,按Ctrl-\仍然可以终⽌程序,因为SIGQUIT信号没有阻塞.
4.捕捉信号
"捕捉信号"就是:当某个信号递达进程时,不执行默认动作,而是调用你自己注册的处理函数.
最常见有两种方式:
1.signal():简单写法
cpp
#include <signal.h>
#include <iostream>
#include <unistd.h>
void handler(int signo)
{
std::cout << "收到信号: " << signo << std::endl;
}
int main()
{
signal(SIGINT, handler);
while (true)
{
std::cout << "pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
运行后按:
text
Ctrl + C
终端会产生:
text
SIGINT
由于你注册了:
cpp
signal(SIGINT, handler);
所以不会直接终止进程,而是执行:
cpp
handler(SIGINT);
2.signal() 的第二个参数
函数原型:
cpp
__sighandler_t signal(int signum, __sighandler_t handler);
第二个参数有三种典型情况:
cpp
signal(SIGINT, handler); // 自定义捕捉
signal(SIGINT, SIG_IGN); // 忽略
signal(SIGINT, SIG_DFL); // 默认处理
可以理解成:
| 设置 | 含义 |
|---|---|
handler |
捕捉信号,执行自定义函数 |
SIG_IGN |
忽略信号 |
SIG_DFL |
使用系统默认动作 |
内核中是怎么实现的
结合前面学的 task_struct:
text
task_struct
|
└── sighand
|
└── action[64]
|
├── SIGINT → handler
├── SIGQUIT → SIG_DFL
└── ...
例如:
cpp
signal(SIGINT, handler);
本质上就是把 SIGINT 对应的处理动作改成:
text
SIGINT
↓
sighand->action[SIGINT]
↓
handler
当信号递达时:
text
SIGINT 产生
↓
pending 中记录
↓
检查 blocked
↓
没有被阻塞
↓
查看 handler
↓
发现是用户自定义函数
↓
执行 handler(SIGINT)
更推荐:sigaction()
实际 Linux 编程中,更推荐 sigaction():
cpp
#include <signal.h>
void handler(int signo)
{
}
int main()
{
struct sigaction act;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGINT, &act, nullptr);
while (true)
pause();
}
它比 signal() 更完整,因为可以控制:
text
sa_handler → 信号处理函数
sa_mask → 执行 handler 时额外屏蔽哪些信号
sa_flags → 信号处理选项
信号"捕捉"和信号"屏蔽"不是一回事:
text
signal / sigaction
↓
决定:信号来了以后怎么处理
sigprocmask
↓
决定:信号现在能不能递达
例如:
cpp
signal(SIGINT, handler);
同时又把 SIGINT 屏蔽:
cpp
sigprocmask(SIG_BLOCK, ...);
那么即使已经注册了 handler,SIGINT 也不会立刻执行处理函数,而是先进入 pending.
解除屏蔽后:
text
pending SIGINT
↓
解除 blocked
↓
递达
↓
handler(SIGINT)
补充一点:SIGKILL 和 SIGSTOP 不能被捕捉、不能被忽略、也不能被屏蔽.
4.1信号捕捉的流程

如果信号的处理动作是⽤户⾃定义函数,在信号递达时就调⽤这个函数,这称为捕捉信号.
由于信号处理函数的代码是在⽤⼾空间的,处理过程⽐较复杂,举例如下:
• ⽤户程序注册了SIGQUIT 信号的处理函数 sighandler .
• 当前正在执⾏ main 函数,这时发⽣中断或异常切换到内核态.
• 在中断处理完毕后要返回⽤⼾态的main函数之前检查到有信号SIGQUIT递达.
• 内核决定返回⽤⼾态后不是恢复main函数的上下⽂继续执⾏,⽽是执⾏ sighandler函数, sighandler和main函数使⽤不同的堆栈空间,它们之间不存在调⽤和被调⽤的关系,是两个独⽴的控制流程.
• sighandler函数返回后⾃动执⾏特殊的系统调⽤sigreturn再次进⼊内核态.
• 如果没有新的信号要递达,这次再返回⽤户态就是恢复main函数的上下⽂继续执⾏了.

4.2sigaction
sigaction() 是 Linux 中更推荐的信号捕捉/设置信号处理方式 的接口,比 signal() 更稳定、功能更完整.
函数原型:
c
#include <signal.h>
int sigaction(int signum,
const struct sigaction *act,
struct sigaction *oldact);
• sigaction函数可以读取和修改与指定信号相关联的处理动作.调⽤成功则返回0,出错则返回- 1.signo是指定信号的编号.若act指针⾮空,则根据act修改该信号的处理动作.若oldact指针⾮空, 则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体.
• 将sa_handler赋值为常数SIG_IGN传给sigaction表⽰忽略信号,赋值为常数SIG_DFL表⽰执⾏系统默认动作,赋值为⼀个函数指针表⽰⽤⾃定义函数捕捉信号,或者说向内核注册了⼀个信号处理函数,该函数返回值为void,可以带⼀个int参数,通过参数可以得知当前信号的编号,这样就可以⽤同⼀个函数处理多种信号.显然,这也是⼀个回调函数,不是被main函数调⽤,⽽是被系统所调⽤.
其中:
signum:要设置的信号,比如SIGINT.act:新的处理方式.oldact:保存原来的处理方式,可为NULL.
最核心的是 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);
};
现阶段重点记这 3 个:
text
sa_handler → 信号处理函数
sa_mask → 执行 handler 时额外屏蔽哪些信号
sa_flags → 控制信号处理行为
例如捕捉 SIGINT:
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int signo)
{
std::cout << "收到信号: " << signo << std::endl;
}
int main()
{
struct sigaction act;
act.sa_handler = handler;
// handler 执行期间,不额外屏蔽其他信号
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
// 捕捉 SIGINT
sigaction(SIGINT, &act, nullptr);
while (true)
{
sleep(1);
}
return 0;
}
可以理解为:
text
sigaction(SIGINT, &act, NULL)
↓
内核中的 sighand
↓
action[SIGINT]
↓
sa_handler = handler
以后 SIGINT 递达时:
text
SIGINT 产生
↓
检查 pending / blocked
↓
允许递达
↓
查看 action[SIGINT]
↓
发现 sa_handler = handler
↓
执行 handler(SIGINT)
sa_mask 很重要
比如:
c
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGQUIT);
表示:
当
handler()正在执行时,临时把SIGQUIT屏蔽掉.
另外,正在处理的那个信号本身默认也会被临时阻塞.
例如处理:
text
SIGINT
期间又来一个 SIGINT,默认不会立即嵌套再次执行 handler(),而是先保持pending,等当前handler 返回.
常见 sa_flags
常见的有:
| 标志 | 含义 |
|---|---|
0 |
默认行为 |
SA_RESTART |
某些被信号中断的系统调用自动重启 |
SA_SIGINFO |
使用 sa_sigaction,获取更多信号信息 |
SA_NODEFER |
handler 执行时,不自动屏蔽当前信号 |
SA_RESETHAND |
信号处理一次后恢复默认动作 |
例如:
c
act.sa_flags = SA_RESTART;
保存旧处理方式
c
struct sigaction oldact;
sigaction(SIGINT, &act, &oldact);
这样 oldact 就保存修改前的处理方式.
恢复:
c
sigaction(SIGINT, &oldact, NULL);
和 signal() 的关系
简单理解:
c
signal(SIGINT, handler);
相当于"简单版"的:
c
sigaction(SIGINT, ...);
但实际 Linux 编程更推荐:
c
sigaction()
因为它可以明确控制:
text
处理函数
+
handler 执行期间的屏蔽字
+
各种处理选项
一句话记忆:
signal()主要解决"谁来处理信号",而sigaction()可以完整描述"怎么处理这个信号".
4.3操作系统是怎么运⾏的
可以把操作系统理解成:CPU 一直在执行代码,而操作系统负责在"用户程序"和"内核代码"之间不断切换,并管理 CPU、内存、设备和进程.
一个最核心的运行过程是:
text
开机
↓
CPU 执行 BIOS / UEFI
↓
加载 BootLoader
↓
加载操作系统内核
↓
内核初始化
↓
启动第一个用户进程
↓
不断运行用户程序
↓
遇到中断 / 异常 / 系统调用
↓
进入内核
↓
内核处理
↓
返回用户态
↓
继续执行
(1)硬件中断

硬件中断:外部设备通知 CPU
硬件中断是由 CPU 外部的硬件设备产生的.
例如:
text
键盘按键
网卡收到数据
磁盘 I/O 完成
鼠标移动
定时器到期
假设 CPU 正在执行用户程序:
text
用户程序正在运行
↓
键盘产生中断
↓
CPU 暂停当前程序
↓
保存当前执行现场
↓
进入内核态
↓
执行键盘中断处理程序
↓
恢复现场
↓
继续原来的程序
核心特点:
硬件中断是异步的.
也就是说,程序并不知道中断什么时候到来.
比如:
cpp
int a = 10;
int b = 20; // 可能执行到这里时网卡突然产生中断
int c = a+b;
CPU 可以在正常指令执行过程中被硬件中断打断.
cpp
//Linux内核0.11源码
void trap_init(void)
{
int i;
set_trap_gate(0,÷_error);// 设置除操作出错的中断向量值。以下雷同。
set_trap_gate(1,&debug);
set_trap_gate(2,&nmi);
set_system_gate(3,&int3); /* int3-5 can be called from all */
set_system_gate(4,&overflow);
set_system_gate(5,&bounds);
set_trap_gate(6,&invalid_op);
set_trap_gate(7,&device_not_available);
set_trap_gate(8,&double_fault);
set_trap_gate(9,&coprocessor_segment_overrun);
set_trap_gate(10,&invalid_TSS);
set_trap_gate(11,&segment_not_present);
set_trap_gate(12,&stack_segment);
set_trap_gate(13,&general_protection);
set_trap_gate(14,&page_fault);
set_trap_gate(15,&reserved);
set_trap_gate(16,&coprocessor_error);
// 下⾯将int17-48 的陷阱⻔先均设置为reserved,以后每个硬件初始化时会重新设置⾃⼰的陷阱
⻔。
for (i=17;i<48;i++)
set_trap_gate(i,&reserved);
set_trap_gate(45,&irq13);// 设置协处理器的陷阱⻔。
outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯⽚的IRQ2 中断请求。
outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯⽚的IRQ13 中断请求。
set_trap_gate(39,¶llel_interrupt);// 设置并⾏⼝的陷阱⻔。
}
void
rs_init (void)
{
set_intr_gate (0x24, rs1_interrupt); // 设置串⾏⼝1 的中断⻔向量(硬件IRQ4
信号)。
set_intr_gate (0x23, rs2_interrupt); // 设置串⾏⼝2 的中断⻔向量(硬件IRQ3
信号)。
init (tty_table[1].read_q.data); // 初始化串⾏⼝1(.data 是端⼝号)。
init (tty_table[2].read_q.data); // 初始化串⾏⼝2。
outb (inb_p (0x21) & 0xE7, 0x21); // 允许主8259A 芯⽚的IRQ3,IRQ4 中断信号
请求。
}
(2)时钟中断

时钟中断本质上也是一种硬件中断.
它是由硬件定时器周期性产生的,比如:
text
程序运行
↓
经过一小段时间
↓
Timer 产生中断
↓
CPU 进入内核
它对操作系统特别重要,因为操作系统要靠它进行:
text
进程调度
时间统计
定时器管理
超时处理
例如CPU正在运行进程 A:
text
进程 A
↓
运行一段时间
↓
时钟中断
↓
进入内核
↓
调度器 scheduler
↓
发现 A 时间片用完
↓
切换到进程 B
也就是:
text
A A A A A
↓ 时钟中断
Kernel
↓
B B B B B
所以时钟中断让操作系统实现了:
抢占式多任务.
cpp
// Linux 内核0.11
// main.c
sched_init(); // 调度程序初始化(加载了任务0 的tr, ldtr) (kernel/sched.c)
// 调度程序的初始化⼦程序。
void sched_init(void)
{
...
set_intr_gate(0x20, &timer_interrupt);
// 修改中断控制器屏蔽码,允许时钟中断。
outb(inb_p(0x21) & ~0x01, 0x21);
// 设置系统调⽤中断⻔。
set_system_gate(0x80, &system_call);
...
}
// system_call.s
_timer_interrupt:
...
;// do_timer(CPL)执⾏任务切换、计时等⼯作,在kernel/shched.c,305 ⾏实现。
call _do_timer ;// 'do_timer(long CPL)' does everything from
// 调度⼊⼝
void do_timer(long cpl)
{
...
schedule();
}
void schedule(void)
{
...
switch_to(next); // 切换到任务号为next 的任务,并运⾏之。
}
(3)死循环

这是理解时钟中断最经典的问题.
假设程序:
cpp
int main()
{
while (1)
{
}
}
从程序自身来看:
text
while → while → while → while → ...
它:
- 不调用系统调用
- 不主动退出
- 不主动让出 CPU
那操作系统怎么重新获得 CPU?
答案就是:
时钟中断。
即使程序执行:
cpp
while (1);
硬件定时器仍然独立工作:
text
用户进程:
while
while
while
while
↓
=============== 时钟中断 ===============
↓
CPU 强制进入内核
↓
操作系统调度
↓
切换到其他进程
所以死循环并不能阻止操作系统运行.
可以把 CPU 想成一个正在看书的人:
text
进程:
"你一直读我的书,永远不要停!"
CPU:
"好的......"
但是旁边有个闹钟:
叮!
CPU:
"我要先去处理操作系统的事情。"
这个"闹钟"就是 时钟中断.
因此:
即使用户进程陷入死循环,操作系统仍然可以通过时钟中断强制拿回 CPU.
不过死循环会消耗大量 CPU 时间:
text
while(1)
↓
获得时间片
↓
一直执行
↓
时间片用完
↓
被调度出去
↓
以后再次被调度
↓
继续死循环
所以你看到:
text
CPU 使用率很高
但其他进程通常仍然能运行.
cpp
void main(void) /* 这⾥确实是void,并没错。 */
{ /* 在startup 程序(head.s)中就是这样假设的。 */
...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
*/
for (;;)
pause();
} // end main
(4)软中断

这里需要区分两个容易混淆的概念.
第一种:广义"软件中断"
操作系统课程里经常把由软件主动触发进入内核的机制称为软件中断.
例如用户程序调用:
cpp
write();
程序需要操作系统提供服务:
text
用户程序
↓
系统调用
↓
通过特定 CPU 指令进入内核
↓
内核执行系统调用
↓
返回用户程序
传统 x86 上曾经常见:
asm
int 0x80
例如:
text
用户程序
↓
int 0x80
↓
CPU 切换到内核态
↓
执行系统调用
这种是:
软件主动触发的同步事件.
和硬件中断相比:
text
硬件中断:外部硬件突然打断 CPU
软件中断:程序执行某条特殊指令主动进入内核
Linux 内核里的 softirq 又是另一回事
Linux 内核里还有一个专门概念叫:
text
softirq
软中断
它主要用于:
把硬件中断中不需要立即完成的工作,延迟到稍后执行.
比如网卡收到数据.
如果硬件中断处理函数一次把所有工作做完:
text
网卡中断
↓
解析数据
↓
处理协议
↓
处理 TCP
↓
处理 IP
↓
交给 socket
↓
...
中断处理时间会非常长.
因此 Linux 会拆成:
text
网卡硬件中断
↓
快速处理紧急工作
↓
安排 softirq
↓
退出硬件中断
↓
稍后执行软中断
↓
继续处理网络数据
简单理解:
text
硬中断:
"有事情来了!赶快先处理最重要的。"
软中断:
"剩下的工作稍后处理。"
所以在 Linux 内核语境下:
text
硬件中断 IRQ
↓
上半部:快速处理
↓
softirq
↓
下半部:延迟处理
网络处理中大量使用软中断机制.
四者放在一起
可以形成这样一张关系图:
text
CPU 正在执行用户程序
│
┌───────────────────┼───────────────────┐
│ │ │
外部设备 定时器 程序主动请求
│ │ │
▼ ▼ ▼
硬件中断 时钟中断 软件触发
│ │ │
└──────────────┬────┴───────────────┘
▼
内核态
│
操作系统进行处理
│
┌───────────┴────────────┐
│ │
返回原程序 进程调度
│
▼
其他进程
而死循环:
text
while(1)
│
│ 无法主动进入内核
│
▼
一直占 CPU
│
│ 但是
▼
时钟中断到来
↓
强制进入内核
↓
调度其他进程
最后用一句话区分
| 概念 | 谁触发 | 主要作用 |
|---|---|---|
| 硬件中断 | 外部硬件 | 通知 CPU 发生硬件事件 |
| 时钟中断 | 硬件定时器 | 周期性让 OS 获得 CPU,支持调度 |
| 死循环 | 用户程序自身 | 一直运行,但仍会被时钟中断打断 |
| 软中断 | 软件/内核机制 | 软件触发进入内核,或 Linux 中用于延迟处理中断工作 |
最值得记住的是这条:
用户程序即使死循环,也不能真正"控制住"CPU,因为硬件时钟会周期性产生中断,把 CPU 强制交还给操作系统,操作系统再决定下一个运行谁.
(5)缺⻚中断、内存碎⽚处理以及除零野指针错误
缺页、除零、野指针通常属于"异常(Exception)"这一类;内存碎片则不是中断或异常,而是内存管理问题.
| 情况 | 本质 | 是否进入内核 | 常见结果 |
|---|---|---|---|
| 缺页 | CPU 异常 / Page Fault | ✅ | 内核补页后继续,或产生 SIGSEGV |
| 内存碎片 | 内存管理问题 | 不一定 | 分配失败、内存整理等 |
| 除零 | CPU 异常 | ✅ | 通常向进程发送 SIGFPE |
| 野指针 | 常导致缺页异常 | ✅ | 通常 SIGSEGV,但不一定 |
1.缺页异常 Page Fault
比如程序访问:
c
int x = *p;
CPU 根据虚拟地址查页表时发现:
text
虚拟地址
↓
查页表
↓
这个页当前不存在 / 权限不允许
↓
CPU 产生 Page Fault
↓
进入 Linux 内核
这里有两种完全不同的结果.
如果地址合法,只是页面暂时没装进物理内存:
text
缺页
↓
内核分配物理页
↓
从磁盘读取 / 建立映射
↓
更新页表
↓
重新执行刚才那条指令
程序甚至不知道发生过缺页.
例如:
c
int *p = malloc(4096);
*p = 100;
某些情况下 malloc() 只是建立了虚拟地址空间,第一次真正访问页面时才发生缺页,由内核分配物理页.
如果地址根本不合法:
c
int *p = NULL;
*p = 100;
则:
text
CPU Page Fault
↓
进入内核
↓
内核检查地址
↓
发现地址非法
↓
给进程发送 SIGSEGV
↓
进程终止
所以:
Page Fault 本身并不一定是错误.它是虚拟内存正常工作的核心机制之一.
2.内存碎片不是"中断"
内存碎片分两种.
内部碎片:分给你的空间比你实际需要的大.
例如:
text
申请 6 KB
实际按 8 KB 分配
用了:6 KB
浪费:2 KB
这 2 KB 就类似内部碎片.
外部碎片:空闲内存很多,但不连续:
text
已用 | 空闲 | 已用 | 空闲 | 已用 | 空闲
2M 3M 1M
总共有:
text
6 MB 空闲
但如果需要连续的:
text
5 MB
可能找不到.
Linux 会通过很多机制处理,例如:
text
伙伴系统 Buddy System
页分配
内存回收
页面换出
内存压缩 compaction
SLAB / SLUB
所以:
内存碎片属于操作系统的内存管理问题,不属于硬件中断、软中断或 CPU 异常.
3.除零错误
例如:
c
int a = 10;
int b = 0;
int c = a / b;
CPU 执行除法指令时直接发现:
text
除数 = 0
CPU 不能继续正常执行,于是产生异常:
text
用户程序
↓
执行除法
↓
发现除 0
↓
CPU 产生异常
↓
进入内核
↓
Linux 处理异常
↓
向当前进程发送 SIGFPE
所以你可能看到:
text
Floating point exception
注意,名字虽然叫 SIGFPE(Floating Point Exception),整数除零也通常会产生这个信号.
它属于:
同步异常.
因为就是当前这条指令直接导致的.
4.野指针错误
例如:
c
int *p = (int *)0x12345678;
*p = 100;
如果这个地址没有合法映射:
text
CPU 访问地址
↓
查页表
↓
发现地址非法
↓
Page Fault
↓
进入内核
↓
内核判断无法修复
↓
SIGSEGV
最终经常看到:
text
Segmentation fault
还有一个很重要的细节:
野指针不一定立即报错.
比如:
c
int *p;
*p = 100;
如果 p 碰巧指向一个当前进程有权限访问的合法地址,那么CPU不会产生异常.
结果可能是:
text
没有崩溃
↓
却修改了不该修改的数据
↓
程序之后莫名其妙出错
这也是野指针危险的地方.
把它们放到"中断 / 异常"体系里
可以整理成:
text
CPU 从正常程序进入内核
│
┌─────┼──────────┐
│ │ │
硬件中断 异常 系统调用
│ │ │
键盘 缺页 read()
网卡 除零 write()
时钟 非法访问 fork()
磁盘 野指针 ...
其中:
text
硬件中断
→ 外部设备产生
→ 异步
异常
→ 当前 CPU 指令执行导致
→ 同步
系统调用
→ 程序主动请求操作系统服务
→ 同步
所以这几个例子最适合这样记:
text
缺页
→ CPU 异常
→ 可能正常修复,也可能 SIGSEGV
除零
→ CPU 异常
→ 通常 SIGFPE
野指针
→ 经常导致缺页异常
→ 通常 SIGSEGV
内存碎片
→ 内存管理问题
→ 不是中断/异常
一句话总结:
异常是 CPU 执行当前指令时"出了特殊情况";缺页、除零、非法地址访问都属于这一范畴,而内存碎片是操作系统管理内存时产生的问题.
4.4理解内核态和⽤户态

5.可重⼊函数

可重入函数(Reentrant Function):一个函数在执行过程中被中断后,尚未执行完,又被再次调用;第二次调用不会破坏第一次调用的执行状态,两个调用都能得到正确结果.
结合前面的信号场景,可以这样理解:
text
main()
↓
调用 func()
↓
func 执行到一半
↓
SIGINT 到达
↓
进入 handler()
↓
handler 又调用 func()
↓
第二次 func 执行完成
↓
返回第一次 func
↓
继续执行
如果最终仍然正确,那么 func() 就具备可重入性.
不可重入的典型原因
比如:
c
int count = 0;
void func()
{
count++;
// ...
count--;
}
这里使用了全局变量 count.假设第一次执行:
text
count = 0
↓
第一次 func
count = 1
↓
发生信号
↓
handler 再次调用 func
count = 2
↓
...
两个调用操作同一个全局变量,容易造成状态混乱,所以这种函数通常不可重入.
之前的链表例子也是一样:
c
node_t *head;
void insert(node_t *p)
{
p->next = head; // ①
// 此时发生中断
head = p; // ④
}
如果在①和④之间发生信号,中断处理函数又调用:
c
insert(&node2);
可能出现:
text
第一次:
node1->next = head
↓ 中断
第二次:
node2->next = head
head = node2
↓ 返回
第一次继续:
head = node1
最终可能:
text
head → node1 → 原链表
node2 → 原链表
node2 从链表入口丢失了.
这就是不可重入导致共享状态被破坏.
可重入函数通常需要满足什么?
核心原则:
一次调用不能依赖或修改其他调用共享的可变状态。
所以通常应做到:
text
尽量使用
✅ 局部变量
✅ 函数参数
✅ 调用者提供的内存
尽量避免
❌ 修改全局变量
❌ 修改 static 局部变量
❌ 依赖共享缓冲区
❌ 在没有保护的情况下操作共享数据
例如:
c
int add(int a, int b)
{
int sum = a + b;
return sum;
}
这个函数只有参数和局部变量:
text
第一次调用:自己的栈帧
第二次调用:自己的栈帧
互不影响,因此天然具有很好的可重入性.
一个很形象的理解
不可重入:
text
两个人共用一张草稿纸
第一次调用写到一半
↓
第二次调用把草稿改了
↓
第一次回来继续算
↓
数据乱了
可重入:
text
第一次调用 → 自己一张草稿纸
第二次调用 → 自己一张草稿纸
互不影响
这里的"草稿纸"就可以理解为:
text
函数栈帧 + 局部变量
一句话记忆
可重入函数:执行到一半被打断,再次调用自己,也不会把第一次调用的数据搞乱。
最典型的特征就是:
text
少用共享状态
多用参数和局部变量
每次调用拥有独立执行现场
6.volatile
volatile 的核心作用是:
告诉编译器:这个变量的值可能在当前代码看不到的地方被修改,所以每次使用时都要真正去内存读取,不能随意把它长期缓存到寄存器里。
1.为什么信号场景需要 volatile
例如:
c
int flag = 0;
void handler(int signo)
{
flag = 1;
}
int main()
{
signal(SIGINT, handler);
while (flag == 0)
{
}
printf("收到信号\n");
}
看起来没问题,但编译器优化后可能认为:
text
main() 里面没人修改 flag
↓
只读取一次 flag
↓
发现 flag == 0
↓
一直循环
甚至可能近似优化成:
c
if (flag == 0)
{
while (1)
{
}
}
但是实际上:
text
main 正在执行
↓
SIGINT 到达
↓
handler 被异步调用
↓
flag = 1
编译器未必能从普通控制流中推断到这一点.
因此:
c
volatile int flag = 0;
表示:
flag随时可能发生变化,每次判断都重新读取.
2.信号处理中更推荐这样写
c
#include <signal.h>
volatile sig_atomic_t flag = 0;
void handler(int signo)
{
flag = 1;
}
为什么不只是:
c
volatile int flag;
而是:
c
volatile sig_atomic_t flag;
因为 sig_atomic_t 是专门为这种场景设计的类型:
对它进行简单读写时,不会在中间被信号打断导致只修改了一半.
所以:
text
volatile
↓
防止编译器把访问优化掉
sig_atomic_t
↓
保证信号场景下简单读写具有原子性
通常组合使用:
c
volatile sig_atomic_t flag;
3.volatile 本身不保证原子性
这是最重要的误区之一.
例如:
c
volatile int count = 0;
count++;
并不是一个不可分割的操作.
实际上大概是:
text
读取 count
↓
+1
↓
写回 count
如果中间发生并发访问:
text
线程 A:读 count = 10
线程 B:读 count = 10
线程 A:写 11
线程 B:写 11
本来执行两次 ++:
text
期待:12
实际:11
所以:
volatile≠ 原子操作。
4.volatile 也不保证线程安全
例如:
c
volatile int value = 0;
多个线程:
c
value++;
依然可能产生数据竞争.
所以多线程同步要使用:
cpp
std::atomic<int>
或者:
text
mutex
pthread_mutex_t
而不是依赖 volatile.
5.volatile 常见用途
信号处理
c
volatile sig_atomic_t quit = 0;
内存映射硬件寄存器
例如嵌入式:
c
volatile unsigned int *reg =
(volatile unsigned int *)0x12340000;
因为硬件可能直接修改这个地址.
某些底层系统代码
值可能被:
text
硬件
中断
特殊运行环境
异步改变.
6.普通变量 vs volatile
普通变量:
c
int flag;
编译器可以:
text
内存 flag
↓ 读取一次
CPU 寄存器
↓
以后一直用寄存器里的值
volatile:
c
volatile int flag;
要求可观察的 volatile 访问不能被随意消掉或合并:
text
每次使用
↓
重新访问 flag
c
volatile sig_atomic_t flag = 0;
这是信号处理函数和正常执行流程之间传递简单状态标志的经典写法.
text
handler
│
│ flag = 1
↓
volatile sig_atomic_t flag
↑
│ 每次重新读取
│
main
一句话总结:
volatile防编译器错误优化,sig_atomic_t保证简单读写的信号安全性;volatile本身既不保证原子性,也不保证线程安全。
7.SIGCHLD信号
SIGCHLD 是 子进程状态发生变化时,内核发送给父进程的信号 。最常见的用途是:通知父进程"子进程退出了,赶紧来回收"。
1.什么时候产生 SIGCHLD
例如:
cpp
pid_t id = fork();
if (id == 0)
{
// 子进程
sleep(3);
exit(0);
}
else
{
// 父进程
...
}
子进程退出时:
text
子进程 exit()
↓
内核保存子进程退出信息
↓
子进程进入 Zombie(僵尸)状态
↓
向父进程发送 SIGCHLD
↓
父进程 wait / waitpid
↓
回收子进程资源
除了终止,子进程停止或恢复执行 等状态变化也可能产生 SIGCHLD.
2.为什么需要 SIGCHLD
如果父进程一直不调用:
cpp
wait();
或者:
cpp
waitpid();
那么已经退出的子进程会留下少量内核信息:
text
PID
退出码
资源使用情况
...
形成:
text
Zombie Process
僵尸进程
所以 SIGCHLD 可以理解成子进程对父进程说:
"我退出了,我的退出信息还在内核里,请把我回收掉。"
3.捕捉 SIGCHLD
简单示例:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void handler(int signo)
{
wait(nullptr);
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if (id == 0)
{
sleep(3);
_exit(0);
}
while (true)
{
sleep(1);
}
}
流程:
text
父进程
│
├──── fork() ────→ 子进程
│ │
│ │ exit
│ ↓
│ SIGCHLD
│ │
↓ │
handler(SIGCHLD) ←──────┘
↓
wait()
↓
回收子进程
4.更正确的处理方式:waitpid()
实际代码通常不能只:
cpp
wait(nullptr);
因为多个子进程可能几乎同时退出,而普通信号不会为每个实例都单独排队。
所以推荐:
cpp
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <errno.h>
void handler(int signo)
{
int old_errno = errno;
while (waitpid(-1, nullptr, WNOHANG) > 0)
{
// 一次尽可能回收所有已经退出的子进程
}
errno = old_errno;
}
注册:
cpp
struct sigaction act = {};
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
act.sa_flags = SA_RESTART;
sigaction(SIGCHLD, &act, nullptr);
这里:
cpp
waitpid(-1, nullptr, WNOHANG);
含义是:
text
-1
→ 等待任意子进程
WNOHANG
→ 如果当前没有已经退出的子进程,立即返回,不阻塞
所以:
text
SIGCHLD 到达
↓
handler
↓
waitpid(-1, ..., WNOHANG)
↓
回收一个
↓
继续 waitpid
↓
回收第二个
↓
继续...
↓
没有可回收子进程
↓
返回
5.为什么一定要 while
假设有三个子进程:
text
child1 ─→ exit
child2 ─→ exit
child3 ─→ exit
它们很快连续退出.
不能简单认为:
text
3 个子进程退出
=
一定收到 3 次 SIGCHLD
普通信号可能发生合并。
所以正确思想是:
收到一次
SIGCHLD后,不是只回收一个,而是把当前所有已经退出的子进程都回收。
因此:
cpp
while (waitpid(-1, NULL, WNOHANG) > 0)
{
}
是经典写法.
6.SIGCHLD 和僵尸进程的关系
可以这样记:
text
子进程退出
↓
变成僵尸
↓
产生 SIGCHLD
↓
父进程调用 wait/waitpid
↓
彻底回收
注意一个很容易混淆的点:
SIGCHLD 的默认动作虽然是"忽略",但默认情况下并不代表自动回收子进程。
也就是说:
cpp
// 什么都不设置
父进程仍然通常需要:
cpp
wait();
waitpid();
否则可能产生僵尸进程。
但是如果父进程显式设置:
cpp
signal(SIGCHLD, SIG_IGN);
在 Linux/POSIX 语义下,子进程退出后通常会被自动回收,不留下僵尸.
还可以通过:
cpp
SA_NOCLDWAIT
实现类似效果.
7.SA_NOCLDSTOP
默认情况下,子进程被停止等状态变化也可能通知父进程.
如果父进程只关心子进程退出:
cpp
act.sa_flags = SA_RESTART | SA_NOCLDSTOP;
其中:
text
SA_NOCLDSTOP
↓
子进程 stop / continue 时
不因为停止状态通知父进程
一句话记忆:
SIGCHLD= 子进程状态变化通知;父进程最典型的处理就是使用wait/waitpid回收退出的子进程,避免僵尸进程。

8.拓展阅读
8.1用户态和内核态
用户态(User Mode)和内核态(Kernel Mode)本质上是CPU 的两种权限级别.
- 用户态:普通应用程序运行的状态,权限低,不能直接操作硬件和关键内核资源.
- 内核态:操作系统内核运行的状态,权限最高,可以访问硬件、内存、设备、进程等系统资源.
CPU指令集 :是CPU实现软件指挥硬件执⾏的媒介,具体来说每⼀条汇编语句都对应了⼀条
CPU 指令,⽽⾮常⾮常多的CPU指令在⼀起,可以组成⼀个、甚⾄多个集合,指令的集合叫CPU 指令集.
CPU指令集有权限分级,⼤家试想,CPU指令集可以直接操作硬件的,要是因为指令操作的不规范,造成的错误会影响整个计算机系统的.好⽐你写程序,因为对硬件操作不熟悉,导致操作系统内核、及其他所有正在运⾏的程序,都可能会因为操作失误⽽受到不可挽回的错误,最后只能重启计算机才⾏.对开发⼈员来说是个艰巨的任务,还会增加负担,同时开发⼈员在这⽅⾯也不被信任,所以操作系统内核直接屏蔽开发⼈员对硬件操作的可能,都不让你碰到这些CPU指令集.
针对上⾯的需求,硬件设备商直接提供硬件级别的⽀持,做法就是对CPU指令集设置了权限,不同级别权限能使⽤的CPU指令集是有限的,以Inter CPU为例,Inter把CPU指令集操作的权限由
⾼到低划为4级:
- ring 0:权限最⾼,可以使⽤所有 CPU 指令集
- ring 1
- ring 2
- ring 3:权限最低,仅能使⽤常规CPU指令集,不能使⽤操作硬件资源的CPU指令集,⽐如 IO 读写、⽹卡访问、申请内存都不⾏.
要知道的是,Linux系统仅采⽤ring 0 和 ring 3 这2个权限.CPU中有⼀个标志字段,标志着线程的运⾏状态,⽤户态为3,内核态为0.
ring 0被叫做内核态,完全在操作系统内核中运⾏:执⾏内核空间的代码,具有ring 0保护级别,有对硬件的所有操作权限,可以执⾏所有 CPU指令集,访问任意地址的内存,在内核模式下的任何异常都是灾难性的,将会导致整台机器停机.
ring 3被叫做⽤户态,在应⽤程序中运⾏:在⽤户模式下,具有ring 3保护级别,代码没有对硬件的直接控制权限,也不能直接访问地址的内存,程序是通过调⽤系统接⼝(System Call APIs)来达到访问硬件和内存,在这种保护模式下,即时程序发⽣崩溃也是可以恢复的,在电脑上⼤部分程序都是在,⽤户模式下运⾏的.
低权限的资源范围较⼩,⾼权限的资源范围更⼤,所以⽤⼾态与内核态的概念就是CPU 指令集权限的区别.我们通过指令集权限区分⽤户态和内核态,还限制了内存资源的使⽤,操作系统为⽤户态与内核态划分了两块内存空间,给它们对应的指令集使⽤.在内存资源上的使⽤,操作系统对⽤户态与内核态也做了限制,每个进程创建都会分配虚拟空间地址,以Linux32位操作系统为例,它的寻址空间范围是4G(2的32次⽅),⽽操作系统会把虚拟控制地址划分为两部分,⼀部分为内核空间,另⼀部分为⽤户空间,⾼位的1G(从虚拟地址0xC0000000到0xFFFFFFFF)由内核使⽤,⽽低位的3G(从虚拟地址0x00000000到0xBFFFFFFF)由各个进程使⽤.

8.2⽤户态与内核态的切换
什么情况会导致⽤户态到内核态切换?
- 系统调⽤ :⽤⼾态进程主动切换到内核态的⽅式,⽤户态进程通过系统调⽤向操作系统申请资源完成⼯作,例如fork()就是⼀个创建新进程的系统调⽤.操作系统提供了中断指令int 0x80来主动进⼊内核,这是⽤⼾程序发起的调⽤访问内核代码的唯⼀⽅式.调⽤系统函数时会通过内联汇编代码插⼊int 0x80的中断指令,内核接收到int0x80中断后,查询中断处理函数地址,随后进⼊系统调⽤.
- 异常 :当 CPU 在执⾏⽤户态的进程时,发⽣了⼀些没有预知的异常,这时当前运⾏进程会切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺⻚异常.
- 中断 :当 CPU 在执⾏⽤户态的进程时,外围设备完成⽤户请求的操作后,会向 CPU 发出相应的中断信号,这时CPU会暂停执⾏下⼀条即将要执⾏的指令,转到与中断信号对应的处理程序去执⾏,也就是切换到了内核态.如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执⾏后边的操作等.
切换时CPU需要做什么?
- 当某个进程中要读写IO,必然会⽤到 ring 0 级别的CPU指令集.⽽此时 CPU 的指令集操作权限只有ring 3,为了可以操作ring 0 级别的CPU指令集,CPU 切换指令集操作权限级别为ring 0(可称之为提权),CPU再执⾏相应的ring 0 级别的 CPU 指令集(内核代码).
- 代码发⽣提权时,CPU 是需要切换栈的!前⾯提到过,内核有⾃⼰的内核栈.CPU切换栈是需要栈段描述符(ss寄存器)和栈顶指针(esp寄存器),这两个值从哪⾥来?CPU通过⼀个段寄存器(tr)确定TSS(任务状态段,struct TSS)的位置.在TSS结构中存在这么⼀个 SS0 和 ESP0.提权的时候,CPU就从这个TSS⾥把SS0和ESP0取出来,放到ss和esp寄存器中.
切换流程
- 从⽤户态切换到内核态时,⾸先⽤户态可以直接读写寄存器,⽤户态操作CPU,将寄存器的状态保存到对应的内存中,然后调⽤对应的系统函数,传⼊对应的⽤户栈地址和寄存器信息,⽅便后续内核⽅法调⽤完毕后,恢复⽤户⽅法执⾏的现场.
- 从⽤户态切换到内核态需要提权,CPU切换指令集操作权限级别为 ring 0.
- 提权后,切换内核栈.然后开始执⾏内核⽅法,相应的⽅法栈帧时保存在内核栈中.
- 当内核⽅法执⾏完毕后,CPU切换指令集操作权限级别为ring 3,然后利⽤之前写⼊的信息来恢复⽤户栈的执⾏.
8.3追踪fopen
源码glibc2.9 :
• 中国科学技术⼤学镜像站:https://mirrors.ustc.edu.cn/gnu/libc/
• 清华⼤学镜像站:https://mirrors.tuna.tsinghua.edu.cn/gnu/libc/
cpp
# define _IO_new_fopen fopen //宏
// 转到定义
_IO_FILE *
_IO_new_fopen (filename, mode)
const char *filename;
const char *mode;
{
return __fopen_internal (filename, mode, 1);
}
// 转到定义
_IO_FILE *
__fopen_internal (filename, mode, is32)
const char *filename;
const char *mode;
int is32;
{
if (INTUSE(_IO_file_fopen) ((_IO_FILE *) new_f, filename, mode, is32)
}
// 搜索:_IO_file_fopen
INTDEF2(_IO_new_file_fopen, _IO_file_fopen)
// 为了保证软件兼容性,给_IO_file_fopen 起别名:_IO_new_file_fopen,也就是底层调⽤的
就是_IO_new_file_fopen
// INTDEF2:这个宏⽐较难看,忽略
// 搜索:_IO_new_file_fopen
_IO_FILE *
_IO_new_file_fopen (fp, filename, mode, is32not64)
_IO_FILE *fp;
const char *filename;
const char *mode;
int is32not64;
{
result = _IO_file_open (fp, filename, omode|oflags, oprot, read_write,
is32not64);
}
// 转到定义
_IO_FILE *
_IO_file_open (fp, filename, posix_mode, prot, read_write, is32not64)
_IO_FILE *fp;
const char *filename;
int posix_mode;
int prot;
int read_write;
int is32not64;
{
fdesc = open (filename, posix_mode, prot); // 系统调⽤
}
// open,被__open替换
#define open(Name, Flags, Prot) __open (Name, Flags, Prot)
// _open之后,因为glibc做了很多隐藏机制,我们直接看伪代码就可以了
int __open(const char *file, int oflag, mode_t mode)
{
return INLINE_SYSCALL(open, 3, file, oflag, mode);
}
# define INLINE_SYSCALL(name, nr, args...) \
({ \
unsigned long int resultvar = INTERNAL_SYSCALL (name, , nr, args);
\
if (__builtin_expect (INTERNAL_SYSCALL_ERROR_P (resultvar, ), 0)) \
{ \
__set_errno (INTERNAL_SYSCALL_ERRNO (resultvar, )); \
resultvar = (unsigned long int) -1; \
} \
(long int) resultvar; })
# define INTERNAL_SYSCALL(name, err, nr, args...) \
INTERNAL_SYSCALL_NCS (__NR_##name, err, nr, ##args)
// #define __NR_open 2
// 这个前⾯⻅过了
# define INTERNAL_SYSCALL_NCS(name, err, nr, args...) \
({
unsigned long int resultvar; \
LOAD_ARGS_##nr (args) \
LOAD_REGS_##nr \
asm volatile ( \
"syscall\n\t" \
: "=a" (resultvar) \
: "0" (name) ASM_ARGS_##nr : "memory", "cc", "r11", "cx"); \
(long int) resultvar; })
// "0" (__NR_##name):系统调⽤号存⼊ %eax

🚀真正的勇者不是流泪的人,而是含泪奔跑的人!
敬请期待下一篇文章内容
每日心灵鸡汤: 你失去的不是一个脾气好的人,而是一个愿意替你扛事的人!
学会珍惜你身边这种低表达、高责任感的人.他们不一定会说很多好听的话,也不擅长反复表达情绪,但他们有一个很明显的特点:不习惯解释自己,却习惯承担结果;不喜欢制造情绪,却习惯解决问题.他们重承诺、重长期、重边界.真正把一个人放在心上时,不只是陪伴和安慰,而是会把你放进自己的时间、资源、责任和未来规划里.很多时候,你觉得生活很稳定、问题总能解决,并不是因为事情简单,而是有人在替你吸收成本、处理风险、承担压力.但这种人的付出并不是无限的.他们可以包容错误,却不会无限接受失衡;可以原谅一次失望,却会持续判断这段关系还值不值得继续.他们不会因为一次冲突立刻离开,而是在一次次失望里降低信任、减少投入、收回责任,直到某个临界点彻底退出.而这种人真正离开时,往往没有争吵,也没有拉扯.因为在正式离开之前,他早已经完成了内部的判断.最让人后知后觉的,是等他离开以后,那些过去被他承担掉的成本重新落回你身上,你才会发现:你失去的不是一个脾气好的人,而是一个曾经愿意长期把你的问题,当成自己问题的人.
