Linux:详解进程信号(信号的捕捉流程,信号的阻塞volatile关键字)

Posted It‘s so simple

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux:详解进程信号(信号的捕捉流程,信号的阻塞volatile关键字)相关的知识,希望对你有一定的参考价值。


前言:阅读的时候如有不懂的的地方,可以看我之前写的详解进程信号(信号的种类、产生、注册、注销以及信号的各种处理方式)(一)

1. 信号的捕捉流程

在讲信号的捕捉流程的时候,我们需要首先明确的一个问题是:信号的注册,是否与操作系统所维护的进程PCB有关?

在上一篇文章中也讲了,信号的注册实质是将tast_struct(PCB)结构体中的struct sigpending结构体变量,它所包含的sigset_t signal位图中所对应的那个信号的比特位置为1,然后在sigqueue队列添加一个sigqueue节点。因此,信号的注册,是注册在了进程的PCB中,是与操作系统的PCB直接打交道的

1.1 捕捉流程

要想清楚信号的捕捉流程,我们还得搞清楚的一个问题是信号是什么时候进行处理的?

首先需要知道的是信号的处理是在内核态完成的。就拿printf()函数来说把,他首先是一个库函数,一旦调用,就会调用里面的系统调用函数,进而切换到内核态,完成相应的工作。然后再将相应的结果返回至用户态。

那么当从内核态切换到用户态的时候,就一定会调用do_signal函数来处理进程所收到的信号。

do_signal函数会对所收到的函数做如下操作:(捕捉流程)

  • sig位图中有信号注册,则执行信号注销的逻辑
  • sig位图当中没有信号的注册,则直接返回用户态。

系统调用不会触发信号队列的处理,在每次由核心态切换到用户状态时,内核都会发起信号队列处理。

1.2 处理信号(信号的注销)

上一篇文章也讲过,处理信号有三种方式,分别是默认处理方式忽略处理方式自定义处理方式

默认处理方式和忽略处理方式都是直接在操作系统内核的代码中完成的。

自定义处理方式是调用程序员自己定义的函数。那么若按自定义处理方式处理信号,其处理的流程如下:

  • ① 在用户态执行程序员自定义的函数
  • ② 调用sigreturn函数再次回到操作系统内核
  • ③ 再次调用do_signal函数判断是否有信号注册,如果还有就继续转去处理该信号
  • ④ 调用sys_sigreturn函数回到用户态去执行代码

那么问题来了:什么时候进入操作系统内核?

  • 调用系统调用函数
  • 调用C库函数(C库函数的内部也是调用了系统调用函数)
  • 内存访问越界、访问空指针

1.3 小结(图解)

还是用printf函数来举例,假设我们信号处理的方式是自定义处理方式,那么它的处理流程如下图:

在这里插入图片描述

1.4 用户态和内核态(扩展)

  • 熟悉Unix/Linux系统的人都知道,fork函数的工作实际上是以系统调用的方式完成相应功能的,具体的工作是由sys_fork负责实施。其实无论是不是Unix或者Linux,对于任何操作系统来说,创建一个新的进程都是属于核心功能,因为它要做很多底层细致地工作,消耗系统的物理资源,比如分配物理内存,从父进程拷贝相关信息,拷贝设置页目录页表等等,这些显然不能随便让哪个程序就能去做,于是就自然引出特权级别的概念,显然,最关键性的权力必须由高特权级的程序来执行,这样才可以做到集中管理,减少有限资源的访问和使用冲突。

  • 对于 Unix/Linux来说,只使用了级特权级和3级特权级。也就是说在Unix/Linux系统中,一条工作在级特权级的指令具有了CPU能提供的最高权力,而一条工作在3级特权级的指令具有CPU提供的最低或者说最基本权力

  • 现在我们从特权级的调度来理解用户态和内核态就比较好理解了,当程序运行在3级特权级上时,就可以称之为运行在用户态,因为这是最低特权级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态;反之,当程序运行在级特权级上时,就可以称之为运行在内核态。

  • 虽然用户态下和内核态下工作的程序有很多差别,但最重要的差别就在于特权级的不同,即权力的不同。运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。

  • 当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态。

