Bootstrap

【Linux初阶】信号入门2 | 信号阻塞、捕捉、保存


☀️前言

通过我们上一篇文章的学习,我们知道信号的生命周期包括四个阶段:预备、信号产生、信号保存、信号处理。同时我们还接触到了信号的保存位置:信号被保存在进程的 task_struct中。知道了信号发送的本质就是修改进程 task_struct中的位图结构

在本片文章中,我将带领大家更加深入学习信号阻塞、捕捉、保存的知识。


☀️一、信号阻塞

🌻1.信号其他相关常见概念

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)
  • 进程可以选择阻塞 (Block ) 某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

对于信号的发送我们还要树立两点共识:1.信号发送是以操作系统为载体,向目标进程发送信号的。2.因为我们的信号不会被立即处理,因此信号产生和信号递达之间就会产生一个简单的时间窗口,在这个时间窗口中,信号已经收到了但是没有被立即处理,因此我们需要将信号保存起来。

🌻2.信号在内核中的表示

图示1:
在这里插入图片描述

  • task_struct中有两张位图和一个指针,它们分别是 pending位图block位图指向 hander函数指针数组的指针
  • pending位图默认为0,它可以表示为32个比特位,比特位的位置表示信号编号,比特位的内容(0 or 1)表示的是是否收到该信号
  • block位图默认也为0,它也可表示32个比特位,比特位的位置表示信号编号,比特位的内容表示 是否阻塞该信号
  • 指针指向 hander函数指针数组,我们可以把它简称为 hander表,数组的下标表示信号的编号,数组下标对应的函数内容表示 对应信号的处理方法
  • 图中右上角为信号的递达的伪代码,它告诉我们:如果一个信号阻塞了,信号就不会递达
  • 图的最上方有一个 signal函数,我们可以通过信号内核表示加以理解:signo代表信号编号,handler表示修改函数数组对应信号的处理方法。

总结:1.如果一个信号没有产生,并不妨碍它被阻塞。2.进程为什么能识别信号?因为每个信号都有自己对应的 pending位图、block位图 和 hander表。

图示2:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HKN04IS3-1690967125451)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230730232240040.png)]


☀️二、信号捕捉(重点)

通过上面的学习,我们知道:信号产生的时候,不会被立即处理,而是会在合适的时候被处理。

那么问题来了,究竟合适的时候是什么时候呢?答案是:从内核态返回用户态的时候,进行处理

🌻1.用户态 & 内核态

  • 进程在运行时,有两种状态(运行级别),它们分别是:用户态内核态
  • 用户态:运行我们在电脑上自己写的代码(包括数据结构等),都是在用户态下完成的。
  • 内核态:运行系统调用,就是在内核态完成的。
  • 用户为了访问某些资源(OS or 硬件),必须通过系统调用完成,因此在访问过程中需要 状态改变
  • 系统调用比较费时间,因此我们应尽量避免频繁调用系统调用。举一个简单的例子:当我们使用 vector进行扩容的时候,计算机往往会为我们多申请一些空间,这就是为了避免频繁调用系统调用。

🌻2.如何判断进程处于用户态或内核态

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ev86QH5C-1690967125453)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230730231112099.png)]

  • CPU中有很多可见和不可见的寄存器,它们保存有当前运行进程的上下文数据。
  • CPU有专门的寄存器可以指向进程的 task_struct(PCB),和页表(用户级页表 & 内核级页表)的起始地址。
  • CPU内有一个名为 CR3的寄存器,它表征当前进程的运行级别:0-内核态,3-用户态

🌻3.OS接口的访问方法

