【Linux系统篇】:信号的生命周期---从触发到保存与捕捉的底层逻辑

✨感谢您阅读本篇文章,文章内容是个人学习笔记的整理,如果哪里有误的话还请您指正噢✨
✨ 个人主页:余辉zmh--CSDN博客
✨ 文章所属专栏:Linux篇--CSDN博客

文章目录

前言:关于信号的基础概念和信号产生相关的内容在上一篇文章中已经讲解,感兴趣的可以看上一篇文章。本篇文章重点讲解信号的保存与捕捉机制。

一.信号保存

1.什么是信号发送

信号是由操作系统发送给进程的,对于发送普通信号来说,实际上是发送给进程的PCB

之前讲过信号编号1~31对应的是普通信号,加上0号位置表示没有收到信号正好对应32个编号。

而一个整形有4字节,一字节是八比特位,所以正好有32个比特位。如果用一个整形位图来表示,1~31个比特位正好对应31个普通信号。

比特位的位置(第几个),表示信号的编号;比特位的内容是0还是1,表明是否收到对应编号的信号

所谓的"发信号",本质上是系统去修改task_struct的信号位图对应的比特位,实际上应该叫做"写信号"

系统是进程的管理者,所以只有系统采用资格修改tast_struct内部的属性!!!

2.信号其他相关常见概念

  • 实际执行信号的处理动作叫做信号递达
  • 信号从产生到递达之间的状态的叫做信号未决(实际上就是信号保存阶段);
  • 进程可以选择性的阻塞(屏蔽)某个信号,被阻塞的信号产生时将保持在未决状态,知道进程解除对该信号的阻塞,才能执行信号处理(递达);
  • 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略则是在递达之后的一种处理动作。

3.信号保存原理

为什么要保存信号,因为信号产生后可能并不会立即处理,所以一定会存在一个时间窗口用来保存信号,此时的信号状态就是信号未决。

下图是信号在内核中的表示示意图:

  • pending是一个位图结构,用来保存未决信号,信号产生到递达这一时间段的信号;下标对应信号编号,内容0表示信号没有产生,内容1表示信号产生,但还没有递达,处于未决状态。
  • block也是一个位图结构,用来保存被屏蔽的信号 ,下标对应信号编号,内容0表示该信号没有被屏蔽;内容1表示该信号被屏蔽(注意,屏蔽是一种状态,和有没有产生没有关系,比如在没有产生之间提前屏蔽掉,产生之后就会一直处于未决状态,不递达,直到解除屏蔽状态,比如图中的SIGOUIT信号)。信号产生时默认为非屏蔽状态,进程可以选择该信号是否要屏蔽。
  • handler是一个函数指针数组,每一个信号都要有自己的一种处理方法;以信号编号为下标,相应位置存放的是该信号的处理方法(本质上是一个函数指针,用来调用对应的函数);如果是默认处理,存放的就是指向默认处理函数的指针。

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,系统在进程控制块中设置该信号的未决状态,直到信号递达才能清除该标志;如果被屏蔽,就会一直处于未决状态,直到解除屏蔽

对于发送普通信号,因为只存在一个pengding位图,所以系统多次发送某一个信号,最终只会被记录一次

发送信号本质上是修改pengding位图表,捕捉信号本质上是修改handle表中的函数指针

根据信号编号到handler数组中找对应的处理方法这一过程和硬件中断非常相似,而信号其实就是模拟硬件中断来实现的

4.信号集以及相关操作函数

1.什么是信号集

信号集sigset_t是系统提供的一种内核数据结构类型,方便用户对进程的pendingblock位图表进行操作。这个类型可以表示每个信号的"有效(1)"和"无效(0)"状态,在阻塞信号集中有效和无效表示该信号是否被屏蔽(阻塞);而在未决信号集中有效和无效表示该信号是否处于未决状态。

cpp 复制代码
//定义一个信号集
struct sigset_t set;

2.相关操作函数

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

// 参数sigset_t *set表示目标信号集的地址 int signo 表示目标信号

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 sigismembet(const sigset_t *set, int signo);
  • sigemptyset函数用来初始化参数set指向的信号集,将其中所有信号的状态设置为无效,表示该信号集不包含任何有效的信号。

  • sigfillset函数和上面的那个相反,将其中所有信号的状态设置为有效,表示该信号集不包含任何无效的信号。

  • sigaddset函数将信号集中的指定信号修改成有效状态,表示添加。

  • sigdelset函数将信号集中的指定信号修改成无效状态,表示删除。

  • sigismembet函数用来判断信号集中的有效信号中是否包含目标信号,若包含则返回1,不包含则返回0,出错返回-1。

前四个函数的返回值都是成功返回0,失败返回-1。

注意:从定义一个信号集到通过相关函数进行操作,都只是在用户层面上对我们自己定义的这个信号集进行操作,并不是对进程PCB中的pengdingblock位图进行操作

5.sigprocmask函数

调用该函数可以读取和更改进程的信号屏蔽字(阻塞信号集,就是进程的block位图)。

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

int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

//返回值:成功返回0,失败返回-1

参数

how是标志位,不同的选项,函数实现功能不同:

how set oset
SIG_BLOCK 向进程的信号屏蔽字(block位图中)添加set信号集中的所有有效信号 获取进程更改前的信号屏蔽字(输出型参数)
SIG_UNBLOCK 向进程的信号屏蔽字中解除set信号集中的所有有效信号 获取进程修改前的信号屏蔽字
SIG_SETMASK 将set信号集直接赋值给进程的信号屏蔽字 获取进程修改前的信号屏蔽字

6.sigpending函数

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

int sigpending(sigset_t *set);

//返回值:成功返回0,失败返回-1

用来获取当前进程的未决信号集(pending位图),通过输出型参数set传出。

测试

通过一个程序用上面的几个函数做一个测试

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;

void Print(const sigset_t &pending){
    for (int signo = 31; signo >= 1; signo--){
        cout << sigismember(&pending, signo);
    }
    cout << endl;
}

void handler(int signo){
    cout << "catch a signo: " << signo << endl;
}

int main(){
    // 0. 捕捉2号信号
    signal(2, handler);
    
    // 1. 先对2号信号进行屏蔽
    sigset_t bset, oset;
    sigemptyset(&bset);
    sigemptyset(&oset);
    sigaddset(&bset, 2);    //此时只是在用户层面上将信号集中的2号信号屏蔽,并没有设置到进程的block位图中
    // 调用系统调用,将数据设置进内核中
    sigprocmask(SIG_SETMASK, &bset, &oset);

    // 2. 重复打印当前进程的未决信号集
    sigset_t pending;
    int cnt = 0;
    while (true)
    {
        // 2.1 调用系统调用获取进程的pending位图
        int n = sigpending(&pending);
        if (n < 0){
            continue;
        }
        // 2.2 打印pending信号集
        Print(pending);

        sleep(1);
        cnt++;

        // 2.3 解除阻塞
        if(cnt==20){
            cout << "unblock 2 signo" << endl;
            sigprocmask(SIG_SETMASK, &oset, nullptr);
        }
    }

    // 3. 发送2号信号

    return 0;
}

二.信号捕捉

1.内核如何实现信号的捕捉

如果信号的处理动作是用户自定义的函数,在信号递达时就会调用这个函数,这个过程就是信号捕捉。

当进程从内核态返回到用户态的时候,会进行信号的检测和处理

先来搞清楚什么是用户态,什么是内核态?

每个进程都有一个大小为4GB的地址空间,其中3GB为用户空间,通过用户级页表映射的是物理内存上用户的代码和数据;而剩余1GB则是内核空间,通过内核级页表影射的是物理内存上的操作系统的代码和数据

因为进程具有独立性,有几个进程,系统中就有几份用户级页表 ;但是操作系统只有一个,所有进程都被同一个系统管理,所以内核级页表只有一个

1.每一个进程看到的内核空间这1GB内容都是一样的,整个系统中,进程再怎么切换,这1GB的空间内容是不变的!!!

2.以进程的视角看待,调用系统中的方法(系统调用函数),就是在当前进程的地址空间中的内核空间进行执行的。

3.以系统的视角看待,任何一个时刻,都有进程在执行,想执行操作系统的代码(系统调用函数),就可以随时执行!

4.计算机硬件中有一个时钟芯片,每个很短的时间向系统发送时钟中断,系统收到中断,就会开始执行对应的方法,所以操作系统是由硬件的时钟中断来推动执行的,本质上就是一个基于时钟中断的死循环

用户态:CPU执行进程时只能访问用户空间的内存区域,也就是只能用户的代码和数据;程序的大部分代码都在用户态执行。

内核态:CPU执行进程时,可以访问所有内存,包括用户空间和内核空间,也就是可以访问用户和系统的代码和数据;对于系统调用,中断处理,异常等只能在内核态执行。

状态切换

  • 用户态--->内核态:通过系统调用,中断,异常等触发
  • 内核态--->用户态:系统调用完成或中断处理等结束时返回

状态标识

CPU有一个ecs寄存器,该寄存器中的其中两个比特位组合表示权限位:00内核态11用户态

由用户态切换到内核态(11--->00),需要借助int 80陷入内核。

信号的捕捉过程图

由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序自定义了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler函数和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

三.补充内容

1.可重入函数

以链表的头插函数为例:

cpp 复制代码
ListNode node1,node2,*head;

void sighandler(int signo){
    ...
    insert(&node2);
    ...
}

void insert(ListNode *p){
    p->next=head;  //第一步
    head=p;        //第二步
}

int main(){
    ...
    insert(&node1);
    ...
}

假设当前mian函数正在执行头插函数,将node1节点头插到链表中,刚做完第一步时,因为硬件中断进程切换到内核态,再次回用户态之前检查到有信号待处理,于是切换到用户态的sighandler函数,而信号的自定义函数此时也需要调用头插函数,将node2节点头插到链表中,插入完成后从用户态sighandler函数返回到内核态,再次检查后没有其他信号待处理,所以重新返回到用户态的main函数,先前完成第一步后被打断,所以现在继续执行第二步。

最后的结果就是main函数和sighandler函数先后向链表中插入两个节点,最后只有node1一个节点真正插入到链表中,而node2节点则会造成丢失,导致内存泄漏

在上面的过程中,信号的sighandler执行流和信号是否到来有关系,和main函数没有关系。执行main函数和信号处理是两套不同的逻辑。这两个过程是在一个进程的上下文里执行的,但是属于两种不同的执行流。

如果一个函数像上面的头插函数一样,被重复进入的情况下,导致出错或者可能出错,这个函数就是不可重入函数 ,否则就是可重入函数

目前学过的大多数函数都是不可重入函数

可重入和不可重入描述的是函数特点

2.volatile关键字

站在信号的角度理解volatile关键字。

测试代码:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;

int flag = 0;

void handler(int signo){
    flag = 1;
    cout << "catch a signo: " << signo << endl;
}

int main(){
    signal(2, handler);

    while(!flag)
        ;
    cout << "process quit!" << endl;

    return 0;
}

正常编译之后,执行生成的可执行程序,结果如下:

收到2号信号后被捕捉,执行自定义动作,修改全局变量flag=1,此时返回到main函数后,while条件不满足,退出循环,然后进程退出。

优化情况下,编译时加上-O3选项,重新生成可执行程序并执行,结果如下:

收到2号信号被捕捉,执行自定义动作,修改全局变量flag=1,此时返回到main函数后,while条件依旧满足,进程继续执行!

为什么会有这样的现象?

这是因为,在没有优化前,CPU寄存器中的flag变量在每次执行条件判断时,会重新获取内存中的flag变量的值,所以执行信号处理动作后,即使变量值修改,重新从内存中获取就会得到更改后的值,然后条件判断不满足,退出循环。

而优化后,CPU寄存器中的flag变量只在第一次从内存中获取后,之后不再从内存中获取,后续执行条件判断时一直保持不变(一直为0不变),所以即使执行信号处理动作后,内存中的flag变量修改,CPU寄存器中的flag变量还是不变,所以就会继续循环执行。

如果在flag变量前添加volatile关键字:

cpp 复制代码
volatile int flag=0;

上面的例子就是因为优化,导致内存不可见。而volatile关键字的作用就是防止编译器过度优化,保持内存的可见性

3.SIGCHLD信号

在之前学习进程等待的时候讲解过父进程可以通过waitwaitpid函数来回收子进程,父进程可以阻塞等待子进程结束,也可以非阻塞轮询的方式等待子进程结束;如果是第一种方式,父进程阻塞就不能继续处理自己的工作;而第二种方式父进程在处理自己工作的同时,还要不断轮询查看子进程是否结束。

但是父进程为什么会知道子进程什么时候退出的?

这是因为子进程并不是悄悄退出的,而是在退出的时候,主动向父进程发送一个SIGCHLD信号(信号编号为17),该信号的默认处理动作是忽略,这就导致在表面上看起来父子进程什么都没做,父进程就知道子进程退出了。

父进程可以自定义处理函数,在自定义处理函数中调用waitwaitpid函数,这样父进程只需要专心处理自己的工作,不必关心子进程了,子进程终止退出时,会自动发送信号通知父进程,然后父进程捕捉信号,执行自定义处理动作,完成子进程回收。

这就是基于信号形式的等待

通过一段代码进行测试:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;

void handler(int signo){
    cout << "catch a signo: " << signo << endl;

    pid_t rid = waitpid(-1, nullptr, 0);
    if(rid>0){
        cout << "wait child success!" << endl;
    }
}

int main(){
    signal(17, handler);

    pid_t id = fork();
    if(id==0){
        // child
        int cnt = 3;
        while(cnt>0){
            cout << "I am a child process, pid: " << getpid() << endl;
            sleep(1);
            cnt--;
        }
        cout << "child process quit!" << endl;
        exit(0);
    }
    // father
    int cnt = 5;
    while(cnt>0){
        cout << "I am a father process, pid: " << getpid() << endl;
        sleep(1);
        cnt--;
    }

    return 0;
}

以上就是关于信号第二部分:信号保存于信号捕捉的讲解,如果哪里有错的话,可以在评论区指正,也欢迎大家一起讨论学习,如果对你的学习有帮助的话,点点赞关注支持一下吧!!!

相关推荐
贺函不是涵8 分钟前
【沉浸式求职学习day41】【Servlet】
java·学习·servlet·maven
Excuse_lighttime8 分钟前
JVM 机制
java·linux·jvm
YOYO--小天35 分钟前
4G和5G模块的使用
linux·嵌入式硬件·5g
愚润求学36 分钟前
【Linux】进程间通信(一):认识管道
linux·运维·服务器·开发语言·c++·笔记
渴望技术的猿37 分钟前
Windows 本地部署MinerU详细教程
java·windows·python·mineru
diving deep39 分钟前
XML简要介绍
xml·java·后端
Uranus^41 分钟前
深入解析Spring Boot与Redis集成:高效缓存实践
java·spring boot·redis·缓存
小吕学编程43 分钟前
Jackson使用详解
java·javascript·数据库·json
持之以恒的天秤1 小时前
多线程与线程互斥
linux
光不度AoKaNa1 小时前
计算机操作系统概要
linux·运维·服务器