
🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、C++ 🐶学习方向:C++方向学习爱好者 ⭐人生格言:得知坦然 ,失之淡然

🏠博主简介
文章目录
- 前言
- 一、信号处理的时机:内核态返回用户态
-
- 1.1 默认和忽略呢?
- 1.2 执行自定义 handler 时,是以什么身份执行的?
- 1.3 纯死循环是怎么进入内核的?
- 二、sigaction:比 signal 更完整的捕捉接口
-
- 2.1 处理期间,当前信号自动被屏蔽
- 三、操作系统是怎么运行的
-
- 3.1 OS 怎么知道键盘上有数据了
- 3.2 中断向量表:硬件事件的函数指针数组
- 3.3 没有中断时,OS 在干嘛?
- 3.4 软中断:软件也能触发这套逻辑
- 3.5 系统调用的完整链路
- 四、用户态和内核态
- 五、可重入函数
- 六、volatile:信号引出的 C 语言关键字
- 七、SIGCHLD:子进程退出的通知机制
-
- 7.1 先验证
- 7.2 用信号回收所有子进程
- 7.3 SIG_IGN 的历史特例
- 总结
前言
前两篇留了一个坑:信号产生了、保存了,但"合适的时候"到底是什么时候? 这一篇填坑,而且这个坑挖得很深——往下挖会挖到中断向量表、时钟中断、系统调用表、用户态内核态切换,最后用可重入函数、volatile 和 SIGCHLD 收尾。
看完这篇你能回答:纯 while(1) 死循环为什么也能被 Ctrl+C 杀掉?自定义 handler 到底以什么身份在执行?int 0x80 是怎么把你带进内核的?
一、信号处理的时机:内核态返回用户态

先说结论:进程从内核态返回用户态的时候,进行信号检查与处理。
- 内核态:执行 OS 的代码和数据时,计算机所处的模式。调用系统调用,实际是 OS 在执行对应方法;
- 用户态:执行用户自己写的代码(比如 while 死循环)时的模式。
进程在 OS 的驱使之下进行信号检查:OS 在返回用户态之前,检查是否有信号到来。收到了且没被 block,就转而进入信号处理流程。如果是自定义动作,OS 会先回到用户层执行 handler,执行完再返回内核,最后由内核返回用户空间,接着执行主流程代码。
这就是那张著名的 4 次切换图:用户主流程 → 内核(信号检查)→ 用户(handler)→ 内核(sigreturn)→ 用户主流程。
1.1 默认和忽略呢?
- 忽略:pending 表 1→0,直接返回用户层继续跑;
- 默认动作:进程当前在内核态,OS 可以直接杀掉进程。
1.2 执行自定义 handler 时,是以什么身份执行的?
以用户身份执行,不能以内核身份。 想想 handler 是用户写的,万一里面有非法操作——删配置文件这种用户本来没权限干的事——要是拿内核身份跑,权限直接被滥用。所以内核态切到用户态执行 handler,必须做身份切换。
那执行完 handler,怎么知道要"回到内核"?这和函数调用的底层机制有关。
函数调用的本质是压栈。 调用 func(地址假设是 100),要先压入返回地址、形参、临时变量。调用完,把返回地址 pop 给 pc 指针,CPU 就接着 func 后面的代码跑。
有个经典的攻击手法正好利用这一点:假设有个 mybug 函数,把 mybug 的地址强行写到 func 的返回地址里,func 一返回,直接跳去执行 mybug——在 main 和 func 之间硬插了一个函数调用。
信号的捕捉流程用的是同一招:OS 把 sigreturn 这个系统调用的地址"埋"在 handler 执行完的返回处,handler 一结束自然触发 sigreturn,重新陷入内核,再由内核返回主流程。这就解释了为什么执行完自定义动作能自动回到内核。

1.3 纯死循环是怎么进入内核的?
写个 while(1) 不调任何系统调用,Ctrl+C 照样杀得掉。它没调系统调用,怎么进的内核?
只要是进程就会被调度,调度就有时间片。 时间片到了,OS 把进程从 CPU 上强制剥离——这本身就是切入内核的过程。进程在调度中周期性、高频地进出内核,不是一直执行用户代码,也不是一直执行内核代码,而是执行一段用户代码、执行一段内核代码,交替进行,这是 OS 决定的。
所以 Ctrl+C 杀掉 while(1):信号先记在 pending 里,时间片一到进程切入内核,切回来之前检查信号,递达,终止。
二、sigaction:比 signal 更完整的捕捉接口
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

