🌈欢迎来到Linux专栏 ~~ 进程信号
- 🌍博客主页 :张小姐的猫~江湖背景
- 🔥所属专栏 :Linux ~ 不破不立
- 作者水平很有限,如果发现错误,可在评论区指正,感谢🙏

Linux 信号
- [🌈欢迎来到Linux专栏 ~~ 进程信号](#🌈欢迎来到Linux专栏 ~~ 进程信号)
-
- 🔥捕捉信号
-
- 🎉信号捕捉的流程
- 🎉sigaction
- 🎉穿插话题-操作系统是怎么运行的
- [🥸如何理解内核态和用户态 ~ 重谈地址空间](#🥸如何理解内核态和用户态 ~ 重谈地址空间)
- 🎉信号捕捉的流程再补充
- 🌈信号部分小结
- 🌈可重入函数
- 🌈volatile关键字
- 🌈SIGCHLD
- 📢写在最后

🔥捕捉信号

处理信号,立即处理吗?我可能正在做优先级更高的事情,不会立即处理?什么时候?合
适的时候。
🎉信号捕捉的流程
正面回答,信号什么时候处理? 怎么处理 ??
- 当进程调度 的时候,从内核态返回用户态的时候,会进行信号的检测和处理
那用户态和内核态是什么?
- 用户态 :进程执行代码,访问数据,都在访问
[0,3G]地址空间的时候,就是访问用户自己的代码,自己的数据 - 内核态 :都在访问
[3G,4G]地址空间的时候,就是访问OS的过程,内核态的权限级别更高,通过系统调用访问

上述如果是执行自定义函数,会执行do_signal(),根据handler表去跳转到指定的函数处
那么执行信号捕捉方法的时候,是以什么身份去执行的呢?
- 用户态!! 理论上内核态是可以的,但是系统不允许你这样做。因为内核态的权限更高,如果自定义函数里有
excel("rm"),会导致越权操作(出现安全漏洞);所以必须以用户态身份去执行
大体流程:
通过系统调用 ,由用户态进入到内核态,内核态把系统调用执行完,返回时会做信号检查 ,查进程的三张表(发现信号没有被block,需要被递达的自定义捕捉),跳转到用户空间 (用内核态转变成用户态)执行handler方法,执行完毕调用sigreturn进入内核,最后返回到用户态曾经执行的位置继续执行。
信号自定义捕捉的速记流程如下:
有四个点代表 状态的转变 ,其中信号检查 在直线下

🎉sigaction
sigaction 也可以用户自定义动作,比 signal 功能更丰富
cpp
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
struct sigaction
{
void (*sa_handler)(int); //自定义动作 ~ 类似与handler表
void (*sa_sigaction)(int, siginfo_t *, void *); //实时信号相关,不用管
sigset_t sa_mask; //待屏蔽的信号集
int sa_flags; //一些选项,一般设为 0
void (*sa_restorer)(void); //实时信号相关,不用管
};
返回值:成功返回 0,失败返回 -1 并将错误码设置
参数1:待操作的信号
参数2:sigaction 结构体,具体成员如上所示
参数3:保存修改前进程的 老sigaction 结构体信息
重点落在sigaction 结构体 上,其中部分字段不需要管,因为那些是与 实时信号 相关的,我们这里不讨论;重点可以看看 sa_mask 字段

sa_mask:当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字
PS:如果要做测试,尽量将
struct sigaction都做好初始化,否则会出现未定义错误
那如何证明呢?
为什么要这样做
🎉穿插话题-操作系统是怎么运行的
先来回顾一下冯诺依曼:外设和cpu之间会存在控制信号与中断进行直接交互的

🌋硬件中断
中断 = CPU在正常执行程序过程中,被一个事件打断,转去执行特定处理程序的一种机制

- 外设 → 中断控制器 → CPU(得知,获取中断号,保护现场) → 查表(
IDT)→ 执行中断服务程序 → 恢复现场
cpu的背面:

真实情况: 当键盘被按下时,会产生一个电信号 发送到中断控制器 ,中断控制器对多个中断源进行管理,并向CPU发出中断请求信号(只用一根线通知CPU:有中断了! )。CPU响应后,再由中断控制器提供中断号,CPU根据中断号查表确定具体是哪个设备。
我们可以把中断向量表(IDT)想象成一个函数指针数组 :必然存在下标(这里我们"认为"中断号 == 下标)
cpp
typedef void(*handler_t)(void)
handler_t IDT[NUMS];
- 当我们要处理中断任务时,也是需要cpu内部的寄存器资源的,所以出现中断时,cpu要把当前进程的寄存器数据保护起来(压入当前进程的栈) ------ 现场保护
- 当把中断任务完成后,把曾经保存进程的上下文数据弹栈,恢复到各个寄存器内 ------ 恢复现场 继续执行之前的工作
硬件中断 :暂停cpu正在执行的任务,处理硬件突发时间,结合中断号(硬件提供) 和中断向量表(OS提供)
上诉中断是否与信号的handler表很相似?
在现实世界中,先有硬件中断,由硬件完成处理,后来发现,进程也需要类似的机制,发明了信号机制
信号机制,是用纯软件的方式,模拟中断完成特定的任务处理的
信号、硬件中断 原理类似,但是本质完全不同
👇🏻结论:
- 中断向量表就是操作系统的一部分,启动就加载到内存中了
- 通过外部硬件中断(外设准备好,通知OS),操作系统就不需要对外设进行任何周期性的检测或者轮询
- 由外部设备触发的,中断系统运行流程,叫做硬件中断
cpp
void trap_init(void)
{
int i;
set_trap_gate(5,&键盘处理);//中断:键盘处理的方法绑定到中断号5
set_trap_gate(0,÷_error);
// 设置除操作出错的中断向量值。以下雷同。
set_trap_gate(1,&debug);
set_trap_gate(2,&nmi);
set_system_gate(3,&int3);
set_system_gate(4,&overflow);
}
可以把中断向量表理解成:每个 16 字节被拆成多个字段。
cpp
unsigned long long desc_table[256];
因为中断向量表不单单要保存地址,还有可能保存很多设定(中断是否开启、中断优先级等等)
⛳时钟中断
✅时钟中断是硬件中断中最特殊、最核心的一种 ,它周期性地向 CPU 发送中断请求,是 OS 唯一能主动、定期从用户进程手中夺回 CPU 控制权的机制。
外设有没有可能以固定的频率(1ms)给CPU发送属于它自己的中断 ,cpu要每一段时间固定的执行对应的方法do_timer()
cpp
do_timer()
{
/执行进程调度、时间片检测、切换、调度等动作!
}
这样是不是就每隔1ms,执行调度工作? ------ 这活不是OS干的活吗? ------ OS也是软件!是谁让OS运行的?

- 中断本身属于OS的一部分 ,当给定固定频率触发中断时(外部晶振 ),cpu固定执行对应的
do_timer()~ 相当于操作系统的入口 ,就是定期执行操作系统 - 也就说IDT属于操作系统的一部分,所以cpu可以通过中断的方式,定期的执行IDT中指定的方法
- 相当于每隔一定的时间,就会叫OS跑起来
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 的任务,并运⾏之。
}
其逻辑线是

下面先回答以下的问题:
1️⃣什么叫做时间片?
在我们进程中的定义:时间片本质就是一个计数器 ,当计数器为0时,就会进行调度schedule()
- 因为时钟中断是固定时间间隔的(
1ms),假如把计数器设为10,只要计数器不为 0,都只是对计数器进行--,直到减到 0,执行调度函数

cpp
if((--current -> counter) > 0) //时间片没有被耗完 ------ return
return;
current -> counter = 0;
schedule();
此时我们要区分清楚中断处理 和进程运行 的区别

2️⃣什么叫做时间片耗尽?
不就是当前进程的counter计数器变为0了,接着调度其他进程
3️⃣为什么OS能计算时间?
前提是触发滴答的频率是一样的 ~ 如 1ms
- 我们把触发一次时钟晶振,叫做时钟滴答 :
long long tickts = 0;,所以可以把开机后触发了多少次的时钟中断次数记录下来 - 电脑关机后,仍有纽扣电池持续的给主板上的计时单元供电,所以开机后是可以知道现在的时间的(可转化成时间戳)
- 开机后每隔1ms就记录一次,不就可以计算时间了吗?开机起始时间 + 滴答时间 = 现在的时间
4️⃣OS凭什么执行它的调度算法?
答:固定时间间隔的时钟中断!
整个操作系统最核心的是时钟中断 ,正是有了它,才可以让进程在合理的时间片中跑,检测时间片 ------ 做调度;只要进程调度起来了,进程才可以调用系统调用去把OS的功能调用起来
OS之所以能执行调度算法,是因为CPU在中断或系统调用时被硬件强制切换到内核态(更多权限),并跳转到内核代码,从而让OS获得执行权。
小细节:为什么要把时间源放在外设呢?太慢了------ 走很多硬件电路
- 当代计算机把时钟源集成到cpu的内部,走线变短了
😈死循环
操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中
断向量表里面添加方法即可,操作系统的本质:就是⼀个死循环!
cpp
void main(void)
/* 这⾥确实是void,并没错。 */
{
/* 在startup 程序(head.s)中就是这样假设的。 */
...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
*/
for (;;) //死循环
pause();
}

OS是如何运行的? ------ 时间源 + 时钟中断 = 硬件中断- OS 本质上是一个 "中断驱动" 的系统,而周期性的时钟中断,是 OS 能主动掌控 CPU、实现多任务管理和资源调度的唯一核心抓手,稳定的时间源则是这一切的硬件基础。
- 所以,什么是时间片?CPU为什么会有主频?为什么主频越快,CPU越快?主频可以作为OS调度执行速度的参考之⼀

我们经常听到cpu的主频:也就是时钟晶振的振荡频率 ,当然频率越高,cpu调度的频率就越高,调度进程的时间更加精细

📌软中断
上述外部硬件中断,需要硬件设备触发。
- 有没有可能,因为软件原因,也触发上面的逻辑?有!
cpu内置了一些特殊的软件指令集 - 为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(
int或者syscall),可以让CPU内部触发中断逻辑。 - 那这样程序员是不是可以写这个汇编,汇编(
syscall)触发中断 ------ 不由外部硬件,而是由语言、代码、软件触发的中断 :软中断
1️⃣为什么要有软中断?
软中断就是为了:让用户程序 "申请进内核",而不是 "直接闯进去"。
它是用户态 ↔ 内核态之间唯一合法的门。其中实现系统调用就是软中断最经典的案例

问题:
• 用户层怎么把系统调用号给操作系统?- 寄存器(比如EAX)
• 操作系统怎么把返回值给用户?- 内核把返回值写进约定好的寄存器,然后执行从内核态返回用户态指令,用户态代码(C 库)直接从那个寄存器里拿走值。
• 系统调用的过程,其实就是先int0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法
• 系统调用号的本质:数组下标!
在外面操作系统的内部,所有的系统调用都被放在一张表里:sys_call_table,里面维护都是各种系统调用的函数地址
- 在内核层面,每一个系统调用------> 都有一个系统调用号 (数组下标)
cpp
// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。
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
};

