目录
[一 前言](#一 前言)
[二 信号在内核中的表示](#二 信号在内核中的表示)
[三 sigset_t](#三 sigset_t)
[四 信号集操作](#四 信号集操作)
[1. sigpending()](#1. sigpending())
[2. sigemptyset()](#2. sigemptyset())
[3. sigfillset()](#3. sigfillset())
[4. sigaddset ()和sigdelset()](#4. sigaddset ()和sigdelset())
[5. sigismember()](#5. sigismember())
[6. sigprocmask()](#6. sigprocmask())
[五 深入理解信号的捕捉流程](#五 深入理解信号的捕捉流程)

一 前言
在Linux: 进程信号初识-CSDN博客信号的初识这一篇我们已经了解了什么是信号,和信号的产生及信号的捕捉,但是那些都是信号在用户层的理解,同时也产生了几个问题:
-
之前讲到的所有进程信号的产生都需要OS来执行,为什么?
-
我们提到进程在接收到信号之后通常有着三种处理方式(1. 忽略此信号。 2. 执行该信号的默认处理动作。 3. 自定义处理动作),那么针对这三种处理方式,进程是立即处理的吗?如果不是立即处理,那么信号是否需要暂时被进程记录下来?记录下来放在哪里呢?
-
一个进程在没有收到信号的时候,怎么知道自己应该对合法信号作何处理呢?
-
怎么理解OS向进程发送信号?是怎么发送的?具体情况是什么?
接下来我们将从系统内核层面着重讨论和理解进程信号产生之后进程处理信号的详细操作以及进程信号的产生到进程接收之间内核做了哪些事情。
为了后续学习,我们需要知道信号其他相关概念
- 执行信号的处理动作称为信息递达(Delivery)
- 信号从产生到递达之间的状态成为信号未决(Pending)
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程接触对信号的阻塞。才执行递达的动作。
- 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是递达之后的可选的一种处理动作。
二 信号在内核中的表示
在上篇Linux: 进程信号初识-CSDN博客,我们简单提到过,进程信号保存是以位图的方式存储在进程的PCB中的,通常每个信号对应位图中的一个特定位置,即一个比特位,如果该比特位设置为1,表示对应信号已经收到但是尚未处理;若为0.则表示没有收到该信号。事实上,在PCB中描述着一个有关进程信号的位图和一个有关进程信号的指针数组如下

-
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志**(pending),位图比特位设为1**,直到信号递达才清除该标志。在上图的例子 中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
-
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前 不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
-
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,也就是说阻塞信号,可以在没有信号传递时就可以阻塞,它的处理动作是用户自定义函数sighandler。
-
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理? POSIX.1允许系统递送该信号一次 或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可 以依次放在一个队列里。本章不讨论实时信号。
-
pending位图 :pending表示信号未决,所以它是未决位图,用来表示进程收到了信号,对应位置即为对应编号的信号,当该位图中的某个位置设置为1时,即表示此位的信号在进程中处于未决状态,即接收到了信号但是还未处理。
-
handler指针数组:显而易见,存储的是信号处理方法的数组,每位对应一个处理方法。
-
block位图:阻塞位图,表示对应位置的进程信号是否阻塞,当指定位置为1时,即表示此位置的信号会被阻塞
三 sigset_t
从上面来看,pending位图和block位图所表示的信息能力都是有限的,其每一位的0 1都只能表示进程信号是否存在或者阻塞并不能表示有多少信号产生并 发送给了进程。
在Linux操作系统中pending和block并不是以整型来表示位图的,而是以一个结构体的形式sigset_t

sigset_t 是一个 typedef 出来的类型, 实际上是一个结构体**__sigset_t,** 不过这个结构体内部只有一个 unsigned long int 类型的数组 ,也就是说pending和block位图其实是以数组的形式表现出来的。
其中, 实际以 sigset_t 形式表现的 pending位图, 被称为未决信号集; 同样以 sigset_t 形式表现的 block位图, 被称为阻塞信号集, 也叫信号屏蔽集。
四 信号集操作
信号集实际上是以数组来表示位图的,且系统为用户提供了相关的系统调用接口:
cpp
int sigpending(sigset_t *set);
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. sigpending()

该接口的作用是检查未决信号集,即获取进程的未决信号集。其参数是一个输出型参数,获取的未决信号集的內容会存储在传入的变量中,成功则返回0,错误返回-1.
2. sigemptyset()

**调用此接口会将传入的信号集初始化为空, 即所有信号、阻塞会被消除, 信号集的所有位设置为0,**成功返回0, 错误返回-1
3. sigfillset()

调用此函数, 会将传入的信号集所有位设置为1.成功返回0, 错误返回-1
4. sigaddset ()和sigdelset()

前者的作用是,给指定信号集中添加指定信号, 即将指定信号集中的指定位置设置为1
后者的作用是, 删除指定信号集中的指定信号, 即将指定信号集中的指定位置设置为0
这两个函数, 都是成功返回0, 失败返回-1
5. sigismember()

调此函数, 可以判断信号集中是否有某信号 即 判断信号集的某位是否为1
如果信号在信号集中返回1,如果不在返回0,如果出现错误 则返回-1
6. sigprocmask()

这个接口的作用是:获取 和 修改信号屏蔽集合(阻塞信号集)
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信 号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后 根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

下面我用图片的形式 解释一下:
1.为指定位置添加阻塞

2.为指定信号解除阻塞

3.直接设置信号屏蔽字

直接将传入的
set覆盖进程原来的信号屏蔽字
,将传入的set作为进程新的信号屏蔽字
测试:
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
#define BLOCK_SIGNAL 2 //需要屏蔽信号2
#define MAX_SIGNUM 31
static void show_pending(const sigset_t & pending)
{
for(int signo=MAX_SIGNUM;signo>=1;signo--)
{
if(sigismember(&pending,signo))
{
std::cout<<"1";
}
else std::cout<<"0";
}
std::cout<<"\n";
}
int main()
{
//先尝试屏蔽指定信号
sigset_t block,oblock,pending;
//1.初始化
sigemptyset(&block);
sigemptyset(&oblock);
sigemptyset(&pending);
//1.2添加要屏蔽的信号
sigaddset(&block,BLOCK_SIGNAL);
//1.3开始屏蔽
sigprocmask(SIG_SETMASK,&block,&oblock);
//2.遍历打印pending 信号集
while(true)
{
//2.1初始化
sigemptyset(&pending);
//2.2获取
sigpending(&pending);
//2.3打印
show_pending(pending);
sleep(1);
}
return 0;
}

五 深入理解信号的捕捉流程
在前言部分,我们曾说过信号产生的时候,不会立即被处理,而是在合适的时候,那是在什么时候呢?
🍉:从内核态返回用户态的时候,进行处理。所以什么是内核态?什么又是用户态呢?
1.进程的内核态与用户态
我们知道对于系统中的每一个进程其都有自己的一份独立的程序地址空间

且进程地址空间与物理内存是通过页表映射的。但是之前讲到的Linux : 进程地址空间-CSDN博客只是用户空间部分与物理内存之间的相互映射。
事实上 ,对于1GB的内核空间,也存在着一张页表,用于内核空间和物理内存之间的相互映射,称为内核级页表

如上图所示,所有的进程都用着同一张内核级页表,也就是说每个进程的内核空间的内容是一样的,也就是说物理内存中只加载着一份有关于进程内核空间内容的数据代码。
如果每个进程都可以访问及随意修改内核空间中的数据代码,这是一件很恐怖的事情,毕竟操作系统做了那么多工作,提供了那么多系统调用封装了那么多系统接口,就是为了不让用户直接操作系统内核。所以为了保护这部分数据代码,进程会分为两种状态: 内核态 和 用户态
🌔当进程 需要访问、调用、执行 内核数据或 代码(系统调用等)时, 就会 陷入内核, 转化为内核态, 因为只有进程处于内核态时, 才有权限访问内核级页表, 即有权限访问内核数据与代码。
🌖当进程不需要访问、调用、执行内核数据或代码或系统调用结束时, 就会返回用户, 转化为用户态 , 此时 进程将不具备访问内核级页表的权限, 只能访问用户级页表

那么系统如何分清当前的进程处于哪一种状态下呢?
事实上,在CPU内部存在着一个 状态寄存器CR3,此寄存器内有比特标识位标识当前进程的状态。
若标识位 表示0, 则表明进程此时处于内核态
若标识位 表示3, 则表明进程此时处于用户态
在操作系统中,当进程处于运行时,它会有两种状态,用户态和内核态,且在进程的整个周期内会发生着无数次的状态转换。我们平常写的代码大部分情况下是没有资格直接访问系统的软硬件资源的 ,本质上我们都是通过调用系统所提供的接口,通过系统去访问这些资源,这样的情况下,进程需要访问硬件资源地时候,就会无数次地陷入内核(切换状态、切换页表),再访问内核代码数据, 然后完成访问,再将结果返回给用户(切换状态,切换页表),最终用户得到结果。
还有一种情况,在用户不调用任何函数的时候,这时候还会发生进程状态的转换吗?答案是会的。因为只要是进程, 那么他就有一定的时间片. 即使是一个什么都不执行的死循环, 只要时间片用完了, 那么就需要将此进程从CPU上剥离下来, 而剥离操作一定是操作系统做的, 那么也就是说将 进程从CPU上剥离下来也是需要陷入内核执行内核代码的. 将进程从CPU上剥离下来的时候, 需要维护一下进程的上下文, 以便下次接着执行进程的代码.剥离下来之后, 操作系统执行调度算法, 将下一个需要运行的进程的上下文加载到CPU中。
🍰:下面我们举个例子详细分析一下在进程切换状态的时候,信号在什么时候处理。

如图所示:
- 代码在运行到需要执行系统调用signal()接口的时候,此时进程就需要陷入内核态执行signal()代码
- 陷入内核并执行完signal()代码后, 需要将signal()结果返回给用户, 需要转换回用户态
- 在转换回用户态之前, 需要先在进程PCB中检测进程的未决信号集
- 在未决信号集中, 检测到1和2信号未决, 并且均未被屏蔽(阻塞). 就需要在handler数组中寻找指定的处理方法
- 1信号默认处理, 需要执行内核中的默认处理方法(一般为进程终止); 2信号忽略处理, 直接将未决信号集中2信号改为0
- 处理完信号, 再将signal()结果返回给用户, 这个过程需要转换为用户态
🍅:上面的信号的处理方式都是默认或者是忽略,但是如果我们捕捉了一个信号并且让它按照自定义的方式处理,这时候在最后一步怎么办?
进程首先会从内核态切换为用户态去执行用户自定义的信号处理动作,(虽然内核态也能处理用户态的代码,但是操作系统不会这样做,因为万一用户自己写个Bug,内核态去执行的话会影响操作系统。)
其次进程现在是用户态,此时进程是无法返回到进程原本代码的执行位置的。因为进程执行系统调用之后的返回信息还在内核中,以用户态的身份是无法访问并返回给用户 的。 所以, 进程还需要再次陷入内核,转换成内核态 然后根据内核中的返回信息使用特定的返回调用 返回到用户.
图示如下:

🍎可以看到如果处理信号**需要执行用户自定义的处理方法
时, 那么 从调用内核代码到返回用户的整个过程一共需要经历4次状态转换
**
🍏而, 如果处理信号**不需要执行用户自定义处理方法
时, 那么 从调用内核代码到返回用户的整个过程 就只需要经历2次状态转换
**
简化图如下:

缩略图:
