【Linux】Linux进程信号详解

  • Post author:
  • Post category:linux



一、引入信号概念

信号其实我们也见过,当我们在shell上写出一个死循环退不出来的时候,只需要一个组合键,ctrl+c,就可以解决了,这就是一个信号,但是真正的过程并不是那么简单的。

1、当用户按下这一对组合键时,这个键盘输入会产生一个硬件中断,如果CPU正在执行这个进程的代码时,则该进程的用户代码先暂停执行,用户从用户态切换到内核态处理硬件中断

2、终端驱动程序将这一对组合键翻译成一个SIGINT信号记在该进程的PCB中(也就是发送了一个SIGINT信号给该进程)

3、当某个时刻要从内核态回到该进程的用户·空间代码继续执行之前,首先处理PCB中的信号,发现有一个SIGINT信号需要处理,而这个信号的默认处理方式是终止进程,所以直接终止进程,不再返回用户空间执行代码。

注意:ctrl+c只能终止前台进程。一个命令可以加&可以将进程放在后台执行,这样shell就不必等待进程结束就可以接收新的命令,启动新的进程

2、shell可以同时运行一个前台进程和多个后台进程,只有前台进程才能收到ctrl+c这种组合键产生的信号

3、前台进程在 运行过程中用户可以随时按下ctrl+c产生一个信号也就是说前台进程的用户空间代码执行到任意一个时刻都可能接收到SIGINT信号而终止,所以信号对于进程的控制流来说是异步的。

二、信号介绍

在bash上执行命令kill -l便可看到系统定义的所有信号


我们只研究前31个信号,后面31个是实时信号这里不做研究

每个信号都有一个编号和一个宏定义名称,这些宏定义都可以在signal.h中找到,在man手册中还可以找到各种信号的详细信息

man 7 signal


这里具体介绍了信号在什么时候产生,处理的动作是什么

三、产生信号的方式

1、通过键盘的组合键产生,比如ctrl+c产生SIGINT信号,ctrl+\产生SIGQUIT信号,ctrl+z产生SIGTSTP信号

2、硬件异常产生信号,这些条件由硬件检测并通知内核,然后内核向进程发送适当的信号,比如执行了除以零的指令,进程访问了非法内存地址,cpu的运算单元都会产生异常,内核将这个异常解释成一个个信号发送给进程

3、一个进程调用kill(2)函数可以发送信号给另一个进程。可以用kill(1)发送信号给某一个进程kill(1)也是用kill(2)实现的如果不清楚指定信号则发送SIGTERM信号,该信号的默认处理动作是终止进程,当内核检测到软件条件发生时也可以通过信号通知进程例如闹钟超时,会产生SIGALRM信号,向读端已经关闭的管道文件写数据时产生SIGPIPE信号,如果不想按照默认动作处理信号,用户可以调用sigaction(2)函数告诉内核如何处理某种信号

4、软件条件产生

四、信号常见处理方式

1、忽略该信号

2、执行信号的默认处理动作

3、提供一个信号处理函数,要求内核在处理信号时切换到用户态执行这个处理函数,这种方式称为捕捉一个异常

五、信号产生具体过程

1、通过终端按键来产生信号

SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并Core Dump,我们在Linux环境下来验证一下,

先来了解一下什么是Core Dump

当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存在磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有BUG,比如非法访问内存导致段错误,事后可以用调试器检查core文件以查清楚错误原因,这叫做事后调试,一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中),默认是不允许改变这个限制,允许产生core文件。首先用ulimit命令来改变shell进程的Resource Limit,允许core文件最大为1024k

ulimit -c 1024


写一个死循环程序


编译并执行程序:


看到的现象是先打印出pid然后一直在死循环,按下组合键ctrl+\后退出并提示core dumped

test程序也会core  dump的原因是我们先修改了shell的Resource Limit值,而test进程是由shell产生的所以test进程的PCB也是由shell复制而来,所以test进程和shell就具有相同的Resource Limit值,所以就会产生core  dump了。


如图所示就是产生的core文件

我们来使用core文件


2、调用系统函数来向进程发信号

首先在后台运行一个死循环程序,然后用kill 命令给它发信号


说明:我们之所以要多按一次回车,是因为3035进程终止掉之前已经回到了shell提示符等待用户输入下一条命令,shell不希望错误信息和用户命令混在一起,所以先等用户输入后再显示

指定发送某种信号的kill命令可以有多种,上面的命令还可以写成kill -11 3035,11是信号SIGSEGV信号的编号。以往遇到的段错误都是由非法内存访问引起的,而这个程序本来也没错误,给它发送一个SIGSEGV信号也能引起段错误

kill命令是由kill函数实现的,kill函数可以给一个指定的进程发送指定的信号,raise函数可以给当前进程发送指定的信号(自己给自己发信号)

下来来介绍一下这两个函数

函数原型:


参数解释:

第一个参数进程id

第二个参数信号标号

返回值:成功返回0失败返回-1

参数解释:

信号标号

返回值:成功返回0失败返回-1


函数功能:

使当前进程接收到信号而异常终止

参数:

无参数

返回值;

无返回值

和exit函数一样abort函数总是会成功,所以无返回值

3、由软件条件产生的信号

软件条件产生的信号我们已经见过一种,就在我们学习进程间通信的时候,信号SIGPIPE就被我们介绍过,我们在这里不再多加介绍,我们接下来要介绍一种有趣的信号和产生这种信号的函数,我们可以想想,有一种声音我们每个人最不想听到的一种声音是什么,当然是每天的闹钟声了,我们介绍的这个信号就和现实中的闹钟很像,

今天要介绍的信号就是SIGALRM信号,以及产生这种信号的函数alarm

先来看一下函数原型:


函数功能:

设定一个闹钟,告诉内核在seconds秒后给当前进程发送一个SIGALRM信号,该信号的默认处理动作是终止当前进程

函数参数解释:

闹钟的时间是多少秒

函数返回值:

这个函数的返回值是0或者闹钟剩下的秒数,当你一直不修改闹钟,直到闹钟响这时的返回值是0,当在设定的秒数之内修改了闹钟的秒数就会返回上个闹钟剩下的时间,将seconds值设为零表示取消闹钟

来段代码来测试一下吧:


运行结果如下:



代码中设置一个闹钟和一个计数器,在闹钟响前,count一直++,并输出count值直到闹钟响,接收到SIGALRM信号才结束进程

六、阻塞信号

1、信号其他相关概念

实际执行信号的动作叫做信号递达

信号从产生到递达过程中的状态叫做信号未决,

进程可以选择阻塞某个信号

被阻塞的信号将处于未决状态,直到进程解除对信号的阻塞,才执行递达的动作

注意:阻塞和忽略是不同的,只要信号被阻塞就不会被递达,而忽略是在递达之后所选择的一种处理动作

2、信号在内核中的表示示意图


解释说明:每个信号都有两个标志位分别表示阻塞和未决,还有一个函数指针表示处理动作,信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才会清除该标志。如果进程解除对某信号的阻塞之前该信号产生过很多次,将如何处理?OPSIX.1允许递达该信号一次或多次。Lunix是这样实现的,常规信号在递达之前产生多次只记一次,而实时信号在递达之前产生多次可以依次放在一个队列里。在这里,不讨论实时信号。

3、sigset_t

由上图可知每个信号都只有一个bit的未决状态,不是0就是1,阻塞标志也是一样。因此阻塞和未决可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以用来表示信号的有效和无效状态,阻塞信号集也叫作当前进程的信号屏蔽字,这里的屏蔽应理解为阻塞而不是忽略。

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

sigemptyset函数初始化set指向的信号集,使其中的所有信号对应的bit位清零,表示该信号集不包含任何有效信号。

sigfillset函数初始化set指向的信号集,使其中所有的信号对应的bit位置位,表示该信号集的有效信号包括系统支持的所有信号。

注意:在使用sigset_t类型的变量之前一定要用sigemptyset函数和sigfillset函数初始化是信号集处于确定的状态,初始化之后就可以使用sigaddset函数和sigdelset函数在该信号集中增加或者删除有效信号。

以上四个函数,成功返回0,失败返回-1

最后一个,sigismember是一个bool函数用于判断一个信号集的有效信号中是否包括某种信号,若包含返回1,若不包含返回0,若出错返回-1

还有一个函数

sigprocmask函数;函数原型

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

参数解释:

如果oset是非空指针则读取进程的信号屏蔽字通过oset参数传出,如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何修改。如果oset和set都是非空指针则先将原来的信号屏蔽字被分到oset里然后根据set和how更改信号屏蔽字。假设当前进程的信号屏蔽字是mask,下面是how参数的三个可选值:

SIG_BLOCK  set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set;

SIG_UNBLOCK:set包含了我们希望从当前信号屏蔽字中解除阻塞的信号相当于mask=mask&~set

SIG_SETMASK:设置当前信号屏蔽字为set所指向的值,相当于mask=set

如果调用了sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少讲一个信号递达。

还有一个函数:sigpending函数

函数原型:

函数功能:



读取当前信号的未决信号集,通过set参数传出。调用成功反悔0,失败返回-1.下面使用前面介绍的几个函数来写一段简单的小程序:


运行结果如下:

七、捕捉信号

信号捕捉示意图


1、内核如何实现信号的捕捉呢?

(1)首先在用户正常执行主控制流程由于中断,异常或系统调用而直接进入内核态进行处理处理这种异常,

(2)内核处理完异常就准备返回用户态了,在这之前会看当前进程有没有可以抵达的信号,如果有就对可递达的信号进行处理,

(3)如果信号的处理函数是用户自定义的就返回用户态去执行用户自定义的信号处理函数

(4)信号处理函数执行完之后,会调用一个特殊的系统调用函数sigreturn而再一次进入内核态,执行这个系统调用

(5)这个系统调用完成之后,就会返回主控制流程被中断的地方继续执行下面的代码

(6)执行主控制流程的时候如果再次遇到异常、中断或系统调用就继续回到(1),继续执行下面的流程

2、下来介绍几个重要的函数

(1)sigaction

函数原型:

#include <signal.h>

int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

函数功能:

可以读取和修改与指定信号相关联的处理动作

函数参数解释:

参数一:指定信号的编号

参数二参数三:若act指针非空,则根据act修改该信号的处理动作。若oact为非空指针,则通过oact传出该信号原来的处理动作,act和oact指向sigaction结构体

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

返回值:

成功返回0,失败返回-1

补充:当某个信号的处理函数被调用时,,内核将该信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某种信号时,如果这种信号再次产生,那么它会被阻塞直到当前处理完成,如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另一个信号则用samask字段说明这些需要额外屏蔽的信号,,当信号处理函数返回时自动恢复原来的信号屏蔽字saflags字段包括一些选项,在这里我们常擦saflags设为0

(2)pause函数

函数原型:

#include <unistd.h>

int pause(void);

函数功能:

是调用进程挂起直到信号递达

函数参数:

无参数

返回值:

如果信号的处理动作是终止进程,则进程终止,pause函数没有机会返回;如果信号的处理动作是忽略,则进程继续处于挂起状态,pause不返回,如果信号的处理动作是捕捉信号,则调用了信号处理函数之后


pause返回-1,errno设置为EINTR,所以pause只有出错的返回值(EINTR表示“被信号中断”)。

下面我们用alarm函数和pause函数来实现一个mysleep

运行结果:


运行代码的现象是每五秒打印一句话。

下来对代码进行简要分析:


八、可重入函数


向上例这样,insert函数被不同的控制流程调用,有可能在第一次调用没返回时就再次进入该函数,这叫做重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入函数。

说明:如果一个函数符合下列条件之一则该函数是不可重入的:

1、调用了malloc或free,因为malloc也是用全局变量来管理堆的

2、调用了标准I/O库函数。标准I/O库函数的很多实现都是以不可重入的方式使用全局数据结构。

九、关键字volatile

这个关键字在我们学习c语言的时候就已经学到了,其功能是保证内存的可见性,保证执行的时候直接在内存取数据而不在寄存器中取。

它是一个限定符,在上面一个例子中main函数和信号执行函数都调用insert函数则有可能导致链表的错乱,其根本原因是链表的插入操作分为两步,而且不是原子的(要么不做,要么全做),如果这两步一起完成,中间不会被打断,就不会出现错乱了。

我们来考虑一种情况,如果对全局的访问只有一行代码会不会是原子的呢?我们来测试一下:


使用gcc test2.c -g命令加上调试信息,然后使用objdump -dS a.out显示汇编代码:


我们截取main函数中对a赋值的汇编代码:


通过观察就算是这一条语句也需要两步来完成,所以不是原子操作,可以根据上面insert函数的例子,要是在第一步完成之后,出现一个异常,而返回的时候出现一个信号,信号的执行函数也是对a赋值,那么变量a的赋值就会造成混乱,如果上述语句在64位机上执行就是原子操作,在32位机上,a的数据类型是int也是原子操作,而在16位机上就不行了,为了在各个平台下都能实现赋值的原子操作,c标准定义了一个类型sigatomict,在不同平台下就取不同的类型例如在32位机上就取的是int,这个类型    在使用时还需要注意一些问题。

看下面一个例子:


然后使用和上例相同的方式将汇编代码打印出来,


分析汇编代码:

将全局变量a从内存读到寄存器,对eax和eax做AND运算,若结果为0则跳过循环开头,再次从内存中读出变量a的值,可见这三条指令等价于c代码中的while(!a);循环。


再看汇编代码:


第一条指令将全局变量a的内存单元与0比较,如果相等,则第二条指令成了一个死循环,注意,这时一个真正的死循环:即sighandler将a改为1,只要没有影响0标志位,回到main函数后仍然死在第二条指令上,因为不会在内存读取变量a的值。

这实际上并不是编译器的错,如果程序只有单一的执行流程,只要当前执行流程没有改变a的值,a的值就没有理由会变,,不需要从内存读取,因此上面的两条指令和while(!a);循环是等价的,并且优化之后减少了每次循环访问内存的次数,效率会很高,,只是编译器无法识别程序中存在的多个执行流程    。之所以存在多个执行流程,是因为调用了特定平台上的特定库函数,比如sigaction、pthread_create,这些并不是c语言本身的规范,不归编译器管,程序员应该自己处理这些问题

c语言提供了volatile限定符如果将上述变量的定义改为

volatile sigatomict a =0;那么即使指定优化级别,编译器也不会优化掉对a内存单元的读写

volatile关键字适用场景:

1、程序中存在多个执行流程访问同一个全局变量的情况

2、变量的内存单元中的数据不需要写操作就可以自己发生变化,每次读上来的值都可能不一样

3、即使多次向变量的内存单元中写数据,只写不读,也并不是在做无用功,而是也有意义的,这样的内存一般都不是普通的内存单元而是映射到内存地址空间的硬件寄存器例如串口接收寄存器,和发送寄存器

说明:sig_atomic_t类型的变量总是加上volatile限定符,因为要使用sig_atomic_t类型的理由也是使用volatile限定符的理由

九、竞态条件与sigsuspend函数

我们再来考虑一下以前写的mysleep函数,我们使用alarm函数设定闹钟之后调用pause函数进行等待,可是SIGALRM信号已经处理完了还在等什么呢?

出现这个问题的根本原因是系统运行的时序并不像我们写程序时想得那样,虽然alarm函数设定闹钟后,后面紧跟的是pause函数,但是不能保证pause函数一定会在nsecs秒之内被调用。由于异步事件在任何时候都可能发生(异步指出现更高优先级的进程),如果我们写程序的时候考虑不周,就有可能会产生时序问题而导致错误,这就叫做竞态条件。

解决这种问题一般有两种思路,一种是在调用pause之前屏蔽SIGALRM信号使它不能提前递达就好了

我们将代码执行过程分为四步

(1)屏蔽SIGALRM信号

(2)alarm(nsecs)设定闹钟

(3)解除屏蔽

(4)pause();

这样的话SIGALRM信号也可能在解除屏蔽和调用pause之间的时间间隔内递达

我们又可以设想将解除信号屏蔽放在pause()函数调用之后,执行过程就变为:

(1)屏蔽SIGALRM信号

(2)alarm(nsecs)设置闹钟

(3)pause();

(4)解除屏蔽

这样更不行还没有解除屏蔽就调用pause,pause根本不可能等到SIGALRM信号,经过这两步的分析我,我们最想得到的就是将解除屏蔽和等待放在一起,让他们中间不要间断的执行,也就是这两条代码的执行是原子的。sigsuspend函数的功能就是这个

对时序要求严格的都应该调用sigsuspend函数而不是pause

接下来介绍一下这个函数:

函数原型:


参数解释:

用来指定信号屏蔽字

返回值:

没有成功返回值,只有执行了一个信号处理函数后才会返回,返回-1,errno设为EINTR

十、SIGCHILD信号

前面我们知道清除僵尸进程的方法就是使用wait和waitpid函数父进程可以阻塞等待子进程结束,也可以非阻塞的查询是否有子进程需要被清理(轮询),第一种方式父进程阻塞就不能做其他事情了,第二种,父进程不断去询问,代码实现比较复杂

其实子进程在终止时会给父进程发一个SIGCHILD信号,默认处理动作是忽略,用户可以自定义SIGCHILD的处理函数,这样父进程就可以专心处理自己的事情,不用关心子进程了,子进程退出时会通知父进程,父进程在信号处理函数中调用wait来处理子进程就可以了

补充:想不产生僵尸进程还有另外一种方法:父进程调用sigaction将SIGCHILD处理动作置为SIG_IGN这样fork出的子进程在终止时会自动清理掉也不会通知父进程系统默认的忽略和用户自定义的忽略一般是没有区别的,但这是一个特例,对于Linux可以用,在其他unix系统上不一定能用。



版权声明:本文为flowing_wind原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。