
🔥小叶-duck:个人主页
❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》
《Linux操作系统从入门到实践》《Qt从入门到实践》
《算法题讲解指南》--优选算法
《算法题讲解指南》--递归、搜索与回溯算法
《算法题讲解指南》--动态规划算法
✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游
目录
1.4 话题穿插:操作系统是怎么运行的(附:硬件中断,时钟中断,软中断……)
二、可重入函数 && volatile && SIGCHLD信号
前言
在上篇文章中我们对信号的第二个个板块信号的保存进行了讲解,并且对信号产生中的core文件进行了补充。在本篇文章中我们将会对信号的最后一个板块——信号捕捉进行讲解,其中我们将会着重理解内核态和用户态,并且我们还会补充一个额外的知识:操作系统是如何运行的?最后我们还会讲解可重入函数、Volatile关键字、SIGCHLD信号这三个知识点,内容比较多我会尽量用通俗易懂的理解对这些知识点进行详细的讲解。
一、 信号捕捉:自定义处理信号的完整流程
信号捕捉是指进程自定义信号的处理动作(而非默认动作或忽略),核心依赖 signal 或 sigaction 函数,其中 sigaction 是更推荐的标准接口。
信号收到不一定会立即递达,而是在合适的时候。
在什么合适的时候呢?
1、核心工作做完;
2、进程从内核态返回用户态的时候,进行信号的检测和处理。

1.1 什么叫内核态、什么叫做用户态?

- 用户执行自身代码——用户态;
- 访问操作系统内核结构的代码和数据——内核态。
但是在原理上我们还是不太清楚用户态和内核态究竟是什么,当我们把信号处理的知识讲解后还会重谈这两个状态。
1.2 为什么要区分这两种状态?

1.3 信号处理的流程
1.3.1 信号捕捉的流程
当进程注册了自定义处理函数后,信号递达的流程如下(用户态↔内核态切换 ):
- 进程在用户态执行主流程,因中断 / 异常 / 系统调用进入内核态;
- 内核处理完中断后,准备返回用户态前,检查未决信号集;
- 若有可递达的信号(未阻塞且有自定义处理函数),内核不直接返回主流程,而是切换到用户态执行自定义处理函数;
- 处理函数执行完毕后,自动调用 sigreturn 系统调用再次进入内核态;
- 内核确认无其他未递达信号后,返回用户态,恢复主流程的执行。

通过信号处理的流程中我们发现第一步到第二步时,处理异常和处理信号的分开的两步,为什么是这样的呢?


1.3.2 记忆信号捕捉流程
或许一开始看到这个状态切换图的时候,有些人会不清楚到底是什么个流程,所以有一个比较好记忆这个信号捕捉流程的图:

这个东西是什么?很像无穷大、极限的概念。
- 交点处:检测点,检查并且处理信号。
横线上四个交点,四次状态切换 !
信号处理的流程就是倒着的8,加一条横线!但一定要注意横线是在交点的上方,因为不难解释交点是检查pending表,这个操作是在内核中完成的。

通过上面的步骤说明我们会发现这个交点并不是单向的,而是判断当前的情况进行流向。
1.4 话题穿插:操作系统是怎么运行的(附:硬件中断,时钟中断,软中断……)
1.4.1 硬件中断
- 中断向量表就是操作系统的⼀部分,启动就加载到内存中了
- 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
- 由外部设备触发的,中断系统运行流程,叫做硬件中断