在这里插入图片描述

  • 进程的 task_strcut有指向该进程的地址空间(mm_strcut)的指针,地址空间分为内核空间(1G)和用户空间(3G),因此页表也有两个:用户级页表内核级页表
  • 内核级页表指向 内核对应的虚拟地址空间。
  • 每个进程都有自己的地址空间,由于不同进程共用同一个内核,每个进程的地址空间中的内核空间都是同一个,因此内核级页表只要有一份就够了,它指向同一份虚拟地址空间和物理内存。
  • 进程要访问OS的接口,因为每个进程的地址空间中都带有同一个内核空间,因此只需要在地址空间中自行跳转到内核空间访问即可

总结:1.用户访问OS的过程:运行到特定代码 -> 系统调用(起始位置会更改CR3寄存器)-> 查看CR3寄存器(确认运行状态) -> 跳转到内核空间进行访问 -> 访问完成 -> 更改CR3寄存器 -> 返回并继续执行下一行代码。

🌻4.信号的捕捉过程

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-l2m3h8QD-1690967125455)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230731231739555.png)]

  • 在有需要的时候陷入内核用户态 -> 内核态)。
  • 由于陷入内核会会产生一定的成本(系统调用比较费时间),因此内核处理完对应的工作或异常的时候,不会立即返回运行下一条代码,而是会以内核的身份处理一些只有内核才能完成的额外的工作。
  • 内核处理完对应的工作后会进行信号检测和递达处理
  • 信号处理分为3种:默认、忽略、自定义。大部分信号的处理方式为终止对应进程,忽略即不需要处理,自定义就是如同 signal函数一样执行我们定义的方法。
  • OS会检测是否收到某一信号、该信号是否阻塞、处理方法是哪个。
  • 如果确认收到某一信号,该信号没有阻塞,且为自定义处理方法,则:回到用户态,执行对应的自定义处理方法。(内核态 -> 用户态
  • 注意:我们不能用用户态执行内核的代码(权限不足),也不能用内核态执行用户态的代码(避免用户对内核的恶意访问)。
  • 执行完自定义处理方法之后,不能直接跳转回代码部分运行下一条代码。这是因为在我们使用系统调用时,我们的部分数据(代码运行位置)是由OS保存的,因此我们需要使用OS的身份进行恢复,再跳转回去运行下一条代码。
  • 执行完自定义处理方法之后,需要重新回到内核态,恢复数据用户态 -> 内核态)。
  • 再使用特定的系统调用,回到代码运行的地方内核态 -> 用户态)。
  • 至此,完成了信号捕捉的全过程,然后继续运行下一条代码。

信号捕捉巧记图:红色圆圈代表操作,绿色圆圈代表状态切换(4个操作 + 4次状态切换),如果信号的执行方法为默认或者忽略,则不会再沿图示路径进行下去。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YIEIQDXR-1690967125456)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230731235922188.png)]

通过学习信号的捕捉过程,我们就可以更加深入理解到本节开头时候的话:信号产生的时候,不会被立即处理,而是会在合适的时候被处理,即从内核态返回用户态的时候


☀️三、信号保存1

综合我们学习的知识,我们可以得出:信号产生之后不会立即递达,而是会在合适的时候递达,因此我们的信号在这个时间周期内需要被保存。信号被保存在进程的 task_struct中,信号发送(保存)的本质就是修改进程 task_struct中的位图结构

这里我们再复习一下信号递达和信号未决的知识点,方便后面的学习:

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)

🌻1.sigset_t

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NQ8SwR98-1690967125456)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230730232240040.png)]

  • 从上图来看,每个信号只有一个bit的未决标志(判断是否收到该信号),非0即1,不记录该信号产生了多少次,阻塞标志(判断信号是否阻塞)也是这样表示的。
  • 因此,未决和阻塞标志可以用相同的数据类型 sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态
  • 下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

🌻2. 信号集操作函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。

#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); 
  • 函数 sigemptyset初始化 set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
  • 函数 sigfifillset初始化 set所指向的信号集,使其中所有信号的对应bit置为1,表示该信号集的有效信号包括系统支持的所有信号。
  • 注意,在使用sigset_ t类型的变量之前,一定要调 用 sigemptysetsigfifillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用 sigaddsetsigdelset在该信号集中添加或删除某种有效信号。
  • 这四个函数都是成功返回0,出错返回-1。
  • sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

