**摘要:**本文围绕 Linux 信号机制展开,先讲信号的保存,梳理信号递达、未决、阻塞三个核心概念,说明进程依靠 block、pending、handler 三张表识别信号,并介绍 sigset_t 信号集以及 sigprocmask、sigpending 等系统调用的用法与验证实验。随后深入信号捕捉,从内核态与用户态切换、硬件中断、时钟中断、软中断与系统调用等角度解释信号在何时、以何种方式被处理,并给出 sigaction 的用法与 sa_mask 屏蔽细节。最后补充可重入函数、volatile 关键字保持内存可见性,以及用 SIGCHLD 信号回收子进程、避免僵尸进程的实践方法。
目录
1.信号保存
我们说过,信号发送给进程并不会立即处理,而是先保存起来!
1.1常见概念
①**我们实际执行的信号处理动作称为信号递达(Delivery),**如:更改信号处理方式,信号捕捉等都叫做信号递达。
②信号从产生到抵达的中间状态,称为信号未决(Pending),即信号收到了但是还没有处理叫做信号未决;
③进程可以选择阻塞(Block)某个信号,阻塞信号也叫做屏蔽信号。
比如:学校上课的过程中,有些课老师我们比较讨厌,但是又不得不上这个老师的课,上完课后老师布置了作业,对于你来讲,并不是马上就能把作业做完了,因此就需要把布置的作业写到小本子上,此时是把题目记录下来,但是答案还没写,因此回到宿舍处理老师布置的作业叫做递达作业,当老师用嘴把作业布置下来,我把作业记录下来,在我还没有写这个作业时,在我处理作业之前,在记录完作业之后,这个点称之为信号未决。如果我记录下来了老师布置的作业,但是我就是不想写,这个状态叫做屏蔽信号,后面老师表扬了我,然后我不再讨厌这个老师了,这个作业我不再屏蔽了,就把布置的作业全部完成了,解除掉对这个老师的讨厌这个过程叫做解除信号屏蔽。
因此在收到信号到处理信号的过程当中,此时所处的阶段叫信号未决,处理信号的过程叫做信号递达,所谓的屏蔽信号并不是阻拦收信号,而是在从信号未决到信号递达这个过程中进行阻拦。
④被阻塞的信号产生时要一直保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
这句话可以这样理解,我讨厌这个老师,在我讨厌期间,我记录下来他布置的作业,但他的作业在我这永远不会递达,我永远不会写,除非我未来不讨厌这个老师了,解除了对他的阻塞,此时这个信号才会递达,因此阻塞信号就是一个开关,决定一个信号最终要不要处理的一个开关。
⑤注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作!
1.1.1理解阻塞
如何理解阻塞?
task_struct有位图,它的位图结构里会包含描述进程是否收到信号即pending,有一个pending位图一共32个比特位,这就相当于收到信号变为信号未决,未来操作系统向PCB写信号,就是比特位的位置表示信号的编号,比特位的内容表示是否收到信号 。此外,还有一个block的位图结构,它和pending一样,比特位的位置表示信号编号,比特位的内容表示是否阻塞/屏蔽。
如果收到1号信号,没有处理是未决的,未来我要处理1号信号,不能直接去处理,而是检测block位图对应的比特位,如果比特位为0,证明了一号信号没有被阻塞,因此就执行1号信号的默认处理动作,如果比特位为1,就代表该信号未决了,但是不能递达因为信号被阻塞了。
1.1.2进程识别信号

除了刚刚的两个位图,它还会存在一共handler表,它是一个函数指针数组,数组的下标就是信号的编号,因此如果为1号信号,它没有收到信号,因此默认处理动作为SIG_DFL;对于2号信号,它收到了,但是block为1代表被阻塞了即收到了但是没递达,因此它的处理动作是SIG_IGN;对于3号信号,即便没有收到信号我也知道该怎么处理,即处理方法已经在handler表当中了。
结论**:围绕这信号,进程能识别信号,本质是三张表:block/pending/handler表。**
之前我们还学过权限掩码:

凡是在umask里设置了的权限就不在最终权限里出现了,umask叫做权限掩码,因此umask相当于屏蔽了权限,所以屏蔽权限和屏蔽信号在语法上本质是类似的,因此所谓的对这个操作就是对这三张表进行操作!
1.1.3信号集(sigset_t)
在学三张表操作之前,我们要学一个数据类型sigset_t:
从上图来看,每个信号只有⼀个bit的未决标志, ⾮0即1, 不记录该信号产⽣了多少次,阻塞标志也是这样表⽰的。因此, 未决和阻塞标志可以⽤相同的数据类型sigset_t来存储, sigset_t称为信号集, 这个类型可以表⽰每个信号的"有效"或"⽆效"状态, 在阻塞信号集中"有效"和"⽆效"的含义是该信号是否被阻塞, ⽽在未决信号集中"有 效"和"⽆效"的含义是该信号是否处于未决状态。下⼀节将详细介绍信号集的各种操作。阻塞信号集也叫做当前进程的 信号屏蔽字 , 这⾥的"屏蔽"应该理解为阻塞⽽不是忽略。
实际上,我们不需要自己去做位操作,而是提供了一系列的函数:
cpp
#include <signal.h>
//信号集操作函数
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
实际上这些函数是根据类型配套的增删查改,但是这样设计的位图结构并没有设置到内核里,而是需要使用系统调用
1.1.3.1sigprocmask

它的作用是检查或者更改block signals,因此这个函数改的是block表,这个接口通常用来设置当前进程block位图,可以选择对某些信号进行屏蔽或解除屏蔽。
第一个参数可以传递:

SIG_SETMASK:把新设置的set用把block位图全部覆盖式的设置,即一次把block所有位图是否被屏蔽全部设置,即把第二个参数set传递进来的位图结构设置到block表里。
SIG_BLOCK:它可以往block表里新增你想屏蔽的信号,如相对一号信号进行屏蔽,set为001,然后|原来的block表即可。
SIG_UNBLOCK:解除某一个信号的屏蔽!
oldset:输出型参数,它会把你在改之前的block图通过这个结构返回,你想用这个参数就用,不想用就算了!
1.1.3.2sigpending