在调用系统调用是,如read()
- 把fd,xxx等参数分别放进不同的寄存器ebx、ecx等
- 把系统调用号3放进
eax中 - 接着触发软中断;直接读取系统调用号,接着去系统调用表查表,执行系统调用
💯细节1:谁来做,软中断之前的工作??
- 真正调用系统调用 ------
mov eax 3, syscall / int 0x80,os只给我们系统调用号 和传递系统调用的寄存器! - 上面几行的参数入参,返回值的获取是 c标准库做的
可是为什么我们用的系统调用,从来没有见过什么 int 0x80 或者 syscall 呢?都是直接调用
上层的函数的啊?
- 那是因为Linux的
gnuC标准库,给我们把几乎所有的系统调用全部封装了。
- 64位下用syscall,32位下用int 0x80
系统调用的核心是:
cpp
_system_call:
call[_sys_call_table + eax*4]
push eax; //把返回结果入栈

应用程序运行在用户态,没有权限直接进入内核;
进入内核的方式(系统调用)是内核 ABI,不稳定、复杂、不跨平台;
C 标准库就是用户态与内核态之间的 "翻译官 + 门卫"。
细节2:内部系统调用怎么知道有多少个参数?分别是谁?
- 把参数记录在寄存器里,再约定一个新寄存器,把参数的个数传递进来 ,如果参数多,就用栈空间来传
2️⃣还有其他中断形式吗?如何进一步理解OS?
📌缺页中断?内存碎片处理?除零野指针错误?
硬件中断 :时钟中断、外设中断
软中断 :系统调用的实现
除了上述两大中断还有 缺页中断、内存碎片处理、除零野指针错误等等,这些都称为异常中断
- 缺页中断 :访问虚拟内存地址,当前不在物理内存里,CPU 发现后立刻触发的异常中断,MMU硬件报错!
- 内存碎片处理 :申请内存空间失败,内存硬件报错
cpp
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);// 设置并⾏⼝的陷阱⻔。
}
- 缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。
📌 所以:
操作系统就是一个基于中断处理的软件集合!! ,CPU内部的软中断,比如int0x80或者syscall,我们叫做陷阱 ;CPU内部的软中断,比如除零/野指针等,我们叫做异常。(所以,能理解"缺页异常"为什么这么叫了吗?)
☀️操作系统demo
下面是mini版本的操作系统demo
操作系统每隔 1 秒产生一次时钟中断 → 减少当前进程时间片 → 时间片用完就随机切换另一个进程 → 循环调度。
- 模拟了 时钟中断 + 进程调度!
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <vector>
int current = 0; //当前进程指针
//进程描述符
struct task_struct
{
private:
int pid;
int status;
int counter;
public:
task_struct(int p):pid(p), counter(5)
{}
void desc()
{
counter--;
}
void run()
{
std::cout << "process" << pid << "running" << std::endl;
}
bool Expired()
{
return counter <= 0;
}
int Pid()
{
return pid;
}
void Reset()
{
counter = 5;
}
~task_struct(){}
};
std::vector<task_struct> tasks;
void do_timer(int signo)
{
//时钟一到
tasks[current].desc(); //时间片-1
if(tasks[current].Expired())
{
std::cout << tasks[current].Pid() << "过期了,重新选择进程调度" << std::endl;
//选择一个进程运行即可
current = rand()%tasks.size();
tasks[current].Reset();
}
else
{
tasks[current].run();
}
alarm(1);
}
int main()
{
alarm(1);
signal(SIGALRM, do_timer);
srand(time(nullptr));
tasks.emplace_back(1);
tasks.emplace_back(2);
tasks.emplace_back(3);
tasks.emplace_back(4);
tasks.emplace_back(5);
for(;;)
{
// std::cout << "OS 被中断唤醒 \n" << std::endl;
pause(); //暂停,直到收到一个信号 ~ 内核进入休眠
}
return 0;
}

