Linux第十节——信号
实际上,信号我们用的并不少,我们本节就来重点地、系统地探讨一下信号的有关内容。为了便于理解,我们先来说ctrl c由ctrl + c发出的信号我们来举一个简单的例子:先该创建文件创建文件:来看这样一个简单的代码:它是一个死循环。我们用这个死循环来演示ctrl c的功能。结合上面的运行,来说两个点:1、在前台运行的程序只能有一个。我们的程序在前台运行起来之后,我们再去输入一些指令等等,就无法执行了。
目录
(有的函数接口可能未在标题中显示,但在文章中应该是介绍到了)
信号的产生不是以传输数据为目的的,而是以通知某个事情是否要发生为目的。
实际上,信号我们用的并不少,我们本节就来重点地、系统地探讨一下信号的有关内容。
为了便于理解,我们先来说ctrl c
由ctrl + c发出的信号
我们来举一个简单的例子:
先该创建文件创建文件:
来看这样一个简单的代码:
它是一个死循环。
我们用这个死循环来演示ctrl c的功能。
结合上面的运行,来说两个点:
1、在前台运行的程序只能有一个。我们的程序在前台运行起来之后,我们再去输入一些指令等等,就无法执行了。在前台没有另外的可执行程序运行的时候,实际上是bash程序。其就会执行我们所输入的命令行中所对应的程序。(相当于进程替换)
而当我们让其在后台运行的时候,前台所运行的程序还是bash,我们再输入指令,就是有效的。
如下图:
我们的显示器同时有后台的程序和前台的指令同时往显示器文件上写,所以这个时候,我们的显示器也是共享资源
2、ctrl c实际上是一种信号,它能够将我们当前在前台运行的程序终止掉。并且,我们可以通过kill -l来去查看,其实际上是二号信号。
怎样见得其是2号命令呢?
我们接下来就来解释:
如果我想要调用一个接口,来自定义实现一个信号的功能,可不可以呢?
答案是可以的。
这里我们就介绍一个自定义接口:signal
signal
第一个参数是一个信号编号,注意这里的编号为普通信号的编号,具体为从第一号到第三十一号。
第二个参数是一个函数指针。就是一个自定义的函数。注意这个函数有一个参数,这个参数就是一个信号编号。
来举一个例子看一下吧:
代码:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<signal.h>
4
5 void handler(int sino)
6 {
7 printf("the signal now is %d\n",sino);
8 }
9
10 int main()
11 {
12 signal(2,handler);
13 while(1)
14 {
15 sleep(1);
16 printf("I am running\n");
17 }
18 return 0;
19 }
这个时候运行,就会发现我们的ctrl c 的信号已经被我们的signal“重定义”了。就是去执行handler函数里面的内容。
而这里的signal函数实际上相当于一份订单,当信号来的时候(即外卖送到的时候),我才去执行我所对应的函数里面的事情。平时的时候,我该干嘛就干嘛。
总结一下:
1. Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
3. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的
解释一下:异步就是说信号产生的时间和进程执行的时间一般情况不会产生太大的关联,我走我的,当你信号发出、我接收之后,才会有交点和重合
信号处理常见方式
1. 忽略此信号。
2. 执行该信号的默认处理动作。
3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号
那么,我们下面就要系统地来说一说信号。
信号的产生
我们从信号的产生、信号的记录、信号的处理三个方面来讲述:
我们先来说说信号的产生:
信号产生一共有四种产生方式:
1、通过键盘产生。(比如我们刚刚说的ctrl c)
2、通过系统调用接口产生。(比如kill)
3、由软件条件产生信号。
4、由硬件条件产生信号。
对于第二点,我们拿kill来举个例子:
那么也就是说,我们自己也可以自己实现一个kill命令来:
先来看代码:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<signal.h>
4 #include<stdlib.h>
5 //void handler(int sino)
6 //{
7 // printf("the signal now is %d\n",sino);
8 //}
9
10 int main(int argc, char* argv[])
11 {
12 if(argc == 3)
13 kill(atoi(argv[1]),atoi(argv[2]));
14 // while(1)
15 // {
16 // sleep(1);
17 // printf("I am running\n");
18 // }
19 return 0;
20 }
(上面打码的那行是写错了,不过不碍事)
这里,我们就是用kill命令,来给指定的进程发送指定的信号。
再来介绍一个接口:raise
raise
它的功能很简单,没必要再截图说明了,功能就是给自己当前进程发送一个信号。
举一个例子:
就是这样。
还有一个接口:about
about
它是给自己发送指定的信号。 一般而言,发送的就是about信号——这里的6号信号
该信号的作用:使当前进程接收到信号而异常终止。
注意:我们的9号信号——SIGKILL是无法被捕捉的!!!!!否则,我可以随意去写一个病毒,然后重定向所有的信号,这样,我就在我的进程里为所欲为,想干什么就干什么了。
所以,为了防止这种现象,9号信号不可被捕捉,其可以理解为管理员信号。
第三点:我们之前说过的,在管道的一方的读已经结束、而另一方的写并没有关闭的时候,这个时候,由于是往一个已经关闭了的管道去写,导致写入条件不满足,使得操作系统给这个进程发送一个强制关闭该管道的信号。就是说,那里的文件已经被关闭了,那么操作系统在软件层面上是不允许你做写的动作,直接给你发送信号——发送的正是13号信号 SIGPIPE
当然,在软件层面层面上,还有alarm接口,它的作用也很简单,就相当于一个闹钟,传入一个参数(int),表示在这些秒钟之后,会对进程发送一个alarm 的信号:(如下图)
对于第四点:
我们来看:
假如,我们定义了一个野指针。
就这样:
(段错误)
这里它就直接报了个错,然后将进程终止了。
实际上,这里的报错实际上是在页表里出现了错误。因为页表没有p的对应关系(或者这里不能写入,如果其为空),而你却要写入数据?!
所以,操作系统从页表里发现了这个东西在干坏事,就直接发出了一个信号——11号信号,让该进程终止。(包括除零错误,访问空指针的错误)
而像这些错误,本质上都是CPU里的溢出标志位、除零标志位里的硬件设施报错引起的,导致操作系统发出信号。
这些和C++中的try / catch很像,其本质也是通过信号来解决的。
如果去用signal重定义,会发现其一直都在打印,本质原因是这里的报错是在CPU的硬件上,而又没有人去修正它。所以其会一直报错、使得OS一直发出信号、一直打印。
注意:所有的信号,都必须要通过OS之手,向进程发出信号。
我们这里补充一下Core Dump(了解)(核心转储)
(以下建议在本地虚拟机上运行,在云服务器上不易观察)
我们首先需要明白,我们系统的每一份资源都是有其明显的上线指标的。
比如上图所示的(我们来挑几个举个例子):比如pipe size最大为512个bytes...
而我们默认的core file size 默认上限为0.
我们可以将其修改一下:
用的指令:
然后我们来去写一个段错误的代码。
然后运行。
然后发现,这里就有了一个core dumped
然后就生成了一个core文件 ,后面的编号表示生成core文件的进程id
这个core文件有什么用呢?
其是给调试器看的。
就是说,其在调试的时候(编译要携带-g选项),会直接给你报出怎么报错的、收到哪一行信号报错的等等错误信息。
就比如:
然后就会报出你所对应的错误。
这就相当于我们在vs里面出现段错误的弹出来的那个框框。
还需要点一个点,就是我们在进程等待的waitpid,其第二个参数的比特位的低第八位保存就是core dump,为0表示的就是不需要core dump,为1表示的就是需要core dump。
注意:9号信号是不需要被core dump的。因为其是要干掉进程的,要啥core dump呢。
稍微总结一下:
1、上面所说的所有信号产生,最终都要有OS来进行执行,为什么?OS是进程的管理者
2、信号的处理是否是立即处理的?在合适的时候。(我们前面也提到过,下面会详细介绍)
3、信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
答案是需要。记录在位图中。
4、一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
当然是可以的。就好比你现在没有碰到红绿灯,但是你还是知道红灯停。
那如何理解OS向进程发送信号?
能否描述一下完整的发送处理过程?
这个就是我们接下来要详细说的。
信号的存储
我们前面所说的一大段都是在讨论信号的产生方式。
我们再次回到那个箭头:
那么接下来,我们就要来探讨一下信号的保存。
信号在操作系统中的保存,不像我们在物理中说的又是波呀又是振动啊那么抽象,其是具象的,本质还是一种数据结构。
我们需要知道这两个问题:
信号是谁保存的?怎么保存是否有这个信号?
答案就是位图。
就是说,我用四个字节的长度来去表示32个位(只用31个,分别用来表示31个普通信号),每一个位1表示信号存在,0表示信号不存在。这样,我们就能非常巧妙地将信号存储了起来。
具体的实现方式呢?
1、我们的猜测进程PCB的task_struct中,应该存在着一个类似于位图的东西(本质就是一个整型)
2、信号是OS发送,那么发送的是哪个信号,OS就只要将进程的对应位置由0改为1就可以了。至此,对于OS来说,信号发送完毕。
所以OS给进程发信号,实际上就是OS给进程写信号(这里说的都是普通信号)
我们这里说一下信号其他的相关概念:
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
这里再次提一下:信号处理方式大体有三种:默认、忽略、自定义。
在一个进程里,有三个和信号直接相关并且极其重要的东西。
它们实际上是三张表——block表、pending表、handler表
解释一下,每一张表是干嘛的:
block表用于表示阻塞信息;pending表用于表示信号有无信息;handler表用于表示信号的执行信息。
说人话:
先说pending表,pending表实际上就是我们上面所说的位图,就是说1表示有该信号,0表示没有。
那block表呢?它实际上也是一张位图。它的1就表示该信号暂时被阻塞;0表示未被阻塞。它也是用来记录信号信息的。但是有用来记录信号是否被阻塞的。也是是谁和是否的问题。但是其表示的是谁被阻塞和是否被阻塞的问题。
而handler实际上是一个函数指针数组,数组元素是函数指针,对应着该信号所执行的内容。
那么上面三层,可以理解为:
当前进程的1号信号暂时并未被阻塞,也并未接收到;一旦接收到且未被阻塞时,执行SIG_DFL指令(默认指令)
当前进程收到了一个二号信号,但当前信号尚未被递达。因为当前进程是被阻塞的。一旦解除阻塞,当信号将要递达的时候,进程将会执行SIG_IGN(忽略指令)
3号信号被当前进程屏蔽(阻塞),且当前进程并未接收到3号信号。当解除阻塞,并且信号将要被递达时,其要执行的动作是用户自定义的函数类型动作。
其他同理。
再做一点补充和总结吧:
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
信号的处理
信号处理我们上面已经说过,有三种方式:
1、默认处理。
2、忽略。
3、自定义处理。
信号集(sigset_t)
注意,这里的sigset_t是一个变量,这里相当于当用户自己定义一个位图,未决和阻塞标志可以用相同的数据类型sigset_t来存储。通过用户传入这样一个位图,操作系统就可以对应到自己的进程当中的位图中,哪一个进程处于怎么样的状态也就是确定的了。
但是,需要注意的是:我们这里一般不直接来去修改位图的东西,不直接用按位或、按位与等直接对位图进行操作。因为在不同的平台,其底层的原理可能会有略微的差异。
我们都是通过信号集操作函数来对其进行操作。这样不仅方便,而且安全。
这里的sigset_t非常像我们前面在共享内存里说的key值。
那么关于信号集的函数接口都有哪些呢?
#include <signal.h>
int sigemptyset(sigset_t *set); //清空位图
int sigfillset(sigset_t *set); //填满
int sigaddset (sigset_t *set, int signo);//向位图中添加signo信号
int sigdelset(sigset_t *set, int signo); //删除sigo信号
int sigismember(const sigset_t *set, int signo); //判断是否有sigo信号
我们马上要举个例子来加深一下理解,在此之前呢,我们先来说说两个函数——sigprocmask和sigpending
sigprocmask和sigpending函数
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)(说一下:这里的信号屏蔽字就是我们上面的Block)
我们可以来看一下函数原型:
第二个和第三个参数都是位图类型(就是我们上面所说的sigset_t)
第一个参数表示我们可以有三个参数:
很好理解:因为第二个参数就是我们上面说的位图,比如上面的SIG_BLOCK,就是让原本的位图添加set位图的信号(因为其0|1是1,而仅当0|0时才是0);其他的同理了,比如SIG_UNBLOCK就是让当前信号位图屏蔽字解除所指定的屏蔽字。
注意一下,这里的第三个参数时一个输出型参数,输出的实际就是原本未被修改过的屏蔽字。
sigpending就稍微简单点了,读取当前进程的未决信号集,通过set参数传出。
当然了,这里的set也是一个输出型参数。
注意的是:这个传进去的set并不会影响这个函数的运行结果,再次强调,sigpending是将内部的pending表获取到,然后给这里的set。
还需要注意一点,也是再次强调:所有想要对pending表、block表进行操作都必须要调用系统接口,不能凭借臆想去直接搞!!!!
是不是感觉还是一头雾水?
ok,下面,我们来举一个例子,来加深理解。
(该创建文件创建文件,我们直接展示代码)
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<signal.h>
4
5
6 void show_pending(const sigset_t* pending)
7 {
8 int sig = 1;
9 for(;sig <= 31;sig++)
10 {
11 if(sigismember(pending,sig))
12 {
13 printf("1");
14 }
15 else
16 {
17 printf("0");
18 }
19 }
20 printf("\n");
21 }
22
23 int main()
24 {
25 sigset_t pending; //创建一个自定义的sigset_t(类似位图)
26
27 sigset_t block,oblock; //同理
28 sigemptyset(&block); //清空
29 sigemptyset(&oblock); //清空
30
31 sigaddset(&block,2); //添加2号信号
32 sigprocmask(SIG_SETMASK,&block,&oblock); //修改该进程的信号屏蔽字
33 while(1)
34 {
35 sigemptyset(&pending); //清空
36 sigpending(&pending); //读取,作为该进程的信号pending表
37 show_pending(&pending); //打印一下这个表来看看
38 sleep(1);
39 }
40
41 return 0;
42 }
(上面的代码和下图的展示可能略有差异,但思想是一样的)
运行结果如图:
这里我们可以看到,由于信号阻塞,2号信号就无法执行,所以我们的pending表中就存在了2号信号。
稍微修改一下:
那么这里,在执行到count为20的时候,将信号屏蔽字改成我们原来的,就是说,将2号信号屏蔽字解除了,这个时候就会去执行2号信号,让进程停下来了。
那这里只是能够看到其由0变为1的过程,那我想要看其由1变0的过程,可以吗?
答案是可以的。
我们只要把二号信号重定义一下(用signal函数) ,然后再去打印,就会看到由1变为0的过程。这里就不做过多举例了。
我们可以来去看看Linux的内核源码:(2.6.32.26)
就是说,我们上面所说的并不是虚无的,而是在Linux内核当中是真实存在的
我们之前说信号在合适的时候会被捕捉。
那合适的时候是什么时候呢?
现在就来谈谈信号的捕捉:
信号的捕捉:
一句话:信号的捕捉是进程在从内核态返回用户态的时候进行的。
我们先来区分一组概念:内核态和用户态。
内核态和用户态
我们结合进程地址空间,从底层来理解一下:
内核的代码都是一样的(因为操作系统的代码只有一份)
如果代码在用户区执行,那么就是用户层,一旦需要调用系统接口,执行内核的代码的时候,就进入了内核态,这个时候,你,就是OS,简而言之,OS用了进程的壳子,帮你做了你想做的事情。
(图示)
注意,我们在内核态的时候来去访问用户区的代码,理论上也是可以的。但是,我们一般不这么做。因为内核态的权限比较高,如果用户区的代码干了一些不好的事情,那么如果是用户态调用,其可能会因为权限等问题无法执行,但是如果用内核的高权限来取执行,当出现较大问题的时候,OS可能就无法将其干掉了。
那么我们信号的捕捉具体是怎么实现的呢?
简单来说,就是一个∞符号:
从上面可以看出,信号执行流可以有多个。
再说一点:如果我的sighandler是二号信号的重定义,而我在sighandler函数中再来一个二号信号,并且我们在该函数中也是会存在着系统调用的,那可不可能会继续去调sighandler呢?(类似套娃,无限调用)
答案是不会的。当我们在捕捉到一个信号、还未将该信号递达完毕、执行完毕的时候,OS会将该信号的信号屏蔽字置为1.即将该信号屏蔽。等结束该信号要完成的动作之后再说。
就是说,我每种信号只能在同一时间处理一个,可以一下子处理多种,但是只能处理一个。
我们这里介绍一个sigaction,其用法于signal类似,再举一个例子。
(sa_mask传进去的表示当我想要执行外面的某个信号的时候,想要屏蔽的信号集)
举例:
具体就不再解释了,相信通过上面的诸多案例已经能够理解了。我们看这个和signal是一模一样。
再来说一个概念:可重入函数。
可重入函数
分析:
- main函数调用insert函数向一个链表head中插入节点node1,
- 插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理。
- 于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。
- 结果是:main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile关键字
这个在多线程里还是比较明显的。
先说volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
再来举例:
这样一个代码,注意到,在main函数里面的while是一个死循环。
当未接受到二号信号的时候,quit为0;
那么现在想问,接收到二号信号之后,quit似乎变成1了。但是while循环会停下来吗?
似乎确实是停下来了。
但实际上这里是gcc编译器将其进行了优化了。
如果我在gcc后面带上一个选项:-O2(指定其优化方式),再来看:
如上图,这个时候,我们再发出2号信号,似乎就并不能够解决问题了,好像其一直都是处于while的死循环的状态。这是为什么呢?我明明将quit的值给改了呀?
注意到,我们的CPU为了提高运行效率,会提前将我们的quit的值加载到寄存器等缓存中,而我们每一次的while循环所判断的quit的值,为了提高效率,其就会直接从寄存器中去拿值,而quit在内存中的值已经被我们修改,可是其在寄存器中的值依然保持着原状!!!
就是说,寄存器中的值被“覆盖”了。
这个时候,我们加上一个关键字 volatile 就能够很好地解决这个问题。
其作用正是:要求每一次进行判断时,都必须从真实存储的位置中获得值!
SIGCHLD信号
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
我们简单来举一个例子:
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <signal.h>
4 #include<unistd.h>
5 void handler(int sig)
6 {
7 pid_t id;
8 while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
9 printf("wait child success: %d\n", id);
10 }
11 printf("child is quit! %d\n", getpid());
12 }
13 int main()
14 {
15 signal(SIGCHLD, handler);
16 pid_t cid;
17 if((cid = fork()) == 0){//child
18 printf("child : %d\n", getpid());
19 sleep(3);
20 exit(1);
21 }
22 while(1){
23 printf("father proc is doing some thing!\n");
24 sleep(1);
25 }
26 return 0;
27 }
那么关于信号的相关知识,我们暂且告一段落。
就这样啦~~~
更多推荐
所有评论(0)