上节我们了解到了预备(信号是什么,信号的基础知识)再到信号的产生(四种方式)。今天我们了解信号的保存。信号产生,进程不一定立马就去处理,而是等合适的时间去处理,那么在这段时间内,进程就需要保存信号,到了合适时间再去执行!
目录
一、递达,阻塞,未决
我们知道,信号是发送给进程的,而进程又是被操作系统创建pcb(信号的相关信息被保存到进程pcb中)而进行管理的,所以修改或者访问进程pcb都需要操作系统来进行,那么信号发送的本质就是:操作系统在向进程发送信号。
信号产生,进程不一定立马就去处理,而是等合适的时间去处理,那么在这段时间内,进程就需要保存信号,到了合适时间再去执行!那么实际执行信号的处理动作称为信号递达;信号从产生到递达之间的状态,称为信号未决(Pending)。进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。我们之前知道,进程递达之后的动作有三种:默认动作、自定义动作、忽略动作(执行动作,只不过这个动作就是什么都不做)。注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
二、信号的保存
我们知道信号是保存到进程pcb中的,信号产生、信号递达、信号阻塞、信号未决这些到底怎么实现的呢?我们来看:
2.1 信号在内核中的数据结构构成
上图就是信号在内核中的数据结构构成,我们来慢慢了解。首先信号的相关信息都在进程pcb中存储,判断信号发送给进程后的状态都是位图来实现的。
unsigned int pending = 0;这是信号未决的位图结构,一共有32个比特位,分别代表32个进程信号的编号,当然比特位的内容(0/1)也代表进程是否收到了对应的信号,收到信号但未递达,对应编号的比特位就会由0改为1。
unsign int block =0 ;这是信号阻塞的位图结构,一共有32个比特位,分别代表32个进程信号的编号,当然比特位的内容(0/1)也代表进程是否阻塞了对应的信号,收到信号被阻塞,对应编号的比特位就会由0改为1。
如果某个信号被阻塞,那么阻塞位图结构中对应的比特位(信号编号)就会置为1,那么在此信号阻塞未被解除之前,会一直处于信号未决(信号产生但未被处理)非阻塞被解除。
handler_t handler[32] :信号递达后要处理动作,那么handler这个数组中一定存放着信号编号所对应的处理动作。handler_t 其实是函数指针类型,typedef void(*handler)(int signo); 参数是信号编号,返回值是void的函数指针。数组的下标就是对应的信号编号,数组下标中的内容就是对应信号的处理方法(函数指针)。
当调用signal(signo,handler); ,就会把信号对应的处理方法设置为自定义方法,内核中就是将数组下标(信号编号)中的内容(处理方法)设置为自定义方法的函数指针。从而在递达后执行处理方法。
所以我们知道,为什么进程可以识别信号呢?原来是因为程序员在设计进程的时候,已经为进程设计好了这三种结构,从而去识别信号!
2.3 用户态和内核态
信号产生时,进程可能不会立马去处理,而是等待合适的时机,那么这个合适的时机是什么时候呢?是从内核态返回到用户态!哦吼,那什么是用户态和内核态呢?我们来看:
我们编写的代码一般都是用户层级的代码,那当我们去调用接口去访问os自身的资源(getpid等等),去printf(访问硬件资源)的时候,这就需要我们切换身份为内核态去执行这些操作!访问不同的资源始终是进程,但是当他的身份不同的时候,那么可以访问的资源就是不同的!
用户为了访问内核或者硬件资源,必须通过系统接口完成访问。那么系统调用肯定是比进程互相调用用户态层级的代码慢得多,因为他需要身份的切换等等,所以我们尽量避免频繁的调用系统接口。(这就是为什么vector中的扩容他需要一次性去扩充1.5/2倍的空间,因为这样就可以避免频繁的扩容,导致频繁的去调用系统接口,导致速度和效率大大下降)
那么我们就会想,那到底是怎么操作这个身份的呢?如何就知道它是内核态或者用户态的呢?我们都知道进程在执行时,会将此进程的上下文投递到cpu的寄存器中,那么此时cpu中还有很多寄存器存放着不同的信息:
cpu内部的寄存器分为:1.可见寄存器 2.不可见寄存器。其中,有存放着进程pcb的起始地址的寄存器(这样就可以访问进程的所有信息),有存放页表起始地址的寄存器,也有存放着当前进程的运行级别的寄存器(利用位图结构,来表示不同的级别),所以当进程去访问内核的资源的时候,os就会到cpu的CR3去看进程的运行级别,如果处于内核态,那可以访问,反之。
我们了解了访问的条件,但是他到底是如何到os中访问资源呢?来看:
每一个进程都有[3,4]G的内核空间,[1,3]G的用户空间,且都享有同一个内核级页表。
之前我们知道,当动态库加载到物理内存时,是可以通过页表映射到进程空间的共享区,之后在执行代码若执行到共享区的代码时,就会在当前地址空间(起始地址+偏移量的方式)去跳转到共享区去执行代码,执行完毕后,再回到对应执行的代码。每一个进程他都有自己的一套内核结构(进程的独立性),且都有不同的用户级页表。
但若去访问操作系统的资源,因为操作系统只有一个,当开机时,操作系统的资源会被加载到物理内存,进程访问时,通过同一个内核级页表。所以无论进程怎么切换,都不会更改3-4G的内核空间。
那什么时候从用户态切换到内核态呢?系统调用的最开始。(根据 Int 80(汇编代码),会把寄存器中的进程运行级别状态修改。(系统调用最开始就设计了这样))
2.3 信号的捕捉流程
我么们了解了内核态和用户态以后,就可以了解到,原来信号产生,不会立即被进程所处理动作,而是等到合适的时机去处理,这个合适的时机就是内核态切到用户态的时候。那我们一定之前就进入了内核态,我们来看:
当进程需要访问内核资源的时,就会通过系统调用来切换身份,由用户态切换到内核态,之后进行系统调用(cpu中改变身份,通过内核级页表去访问内核资源),到这里本应该就是切换到用户态返回的,但是来都来了,而且切换到内核态确实不容易。所以就会通过进程中的pending,block,headler进行信号的检测过程(先在pending中查看信号是否存在,再到block中查看是否被阻塞,如果阻塞则该信号处于未决,继续查看pending中的下一个信号,如果没有被阻塞,那就信号递达,通过handler去处理动作(默认、自定义、忽略)。当然在信号递达前,会将pending中该信号对应的比特位由1变为0,再去执行。
忽略其实最容易执行,只需要将pending中1改为0以后,啥都不做;而自定义就需要再将身份切换为用户态,然后去执行handler中的方法。那为什么不直接在内核态中去执行用户态中的方法呢?是因为操作系统不信任任何人,如果用户态的代码是问题代码,那么就会导致操作系统出现严重问题,所以会先切换用户态,再去执行handler中对应的方法(用户态执行一些代码会受到限制)。递达后为什么不直接回到进程中呢?是因为我们没办法直接回到当前进程执行的位置,这个过程需要操作系统的操作。所以只能再回到内核态,再由内核态切到用户态回到进程执行的位置。
我们直接抽象看本质:
四个交点(四次身份切换)
在用户态中因为一些原因陷入内核,执行系统调用后,在内核态中再进行信号的检测过程,再由内核态切换到用户态执行方法,完毕后再切换身份回到内核态,通过信号检测结束后,再身份切换,回到进程执行流中上次中断的地方。
三、sigset_t 信号集
我们知道信号是在进程的pcb中,即内核中。所以用户级操作难免会困难一些。所以sigset_t 信号集就是为了更好的在用户级操作信号所产生的类型。sigset_t 信号集包括 pending信号集、信号屏蔽字(block信号集)。sigset_t 底层就是一个大数组实现的位图结构。
信号集操作函数:
#include <signal.h>int sigemptyset(sigset_t *set); //初始化set信号集int sigfillset(sigset_t *set); //将信号集set全部设置为1int sigaddset (sigset_t *set, int signo); //往set信号集添加信号int sigdelset(sigset_t *set, int signo); //删除set信号集中的信号这四个函数都是成功返回0,出错返回-1 。int sigismember(const sigset_t *set, int signo); //判断信号是否在set中sigismember 是一个布尔函数 , 用于判断一个信号集的有效信号中是否包含某种信号, 若包含则返回 1, 不包含则返回 0, 出错返回 -1 。
sigprocmask调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字 ( 阻塞信号集)#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oset);how就是下面的几种方式:
返回值 : 若成功则为 0, 若出错则为 -1
sigpending#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oset);返回值 : 若成功则为 0, 若出错则为 -1读取当前进程的未决信号集 , 通过 set 参数传出。调用成功则返回 0, 出错则返回 -1。
下面我们利用上面所学,来实现一个观察pending信号集,通过信号屏蔽子来观察pending信号集的变化:
#include <iostream>
#include <vector>
#include <signal.h>
#include <unistd.h>
// #define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31
using namespace std;
static vector<int> sigarr = {2};
//输出pending信号集
static void show_pending(const sigset_t &pending)
{
for(int signo = MAX_SIGNUM; signo >= 1; signo--)
{
if(sigismember(&pending, signo))
{
cout << "1";
}
else cout << "0";
}
cout << "\n";
}
//递达自定义动作
static void myhandler(int signo)
{
cout << signo << " 号信号已经被递达!!" << endl;
}
int main()
{
for(const auto &sig : sigarr) signal(sig, myhandler);
// 1. 先尝试屏蔽指定的信号
sigset_t block, oblock, pending;
// 1.1 初始化
sigemptyset(&block);
sigemptyset(&oblock);
sigemptyset(&pending);
// 1.2 添加要屏蔽的信号
for(const auto &sig : sigarr) sigaddset(&block, sig); //批量化屏蔽
// 1.3 开始屏蔽,设置进内核(进程)
sigprocmask(SIG_SETMASK, &block, &oblock);
// 2. 遍历打印pengding信号集
int cnt = 10;
while(true)
{
// 2.1 初始化
sigemptyset(&pending);
// 2.2 获取它
sigpending(&pending);
// 2.3 打印它
show_pending(pending);
// 3. 慢一点
sleep(1);
if(cnt-- == 0)
{
sigprocmask(SIG_SETMASK, &oblock, &block); // 一旦对特定信号进行解除屏蔽,一般OS要至少立马递达一个信号!
cout << "恢复对信号的屏蔽,不屏蔽任何信号\n";
}
}
}
四、信号的处理细节
4.1 对于同类型信号的处理
当我们正在递达一个信号期间,同类型的信号无法被递达!(信号的处理细节)
当信号正在被递达中,又来了同类型的信号,此时当前信号会被加入到进程的信号屏蔽字,且会将pending中该信号对应的那一位由0变为1。(因为该信号被递达前,会将pending中对应的那一位由1改为0),若结束递达后,同类型仍发送,则会继续重复上面的动作。但若结束递达后,同类型的信号没有发送了,进程就只会再捕捉一次,将pending中的1改为0。递达后则继续检其他信号进行递达。
进程处理信号的原则是穿行的处理同类型的信号,不允许递归处理!
4.2 可重入函数和不可重入函数
举例说明:
在main执行流中,没有头结点的单链表进行头插,如上图所示:在执行到第一步时,此时被信号中断,结果导致main中还没有执行完又进入insert()中,最后回到main执行流中,再执行完剩下的代码结果导致内存泄漏等问题。
1.一般而言,main执行流和信号捕捉执行流是两个执行流!
2.如果在main中,和在handler中,该函数被反复进入:1出现问题的就是不可重入函数;2.没有出现问题的就是可重入函数。当然可重入和不可重入只是他们的特性,没有好坏之分。
4.3 volatile关键字
我们在读取变量的值时,一般会从内存中读取,但是由于编译器的优化,就会将内存中的值加载到cpu的寄存器中,从而之后访问该变量的值只会从寄存器中读取,如果这个变量的值被修改了,自然而然内存上的值也被修改了,但是寄存器中的值仍然没有变化,还是修改之前的值,所以为了避免这种优化产生的后果,我们就会在变量前加上volatile,意为一直从内存中读取值!
总结:
我们了解了信号的保存原来是通过进程pcb中的pending、block位图,handler函数指针数组来进行保存,从而信号递达。