🥸如何理解内核态和用户态 ~ 重谈地址空间
首先简单回顾下 进程地址空间 的相关知识:
- 进程地址空间 是虚拟的,依靠 页表+
MMU机制 与真实的地址空间建立映射关系 - 每个进程都有自己的 进程地址空间,不同 进程地址空间 中地址可能冲突,但实际上地址是独立的
- 进程地址空间 可以让进程以统一的视角看待自己的代码和数据

关于 进程地址空间 的相关知识详见 《Linux 虚拟地址空间》
不难发现,在 进程地址空间 中,存在 1 GB 的 内核空间,每个进程都有,而这 1 GB 的空间中存储的就是 操作系统 相关 代码 和 数据 ,并且这块区域采用 内核级页表 与 真实地址空间 进行映射
一个进程会把OS相关的内存资源通过内核页表映射 到自己的[3g,4g]空间

细节:
- 进程的所有函数调用,都是在自己的虚拟地址空间内完成的 (scanf ------
read()------ 触发软中断 进入内核空间) - 每个进程有自己的用户级页表,但是内核级页表只有一份,被所有进程共享!!
- 进程在任何时候进行调度的时候,想找到OS,随时可以找到
原则上我们可以在代码区直接跳转到内核区去访问,但是这是具有安全风险的!
为了禁止用户直接用指针访问内核空间,就必须设置执行级别:用户态和内核态
- 当前进程处于用户态 时,只能访问用户空间
【0,3G】 - 处于内核态 时,才能访问内核空间
【3G,4G】
为什么要区分 用户态 与 内核态?
- 内核空间中存储的可是操作系统的代码和数据,权限非常高,绝不允许随便一个进程对其造成影响 (直接进银行钱库取钱的例子❌)
- 区域的合理划分也是为了更好的进行管理
那我们怎么知道进程处于什么状态呢? 在Linux中会有很多地方要进行权限管理!
- 进程的
cpu中,包含了大量的寄存器值(统称 进程硬件的上下文) - 内核态和用户态,需要硬件级支持 ,也是
cpu的两种执行级别 - 比如cs寄存器 (
code segment)中有两个比特位(低两位 ):00与11(CPL),分别对应 0 和 3,处于0时,对应的是内核态,处于3 对应用户态


