【linux】信号的保存和递达处理

2023-10-17 13:56:13 浏览数 (1)

        上节我们了解到了预备(信号是什么,信号的基础知识)再到信号的产生(四种方式)。今天我们了解信号的保存。信号产生,进程不一定立马就去处理,而是等合适的时间去处理,那么在这段时间内,进程就需要保存信号,到了合适时间再去执行!


一、递达,阻塞,未决

        我们知道,信号是发送给进程的,而进程又是被操作系统创建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全部设置为1 int 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信号集的变化:

代码语言:javascript复制
#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函数指针数组来进行保存,从而信号递达。 

0 人点赞