Bootstrap

【Linux】信号

在这里插入图片描述

一、信号的概念

我们生活中就有很多信号,例如红绿灯、下课铃声、闹钟声音、电话铃声…,这里我先以红绿灯为例,我为什么认识红绿灯?这里认识包括: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 小结

  1. 问题:上面所说的所有信号产生,最终都要有操作系统来进行发送,为什么?
    :操作系统是进程的管理者。

  2. 问题:信号的处理是否是立即处理的?
    :进程收到信号后并不会立即处理,而是在合适的时候处理。

  3. 问题:信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
    :需要,进程的PCB中有一个位图是用来存储相关信息的。

  4. 问题:一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
    :能够做到,操作系统中为了让进程处理信号,给每个进程都维护了一张表,这个表就是一个函数指针数组,它的下标就是信号的编号,里面存储的也是处理信号的方法。

  5. 问题:如何理解操作系统向进程发送信号?能否描述一下完整的发送处理过程?
    :我认为描述为操作系统向目标进程写信号更形象,操作系统找到目标进程,将目标进程PCB中位图中与信号编号对应的比特位上的内容由0置1。


三、信号的保存

3.1 信号其他相关常见概念

  1. 实际执行信号的处理动作称为信号递达(Delivery)。
    信号抵达又分为信号的默认、信号的忽略和信号的自定义捕捉。
    信号的忽略不是不做处理,而是处理了不做任何事,它就是处理信号的一种方式。
  2. 信号从产生到递达之间的状态,称为信号未决(Pending)。也就是信号在位图中时,信号处于信号未决状态。
  3. 进程可以选择阻塞 (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,表示该信号集不包含任何有效信号。

参数

  • set:指向要初始化的信号集的指针。

3.4.2 sigfillset函数

#include <signal.h>
int sigfillset(sigset_t *set);

功能:初始化set所指向的信号集,使其中所有比特位上的内容变为1,表示该信号集包含任何有效信号。

参数

  • set:指向要初始化的信号集的指针。

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 | set
SIG_UNBLOCKset中包含了我们需要向信号屏蔽字中删除的信号,相对于mask = mask & ~set
SIG_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信号。


结尾

如果有什么建议和疑问,或是有什么错误,大家可以在评论区中提出。
希望大家以后也能和我一起进步!!🌹🌹
如果这篇文章对你有用的话,希望大家给一个三连支持一下!!🌹🌹

在这里插入图片描述

;