还有细节:
- 页表也有自己的标志位 (
DPL):用户设置为 3,内核设置为 0 - ✅当访问内核地址时,直接帮忙查页表:
MMU自动对比CPL与DPL是否相等,如果不相等就直接报错 - 如果调用系统调用,首先不是跳转到内核态,而是先把cs段寄存器中的
11改成00,再触发中断,再拿系统调用号查内核页表
有个小误区:即便我们修改cpu状态进入了内核态 ,也只是允许你调用系统调用而已,而不是随便拿指针进行访问
cpp
#include<stdio.h>
int main()
{
while(1);
}
上面的死循环,什么都没做(没调用系统调用)也一样会被 ctrl + c 中断
- 答案是:时钟中断! 虽然程序在死循环,但硬件时钟中断(
几微秒)是一直存在的;CPU 立即从用户态切换到内核态,暂停执行当前的死循环代码,转而去执行内核的中断处理程序;内核处理完时钟事务后,进行信号检查!发现了一个SIGINT(没有被阻塞,但处于未决)信号在排队。 - 内核不会再返回那个死循环了,而是直接按照信号的默认处理动作(终止进程)来执行。
🎉信号捕捉的流程再补充
情况:当前信号的执行动作为 用户自定义
这种情况就比较麻烦了,用户自定义的动作位于 用户态中,也就是说,需要先切回 用户态,把动作完成了,重新坠入 内核态,最后才能带着进程的上下文相关数据,返回 用户态