它的作用是检查pending信号集,也就是检查当前信号有多少个信号被pending了,它会获取当前调用进程对应的pending表,其中它的参数是输出型参数,传入参数它会把位图传递给这个参数,支持用户在代码对pending信号集做分析。
如果我们想要修改这个pending信号集,之前我们学过的产生信号的五种方式都可以修改pending表。
如果一个信号被block,即便我们收到了信号这个信号也不能被递达,直到解除屏蔽才能被递达。
默认情况下block表和pending表都是0,所以我们可以设置一个程序,先屏蔽2号信号,然后打印pending表,然后再发送2号信号,再打印pending表。
1.1.3.3使用信号集
代码为:
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
void PrintPending(const sigset_t& pending)
{
for(int signo = 31;signo > 0;signo--)
{
//判断这个信号是否在信号集
if(sigismember(&pending,signo))
std::cout << 1;
else
std::cout << 0;
}
std::cout << std::endl;
}
int main()
{
// 先定义位图
sigset_t block, oblock;
// block位图清空
sigemptyset(&block);
sigemptyset(&oblock);
// 给2号信号添加到位图里
sigaddset(&block, SIGINT); // 此时还没有设置当前进程的信号屏蔽字
// 设置内核的信号屏蔽字
// oblock:老位图,block:当前进程的位图
sigprocmask(SIG_SETMASK, &block, &oblock);
// 获取当前进程的pending信号集
sigset_t pending;
//持续获取pending信号集
while (true)
{
// 清空位图
sigemptyset(&pending);
//获取如今的信号集
sigpending(&pending);
//不断打印pending信号集中的信号
PrintPending(pending);
//休眠
sleep(1);
}
return 0;
}
运行有:

当我们没有Ctrl+C时就没有发送2号信号给进程,而我们Ctrl+C后,pending倒数第二个比特位变为1,代表2号信号存在并且为1,由于2号信号被阻塞了,所以没法导致进程结束!
此外如果我们以其他的方式产生信号,也能出现差不多的情况!
①那么我们如果屏蔽了所有信号呢?
②如果解除了对2号信号的屏蔽,我想要看到该位置由1->0
③2号信号被递达,pending 1->0,递达前变化,还是递达后变化?
①:
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
void PrintPending(const sigset_t& pending)
{
for(int signo = 31;signo > 0;signo--)
{
//判断这个信号是否在信号集
if(sigismember(&pending,signo))
std::cout << 1;
else
std::cout << 0;
}
std::cout << std::endl;
}
int main()
{
// 先定义位图
sigset_t block, oblock;
// block位图清空
sigemptyset(&block);
sigemptyset(&oblock);
//屏蔽所有信号
for(int i = 1;i <= 31;i++)
{
sigaddset(&block,i);
}
// 设置内核的信号屏蔽字
// oblock:老位图,block:当前进程的位图
sigprocmask(SIG_SETMASK, &block, &oblock);
// 获取当前进程的pending信号集
sigset_t pending;
//持续获取pending信号集
while (true)
{
// 清空位图
sigemptyset(&pending);
//获取如今的信号集
sigpending(&pending);
//不断打印pending信号集中的信号
PrintPending(pending);
//休眠
sleep(1);
}
return 0;
}
这个程序运行后确实很多的信号被屏蔽了,但是9号信号和19号信号无法被屏蔽,这些自己去试着看(9号信号无法捕捉,无法屏蔽)。
②:
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
void PrintPending(const sigset_t& pending)
{
for(int signo = 31;signo > 0;signo--)
{
//判断这个信号是否在信号集
if(sigismember(&pending,signo))
std::cout << 1;
else
std::cout << 0;
}
std::cout << std::endl;
}
int main()
{
// 先定义位图
sigset_t block, oblock;
// block位图清空
sigemptyset(&block);
sigemptyset(&oblock);
// 给2号信号添加到位图里
sigaddset(&block, SIGINT); // 此时还没有设置当前进程的信号屏蔽字
// 设置内核的信号屏蔽字
// oblock:老位图,block:当前进程的位图
sigprocmask(SIG_SETMASK, &block, &oblock);
// 获取当前进程的pending信号集
sigset_t pending;
int cnt = 10;
//持续获取pending信号集
while (true)
{
// 清空位图
sigemptyset(&pending);
//获取如今的信号集
sigpending(&pending);
//不断打印pending信号集中的信号
PrintPending(pending);
//休眠
sleep(1);
cnt--;
if(cnt == 0)
{
//解除对2号信号的屏蔽
std::cout << "解除对2号信号的屏蔽" << std::endl;
//直接用老的block即可
sigprocmask(SIG_SETMASK,&oblock,nullptr);
}
}
return 0;
}
但是这样看不到效果,因为2号信号的默认行为是终止进程,如果一旦停止阻塞就会执行终止进程的行为,因此看不到!因此要设置2号信号的捕捉!
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int signo)
{
//不要让进程终止
std::cout << "我获取到了" << signo << "信号" << std::endl;
}
void PrintPending(const sigset_t& pending)
{
for(int signo = 31;signo > 0;signo--)
{
//判断这个信号是否在信号集
if(sigismember(&pending,signo))
std::cout << 1;
else
std::cout << 0;
}
std::cout << std::endl;
}
int main()
{
//自定义捕捉
signal(2,handler);
// 先定义位图
sigset_t block, oblock;
// block位图清空
sigemptyset(&block);
sigemptyset(&oblock);
// 给2号信号添加到位图里
sigaddset(&block, SIGINT); // 此时还没有设置当前进程的信号屏蔽字
// 设置内核的信号屏蔽字
// oblock:老位图,block:当前进程的位图
sigprocmask(SIG_SETMASK, &block, &oblock);
// 获取当前进程的pending信号集
sigset_t pending;
int cnt = 10;
//持续获取pending信号集
while (true)
{
// 清空位图
sigemptyset(&pending);
//获取如今的信号集
sigpending(&pending);
//不断打印pending信号集中的信号
PrintPending(pending);
//休眠
sleep(1);
cnt--;
if(cnt == 0)
{
//解除对2号信号的屏蔽
std::cout << "解除对2号信号的屏蔽" << std::endl;
//直接用老的block即可
sigprocmask(SIG_SETMASK,&oblock,nullptr);
}
}
return 0;
}
③是递达之前就把pending由1置为0了,如何证明?
我们能不能在对2号信号捕捉的代码中,获取pending并进行打印呢?
可以的,如果打印出的结果为1,那么就是要把handler做完才能被置为0;如果打印出的结果为0,就代表handler之前已经被置为0了!
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
void PrintPending(const sigset_t& pending)
{
for(int signo = 31;signo > 0;signo--)
{
//判断这个信号是否在信号集
if(sigismember(&pending,signo))
std::cout << 1;
else
std::cout << 0;
}
std::cout << std::endl;
}
void handler(int signo)
{
//不要让进程终止
std::cout << "我获取到了" << signo << "信号" << std::endl;
sigset_t pending;
sigpending(&pending);
//区分
std::cout << "######################" << std::endl;
PrintPending(pending);
std::cout << "######################" << std::endl;
}
int main()
{
//自定义捕捉
signal(2,handler);
// 先定义位图
sigset_t block, oblock;
// block位图清空
sigemptyset(&block);
sigemptyset(&oblock);
// 给2号信号添加到位图里
sigaddset(&block, SIGINT); // 此时还没有设置当前进程的信号屏蔽字
// 设置内核的信号屏蔽字
// oblock:老位图,block:当前进程的位图
sigprocmask(SIG_SETMASK, &block, &oblock);
// 获取当前进程的pending信号集
sigset_t pending;
int cnt = 10;
//持续获取pending信号集
while (true)
{
// 清空位图
sigemptyset(&pending);
//获取如今的信号集
sigpending(&pending);
//不断打印pending信号集中的信号
PrintPending(pending);
//休眠
sleep(1);
cnt--;
if(cnt == 0)
{
//解除对2号信号的屏蔽
std::cout << "解除对2号信号的屏蔽" << std::endl;
//直接用老的block即可
sigprocmask(SIG_SETMASK,&oblock,nullptr);
}
}
return 0;
}

