Linux信号保存的核心:未决信号集与阻塞信号集——探秘内核如何实现信号的阻塞、暂存与派发

🔥海棠蚀omo个人主页

❄️个人专栏《初识数据结构》《C++:从入门到实践》《Linux:从零基础到实践》

追光的人,终会光芒万丈

博主简介:

​​​​​​

目录

一.信号保存相关的常见概念

二.信号在内核中的表示

三.信号集的具体操作

3.1sigset_t类型

3.2信号集操作函数

[3.3 sigprocmask函数](#3.3 sigprocmask函数)

[3.4 sigpending函数](#3.4 sigpending函数)

四.扩展知识

4.1问题一

4.2问题2

4.3问题3

前言:

我们前面在讲解信号产生的时候,说到进程在收到信号时可能并不会立即处理,既然不会处理那就要将信号保存起来,我们当时只简单讲解了在进程的PCB中会有一个位图来保存信号。

而我们今天就来详细探讨一下关于信号保存方面更为详细的知识。

一.信号保存相关的常见概念

1.实际执⾏信号的处理动作称为信号递达(Delivery)
2.信号从产⽣到递达之间的状态,称为信号未决(Pending)。
3.进程可以选择阻塞 (Block )某个信号。
4.被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作。
在这里面呢我们对实际执行信号的处理动作,也就是信号递达是比较熟悉的,我们在上一篇的信号产生中就详细介绍了信号的几种处理方式,这里就不再介绍了。
而在这里面我们相对陌生的就是 阻塞的概念,这个阻塞可不要认为是进程状态中的阻塞,二者只是名字相同,实际上是完全不同的两个概念。
我们可以认为 阻塞信号就是屏蔽信号,下面我用图来帮助大家更好的去理解:

上面我就用图标识出了三个概念所处的阶段, 阻塞/屏蔽信号所处的阶段是在信号未决的阶段,一旦我们将某个信号给阻塞了,那么我们再次向目标进程发送相应的信号,进程就不会进行信号递达,就跟没收到这个信号一样,不会进行任何的处理动作。
可能有人会就觉得:这不就是信号递达中的忽略信号吗?
确实他们所产生的效果是一样的,但是我们要区别于阻塞和忽略,这二者是不同的,只要信号被阻塞就不会递达,而忽略是递达之后可选的一种处理动作,二者所处的阶段是不同的
那么可能有人就会问了:那么忽略信号的处理动作我们可以通过signal函数来执行,那么该如何屏蔽一个信号呢?
这就与我们的下面要讲的内容有关,我们接着往下看。

二.信号在内核中的表示

上面问题的答案就是 在进程的PCB中不只有我们前面讲的保存信号的位图,同样还有一个阻塞的位图,名为:block,我们来看:

而我们前面讲的保存信号的位图叫做: pending,block位图就是用来表示对应编号的信号是否被阻塞的。

而我们要了解的不止上面的两张位图,还有一个 handler表,这个表的本质是一个函数指针数组,里面的指针指向的就是一个个的函数,就如我们前面曾经使用过的的 SIG_DFL,SIG_IGN,当时大家看到这两个可能以为它们是宏,其实他们都是函数。
所以我们的 进程能够识别信号,本质就是通过这三张表:block表,pending表,handler表!!!
这三张表就涵盖了信号从产生到递达这之间的所有行为。
说了这么多,你怎么证明呢?所以下面我们直接从linux的源码中来找这几个:

cpp 复制代码
struct task_struct {
    ...
    /* signal handlers */
    struct sighand_struct *sighand;
    sigset_t blocked
    struct sigpending pending;
    ...
}

struct sighand_struct {
    atomic_t count;
    struct k_sigaction action[_NSIG]; // #define _NSIG 64
    spinlock_t siglock;
};

struct __new_sigaction {
    __sighandler_t sa_handler;
    unsigned long sa_flags;
    void (*sa_restorer)(void); /* Not used by Linux/SPARC */
    __new_sigset_t sa_mask;
};

struct k_sigaction {
    struct __new_sigaction sa;
    void __user *ka_restorer;
};

/* Type of a signal handler. */
typedef void (*__sighandler_t)(int);

struct sigpending {
    struct list_head list;
    sigset_t signal;
}

上面就是我从linux源码中截取的部分代码,从这里面我们就可以看到上面的三张表,证明上面我说的三张表确实是存在的。

而我们对信号的操作实际上都是对这三张表的操作。

三.信号集的具体操作

想必下面我们都对如何对这三张表进行操作比较好奇,那么我们就来看看吧。

3.1sigset_t类型

从上图来看,每个信号只有⼀个bit的未决标志, ⾮0即1, 不记录该信号产⽣了多少次,阻塞标志也是这样表⽰的。
因此, 未决和阻塞标志可以⽤相同的数据类型sigset_t来存储, sigset_t称为信号集 , 这个类型可以表⽰每个信号的"有效"或"⽆效"状态。
阻塞信号集中"有效"和"⽆效"的含义是该信号是否被阻塞, ⽽在未决信号集中"有 效"和"⽆效"的含义是该信号是否处于未决状态 。阻塞信号集也叫做当前进程的 信号屏蔽字(Signal Mask), 这⾥的"屏蔽"应该理解为阻塞⽽不是忽略。

从上面的源代码中我们看到了pending表和block表它们的类型都为sigset_t,上面就是对这个类型详细的介绍,这里我们只要了解上面对这个类型的介绍就足够了。

而为什么要介绍这个类型我们就与下面的知识有关。

3.2信号集操作函数

下面我们就来看看如何对上面的信号集进行操作:

cpp 复制代码
#include <signal.h>

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.函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表⽰该信号集不包含任何有效信号。

2.函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位全部为1,表⽰该信号集包含任何有效信号。

3.函数sigaddset和函数sigdelset作用是将对应的信号向set所指向的信号集进行添加和删除,它们每次只能添加和删除一个信号。

4.函数sigismember作用就是判断对应的信号是否在set所指向的信号集中。

四个函数都是成功就返回0,出错就返回-1

注意:在使⽤sigset_ t类型的变量之前,⼀定要调⽤sigemptyset或sigfillset做初始化,使信号集处于确定的状态

那么我们下面就来写个简单的例子,用一下这些函数:

这里我写了一个很简单的例子,就是创建了一个信号集,并用sigemptyset函数对其进行初始化,那么我问大家一个问题:我们对目前的信号集所进行的修改有没有设置到内核中?

这个问题很关键,答案并没有,因为我们所创建的信号集只是用户空间的栈上的一个变量而已,我们要想改变的进程的信号集是在内核空间中的,所以我们上面对当前信号集所做的任何操作对不会影响到内核空间中的信号集。

那么问题就来了,我们要如何才能对内核中的信号集进行修改呢?

既然是在内核中,而我们要想修改内核中的数据,只有操作系统能做到,所以我们要调用系统调用函数!!!

3.3 sigprocmask函数

那么我们首先讲的就是能够获取和修改阻塞信号集(信号屏蔽字)的系统调用:sigprocmask

上图就是对sigprocmask函数的介绍,要使用这个函数需要引用<signal.h>的头文件。

它的返回值也很简单,成功就返回0,失败就返回-1。

西面我们就来介绍一下这个函数的三个参数:

1.对于第一个int 类型的参数how,有下面几种选择方式:

共有三种选择方式,描述中的set就是函数中的第二个参数。

如果你使用SIG_BLOCK,那么相当于把set中对应的信号给阻塞了;如果你使用SIG_UNBLOCK,那么相当于把set中对应的信号解除阻塞;如果你使用SIG_SETMASK,那么就不是针对某个信号,而是直接用set把内核中的阻塞信号集给覆盖掉,针对的是所有的信号。

2.第二个参数其实上面我们已将讲过了,就是我们自己定义的sigset_t信号集,通过信号集函数对其进行修改后,再传入其中,最终通过sigprocmask系统调用完成对内核中阻塞信号集的修改。

也就是上面的信号集操作函数和这里的sigprocmask配合着使用才能完成对内核中阻塞信号集的修改工作!!!

3.而最后一个参数叫做oldset,这个参数是什么呢?

我们通过sigprocmask函数完成了对内核中阻塞信号集的修改,但是我们如果后悔了,想要找到修改之前的阻塞信号集呢

这个参数就是这个后悔药,它是一个输出型参数,通过sigprocmask函数能够将原来的阻塞信号集给带出来

3.4 sigpending函数

上面我们介绍了针对阻塞信号集的函数,那么下面我们就来介绍针对未决信号集的系统调用函数:

这个函数叫做:sigpending,它的作用仅仅是能够获取到内核中的未决信号集,那么有人就要问了:为什么不能通过这个函数来对内核中的未决信号集进行修改呢?

我们上一篇所讲的**信号产生的各种方式不正是对内核中的未决信号集进行修改吗?**信号产生的方式有很多种,但最终都是由操作系统对进程的未决信号集进行修改的,所以这个函数没必要实现修改内核未决信号集的功能。

那么好,现在已经有了上面知识的铺垫,下面我们就以一个综合的案例来帮助大家消化上面的各种函数:

cpp 复制代码
void PrintPending(sigset_t &pending)
{
    // 从左到右,由高到低,31号信号->1号信号
    cout << "[ pid ]" << getpid() << " sigpending list: ";
    for (int i = 31; i > 0; i--)
    {
        if (sigismember(&pending, i))
        {
            cout << "1";
        }
        else
        {
            cout << "0";
        }
    }
    cout << endl;
}

int main()
{
    sigset_t block;
    sigset_t oblock; // 用于表示修改之前的阻塞信号集
    // 对信号集进行初始化
    sigemptyset(&block);
    sigemptyset(&block);

    // 以阻塞2号信号为例
    sigaddset(&block, 2);

    // 这里以覆盖的形式来演示
    // 修改内核中的阻塞信号集
    sigprocmask(SIG_SETMASK, &block, &oblock);

    while (true)
    {
        sigset_t pending;
        sigpending(&pending);

        PrintPending(pending);
        sleep(1);
    }
    return 0;
}

上面我以阻塞2号信号为例,也就是阻塞了ctrl + c所发送的信号,并且我采用的方式是SIG_SETMASK,也就是用我们自己写的信号集来覆盖掉内核中的阻塞信号集。

阻塞完我们得看到结果,所以我用一个循环来重复打印出内核中的未决信号集,观察我们在阻塞了某个信号后,是否未决信号集收到了该信号并且该信号确实被阻塞了。

打印的函数逻辑也并不难,通过循环和sigismember函数,从左到右依次判断每种信号是否在未决信号集中,在的话就打印出1,不在就打印出0,这样我们就可以将所有的信号都打印出来。

那么代码写完了,我们来看看实战效果:

从结果中我们可以看到,再按下ctrl + c向目标进程发送2号信号后,内核的未决信号集中2号信号的位置由0变为1,说明当前进程确实收到了该信号。

并且我们按下ctrl + c后进程确实也没有执行默认动作终结掉当前进程,说明没有走到信号递达这一步,也证实了该信号确实被我们阻塞了。

通过上面的例子我们确实做到了将某个信号给阻塞掉,也看到了信号被阻塞后的现象,和忽略信号是一样的效果。

四.扩展知识

那么对于上面的例子我还有几个问题,分别是:

1.如果屏蔽了所有的信号,那么该进程还能被终止掉吗?

2.如果解除了对2号信号的阻塞,那么也就意味着内核中未决信号集的2号信号的位置会由1变为0,如何看到由1变为0的现象?

3.解除阻塞后2号信号执行信号递达,pending表中相应位置由1变为0,是在递达前就发生了变化,还是在递达后发生了变化?

4.1问题一

那么要解决这个问题,我们不妨来实践一下,看看效果:

从输出的结果看,好像确实终止不掉这个进程了,我们一连向当前进程发送了多个信号,可以看到每个信号确实都被当前进程接收了,但是都没起作用,也就是都被阻塞了。

那么就真有办法了吗?我们来看:

没错,依旧是我们的9号信号来救场了,在我们讲解上一篇的进程产生时,我们就说了9号信号不可被捕捉,不可被忽略,现在我们又知道了9号信号同样不可被阻塞,也就是9号信号不吃任何异常状态!!!

至此我们就可以得出一个结论了:不是所有的信号都可以被阻塞,部分信号不可被阻塞!!!

4.2问题2

那么我们要解除对2号信号的阻塞,该如何做呢?我们来看:

上面我们就可以通过我们之前定义的oblock来恢复修改前的阻塞信号集,这样就可以解除对2号信号的阻塞,并且为了演示出效果,我对2号信号进行了自定义捕捉,这样我们就能看到未决信号集中2号信号由1变为0的过程。

那么下面我们就来看看效果吧:

从结果中我们也可以看到当循环了15次过后,解除了对2号信号的阻塞,之后便可以看到触发了自定义捕捉的处理动作,执行了handler函数,之后我们就可以看到内核中未决信号集中2号信号的位置已经由1变为0了。

4.3问题3

那么上面内核中未决信号集中2号信号的位置由1变为0是在抵达前就被就修改了还是在递达后被修改了呢?我们下面就来看看:

cpp 复制代码
void handler(int signo)
{
    cout << "捕捉到了信号: " << signo << endl;
    while (true)
    {
        sigset_t pending;
        sigpending(&pending);

        PrintPending(pending);
        sleep(1);
    }
}

我们的思路是:在handler函数中打印出内核中未决信号集的信息,如果执行handler函数时,打印出的结果中2号信号的位置已经由1变为0,说明是在递达前就已经发生了改变,反之就是在递达后发生的变化。

那么是骡子是马,拉出来遛遛就知道了:

从结果中我们可以看到,在执行handler函数时,2号信号的位置已经由1变为0了,所以:pending表中相应信号的位置由1变为0,是在递达前就发生的!!!

至此我们就讲解完了关于信号保存的所有知识,以上就是Linux信号保存的核心:未决信号集与阻塞信号集------探秘内核如何实现信号的阻塞、暂存与派发的全部内容。

相关推荐
傲世(C/C++,Linux)2 小时前
Linux系统编程——TCP服务器
linux·服务器·tcp/ip
不穿格子的程序员3 小时前
操作系统篇4——深入理解操作系统:僵尸进程、孤儿进程与进程调度算法详解
操作系统·僵尸进程·孤儿进程·进程调度
橘子真甜~3 小时前
C/C++ Linux网络编程8 - epoll + ET Reactor TCP服务器
linux·服务器·网络
万变不离其宗_83 小时前
centos 手动安装redis
linux·redis·centos
_lst_3 小时前
linux进程状态
linux·运维·服务器
稚辉君.MCA_P8_Java4 小时前
Gemini永久会员 归并排序(Merge Sort) 基于分治思想(Divide and Conquer)的高效排序算法
java·linux·算法·spring·排序算法
wanderist.4 小时前
Linux使用经验——离线运行python脚本
linux·网络·python
biter00885 小时前
Ubuntu 22.04 有线网络时好时坏?最终解决方案
linux·网络·ubuntu
zzzsde5 小时前
【Linux】基础开发工具(3):编译器
linux·运维·服务器