1️⃣在 内核态 中,也可以直接执行自定义动作,为什么还要切回 用户态 执行自定义动作?
- 因为在 内核态 可以访问操作系统的代码和数据 ,自定义动作可能干出危害操作系统的事
- 在 用户态 中可以减少影响,并且可以做到溯源
2️⃣为什么不在执行完自定义动作直接后返回进程?
- 当进程因中断或系统调用进入内核态时,内核会将进程上下文数据都保存在内核栈中。 所以需要先坠入内核态,才能正确返回用户态
- 进程在执行自定义信号处理函数时,使用的是用户态的栈,权限不够

🌈信号部分小结
截至目前,信号 处理的所有过程已经全部学习完毕了
信号产生阶段:有四种产生方式,包括 键盘键入、系统调用、软件条件、硬件异常
信号保存阶段:内核中存在三张表,blcok 表、pending 表以及 handler 表,信号在产生之后,存储在 pending 表中
信号处理阶段:信号在 内核态 切换回 用户态 时,才会被处理

🌈可重入函数
可以被重复进入的函数称为 可重入函数
比如单链表头插的场景中,节点 node1 还未完成插入时,node2 也进行了头插,最终导致 节点 node2 丢失,造成 内存泄漏

导致 内存泄漏 的罪魁祸首:对于 node1 和 node2 来说,操作的单链表 是同一个,同时进行并发访问(重入)会出现问题的 ,因为此时的单链表是临界资源
我们学过的函数中,90% 都是 不可重入的
函数是否可重入是一个特性,而非缺点,需要正确看待
不可重入的条件:
- 调用了内存管理相关函数 (
malloc/free) - 调用了标准
I/O库函数(printf 向全局缓冲区输出),因为其中很多实现都以不可重入的方式使用数据结构
🌈volatile关键字
volatile 关键字可以避免 编译器 的优化,保证内存的可见性
比如在下面这个例子中
借助全局变量 flag 设计一个死循环的场景,在此之前将 2 号信号进行自定义动作捕捉 ,具体动作为:将 flag 改为 1,可以终止 main 函数中的循环体
cpp
#include <stdio.h>
#include <signal.h>
int flag = 0; //一开始为假
void handler(int signo)
{
flag = 1;
printf("change flag: 0 -> 1\n");
}
int main()
{
signal(2, handler);
while(!flag);
printf("进程正在退出\n");
return 0;
}