核心作用同 signal,但功能更多(还能处理实时信号)。act 是用户填充的内核数据结构,oldact 是输出型参数,把历史上对特定信号的处理动作带回来,方便将来恢复。
struct sigaction
{
void (*sa_handler)(int); // 自定义捕捉方法
void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号的处理函数
sigset_t sa_mask; // 信号集:额外要屏蔽的信号
int sa_flags; // 选项,本文都设 0
void (*sa_restorer)(void);
};
sa_handler 赋 SIG_IGN 表示忽略、赋 SIG_DFL 表示默认、赋函数指针就是自定义——这也是个回调函数,不被 main 调用,而被系统调用,参数就是信号编号,所以一个函数能处理多种信号。
2.1 处理期间,当前信号自动被屏蔽
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字(block 表置 1),处理函数返回时自动恢复——防止 handler 还没执行完,同类信号又进来把 handler 递归调用一遍。如果还希望处理期间额外屏蔽别的信号,用 sa_mask 字段说明,返回时同样自动恢复。
demo 验证:
void handler(int signum)
{
std::cout << "hello signal:" << signum << std::endl;
while (true)
{
// 不断打印 pending 表
sigset_t pending;
sigpending(&pending);
for (int i = 31; i >= 1; i—)
{
if (sigismember(&pending, i)) std::cout << "1";
else std::cout << "0";
}
std::cout << std::endl;
sleep(1);
}
exit(0);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGINT, &act, &oact); // 捕捉 2 号信号
while (true)
{
std::cout << "hello world" << getpid() << std::endl;
sleep(1);
}
return 0;
}

进了 handler 后再狂按 Ctrl+C:第二次及以后的 2 号信号全部 pending——当前正在处理的信号被自动屏蔽了(Ubuntu20.04、CentOS7 上均验证)。
sa_mask 的用法——处理 2 号期间把 3、4 号也屏蔽:
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
三、操作系统是怎么运行的
这部分是信号的地基。信号处理挂在"内核态→用户态切换"上,那切换本身从哪来?答案:中断。
3.1 OS 怎么知道键盘上有数据了

外部设备在硬件上直接和 CPU 的针脚连接,设备一旦就绪(按下、回车),就向特定针脚发硬件中断。CPU 针脚可以和外设做信号沟通,外设通过触发高低电平通知 CPU。
但外设不可能每个都直接连 CPU——两者速度差几何倍,而且 CPU 引脚数量有限。所以中间有个中断控制器:

全部外设接入中断控制器,控制器只用少数引脚连 CPU。 把中断控制器想成一个多插槽设备:外设接在插槽上,某外设就绪就给对应插槽发高电平,控制器识别出是哪个针脚被点亮,把针脚编号写进自己的寄存器,再主动给 CPU 特定针脚发高电平。CPU 知道"有设备准备好了",但不知道是谁,于是读中断控制器的寄存器拿到中断号——由此确定是哪个设备。
顺便说一句,外设里也有寄存器:CPU 向磁盘写数据,指令(in)发给磁盘的控制器,控制寄存器存指令、地址寄存器存要访问的位置、数据寄存器存内容;CPU 访问内存也一样,先把物理地址写进内存的地址寄存器,内存据此定位。
3.2 中断向量表:硬件事件的函数指针数组
CPU 知道哪个设备就绪还没完——CPU 不能执行代码,只有软件知道怎么处理数据。
所以 OS 在自己内部维护一张中断向量表:一个函数指针数组,数组下标就是中断号,指向各类中断处理方法。CPU 拿着中断号查表,找到方法入口,执行中断处理(比如读键盘数据)。
OS 不需要轮询外设:外设准备好了会"叫我"。 中断向量表本身就是 OS 的一部分,开机就加载进内存了。
看到这里是不是觉得眼熟?
发中断 —— 发信号
保存中断号 —— 记录信号(pending)
中断号 —— 信号编号
处理中断 —— 处理信号(handler)
外部设备 —— 中断源/信号源
中断被屏蔽 —— block 表
信号是纯软件的,但本质是用软件模拟硬件中断——信号的思想来源就是硬件中断。
3.3 没有中断时,OS 在干嘛?
什么都不做,OS 是暂停的。