因为倒数第二位为0,因此代表在handler前就变化pending表了!
2.信号捕捉
进程收到信号后,不会立即处理,因为可能正在做更重要的事,而是在合适的时候处理(不考虑信号屏蔽)!
合适的时候处理是什么时候,具体怎么处理?

答案是:进程从内核态切换回用户态的时候,操作系统会检测当前进程的三张表,来决定要不要处理信号以及如何处理!
2.1内核态
内核态是什么?
我们会调用系统调用,当调用系统调用的时候就会进入到内核当中执行操作系统的代码,此时执行系统调用的过程当前进程是内核态,而在自己的代码中跳转如:访问命令和环境变量,访问动态库等时间都是在用户态进行操作。
那么进程会从内核态切换回用户态是不是代表曾经进程自己从用户态进入过内核态呢?
Linux代码执行的模式:内核态和用户态 ---- 只有这两种模式。
过程是这样的,我们调用系统调用进入内核,操作系统就会要求用户执行完系统调用的代码,执行完后正常情况下会回到你的代码处继续去运行,实际上操作系统在返回之前,它会主动调用一个函数叫做do_signal,这个函数会查找当前进程的三张表:Ⅰ检测pending表是否有1,如果全为0,就继续执行后续代码;如果有1,就会去block查找1对应的block的位图是否也为1,如果为1,当前进程什么都不做,继续返回并执行后续的代码(被阻塞了);如果为0,代表没有被阻塞,此时就需要处理信号了,此时操作系统就需要查看对应的handler表的执行方法,如果handler表里为IGN(忽略这个指令),则pending表位图由1置为0,继续返回代码去执行;否则执行对应的方法!
因此操作系统处理默认和忽略是很容易的事情,因为操作系统默认就有权限!
但是自定义捕捉比较麻烦,因为自定义捕捉是我们自己的方法,而我们自己的代码是在用户态的,所以需要先回到用户态执行完代码后调用特殊的系统调用sigreturn再次进入到内核态,然后再从内核态回到用户态继续执行后续代码!
那么执行自定义方法(sighandler)的是用户还是操作系统?
不能是操作系统,只能是用户!因为sighandler方法是用户写的,操作系统不相信人,操作系统不敢以自己的身份执行你的代码,操作系统内部禁止主动执行用户写的代码,如:代码里写到删除根目录,那么用户不就可以通过操作系统的身份达到自己的目的了吗?这就间接跳过了操作系统的权限!因此handler方法不能由操作系统来执行,必须是用户!
2.2理解内核和用户态
但是这里产生了一个问题:如果我们只单独写一个死循环,不调用任何系统调用,为什么还能收到信号呢,它也不会进入到内核态啊?
什么叫做用户态:CPU执行用户自己的代码所处的状态叫做用户态。
为了深刻理解,我们需要重谈硬件中断。
2.2.1重谈硬件中断

有的外设和CPU以及内存沟通,做的就是把数据从外设搬到内存,从内存搬到外设,整个过程就是IO,但是除了要拷贝对应的数据之外,你怎么知道外设把数据准备好了可以随时拷贝?拷贝完毕了CPU又怎么知道?
所以往往外部设备在硬件层面,在接线时会存在硬件外设键盘网卡磁盘设备统一叫做外设,而外设和CPU在主板上走线的时候,在硬件级别它们间接会采用某些总线连接起来,外设可以向CPU特定的针脚去触发对应的硬件中断。而外设和CPU并没有直接拿线连接起来,而是通过一个叫做中断控制器的设备,由它来获取外设所对应的高低电平,寄存器所谓的01本质是高低电平,寄存器是硬件,其实每一个寄存器在做的时候,内部是由各种各样的硬件电路,一个硬件电路可以锁存一个比特位,实际上就是高低电平锁存在了一个硬件电路里。所以可以理解成外设给我们未来对应的寄存器里写数据本质就是通过外设触发硬件中断,向CPU内部充电。
外部设备并不是直接向CPU发起中断,它是往中断控制器里发的,中断控制器就可以把各种外设中断信息收集在一起,然后CPU和状态控制器构建连接,此时就只需要一个引线就可以连接CPU了。一旦一个设备就绪的时候,中断控制器就会仲裁是哪一个设备向它发起中断,在中断控制器内部也存在寄存器,它会识别出来是哪个设备中断了,中断控制器替外设向CPU发送中断请求,通知CPU发起中断,CPU硬件要求一旦有外设有中断,CPU一般需要把自己当前做的工作停下来,即产生中断的过程和CPU本身的运行是异步的。
一旦触发中断了,CPU要做以下事情:①会把自己寄存器的临时值保存起来,保存在自己特定的内存段里面;②CPU从中断控制器获取中断号,不同设备的中断号不一样,因此他就能知道这个是哪一个设备的中断了。
操作系统必须提供一个模块叫做中断向量表和中断处理方法,存放各种外设所对应的中断服务,操作系统会把所有的函数聚合在一起,用函数指针数组维护起来,这个数组叫做中断向量表,然后CPU根据中断号来执行处理例程,执行中断方法。
结论①:中断向量表是操作系统提供的 ---- 中断向量表就是操作系统的一部分!
结论②:CPU执行中断向量表中的方法 ----- 就是执行操作系统的代码!
硬件中断是不是和信号中断差不多呢?
硬件中断和信号没有任何关系,它们俩个是两套技术体系,信号是软件方式模拟中断行为的!
①中断向量表就是操作系统的⼀部分,启动就加载到内存中了;
②通过外部硬件中断,操作系统就不需要对外设进⾏任何周期性的检测或者轮询;
③由外部设备触发的,中断系统运⾏流程,叫做硬件中断。
2.2.2时钟中断
①进程可以在操作系统的指挥下被调度,运行,那么操作系统自己被谁指挥,被谁推动执行呢?
②外部设备可以触发硬件中断,但是这个是需要用户或设备自己触发,有没有自己可以定期触发的设备?

