目录
[3、sigfillset(sigset_t *set);](#3、sigfillset(sigset_t *set);)
[4、int sigaddset ()和sigdelset()](#4、int sigaddset ()和sigdelset())
一、前言
在信号这一篇我们已经了解了什么是信号,和信号的产生方式及信号的捕捉,但是那些都是信号在用户层的理解,同时也产生了几个问题:
- 之前讲到的所有进程信号的产生都需要OS来执行,为什么?
- 我们提到进程在接收到信号之后通常有着三种处理方式,那么针对这三种处理方式,进程是立即处理的吗?如果不是立即处理,那么信号是否需要暂时被进程记录下来?记录下来放在哪里呢?
- 一个进程在没有收到信号的时候,怎么知道自己应该对合法信号作何处理呢?
- 怎么理解OS向进程发送信号?是怎么发送的?具体情况是什么?
接下来我们将从系统内核层面着重讨论和理解进程信号产生之后进程处理信号的详细操作以及进程信号的产生到进程接收之间内核做了哪些事情。
首先我们还得知道几个概念:
- **信号递达:**进程对信号的实际处理动作。
- **信号未决(pending):**信号从产生到递达之间的状态,即信号接收到了但是还没有处理的状态。
- 进程可以选择阻塞(Block)某个信号,即进程接收到了信号,但是阻塞信号递达,就是不做处理。
- 当信号被阻塞时,该信号将保持未决状态,知道进程解除对此信号的阻塞,此时信号才会递达。
- 注意 信号阻塞 和 信号忽略 不同,信号忽略是对信号的处理动作,即信号已经递达了。而阻塞是信号抵达之前的状态。
二、系统内核中的信号
在上篇文章中,我们简单提到过,进程信号是以位图的方式存储在进程的PCB中的,通常每个信号对应位图中的一个特定位置,即一个比特位,如果该比特位设置为1,表示对应信号已经收到但是尚未处理;若为0.则表示没有收到该信号。那么具体是什么样子的呢?
事实上,在PCB中描述着两个有关进程的位图和一个有关进程信号的指针数组如下
- **pending位图:**pending表示信号未决,所以它是未决位图,用来表示进程收到了信号,对应位置即为对应编号的信号,当该位图中的某个位置设置为1时,即表示此位的信号在进程中处于未决状态,即接收到了信号但是还未处理。
- **handler指针数组:**显而易见,存储的是信号处理方法的数组,每位对应一个处理方法。
- **block位图:**阻塞位图,表示对应位置的进程信号是否阻塞,当指定位置为1时,即表示此位置的信号会被阻塞。
分析上图:
1、SIGHUP(1) 信号, 进程对此信号的处理方法是 SIG_DFL(默认处理动作), 进程并没有收到此信号(pending为0), 也没有阻塞此信号递达(block为0),所以当该信号递达时执行默认处理动作。
2、SIGINT(2) 信号, 进程对此信号的处理方法是 SIG_IGN(忽略), 进程收到了此信号(pending为1), 但是进程会阻塞此信号递达(block为1), 即 进程收到的信号会一直处于未决状态(pending一直为1). 除非阻塞解除。但是在解除对这个信号的阻塞前仍然不能忽略这个信号,因为进程仍有机会改变处理动作之后在接触阻塞。
3、SIGQUIT(3) 信号, 此进程对此信号的处理方法是自定义的 sighandler(), 进程没有收到此信号(pending为0), 但是进程会阻塞此信号递达(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(sigset_t *set);
调用此函数, 会将传入的信号集所有位设置为1.成功返回0, 错误返回-1
4、int sigaddset ()和sigdelset()
前者的作用是,给指定信号集中添加指定信号, 即 将指定信号集中的指定位置设置为1
后者的作用是, 删除指定信号集中的指定信号, 即 将指定信号集中的指定位置设置为0
着两个函数, 都是成功返回0, 失败返回-1.
5、sigismember()
调用此函数, 可以判断 信号集中是否有某信号. 即 判断信号集的某位是否为1
如果 信号在信号集中 返回1, 如果不在 返回0, 如果出现错误 则返回-1
6、sigprocmask()
这个接口的作用是:获取 和 修改信号屏蔽字(阻塞信号集)
1、首先是第二个参数
第二个参数需要传入一个信号集,此信号集是 修改进程的信号屏蔽字用的,此参数需要根据第一个参数的不同来传入不同意义的信号集
2、然后是第三个参数
该参数也是需要传入一个信号集,不过传入被全部置为0的信号集。此参数是一个输出型参数 ,用于获取 没做修改的信号屏蔽字,即该函数接口执行完毕后,此参数里存放的是没有执行此函数的原始的信号屏蔽字。
3、第一个参数
该参数是一个整型参数,需要传入的是系统提供的宏,不同宏的选择此函数会有不同的功能, 就需要传入不同意义的 set(第二个参数)
即如果想要为指定位置添加阻塞
为指定位置解除阻塞
直接设置阻塞
五、信号集操作代码演示
接下来我们演示,先对该进程的信号屏蔽字(阻塞集)做修改,将信号屏蔽字置为0,即将所有的普通信号都进行阻塞。
接着该进程的未决信号集都置为0,表示此时没有产生任何信号。
接着我们通过命令行 kill 的方式不断给该进程发送信号。
通过程序内的打印进程的未决信号集的一个函数我们就可以看到该进程的当前的未决信号集。
cpp
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
using std::cout;
using std::cerr;
using std::endl;
int cnt = 0;
void handler(int signo) {
cout << "我是进程, pid: " << getpid() << ", 我捕捉到一个信号:" << signo << ", count: " << cnt++ << endl;
}
// 打印信号集的函数
void showSignals(sigset_t *signals) {
// 使用 sigismember() 接口判断 31个普通信号是否在信号集中存在
// 存在的信号输出1, 否则输出0
for(int sig = 1; sig <= 31; sig++) {
if(sigismember(signals, sig)) {
cout << "1";
}
else {
cout << "0";
}
}
cout << endl;
}
int main() {
// 先输出进程的 pid
cout << "pid: " << getpid() << endl;
// 定义sigsetmask()需要使用的 set 和 oldset, 并初始化
sigset_t sigset, osigset;
sigemptyset(&sigset);
sigemptyset(&osigset);
// 将进程的 所有普通信号屏蔽
for(int sig = 1; sig <= 31; sig++) {
sigaddset(&sigset, sig);
signal(sig, handler);
}
sigprocmask(SIG_BLOCK, &sigset, &osigset);
// 获取并打印进程的未决信号集
sigset_t pendings;
while(true) {
sigemptyset(&pendings);
sigpending(&pendings);
showSignals(&pendings);
sleep(1);
}
return 0;
}
运行结果,可以看到结果符合预期实验,也同时验证了9号信号的特殊之处(不能被捕获、忽略或阻塞)。
接下来我们设置一段时间以后放开一些信号的阻塞,看看结果
六、深入理解进程的信号处理
在文章开篇的时候我们提到过几个问题,第二个问题是当进程收到信号之后会立即处理吗?答案是不会,进程会在合适的时候处理信号,那么这个合适的时间是什么时候呢?
合适的时候,即当进程从内核态转换为用户态的时候,进程会进行信号的检测与处理,所以什么是内核态?什么又是用户态呢?
1、进程的内核态与用户态
我们知道对于系统中的每一个进程其都有自己的一份独立的程序地址空间
且进程地址空间与物理内存是通过页表映射的。但是之前讲到的只是 用户空间部分与物理内存之间的相互映射。
事实上 ,对于1GB的内核空间,也存在着一张页表,用于内核空间和物理内存之间的相互映射,称为内核级页表
且与用户级的页表不同,内核级的页表整个操作系统只有一张,即在整个操作系统中,所有的进程共用一张内核级页表。
如上图所示,所有的进程都用着同一张内核级页表,也就是说每个进程的内核空间的内容是一样的,也就是说物理内存中只加载着一份有关于进程内核空间内容的数据代码。
想象一下,如果每个进程都可以访问及随意修改内核空间中的数据代码,这是一件很恐怖的事情,毕竟操作系统做了那么多工作,提供了那么多系统调用封装了那么多系统接口,就是为了不让用户直接操作系统内核。
所以为了保护这部分数据代码,进程会分为两种状态: 内核态 和 用户态
当进程 需要访问、调用、执行 内核数据 或 代码(中断、陷阱、系统调用等)时
, 就会****陷入内核** , 转化为
内核态** , 因为只有 进程处于内核态时, 才有权限访问内核级页表, 即有权限访问内核数据与代码。
当进程**不需要访问、调用、执行 内核数据 或 代码, 或进程时间片结束时
**, 就会 返回用户, 转化为
用户态 , 此时 进程将不具备访问内核级页表的权限, 只能访问用户级页表
也就是在进程从内核态转换为用户态时,系统才会检测进程的信号并处理。 那么系统如何分清当前的进程处于哪一种状态下呢?
事实上,在CPU内部存在着一个 状态寄存器CR3,此寄存器内有比特标识位标识当前进程的状态。
- 若标识位 表示0, 则表明进程此时处于内核态
- 若标识位 表示3, 则表明进程此时处于用户态
在操作系统中,当进程处于运行时,它会有两种状态,用户态和内核态,且在进程的整个周期内会发生着无数次的状态转换。因为我们使用的语言大部分情况下是 没有资格直接访问系统的软硬件资源的 ,本质上我们都是通过调用系统所提供的接口,通过系统去访问这些资源,这样的情况下,进程需要访问硬件资源地时候,就会无数次地陷入内核(切换状态、切换页表),再访问内核代码数据, 然后完成访问,再将结果返回给用户(切换状态,切换页表),最终用户得到结果。
还有一种情况,在用户不调用任何函数的时候,这时候还会发生进程状态的转换吗?答案是会的。因为只要是进程, 那么他就有一定的时间片. 即使是一个什么都不执行的死循环, 只要时间片用完了, 那么就需要将此进程从CPU上剥离下来,而剥离操作一定是操作系统做的 , 那么也就是说将 进程从CPU上剥离下来也是需要陷入内核执行内核代码的 . 将进程从CPU上剥离下来的时候, 需要维护一下进程的上下文, 以便下次接着执行进程的代码.剥离下来之后, 操作系统执行调度算法, 将下一个需要运行的进程的上下文加载到CPU中, 然后新进程从内核态转换为用户态, CPU再执行新进程的代码.
进程被剥离下来, 进程会进入内核态维护起来. 等待下次运行时, 又会回到用户态执行代码.
下面我们举个例子详细分析一下在进程切换状态的时候,信号在什么时候处理。
如图所示:
- 代码在运行到需要执行系统调用open()接口的时候,此时进程就需要陷入内核态执行open()代码
- 陷入内核并执行完open()代码后, 需要将open()结果返回给用户, 需要转换回用户态
- 在转换回用户态之前, 需要先在进程PCB中检测进程的未决信号集
- 在未决信号集中, 检测到1和2信号未决, 并且均未被屏蔽(阻塞). 就需要在handler数组中寻找指定的处理方法
- 1信号默认处理, 需要执行内核中的默认处理方法(一般为进程终止); 2信号忽略处理, 直接将未决信号集中2信号改为0
- 处理完信号, 再将open()结果返回给用户, 这个过程需要**
转换为用户态
**
上面的信号的处理方式都是默认或者是忽略,但是如果我们捕捉了一个信号并且让它按照自定义的方式处理,这时候在最后一步怎么办?
实际上进程就会先去 执行自定义的信号处理动作,然后再将open()的结果返回给用户。且自定义函数是用户实现的,所以进程需要先切回用户态执行完信号的自定义处理动作函数。
接着进程原本是因为需要执行内核代码才陷入内核的, 只是在执行完毕之后需要先处理一下信号才暂时回到了用户态。此时进程是无法返回到进程原本代码的执行位置的。因为进程执行内核代码之后的返回信息还在内核中,以用户态的身份是无法访问并返回给用户的。
所以, 进程在以用户态的身份执行过信号的用户处理方法之后, 还需要再次陷入内核, 然后根据内核中的返回信息使用特定的返回调用 返回到用户.
图示如下:
可以看到, 如果处理信号需要执行用户自定义的处理方法
时, 那么 从调用内核代码到返回用户 的整个过程一共需要 经历4次状态转换
而, 如果处理信号不需要执行用户自定义处理方法
时, 那么 从调用内核代码到返回用户 的整个过程 就只需要 经历2次状态转换
下面是简化版
需要注意的是
进程执行完用户自定义信号处理方法 返回内核之后, 之后的执行流程与PCB信号集有一个交点.
此交点表示, 此时 还会进行 信号集的检测.
如果此时又有信号未决, 并且时间片充足, 那么就会再次处理.
在进程处理信号时, 如果操作系统还向进程发送相同的信号, 进程时不会处理的. 因为pending信号集中 只能表示信号是否存在, 而不能记录信号被发送过来的次数. 也就是说, 信号未决时, 依旧有相同的信号发送过来, 进程不会处理后续的信号.