一、信号的概念
我们生存中就有很多信号,比方红绿灯、下课铃声、闹钟声音、电话铃声…,这里我先以红绿灯为例,我为什么认识红绿灯?这里认识包罗:1、我能辨认出来红绿灯。2、我知道对应的灯亮了意味着什么,要做什么。我们认识红绿灯一定是有人告诉过我们,而且我们将其记着了。以是我能辨认信号,一定是有人提前告诉过我的。
在我们认识红绿灯的前提下,现在我们没有看到红绿灯,但是我们知道应该怎么做,以是信号在没有产生的时候,实在我们已经能够知道怎样处理这个信号了。
假设我现在就在等红绿灯,这时候绿灯了,但是现在我必要处理我手机上的事情,这时候我可以选择不走,以是信号产生了,我们并不一定必要立即处理它,而是我们在合适的时候处理它。
再以点外卖举个例子,假设我现在点了一份外卖,然后就开始在网上刷题了,当外面送到了楼下,外卖员就给我打电话说外卖到了,这时已经到了解题的关键步骤思路不能断,我就让他放在楼下了。
在我刷题到外卖员将外卖送达的这段区间中,我们并不知道外卖什么时候到达,也不知道他什么时候给我打电话,以是信号到来相对于我正在做的事情是异步的。
当我们把题解完后,就必要下楼去取外卖,如果说我们忘记了外卖到了这个事情,我们就不会下去取外卖,以是我们要有一种能力,将已经到来的信号举行临时的保存。
这里的我/我们都是以进程为单元的,进程在操作体系的编写者计划的时候,进程就必要内置对信号的认识的能力,它必须知道操作体系中有哪些信号,每个信号的处理动作是什么,进程现在没有收到信号,但是它知道它收到信号了必要怎么做,信号的产生相对于进程是异步的,举行必要在合适的时候处理信号,而且这个前提是它要有这个能力保存这个信号。
信号:信号是向进程发送关照的一种机制 。每一个信号都有一个编号来标识它的唯一性。
二、信号的产生
预备知识:
进程在运行的时候,可以分为前台进程和后台进程,区分它们的方式就是如果说当前用户在命令行输入命令无效,当前运行的进程就是前台进程,反之则是后台进程。前台进程只有一个,而后台进程可以有很多个。
我们可以通过./步伐名的方式运行前台进程,通过./步伐名 &的方式运行后台进程。当我们使用上面方式运行后台进程时,操作体系会为用户输出当前进程的任务编号和进程PID。
我们可以通过jobs命令检察后台任务
fg + 任务编号可以将对应的后台任务提到前台
ctrl + z,bg + 任务编号可以将对应的前台进程提到后台
Shell(bash)也是进程,当我们运行自己的步伐时,操作体系会将其提到后台,进程被暂停/停止/结束时,操作体系会将其提到前台。以是操作体系会主动将Shell提到前台或后台。
ctrl + z会让前台进程被挂起,并让这个进程被挂到后台。
ctrl+c一样寻常情况下会停止前台进程,它不能停止Shell。
操作体系怎么知道外设就绪了呢?不大概一遍一遍的轮询遍历外设驱动吧?
有一种技能能够让操作体系知道硬件就绪的技能叫做中断技能。CPU中不仅仅有计算器另有控制器,CPU不仅仅直接毗连内存,它另有很多针脚而且有对应的编号,这些针脚是与主板毗连着的,主板中又有很多硬件电路,这些硬件电路使用外设直接毗连的。我们在冯诺依曼体系中讲到了,CPU在数据层面上不与外设直接打交道,它只与内存直接打交道,但是在控制信息层面上CPU是必要与外设打交道的,以是外设就可以间接的给CPU发送中断信号的(也就是高电平)。当外设就绪时,会向对应的针脚上发送高电平,CPU知道每个针脚对应的编号,CPU中的某个寄存器就会将当前针脚的编号存储到寄存器中,以是终极将硬件数据就绪就转换成了某种数据存储到了寄存器中,这时候寄存器中的数据就可以被步伐读取了,此时硬件行为就被转换成了软件行为,寄存器中存储的编号我们称之为中断号。为了更快的对外设举行响应,操作体系内部会提供一个函数指针数组,内里存储着特定硬件的读取方法,这个数组我们称之为中断向量表,操作体系内为每一个外设规定了特定的中断号,操作体系在开机的时候,就会创建中断向量表,然后根据下标与中断号的对应关系使用对应的外设的读写方法将表填满。当外设就绪以后,操作体系会停掉正在干的工作,然后读取对应的中断号,根据中断号获取对应的读写方法,然后执行对应的方法,这样就能将外设的数据直接拷贝到操作体系内特定的内存区里。
我们发现信号与这里的行为非常相似,实际上信号的本质就是用软件来模拟中断行为。
2.1 通过键盘举行信号的产生
当我们对一个前台进程按下ctrl+c就是向进程发送一个2号信号,怎么证明呢?
进程在处理信号时分为三种行为,默认行为、忽略和自界说行为。自界说行为我们又称作信号的捕捉。
signal函数
- typedef void (*sighandler_t)(int);
- sighandler_t signal(int signum, sighandler_t handler);
复制代码 功能:通过signal函数我们可以让进程处理signum号信号的默认行为转换为handler的自界说行为。
参数:
- signum:一个整数参数,表示要处理的信号编号
- handler:一个函数指针参数,指向一个信号处理函数。
这里的handler必须是一个参数为int返回值为void的函数,参数int表示的是哪一个信号调用了这个handler函数,也就是信号编号。
返回值:signal函数返回一个指向先前为该信号设置的信号处理函数的指针。如果调用堕落,则返回(sighandler_t)SIG_ERR,并设置errno以指示错误。
观察下面代码,我们使用signal函数将2号信号的默认行为转变为我们自己的自界说行为,原来遇到2号信号是直接退出,现在遇到2号信号是先输出一段数据再退出,观察下面运行结果,起首我们给使用kill命令给进程发送了一个2号命令,进程先打印了一段数据就退出了,然后再运行进程,按下ctrl+c我们发现进程也是先打印了一段数据就退出了,现在就能证明ctrl+c就是产生了2号信号了。
signal函数只必要调用一次,就会在本次进程运行中不绝有效。
我们可以通过man 7 signal来检察操作体系中进程处理信号的默认行为,我们知道signal函数可以将特定信号的默认处理方法转化为自界说方法,那么我们是否可以将所有信号的默认处理方法全部转化为自界说方法,而且自界说方法中都不含停止进程的代码,让这个进程无法被关闭呢?
这是不可以的,大部分信号的默认处理方法可以被转换为自界说方法,少部分信号不可以,比方我们熟知的9号信号就不可以。
我们可以使用kill -l来检察操作体系中所有的信号,我们发现并没有0号信号,在进程退出时,父进程会获取到它的退出信息,退出信息由退出码和停止信号组成,停止信号部分为0则代表进程正常退出,以是操作体系中没有0号信号。
细致观察下图发现也没有31、32号信号,实际上操作体系中的信号由1 ~ 31号的普通信号和33 ~ 64号的实时信号组成。
操作体系中为了让进程处理信号,给每个进程都维护了一张表,这个表就是一个函数指针数组,它的下标就是信号的编号,内里存储的也是处理信号的方法。
对于普通信号而言,进程收到信号之后,进程要表示自己是否收到某种信号,且进程大概不止收到一个信号,那么进程要对收到的信号举行管理,以是先描述再构造,大家看到“是否”会想到什么呢?我会想到位图,比特位的位置表示信号的编号,比特位的内容表示是否收到信号,以是一个进程只必要32位的位图就能管理好信号。
操作体系向目的进程发信号应该怎么理解呢?实际上我认为描述为操作体系向目的进程写信号更形象,操作体系找到目的进程,将目的进程PCB中位图中与信号编号对应的比特位上的内容由0置1。
这里我们讲到通过键盘可以产生信号,但终极是由操作体系向进程发送信号的,以是在反面无论有多少种产生信号的方式,终极都是由操作体系向进程发送信号,因为操作体系是进程的管理者。
键盘不仅仅可以使用ctrl+c来产生信号,还可以使用ctrl+z和ctrl+\来产生信号,它们分别产生的是20号和3号信号。
当键盘按下组合键后完整的过程:
键盘按下组合键后向对应的针脚发送中断信号,操作体系根据中断号去中断向量表中找到并执行对应的方法,将键盘中的数据读到内存,操作体系会对数据举行辨认,如果说是ctrl+c、ctrl+z和ctrl+\,操作体系就会将其转换为对应的信号,在向进程PCB中位图写入信号,操作体系就完成了信号的发送,当反面进程运行时,会根据位图在合适的时机来处理信号。
在操作体系中用户所有的行为都是以进程的方式表现的,操作体系只必要减进程调度好就可以完成用户所有的任务,但是操作体系也是代码,谁来执行操作体系的代码呢?
在计算机中有一个硬件COMS,它可以周期性的、高频率的,向CPU发送时钟中断,我们简朴的理解一下操作体系,当操作体系完成了前置工作后,就处于死循环的状态了。当COMS向CPU发送中断信号后,CPU根据中断号在中断向量表中找到操作体系的调度方法,而COMS会高频的给CPU发送中断信号,使得操作体系的调度方法被不绝的执行,这样操作体系就在硬件的驱动下被调度了。在计算机中另有很多硬件,它们在中断向量表中也有各自的方法,这些硬件也可以向CPU中发送中断信号,以是当进程被启动后,COMS就会推动操作体系去执行操作体系的调度方法,其他已经也可以发送中断信号去告诉操作体系自己就绪了,让操作体系去调度它的方法,以是操作体系的执行是基于硬件中断的。
2.2 通过体系调用举行信号的产生
2.2.1 kill函数
- #include <sys/types.h>
- #include <signal.h>
- int kill(pid_t pid, int sig);
复制代码 kill函数能够向指定进程发送指定的信号,我们经常使用的kill命令就是通过kill函数来实现的,那么我们自己也可以实现一个kill命令。
- #include <iostream>
- #include <unistd.h>
- #include <signal.h>
- #include <string>
- #include <stdlib.h>
- using namespace std;
- static void Usage(char* proc)
- {
- cout << "Usage:" << proc << "-signalid processpid" << endl;
- }
- int main(int argc,char* argv[])
- {
- // ./mykill -signalid processpid
- // 没有这三部分说明它不会用,后面就不用执行了,并教他对应的使用方法
- if(argc != 3)
- {
- Usage(argv[0]);
- exit(0);
- }
- int signalid = stoi(argv[1]+1);
- int processpid = stoi(argv[2]);
- kill(signalid,processpid);
- return 0;
- }
复制代码
2.2.2 raise函数
- #include <signal.h>
- int raise(int sig);
复制代码 raise函数能够为调用这个函数的进程发送指定的信号。
这里我们自界说捕捉2号信号,通过代码运行结果来看,raise函数确实在不绝的发送2号信号。
2.2.3 abort函数
- #include <stdlib.h>
- void abort(void);
复制代码 abort函数能够为调用这个函数的进程发送6号信号。
这里我们调用自界说捕捉6号信号,自界说函数中并没有让进程退出的代码,而且我们调用完abort函数后不绝循环不让进程退出,运行代码观察现象,我们发现确实捕捉到了6号信号,但是进程照旧退出了,因为abort函数内部有让进程退出的语句。
2.3 通过异常的方式举行信号的产生
这里先以除0错误为例,CPU中有一个寄存器叫做状态寄存器,它其中有一位是叫做溢出标记位,当一个数除以0时,会导致溢出标记位被置为1,这时候CPU就会关照操作体系自己堕落了,并将自己的堕落信息交给操作体系,操作体系就会向对应的进程发送信号,末了对应的进程就默认停止了。除0错误是可以在硬件层面上被甄别出来的。
当进程出现了除0错误时,操作体系会向当前进程发送八号信号。
下面我通过代码来证明一下进程发生除0错误时,操作体系会发送8号信号。在下面的代码中,我们自界说捕捉8号进程,函数中并没有让进程退出的代码,而且main函数中也没有循环阻止进程退出的代码,运行步伐观察现象,通过现象来看进程发生除0错误时,操作体系确实会发送8号信号,但是main函数中没有循环语句,但是操作体系不绝在发送8号信号,这是为什么呢?
使用默认的方式处理8号信号的全过程:
起首我们知道寄存器 != 寄存器中的内容(进程的上下文),寄存器中的内容是属于进程的,当进程发生除0错误时,CPU中的状态寄存器会将溢出标记位置为1,这个1本质上是进程的上下文,然后告诉操作体系自己堕落了,这时操作体系会向对应的进程发送信号,末了对应的进程就默认停止了,当CPU运行下一个进程的时候,下一个进程的寄存器内容会被CPU读取,这样状态寄存器中的溢出标记位就被间接的置为0了。操作体系将进程杀掉默认就是处理问题的一种方式,出问题的进程被杀掉后,该进程的上下文也就不存在了,后序进程的运行就不会出现问题了。
而这里我们将默认处理8号信号的方式(停止进程)改为了handler,也就是只输出一句话并不绝止进程,当调度这个进程,CPU就会告诉操作体系这里出现问题了,但是操作体系认为输出一句话就是对该进程举行处理了,然后操作体系又去干其他事情了,实际上这个进程依旧存在,当CPU再次运行这个进程时,读取该进程的上下文,会导致状态寄存器会将溢出标记位置为1,也就是错误了,CPU遇到错误就不会执行反面的代码了,又会重复的操作。以是操作体系不绝发送8号信号的原因是,CPU辨认到进程中有异常不在执行后序代码,然后不断关照操作体系处理异常,操作体系不作为只输出一句话,本质上就是进程没有被停止导致进程不绝被调度,终极导致操作体系不绝发送8号信号。以是在进程堕落后一样寻常就必要停止进程。
访问0号地点问题与除0错误相似,目前CPU中集成了MMU(内存管理单元),MMU能够做到通过页表将虚拟地点转化为物理地点,当它想要将0号下标转换为物理地点时,发现页表中并没有它的映射关系,以是MMU就报错告诉操作体系堕落了,操作体系就给当前进程发送信号。访问0号地点就属于野指针问题,野指针问题本质上就是虚拟地点与物理地点映射失败,操作体系就辨认到了,进而停止这个进程。在Linux中野指针问题通常会导致段错误。
发生段错误也就是内存问题,操作体系会为对应的进程发送11号信号。
通过下面代码的运行结果我们可以得知,操作体系确实发送的是11号信号,这里不绝发送11号信号的原因与上面的原因一致。
2.4 通过软件条件的方式举行信号的产生
2.4.1 关闭管道读端
SIGPIPE是一种由软件条件产生的信号,我们在进程间通信中管道中讲到过,若读端关闭,操作体系就会检测到,然后向写端进程发送信号,末了将写端进程杀掉,就是操作体系向该进程通过发送SIGPIPE完成的。
2.4.2 alarm函数
- #include <unistd.h>
- unsigned int alarm(unsigned int seconds);
复制代码 功能:alarm函数用于设置一个闹钟(定时器),当闹钟指定的时间(以秒为单元)到达时,它会向进程发送SIGALRM信号(14号信号)。
返回值:如果调用alarm函数前,进程已经设置了闹钟时间,则返回上一个闹钟时间的剩余时间(以秒为单元),否则返回0。
我们在任何操作体系中都能够设置闹钟,Linux操作体系中进程可以通过alarm函数设置闹钟,当到达指定时间后,会关照进程时间到了,那么操作体系中就会存在很多闹钟,以是操作体系必要对闹钟举行管理,也就是先描述再构造,为闹钟计划一个布局体,布局体中一定有剩余时间、创建闹钟进程的PID、拥有者等等用于管理闹钟的字段,然后通过某种数据布局将这些闹钟管理起来,操作体系想知道这些闹钟有没有到期,实际上只必要知道闹钟中还剩时间最少闹钟有没有到期,只有它没有到期,其他的闹钟就都没有到期,这么一想使用优先级队列创建一个小堆,再以闹钟所剩时间为键值就很得当管理这些闹钟,只要堆顶元素没有到期,其他都没有到期,堆顶元素到期后,关照对应的进程,堆中删除堆顶元素,堆主动调整,再次检察堆顶元素到期,这样就很好的管理了这些闹钟了。
在下面的代码中我们自界说捕捉了14号信号,当我们运行步伐后3秒,进程调用alarm函数后,先是输出得到了14号信号,然后再是进程退出,这就能证明alarm函数确实能够产生14号信号。
在下面这段代码中,我们先设置一个闹钟,然后再自界说捕捉14号信号,在函数中我们在重新设置一个闹钟,运行步伐过几秒后,我们通过给进程发送14号进程,让进程处理14号信号,然后再重新设置闹钟获取alarm函数的返回值,这也能证明调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间。
我们上面设置的闹钟都是一次性的,如果我们想让闹钟是多次性,可以使用自界说捕捉的方式,在处理14号信号的自界说函数中重新设置一个闹钟就可以做到闹钟的多次性了。
2.5 Core Dump(焦点转储)
Core Dump又叫做焦点转储。当一个进程要异常停止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core.进程PID,这叫做Core Dump。
命令ulimit -a可以检察体系的基本配置项,云服务器上通常是把Core Dump选项关闭了,也就是把core文件大小设置为0,不答应生成core文件,因为core文件中大概包含用户暗码等敏感信息,不安全。
我们可以ulimit -c这样修改core文件的大小,但是这样修改只是内存级别的,下一次重启Shell又会变回原样。
下面这张图片是无焦点转储和有焦点转储情况下出现异常的情况,有焦点转储的情况下出现了异常,在当前目次下创建了一个core.PID的文件。。
那么Core Dump到底有什么用呢?
当我们遇到野指针、浮点数错误等问题时,它提示的错误类型很明确,可以帮助步伐员很轻易的发现错误原因,Core Dump的出现能够帮助步伐员更快的找到错误的位置,方便步伐员举行调试。
编译代码时带上-g选项让代码能够被调试。core-file core.PID,它能帮我们解析这个core文件,能够告诉我们代码具体在哪一行出现了问题。
2.6 小结
- 问题:上面所说的所有信号产生,终极都要有操作体系来举行发送,为什么?
答:操作体系是进程的管理者。
- 问题:信号的处理是否是立即处理的?
答:进程收到信号后并不会立即处理,而是在合适的时候处理。
- 问题:信号如果不是被立即处理,那么信号是否必要临时被进程记录下来?记录在哪里最合适呢?
答:必要,进程的PCB中有一个位图是用来存储相关信息的。
- 问题:一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作那边理呢?
答:能够做到,操作体系中为了让进程处理信号,给每个进程都维护了一张表,这个表就是一个函数指针数组,它的下标就是信号的编号,内里存储的也是处理信号的方法。
- 问题:怎样理解操作体系向进程发送信号?能否描述一下完整的发送处理过程?
答:我认为描述为操作体系向目的进程写信号更形象,操作体系找到目的进程,将目的进程PCB中位图中与信号编号对应的比特位上的内容由0置1。
三、信号的保存
3.1 信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)。
信号抵达又分为信号的默认、信号的忽略和信号的自界说捕捉。
信号的忽略不是不做处理,而是处理了不做任何事,它就是处理信号的一种方式。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。也就是信号在位图中时,信号处于信号未决状态。
- 进程可以选择阻塞 (Block)某个信号。被阻塞的信号产生时将保持在未决状态,直到进程排除对此信号的阻塞,才执行递达的动作。
注意:阻塞和忽略是差别的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
3.2 信号在内核中的表现
每一个进程中都会维护三张表,block表、pending表和handler表。
- block表:比特位位置代表对应信号编号,比特位内容代表对应信号是否被阻塞(屏蔽)
- pending表:比特位位置代表对应信号编号,比特位内容代表该进程是否收到对应信号。
- handler表:它其中存储的是函数指针,也是对应信号的处理方法,分为默认、忽略和自界说,数组下标代表对应信号编号。
block表和pending表布局是完全一模一样的,当我们向检察进程中某一个具体的信号时,必要横着看,比方上图第一行,进程未收到对应信号(0),进程未阻塞对应信号(0),信号的处理方式为默认。
在进程中进程是使用pending表来存储对应信号的,如果在进程排除对某信号的阻塞之前这种信号产生过多次,将怎样处理?
根据POSIX.1尺度,答应体系递送该信号一次或多次,Linux操作体系中是这样实现的:通例信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
3.3 sigset_t
从上图来看,pending表每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,block表中的阻塞标志也是这样表示的。因此,未决和阻塞标志可以用雷同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的寄义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的寄义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
3.4 信号相关函数
3.4.1 sigemptyset函数
- #include <signal.h>
- int sigemptyset(sigset_t *set);
复制代码 功能:初始化set所指向的信号集,使其中所有比特位上的内容变为0,表示该信号集不包含任何有效信号。
参数:
3.4.2 sigfillset函数
- #include <signal.h>
- int sigfillset(sigset_t *set);
复制代码 功能:初始化set所指向的信号集,使其中所有比特位上的内容变为1,表示该信号集包含任何有效信号。
参数:
3.4.3 sigaddset函数
- #include <signal.h>
- int sigaddset (sigset_t *set, int signo);
复制代码 功能:在指定信号集set中添加指定信号,使其中对应比特位上的内容变为1。
参数:
- set:指向要修改的信号集的指针。
- signo:指向信号集中添加信号的编号
3.4.4 sigdelset函数
- #include <signal.h>
- int sigdelset(sigset_t *set, int signo);
复制代码 功能:在指定信号集中删除指定信号,使其中对应比特位上的内容变为0。
参数:
- set:指向要修改的信号集的指针。
- signo:指向信号集中删除信号的编号
3.4.5 sigismember函数
- #include <signal.h>
- int sigismember(const sigset_t *set, int signo);
复制代码 功能:判断指定信号集中是否有对应信号。
参数:
- set:指向要判断的信号集的指针。
- signo:指向必要再信号集中判断信号的编号
3.4.6 sigprocmask函数
- #include <signal.h>
- int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
复制代码 功能:用于获取或设置调用进程的信号屏蔽字
参数:
how 选项寄义SIG_BLOCKset中包含了我们必要向信号屏蔽字中添加的信号,相对于mask = mask | setSIG_UNBLOCKset中包含了我们必要向信号屏蔽字中删除的信号,相对于mask = mask & ~setSIG_SETMASK设置当前信号屏蔽字为set指向的值,相对于mask = set
- set:指向一个 sigset_t 类型的变量,该变量包含要修改的信号聚集。
- oset:它是一个输出型参数,如果不为 NULL,则指向一个 sigset_t 类型的变量,该变量将存储调用 sigprocmask 之前的信号屏蔽字。
3.4.7 sigpending函数
- #include <signal.h>
- int sigpending(sigset_t *set);
复制代码 功能:获取当前进程中被阻塞且尚未处理的信号聚集。
参数:
- set:指向一个 sigset_t 类型的变量,该变量用于存储被阻塞且尚未处理的信号聚集。
3.4.8 函数的综合使用
我们先屏蔽2号信号,再不断的获取当前进程的pending表并以0/1序列的方式将其打印出来,开始进程屏蔽了2号而且进程也未收到2号信号,这时候打印的pending表就应该是全0,后来我们在某一时刻给pending表发送2号信号,由于进程将2号信号屏蔽了,以是2号信号不能被递达,只能被保存在pending表中,这时候我们就能看到pending表中2号比特位上的内容由0变为了1。
然后我们再排除进程对2号信号的屏蔽,这时候2号信号就可以递达了,我们就能看到pending表中2号比特位上的内容由1变为回0了。
如果说进程能够屏蔽2号信号,那么是否代表进程能够屏蔽所有的信号,使得进程无法被停止呢?进程是无法将所有信号举行屏蔽的,比方9号信号(管理员信号),其他的信号大家也可以去试试可不可以屏蔽。
- #include <iostream>
- #include <unistd.h>
- #include <signal.h>
- #include <string.h>
- #include <stdlib.h>
- using namespace std;
- void handler(int signo)
- {
- cout << "get a signo : " << signo << endl;
- }
- void PrintPending(const sigset_t &pending)
- {
- for (int signo = 31; signo > 0; signo--)
- {
- if (sigismember(&pending, signo))
- {
- cout << "1";
- }
- else
- {
- cout << "0";
- }
- }
- cout << endl;
- }
- int main()
- {
- signal(2, handler);
-
- cout << "getpid : " << getpid() << endl;
- // 屏蔽2号信号
- sigset_t block, oblock;
- sigemptyset(&block);
- sigemptyset(&oblock);
- // 就当前而言,这里只是对数据类型进行修改,并未对信号进行屏蔽。
- sigaddset(&block, 2);
- sigprocmask(SIG_BLOCK, &block, &oblock);
- // 不断打印pending表,并在合适的时候发送2号信号
- sigset_t pending;
- int cnt = 0;
- while (1)
- {
- sigpending(&pending);
- PrintPending(pending);
- sleep(1);
- cnt++;
- if (cnt == 5)
- {
- sigprocmask(SIG_SETMASK, &oblock, nullptr);
- }
- }
- return 0;
- }
复制代码 大概大家另有一个问题就是先清除pending中对应信号的内容照旧先处理信号呢?下面我们自界说捕捉2号信号,而且在函数中输出pending表的0/1序列,如果是2号信号对应在位图中的内容为0,则先清除pending位图,2号信号对应在位图中的内容为1,则先处理信号,通过下面代码的运行结果我们可以知道是先清除pending位图。以是进程在准备处理信号时,必要先清除pending表中信号对应的内容。
四、信号的处理
4.1 信号捕捉
问题:我们之前在讲信号产生的时候讲到了,进程收到了信号并不是立即处理的,而是必要在合适的时候处理,那什么时候是合适的时候呢?
答:进程从内核态返回到用户态的时候,进程会举行信号的检测和信号的处理。
用户态是一种受控的状态,能够访问的资源是有限的。
内核态是一种操作体系的工作状态,能够访问体系中的大部分资源。
体系调用中就包含了这两种身份的变更。
我们之前在讲到过运行步伐时,必要将步伐加载到内存变为进程,进程有自己的task_struct、进程地点空间和页表,但是加载到内存中的不仅仅只有进程,在机器启动的时候,操作体系就已经被加载到内存中了,那么操作体系在哪里呢?进程应该怎样看到操作体系呢?
在CPU执行进程时,检查到了该进程的时间片到了,必要执行操作体系的代码了,虽然中断向量表中有操作体系的调度方法,但是会存在嵌套函数的存在,以是我们必要一个方式来更快的找到操作体系的调度方法。
在讲进程地点空间时,只讲到了4GB中的[0,3]GB的用户级空间,这3GB的用户级空间是用户想怎么访问就怎么访问的,页表会将这3GB的用户空间与物理内存及创建映射,我们称这样的页表为用户级页表。我们发现进程地点空间中不仅仅有用户空间另有内核空间,[3,4]GB的空间我们称之为内核空间,操作体系中还存在一种页表名为内核级页表,它能够将内核空间与物理内存创建映射。
今后以后我们想要访问操作体系的代码和数据,就可以直接从代码区跳转到内核空间,就可以内核级页表访问物理内存中的操作体系了,访问完后再跳转回代码区。我们之前在讲动态库的时候,我们步伐中使用了库函数,就直接从代码区直接跳转到共享区中的库函数中,执行完毕后再跳转回代码区中,代码是通过在进程地点空间中完成函数调用的。以是我们的进程的所有代码的执行,都可以在自己的进程地点空间以跳转的方式举行调用和返回。
在体系中并不是只存在一个进程,有多少个进程就有多少个PCB、用户级页表(目前来说)和进程地点空间,但是操作体系的代码、数据等在内存中只有一份,以是内核级页表在体系中也就只必要一份即可。反面的进程只必要将内核空间通过内核级页表映射到操作体系中,就可以和其他进程看到同一份操作体系了。
以是有了内核空间和内核级页表的存在,无论是哪一个进程在被调度,CPU随时都可以看到操作体系。
我们凭什么说进程处于用户态照旧内核态呢?CPU中有一个寄存器叫做CS寄存器,它其中有两个比特位就是用来表示CPU的工作状态的,组合为1就是内核态,组合为3就是用户态,以是状态的切换本质上就是修改CS寄存器中特定的两个比特位。状态的切换并不能由用户来举行切换,必要操作体系通过某种形式举行切换,比方体系调用时,必要先将用户态切换为内核态。
CPU中有一个寄存器叫做CR3寄存器,它是用来存储当前运行进程的页表信息的,它存储的是页表的物理地点,具体是内核级页表照旧用户级页表,要看是内核态照旧用户态。像CR3这样的CPU寄存器中存储的是内核级数据,通常是直接访问物理内存的,纵然不直接也要用一种简朴的映射关系快速找到并访问物理内存。
进程从内核态返回到用户态的时候,进程会举行信号的检测和信号的处理。
起首进程中代码中使用了体系调用,将进程由用户态切换为内核态,执行对应体系调用的函数,执行完毕后本应该直接切换回用户态回到用户空间,现在必要加一步就是举行信号的检测和信号的处理。先检察pending表中是否有比特位上的内容为1,有则看对应block表中该信号是否被屏蔽,没有屏蔽才对信号举行处理,而信号的默认和信号的忽略都可以在内核中完成,而信号的自界说捕捉由于函数在用户空间中,以是比其他两个麻烦,这里重点讲信号的自界说捕捉。
当信号的处理方式为自界说捕捉时,必要到用户空间中执行对应的函数,注意这时候必须以用户态的方式去执行对应的函数,如果以内核态的方式,技能上一定是可以实现的,但是handler方法是用户自己界说的方法,用户就可以在自界说的方法中做了非法的事情,用户就可以的绕过权限认证。以是举行信号捕捉时,必要进程从内核态切换为用户态。
当执行完自界说方法后,不能直接跳转到进程调用体系调用的方法处,因为这个自界说方法什么都不知道,它不知道调回到哪里,而且体系调用大概也会有返回值,自界说方法也不知道,以是不能从用户态直接跳回到用户态,必要调用特殊的体系调用sigreturn,使用户态切换为内核态,重新回到内核空间,这时候体系调用的结果、信号处理的结果等等都可以被进程知道,就可以直接跳回到进程调用体系调用的地方,并将内核态切换为用户态。
上面我们讲到的是进程处理一个信号的情况,处理一个信号的时候,通过sigreturn函数回到内核态后,就可以直接返回进程调用体系调用处了,如果有多个信号存在进程该怎样处理呢?起首我们屏蔽2、3、4、5号信号,并在屏蔽期间,向进程发送2、3、4、5号信号,过一段时间后,排除对2、3、4、5号信号的屏蔽,我们看看在多个信号存在的情况下,进程是怎样处理信号的。
运行下面这段代码后,我们在屏蔽这段时间内向进程发送2、3、4、5号信号,当排除这四个信号的屏蔽后,这四个信号先后被执行,但并未按从小到大的次序,因为信号是有优先级的,而且在执行期间并没有打印pending表,说明处理完一个函数后并没有回到用户空间,而是继续检测是否另有其他信号,有则继续处理,没有则切回用户态回到用户空间。以是进程中有多个信号的时候,会按照优先级依次的处理信号。
- #include <iostream>
- #include <unistd.h>
- #include <signal.h>
- #include <string.h>
- #include <stdlib.h>
- using namespace std;
- void PrintPending(const sigset_t &pending)
- {
- for (int signo = 31; signo > 0; signo--)
- {
- if (sigismember(&pending, signo))
- {
- cout << "1";
- }
- else
- {
- cout << "0";
- }
- }
- cout << endl;
- }
- void handler(int signo)
- {
- cout << "get a sig :" << signo << endl;
- sleep(1);
- }
- int main()
- {
- cout << "pid :" << getpid() << endl;
- signal(2,handler);
- signal(3,handler);
- signal(4,handler);
- signal(5,handler);
- sigset_t block,oblock;
- sigaddset(&block,2);
- sigaddset(&block,3);
- sigaddset(&block,4);
- sigaddset(&block,5);
- sigprocmask(SIG_SETMASK,&block,&oblock);
- int cnt = 0;
- while (1)
- {
- cnt++;
- sigset_t pending;
- sigpending(&pending);
- PrintPending(pending);
- if(cnt == 20)
- {
- cout << "unblock 2、3、4、5 signal" << endl;
- sigprocmask(SIG_SETMASK,&oblock,nullptr);
- }
- sleep(1);
- }
- return 0;
- }
复制代码 4.2 sigaction函数
- #include <signal.h>
- int sigaction(int signum, const struct sigaction *act,
- struct sigaction *oldact);
复制代码 功能:可以读取和修改与指定信号相关联的处理动作
参数
- signum:指定要处理的信号编号。
- act:指向一个 struct sigaction 布局的指针,该布局包含新的信号处理信息。如果此参数为 NULL,则 sigaction 调用会返回当前信号的处理信息,而不会改变它。
- oldact:指向一个 struct sigaction 布局的指针,该布局用于存储先前信号的处理信息。如果此参数为 NULL,则不会返回旧的处理信息。
struct sigaction 布局
- struct sigaction {
- void (*sa_handler)(int);
- void (*sa_sigaction)(int, siginfo_t *, void *);
- sigset_t sa_mask;
- int sa_flags;
- void (*sa_restorer)(void);
- };
复制代码 这里重点只讲授sa_handler和sa_mask。
- sa_handler:这是一个指向信号处理函数的指针,雷同于 signal 函数中的处理函数。
- sa_mask:在信号处理函数执行期间,要阻塞的信号聚集。
我们先使用下面的简朴代码来使用一下sigaction函数,我们发现sigaction函数确实能自界说捕捉信号。
当某个信号的处理函数被调用时,内核主动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时主动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
在下面这段代码中,我们使用sigaction函数对2号信号举行自界说捕捉,自界说函数中循环打印当前进程的pending表,运行步伐观察现象,起首我们按ctrl+c给进程发送一个2号信号,然后进程就开始自界说处理2号信号,不绝打印进程的pending表,开始我们看到pending表中每一位都为0,然后我们再向进程发送2号信号,我们发现pending表中第2位上的内容为1,这就代表2号信号目前处于未决状态,这就是因为在处理2号信号的时候,2号信号被屏蔽了,导致2号信号无法被递达,以是不绝处于未决状态。
如果在调用信号处理函数时,除了当前信号被主动屏蔽之外,还希望主动屏蔽别的一些信号,则用sa_mask字段说明这些必要额外屏蔽的信号,当信号处理函数返回时主动恢复原来的信号屏蔽字。
在下面这段代码中,我们使用sigaction函数对2号信号举行自界说捕捉,而且将3号信号添加到sa_mask中,自界说函数中循环打印当前进程的pending表,运行步伐观察现象,起首我们按ctrl+c给进程发送一个2号信号,然后进程就开始自界说处理2号信号,不绝打印进程的pending表,开始我们看到pending表中每一位都为0,然后我们再向进程发送2号信号,我们发现pending表中第2位上的内容为1,然后我们再向进程发送3号信号,我们发现pending表中第3位上的内容为1,我们发现3号信号确实被屏蔽了,不绝处于未决状态。
- #include <iostream>
- #include <unistd.h>
- #include <signal.h>
- #include <string.h>
- #include <stdlib.h>
- using namespace std;
- void PrintPending(const sigset_t &pending)
- {
- for (int signo = 31; signo > 0; signo--)
- {
- if (sigismember(&pending, signo))
- {
- cout << "1";
- }
- else
- {
- cout << "0";
- }
- }
- cout << endl;
- }
- void handler(int signo)
- {
- cout << "get a sig :" << signo << endl;
- while (1)
- {
- sigset_t pending;
- sigpending(&pending);
- PrintPending(pending);
- sleep(1);
- }
- }
- int main()
- {
- cout << "pid :" << getpid() << endl;
- struct sigaction act, oact;
- act.sa_handler = handler;
- sigemptyset(&act.sa_mask);
- sigaddset(&act.sa_mask, 3);
- sigaction(2, &act, &oact);
- while (1)
- {
- sleep(1);
- }
- return 0;
- }
复制代码 五、信号的其他补充问题
5.1 可重入函数
main函数调用insert函数向一个链表head中头插节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中头插节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果main函数和sighandler先后向链表中插入两个节点,导致末了只有一个节点真正插入链表中了。
像上例这样,insert函数被差别的控制流程调用,有大概在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有大概因为重入而造成繁芜,像这样的函数称为 不可重入函数,反之如果一个函数只访问自己的局部变量或参数,则称为可重入函数。函数是否可重入描述的是函数的特性,并没有优劣之分。
5.2 关键字volatile
我们计划下面一个代码,界说一个全局变量flag,我们在main函数中,循环判断!flag而且循环没有函数体,main函数中只有这一个地方使用到了flag,我们对2号信号举行自界说捕捉,并在自界说函数中修改flag的值,运行步伐观察结果,当我们将步伐运行起来后,向进程发送2号信号,处理2号信号时,修改了flag的值,处理完后,不满意循环条件,跳出循环,然后进程退出。
我们知道编译器有优化选项的,gcc编译时默认不优化,我们可以带对应的选项对代码举行优化,下面照旧雷同的代码,在编译时,我们进步优化品级,运行步伐观察结果,当我们运行步伐后,再向进程发送2号信号,我们发现flag被改变了,但是进程却没有退出,这是为什么呢?
while循环是逻辑计算也是计算的一种,flag是全局变量,步伐被加载到内存后,flag也就在内存中了,计算只能由CPU举行,CPU中有一个寄存器会读取flag的值,每次循环判断都将flag读取到寄存器中,根据条件的真假来决定下一步应该做什么,在这里我们向进程发送2号信号,flag被设为1,不满意循环条件,然后进程就退出了。
而优化后会导致汇编发生改变,由于main函数中没有其他地方使用到flag,以是寄存器只会一开始读取内存中flag的值,反面就再也不读取了,而CPU要举行判断时,就直接读取寄存器中的内容,根据真假来执行后序任务,纵然我们反面发送信号导致flag发生改变,但实际改变的是内存中的flag,与寄存器没有任何关系,这就是导致这里的循环条件不绝成立,进程不退出的原因。
以是为了防止编译器过度的优化,导致CPU只读取寄存器中的内容,我们可以给变量加上volatile,可以使循环判断时,先由寄存器读取内存中flag的值,然后CPU再读取寄存器中的值。
以是关键字volatile的作用就是保持内存的可见性。
5.3 SIGCHLD信号
我们在讲父子进程的时候,讲到过子进程退出后,父进程必要举行等待,但是子进程退出后并不是什么都不做的,子进程在退出后会给父进程发送SIGCHLD(17号)信号,通过下面的代码和现象来看,我们发现子进程确实会在退出后给父进程发送17号信号,而且变为了阻塞状态,等待父进程的回收。
实在,子进程在停止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 界说SIGCHLD信号的处理函数,这样父进程只需用心处理自己的工作,不必关心子进程了,子进程 停止时会关照父进程,父进程在信号处理函数中调用wait清算子进程即可。
既然子进程退出时会给父进程发送17号信号,那么我们就可以基于信号的自界说捕捉来回收子进程了,而且这时候也不会产生僵尸进程。
要想不产生僵尸进程另有别的一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在停止时会主动清算掉,不会产生僵尸进程,也不会关照父进程。体系默认的忽略动作和用户用sigaction函数自界说的忽略通常是没有区别的,但这是一个特例。
父进程等待的目的并不是单单的处理子进程的僵尸状态,另有大概是想要获取子进程的退出信息,不想获取退出信息就可以直接忽略SIGCHLD信号。
末端
如果有什么发起和疑问,或是有什么错误,大家可以在批评区中提出。
希望大家以后也能和我一起进步!! |