一直闲着太尴尬,所以:在 OS 的中断向量表里注册一个中断服务——进程调度;在硬件上引入时钟源,以固定频率向 CPU 发中断。操作系统就在硬件时钟中断的驱动下进行调度。 OS 就是一个基于中断工作的软件,本质是一个死循环——需要什么功能,就往中断向量表里加方法,剩下的"躺平"等中断。
后来发现时钟源放外面会和外设抢中断控制器、硬件触发效率也低,干脆把时钟源集成进 CPU 内部:没有外设中断时,CPU 自己按固定间隔触发时钟中断。CPU 由此有了"主频"这个概念——频率就是中断触发的频率。 主频为什么越快 CPU 越快?主频可以作为 OS 调度执行速度的参考之一,这就是答案。时间片是什么?时钟中断驱动的调度节拍。

3.4 软中断:软件也能触发这套逻辑
外部硬件中断要硬件触发。有没有可能因为软件原因也触发?有。

CPU 把除 0 这类错误规定成由 CPU 内部触发的中断:一旦检测到除 0 错误,CPU 自己生成中断号,OS 停下来走中断处理——比如给目标进程发信号。野指针也在 CPU 内部转成硬件中断。所有异常都会被转成中断,OS 由此知道硬件出异常了,系统自动注册了对应的异常处理方法。
缺页中断也在中断向量表里:虚拟地址合法、页表却没建映射(物理页不存在),触发缺页中断,处理方法重新申请物理空间、构建映射。所以在虚拟地址空间申请空间,不一定立刻在物理内存开辟。
注意区分:除 0 是我们写的代码导致硬件先出错,硬件触发的中断;而 CPU 还提供了主动陷入内核的指令——x86 的 int、x86_64 的 syscall,执行 int 0x80 就主动触发一次中断流程。
3.5 系统调用的完整链路
CPU 是硬件,但有自己的指令集,C/C++ 编译成二进制,本质就是指令集 + 数据。Linux 把所有系统调用写进一张系统调用表(sys_call_table,内核里的函数指针数组),每个系统调用有唯一下标——系统调用号。 在中断表对应下标处注册方法:1. 获取系统调用号 2. 调用系统调用方法。
用户层以 open 为例:
move eax, 5 # 把系统调用号 5 放进寄存器 eax
int 0x80 # 触发软中断
→ CPU 查中断向量表执行 int 80 的方法
→ 从约定寄存器 eax 拿到系统调用号
→ 索引 sys_call_table,调用对应方法
→ 返回值经寄存器带回用户层
那我们平时调 open 怎么没写过 int 0x80?因为 OS 根本不提供系统调用接口,OS 只提供系统调用号。我们用的 open、fork 全是 glibc 封装的。

glibc 里 #define SYS_ify(syscall_name) __NR_##syscall_name 这个宏把系统调用名转成调用号:SYS_ify(open) 展开为 __NR_open。系统调用号不是 glibc 提供的,是内核提供的;内核提供入口(man 2 syscall)、汇编级软中断命令和头文件,让上层语言的设计者完成封装。
命名习惯:故意的 int 0x80/syscall 叫陷阱(Trap)——没有异常,是故意陷入内核;除 0、野指针这种叫异常(Exception)。 "缺页异常"这个名字的由来也在这。
四、用户态和内核态
系统调用的过程也是在进程地址空间上进行的,所有函数调用都是地址空间之间的跳转。
32 位下地址空间分两半:0-3G 用户空间,3-4G 内核空间。OS 也是软件,也在内存里,1G 的内核空间同样要映射到物理内存——内核页表。
内核页表和用户页表的关键区别:每个进程都有自己 0-3G 的数据,用户页表有多份;而 OS 的代码数据只有一份,每个进程都把同一份 OS 映射到自己 3-4G 的内核空间——内核页表全系统只有一份,所有进程共享。
两条结论:
用户态:以用户身份,只能访问自己的 [0-3GB]
内核态:以内核身份,允许通过系统调用访问 OS 的 [3-4GB]
硬件上怎么区分? CPU 内部的代码段寄存器 cs,低 2 个比特位记录当前模式:0 是内核态,3 是用户态(称作 CPL:当前权限级别)。用户态下访问 3-4G 的地址,CPU 直接终止进程。执行 int 0x80 时,cs 指向 OS 代码段、权限位 3→0,这就是陷入内核——但不能直接拿地址访问任意内核代码,必须带系统调用号,否则视为非法操作。
五、可重入函数

