[Linux]信号

●🧑个人主页:你帅你先说.
●📃欢迎点赞👍关注💡收藏💖
●📖既选择了远方,便只顾风雨兼程。
●🤟欢迎大家有问题随时私信我!
●🧐版权:本文由[你帅你先说.]原创,CSDN首发,侵权必究。

1.信号概念

信号是进程之间事件异步通知的一种方式,属于软中断。

我们首先要明确的是信号和信号量是完全不同的两个概念,他们没有关系,只是名字很像而已。
信号我们很好理解,就是字面意思。
比如你正在上某节课时,突然一声"TiMi~~",这个时候你就明白有人上号了,这里的"TiMi"就是一种信号。
我们来看看Linux的常见信号。
在这里插入图片描述
我们发现,虽然标有64,但不是连续的,31以后直接跳到了34,实际上有62个信号,前31个信号我们叫做普通信号,后31个我们叫做实时信号

2.产生信号

2.1键盘产生信号

在这里插入图片描述
Linux系统提供了一个可以修改进程对信号的默认处理动作。
我们用代码来使用一下这个函数
在这里插入图片描述
在这里插入图片描述
刚开始我们没有发送信号时,函数没有被调用,但我们在键盘上敲出Ctrl C,系统就接收到了信号。
信号的产生方式其中一种就是通过键盘产生的信号,只能用来终止前台进程。
总结:
进程收到信号的处理方案有三种情况。
1.默认动作—一般是终止自己、暂停等。
2.忽略动作—是一种信号处理的方式,只不过动作就是什么也不干。
3.自定义动作(信号的捕捉)—我们刚刚用signal方法,就是在修改信号的处理动作,由默认动作变成自定义动作。(注意:9号信号不可以被捕捉,即不能被自定义)
在Windows 或 Linux下,进程奔溃的本质是进程收到了对应的信号,然后进程执行信号的默认处理动作(杀掉进程)。
为什么会发送信号?
在操作系统中,软件上面的错误,通常会体现在硬件或其他软件上。当CPU发现硬件被破坏了就会发送信号来终止进程。
当你程序崩溃时,你肯定会收到崩溃的原因,这个崩溃的原因我们之前讲过,是在waitpid里status的低七位存储的。
总而言之,在Linux中,当一个进程正常退出的时候,它的退出码和退出信号都会被设置。当一个进程异常退出时,进程的退出信号会被设置,表明当前进程退出的原因。有的时候,OS会设置退出信息中core dump标志位,并将进程在内存中的数据转储到磁盘中,方便我们后期调试。

2.2进程异常产生信号

我们来看看怎么用status来查看退出信息。
在这里插入图片描述
在这里插入图片描述
我们知道0是不能做除数的,所以对应8号信号的浮点数错误。
在这里插入图片描述

2.3系统调用产生信号

除了我们在命令行上敲

kill 信号 pid 

来控制信号外,我们还可以通过系统调用接口kill()来操控信号。
在这里插入图片描述
我们发现这个函数使用起来非常容易,只需要pid和信号就可以执行。下面来看看kill的使用方法。
在这里插入图片描述
命令行中我们输入 ./xxxx 信号 pid,所以argc是3。
在这里插入图片描述
在这里插入图片描述
还有两个系统调用也可以发送信号

void abort(void);//向自身发送6号信号
int raise(int sig);//指定发送某个信号

2.4软件条件产生信号

通过某种软件来触发信号的发送。
例如在系统层面设置定时器,或者某种操作而导致条件不就绪等这样的场景下,触发的信号发送。
我们之前将进程间通信时,当读端不读,还关闭了fd,写端一直在写,最终写进程会收到sigpipe(13),这就是一中典型的软件条件触发的信号发送。