有一种东西叫做时钟源,它可以一直以固定的频率在震动,它会以固定的频率向CPU触发中断,如:每隔1ms给CPU触发一次中断,我们可以把时钟源当做一个外设,只不过它的唯一工作是专门触发中断,因此它就可以让CPU每隔1ms得到中断号,然后操作系统就可以给这个中断号绑定一个方法叫做进程调度,因此,操作系统就是中断向量表!因此,操作系统在一个固定的时钟频率下一直触发硬件中断来完成对进程的控制,因此在时钟中断的高频率下由CPU以中断的形式来执行操作系统的代码的!
结论:操作系统的运行是在时钟中断下,一直触发中断管理,执行的操作系统的代码!
因此,操作系统是在时钟源的推动下一直执行,因此操作系统在硬件的推动下一直调度了!
操作系统本身是什么样的软件呢?
操作系统可以是什么都不用做的软件,只需要是一个死循环即可!操作系统是一个躺在中断上的软件集合。
时钟中断也是中断,也要有自己的中断号,也要有自己的处理方法!
实际上,当代计算机已经没有外部时钟源了,而是集成到CPU内部了!
因为放在外部,那么效率还是比较低的,不够快!所以我们的电脑属性有一个:

这个叫做主频,每秒触发的电平是:1/(1.90*10^6)次,此外Linux系统也能查自己的主频:
bash
cat /proc/cpuinfo
这个GHz就是时钟源产生频率的时间,操作系统并不会拿着时钟源直接触发中断的,当代计算机在CPU内部还会存在计数器硬件,时钟源叫做时钟发生器,时钟发生器和计数器是连接的,计数器我们自己设,时钟源发过来一个时钟信号,计数器就-1,到0时,计数器自动向外部触发时钟中断,计数器就是以特定的频率来触发实时中断,这是可控的!时钟源频率越高,操作系统被调度的频率越快,系统整机效率比较高!
**CPU有自己的主频的,CPU用固定的频率收到对应的中断的,因此每触发一次时钟中断,时间就是固定的,那么时钟中断触发的次数 * 中断触发的时间间隔 = 时间,**因此就能进行动态更新了,因此电脑只要开机那一次获取外部的时间,后续只需要记录时钟中断触发的次数就能动态更新了。
什么叫做进程的时间片?
时间片本质是计数器,当进程开始执行的时候,时间片就开始计数,直到时间片用完进程就停止调度!
如何理解时间片到了?
操作系统内存在一个全局指针current,指针指向当前进程的PCB,时钟中断一到来它会统计当前进程已经运行了多久时间了,它会做当前进程的counter时间片--,只要减值大于0即进程的运行时间还没完,则退出,即如果时间片没有用完,计算机让中断结束,让CPU执行操作系统当前执行的进程的代码,不做切换,否则就重新设置时间片,给其他进程运行。
每隔一段时间触发一段时钟中断,什么时候执行自己的代码呢?
如果1ms就触发一次时钟中断,那么每个时钟中断中间还有时间,就可以执行进程代码了!即它在时钟中断之间运行自己的代码!
结论:围绕进程展开的:内存管理,文件管理等,就有理解的切入点了!
如果是这样,操作系统不就可以躺平了?对,操作系统不做任何事情,需要什么功能,在中断向量表里加方法即可,操作系统本质:死循环!主频决定操作系统的整体效率!
2.2.3软中断
上述硬件中断,需要硬件设备触发,有没有可能,因为软件的原因触发上面的中断逻辑?
为了让操作系统支持系统调用,CPU设计了汇编指令叫做(int或者syscall),可以让CPU内部触发中断逻辑!
实际上,是有用软件来触发中断的,C语言最终要形成汇编,有一个汇编指令int,它并不是C/C++中整型的意思,它是interrupt中断的简写,还有一个syscall:system call,这两个都是汇编命令,当这两个命令被CPU执行了,那么就会执行特定中断号所对应的中断处理流程,一般int触发的中断号为0x80,因此CPU可以不需要外设,让CPU根据0x80来查找中断表执行对应的方法,这个中断和之前的两种中断的区别是:它不需要外部设备来触发,它是CPU通过软件的方式触发的!
其实汇编也不是本质,它最终也要变成二进制,CPU内部硬件集成度特别高,CPU要正常去运行其内部一定要有一套指令集的东西。为什么呢?
就好比一个刚出生的婴儿,他有大脑,四肢,五官,器官硬件他都有,但是对这个婴儿来说,如果我们发出一些指令如:站起来,坐下这种,他根本听不懂,在爸爸妈妈的帮助下,他能够识别简单的短语,但是如果让他站起来去拿东西,这些东西都听得懂,因此,这个小孩就会对你说的话进行解析,因此他把你给他的复杂指令组合在一起,他就能完成某种动作了。
这个例子中,刚出生的婴儿就是刚出厂的CPU的原始状态,这个原始状态是不能被商家拿去卖的,CPU需要进行刻录,刻录就是向CPU芯片里写入对应的指令集,这些指令集单看没什么意义,但是如果把这些指令连起来,小朋友被父母教育的过程中就相当于CPU刻录,因此,CPU一旦有了指令集,就能进行简单的操作了。CPU的指令要包含你要做什么动作还要包含对什么数据做动作,因此CPU就能帮你做事了,二进制开发其实是基于CPU指令集开发,后面指令集看起来很难记,因此就衍生出了助记符,未来就可以反向转化为二进制了,后面就衍生出了C/C++这种偏向自然语言的了!
因此CPU不仅仅是一个硬件,内部还写了指令集这样的东西!
因此int和syscall就是规定好的二进制指令,CPU识别发现要做中断,因此就能完成中断的操作了!
因此可以用软件的方式让CPU进入中断的处理例程中!这个中断的方式叫做软中断。
软中断的实际应用场景 ----- 辅助实现系统调用。
所有的系统调用Linux内核里都被维护在一张表里面:

这是一个函数指针数组,是全局的系统调用表,其中的fn_ptr是void*类型,因此要调用系统调用,需要通过数组下标查找到要调用的系统调用,我怎么知道系统调用的下标号是什么?
它会给每一个系统调用定义一个系统调用号,而系统调用号本质是系统调用的下标
未来想用系统调用,只需要把系统调用对应的系统调用号交给操作系统,就能调用对应的系统调用函数了。
每一个进程未来都会有自己的虚拟地址空间,0-3GB称之为用户空间,3-4GB称之为内核空间,实际上这些系统调用的相关内容都在内核区,想要调用系统调用,就要从当前进程跳转到内核空间执行系统调用的代码了。每一个进程都有自己的用户级页表,专门用来把当前进程的用户对应的代码通过用户级页表映射到物理内存中,之前我们所说的页表叫做用户级页表。
但是计算机刚开机的时候,不应该把操作系统加载到物理内存里面吗?
操作系统要先加载到内存,实际上,在内核当中还存在一个内核级页表,将来它和用户级页表都属于内核数据结构,其实就是一张表,只不过内核级页表有标志位来标识自己是操作系统的代码和数据,内核级页表是把当前进程内核的3-4GB空间虚拟地址映射到内核页表映射到操作系统相关代码和数据当中的。每一个进程都有自己的用户级页表,但要共享同一份内核级页表!也就是说所有进程的3-4GB虚拟地址都要映射到操作系统内部!
因此结论①:从现在开始,无论进程怎么切换,怎么调度,每一个进程都可以找到同一个内核!CPU自身也能随时找到内核!
结论②:用户要访问操作系统,只有一种途径,就是系统调用!那么你的进程如何看到系统调用的?通过虚拟地址空间! ------> 所有的函数调用,未来全部都可以理解成为在我自己的虚拟地址空间中完成!
那这样会带来两种问题:
Ⅰ操作系统被映射到系统地址空间了,如果我在自己的代码里用野指针不就能访问到操作系统了?
因此我们需要区分内核态和用户态,只有在内核态的时候才允许访问3-4GB的范围,因此这里涉及到内核权限的问题!
Ⅱ我们用的系统调用,我们只知道系统调用的名字,并不知道系统调用具体在哪里啊?
所以我们需要想办法将系统调用名转化为系统调用号;主动触发一次软中断,让CPU执行内核代码,使用系统调用号去查表,然后执行!
在操作系统内部要调用系统调用,CPU内部有eax寄存器,调用系统调用的时候,就有人帮我把系统调用名翻译成系统调用号,然后把系统调用号放在CPU内的寄存器,比如eax里面,然后有人帮我们进行int或syscall触发软中断,只要这个软中断触发了,CPU就要执行内核中的中断处理方法,这个方法就是查表并进行系统调用!
但是我们只是调用了一次open函数啊,我可什么都没做啊,这些工作谁做的?
操作系统提供的真正的系统调用,不是C风格的,而是系统调用号,约定寄存器,int 0x80等技术 ----- 提供的系统调用! ------ >可是这种方法不好用,因此Linux内核的设计者就用C语言给我们封装起来了,因此它也属于C语言范畴!在C语言内部就进行函数名 -> 系统调用号的操作了,因此这是C语言做的!在Linux的GNU C标准库,几乎把所有的系统调用都封装了,因此我们在C语言的标准库是看不到的!
++因此,系统调用是基于软中断的!!!++
很明显,执行我们自己的代码和执行操作系统的代码有明显的区别,那么安全如何保证,即操作系统怎么做到只能让系统调用去访问,如何体现权限和安全?
在CPU内部存在一个段寄存器叫做CS(code segment),代码段寄存器,实际上是有很多段寄存器的,还有SS/DS/ES/FS......,CS里有两个比特位用来专门表示CPU本身的执行级别,两个比特位取值范围按照十进制算的话,能取0/1/2/3,我们用0表示内核态,3表示用户态,即如果为3,表示当前CPU只允许访问0-3GB的用户空间,只有当这个数字由3变成0的时候,整个系统才允许指针指向3-4GB的内核空间。1和2不代表执行级别,只有0和3这两个执行级别,表示执行级别的数字叫做CPL(current privilege level)当前权限级别。那么它是怎么做到这一点的?
在整个页表中(用户页表+内核页表)存在一个标志位两个比特位,它代表的是指定条目的执行级别,即CPL,当我们拿着指针去页表访问虚拟地址时,要经过页表进行转化,在转化的过程中,还需要通过页表的标志位来查指针级别,如果指针级别是0,但你当前CPU段寄存器指针级别是3,不相同,会触发MMU报错,只有当二者相同(页表的CPL和CS的CPL相同)才能访问对应的物理空间!因此要真正的从用户代码去访问内核空间必须更改CPL!
结论:如果我们要访问内核空间,就必须更改CPL!
2.2.4一些疑问
如何更改CPL?
有且只有一种方法就是:通过int 0x80或syscall来更改,它们可以让CPL从3->0,并且只要用这种方法改了CPL,就固定触发一次软中断。实际上,进入内核态还有很多方法,之前所说的很多中断也能进入,只要绑定中断,它都是进入内核态的,但是当前的内核想要通过软件进入内核只能通过这种方式!
因此,你想要访问内核,就必须改执行级别,因为你绕不开页表,因此就必须调用int 0x80或syscall,且这个方式是固定的,此时就能做到只能通过系统调用来访问内核了!
至于如何把CPL从0变为3,系统调用在返回时会做对应的操作,这里就不细说了!
那么对应操作系统怎么知道除零会报错,即操作系统怎么知道硬件报错?
硬件报错触发了CPU的中断,CPU执行中断处理方法,中断处理方法就是操作系统的一部分,因此操作系统就知道了!
如何理解写时拷贝?如何理解缺页中断?
一旦发生写时拷贝,就会把数据段设置为只读权限,会触发MMU报错,但是操作系统能识别这个报错有以下三个原因:①MMU转化时页表的虚拟地址到物理地址的映射关系是存在的;②权限级别是r(只读);③访问的区域是代码区。因此操作系统就识别到它应该要进行拷贝了!因为识别到你权限是r,因此就要给你重新开辟空间,会触发软中断,执行中断向量表中的中断方法开辟空间,申请出来的物理地址重新修改页表,再完成后面的事情!
我们在代码里申请了1G的空间,它并不会立马申请1G物理空间,只需要在虚拟地址空间上开辟,并没有映射到具体的物理地址,只有当使用的时候才会开辟物理内存,因为发现你有虚拟地址,但是没有物理地址,就会触发MMU报错,触发硬件中断,执行向量表中的中断处理方法,确认没有开辟空间,给你开辟空间。因此缺页中断是基于中断机制的!
总结:操作系统是一个死循环,是基于中断工作的软件集合!操作系统是躺在中断处理例程的代码块。
CPU内部的软中断,比如:int 0x80或syscall,我们叫做"陷阱",CPU内部的软中断,比如:除零/野指针等,我们叫做:"异常"。
外部硬件引起的错误 ---- "硬件中断"。
陷阱 ---- 它并没有出错,是用户主动让自己陷入内核(更改CPU的CPL执行级别)。
异常 ---- 出错了,但不是外设引起的,是用户操作导致CPU内部硬件出问题!
什么叫做信号处理?
信号处理并不是立即处理,而是从内核态返回到用户态的时候,进行检查用户的三张表,进行信号捕捉(前提:你曾经从用户态进入过内核态)。
那么如果我的代码是死循环,什么函数都没有调用,那么这个进程有没有可能进入内核态呢?
会,因为还有时钟中断,进程时间片进行切换调度。操作系统在调度你这个死循环的进程的时候,一直在进行内核到用户,用户到内核的切换,不以你的意志而转移。
2.3信号捕捉的系统调用
cpp
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);