结果符合预期,2 号信号发出后,循环结束,程序正常退出
建议性关键字 :
register------ 给编译器提个"建议 ":把这个变量直接存在 CPU 的寄存器里,而不是内存里。省去了加载的io
这段代码能符合我们预期般的正确运行是因为 当前编译器默认的优化级别很低,没有出现意外情况
通过指令查询 gcc 优化级别的相关信息
c
man gcc
: /O1

其中数字越大,优化级别越高,理论上编译出来的程序性能会更好
果真如此吗?
让我们重新编译上面的程序 ,并指定优化级别为 O1
c
gcc -o $@ $^ -O1

此时得到了不一样的结果:2 号信号发出后,对于 flag 变量的修改似乎失效了
将优化级别设为更高是一样的结果,如果设为 O0 则会符合预期般的运行 ,说明我们当前的编译器默认的优化级别是 O0
那么我们这段代码哪个地方被优化了呢?
- 答案是:
while循环判断,把flag数据读入了寄存器,后续再也不检测内存中flag的值了 - 也就是寄存器覆盖了内存,让内存不可见了
首先要明白:
- 对于程序中的数据 ,需要先被
load到CPU中的 寄存器 中 - 判断语句所需要的数据(比如 flag),在进行判断时,是从 寄存器 中拿取并判断
- 根据判断的结果,判断代码的下一步该如何执行(通过 PC 指针指向具体的代码执行语句)
所以程序在优化级别为 O0 或更低时,是这样执行的:

此时是就是"负优化"了,volatile修饰flag即可,告诉编译器:不要对flag做任何内存级的优化 ------ 必须在内存中做检测再加载到内存中
clike
volatile int flag = 0; //保持flag变量的内存可见性
🌈SIGCHLD
在 进程控制 学习时期,我们明白了一个事实:父进程必须等待子进程退出并回收,并为其 "收尸",避免变成 "僵尸进程" 占用系统资源、造成内存泄漏
那么 父进程是如何知道子进程退出了呢?
- 在之前的场景中,父进程要么就是设置为 阻塞式专心等待 ,要么就是 设置为
WNOHANG非阻塞式等待 ,这两种方法都需要 父进程 主动去检测 子进程 的状态
如今学习了 进程信号 相关知识后,可以思考一下:子进程真的是安安静静的退出的吗?
- 答案当然不是,子进程在退出后,会给父进程发送
SIGCHLD信号
可以通过 SIGCHLD 信号 通知 父进程,子进程 要退出了 ,这样可以解放 父进程 ,不必再去 主动检测 ,而是 子进程 要退出的时候才通知其来 "收尸"

SIGCHLD 信号比较特殊,默认动作 SIG_DEF 是 什么都不做
首先通过程序证明一下子进程会发出 SIGCHLD 信号
clike
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
void handler(int signo)
{
printf("父进程获取信号:%d, pid: %d\n", signo, getpid());
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if(id == 0)
{
printf("子进程退出, pid: %d\n", getpid());
exit(10);
}
while(1)
{
printf("父进程在running, pid: %d\n", getpid());
sleep(1);
}
return 0;
}

因此可以证明 SIGCHLD 是被子进程真实发出的,当然,我们可以自定义捕捉动作为 回收子进程,让父进程不再主动检测子进程的状态,可以自己忙自己的事
cpp
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
void handler(int signo)
{
printf("父进程获取信号:%d, pid: %d\n", signo, getpid());
int status = 0;
waitpid(-1, &status, 0);
printf("status code : %d\n", WEXITSTATUS(status));
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if(id == 0)
{
int n = 5;
while(n)
{
printf("子进程剩余退出时间:%d, pid: %d\n", n--, getpid());
sleep(1);
}
exit(10);
}
// 父进程很忙的话,可以去做自己的事
while(1)
{
//todo
printf("父进程在忙, pid: %d\n", getpid());
sleep(1);
}
return 0;
}

那么这种方法就一定对吗?
- 答案是不一定,在只有一个子进程的场景中,这个代码没问题 ,但如果是涉及多个子进程回收时,这个代码就有问题了
📢写在最后