我们再来认识个接口
在这里插入图片描述
设置在seconds秒后发送一个SIGALRM信号。
**这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。**当seconds等于0时表示取消闹钟。
前面说了那么多,总结一下就是,产生信号有三种方式:
1.键盘产生信号。
2.进程异常产生信号。
3.通过系统调用产生信号。
4.软件条件产生信号。
学到这里,我们可能还是有困惑,OS系统是如何给进程发送信号的?
实际上是task_struct里定义了用于保存记录是否收到了对应信号的变量,在这里用到了我们之前在文件系统里所讲的位图结构,但接收到信号就把该信号置为1,没收到则为0。所以OS给进程发送信号的本质是向指定进程的task_struct中的信号位图写入bit位。与其说是发送信号,不如说是写入信号。
例如:
00000000000000000000000000001001
这个二进制序列的含义是收到了1号和4号信号。

3.阻塞信号

3.1信号其他相关常见概念

  • 实际执行信号的处理动作称为信号递达(自定义捕捉、默认、忽略)
  • 信号从产生到递达之间的状态,称为信号未决(本质是这个信号被暂存在task_struct信号位图中,未决)。
  • 进程可以选择阻塞 (Block )某个信号(本质是OS系统允许进程暂时屏蔽指定的信号,该信号依旧是未决的,该信号不会被递达,直到解除阻塞才能递达)。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

3.2信号在内核中的结构

在这里插入图片描述
pending位图实际上就是我们刚刚讲的用来标识是否接收到了信号。(已经收到但是还没有被递达的信号)。
阻塞位图和pending位图类型,代表的是这个信号是否被阻塞。
所以OS对于信号的处理可能是这样的

int isHandler(int signo)
{
	//信号被阻塞
	if(block & signo)
	{
		//不做处理
	}
	else
	{
		//如果信号被递达,则处理
		if(signo & pending)
		{
			hander_array[signo];
			return 0;
		}
	}
	return 1;
}

总结:
一个信号总是能被pending,但是能不能递达取决于是否被block。

3.3 sigset_t

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

信号集操作函数

#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清零,表示该信号集不包含 任何有效信号。
  • 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。
  • 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信。

这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
返回值:若成功则为0,若出错则为-1
//函数的本质就是修改block位图

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。

在这里插入图片描述
来看段代码

#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
void handler(int signo)
{
    while(1)
    {
        printf("get a signo: %d\n", signo);
        sleep(1);
    }
}
int main()
{
	sigset_t iset,oset;
	
    sigemptyset(&iset);
    sigemptyset(&oset);

    sigaddset(iset, 2);
    //sigaddset(iset, 9);9号信号不可被屏蔽!
	//设置屏蔽字
    sigprocmask(SIG_SETMASK,&iset,&oset);
    while(1)
    {
        printf("hello world!\n");
        sleep(1);
    }
    return 0;
}

在这里插入图片描述
此时2号信号对应的Ctrl C键就失效了。
sigpending

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

接下来我们来实现一个功能。
首先屏蔽掉2号信号,不断的获取当前进程的pending位图,并打印显示,手动发送2号信号(信号不会被递达),然后再不断的获取当前进程的pending位图,并打印。

#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("\n");
}

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

   sigset_t iset, oset;

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

   sigaddset(&iset, 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)
       {
           //解除对2号信号的屏蔽
           sigprocmask(SIG_SETMASK, &oset, NULL);
           //2号信号的默认动作是终止进程,所以看不到现象
           printf("恢复2号信号,可以被递达了\n");
       }
   }
   
   return 0;
}

现象我就不演示了,在这里要注意一点,当信号被递达,在pending位图中就会由0置1。