经典事故现场:main 调用 insert 向链表 head 插节点 node1,插入分两步(第一步 p->next = head),刚做完第一步,硬件中断使进程切到内核,回用户态前检查到有信号待处理,于是进 sighandler——handler 里也调用 insert 插 node2,两步都做完了。回到 main 的 insert 继续第二步。结果:插了两个节点,链表上只剩一个——另一个节点丢了,内存泄漏。
像这样,函数被不同的控制流程调用(main 执行流、handler 执行流),第一次调用还没返回就再次进入,称为重入。insert 访问全局链表,可能因重入造成错乱——不可重入函数。反之,只访问自己局部变量或参数的函数是可重入(Reentrant)函数。为什么访问局部变量没事?局部变量在各自栈帧里,两个执行流各用各的。
大部分函数都是不可重入的,符合以下条件之一就是:
- 调用了 malloc 或 free——malloc 用全局链表管理堆;
- 调用了标准 I/O 库函数——标准库的很多实现以不可重入方式使用全局数据结构。
可重入和不可重入描述的是函数的特点,不是优缺点。
六、volatile:信号引出的 C 语言关键字
看个 demo:
int flag = 0;
void handler(int signu)
{
std::cout << "更改全局变量," << flag << "-> 1" << std::endl;
flag = 1;
}
int main()
{
signal(2, handler);
while (!flag); // main 里并不修改 flag
std::cout << "process quit normal" << std::endl;
return 0;
}
预期:Ctrl+C 后 handler 把 flag 改成 1,循环退出。但在高优化级别下,main 循环可能永远退不出去。
为什么?先补一个背景:所有变量都在物理内存上,CPU 计算分三步——把数据从内存加载到 CPU、在寄存器里运算、需要时写回内存。 编译器优化时,如果看到 flag 在 main 里只读不写,就自作主张把 flag 缓存进寄存器,之后循环只查寄存器、不再访存。
而编译器无法识别执行流的概念——它看不到 flag 和 handler 的关系,不知道 handler 会异步修改 flag。
gcc 的优化级别:-O0 不优化(默认)、-O1 常规、-O2/-O3 更高。

O1 下的现象:循环卡死,再按一次 Ctrl+C 打印 1->1。解释:handler 里读的是内存里真实的 flag,第一次 Ctrl+C 把内存改成 1,第二次再按,内存已经是 1,所以打印 1->1;而 main 循环读的是寄存器里的旧副本 0——handler 看内存、main 看缓存副本,两者不同步,寄存器把变量的真实情况挡住了,内存不可见了。
O2 下的现象:程序直接输出 process quit normal 跑完退出,连 Ctrl+C 都不用按。因为新版本 GCC 能识别 signal 这个库函数,知道注册信号处理器会异步修改全局变量,于是放弃激进优化。老版本 gcc 才会直接优化成 while(1) 死循环。
解法:volatile。
volatile int flag = 0;

volatile 的作用:保持内存的可见性。告知编译器,被它修饰的变量不允许被优化到寄存器,对该变量的任何操作都必须在真实内存中进行。
七、SIGCHLD:子进程退出的通知机制
之前讲进程时说过用 wait/waitpid 清理僵尸进程:阻塞等待——父进程被卡住干不了自己的活;非阻塞轮询——父进程要一边干活一边惦记着问。两种都不优雅。
其实子进程终止时会给父进程发 SIGCHLD 信号,默认处理是忽略。父进程自定义这个信号的处理函数,在 handler 里调 wait 清理——子进程终止会通知父进程,父进程只管专心干自己的事。
7.1 先验证
void Say(int num)
{
std::cout << "father get a signal:" << num << std::endl;
}
int main()
{
signal(SIGCHLD, Say); // 父进程捕捉
pid_t id = fork();
if (id == 0)
{
std::cout << "I am child,exit" << std::endl;
sleep(3);
exit(3);
}
waitpid(id, nullptr, 0);
std::cout << "I am father,exit" << std::endl;
return 0;
}

