您现在的位置是:首页 >技术教程 >【Linux】进程信号详解(二)网站首页技术教程

【Linux】进程信号详解(二)

清扰077 2024-06-17 10:18:46
简介【Linux】进程信号详解(二)

前言

上篇文章讲解了进程信号的第一部分,主要讲解了信号概念与信号产生的主要内容,这篇文章来讲解信号发送以及信号处理的内容。

一、信号阻塞

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

信号递达:

信号递达就是在进程收到信号之后,实际执行信号的处理动作。
前边我们提到了信号的处理动作:默认,忽略,自定义。

信号未决:

指的是产生信号之后到信号递达之前的状态。

信号阻塞:

就是os允许进程暂时屏蔽某一信号,就算该信号已经到达未决状态,也暂时不会去处理。
1.该信号依旧是未决的。
2.该信号不会被递达,直到取消阻塞,才会被递达。

信号阻塞vs信号递达的忽略动作

信号阻塞是信号的一种状态,而忽略动作只是递达的一种方式,而阻塞没有被递达,是独立状态,在解除阻塞之后,还可以被递达,此时递达的方式还可以是默认,自定义忽略三种。

2. 在内核中的表示

我们知道了信号的这几种状态,他们一定是保存在内核中的,但是我们有没有想过他们是如何保存的呢?
其实他们是已位图的形式在内核的task_struct中,并且有三个位图,分别代表的一个信号是否被发送,是否被屏蔽,以及信号处理的默认动作。
在这里插入图片描述

在位图中,0就是代表假,1代表真。

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。

在上图中,1号信号没有pending,并且没有block,所以不会执行该信号的默认动作。
2号信号的pending标志位为1,所以代表该信号是未决的,但是block标志位为1,所以该信号被屏蔽,也是堵塞,所以也不会被递达。
3号信号没有pending,所以不管是否被屏蔽,都不会执行默认处理动作。

而在一个信号被处理的过程中,会默认屏蔽该信号,所以处理过程中,至多会产生一次。

3. sigset_t

由于刚才提到的几种位图结构都在内核之中,并且操作系统不相信任何人,所以我们不能直接修改他们,必须使用系统调用接口来实现。
我们在之前的学习中也多次使用了系统调用接口,但是系统调用接口并不只是一种函数,系统调用也有可能是通过一种自定义类型实现的。
例如我们今天要学习的sigset_t类型就是系统调用,从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

4. 信号集操作函数

在有了信号集的概念之后,我们就需要掌握几个操作信号集的函数来完成对信号集的修改工作。
来看下边的信号集操作函数:

#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);

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

sigemptyset函数:清空信号集,将信号集的每个位置置0。
sigfillset函数:填充信号集,将信号集的每个位置置1。
sigaddset函数:将给定的signo信号加入信号集中。
sigdelset函数:将给定的signo信号从信号集中删除。
sigismember函数:判断signo信号是否存在于信号集中。
注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
sigemptyset、sigfillset、sigaddset和sigdelset函数是成功返回0,出错返回-1。
sigismember是一个布尔函数,若包含则返回1,不包含则返回0,出错返回-1。

5.sigprocmask函数

在有了信号集,并且掌握信号集的修改之后,我们就要考虑怎么去使用信号集来修改内核中的pending和block位图了。
首先来看sigprocmask函数:

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
返回值:若成功则为0,若出错则为-1

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

how参数:代表怎么去操作这个信号集。
在这里插入图片描述
set参数:是一个输入型参数,就是我们要传入信号集的地址,通过这个信号集来修改block位图。
oset参数:是一个输出型参数,是旧的信号屏蔽字。
返回值:成功返回0,出错返回-1.

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。(sigprocmask也是一个系统调用接口,在内核态转变为用户态是信号会进行处理,所以当有一个未决信号调用该接口取消阻塞时,一定会至少有一个信号递达,这些内容后续会讲到。)

下边通过一段程序来验证一下:

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