// Linux 内核 0.11 源码片段,带详细注释
void trap_init(void)
{
int i;
// 设置陷阱门(trap gate),用于处理 CPU 异常
// 除零错误
set_trap_gate(0, ÷_error);
// 单步调试
set_trap_gate(1, &debug);
// 非屏蔽中断 NMI
set_trap_gate(2, &nmi);
// 以下 3-5 号陷阱门允许从任何特权级调用(系统门)
set_system_gate(3, &int3); // int3 断点
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); // 无效 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); // 协处理器错误
// 将 int 17~47 的陷阱门暂时都设置为 reserved(保留处理)
// 后续各个硬件初始化时会重新设置自己的中断向量
for (i = 17; i < 48; i++)
set_trap_gate(i, &reserved);
// 设置协处理器中断(IRQ13,中断号 45)
set_trap_gate(45, &irq13);
// 允许主 8259A 芯片的 IRQ2 中断请求(用于级联从片)
outb_p(inb_p(0x21) & 0xfb, 0x21);
// 允许从 8259A 芯片的 IRQ13 中断请求(协处理器)
outb(inb_p(0xA1) & 0xdf, 0xA1);
// 设置并行口的陷阱门(IRQ7 或 中断号 39,具体取决于硬件)
set_trap_gate(39, ¶llel_interrupt);
}
void rs_init(void)
{
// 设置串行口1的中断门,硬件 IRQ4 对应中断号 0x24
set_intr_gate(0x24, rs1_interrupt);
// 设置串行口2的中断门,硬件 IRQ3 对应中断号 0x23
set_intr_gate(0x23, rs2_interrupt);
// 初始化 tty 表对应串行口1和串行口2的读队列端口号
init(tty_table[1].read_q.data); // 串行口1
init(tty_table[2].read_q.data); // 串行口2
// 允许主 8259A 芯片的 IRQ3 和 IRQ4 中断请求(分别对应串口2和串口1)
outb(inb_p(0x21) & 0xE7, 0x21);
}
1.4.2 时钟中断
- 进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?
- 回答:操作系统不是一个独立的“进程”,它本质上是硬件的“管家”。当用户程序运行时,CPU 在执行用户代码;只有发生硬件中断(如时钟中断、键盘中断)、异常(如缺页)或系统调用(程序主动请求内核服务)时,CPU 才会切换到内核态,执行操作系统代码。
也就是说,操作系统是靠硬件事件和程序主动调用来“唤醒”的。没有事件时,CPU 会执行一个特殊的“空闲进程”(idle),什么都不做,等待下一次中断。
- 回答:操作系统不是一个独立的“进程”,它本质上是硬件的“管家”。当用户程序运行时,CPU 在执行用户代码;只有发生硬件中断(如时钟中断、键盘中断)、异常(如缺页)或系统调用(程序主动请求内核服务)时,CPU 才会切换到内核态,执行操作系统代码。
- 外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?
- 回答:有,最常见的是定时器(Timer)。例如 PC 上的可编程间隔定时器(PIT)可以设置一个时间间隔,每隔固定时间(比如 10 毫秒)自动向 CPU 发送一个时钟中断。操作系统利用这个中断来切换进程、统计时间等。这类设备不需要人为触发,只要通电并按设定运行,就会一直自己“滴答”地产生中断。
// Linux 内核 0.11
// main.c
sched_init(); // 调度程序初始化(加载了任务0 的 tr, ldtr) (kernel/sched.c)
// 调度程序的初始化子程序。
void sched_init(void)
{
// ... 其他初始化代码 ...
// 设置时钟中断门,中断号 0x20(对应硬件 IRQ0)
set_intr_gate(0x20, &timer_interrupt);
// 修改中断控制器屏蔽码,允许时钟中断(清除 IRQ0 的屏蔽位)
outb(inb_p(0x21) & ~0x01, 0x21);
// 设置系统调用中断门,中断号 0x80(int 0x80 用于系统调用)
set_system_gate(0x80, &system_call);
// ...
}
// system_call.s 中的汇编代码片段
_timer_interrupt:
// ... 保存现场等操作 ...
; // do_timer(CPL) 执行任务切换、计时等工作,在 kernel/sched.c 第305行实现
call _do_timer // do_timer(long CPL) 负责所有定时器相关处理,最终可能调用 schedule()
// ... 恢复现场、iret 等 ...
// 调度入口函数(kernel/sched.c)
void do_timer(long cpl)
{
// ... 更新进程时间片、处理定时器等 ...
schedule(); // 调用主调度函数
}
// 进程调度函数
void schedule(void)
{
// ... 选择下一个要运行的任务(next) ...
switch_to(next); // 切换到任务号为 next 的任务,并运行之
}
1.4.2.1 时钟中断机制