子进程退出,父进程确实收到了 SIGCHLD(17 号)。
7.2 用信号回收所有子进程
handler 里循环 waitpid,注意三个返回值的分支:
void Waitall(int num)
{
while (true)
{
// -1 表示任意一个子进程
pid_t n = waitpid(–1, nullptr, WNOHANG);
// waitpid 默认阻塞:没有子进程死亡,父进程就挂在这什么都干不了
// 有 10 个子进程 6 个退出 4 个没退,阻塞版会一直卡住
// 所以用 WNOHANG:非阻塞
// n > 0:回收成功,继续循环,把退出的都收掉
// n == 0:没有已退出的子进程还活着,收工
if (n == 0) break;
else if (n < 0)
{
std::cout << "waitpid error" << std::endl;
break;
}
}
std::cout << "father get a signal:" << num << std::endl;
}
7.3 SIG_IGN 的历史特例
由于 UNIX 的历史原因,还有另一招:父进程调用 sigaction 把 SIGCHLD 置为 SIG_IGN,fork 出的子进程终止时会被内核自动清理,不产生僵尸进程,也不通知父进程。 这是特例,对 Linux 可用,但不保证其他 UNIX 系统通用。
疑点来了:SIGCHLD 默认处理本来就是忽略(SIG_IGN),为什么自己再设一次 SIG_IGN 表现就完全不同?
因为父进程对 SIGCHLD 的默认处理是 SIG_DFL,只是这个默认动作的具体内容恰好是"忽略"。默认忽略和显式 SIG_IGN 走的是两套内核逻辑:
| SIG_DFL(默认) | 收到信号直接丢弃 | 产生僵尸进程 | ✅ 可 wait 到 pid 和退出码 |
| signal(SIGCHLD, SIG_IGN)(显式忽略) | 内核特殊处理 | 不产生僵尸 | ❌ waitpid 返回 -1 |
| 自定义 handler(Waitall) | 收到信号进回调 | handler 里 waitpid 回收 | ✅ 拿到退出状态 |
关键:同样是"忽略信号",一个是出厂默认 SIG_DFL,一个是手动 SIG_IGN,内核两套逻辑。信号都被丢弃,但子进程资源的处理完全不同。SIG_DFL 不等于 SIG_IGN。
总结
这一篇把信号的地基打通了:
信号处理的时机 = 内核态返回用户态时的检查点
↓ 为什么会回用户态?调度、时间片、系统调用
↓ 什么驱动调度?时钟中断
↓ 中断谁来管?中断控制器 + 中断向量表
↓ 软件怎么触发?int 0x80 / syscall(陷阱)+ 异常(除0/缺页)
↓ 用户态内核态怎么隔离?cs 寄存器 CPL 0/3 + 3G/1G 地址空间
- 自定义 handler 以用户身份执行,靠埋 sigreturn 返回内核;
- sigaction 处理期间自动屏蔽当前信号,sa_mask 额外加屏蔽;
- 信号的本质是用软件模拟硬件中断;
- 可重入:handler 里别碰 malloc/标准 IO;volatile:别让编译器把变量缓存进寄存器;
- SIGCHLD:SIG_DFL 和显式 SIG_IGN 是两套内核逻辑,显式忽略不产生僵尸。
三篇到这里齐了:产生 → 保存 → 捕捉。把三张表(pending/block/handler)和一条主线(OS 何时检查信号)记住,Linux 信号这块就通了。
有收获点个赞,评论区聊聊你在 handler 里踩过的坑——比如在 handler 里 printf 结果丢日志的,都来说是为什么。
资源分享: 【Linux】Ctrl+C 到底做了什么?从键盘、kill 到 alarm,一次讲清信号的产生 【Linux】共享内存为什么快?System V 的 key、shmget、shmat 与进程通信实战 【Linux】两个毫无关系的进程怎么通信?命名管道 FIFO 从原理到 Server/Client 实战