这里的内容引用自用户态和内核态的概念区别这篇文章,想要详细了解的可以查看这篇文章。

2. 信号的阻塞

首先我们需要知道的是信号的阻塞是不会影响信号的注册

2.1 信号阻塞在内核中的存储表示及阻塞原理

首先我们来查看struct task_struct(PCB)结构体中关于信号阻塞的变量,sigset_t blocked
在这里插入图片描述
在上一篇文章中已经介绍了sigset_t是一个位图,那么我们该如何用sigset_t blocked来表示信号的阻塞呢?图解如下:
在这里插入图片描述

blocked位图的作用是:阻塞某一个信号的处理。

也就意味着执行流进入到内核之后,调用do_signal函数判断是否有信号需要处理的时候,发现某一个信号被注册了,同时也是需要判断blocked位图该信号所对应的比特位是否为1

  • 若为1:则暂时不进行处理(即暂时不进行信号的注销)
  • 若为0:则直接进行处理

2.2 信号的阻塞接口

2.2.1 sigprocmask函数

int sigprocmask(int how,const sigset_t* set,sigset_t* oldset)

参数:

  • how:想让sigprocmask函数做什么事情。(相当于fcntl函数中的cmd参数一样)
  • SIG_BLOCK:设置某个信号为阻塞状态
  • SIG_UNBLOCK:设置某个信号为非阻塞状态
  • SIG_SETMASK:设置新的blocked位图
  • set:使用set位图去设置blocked位图(注意是去设置,而不是去替换),并且它也分为两种情况:
  • how参数中设置为SIG_BLOCK参数(即设置为阻塞),则需要进行按位或的操作。
    例如:block(new)= block(old) | set
  • how参数中设置为SIG_UNBLOCK参数(即设置为非阻塞),则需要对set位图取反,再按位与上blocked位图
    例如:block(new) = block(old) & ~set
  • how参数中设置为SIG_SETMASK参数,则直接去设置新的blocked位图。
    例如:block(new) = set

返回值:

  • 0:成功
  • -1 :失败

2.2.2 sigprocmask函数的代码验证

创建一个文件test.c,在该文件中我们利用sigprocmask函数将blocked位图中全部设置为1,调用sigfillset(sigset_t* set)函数可以将位图中所有的位都设置为1(即设置为满),然后利用while死循环来观察其全部阻塞后的现象。

代码如下:

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

int main()
{
    sigset_t set;
    //设置set位图为满
    sigfillset(&set);
    //调用sigprocmask函数将所有信号都设置为阻塞
    int ret = sigprocmask(SIG_SETMASK,&set,NULL);
    if(ret < 0)
    {
        perror("sigprocmask failed");
        return 0;
    }

    while(1)
    {
        puts("It's test to  sigprocmask");
        sleep(1);
    }
    return 0;
}

运行结果如下:

在这里插入图片描述

根据我们代码的逻辑,当前进程的所有的信号都被阻塞掉,不管你使用什么信号,按理来说都应该被阻塞掉,那么问题来了,我们该如何停止该进程呢?

操作系统为了防止这种情况的发生,将9号信号和19号信号设置为不会被修改,也不会被修改的状态。

  • 使用9号信号杀死进程(SIGKILL)

在这里插入图片描述
查看当前程序发现已经被杀死了
在这里插入图片描述

  • 使用19号信号暂停进程(SIGSTOP)

在这里插入图片描述
查看当前程序发现已经被暂停了
在这里插入图片描述
注:这里暂停的程序,可以使用fg命令进行恢复

2.3 sigset_t信号集操作函数(扩展)

以下sigset_t操作函数均包含在#include<signal.h>头文件中。

int sigemptyset(sigset_t *set);

初始化由set指定的信号集,信号集里面的所有信号被清空。

