1. 快速认识信号
1.1 生活角度的信号
你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是⼀定要立即执行,可以理解成“在合适的时候去取”。
在收到通知,再到你拿到快递期间,是有⼀个时间窗⼝的,在这段时间,你并没有拿到快递,但是你知道有⼀个快递已经来了。本质上是你“记住了有⼀个快递要去取”
当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递⼀般方式有三种:1.执行默认动作(幸福的打开快递,使用商品)2.执行自定义动作(快递是零食,你要送给你的⼥朋友)3.忽略快递(快递拿上来之后,扔掉床头,继续开⼀把游戏)
快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
基本结论:
- 你怎么能识别信号呢? 识别信号是内置的,进程必须能够识别+处理信号 ,进程识别信号,是内核程序员写的内置特性。
- 信号产生之后,你知道怎么处理呢? 知道。如果信号没有产生,你知道专门处理信号吗?
- 知道。所以,信号的处理方法,在信号产生之前,就已经准备好了。
- 处理信号,是立即处理吗? 我可能正在做优先级更高的事情,不会立即处理? 什么时候? 合适的时候
- 信号到来之后,如果没有处理,进程会记录下来对应的信号,等合适的时候再进行处理——进程一定会有时间窗口
- 怎么进行信号处理? a.默认行为 b. 忽略信号 c. 自定义行为 —— 信号捕捉
1.2 技术应用角度的信号
1.2.1 前台进程
Ctrl+C,我们都认识,也都知道这个组合键能够终止进程。那么它是怎么做到的呢?
我们下面写一个死循环程序,测试下
编译运行,程序就会每隔一秒向显示器打印 I am a process, 在程序打印的过程中,不管我们输入什么指令,都是无效的,也就是,一旦 ./ 启动程序了,bash就开始执行程序了, 不再接收任何指令了,只有当我们在键盘上按下Ctrl + C,程序才会结束。 我们把这种进程称之为前台进程。
Linux,一次登录,会有一个终端,一般会配上一个bash,每一次登录,只允许一个进程是前台进程,可以运行多个进程是后台进程。
下图,这个场景相当于我们打开了两个终端,有而就是登录了两次
我们用的云服务器,可以理解为把bash放在前台,运行它接收指令。
通过刚刚我们的测试不难发现,刚才本质上 原本bash是前台进程,因为我们执行程序process1,所以前台进程转变为process1,此时键盘输入的数据就由process1程序来接收了,所以此时输入指令什么的也不会执行,我们也就得出来以下结论
- 同一时刻内前台进程只有一个
- 键盘上输入的数据是由前台进程接收的
可为什么前台进程可以接收到ctrl+c ,并会对此做出结束进程操作呢?
在上一节中,我们说过,进程要想执行信号,首先便是要识别信号,所以识别信号是由内核程序员内置在其中的,所以答案也就呼之欲出了—— Ctrl + C会发生一种结束前台进程的信号!!!
进程会识别到这种信号,并且执行这个信号,也就是结束前台进程(注意这里说的是前台进程)
也就是完整的动作过程是,当我们登陆Linux终端之后,会得到一个bash的前台进程(需要注意的是bash不会受到Ctrl+c的影响,即bash不会被结束),之后我们使用bash执行我们的自定义程序,此时bash将前台程序转变为我们的自定义程序,之后我们在键盘上输入由我们的自定义程序接收,此时我们按下Ctrl+c,此时会发送一个信号,该信号的作用是结束当前前台进程,所以我们的自定义程序进程便会结束
1.2.2 硬件上的信号处理
那么这里还有两个疑问。
1. 我们在键盘上摁下Ctrl+c,发送信号的东西是如何知道的?
2. 发送信号的东西是什么?
首先,进程本质上就是软件层的产物,而记不记得我们以前说过有一个东西是管理它们的,那就是操作系统(OS),OS即管理硬件也管理软件,所以它会知道我们什么时候摁下的Ctrl+c,之后发送信号来达到管理进程的目的
那OS是怎么知道键盘中有数据的? 其实CPU周围有很多的针脚,键盘会通过中断元件 ,以硬件中断的形式,告诉CPU键盘有数据了。可以引起硬件中断的设备不止一个,这么多硬件中断,CPU怎么区分哪个硬件中断是哪个设备引起的呢? 于是,就有了中断号的概念,中断号和数字1,2,3类似。
接下来就又衍生出来一个问题:
- 键盘是如何把数据给操作系统的?
当键盘上有数据了,就会引起硬件中断请求(计算机中,硬件之间常常通过中断来进行通信),通知CPU。操作系统根据CPU中记录的数字,索引到中断向量表中对应的位置,执行访问外设的方法。在对键盘中的数据操作之前,操作系统会先判断,键盘输入的是信号还是纯数据。如果说是给进程的数据,那么数据会先被拷贝到OS提供的文件缓冲区,再通过scanf,read读到进程中
中断向量表是在操作系统中比较靠前的位置,在开机启动的时候,自动形成的。中断向量表中都是方法的地址,方法主要是直接访问外设的方法。
CPU中的寄存器也是具有存储能力的,它凭什么能存储数据呢?
可以简单理解为32个bit,每个bit位对应一个硬件单元,把其中一个硬件单元充为高电位,这里低电位表示0,高电位表示1.比如 0000 0000 0000 0001 0000 0000 0000 0000 。就这样CPU的寄存器就有了数据
CPU和键盘的交流,只局限于控制层面,数据层面它们没有交互。如果CPU过多参与IO,效率会变低,计算机中有一个专门负责IO的芯片,叫DMA。
那为什么Ctrl+C能杀掉前台进程呢?
1. 键盘的输入首先会被前台进程接收到。
2. Ctrl+C 本质是被进程解释成为 收到了2号信号,而2号信号的默认动作,就是结束本身进程
注: Ctrl+\ 是3号信号,默认也是终止当前进程
1.2.3 后台进程
与前台进程相对应的便是后台进程,将程序以后台进程的方式运行的方式便是在运行的程序名后面加上&
这样我们的process1也就以后台进程的形式运行了起来,由我们看到一样,在后台进程运行下,前台进程依旧是bash,我们从键盘上输入的数据由bash进程接收,所以此时,我们输入的指令依旧可以运行,此时摁下Ctrl+c,后台进程也结束不了了
那么我们该怎么结束后台进程呢? 这里可以使用一个指令 kill -9 [进程PID]
当前后台进程的PID,在它运行的时候,系统就给出了
那么为什么显示器看着是乱的呢? 这是因为操作系统把键盘文件中的数据拷贝到显示器文件缓存区时,后台进程process1也在向显示器文件缓冲区写入数据。这两者都在向显示器文件缓冲区写入,可显示器文件缓存区并没有加保护。所以显示会数据混乱。
那么实际上到底有没有乱呢? 通过我们的执行结果我们也知道了实际上是没有乱的,那么为什么呢? 实际上键盘和显示器都有自己的文件缓存区,我们键盘输入的数据直接到键盘的文件缓存区的,然后,再拷贝到显示器的文件缓存区,进行显示。这是数据回显的过程。向显示器文件缓冲区写数据的人,又不止键盘一个,所以数据乱很正常,但那和键盘是没有关系的,键盘有自己的文件缓冲区,并且我们在之前的学习中也知道,进程的stdin也就是fd为0的文件描述符指向的是键盘,所以进程读取数据读取的也是键盘的文件缓存区,和显示器的缓冲区毫无关系
2.信号概念
2.1 如何查看信号及其含义
那么,接下来我们来讲解下结束后台进程的指令 kill -9 [进程PID]
kill的格式是 kill [选项][进程PID] 作用是向指定进程发送信号,以达成控制进程的目的
kill -9 [进程PID] 本质上就是向指定进程发送9号信号,那么我们如何知道9号信号对应的含义是什么呢?
首先,我们先使用kill -l 查看所有的信号
信号是模拟硬件终端的纯软件行为,属于软中断,其本质就是数字,数字旁边的大写英文是宏。信号中没有0,32,33号,一共62个。32号之前的信号,我们称之为普通信号,34~64是实时信号。实时信号就是收到信号后,需要马上处理的。
这里,kill 的默认信号是15号 SIGTERM
之后,我们可以通过 man 7 signal 来查看每个信号所对应含义
从图中,我们便可以看到9号信号所对应的SIGKILL所对应的功能是 kill signal,Ctrl+c和Ctrl+\所对应的2号和3号对应的是 interrupt/quit from keyboard 也就是从键盘上中断/退出。
这里我们也注意到虽然2号信号和3号信号作用都是退出进程,但是它们的Action却是不同的,那么那个Action的参数所代表的含义是什么呢?
Action 其实指的是进程接收到信号后默认执行的操作,参数主要分为5个,分别是
- Term(Terminate) 表示进程接收到该信号后会默认终止。不过这种终止时相对 “优雅”的,进程有机会在终止前执行一些清理操作,例如关闭打开的文件描述符,释放动态分配的内存等等。像 STGTERM 和 SIGINIT(Ctrl+C) 的默认动作就是Term
- Core(Core Dump and Terminate) 意味着进程在接收到信号后会终止,并且会生成一个核心转储文件(core dump file)。 核心转储文件记录了进程在终止时刻的内存状态,开发人员可以使用调试工具(如gdb)来分析这个文件,从而找出程序崩溃的原因。 SIGNQUIT(Ctrl +\)、SIGABRT(通常由abort()函数触发)、SIGSEGV(段错误信号)等信号的默认动作就是Core
- Ign(Ignore) 表示进程会忽略接收到的信号,即信号对进程没有任何影响,进程会继续正常执行。SIGCHLD信号(子进程状态改变时发送给父进程的信号)的默认动作通常是Ign,这意味着父进程默认会忽略子进程状态的改变。
- Stop(Stop Execution) 进程收到该信号后会暂停执行,进入停止状态。此时进程不会占用CPU资源,知道接收到 SIGCONT 信号才会恢复执行。 SIGSTOP(不可被忽略或捕获的停止信号)和 SIGTSTP( Ctrl +Z ) 的默认动作就是Stop
- Cont(Continue Execution) 用于恢复被暂停的进程,让其继续执行。 SIGCONT 信号的默认动作就是Cont。 暂停的程序可以通过fg命令(发送SIGCONT)让程序恢复到前台继续执行,或者使用bg命令让程序在后台继续执行。
OS在收到信号之后,通常会有三种处理方式:
一是默认动作,也就是系统本身提供的处理方式。比如说2信号,系统的默认动作是终止进程
二是忽略,也就是我们收到信号后,什么也不要做
三是自定义动作,我们又把自定义动作称之为信号捕捉。自定义动作,就是我们自己设置收到信号,进程需要进行的动作
图中,就是系统自己所定义的行为宏,可以直接使用
那么,我们应该如何进行信号捕捉呢?
2.2 信号捕捉,signal
这里介绍一个新的函数signal。 它的第一个参数是传信号,可以直接传数字,也可以直接传信号对应的宏。第二个参数是函数指针。也就是当进程收到对应的信号后,需要执行的函数
下面,我们来验证下Ctrl+C 给进程发送的信号是2号信号
我们编译运行, Ctrl+C,我们发现程序确实收到了2号信号,可为什么没有终止呢? 这是因为自定义动作和默认动作是并列的,有了自定义动作了,默认动作就失效了。而我们定义的自定义动作,并没有让程序终止。
如果我们想让程序进行退出,可以加上exit函数的使用
这样程序在收到2号信号之后,就会退出了
需要注意的是,signal函数仅仅是设置了特定信号的捕捉行为处理方式,并不是直接调用处理动作,如果后续特定的信号没有产生,设置的捕捉函数永远也不会被调用!!
并且signal函数只需要设置一次,设置之后,就是有效的了,那它什么时候起作用呢?在收到信号的时候,并不是你设置它,它就生效了。
那这里思考下,为什么handler函数要有一个信号参数呢? 收到信号之后,执行handler不就可以了吗? 这里可以设想下,如果我们设置的handler有多个接收到的信号,它们都调用handler这个函数,是不是我们就可以利用signo这个参数,去确定我们具体收到的是哪个信号。
信号具体什么时候产生,我们也不知道,它是进程之间时间的一部通知的一种方式。
什么是异步? 举个例子:小张课上睡着了,老师讲课感觉嗓子有点干,就说小张啊,你去楼下小卖铺帮我买瓶水,而在小张出去买水的这段时间,老师让大家自习等小张回来,这叫同步,如果说在小张出去的时间里,老师接着继续上课,那这叫异步
2.2.1 软件上的信号处理
我们在上面谈过了硬件上的信号处理过程及其细节,现在我们再谈一下软件上的信号处理
在OS将信号发送给进程之后,进程会通过用户要求,看是忽略信号,还是默认处理,还是自定义处理,那么这里就有两个问题了
- 我们的自定义程序是我们自己写的,那进程的默认处理是怎么调用的?
- 进程如果要调用的话,进程一定是要记录下来是什么信号,那他是如何做到的呢?
我们先谈第二个问题,其实在目标进程的PCB中会维护一个变量signal,用于记录收到了什么信号,不过,这个变量实际上是一个32为的信号位图,这个信号位图就是用来表示该进程的信号接收情况,信号位图的每一个比特位的位置就是信号的编号,比特位是否为1,表示是否收到对应的信号,所以发送信号的本质实际就是写入信号!
普通信号有31个,没有0好信号,刚好与32个bit位对应
OS修改目标进程的PCB中的信号位图将0改为1
这里需要注意,OS是进程(task_struct)的管理者且是唯一管理者,所以无论以什么方式发送信号(后面会说多种发送信号的方式),最终都是转换到OS,让OS写入信号的!!
第一个问题,同上面一样在目标进程的PCB中也会维护一个函数指针数组,而这个函数指针数组的类型就是 sighandler_t 的,而这个类型不就是我们上面用signal捕捉信号之后,所执行的自定义函数类型吗!
这个函数指针数组的每一个下标都会指向一个handler方法,代表的就是下标+1所对应的信号默认执行方法!
所以,我们使用signal捕捉信号在执行自定义函数,实际上就是修改所对应信号下标的函数指针,将其指向了我们的自定义函数!
2.2.2 硬件中断
我们经常说信号是用软件模拟的硬件中断,硬件之间的通信也是通过硬件中断来完成的,那么硬件中断到底是什么呢?
硬件中断是由硬件设备产生的中断信号,用于向CPU发出请求,告知其有特定事件发生,需要处理器进行处理,目的是为了协调硬件设备与CPU之间的通信和交互,实现了硬件之间的异步机制
这里我们想一个场景,键盘要向进程发送数据,而OS作为进程的管理者,一定是由OS来完成这一项操作的,但OS不知道键盘什么时候会发送数据,OS也不可能一直等着键盘,自己不忙活自己的事了,所以这就需要一种机制,来使得键盘能够主动告诉OS,自己可以发送数据了,这种机制便是硬件中断
具体工作过程就是,当键盘准备好了,之后会给CPU发送一个硬件中断,CPU收到中断之后,会告诉OS,有设备准备好了,这时OS看时机,再过来将键盘上的数据拷贝到内存中
这也就使得硬件和OS可以并行执行了!
信号和硬件中断的区别就是
- 信号是纯软件的,模拟中断的行为,而硬件中断是纯硬件的
- 信号是发给进程的,而硬件中断是发给CPU的
- 两者有相似性,但是层级不同,这点我们后面的感觉会更加明显
3 信号的产生
信号的产生有五种方式:
1. 键盘组合键 Ctrl +C (2号信号) 、Ctrl + Z (20号信号)、Ctrl + \(3号信号)
2. kill 指令 kill [-信号编号或信号名称] <进程PID>
3. 系统调用
系统调用发送信号 本质上都是操作做系统!
3.1 kill函数
参数如图很简单,第一个参数进程PID,第二个参数信号编号
3.1.1 kill 函数测试捕捉所有信号
我们可以使用kill函数配合getpid函数给自己发送信号
这里我们可以通过kill函数实验一个东西,上面我们说了信号是可以被捕捉的,并且也可以被替换执行方式,那么这里思考一个问题
所有的信号产生都可以被抓捕吗? 我们可以通过kill函数来测试一下。
测试运行之后,我们发现进程在收到9号信号时,并没有执行我们的自定义函数
在将signal捕捉到信号后的行为修改为SIG_IGN之后,我们再次测试,
我们发现,9号信号也是无法忽略了,那么我们在发送信号上做下改变,看下除了9号信号外,还有哪些信号具有这种特性
再次编译运行之后,我们发现19号信号也是具有这种特性的,那么我们也忽略19号信号,再看看
这次程序运行结束了,最后我们总结下 在前31个信号中,9号和19号信号是不能被抓捕修改的。
其实这也情有可原, 为什么? 首先,9号,如果说允许所有的信号被捕捉,那么所有进程都杀不掉了。万一有一天来了个恶意软件,捕捉了所有的信号,那还得了了。
3.1.2 通过kill函数模拟kill指令
mykill.cc
在用之前写的死循环代码模拟
编译运行,就可以完成杀进程的任务了
3.2 raise函数
raise函数的使用方式就更加简单了,它默认是给调用它的进程发送指定信号。也就是谁调用它,他就立马给调用它的进程发送一个指定信号。
编译后运行,进程调用raise(2),之后就收到了2号信号
3.3 abort函数
调用abort函数会给调用它的进程发送6号信号
而6号信号SIGABRT的作用时请求程序异常终断,行为上是CORE类型的,所以结束后会生成一个core dump文件,可以查看错误
所以,abort函数的用途就是当程序检测到严重错误,无法继续正常运行时去调用该函数
编译运行,之后我们发现进程确实收到了6号信号,core dump文件也确实生成了
4. 软件条件
在上面信号编号图中,我们注意到了SIGPIPE信号,该信号对应的编号是13,它主要用于管道通信,在进程间通信中,我们讲到过,当一个进程尝试向一个关闭管道读取端的进程发送数据时,操作系统就会杀死正在写入的进程,那么操作系统是怎么杀死的呢?
注:下面来源于『Linux』 第八章 进程间通信-CSDN博客
就是通过13号信号来杀死该进程的,而实际上该信号的默认动作就是终止进程
SIGPIPE 就是一种由软件条件产生的信号,那么接下来我们再介绍一种信号SIGALRM
这个信号是由alarm系统调用而产生的,也就是基于软件条件产生的信号
如上图,alarm的参数是秒数,给它一个秒数,也就是告诉内核再 seconds 秒之后给当前进程发SIGALRM信号,该信号的默认处理动作是终止当前进程。
如果之前没有设置过闹钟,或者之前设置的闹钟已经超时了,那么函数的返回值是0
如果之前设置的闹钟还没超时,那么alarm函数会返回之前设置的闹钟时间还余下的秒数同时会用新的seconds 值重新设置闹钟。打个比方,有个人要小睡一会,设置闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设置闹钟为15分钟之后响,此时,“以前设置的闹钟时间还余下的时间”就是10分钟。如果seconds 值为0,就表示取消以前设定闹钟,函数的返回值仍然是以前设置的闹钟时间还余下的秒数。
4.1 基于alarm验证-体会IO效率问题
我们之前经常说IO系统调用也是很影响效率的,那么这里我们学习了alarm函数,这里我们可以使用alarm函数来做一个实验,直观观察下IO系统调用的消耗
程序作用是1秒钟之内不停地数数,1秒到了就被SIGALRM信号终止
上面的版本是加入了IO操作,编译运行下,我们看下效果
这是最后的运行结果,接下来我们修改下代码,将其IO操作减少
编译运行
通过这两次运行我们可以很直观的看到,IO系统调用对于程序运行效率的影响
4.2 设置重复闹钟
首先我们知道闹钟每次设置之后,只会响一次,那么我们如果想要闹钟响多次怎么办?也就是达到类似于每个5秒响一次的效果
这里就可以设置重复闹钟,所谓重复闹钟就是当一个闹钟结束之后,立马设置一个闹钟,达到重复设置的效果
编译运行
我们可以看到程序每隔5秒闹钟都会响一次,这样也就达成了重复设置闹钟的需求了
4.3 用重复闹钟模拟定时清理工具
我们既然学习了重复设置闹钟,其实很多定时清理工具的底层逻辑就是反复设置重复闹钟,正常情况下都是挂着不占用CPU资源,等闹铃返回信号后,才苏醒一次执行清理操作,执行结束之后,在设置闹钟之后再挂着,接下来我们就来模拟一个来看看吧
这里先介绍一个函数pause
功能就是会让调用它的进程挂起(进入睡眠状态),放弃CPU资源,直到进程接收到一个信号。当进程接收到信号后,会根据信号的处理方式进行相应的操作:
- 如果信号的处理方式是默认处理且该默认处理会导致进程终止,那么进程终止。
- 如果信号的处理方式是忽略,那么pause函数会继续等待下一个信号
- 如果信号有自定义的处理函数,那么会先执行该处理函数,处理函数执行完毕后,pause函数会返回-1,并且将errno设置为 EINTR(表示被信号中断)。
接下来,我们测试下alarm(0) 也就是取消之前的闹钟
编译运行之后,我们摁下Ctrl+C,闹钟就被取消了
4.4 如何理解软件条件
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间达到)、软件异常(如向已关闭的管道写数据产生的SIGPIPE信号)等。当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理。简言之,软件条件是因操作系统内部或外部软件操作而触发的信号产生的。
4.5 如何快速理解系统闹钟
系统闹钟,其实本质是OS必须自身具有定时功能,并能让用户设置这种定时功能,才可能实现闹钟这样的技术。
我们设置一个闹钟,在操作系统层面实际上就是设置了一个定时器
现代的Linux是提供了定时功能的,那么大量的定时器也是需要被管理的:先描述,在组织。
内核中的定时器数据结构是这样的:
struct timer_list
{struct list_head entry;unsigned long expires;void (*function)(unsigned long);unsigned long data;struct tvec_t_base_s *base;
};
5. 硬件异常
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核(OS)向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU(内存管理单元,用于实现虚拟地址到物理地址的转换)会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
5.1 模拟除0,野指针及其CPU、OS关联原理
编译运行,我们发现,我们将8号信号捕捉之后,OS发现CPU错误后并不是只发送1次信号,而是一直在发送信号,同样的方式还有野指针的情况,我们也试一下
我们发现也是这样的,只有当OS一直检测到错误发生时,OS才会一直发送信号,那么既然OS能一直检测到错误,说明CPU中的错误一定是被保存下来了,那么CPU中的错误是如何保存下来的呢?
实际上C/C++中,常见的异常,进程崩溃了,OS给目标进程发送对应错误的信号,进而导致进程退出
CPU中由很多寄存器,有一个叫eip/pc,用来记录程序执行到哪了。CPU还集成一个寄存器,叫状态寄存器,状态寄存器本质是一个位图,对应这一些状态标记位、溢出标记位,但实际其中每一位bit表示说明含义,由芯片制造商来决定。我们可以通过制造商所提供的相应的手册,比如Intel CPU开发手册。
状态寄存器中有一个bit位,叫溢出标志位,当程序中数据出现溢出的时候,这个标志位就被置为1,否则就保持为0.
而CPU当前所有寄存器中的数据就是目前进程的上下文,也就是一个进程在CPU上执行时所需要的所有信息。
代码/0问题,会被转化为硬件问题,也就是转换成硬件上的数据。进而被OS识别到,做处理。问题发生到处理的整个过程,并不会影响OS,只会影响当前的进程。因为这些对应的问题实际上都是CPU寄存器中的进程上下文。
因为进程的存在以及所有用户的任务都是使用的进程执行,所以,任何异常都只会影响进程本身,并不会波及到操作系统。
野指针异常的问题。 刚开始CPU并不知道一个指针变量是否是野指针,它只会拿着指针变量的地址去查页表,进行虚拟到物理地址转换,然后,到页表中一查表就权限拦截了,就转换失败了。
页表转换失败的虚拟地址,会被填写到CPU中的寄存器中,CPU就报错了,OS在CPU硬件内部检测到报错后就会给目标进程发送信号,想要杀死对方
上面OS一直给进程发送信号的原因是,div/0,野指针发生异常后,我们并没有清理内存,关闭进程打开的文件,切换进程等擦欧总,所以CPU中还保留上下文数据以及寄存器内容,异常会一直存在,所以OS唯一的解决掉的方法就是杀死我们的进程,但是我们这把OS所发送的信号给捕捉了,所以问题一直存在,OS一直检测到错误,所以也就一直发送信号了
那么异常时无法在程序进行修复的,那为什么我们还要进行异常信号捕捉呢? 捕捉信号的目的是我们需要知道是什么原因引起的异常,这样才能进行修改。
异常处理的本质一定和我们说的信号有关系,但引起异常的方式不全部和信号有关系。引起异常的方式可能是硬件,也可能是软件。比如说管道SIGPIPE。
进程在运行中,会出现各种各样的错误,各种错误属于什么类别,根据信号的类型区分。但有些错误是以异常的方式呈现的,有些错误是以函数返回值方式呈现的。比如说read函数,当读取失败时,程序并不会崩溃,而是返回值为-1来标记失败
5.2 子进程退出core dump
还记得我们之前在进程等待中,说的core dump标志位吗?
我们在上面2.1中提到过信号是有动作的,对应这里如果进程收到的信号是Core类型的,Core dump标志位会被置为1。如果进程收到的信号是Term类型的,core dump 标志位就不会被设置,默认为0。
下面我们来编写一个程序来验证一下
我们知道3号信号的信号动作是Core类型的,所以这里进程退出后core dumped 置为1了,而2号信号的信号动作是Term类型的,所以这里core dumped 还是0
5.3 Core dump
首先我们解释下什么是Core dump, 当一个进程要异常终止时,可以选择把当前进程的用户空间内存数据全部保存到磁盘上,文件名通常是Core,这叫做Core dump(核心转储)
进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。
· 一个进程允许产生多大的core文件取绝于进程的 Resoure Limit (这个信息保存在PCB中)。
那么,为什么我们上面运行之后,并没有产生core文件呢? 这是因为我们采用的云服务器,默认都是关闭掉这项服务,因为core文件中可以包含用户密码等敏感信息,不安全,我们可以使用 ulimit -a 指令来查看操作系统的标准配置
core file size 大小为0,所以才不会生成core文件,我们可以通过下面的指令,将core file size 设置为10240
ulimit -c 10240
之后,我们再次运行程序,发送3号信号,与此同时,当前目录下,还会多出一个core.pid的文件。这是什么原因呢? 这是因为一旦打开操作系统的core dump功能,OS会将进程在内存中的运行信息,给dump(转储)到进程的当前目录(磁盘)形成core.pid 文件。我们把这个功能称之为:核心转储(core dump)
核心转储,会记录下是哪一行代码,导致程序运行时错误,那么我们应该怎么去使用这个core.pid文件呢? 我们先使用gdb打开程序
需要注意的是程序要以debug方式编译,所以我们将make中的内容做一下改变
-g 表示的就是生成调试信息,这些信息会被存储在可执行文件中,方便调试器(如 GDB)使用。
然后,使用core-file直接导入core.pid文件即可查看出错位置。
到这里,我们也就证实了core.pid文件是可以直接复现问题,直接定位出错位置。 core-file 也就是事后调试
那么 core这个功能不是挺好的吗,为什么云服务器默认要把这个功能关掉?
我们目前得出的结论只是针对我们个人使用而言,假设公司的服务器挂掉,首先要做的是什么?首先要做的就是让服务器恢复服务。这个bug比较特别,服务器一重启就又挂掉了。可服务器内置的自动修复程序不知道,一直重启,又一直挂掉。每一次挂掉都会生成一个core.pid文件,服务器本身的问题没解决,又生成一堆的core.pid文件。这不是雪上加霜吗?
Core 其实就相当于Term + core dump
总结思考⼀下
- 上面所说的所有信号产生,最终都要有OS来执行,为什么? OS是进程的管理者
- 信号的处理是否是立即处理的? 在合适的时候
- 信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来? 记录在哪里最合适呢?
- 一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
- 如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
4.信号的保存
当前阶段
4.1 信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到抵达之间的状态,称为信号未决(Pending)
- 在信号抵达之前,信号没有被处理,会被保存在PCB中
- 进程可以选择阻塞(Block)某个信号
- 阻塞特定信号(屏蔽信号),信号产生了,一定要把信号要进行pending(保存),永远不抵达,除非我们解除阻塞
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行抵达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会抵达,而忽略是在抵达之后可选的一种处理动作。
4.2 信号在内核中的表示
在Task_struct 中存在两个变量以及一个函数指针数组,两个变量本质上就是一个32位的位图,每一位代表一个信号,函数指针数组则有32个,1~31每一个下标对应一种信号
信号在内核中的表示示意图
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号抵达才清楚该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它抵达时执行默认处理动作
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作时用户自定义函数sighandler。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理呢?
POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的: 进程在收到常规信号,使用位图pending来保存的,而pending表只有一张,这意味着如果进程在递达之前产生多次,那么只计为一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
信号相关的接口都是围绕,block、pending,handler这三张表来展开的,它们属于内核数据结构,作为用户无权访问。我们需要通过相关的接口获取。获取位图的数据,需要进行数据的拷贝,拷贝需要设计输入输出型参数。参数的类型是sigset_t。
4.3 sigset_t
从上图来看,每个信号都有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 “有效”或 “无效” 状态。
在阻塞信号集(block)中 “有效”或 “无效”的含义是该信号是否被阻塞,而在未决信号集(pending)中 “有效”或 “无效”的含义是该信号是否处于未决状态。
阻塞信号集也叫当前进程的信号屏蔽字(Signal Mask),这里的 “屏蔽” 应该理解为阻塞而不是忽略。
4.4 信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或 “无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_set变量是没有意义的。
- sigemptyset 函数,初始化set所指向的信号集,将其中所有信号的对应bit置为0,表示该信号集不包含任何有效信号。
- sigfillset函数,初始化set所指向的信号集,将其中所有信号对应的bit置为1,表示该信号集的有效性好包括系统支持的所有信号。
- sigaddset函数,用于增加某个信号,把信号对应的位置置为1。
- sigdelset函数,用于删除某个信号,把信号对应的位置置为0.
- sigismember函数,用于确定某个信号对应的位置是否为1。
这四个函数都是成功返回0,出错返回-1。
4.4.1 sigprocmask
sigprocmask 函数可以读取或者修改进程的信号屏蔽字(阻塞信号集)。这里的屏蔽和阻塞是一个意思,实际上就是用来修改或读取内核的block表
返回值:若成功则为0,若出错则为-1。
sigprocmask函数的第一个参数是选项,选项共有三个。一个是SIG_BLOCK,用于把set中的屏蔽字增加到当前进程中,一个是SIG_UNBLOCK,用于解除set中包含的屏蔽子。一个是SIG_SETMASK,以覆盖的方式,把set中内容,写入当前进程中的block表
第二个参数是一个输入型参数,里面是我们想要操作的信号屏蔽集
第三个参数是一个输出型参数,用于存放修改之前的block表,将其输出出来,也就是对操作前的block做了备份,用于恢复。
获得当前进程的阻塞信号集:sigprocmask(0,nullptr,&oldset)
4.4.2 sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功返回0,出错则返回-1。
理论学习完了,接下来,我们来做几个实验
我们把2号信号屏蔽后,给进程发送2号信号,观察pending表的变化
预测我们会观察到pending表第二个bit位由0变为1的变化
编译运行,当我们给进程发2号信号,pending中2号信号的位置果然由0变为了1了,但是进程并没有被终止,这是因为我们把2号信号给屏蔽了,2号信号无法递达。
这次,让程序10秒之后,解除对2号信号的阻塞,并且给2号信号设置一个自定义动作
4.4 总结
一张图总结,
5. 捕捉信号
5.1 信号捕捉的流程
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
首先,我们还要知道信号是什么时候被处理的?
信号被处理的前提是,你得知道你收到了信号。那我们怎么知道自己收到了信号呢?
我们在上面章节中说过信号的产生,如果是硬件,异常会产生硬件中断来提醒OS来发送信号,或者是我们调用系统调用来告诉OS发送信号,那么OS发送信号的本质是什么?
就是发送给进程即——进入进程中的pending表中
可pending表是内核数据结构,用户无权访问啊,那是不是我们就需要调用系统调用了?并不是,信号的处理是由进程自己完成的,在检查是否收到信号的时候,进程需要切换到内核态。
那信号什么时候被处理呢? 在进程由内核态返回用户态的时候。那为什么要在返回的时候处理呢?
因为,返回的时候,我们刚好处于内核态。更重要的是我既然要返回了,说明我已经把更重要的事做完了。然后,顺便把信号检测一下。
这里如果信号处理函数是位于用户空间的,那么处理过程就比较复杂了,下面举个例子
- 用户程序注册了SIGQUIT信号的处理函数sighandler。
- 当前正在执行main函数,这时发生中断或异常程序会切换到内核态。
- 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
- 注意这里,如果SIGQUIT屏蔽了,那么这里就不会去执行了也就是检测pending&&block。
- 没有屏蔽,则会根据信号动作执行默认动作则在内核态中完成,忽略则直接跳过。
- 内核决定返回用户态后不是直接恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
- sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
- 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
如图,在符号的交叉点就是做信号检测的时机(检测pending表),线和符号的焦点,就是内核态和用户态切换的地方
注意: 这里信号处理完,只能从内核返回
5.2 内核态和用户态
我们所写的代码,有自己写的,也有不是自己写的。比如说系统调用就不是我们写的。在执行我们写的代码或者调用库函数的时候,进程处于用户态就可以执行了,进程是用户的代表,但操作系统并不相信用户,所以进程是不能够直接使用内核代码的,出于安全以及隔离关键资源考虑,权限是不允许的。可用户也是需要使用内核代码的呀,那这就取了个折中的办法,那就是将内核分为内核态和用户态,用户在用户态上操作,系统在用户态提供系统接口,用户可以通过系统接口,将用户态切换到内核态,执行程序。
那么进程是如何陷入内核态的呢? 对于X86 intel 芯片,有一条汇编语句,叫int 80 ,CPU一旦调用int 80 语句成功,进程就会从用户态陷入内核态。
5.3 穿插话题 - 操作系统是怎么运行的
这里我们再系统,联合性地讲解下操作系统是如何运行的,将之前所学的点一切都连接起来,形成闭环
5.3.1 硬件中断
这里再回顾一下硬件中断
中断向量表就是操作系统的一部分,启动就加载到内存中了。
通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
由外部设备触发的,中断系统运行流程,叫做硬件中断
5.3.2 时钟中断
问题:
- 进程可以在操作系统的指挥下,被调度,被执行, 那么操作系统自己被谁指挥,被谁推动执行呢?
- 外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,那有没有自己可以定期触发的设备?
其实在CPU内部有一个时钟源,时钟源里有一个计时器,每当计时器到0,就会触发一个时钟中断,而CPU发出时钟中断的频率就是CPU的主频!
而这个时钟中断,也是中断,触发了,操作系统也是需要去处理的,在中断向量表中时钟中断所对应的终端服务就是进程调度!!
当然也不一定就会切换,每一个进程的切换是严格按照 操作系统分配的时间片的,操作系统通常是利用时钟中断来对进程的时间片使用情况进行检查和管理的。每次时钟中断发生时,操作系统的时钟中断服务程序会查看当前正在运行的进程的时间片是否已经用完了,如果时间片用完了,就会触发进程切换,让其他进程获得CPU的使用权。
也正是因为操作系统按照时钟中断发生来检查时间片情况,所以时间片长度通常都是时钟中断间隔的整数倍,因为操作系统只能在时钟中断发生时才会去检查和处理时间片相关的操作,所以时间片不能小于时钟中断间隔。
了解了这些,我们就可以得出结论,操作系统实际上就是基于中断向量表,进行工作的,并且在整个运行过程中,中断和异常处理机制贯穿始终!!
时钟中断总是在固定的时间触发,这样,操作系统不就在硬件的推动下,自动调度了嘛!!!
5.3.3 死循环
那如果是这样的话,操作系统不就可以躺平了吗? 对,操作系统自己就是不做任何事情,需要什么功能,就向中断向量表里面添加方法即可,操作系统的本质:就是一个死循环!!!
这样,操作系统,就可以在硬件时钟的推动下,自动调度了。
所以,至此,我们也就了解了什么是时间片,以及CPU为什么会有主频,为什么主频越快,CPU越快
5.3.4 软中断
上述的都是外部硬件中断,都需要硬件设备才能触发
那有没有什么,通过软件也能触发上面的逻辑呢? 当然有!
为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 0x80 或者 sysscall),可以让CPU内部触发中断逻辑。
有了汇编指令了,那么我们不就可以写到我们的软件中了吗! 这样我们也就可以通过软件程序来触发中断逻辑
其实对应处理软中断的服务就是系统调用的处理方法。
那么OS是如何处理系统调用的呢?
OS向用户层提供的大量的系统调用,是不是也是需要先描述,再管理的,所以,在OS内部会有一个系统调用表,这个表本质上其实就是一个函数指针数组,指向的就是对应的系统调用
我们都知道中断向量表中对应的其实都是些函数指针,所以这个系统调用的处理方法也是一个函数,函数内容就是调用我们上面提到的系统调用表,那么系统是如何知道该调用哪一个表呢?
通过CPU的寄存器(如EXA)这样就可以获得系统调用号,我们上面也提到过系统系统调用表实际上就是一个数组,所以系统调用号本质上就是个下标!
于是我们也就拿着对应的数组下标调用我们想要调用的系统调用了。
那么系统调用的返回值怎么办?
OS可以在内核态通过寄存器或者用户传入的缓冲区地址来返回系统调用的返回值
总结:
- 用户层是怎么把系统调用号给操作系统的? —— 寄存器(比如EAX)
- 操作系统怎么把返回值给用户? ——寄存器或者用户传入的缓冲区地址
- 系统调用的过程,其实就是先 int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法
- 系统调用号的本质:数组下标!
可为什么我们用的系统调用,从来没有看到过什么 int 0x80 或者 syscall 呢? 都是直接调用的上层的函数啊?
那么因为Linux的GNU C标准库,给我们把几乎所有的系统调用全部封装了!
5.3.5 缺页中断? 内存碎片处理? 除零野指针错误?
缺页中断? 内存碎片处理? 除零野指针错误? 这些问题,全部都会被转化为称为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。
所以:
- 操作系统就是躺在中断处理例程上的代码块!
- CPU内部的软中断,比如int 0x80 或者syscall, 我们叫做 陷阱(因为会从用户态陷入内核态)
- CPU内部的软中断,比如除零/野指针等,我们叫做 异常 。(这也就为什么叫“缺页异常”)。
5.4 如何理解内核态和用户态
通过我们之前的学习,我们知道了进程会有自己的task_struct,进程地址空间,页表。进程地址空间总共有4GB, 0~3 GB 我们称之为用户空间,3~4GB 称之为内核空间,顾名思义,用户空间就是用来存放,用户所使用的结构,例如栈,堆,临时变量,全局变量,都在用户空间。那内核空间,映射的什么呢?
内核空间映射的是操作系统的代码和数据,电脑开机,最先加载的进程是操作系统,所以,操作系统会被加载到物理内存比较下面的位置。
那OS上的代码和数据怎么映射到内核空间?是不是得有个页表,而这个页表,我们称之为内核级页表。实际上,OS很复杂,OS地址空间和物理内存可以直接建立映射,只是部分OS代码和数据需要内核级页表进行映射,这里为了方便理解,我们就简单的认为OS的代码和数据都需要借助页表映射。
思考一个问题: 用户级页表有多少份,内核级页表有多少份?
答案是,进程有多少份,就有多少个用户级页表(因为进程具有独立性),而内核级页表只有一份(因为OS只有一个)
内核级页表只有一个,那么每个进程看到的3~4GB的东西都是一样的! 这也意味着,整个系统中,进程再怎么切换,3~4GB空间的内容是不变的!!
那么我们为什么要将OS的代码和数据映射到进程的虚拟内存空间呢?
首先就是方便操作,无论如何,要执行OS代码和数据。OS就一定要加载到内存中(OS在开机时就加载了),如果不映射的话,每次执行系统调用就需要到物理内存中找,首先如果要去物理内存找就是映射的问题,还会有各种后续的问题,所以还不如就直接映射到进程的虚拟内存空间
映射之后,3~4GB中会有什么fork,getpid,signal等系统调用。进程在调用这些系统调用时,就是在自己的地址空间里进行执行,这是进程的视角。站在操作系统视角,任何时刻,都会有进程在被调度。进程想要执行操作系统的代码,随时都可以执行。
其次就是方便传递数据,不管是系统调用所需要的参数,还是系统调用的返回值,都是在进程虚拟内存空间进行的,不需要在进行额外的地址映射,直接就可以返回
还有一个问题 :OS怎么知道,我们处于什么状态的?
CPU中有很多寄存器,其中CR3用于用户级页表地址。今天,我们介绍一个新的寄存器——ecs寄存器(CS寄存器),ecs寄存器是代码段寄存器,一般用于存储代码段位置的地址也就是整个虚拟内存空间的起始位置,它的最低两个bit位,表示的是CPL 当前特权级(Current Privilege Level)总共4个特权级别,编号从0到3,其中0最高,3最低,两个bit 00表示的就是内核态,11表示就是用户态
结论:
- 操作系统无论怎么切换进程,都能找到同一个操作系统! 换句话说操作系统系统调用方法的执行,是在进程的地址空间中执行的!
- 关于特权级别,涉及到段,段描述符,段选择子,DPL,CPL,RPL等概念,而现在芯片为了保证兼容性,已经非常复杂了,进而导致OS也必须得照顾它的复杂性,这块我们不做深究了。
- 用户态就是执行用户内存 [0,3] GB时所处的状态
- 内核态就是执行内核 [3,4] GB时所处的状态
- 区分就是按照CPU内的CPL决定,CPL的全称是Current Privilege Level,即当前特权级别。
- 一般执行 int 0x80 或者 syscall 软中断, CPL会在校验之后自动变更
至此,操作系统中,中断的产生,以及操作系统是如何运行起来的(通过中断)我们也就都清楚了
那么中断和信号的核心区别是什么?
本质就是中断就是用来告诉操作系统该去做什么了,而信号则是操作系统用来管理进程的一种方式
5.5 sigaction函数
我们理论学习完了,接下来开始实践
首先我们要学习一个新函数,sigaction函数
sigaction函数可以读取和修改与指定信号相关联的处理动作,和signal一样,也是一种捕捉信号的函数。调用成功则返回0,出错则返回-1。
signo是指定信号的编号。act为一个输入型参数,若act指针非空,则根据act修改该信号的处理动作。oact为一个输出型参数,若oact指针非空,则通过oact传出该信号原来的处理动作。
需要注意的是 这里参数传入的结构体类型名和函数名是相同的,这个在C++是允许,C++允许函数名和类名相同。
act和oact都指向sigaction结构体:
这里我们只用关心结构体内的第一个成员和第三个成员。
第一个成员 sa_handler, 就是自定义的处理方法。
如果传入的信号的sa_handler赋值为常数SIG_IGN 传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
我们可以简单做一个测试
编译运行,我们给进程发送2号信号,进程并不会进行终止,而是打印提示信息
那么我们在思考一个问题,那pending位图中,2号信号,是什么时候由0置为1呢? 我们可以在handler方法里,加一个打印pending位图的方法,如果进入handler方法,打印的2号信号的位置为1,说明pending位图是在handler方法执行完,再将1置为0.如果进入到handler方法,打印2号信号的位置为0,说明pending位图是在handler方法执行前,将1置为0的。
编译运行,我们就会发现pending位图在进入handler方法之后,全部被置为0了。这说明,在调用处理方法之前,pending位图相应的位置,就会被1置为0,
那么这里就有一个问题,如果pending位图是在执行自定义方法之前变为0的,那么这是否意味着,我们在处理信号期间,是有可能进行陷入内核,即我们可以在处理自定义方案期间,再次发送信号,然后再次执行自定义方法形成嵌套呢?
这当然是不可能的,OS的制作者就已经考虑到我们的这种想法了,
看看下面的这段文字,这段文字是什么意思呢? 它的意思是当OS调用信号的处理方法时,相应的信号会被阻塞。直到信号处理完了,才会自动恢复原来的屏蔽字,同样也是为了保证执行的方法的原子性。
那我们怎么去验证呢?如果说OS会在调用处理方法时,把相应的信号屏蔽了,那我们再发送这个信号,信号是无法递达的。也就是说,pending表中,这个信号的位置会一直为1。所以,我们可以通过打印pending表的方式验证。
我们可以再handler方法里写一个死循环,一直打印pending表,然后,我们再发送2号信号,观察pending表的变化
编译运行,第一次Ctrl+C,2号信号被捕捉。第二次Ctrl+c,pending表其中一个位置,由0变为1,表示2号信号已收到。但并没有receive 这样的提示信息打印,说明之前的handler还在死循环执行,2号信号确实被阻塞了,无法递达
我们知道,处理2号信号的时候,2号信号会被自动屏蔽,如果我们不只想屏蔽2号信号,还想屏蔽其他的信号怎么办?我们可以通过结构体的sa_mask成员来进行实现。
下面,我们来实践一下:
编译运行,发送2号信号后,我们再给进程发送1,2,3,4号信号。信号就被阻塞了。