实际上,接收到的信号不一定是马上处理的,有的时候进程正在处理更重要的事,这时信号就会被延时处理。
那信号什么时候被处理(检测,递达(默认、忽略、自定义)?
信号是被保存在进程的PCB中,即pending位图里面。当进程从内核态返回到用户态的时候,进行检测和处理工作。

内核态:执行OS的代码和数据时,计算机所处的状态叫做内核态,OS的代码的执行全部都是在内核态。
用户态:用户代码和数据被访问或者执行的时候所处的状态。我们自己写的代码全部都是在用户态执行的!

用户调用系统函数后,除了进入函数,身份也会发生变化,用户身份变成内核身份。
到这可能大家还是很懵,这个用户态和内核态究竟是什么?
前面我们说过每个进程都有它的虚拟地址空间,地址空间有页表可以映射到物理内存上,我们之前所说的页表准确来说是用户级页表,每个进程都有属于自己的用户级页表,而在OS系统中,除了用户级页表还有系统级页表,系统级页表在OS系统中只有一份(即被所有进程共享)。在地址空间的分布中,有1个G的内核空间,这个空间就是通过内核页表映射到物理内存上找到OS的代码和数据。这样设计就能保证既能看到自己写的代码,也能看到OS的代码。那在OS系统中是怎么区分状态的?实际上在OS系统中有一个寄存器CR3保存着状态,进程具有地址空间是能看到用户和内核的所有内容的,但不一定能访问,能不能访问取决于现在CR3保存的是什么状态。
总结一下就是两点:
1.用户态使用的是用户级页表,只能访问用户数据和代码。
2.内核态使用的是内核级页表,只能访问内核级的数据和代码。

所以现在你就能明白了,所谓的系统调用就是进程的身份转化为内核,然后根据内核页表找到系统函数执行调用。
理解了这些接下来我要放的图你就能更好的理解了。
在这里插入图片描述
这就是整个信号处理的过程。
不知道有没有会有疑惑,为什么在进行信号捕捉的时候一定要切回用户态,按理说OS系统拥有的权限更高,完全可以执行用户的代码。其实这是为了安全起见,因为OS系统的身份特殊,一般不会直接去执行用户的代码。

3.4 sigaction

#include <signal.h>
int sigaction(int signo, 
			  const struct sigaction *act, 
			  struct sigaction *oact);
sigaction函数可以读取和修改与指定信号相关联的处理动作。
调用成功则返回0,出错则返回-1。
signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。
若oact指针非空,则通过oact传出该信号原来的处理动作。
act和oact指向sigaction结构体。

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

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

#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
void handler(int signo)
{
    while(1)
    {
        printf("get a signo: %d\n", signo);
        sleep(1);
    }
}
int main()
{
    struct sigaction act;
    memset(&act, 0, sizeof(act));
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);

    sigaddset(&act.sa_mask, 3);

    //act.sa_handler = SIG_IGN
    //act.sa_handler = SIG_DFL;
    //本质是修改当前进程的的handler函数指针数组特定内容
    sigaction(2, &act, NULL);

    while(1)
    {
        printf("hello world!\n");
        sleep(1);
    }

    return 0;
}

在这里插入图片描述
此时会发现2、3号信号都被屏蔽了

4.volatile

我们来看一个场景

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

int flag = 0;

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

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

    while(!flag);

    printf("这个进程是正常退出的!\n");

    return 0;
}

在编译器有优化的情况下,这段程序运行起来之后,无论你发送几次2号信号,都无法退出,这里是因为做了优化。
在这里插入图片描述
我们知道计算是在CPU上进行的,所以flag的值会给CPU去运算,但这段代码编译器检测到flag的值(在主函数里)只是用来检测,并没有修改,干脆省点事,之后直接去访问CPU上存的数据,这就会导致flag的值永远是0,虽然在handler函数里修改了,但只是修改了内存上的flag值。编译器的这种优化显然不是我们想要的,这时就有了volatile的关键字。

#include <stdio.h>
#include <signal.h>
//此时编译器不会对这个变量做任何优化
volatile int flag = 0;

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

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

    while(!flag);

    printf("这个进程是正常退出的!\n");

    return 0;
}

总结一下,volatile的作用是:
让编译器不对此变量做任何优化,读取必须贯穿式的读取内存,不要读取中间缓冲区寄存区中的数据。

5.SIGCHLD信号

我们在学进程时讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:

父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。

喜欢这篇文章的可以给个一键三连点赞👍关注💡收藏💖

  • 17
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 12
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

你帅你先说.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值