这个系统调用的作用是:对指定的信号改变信号的处理动作。
第一个参数signum:信号编号;第二个参数act:你要如何处理信号即你要设置的新的捕捉方法,输入型参数;第三个参数oldact:输出型参数,你可以选择传递一个结构体指针,它会给你返回老的捕捉方法。
对应的struct sigaction:
cpp
struct sigaction {
void (*sa_handler)(int);//信号捕捉方法,等同于signal中的handler自定义捕捉
void (*sa_sigaction)(int, siginfo_t *, void *);//和实时信号有关,不用管
sigset_t sa_mask;//最重要的
int sa_flags;//默认等于0即可
void (*sa_restorer)(void);//和实时信号有关,不用管
};
下面演示一下这个系统调用:
cpp
#include<iostream>
#include<unistd.h>
#include<signal.h>
//信号捕捉
void handler(int signo)
{
std::cout << "获取到一个信号:" << signo << std::endl;
}
int main()
{
//sigaction结构体
struct sigaction act,oact;
//设置新的捕捉方法
act.sa_handler = handler;
act.sa_flags = 0;
//清空
sigemptyset(&(act.sa_mask));
//将2号信号的捕捉方法设置到内核中
sigaction(2,&act,&oact);
//死循环
while(true)
{
std::cout << "我是一个进程:" << getpid() << std::endl;
sleep(1);
}
return 0;
}
只要我们Ctrl+C,它就会执行handler方法:

这个操作和我们之前在演示signal函数一样的!那这个系统调用又有什么用,signal函数不好么?
signal函数我们使用最多,因为它简单,但是它有一个sa_mask!
当某个信号的处理函数(sigaction)被调⽤时,内核⾃动将当前信号加⼊进程的信号屏蔽字,当信号处理函数返回时⾃动恢复原来的信号屏蔽字 ,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么 它会被阻塞到当前处理结束为⽌。 如果在调⽤信号处理函数时,除了当前信号被⾃动屏蔽之外,还希望⾃动屏蔽另外⼀些信号,则⽤sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时⾃动恢复原来的信号屏蔽字。 sa_flags字段包含⼀些选项,现在都把sa_flags设为0,sa_sigaction是实时信号的处理函数,不详细解释这两个字段,感兴趣的自己可以在了解⼀下。
也就是说,如果正在处理某个信号,这个信号会自动屏蔽,它可以防止任意信号进行递归处理,即如果频繁发送同一个正在处理的信号,那么它并不会再继续执行后面发送的同样的信号,而是屏蔽掉这个信号,直到处理完这个信号才会解除屏蔽,即如果发送200个2号信号,那么它并不会在执行处理2号信号的方法里继续执行2号信号的方法,不会递归200次2号信号捕捉方法!
如果来了不同的信号,那么其他信号确实不会被屏蔽,但是信号总共也就31种,它最多递归31次。
如果在调用信号处理的函数时,如果只屏蔽当前信号我不得劲,即我要屏蔽另外一些信号,就可以设置sa_mask字段来说明这些额外屏蔽的信号,即sa_mask的意义是:如果用户想要屏蔽其他信号,就可以把其他信号加进来!
怎么证明?
我们可以在handler方法里获取一下所有的信号:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
// 信号捕捉
void handler(int signo)
{
std::cout << "获取到一个信号:" << signo << std::endl;
// 收到的信号
sigset_t pending;
while (true)
{
// 清空
sigemptyset(&pending);
// 获取信号
sigpending(&pending);
// 打印
for (int i = 31; i > 0; i--)
{
// 打印所有信号
if (sigismember(&pending, i))
std::cout << "1";
else
std::cout << "0";
}
std::cout << std::endl;
sleep(1);
}
}
int main()
{
// sigaction结构体
struct sigaction act, oact;
// 设置新的捕捉方法
act.sa_handler = handler;
act.sa_flags = 0;
// 清空
sigemptyset(&(act.sa_mask));
// 将2号信号的捕捉方法设置到内核中
sigaction(2, &act, &oact);
// 死循环
while (true)
{
std::cout << "我是一个进程:" << getpid() << std::endl;
sleep(1);
}
return 0;
}
此时只需要获取一个2号信号就会一直执行handler方法一直打印在期间获取到的方法!
这个就不做演示了,很容易理解。但是我想要在执行2号信号的方法时把3、4、5、6号信号都屏蔽该怎么改代码?
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
// 信号捕捉
void handler(int signo)
{
std::cout << "获取到一个信号:" << signo << std::endl;
// 收到的信号
sigset_t pending;
while (true)
{
// 清空
sigemptyset(&pending);
// 获取信号
sigpending(&pending);
// 打印
for (int i = 31; i > 0; i--)
{
// 打印获取到的信号
if (sigismember(&pending, i))
std::cout << "1";
else
std::cout << "0";
}
std::cout << std::endl;
sleep(1);
}
}
int main()
{
// sigaction结构体
struct sigaction act, oact;
// 设置新的捕捉方法
act.sa_handler = handler;
act.sa_flags = 0;
// 清空
sigemptyset(&(act.sa_mask));
// 设置屏蔽信号
sigaddset(&(act.sa_mask),3);
sigaddset(&(act.sa_mask),4);
sigaddset(&(act.sa_mask),5);
sigaddset(&(act.sa_mask),6);
// 将2号信号的捕捉方法设置到内核中
sigaction(2, &act, &oact);
// 死循环
while (true)
{
std::cout << "我是一个进程:" << getpid() << std::endl;
sleep(1);
}
return 0;
}
此时我们执行代码并用另一台机器给进程发送信号有:

如果想要验证:当信号处理函数返回时自动恢复原来的信号屏蔽字,可以参考如下代码:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
// 信号捕捉
void handler(int signo)
{
std::cout << "获取到一个信号:" << signo << std::endl;
// 收到的信号
sigset_t pending;
//定义循环次数
int cnt = 10;
while (cnt--)
{
// 清空
sigemptyset(&pending);
// 获取信号
sigpending(&pending);
// 打印
for (int i = 31; i > 0; i--)
{
// 打印获取到的信号
if (sigismember(&pending, i))
std::cout << "1";
else
std::cout << "0";
}
std::cout << std::endl;
sleep(1);
}
}
int main()
{
// sigaction结构体
struct sigaction act, oact;
// 设置新的捕捉方法
act.sa_handler = handler;
act.sa_flags = 0;
// 清空
sigemptyset(&(act.sa_mask));
// 设置屏蔽信号
sigaddset(&(act.sa_mask),3);
sigaddset(&(act.sa_mask),4);
sigaddset(&(act.sa_mask),5);
sigaddset(&(act.sa_mask),6);
// 将2号信号的捕捉方法设置到内核中
sigaction(2, &act, &oact);
// 死循环
while (true)
{
std::cout << "我是一个进程:" << getpid() << std::endl;
sleep(1);
}
return 0;
}
具体操作和之前一样,只需要改变一下循环不是死循环即可!

我们对照所有信号发现这个是5号信号:

因为实际上右侧的4号信号并没有发出这个进程就结束了,所以说它是执行最后一个发出的信号对应的操作!
2.4如何理解用户态和内核态(总结)
①操作系统⽆论怎么切换进程,都能找到同⼀个操作系统!换句话说操作系统系统调⽤⽅法的执⾏,是在进程的地址空间中执⾏的!
②关于特权级别,涉及到段,段描述符,段选择⼦,DPL,CPL,RPL等概念, ⽽现在芯⽚为了保证兼容性,已经⾮常复杂了,进⽽导致OS也必须得照顾它的复杂性,这块我们不做深究了。
③用户态就是执⾏用户0,3GB时所处的状态,内核态就是执⾏内核3,4GB时所处的状态;
④区分就是按照CPU内的CPL决定,CPL的全称是Current Privilege Level,即当前特权级别。
⑤⼀般执⾏ int 0x80 或者 syscall 软中断,CPL会在校验之后⾃动变更。
3.可重入函数

如main函数正在执行insert方法的时候,执行完p->next = head;这段代码之后,收到了一个信号,虽然说要等到中断时才会执行处理信号的步骤,但是刚好在这一步结束之后就刚好收到信号并立即处理了,有没有可能不去执行下面的head = p代码,而去执行信号捕捉的方法呢?
肯定是有可能的,因为可能刚好时间片用完了。那么会执行信号捕捉方法,如果这个方法也要执行insert方法怎么办,那么就也会让node2作为头结点,并且node2的next指向头结点,执行完信号捕捉方法后,回到原来的main函数的insert执行head = p的代码处,此时就会让head变为了node1,而原来的node2 就丢失了,即内存泄漏了,这个node2结点丢失了,这种现象叫做该insert函数因为信号导致insert函数被main执行流与handler执行流重复进入,叫做insert函数被重入了!
因为在进程运行的过程中,任何一个时间点都有可能进行信号的递达,因此就肯定是会有这种情况 的,所以不能忽视!
因为insert函数被重复进入导致的错误,这种错误不一定是内存泄漏,因此我们可以称insert函数叫做不可重入函数。重入和不可重入是描述函数的特点,不要把它理解成函数的优缺点,不能因为它不可重入就是缺点!大部分函数是不可重入的!printf函数可重入,malloc函数不可重入。
我们需要理解一下main执行流和handler执行流!
执行自己的函数时也是当前进程执行,执行信号函数还是当前进程执行,但它们两个属于不同的执行流,因为main函数调用时有自己的调用栈,信号捕捉函数也有一条独立的调用栈,如:如果捕捉了信号,这个信号捕捉函数不一定会被调用,可能这个信号根本没有到来,这种情况叫做信号函数不被执行,只有当信号到来的时候才会被执行,因此我们可以说,这个信号捕捉方法是当前进程可执行和不可执行的函数,因此称之为两个执行流,相当于我们可以在坐地铁的同时玩手机,我们可以同时做两件事情,虽然是同一个人,但可以认为是两个执行流:一个执行流叫坐地铁,一个执行流叫玩手机。
因此如果两个执行流执行同一个函数,就有可能出现重入问题。如果函数被重入了不出错叫做可重入函数,而函数被重入了出错了叫做不可重入函数!
因为后面我们要谈论多线程,所以需要在这里讲解一下,我们只需要知道这个概念即可,后面线程会更加深入讲解!
如何判断一个函数是否不可重入呢?
符合下面条件之一的是不可重入函数:①调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的;②调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构;③函数具有全局变量或全局数据,这样的函数一般是不可重入的;④STL中大部分接口是不可重入的,且我们学到的大部分函数都是不可重入函数。
什么是可重入函数呢,要满足:函数内部只有局部变量,没有任何全局变量这种函数基本都是可重入函数!
4.volatile
我们学过的C语言是C90标准,一共32个关键字,其中我们已经学了31个,还有一个就是volatile关键字。
volatile:不稳定的,在C语言中我们一般把它叫做易变关键字。因为之前没有讲过信号,所以没法演示这个关键字,现在可以了!
cpp
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
int flag = 0;
void handler(int signo)
{
flag = 1;
printf("改变flag:0->1");
}
int main()
{
signal(2,handler);
printf("进程启动:%d\n",getpid());
while(!flag);
printf("进程正常结束!\n");
return 0;
}
这个代码很简单,C语言代码,当我们不进行Ctrl+C时,while循环一直判断为true,就是一个死循环,直到我们Ctrl+C把flag变为1就会退出循环:


实际上,①这个进程有两个执行流,一套是main执行流,一套handler执行流,比如一个同学正在上课,如果他饿了,他会去吃东西,他做了另外的工作:吃东西。②编译器识别不出来这两个执行流,因为这两个执行流只有运行之后才能看得出来。③对main执行流来说,它只能看到flag全局变量在进行检测,所谓的检测本质上是对全局变量flag进行读取,虽然handler对flag修改了,但是它不在main函数内部,而是在其他函数中。因此会引申出一个问题,编译器在编译代码时,会自作聪明的对我们的代码进行优化,可能会对main中的flag因为对main执行流来说它是只读的,因此它会直接优化到寄存器当中,C语言存在register关键字,它是一种建议性关键字,它会建议编译器编译被建议的变量除了开辟空间外,甚至不开辟空间,直接把这个变量的值代表这个变量优化到寄存器里:
cpp
register int a = 10;
当然这是建议的,编译器可以不听建议!
可是我们这个代码能正确执行的前提是:没有做优化,因为gcc/g++默认没有对代码做优化,如果想要做优化:
bash
gcc/g++ -O数字/-O字母/-O
如gcc -O1代表做一级优化,这个数字有0/1/2/3,字母有s/fast/g具体作用自己去查看手册!
一般情况下gcc默认级别是-O0表示没有优化!只要我们编译是这样的:
bash
gcc vo.c -O1/-O2/-O3
这个代码就结束不了了:

实际上是进行了自定义捕捉的,只是刚刚没有进行缓冲区刷新,因此打印信息没出来,如果把代码改为这样再运行即可:
cpp
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
int flag = 0;
void handler(int signo)
{
flag = 1;
printf("改变flag:0->1\n");
}
int main()
{
signal(2,handler);
printf("进程启动:%d\n",getpid());
while(!flag);
printf("进程正常结束!\n");
return 0;
}