🌻3.sigprocmask

调用函数 sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
返回值:若成功则为0,若出错则为-1
  • 如果 oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出
  • 如果 set是非空指针,则更改进程的信号屏蔽字
  • 参数 how指示如何更改
  • 如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
  • 假设当前的信号屏蔽字为mask,下表说明了how参数的可选值

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4UoEX5Jk-1690967125457)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230801105602021.png)]

  • 如果调用 sigprocmask解除了对当前若干个未决信号的阻塞,则在 sigprocmask返回前,至少将其中一个信号递达

🌻4.sigpending

#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1

总结:sigprocmask - 修改block位图(阻塞信号集/信号屏蔽字),sigpending - 获取pending位图(未决信号集),signal - 修改信号处理方法。

🌻5.代码示例

  • 下面代码讲述的是如何调整信号屏蔽字
#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,3};
static vector<int> sigarr = { 2 };

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";
        }
  • 运行结果如下

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hxSWfAj0-1690967125459)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230801225551420.png)]

总结:我们可以通过信号集操作函数初始化信号集,并将需要屏蔽的信号加入屏蔽信号集中,然后用 sigprocmask函数将信号集内容射入内核,然后通过 sigpending函数查看 pending信号集。上面的示例显示,当我们屏蔽2号信号之后,我们输入 ctrl+C 后会将信号存储于 pending信号集中,而不会递达,即不会执行 signal函数中的 myhandler方法。


☀️四、信号保存2

🌻1.sigaction

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

知识点1:

  • sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。
  • signo是指定信号的编号。
  • act指针非空,则根据act修改该信号的处理动作。
  • oact指针非 空,则通过oact传出该信号原来的处理动作。
  • act和oact指向sigaction结构体
  • sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。

知识点2:

  • 当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止
  • 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
  • sa_flflags/sa_flags字段包含一些选项,本章的代码都把sa_flflags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段,有兴趣的同学可以在了解一下。

🌻2.代码示例 - sigaction

  • 下述代码用于验证:某个信号在递达时,该信号会被屏蔽。
 #include <iostream>
 #include <cstdio>
 #include <signal.h>
 #include <unistd.h>

 using namespace std;

 void Count(int cnt)
 {
     while(cnt)
     {
         printf("cnt: %2d\r", cnt);
         fflush(stdout);
         cnt--;
         sleep(1);
     }
     printf("\n");
 }

 void handler(int signo)
 {
     cout << "get a signo: " << signo << "正在处理中..." << endl;
     Count(20); //调用计时程序
 }

 int main()
 {
     struct sigaction act, oact; 
     act.sa_handler = handler;
     act.sa_flags = 0;
     sigemptyset(&act.sa_mask); // 当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中
     sigaddset(&act.sa_mask, 3); //对3号信号也添加屏蔽
     sigaction(SIGINT, &act, &oact); //SIGINT为2号信号

     while(true) sleep(1);

     return 0;
 }
  • 运行结果如下

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SstA1NV6-1690967793303)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230801233636876.png)]

总结:代码运行时,在第一个信号递达过程中(计数器开始计时),我们再向该进程发送2号信号则无法递达,第二次发送的2号信号将被保存在 pending位图中,等待第一次发送的信号递达完成之后才会执行对应方法,第3、4…次的信号发送均会失效/丢失。

🌻3.可重入函数

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IPuHK8xW-1690967125460)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230802001418612.png)]

  • main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步(如上图insert代码所示),刚做完第一步的 时候,因为硬件中断(该进程的时间片到了)使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
  • main 执行流 和 handler执行流(信号捕捉执行流)是两个不同的执行流,它们之间相互独立
  • 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

🌻4.volatile关键字

  • 该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下
[ldx@localhost code_test]$ cat sig.c
#include <stdio.h>
#include <signal.h>

int flag = 0;

void handler(int sig)
{
	printf("chage flag 0 to 1\n");
	flag = 1;
}

int main()
{
	signal(2, handler);
	while (!flag);
	printf("process quit normal\n");
	return 0;
}

[ldx@localhost code_test]$ cat Makefile
sig : sig.c
gcc -o sig sig.c #-O2      #使用#号屏蔽优化,02为优化级别
.PHONY : clean
clean :
rm - f sig

[ldx@localhost code_test]$ ./sig
^ Cchage flag 0 to 1
process quit normal

标准情况下,键入 Ctrl-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 , while 条件不满足,退出循环,进程退出

[ldx@localhost code_test]$ cat sig.c
#include <stdio.h>
#include <signal.h>

int flag = 0;

void handler(int sig)
{
	printf("chage flag 0 to 1\n");
	flag = 1;
}

int main()
{
	signal(2, handler);
	while (!flag);
	printf("process quit normal\n");
	return 0;
}

[ldx@localhost code_test]$ cat Makefile
sig : sig.c
gcc -o sig sig.c -O2       #放开屏蔽,设置优化级别02
.PHONY : clean
clean :
rm - f sig

[ldx@localhost code_test]$ ./sig
^ Cchage flag 0 to 1
^ Cchage flag 0 to 1
^ Cchage flag 0 to 1

我们的代码在编译过程中,编译器会对其进行优化,优化有不同级别,优化情况下,键入 Ctrl-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 ,但是 while 条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?很明显while 循环检查的flag,并不是内存中最新的flag,这就存在了数据二异性的问题。 while检测的flag其实已经因为优化,被放在了CPU寄存器当中。如何解决呢?很明显需要 volatile

  • 优化过程中,编译器认为while循环中的flag不会被修改,因此它默认提前将flag的值加载到cup中去了,然后让出资源执行其他代码去了,即对于 flag只做了检测,没有做修改。
  • handler中修改的值是内存中的flag值,和已经 load到cpu中的 flag数据并不相同,只要cpu(寄存器)中的flag不变,那么循环就会一直进行下去。
[ldx@localhost code_test]$ cat sig.c

#include <stdio.h>
#include <signal.h>

volatile int flag = 0; //在全局变量前加volatile关键字

void handler(int sig)
{
	printf("chage flag 0 to 1\n");
	flag = 1;
}

int main()
{
	signal(2, handler);
	while (!flag);
	printf("process quit normal\n");
	return 0;
}

[ldx@localhost code_test]$ cat Makefile
sig : sig.c
gcc - o sig sig.c - O2
.PHONY : clean
clean :
rm - f sig

[ldx@localhost code_test]$ . / sig
^ Cchage flag 0 to 1
process quit normal

volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作

我们需要根据实际应用场景(优化级别比较高且存在需要更新的判断变量),判断我们是否需要添加volatile 关键字。

☀️五、信号总结

  • 下面是我在前面的信号文章中给出的信号生命周期图

在这里插入图片描述

  • 下面是信号的知识点汇总,方便大家对应回顾

  • 信号的预备,信号的基本概念。

  • 信号的产生,信号的产生方法,发送本质。

  • 信号捕捉(用户态内核态 & OS接口的访问方法 & 捕捉过程)

  • 信号的保存,保存位置,保存方法,未决与递达的概念,信号阻塞,信号集及其操作,修改信号屏蔽字的方法,查看pending位图的方法,多次发送同一信号的现象。

  • 信号处理,信号递达。


☀️结语

🌹🌹 信号阻塞 & 信号捕捉 & 信号的保存 的知识大概就讲到这里啦,博主后续会继续更新更多C++ 和 Linux的相关知识,干货满满,如果觉得博主写的还不错的话,希望各位小伙伴不要吝啬手中的三连哦!你们的支持是博主坚持创作的动力!💪💪

;