int main()
{
  //创建信号集
  sigset_t set;
  //将信号集清空
  sigemptyset(&set);
  //添加信号到信号集
  sigaddset(&set,2);
  //将信号集加到block位图
  sigprocmask(SIG_SETMASK,&set,NULL);
  while(1)
  {
    printf("i am a process
");
    sleep(1);
  }
  return 0;
}

在这里插入图片描述
我们发现2号信号被屏蔽之后,一直不会递达。

6.sigpending

有了对block位图的处理之后,我们也要考虑怎么去处理pending位图,我们也可以使用sigpending函数。
在这里插入图片描述
sigpending函数的作用是传入一个输出型参数,就可以获得sigpending位图,也就是获得未决的信号集。
同样我们使用一段代码来验证一下:

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

void show_pending(sigset_t *set)
{
    printf("curr process pending: ");
    for(int i = 1; i <= 31; i++){
        if(sigismember(set, i)){
            printf("1");
        }
        else{
            printf("0");
        }
    }

    printf("
");
}

void handler(int signo)
{
    printf("%d 号信号被递达了,已经处理完成!
", signo);
}

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

    sigset_t iset, oset;

    sigemptyset(&iset);
    sigemptyset(&oset);

    sigaddset(&iset, 2);
    //sigaddset(&iset, 9);

    //1. 设置当前进程的屏蔽字
    //2. 获取当前进程老的屏蔽字
    sigprocmask(SIG_SETMASK, &iset, &oset);

    int count = 0;
    sigset_t pending;
    while(1){
        sigemptyset(&pending);

        sigpending(&pending);

        show_pending(&pending);

        sleep(1);

        count++;

        if(count == 10){
            sigprocmask(SIG_SETMASK, &oset, NULL);
            //2号信号的默认动作是终止进程,所以看不到现象
            printf("恢复2号信号,可以被递达了
");
        }
    }
    return 0;
}

在这里插入图片描述

二、深入理解捕捉信号

我们前边一直提到信号会在一个合适的时间被处理,那么什么是合适的时间呢?我先来告诉答案,信号处理的时机一定是从内核态转变为用户态的时候,那么什么是内核态,什么又是用户态呢?接下里我为大家讲解。

1. 虚拟地址空间

我们先来看我们之前学习的进程地址空间,我们知道不同的进程拥有不同的地址空间,他们通过页表映射到屋里内存中。
在这里插入图片描述
但是有没有人想过最上边的内核区会怎么样呢?也是通过页表映射到物理内存上吗?答案是不是的,其实内核的地址空间只有一份,所有的进程都共同拥有一份,每个进程都有一份用户级页表,这张页表是独立的,而内核级页表只有一份,所有进程共享。

用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系
内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系
每个进程都有自己的地址空间,用户空间(0 ~ 3GB)每个进程独占,内核空间被映射到了每一个进程的3 ~ 4GB,即每个进程看到的内核空间都是一样的
结合这些知识可以再次理解进程切换,在进程切换的时候:

在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据;
执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据,所以将这些数据保存在上下文当中,os可以在进程的上下文当中直接运行。

2.用户态和内核态

由于虚拟地址空间分为内核区和用户区,所以将状态分为用户态和内核态。
用户态:我们平时写的C或C++代码就是用户态的,就是普通用户的代码和数据。
内核态:操作系统的代码和数据,权限较高。

用户态权限较低,不能执行内核态的代码,虽然内核态的权限较高,但是os不相信任何人,也不会去随意执行别人的代码。

从用户态切换为内核态通常有如下几种情况:
需要进行系统调用时
当前进程的时间片到了,导致进程切换。
产生异常、中断、陷阱等。
与之相对应,从内核态切换为用户态有如下几种情况:
系统调用返回时
进程切换完毕。
异常、中断、陷阱等处理完毕。

我们将从用户态到内核态的动作叫做陷入内核,例如我们使用系统调用时,本来是我们的C语言代码,属于用户态,进行系统调用,状态由用户态变为内核态去执行内核的代码和数据,执行完毕之后又返回用户态。

3.信号的捕捉流程

首先我们的进程运行时,这时出现系统调用或者中断,异常时,进入内核态,此时进行信号的检测,如果没有可以递达的信号,就直接返回,如果有可以递达的信号,进行默认或者忽略处理,但是当处理动作为自定义时,就要回到用户态去执行自定义handeral函数,执行完函数后,使用特殊的系统调用sigrturn再次进入内核,到内核之后,再使用sys_sigreturn返回用户态的主程序。
在这里插入图片描述
我们可以使用以下的方法来理解,记忆这幅图。
在这里插入图片描述
它类似于数学中的无穷大符号,一个红圈意味着一个状态的切换,有两次从内核态切换到用户态,有两次从用户态切换到内核态,而交点处就是信号检测的地方,检测是否会有信号要被处理。

4 .sigaction函数

sigaction函数与signal函数类似,都是对信号进行捕捉的函数,唯一不同的就是signal的参数与aigaction函数的参数不同。

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

signo参数:就是信号的编号。
act参数:是一个结构体参数,也是一个输入型参数,可以通过这个机构体来改变handler位图的默认处理动作。
oact参数:输出型参数,可以获得原来的处理动作。

下边的是act结构体的内容:

struct sigaction {
	void(*sa_handler)(int);
	void(*sa_sigaction)(int, siginfo_t *, void *);
	sigset_t   sa_mask;
	int  sa_flags;
	void(*sa_restorer)(void);
};

结构体的第一个成员变量是 sa_handler:

将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。

结构体的第三个成员变量是sa_mask

注意:如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,可以把需要屏蔽的信号加入到 sa_mask中,当信号处理函数返回时,自动恢复原来的信号屏蔽字。

下边一段程序来验证一下:

#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<string.h>
void handler(int signo)
{
  while(1)
  {
    printf("i am %d signal
",signo);
    sleep(1);
  }
}
int main()
{
  struct sigaction act;
  memset(&act,0,sizeof(act));

 // sigemptyset(&act.sa_mask);
 //  sigaddset(&act.sa_mask,3);
  act.sa_handler=handler;
  sigaction(2,&act,NULL);

  while(1)
  {
    printf("hello bit
");
    sleep(1);
  }
  return 0;
  }

在这里插入图片描述

当我们将3号信号加入sa_mask中时,我们会发现在处理2号信号时,不仅将2号信号屏蔽了,也将3号信号屏蔽了,所以就能得出结论:

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字.

总结

今天讲解了信号的发送以及信号的处理,希望可以帮到大家。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。