为什么我们对代码进行一定程度的优化,进程就不结束了呢,我们发现自定义捕捉方法仍然执行了,那么就代表flag值变化了,那么为什么不行?
①flag是全局变量,它会在内存中开辟一段空间。②在执行代码时,CPU要对我们某个变量做检测,它往往要这样操作:Ⅰ将变量从内存读到寄存器当中;Ⅱ在CPU内部把数据拿到进行某种运算,如:!flag是一种运算,while也是一个逻辑运算,因此while会在CPU进行运算的,如果有变量的值发生变化了,那么就会把写完的数据写回内存,但是如果没有变化,就会一直执行Ⅰ和Ⅱ,在没有做优化时,CPU默认每次循环都要先进行访存,因此就会出现只要flag变为1,就会退出循环了;但是只要优化代码,当前计算机可能把flag的值直接优化到eax寄存器里(CPU内部),变成一个寄存器变量,往后while循环再进行检测时,汇编出的代码不用再访问内存了,只执行上面的Ⅱ检测寄存器内部的值就可以,只做检测而不访存,因此读取数据不需要从内存读了,直接从寄存器内部读,因为main执行流里,只会对flag变量进行检测,没有做修改,因此就会这样做。
因此,只要优化了后,它未来只会检测这个eax寄存器里的值是否为0,后来flag变为1,只是内存的flag变为1了,但是CPU不会从内存里读了,因此CPU读的flag还是0!因此,编译器优化会导致屏蔽了内存数据。
如果想要解决这个问题,就需要volatile关键字!它能保持内存可见性,只需要这样改就行:
cpp
volatile int flag = 0;
volatile关键字的作用是:禁止编译器进行优化,保持内存可见性!
因此,我们不管怎么优化也能成功运行:

5.SIGCHLD信号--了解即可
进程⼀章讲过⽤wait和waitpid函数清理僵⼫进程,⽗进程可以阻塞等待⼦进程结束,也可以⾮阻塞地查询是否有⼦进程结束等待清理(也就是轮询的⽅式)。 采⽤第⼀种⽅式,⽗进程阻塞了就不 能处理⾃⼰的⼯作了;采⽤第⼆种⽅式,⽗进程在处理⾃⼰的⼯作的同时还要记得时不时地轮询⼀ 下,程序实现复杂,因此这两个方式都有短板!
其实, ⼦进程在终⽌时会给⽗进程发SIGCHLD信号,该信号的默认处理动作是忽略,⽗进程可以⾃ 定义SIGCHLD信号的处理函数, 这样⽗进程只需专⼼处理⾃⼰的⼯作,不必关⼼⼦进程了,⼦进程 终⽌时会通知⽗进程,⽗进程在信号处理函数中调⽤wait清理⼦进程即可。
因此,我们可以写出如下代码:
cpp
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<stdlib.h>
void handler(int signo)
{
//验证是否收到的是17号信号
printf("%d进程,收到了信号:%d\n",getpid(),signo);
}
int main()
{
//自定义捕捉17号信号SIGCHLD
signal(SIGCHLD,handler);
//创建子进程
pid_t id = fork();
if(id == 0)
{
//子进程
sleep(3);
//直接退出
exit(0);
}
//父进程
while(1)
{
printf("我是父进程,pid:%d\n",getpid());
sleep(1);
}
}
我们编译运行就有
这就能证明子进程在退出时,父进程收到了17号信号!

实际上的SIGCHLD默认动作是忽略的(Ign)。
此时,我们进行单个子进程的回收,这样写:
cpp
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
//单个子进程回收
void handler(int signo)
{
//等待信号
pid_t rid = waitpid(-1,NULL,0);
//验证是否收到的是17号信号
printf("%d进程,收到了信号:%d,回收子进程:%d\n",getpid(),signo,rid);
}
int main()
{
//自定义捕捉17号信号SIGCHLD
signal(SIGCHLD,handler);
//创建子进程
pid_t id = fork();
if(id == 0)
{
printf("我是子进程,pid:%d\n",getpid());
//子进程
sleep(3);
//直接退出
exit(0);
}
//父进程
while(1)
{
printf("我是父进程,pid:%d\n",getpid());
sleep(1);
}
}
但是还有其他情况:创建了很多子进程,然后所有子进程同时退出,同时发出信号,那么handler方法只能执行一次,因此要这样写:
cpp
// 多个子进程同时退出回收
void handler(int signo)
{
while (1)
{
// 等待信号
pid_t rid = waitpid(-1, NULL, 0);
// 验证是否收到的是17号信号
printf("%d进程,收到了信号:%d,回收子进程:%d\n", getpid(), signo, rid);
}
}
因为是两个执行流,所以我们写个死循环是没有问题的!
如果创建多个子进程,有些子进程退出,有些子进程不退出呢?
如果我们确定了子进程的数量,并且确定了有多少个子进程退出,那么确实很简单,但是这样的代码不具有灵活性,因此如果遇到没退的子进程,waitpid就会阻塞,因此我们等待方式要设置为WNOHANG,代表非阻塞等待,此时只需要判断rid是否为0即可,因此可以:
cpp
// 多个子进程,一些退了,一些没退
void handler(int signo)
{
while (1)
{
// 等待信号
pid_t rid = waitpid(-1, NULL, WNOHANG);
//等待成功
if(rid > 0)
printf("%d进程,收到了信号:%d,回收子进程:%d\n", getpid(), signo, rid);
//等待失败
else
break;
}
}
不过这样写不怎么好理解,可以这样写:
cpp
// 多个子进程,一些退了,一些没退
void handler(int signo)
{
pid_t id;
//非阻塞等待
while ((id = waitpid(-1,NULL,WNOHANG) > 0))
{
//等待成功
printf("%d进程,收到了信号:%d,回收子进程:%d\n", getpid(), signo, id);
}
}
事实上,由于UNIX 的历史原因,要想不产⽣僵⼫进程还有另外⼀种办法: ⽗进程调 ⽤sigaction将 SIGCHLD的处理动作置为SIG_IGN,这样fork出来的⼦进程在终⽌时会⾃动清理掉,不会产⽣僵⼫进程,也不会通知⽗进程。 系统默认的忽略动作和⽤⼾⽤sigaction函数⾃定义的忽略 通常是没有区别的,但这是⼀个特例。此⽅法对于Linux可⽤,但不保证在其它UNIX系统上都可⽤。
如:
cpp
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
//防止子进程僵尸
int main()
{
//忽略17号信号,此时子进程就不会僵尸
signal(SIGCHLD,SIG_IGN);
// 创建子进程
pid_t id = fork();
if (id == 0)
{
printf("我是子进程,pid:%d\n", getpid());
// 子进程
sleep(3);
// 直接退出
exit(0);
}
// 父进程
while (1)
{
printf("我是父进程,pid:%d\n", getpid());
sleep(1);
}
}
各位可以自己去运行一下,是能保证不出现僵尸的(前提:这个方法可能不通用,建议用Ubuntu20.04或Ubuntu22.04下)。
但是不是说这个信号默认会动作是Ign,为什么我们还要单独设置为IGN呢?
因为实际上的Ign它会做特殊处理,让子进程变僵尸,而系统的SIG_IGN要求操作系统会把子进程的僵尸状态释放掉!即实际上父进程收到SIGCHLD的处理动作不是SIG_IGN,而是SIG_DFL只是它在内核当中默认被设置成为了忽略!如果我们手动设置了SIG_IGN那么就是回收子进程。
对应的所有代码已上传至Gitee,各位可以去查看: