一问搞懂Linux信号【上】

破晓的历程 2024-06-23 15:07:10 阅读 79

Linux信号在Linux系统中的地位仅此于进程间通信,其重要程度不言而喻。本文我们将从信号产生,信号保存,信号处理三个方面来讲解信号。

🚩结合现实认识信号

在讲解信号产生之前,我们先做些预备的工作。

现实生活中信号无处不在,大家见过哪些信号呀?红绿灯,手机铃声,闹钟等等。

我们拿红绿灯来举例说明

毫无疑问,之所以会出现信号,一定有它自己的用处。我们在十字路口看到了绿灯,就知道可以安全过马路了。所以首先,我们需要认识这个红绿灯,假如一个老奶奶一辈子生活在农村,没有见过红绿灯,所以即使老奶奶看到了绿灯,她也不认识绿灯的含义。其次,要对信号有相应的行为产生。

接下来,我们谈谈这背后的几个问题

:你为什么可以认识红绿灯呢?

:有人教育过你(手段),让你在大脑中记住了绿灯对应的属性和行为(结果)。


假设星期天张三一个人在家打游戏。然后张三点了一份外卖,张三知道:一会有人敲门(敲门声对张三来说就是信号),就是外卖来了,他就该取外卖了(对信号的反应)。 不一会,果然有人敲门,但是,张三打游戏正尽心着呢,到了关键时刻。所以就对外卖员说:你把外卖放在门口吧。我一会开门去拿。

这就是当信号来时,我们可能坐着更重要的事情,信号的来临是异步的所以我们要暂时存储这个信号。也就是张三要记住待会拿外卖这个时,如果张三是一个记忆力为0的人,这个敲门声对他来说就是无意义的。

总结:信号被捕捉,可能不会马上被处理。会存在一个时间窗口,所以我们要保存信号


一个信号产生,我们就要对这个信号作出反应。包括:默认行为,自定义行为,忽略行为。

绿灯亮了人们纷纷过马路(这种行为就是默认行为),李四妈妈从小就教育李四:在绿灯亮了之后,不要马上就过马路,要先跳个舞(这个行为就是自定义行为)。

早上妈妈叫我们起床,我们继续睡觉,当她没说,这种行为就是忽略行为。

🚩初识Linux中的信号

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

查看Linux信号指令:kill -l

 

并且每个信号的编号都有自己的名字,这些 名字 其实就  C 语言的 ,如果调用信号,既可以通过信号的名称调用,也可通过信号的编号调用。当然,这么多的信号并不需要你全部记下来,我们在运用的过程中就会知道哪些信号常用,哪些不常用。

仔细观察,我们看到:

 这个信号集中一共有62个信号,没有32号和33号。1--31称为普通信号,34-64称为实时信号,我们只学习普通信号。

我们可以查询7号手册来查询信号的默认行为。


 我们应如何把现实生活中信号的属性和特征迁移到操作系统的信号中呢?

我们要明白:操作系统中的信号是给进程发的。

问:进程是如何识别信号的呢?

答:认识+动作。进程本身就是程序员编写的属性和逻辑的集合,所以认识的过程由程序员编码完成。

当进程收到信号时,进程可能做着更加重要的代码,所以信号不一定会立即处理,这也就以为着要有地方存储信号。即进程本身要有对信号的存储能力。

进程在处理信号时,又称捕捉到了信号,一般对信号的处理分为三种:默认行为,自定义行为,忽略行为。


如果一个信号是发给某一个进程的,而进程要对信号进行保存,而保存的位置就是该进程所对应的task_struct结构体中。如下图:

在task_struct结构体中有一个unsigned int类型的变量。这个变量有32个比特位,4个字节。

比特位的位置,代表着信号的编号。比特位的内容,代表着是否收到对应的信号,0表示没有,1表示有。

信号发送的本质:就是修好对应的进程PCB的信号位图。PCB作为内核数据结构,只有操作系统才有权利修改PCB中的数据,所以,无论将来我们学习多少种发送信号的方式,本质都是通过OS向对应的进程发送信号。OS必须提供发送信号处理信号的相关的系统调用接口。

所以接下来我们就要重点学习发送信号和处理信号两部分内容。

到现在,我们还未介绍新的知识,一切都是我们通过现有的知识推导出来的。所以kill命令的底层一定调用了相关的系统调用接口。 

 🚩Linux中信号的产生
🌸通过键盘组合键产生信号

我们来看一段代码

#include<iostream>#include<unistd.h>int main(){ while(1) { std::cout<<"我是一个进程:"<<getpid()<<std::endl; sleep(1); } return 0;}

此时,我们就有必要简单介绍一下Ctrl+C组合键了。

 Ctrl+C本质是是一个热键,我们按下这个组合键,会被操作系统捕获。操作系统就会把Ctrl+C解释位2号信号。

2号信号的默认行为是终止前台进程,为了证明操作系统会把Ctrl+C解释为2号信号,我们自定义2号信号的行为。

接下来,我们迎来了我们信号部分第一个函数,也是最常用的一个函数。

🚀signal

参数介绍

①signum:传入需要捕捉的信号(名字或编号),当进程收到与其相匹配的信号时则会调用第二个参数,否则不会有任何动作

②handler:handlder方法,此方法为自定义方法,当收到signum信号则不会执行该信号的默认动作,变为执行该方法。

返回值

返回上一个信号处理方法。


接下来,我们就2号信号设置一个自定义行为,值得注意的是,我们不需要将这个接口放在循环体中,在一份代码中对一个信号自定义一次即可。

#include<iostream>#include<unistd.h>#include<signal.h>void my_hander(int signo){ std::cout<<"get a sig:"<<signo<<std::endl;}int main(){ signal(2,my_hander); while(1) { std::cout<<"我是一个进程:"<<getpid()<<std::endl; sleep(1); } return 0;}

 我们发现:使用ctrl+c竟然杀不死这个进程了,因为我们的自定义函数没有设置让进程退出,如果想让进程退出,可以使用exit

值得注意的是:我们的自定义行为只有当我们向进程发送该信号时,我们的自定义行为才凸显出来。 


除了使用Ctrl+C来终止一个进程外,我们还可以使用Ctrl+\来终止一个进程。

 操作系统会将Ctrl+\解释为3号信号。我们还是使用刚刚的代码

 总结一下:通过键盘发送信号给指定进程的过程为:

键盘特定输入 ——> OS解释为信号 ——> 向目标进程发送信号 ——> 进程收到信号 ——> 进程做出响应

🌸通过系统调用产生信号

我们先认识一个系统地要用接口:kill 可以将任意进程发送任意信号

这个函数使用起来非常简单。

参数

①pid:要发送信号给进程pid。

②sig;要发送的信号的编号。

返回值:

成功的话,返回0;失败,错误码被设置。


接下来,我们写一段有意思的代码

mykill.cc

#include<iostream>#include<unistd.h>#include<signal.h>#include<cstring>#include<cstdio>void Usage(const std::string &path){ std::cout<<"\nUsage: "<<path<<" pid sig\n"<<std::endl;}int main(int argc,char *argv[]){ if(argc!=3) { Usage(argv[0]); exit(1); } pid_t pid=atoi(argv[1]); int num=atoi(argv[2]); int n=kill(pid,num); std::cout<<"send sig successful"<<std::endl; return 0;}

#include<iostream>#include<unistd.h>#include<signal.h>void my_hander(int signo){ std::cout<<"get a sig:"<<signo<<std::endl;}int main(){ signal(2,my_hander); while(1) { std::cout<<"我是一个进程:"<<getpid()<<std::endl; sleep(1); } return 0;}


我们这就完成了通过一个进程发送信号来终止一个进程的工作。 


接着,我们再来一个接口:raise:给调用这个接口的进程发送信号。

SYNOPSIS #include <signal.h> int raise(int sig);

参数只有一个,发送的信号的编号

返回值:成功返回0,失败错误码被设置。

接着,我们用一用这个接口:

#include<iostream>#include<unistd.h>#include<signal.h>#include<cassert>void my_hander(int signo){ std::cout<<"get a sig:"<<signo<<std::endl;}int main(){ signal(2,my_hander); int cnt=0; while(1) { std::cout<<"我是一个进程:"<<getpid()<<std::endl; sleep(1); cnt++; if(cnt==10) { int n=raise(2); assert(n==0); } } return 0;}

其实,如果将kill的第一个参数传入调用kill的进程本身的pid,其功能和raise相同。 


接着下一个函数,这个函数是C语言的函数,向使用该函数的进程发送特定的信号(6号信号)。

#include <stdlib.h> void abort(void);

无参数无返回值,要是所有的函数都这么简单该多好呀! 

 按照惯例,我们使用一下:

#include <iostream>#include <unistd.h>#include <cstdlib>int main(){ int cnt=0; while(1) { std::cout<<"我是一个进程:"<<getpid()<<std::endl; sleep(1); cnt++; if(cnt==10) { abort(); } }}

空口白牙,何以证明?我们把代码给改一下。

#include <iostream>#include <unistd.h>#include <cstdlib>int main(){ while(1) { std::cout<<"我是一个进程:"<<getpid()<<std::endl; sleep(1); }}

证据已经展现,我们使用kill 发送6号信号和abort()的现象一样。

总结: 

 我们一共认为了3个函数:kill ,raise,abort,raise和abort的都可以用kill通过传入不同的参数来实现。

 🌸信号的意义

:我们已经发现,很多信号的作用都是终止进程,那既然都是终止进程,为社么要有那么多种类的信号呢?

 :信号的意义并不由信号的处理动作决定,不同的信号,代表着不同的事件。对信号的处理可以一样。就像代码出错,返回不同的错误码,代表着不同的意义,但结构就是终止运行。

🌸通过硬件异常产生信号

信号的产生,不一定非得用户显示的发送。硬件异常也可以通知操作系统,由操作系统向进程发送信号,来终止该进程。

来看一份代码

#include <iostream>#include <unistd.h>#include <cstdlib>int main(){ int a=0; a=a/0; return a; }

这份代码很明显的出现了除零错误,是编译不过的。 

不出所料,操作系统通过指定信号终止了进程,这种情况下,操作系统终止进程发送的信号为8号信号。如何证明?自定义捕捉。

#include<iostream>#include<unistd.h>#include<signal.h>#include<cassert>void my_hander(int signo){ std::cout<<"get a sig:"<<signo<<std::endl;}int main(){ signal(8,my_hander); int a=0; a=a/0; return a; }

我的天呀!运行起来,就疯狂的刷屏,我明明只出现了一次除零错误,OS犯得着一直给我发送信号吗?操作系统怎么知道该进程发生除零错误了? 这里就要理解一下除零错误了。


 发生除零错误,程序默认终止。

cpu中存在诸多的寄存器, 我们可以大致区分为普通寄存器和状态寄存器。CPU不仅要负责运算,而且要负责正确的运算,而运算是否正确就要看状态寄存器。0可以看作一个接近零的数,一个数除以一个很小的数,结果一定很大,所以寄存器不能装下这个数据,就会发生溢出,溢出标志位就由零变为1,表示发生运算错误。

操作系统作为软硬件资源的管理者,知道发生错误后,就向发生错误的代码所属的进程发送信号,终止进程。


现在我们就可以理解为什么我只发生一次除零错误,但是操作系统会一直给我发送信号?

通过自定义行为,进程在收到信号时,不一定会退出。没有退出就有可能被再次被调度。cpu的内部寄存器只有一份,但是寄存器里的数据属于当前进程的上下文。这些数据除了操作系统,用户没有能力被修改。当进程被切换时,就有无数次寄存器被保存和恢复的过程。所以每日一次恢复的过程。就让OS识别到了CPU内部的状态寄存器为1。所以就引发操作系统向该进程发送信号终止进程。因为一直杀不死该进程,所以操作系统就会一直给该进程发送信号,恶性循化。

🌸软件条件产生信号

记得我们再学匿名管道时,当读端关闭时,写端写入管道中的数据就没有用处了,操作系统不允许浪费资源的情况出现,这时会向写进程发送13号信号(SIGPIPE)。由于这种信号是因为在软件层次上读端关闭引起的,所以叫做软件条件产生的信号。

接下来,我们讲一讲alarm函数和SIGALRM信号。

这个函数的作用和sleep相似。作用为从执行该调用时起,经过设定的时间后,操作系统会向其发送14号信号(SIGALRM)来终止进程。

#include <unistd.h> unsigned int alarm(unsigned int seconds); 功能:用于进程闹钟,指定时间(以秒为单位)后,向调用它的进程发送 SIGALRM 信号。 seconds参数:表示在多少秒后发送14号新号,如果为0,则任何未响应的 闹钟被取消。 返回值:无符号整形,表示上次设置的闹钟还剩余的秒数。之前未设置闹钟,则返回0。

接下来,我们来感受一下alarm函数的使用方法

#include <iostream>#include <unistd.h>#include <cstdlib>int main(){ int cnt = 0; alarm(1); while (1) { std::cout << cnt << std::endl; cnt++; } return 0;}

这里有一点要注意: 代码执行到alarm语句时,进程不会马上终止,而是到设定时间到了之后再终止进程。就像昨天晚上我定了一个闹钟⏰,今天早上闹钟才响,一个道理。

但是,闹钟也可能会提前响,也许在闹钟响之前,系统突然给进程发了另外一种信号,导致进程终止。

在这里系统中,闹钟分为一次性闹钟和循环性闹钟,一次性闹钟只响一次,循环闹钟可以等时间段响起。

但是,我这电脑也太low了,跑这么慢。别着急,我该一下代码。

#include <iostream>#include <unistd.h>#include <cstdlib>#include<signal.h>int cnt = 0;void hander(int signo){ std::cout<<cnt<<std::endl; alarm(1);}int main(){ alarm(1); signal(14,hander); while (1) { cnt++; } return 0;}

卧槽,提高了一万倍左右。其实这也体现出了内存访问外设的速度有多慢,在第一种方案中,由于频频访问外设,所以导致计数过慢。 


🍃 如何理解"闹钟"是软件条件呢?

操作系统中的所有进程都可以设定闹钟,所以操作系统中会有很多闹钟,所以这就需要操作系统对这些闹钟进行有效的管理。如何管理?先描述,再组织。

操作系统会为每一个闹钟设置对应的数据结构,然后用链表的形式讲这些结构体链接起来。如图:

操作系统会将未来这个闹钟醒来的时间戳导入结构体中。然后操作系统会周期性的检查这些闹钟是否到了时间(对比时间戳),如果时间到了,操作系统会向对应的进程发送SIGALRM信号,终止进程。由于这是由操作系统这个软件来检查是否到了预定时间,这一切都是建立在软件的基础上,而我们的条件体现在超时这个条件,所以这种产生信号的方式叫做软件条件。

 🚩由信号引起的进程退出时核心转储问题

如上图,通过查询man手册,我们发现不同信号默认的关闭进程的方式不同,有的是Core,有的是Term,这两个有什么区别呢? 

Term是退出时,对进程的上下文数据不做任何的保存。

Core是退出时,保存进程的上下文数据,方便进行调试。

看下面的代码,很明显出现了数组越界问题

#include<stdio.h>int main(){ while(1) { int arr[10]; arr[100000]=0; }}

果然给我们报了数组越界的错误。

我们查看一下在系统中支持的各种信息

其中,我们发现:默认的core file是关闭的,但是我们可以通过指令进行打开。

然后我们再运行一下代码:

对比一下;发现有了些许变化。在路径下多了一个文件,该文件中保存的是进程的上下文数据。

 什么是核心转储呢?

当进程出现异常的时刻,我们将进程对应的时刻,在内存中的有效数据转储到磁盘上,这就是核心转储。核心转储的存在是为了方便调试。如何支持?

如此,就大大利于我们追踪错误。

 到这里,本篇博客暂时结束了。感谢观看。

声明:本博主的文章会同步到腾讯云社区。



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。