OS的部分源码:
void main(void) /* 这里确实是void,并没错。 */
{ /* 在startup 程序(head.s)中就是这样假设的。 */
// ... 前面的初始化代码 ...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到一个信号才会返
* 回就绪运行态,但任务0(task0)是唯一的意外情况(参见'schedule()'),因为任
* 务0 在任何空闲时间里都会被激活(当没有其它任务在运行时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运行,如果没
* 有的话我们就回到这里,一直循环执行'pause()'。
*/
for (;;)
pause(); // 任务0进入空闲循环,当没有其他任务时被激活,不断检查是否有任务可运行
} // end main
问题:当操作系统空闲的情况下,硬件时钟源还会一直工作吗?是的!

1.4.2.2 时间戳
1.4.2.3 时间片

1.4.3 软中断
- 上述外部硬件中断,需要硬件设备触发。
- 有没有可能,因为软件原因,也触发上面的逻辑?有!
- 为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑。

- 用户层怎么把系统调用号给操作系统? - 寄存器(比如EAX)
- 操作系统怎么把返回值给用户?- 寄存器或者用户传入的缓冲区地址
- 系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法
- 系统调用号的本质:数组下标!
// sys.h 中定义的系统调用函数指针表,用于 int 0x80 中断处理程序跳转
extern int sys_setup(); // 系统启动初始化设置
extern int sys_exit(); // 程序退出
// ... 其他系统调用声明省略 ...
// 系统调用函数指针表(跳转表),按系统调用号索引
fn_ptr sys_call_table[] = {
sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid, sys_setregid
};
// 调度程序初始化
void sched_init(void)
{
// ...
// 设置系统调用中断门,中断号 0x80,入口为 system_call 汇编例程
set_system_gate(0x80, &system_call);
}
// system_call.s 中的汇编代码(简化)
_system_call:
// 检查系统调用号(eax)是否超出范围
cmp eax, nr_system_calls-1
ja bad_sys_call
// 保存用户态段寄存器及参数
push ds
push es
push fs
push edx // 第3个参数
push ecx // 第2个参数
push ebx // 第1个参数
// 设置 ds, es 指向内核数据段
mov edx, 10h
mov ds, dx
mov es, dx
// 设置 fs 指向用户数据段(局部描述符表)
mov edx, 17h
mov fs, dx
// 通过系统调用号索引跳转表,调用对应的 C 函数
// 等价于 call sys_call_table[eax*4]
call [_sys_call_table + eax*4]
// 返回值已在 eax,将其压栈以备后续处理
push eax
// 取当前任务(进程)结构指针
mov eax, _current
// 检查当前进程状态:如果 state != 0 或 counter == 0,则重新调度
cmp dword ptr [state+eax], 0
jne reschedule
cmp dword ptr [counter+eax], 0
je reschedule
ret_from_sys_call:
// 从系统调用返回后,会检查信号并恢复现场,最终 iret 回到用户态
// ...
系统调用流程简要说明:
- 用户程序执行 int $0x80,传入系统调用号(eax)和参数(ebx, ecx, edx)。
- CPU 通过中断门跳转到 system_call 汇编入口。
- 保存现场,将段寄存器指向内核段,fs 指向用户段(便于访问用户空间)。以系统调用号为索引,从 sys_call_table 中取出对应函数地址并调用。
- C 函数执行完毕,返回值存于 eax。
- 检查当前进程是否需要重新调度(状态或时间片),若需要则切换进程。
- 最后恢复现场,iret 返回用户态继续执行。
- 可是为什么我们用的系统调用,从来没有见过什么 int 0x80 或者 syscall 呢?都是直接调用上层的函数的啊?
- 那是因为 Linux 的 gnu C 标准库,给我们把几乎所有的系统调用全部封装了。

// 源代码路径:linux-2.6.18\linux-2.6.18\include\asm-x86_64\unistd.h
// 该头文件定义了 x86_64 架构的系统调用号及对应的内核函数入口
/* at least 8 syscall per cacheline */ // 注释:每个缓存行至少包含 8 个系统调用(优化缓存局部性)
// 系统调用号从 0 开始递增,每个宏定义分配一个唯一的编号
#define __NR_read 0 // 系统调用号 0:读取文件
__SYSCALL(__NR_read, sys_read) // __SYSCALL 宏将系统调用号与内核实现函数关联(用于构建系统调用表)
#define __NR_write 1 // 系统调用号 1:写入文件
__SYSCALL(__NR_write, sys_write)
#define __NR_open 2 // 系统调用号 2:打开文件
__SYSCALL(__NR_open, sys_open)
#define __NR_close 3 // 系统调用号 3:关闭文件
__SYSCALL(__NR_close, sys_close)
#define __NR_stat 4 // 系统调用号 4:获取文件状态(通过路径)
__SYSCALL(__NR_stat, sys_newstat) // 使用新的 stat 结构(sys_newstat)
#define __NR_fstat 5 // 系统调用号 5:获取文件状态(通过文件描述符)
__SYSCALL(__NR_fstat, sys_newfstat)
#define __NR_lstat 6 // 系统调用号 6:获取链接文件状态(不跟随符号链接)
__SYSCALL(__NR_lstat, sys_newlstat)
#define __NR_poll 7 // 系统调用号 7:poll 多路复用 I/O
__SYSCALL(__NR_poll, sys_poll)
#define __NR_lseek 8 // 系统调用号 8:重定位文件读写偏移
__SYSCALL(__NR_lseek, sys_lseek)
#define __NR_mmap 9 // 系统调用号 9:内存映射
__SYSCALL(__NR_mmap, sys_mmap)
#define __NR_mprotect 10 // 系统调用号 10:修改内存保护属性
__SYSCALL(__NR_mprotect, sys_mprotect)
#define __NR_munmap 11 // 系统调用号 11:解除内存映射
__SYSCALL(__NR_munmap, sys_munmap)
#define __NR_brk 12 // 系统调用号 12:调整数据段(堆)大小
__SYSCALL(__NR_brk, sys_brk)
#define __NR_rt_sigaction 13 // 系统调用号 13:实时信号处理函数设置
__SYSCALL(__NR_rt_sigaction, sys_rt_sigaction)
#define __NR_rt_sigprocmask 14 // 系统调用号 14:实时信号屏蔽字操作
__SYSCALL(__NR_rt_sigprocmask, sys_rt_sigprocmask)
#define __NR_rt_sigreturn 15 // 系统调用号 15:从实时信号处理函数返回
__SYSCALL(__NR_rt_sigreturn, stub_rt_sigreturn) // 使用汇编桩(stub)实现
// ... 后续还有更多系统调用定义 ...
1.4.4 缺页中断
- 缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。

1.4.5 中断分类

1.5 重谈内核态和用户态
理解:
在OS层面上进程所对应的0-3GB通常会由用户映射表映射到对应位置,我们3-4GB内核空间也会映射,所以有内核页表和用户页表。不过不同进程内核页表都是一样的,不过实际上其实两个页表是混在一起的。逻辑上我们分开来说。进程之间是具有独立性的。
无论我的进程怎么调度进程PCB怎么切换,我们都可以找到同一个操作系统,变的只是0~3GB的用户区。
Linux中,任何函数跳转都是在进程的地址空间内进行跳转的。 从逻辑上可以直接跳转到内核区但是实际上是不行的。我们设置了一个条件,CPU里面有个CS寄存器,用低两位的比特位来表示当前CPU本身的执行级别,其中Linux只使用这两种执行级别,00(0):表示内核态;11(3): 表示用户态。所以我们就会判断执行级别,如果是11会禁止,是00系统调用才能被调用。我们当前进程是处于用户态还是内核态是由CPU里面来标识的。
那么现在的问题就是对应的用户能不能直接访问3-4GB呢,是不行的,因为会对执行级别进行处理,我要访问内核就得3变为0,内核态才行,我们使用汇编语言其实也是改不了这个的。要改只能 int80 或者 syscall,自动由3->0结束再变回3,和这个软中断强制绑定了。执行级别为0才可以访问OS。


1.6 sigaction 函数(推荐使用,功能更强)

signal 函数简单但兼容性差,sigaction 支持更精细的控制(如设置额外阻塞信号、保留信号上下文),原型如下:
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
struct sigaction 结构体解析:
struct sigaction {
void (*sa_handler)(int); // 信号处理函数(与signal兼容)
void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号处理函数
sigset_t sa_mask; // 处理信号时,额外阻塞的信号集
int sa_flags; // 选项(0表示默认)
void (*sa_restorer)(void); // 已废弃,不用关注
};

关键特性:
- sa_mask:处理信号时,内核会自动将当前信号加入阻塞集,同时阻塞sa_mask中的信号,避免嵌套处理;
- sa_flags:常用SA_RESTART(被信号中断的系统调用自动重启)。
实战 1:用 sigaction 自定义捕捉2号信号

实战 2:证明信号的处理函数在被调用时,会自动将当前信号加入信号屏蔽字block表中
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int signum)
{
std::cout << "捕捉信号: " << 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(1);
}
int main()
{
struct sigaction act, oldact;
act.sa_handler = handler; //自定义信号捕捉
sigisemptyset(&act.sa_mask); //信号集初始化
act.sa_flags = 0;
sigaction(SIGINT, &act, &oldact);//对2号信号进行捕捉
while(true)
{
std::cout << "hello world, pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}

实战 3:利用 sa_mask 额外屏蔽其他信号
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int signum)
{
std::cout << "捕捉信号: " << 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(1);
}
int main()
{
struct sigaction act, oldact;
act.sa_handler = handler; //自定义信号捕捉
sigisemptyset(&act.sa_mask); //信号集初始化
act.sa_flags = 0;
//sa_mask:额外屏蔽其他信号
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaction(SIGINT, &act, &oldact);//对2号信号进行捕捉
while(true)
{
std::cout << "hello world, pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}

二、可重入函数 && volatile && SIGCHLD信号
2.1 可重入函数
下图展示了在多任务或信号处理环境下如果是不可重入函数(Non-reentrant function) 会导致的“灾难现场” 。

重入问题示例:
- main 函数调用 insert 向链表插入节点,刚完成第一步时发生硬件中断。
- 进程切回用户态前发现待处理信号,执行信号处理函数 sighandler。
- sighandler 也调用了 insert 插入另一个节点,并完整执行了两步。
- 返回 main 后继续完成之前未完成的第二步插入操作。
- 结果:两个不同控制流先后插入节点,但链表最终只成功插入了一个节点(数据被覆盖或丢失)。
重入与可重入定义:
- 重入:同一个函数在第一次调用尚未返回时,又被另一个控制流程再次调用。
- 不可重入函数:函数内部访问了全局或静态数据结构(如上述
insert操作的全局链表),可能因重入导致数据错乱。 - 可重入函数:函数只使用自身的局部变量或参数,不依赖共享资源,可以被多个控制流安全地同时调用。
为什么局部变量不会出错?
每次调用函数时,局部变量和参数都会在栈上分配独立的空间,不同调用之间互不干扰,因此不会出现交叉污染。
2.1.1 如何判断可重入和不可重入?

2.1.2 场景应用的区别
可重入还是不可重入都没有绝对的好坏,只有场景的适配:
-
不可重入函数:编写简单,适合单线程、不涉及异步信号、对性能要求极高(避免栈开销)的底层代码。
-
可重入函数:是线程安全(Thread-safe) 的基石。在编写多线程程序、实时系统(RTOS)或信号处理程序时,必须保证函数是可重入的。
核心原则:在 sighandler(信号处理函数)内部,永远只调用“Async-signal-safe(异步信号安全) ”的函数。像 printf 内部维护了全局缓冲区,在信号处理里用它其实是很危险的!
2.2 volatile
volatile 关键字的核心作用:确保内存变量的“可见性”,抑制编译器的过度优化。
2.2.1 核心概念:什么是 volatile?
volatile 的字面意思是“易变的”。在编程中,它告诉编译器:这个变量可能会在当前代码流之外被修改(例如:由信号处理函数、多线程、或者硬件中断修改)。
因此,编译器在编译时严禁对该变量进行任何“假定不变”的优化,每次使用它时必须老老实实地去内存地址中读取。
2.2.2 现象产生:消失的信号响应
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
// int flag = 0;
volatile int flag = 0;
void handler(int signum)
{
(void) signum;
flag = 1;
printf("更改flag 0->1\n");
}
int main()
{
signal(2, handler);
while(!flag);
printf("进程正常退出\n");
return 0;
}
上述代码通过一个典型的while(!flag)循环展示了优化带来的问题:
-
代码意图: main 函数进入死循环等待 flag 变为 1;当用户按下 Ctrl + C 触发信号,执行 handler 将 flag 改为 1,理论上 main 应该退出循环。
-
非优化状态(-O0): 程序运行符合预期,信号触发后进程正常退出。
-
开启优化(如 -O1 及以上):
-
异常现象:即便执行了 handler 并打印了“更改 flag 0->1”,main 函数的循环依然停不下来。
-
原因:编译器认为在 main 函数的执行流中没有修改 flag 的语句,为了提高效率,它将 flag 的值缓存到了 CPU 寄存器中。
-
2.2.3 现象原因:CPU寄存器与内存的“可见性”冲突
-
编译器的推断:编译器扫描 main 函数,发现从循环开始到结束,没有任何逻辑会改变 flag。
-
寄存器缓存:CPU 访问寄存器的速度远快于内存。为了追求极致性能,编译器生成的汇编代码只在循环开始前将内存中的 flag 载入寄存器(如 eax),后续的 while 判断全部基于寄存器中的旧值。
-
逻辑断层:当信号发生时,内核触发的 handler 修改的是内存里的 flag。但此时 main 函数只盯着自己的寄存器看,完全不知道内存里的值已经变了。
这就是“内存不可见性”: 内存的改变对正在高速运行的 CPU 寄存器副本无效。
2.2.4 现象解决方案:volatile 的作用
当给变量加上 volatile 修饰(volatile int flag = 0;)后,情况发生了根本变化:
-
抑制优化:强制要求编译器每次执行 while 判断时,必须重新生成一条 mov 指令,从内存地址读取最新值。
-
保持可见性:确保了异步执行流(如信号处理)对变量的修改,能即时被当前执行流(main)察觉。
2.2.5 为什么不默认给所有变量加 volatile?

2.3 SIGCHLD信号
对于这个信号,我们主要讨论一下:SIGCHLD 信号的处理机制以及如何优雅地解决“僵尸进程”回收问题。

2.3.1 证明SIGCHLD信号的存在


2.3.2 为什么要处理 SIGCHLD?(僵尸进程问题)
在多进程服务器中,父进程通常在忙于处理业务逻辑(如 while(1) 循环),无法预知子进程何时退出。
-
如果不回收:子进程变成僵尸进程(Zombie),占用进程表项。
-
常规阻塞等待:如果在主逻辑调用 wait(),父进程会被阻塞,无法处理新请求。
-
解决方案:利用信号处理机制。当 SIGCHLD 到达时,父进程异步跳转到处理函数中执行 waitpid,实现“随死随埋”。
我们以前等待子进程是父进程主动去等,那我们现在等待子进程方式要不要发生一些改变,父进程自己忙自己的事,不退出就行,当父进程收到信号,在信号处理函数内部,回收子进程?是可以的,这也就是 SIGCHLD 信号存在的其中一个意义。
2.3.3 SIGCHLD 信号的作用

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
void Say(int signum)
{
// 父进程和子进程异步解耦
waitpid(-1, nullptr, 0);
std::cout << "father get a signal: " << signum << std::endl;
}
int main()
{
// 父进程
// signal(SIGCHLD, Say);
struct sigaction act, oldact;
act.sa_handler = Say;
sigaction(SIGCHLD, &act, &oldact);
sleep(3);
int id = fork();
if (id == 0)
{
// 子进程
std::cout << "I am child, exit" << std::endl;
sleep(3);
exit(3);
// 当子进程退出时则会发送SIGCHLD信号,则被自定义捕捉
}
// waitpid(id, nullptr, 0);
while (true)
{
std::cout << "I am father, exit" << std::endl;
sleep(1);
}
return 0;
}


#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
// 配合 while+WNOHANG 批量回收并发退出的子进程,弥补普通信号丢失缺陷
void waitAll(int signum)
{
while (true)
{
// int n = waitpid(-1, nullptr, 0); // waitpid默认是阻塞等待的,即会导致如果一个子进程不退出则无法跳出循环
int n = waitpid(-1, nullptr, WNOHANG);
if (n == 0)
{
break; // 返回值为0则说明当前无退出的子进程,则退出循环让父进程继续执行自己的代码,不要卡在信号处理函数中
}
else if (n < 0)
{
std::cout << "waitpid error" << std::endl;
break;
}
}
std::cout << "father get a signal: " << signum << std::endl;
}
int main()
{
// 父进程
// signal(SIGCHLD, Say);
struct sigaction act, oldact;
act.sa_handler = waitAll;
sigaction(SIGCHLD, &act, &oldact);
sleep(3);
// 多个子进程并发退出
for (int i = 0; i < 6; i++)
{
int id = fork();
if (id == 0)
{
std::cout << "I am child, exit" << std::endl;
sleep(3);
exit(3);
}
}
// waitpid(id, nullptr, 0);
while (true)
{
std::cout << "I am father, exit" << std::endl;
sleep(1);
}
return 0;
}

2.3.4 最省事的方案:SIG_IGN
signal(SIGCHLD, SIG_IGN);
-
特殊性:在 Linux 中,如果父进程显式地将 SIGCHLD 的处理动作设为 SIG_IGN(忽略),那么子进程在退出时会直接被内核回收,不会产生僵尸进程。
-
优点:代码最简,无需写处理函数,无需手动调用 wait。
2.3.5 总结子进程回收的三种方案

结束语
到此,信号这个部分我们用了三篇文章就全部讲解完了。从信号的产生,到信号的保存,再到信号的处理,在其中我们还补充了很多额外拓展的知识。而接下来就是对线程这个大板块进行讲解,希望本篇文章对大家学习信号有所帮助!
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2501_91275995/article/details/162684184





