本章节将会围绕信号处理进行展开讲解
目录
- 回顾一下:
- 历史问题:
- 内核态 VS 用户态
- 地址空间:
- 键盘的输出如何被检测到:
- OS如何正常运行:
- 如何执行系统调用:
- 信号的处理:
- sigaction:
- 信号的特点:
- 基于信号的理解讨论3个子问题:
- 可重入函数:
- volatile:
- SIGCHLD:
回顾一下:
信号处理也就是信号递达
我们说过递达时一种有3种行为
- 默认行为
- 忽略行为
- 自定义行为
历史问题:
我们其实一直都存在一个问题:一般信号发送时不会被立即处理,而是等到合适的时候进行处理,那么这个合适的时候究竟是什么时候?
先说结论:从内核态返回到用户态时进行处理。
这两个名词等会会有解释,现在重要的是先将脉络理清楚,在去深究细节。
返回时我们会先看此时pending表有没有为1的信号,在看是否阻塞,如果没有,那么我们就会执行handler函数,执行handler函数我们一般有3种行为
- SIG_DFL :默认行为,大部分进行都是进行终止,如果是终止的话,我们OS在内核态会直接杀死进程,不会返回到main函数了。
- SIG_ING:忽略行为,把对应的1->0,返回到main函数继续执行
- 自定义:我们一般把这种情况叫做信号捕捉。
也就是下图
那我们现在就要理清一下这张图的逻辑。
首先,我们因为某些系统调用等情况进入到内核,当处理完进入到内核的情况后会检查信号,观察pending与block位图是否符合要求,如果是默认OS在内核态会直接杀死进程,不会返回到main函数了;如果是忽略把对应的1->0,返回到main函数继续执行;但是最恶心的是自定义行为,也就是我们这样图所示。
此时我们面临一个问题,OS可以直接去执行用户的代码吗?
答案是否定的,如果用户进行了exec等系列函数,或者代码出错,那么OS岂不就完蛋了吗,但是在技术角度肯定可以实现,但是仍然不会给你实现。
那就必须进行状态切换,让用户自己执行自己的代码,自己出错自己负责。
那么我们可以直接从第四步执行到第一步吗,
答案也是否定的,最直观的就是可以看到我们的handler函数并没有调用main函数,也就是没有直接的相互调用关系。
此时需要使用一些特殊的系统调用函数,返回内核再返回main函数继续执行。
这样我们就·大概了解了信号捕捉的流程。一个无穷符号。
对于如何快速记忆,我们可以先画一个无穷符号,在焦点的上方画一条横线。
横线上方是用户态,下方是内核态。
到现在为止要开始暂停一下了,我们要开始讲几个子问题,知道这些才能搞定内核态与用户态的区别。
内核态 VS 用户态
地址空间:
我们先来看一个大概的图。
对于程序地址空间我们已经接触过很多次了,在自定义函数中会在正文代码内跳转,在申请内存时又会在堆中跳转,在使用动静态库时会在共享区跳转。综合来看也就只有内核[3,4]GB我们还没有接触过。
我们已经使用过很多次系统调用了,系统调用时函数,那么就会有函数地址,可是我们从没交过函数地址,那么他在哪里呢?
注意:OS是第一个在内存中被加载的软件,另外我们其实还有一份页表是内核级的,专门负责OS的映射。
这也就意味着:OS本身就在我的地址空间中。
但也会有很多个进程同时存在的情况。
用户级的页表每个进程都有一个,但是内核级页表只有一个,他们是按照统一的方式进行映射
这也就意味着,不论进程如何切换,我们总能找到OS,通过访问[3,4]GB的进而找到所有的代码和数据,就可以进行系统调用了!
所以访问系统调用其实是和访问库函数没有区别的,都是在地址空间中进行的。
而OS不会让用户直接进行访问内核的部分,要受到约束,所以要使用OS提供的系统调用进行访问!
键盘的输出如何被检测到:
我们先来看一个简图。
那么问题出现了,当我们按下键盘时,OS是通过何种方式得到我们的数据呢?
方法一:OS进行轮询–>结果:累死。
方法二:也是那些计算机软件科学家发明出来的。
根据冯诺依曼体系我们知道CPU不会和硬件直接打交道(数据层面),
但是当我们按下键盘的时候,会向CPU发送硬件中断(控制信号),每个硬件都有自己的中断号(假设键盘为3)。
CPU有很多针脚,也就是图中的红色凸出部分,我们向特定针脚发送高电平,CPU接受到信号就会将对应的中断号放入到对应的寄存器中,此时就变成软件了!只需要对软件进行操作即可。
那我们现在以软件的角度进行观察,第一个被加载的软件是OS,实际上,OS会首先形成一分函数指针数组,这些函数实际上是OS的一些方法,包括但不限于硬件的读写方法。
,所谓的中断号其实也就是对应的下标。
从此,当我们进行摁键盘的操作时,CPU会把当前所有的硬件任务停止,把中断号读到并去索引对应的方法下标。
这个表叫做中断向量表,所有的外设都是这样的!
这个程序流程是不是与信号有些相似呢?
实际上历史原因是中断先出现,后来发现进程也是需要类似的操作,于是使用纯软件仿照了中断这一硬件+软件的操作。
OS如何正常运行:
首先我们要明确一个事实:OS是第一个被加载的软件,从电脑开机到关机,他一直运行,本质上是一个死循环。
既然他是一个死循环,那么是如何进行驱动的呢?
我们在键盘那里提到会有一个中断向量表,同时外部硬件可以发送中断给CPU,进而会使用这个中断号去索引中断向量表去执行对应的方法。
同样的,我们也有一个时钟,时钟可能会每隔10纳秒发送一个中断去执行调度方法
调度方法会检测时间片是否到达,未到达就结束,到达就执行切换进程。
所以进程被OS进行驱动,OS被硬件进行驱动!
结论:OS本质是死循环+时钟中断
如何执行系统调用:
系统调用首先肯定与我们库函数和自定义函数有很大的不同,就凭他是在地址空间的[3,4]GB,那我们是怎样去执行系统调用呢?
肯定不是使用函数地址直接访问,这样就不能限制用户访问[3,4]GB了。
所以我们有一张表:函数指针数组表。
下图正是源代码中的表示,我们也可以看到我们使用的fork在用户层叫做fork(),在内核其实还有一个sys_前缀。
所以我们直接通过特定数组下标即可调用对应系统调用,这个下标叫做系统调用号
。
那我们怎么调用呢?(问题一)我们系统调用号怎么来?(问题二)
我们在中断向量表中,还有一个方法是 执行任意系统调用(问题一:通过中断向量表进行调用),
那我们中断向量表的下标呢,系统调用号呢?
在CPU中我们需要使用两个寄存器,一个叫做eax,另一个我们暂命名为x(问题二)。
当我们使用fork时
pid_t fork()
{
mov 2 eax // 将系统调用号放入eax中,我们的系统调用号从fork函数中来
int 0x80
}
我们说过CPU可以由外部硬件产生中断,外部产生叫做外部中断,其实CPU内部也可以自己产生的!叫做陷阱或缺陷,就是我们上面代码中的int 0x80
,也就是放入寄存器x中的值,我们暂且可以将他理解为中断向量表中的下标,
于是CPU就可以去中断向量表中索引执行系统调用的方法,在拿eax中的系统中断号去索引系统调用表中的下标即可!
注意:不论是内部中断还是外部中断都会陷入内核
回到主线,相信细心的小伙伴已经发现了一个不对劲的地方,我们在执行系统调用中说,不能直接以函数地址去访问对应系统调用方法,是为了限制用户,可是我们的流程中并没有提到如何限制啊。
由此我们引出两个问题
问题一:OS是如何进行限制我们的?
问题二:我们是如何还是调用到的?
说在前边,这是个很复杂的过程,但只会说其中一部分。
我们需要硬件进行配合,这个硬件叫做cs
(code segment)寄存器,我们的寄存器有整体使用的,就比如eax,也有按位使用的,比如eflag,我们的cs也是按位使用,他的作用是指向特定代码的起始位置与结束位置,标识一个范围。
只有为指定的状态才可以执行对应的代码,所以我们的主线任务内核态与用户态其实就由这两个数字表示!
那么是什么时候进行修改呢?
我们可以认为在执行
int 0x80之前会有一个动作是进行状态转换的。这个动作是我们用户无法操作的!
结论:用户只能访问[0,3]GB的空间,当执行系统调用时,系统调用内部会进行状态的转换,同时提供了系统调用号,然后int 0x80产生陷阱,执行对应的中断向量表,进而由系统调用号去索引系统调用方法,与PC指针相互配合,最终返回。
硬件上他的表示是由CPU的状态标志位决定的!
回过头来看
当我们由第一步到第二步,先在eax中存入对应的系统中断号,执行int 0x80陷入内核,根据PC指针的指向跳入内核执行系统调用,在做信号检测。
信号的处理:
sigaction:
我们不仅仅signal可以进行捕捉,还有一个sigaction也可以进行捕捉!
他的功能实际上与signal是很相似的!
signum与signal中的signum含义一致。
对于这个结构体我们来看看:
用黑线划掉的是与实时信号相关的,我们不用管,其中flags设为0就好。
其中最主要的是要了解sa_mask,但先把sigaction测试完再来。
sa_handler就是我们的自定义函数。
act是一个输入型传输,而oact是输出型参数,修改之前先把当前的结构体拷贝给oact。
测试代码:
现象:
信号的特点:
那么说到sa_mask就不得不说信号的特点了。
直接说结论:
当信号在被处理,默认被处理信号被屏蔽;
当此信号被处理完成,屏蔽自动解除。
代码验证:
现象:
果然如我们所料,是被屏蔽了。
但是其他信号仍旧不会被屏蔽。
可以看到发送3号新号时进程终止。
所以当你的2号屏蔽被屏蔽时,同时也想3号被屏蔽,在sa_mask内进行设置即可~
想屏蔽几个就设置几个。
代码:
现象:果然2与3号新号都被屏蔽~
那么我们可以把所有的信号都addset到mask中吗?
操作上可以,但是最终结果是无法屏蔽一些信号的。
否则你不就成了金刚不坏的进程了吗?
例如9号。
基于信号的理解讨论3个子问题:
可重入函数:
听着是一个很高大上的函数。
具体是怎么一回事呢?
我们以上图的链表头插举例,可以看到头插是分为两步代码的。
当我们执行到第二句时突然来了一个信号,去执行自定义函数.
等等,信号的捕捉去执行自定义函数不是需要从内核态转移到用户态吗,可是我们一直处于用户态,都未曾进入过啊。
其实不然,我们提到过有一个外设时钟,他会每隔几纳秒就发一次中断,进而执行对应的调度方法,但是我们的程序并不知道什么时候会产生这个中断,所以在程序的任意一行都有可能产生,当外部中断产生就会陷入内核,执行完自己的方法后检测信号,进行信号处理,所以结论就是在程序的任意一行都可能会发生从内核态转移到用户态进而递达。
可是这样就导致了我们上图中节点的丢失,造成内存泄露。
我们将这种现象称为insert函数被重入了,叫做不可重入函数,我们学的大部分函数都是不可冲入函数,基本上涉及到全局的数据结构之类的一般都属于不可重入函数。
volatile:
这是C语言中一个冷门的关键字,在只有C语言的基础上比较难以理解这个关键字,但是信号可以比较好的帮助我们进行理解。
先来看这样一段代码:
现象:
这些对于我们都是很容易理解的代码。
但是编译器是会优化的!
我们先看未优化的,每次执行while(!gflag)都会执行一下逻辑
编译器发现在main函数中没有任何代码对gflag做修改,所以可能会对程序进行优化。
在寄存器中的值一直是第一次从内存中获取的值,不在每次都去内存中获取。
那我们如何验证?
g++进行编译时是有等级划分的。
默认的优化等级是-O 0,也就是没有优化。
那我们进行优化一下。
可以看到仅仅只是提升到了O1就不可以了。
这种现象叫做寄存器隐藏了内存的真实值,我们的代码将gflag修改了吗,在内存中修改了,但是寄存器不从内存中获取了。
如何进行解决?
那就是volatile关键字啦~
声明时加上
现象:变正常~
SIGCHLD:
我们回顾一下进程退出的问题。
在以前我们的父进程都会对子进程进行等待,否则就僵尸进而造成内存泄漏。
这是不是就代表我们的进程是悄悄的退出的啊?
毕竟如果不是悄悄的退出,那我们的父进程为何还要进行阻塞或者非阻塞轮询呢?
但实际上并不是,我们的子进程退出并不是悄悄的退出,会给父进程发一个SIGCHLD的信号。只不过这个信号的默认处理是忽略罢了。
现象:父进程果然收到了SIGCHLD信号。
所以以后我们就可以解放父进程了,在handler中进行等待即可(将等待的子进程pid设置为-1即可),顺便取消了强耦合。
代码:增加了一个fatherRun,同时在handler进行等待。
现象:
那既然如此,我们就对这个程序找找茬
如果我们有10个进程同时要进行退出,我们的程序可能会同时收到10个SIGCHLD信号,但是pending位图只能记录一个,那我们怎么进行等待?
将handler中的等待设置为死循环即可。
我们进行程序验证:
现象:一共等待了10次,并且都成功了,最后父进程仍然doOtherThing。
那么如果我们有10个进程,5个不退出呢?
将handler中的wait进行一下修改,需要将阻塞改为WNOHANG即可,这样就不会阻塞了。
此时还有最后一个知识点:
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
我们进行等待的目的是为了
释放空间和获得退出信息。
当你不需要获得退出信息时就可以采取这种方法。
但是还有一个疑问,SIGCHLD的默认处理动作已经是忽略了啊,再次设置为忽略为什么就可以等待了呢?
是因为这两个忽略不一样,一个是用户级一个是内核级!