int sigfillset(sigset_t *set);

调用该函数后,set指向的信号集中将包含linux支持的62种信号。

int sigaddset(sigset_t *set, int signum);

在set指向的信号集中加入signum信号。

int sigdelset(sigset_t *set, int signum);

在set指向的信号集中删除signum信号。

int sigismember(const sigset_t *set, int signum);

判定信号signum是否在set指向的信号集中。

3. 代码验证非可靠信号的注册和可靠信号的注册

可靠信号和非可靠信号的区别就是非可靠信号对多次相同的信号只注册一次,而可靠信号对多次相同的信号可以注册多次。

并且非可靠信号的信号值为1-31,可靠信号的信号值为34-64。

我们可以调用signal函数或sigaction函数,将接收到的信号自定义处理,然后将所有的信号均设置为阻塞状态,再利用kill -[num] PID分别给程序发送5次2号信号,和5次40号信号,然后将所有的信号都恢复原样,然后观察其现象。

代码如下:

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

void sigcallback(int i)
{
    puts("It's test to verify signal");
}

int main()
{

    signal(2,sigcallback);
    signal(40,sigcallback);

    sigset_t set;
    //设置set位图为满
    sigfillset(&set);

    sigset_t oldset;
    //调用sigprocmask函数将所有信号都设置为阻塞
    int ret = sigprocmask(SIG_SETMASK,&set,&oldset);
    if(ret < 0)
    {
        perror("first sigprocmask failed");
        return 0;
    }
    
    getchar();

    ret = sigprocmask(SIG_SETMASK,&oldset,NULL);
    if(ret < 0)
    {
        perror("second sigprocmask failed");
        return 0;
    }

    while(1)
    {
        sleep(1);
    }
    return 0;
}

运行结果如下:

我们先来验证一下2号信号的

①由于有getchar()的存在,程序现在正在等待用户输入,而陷入阻塞
在这里插入图片描述
②我们借此使用kill命令发送5次2号信号和5次40号信号,查看sigcallback函数的打印情况来验证它们是否为非可靠信号和可靠信号
在这里插入图片描述

③给其输入一个值,让程序进行下去,查看程序这边对应的状况为:
在这里插入图片描述
说明2号信号为非可靠信号,且40号信号为可靠信号

4. volatile关键字

volatile关键字的作用是:使变量保证内存的可见性

根据冯诺伊曼体系结构我们可以知道,一个变量要进行运算,首先要从内存中将变量发送至缓存,然后再运送至寄存器,由寄存器移送至CPU进行相应的运算。

而gcc/g++都有编译代码的优化选项,当CPU在计算数据的时候,为了快,对已经从内存中拿出来的数据,不会从内存中获取,而是直接从寄存器中获取。

举个例子来说就是:

int g_val = 1;
while(g_val <= 0)
	g_val--;

正常情况是g_val的值从内存移送至寄存器,然后由CPU进行减减操作,然后再由寄存器将该值返回内存,从而结束循环。

而若加上gcc优化选项,则CPU会直接从寄存器获取值,而不是从内存中获取,因此,当g_val - - 的时候,内存中g_val = 0,而寄存器中g_val的值为1,在运行时,CPU直接从寄存器中获取值进行减减,因此,程序就会陷入死循环。

但是,只要加了关键字volatile,就会规定编译器只能从内存中进行获取,就能很好的解决该类问题,这就是内存可见性的具体的含义。

gcc优化选项:-o0、-o1、-o2、-o3,优先级是由低到高。要使用就在编译时加上该选项即可,例如gcc -o2 test.c

以上是关于Linux:详解进程信号(信号的捕捉流程,信号的阻塞volatile关键字)的主要内容,如果未能解决你的问题,请参考以下文章

linux进程信号——信号的保存和处理

Linux之信号详解

Linux之信号详解

《Linux从0到99》十一 进程信号

《Linux从0到99》十一 进程信号

《Linux从0到99》十一 进程信号