信号的概念
信号的基本概念很简单,谍战剧里面的信号的概念就体现的非常形象,每次情报人员之间沟通的时候就用电台,就比如电台和密码本,每个对应的电台信号都有一个对应的意义,Key-Value形式的,比如A信号表示进攻、B信号表示撤退,非常容易理解的概念。再比如街上的红绿灯,红灯停、绿灯行….
我们既然知道了什么是信号,那么如何处理信号呢?
- 收到信号执行默认动作,比如看到红灯就停下来
- 忽略信号,比如看到红灯就当没看到,继续往前走
- 收到信号执行自定义动作,比如看到红灯就躺在街上睡觉,然后被车碾压…
那么Linux下的进程能够处理信号的前提是认识信号,这就和我们要处理红绿灯的信号的前提是必须认识红绿灯信号,进程收到信号有可能并不会立即处理,而是在合适的时候!
查看Linux下所有的信号(编号34以上的是实时信号,实时信号必须立即处理):
信号事件的产生对进程而言是异步的,这个不难理解,因为你也不知道别人什么时候给你发信号,所以信号的产生跟进程不是同步的,这是两个没有因果关系的东西!
进程即使收到信号可能也无法立即处理,信号如果无法立即处理就应该把信号保存起来,保存在PCB的一个位图里面,因为只需要用31个比特位来存储是否收到信号即可,一个int32字节,所以使用一个int就可以保存31个信号
所以:发送信号的本质就是让操作系统去修改目标进程的信号位图
在此我猜想一下,Java等高级语言捕获异常的原理只不过是程序出错后屏蔽了导致进程退出的信号而已!!!
信号的产生
键盘
通过键盘产生,比如Ctrl C
产生终止进程的SIGIN
信号
注意:键盘上的组合键形成的信号只能用于前台进程!
前台进程随时随地都可以收到一个信号,因为你在这个进程运行的任何时刻,你都可以出入Ctrl-C
终止该进程。这也就说明了:信号对于进程来说是异步的。
程序运行时异常
程序运行时异常,比如除0产生SIGFPE
信号
1 | int main(){ |
接下来说一个调试程序BUG的技巧:事后调试
就是在程序已经出错的情况下通过产生的Core Dump来查看程序异常信息,当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。
一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K:ulimit -c 1024
1 |
|
其中限定了一些用户使用资源的上限,比如core文件大小,最多打开的文件数目,最多多少消息队列等等,只要把core文件大小设定一下,就可以把系统产生的core文件保存下来,因为core文件通常较大和安全性的问题,所以默认是设置core文件大小为0
当然,gcc和g++编译器默认生成的是Release版本的程序,无调试功能,在编译的时候需要加上-g
选项才能进行事后调试:
1 | [root@xpu code]# gdb test |
很明显的除0错误,连行号都可以显示出来!
很显然,进程收到信号的时候有很多种选择,执行默认动作,忽略,执行自定义动作,那么信号如何捕捉?
1 |
|
只需要使用signal函数即可捕捉信号,参数里面的函数指针锁指向的函数将决定收到信号后究竟会做什么,下面这个例子展示了如何捕捉信号:
1 |
|
为什么虽然捕捉到了信号,但是一直不停的捕捉信号呢?原因是因为捕捉到异常信号后没有终止进程,导致PCB上下文中保存着CPU的寄存器中的信息,该进程被切换出去之后当再次获得CPU执行权的时候,等到寄存器中的错误信息一恢复又会出现硬件错误,操作系统又会给进程发送SIGFPE信号,所以在捕捉到异常信号的时候别忘记终止进程,本例中也就是在handler函数中添加一句exit(1)
;
Kill命令
kill命令产生信号,这个不难理解,比如我们杀死进程用的kill -9
系统调用
1 |
|
这两个函数都是成功返回0,错误返回-1。
kill一般用于向别的进程发送信号,而raise用于进程自己向自己发送信号
1 |
|
abort函数使当前进程接收到信号而异常终止,完全可以理解为调用abort函数的进程打算使用6号信号自杀,就像exit函数一样,abort函数总是会成功的,所以没有返回值。即使SIGABORT被进程设置为阻塞信号,调用abort()后,SIGABORT仍然能被进程接收!
Kill命令其实就是调用了kill系统接口实现的命令,简单的实现一个kill命令:
1 | //mykill 1234 9 |
软件条件产生信号
软件条件产生信号比较特殊,因为像野指针访问内存错误、除零这种错误其实都属于硬件错误,由于硬件发送错误引起的异常(例如:你使用了野指针,那么直接导致报错的硬件就是MMU),但是接下来要说的这种情况是软件产生的异常:
在学习管道的时候我们就发现,如果读端都把文件描述符关闭了,那么写端也不会再写了,操作系统向写端发送9号信号终止写端进程,避免资源浪费,所以由此可见:不但硬件错误会产生信号,软件条件或者错误同样会产生信号!
1 |
|
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发送SIGALRM信号, 该信号的默认处理动作是终止当前进程。
接下来的演示就是捕获一下alarm函数产生的信号,应该是捕获14 SIGALRM信号:
1 |
|
阻塞信号
信号相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)
- 进程可以选择阻塞 (Block )某个信号
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
- 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号在内核中的示意图
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没 有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。 SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,暂时不讨论实时信号。
sigset_t
sigset_t是一种结构体,sigset_t类型对于每种信号用个bit表示”有效”或”无效”状态,至于怎么实现,我们作为使用者无须在意,其定义在/usr/include/bits/sigeset.h
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t
来存储,sigset_t
称为信号集,这个类型可以表示每个信号的”有效”或”无效”状态,在阻塞信号集中”有效”和”无效”的含义是该信号是否被阻塞。而在未决信号集中”有效”和”无效”的含义是该信号是否处于未决状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),注意:这里的”屏蔽”应该理解为阻塞而不是忽略。
信号集操作函数
为什么提供了一组信号集操作函数呢?很明显Linux系统的设计者认为让其他人自行操作信号机是非常危险的一件事情,或者说设计者们根本不信任我们对比特位的操作能力,于是乎为我们提供了一种API来操作信号集,当然你可以理解为这是为了让我们这些使用者更方便!
sigset_t
类型对于每种信号用一个bit表示”有效”或”无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t
变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t
变量是没有意义的
1 |
|
sigemptyset
此函数用于清空信号集,使得目标信号集中不包含任何有效信号sigfillse
此函数用于初始化目标信号集,把所有的信号加入到此信号集里即将所有的信号标志位置为1,可以理解为把所有信号都加入集合, 如果你不想阻塞哪些信号再sigdel单独删去它们即可sigaddset
和sigdelset
在初始信号集之后就可以调用sigaddset
和sigdelset
在该信号集中添加或删除某种有效信号,两个函数都是成功返回0,出错返回-1sigismember
是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1sigprocmask
此函数用于读取或更改进程的信号屏蔽字(阻塞信号集)如果oldset是非空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oldset和set都是非空指针,则先将原来的信号屏蔽字备份到oldset里,然后根据set和how参数更改信号屏蔽字。1
2
3
/* Prototype for the glibc wrapper function */
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
how
: 如何更改进程的信号屏蔽字
假设当前的信号屏蔽字为mask,下表说明了how参数的可选值:
| 选项 | 描述 |
| :———: | :———————————————————-: |
| SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set
|
| SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除的信号,相当于mask=mask&~set
|
| SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set
|sigset_t *set
:要更改的信号屏蔽字的结构体指针sigset_t *oldset
:将原来的信号屏蔽字备份到oldset中,不需要备份传入NULL即可return
:若成功则为0,若出错则为-1
注意:如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回之前,至少将其中一个信号递达!
sigpending
读取当前进程的未决信号集,通过set参数传出,调用成功返回0,失败返回-1
1 |
|
下面的一个示例程序演示了上述函数的作用
1 |
|
由于我们阻塞了SIGINT信号,所以Ctrl C
也终止不了程序,SIGINT信号处于未决状态,但是可以按Ctrl \
来终止程序
捕捉信号
内核如何捕捉信号
如果信号的处理动作是用户的自定义函数,在信号递达的时候就会调用这个函数,这就是信号捕捉!
由于信号处理函数的代码是在用户空间,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
sigaction
sigaction函数的功能是检查或修改与指定信号相关联的处理动作(或同时执行这两种操作)
1 |
|
signum
:指定信号的编号或者类型act
:指定新的信号处理方式sigaction
:原来的信号处理方式
sigaction结构体:
1 | struct sigaction { |
若act指针非空,则根据act修改该信号的处理动作。若oldact指针非空,则通过oact传出该信号原来的处理动作。act和oldact指向sigaction结构体:将sahandler赋值为常数SIGIGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask
字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags
字段包含一些选项,本章的代码都把sa_flags
设为0,sa_sigaction
是实时信号的处理函数,暂时不用关心
pause
pause函数使调用进程挂起直到有信号递达
1 |
|
如果信号的处理动作是终止进程,则进程终止,pause函数没有机会返回;如果信号的处理动作是忽略,则进程继续处于挂起状态,pause不返回;如果信号的处理动作是捕捉,则调用了信号处理函数之后pause返回-1,errno设置为EINTR,所以pause只有出错的返回值,这和程序替换那几个函数是一样的,出错才返回,错误码EINTR表示”被信号中断”。
接下来演示一个用闹钟+信号的方式实现的sleep函数mysleep()
1 |
|
执行流程分析:
1.main函数调用mysleep函数,后者调用sigaction注册了SIGALRM信号的处理函数sig_alrm
2. 调用alarm(nsecs)设定闹钟
3. 调用pause等待,内核切换到别的进程运行
4. nsecs秒之后,闹钟超时,内核发SIGALRM给这个进程
5. 从内核态返回这个进程的用户态之前处理未决信号,发现有SIGALRM信号,其处理函数是sig_alrm
6. 切换到用户态执行sig_ alrm函数,进入sig_ alrm函数时SIGALRM信号被自动屏蔽,从sig_alrm函数返回时SIGALRM信号自动解除屏蔽。然后自动执行系统调用sigreturn再次进入内核,再返回用户态继续执行进程的主控制流程(main函数调用的mysleep函数)
7. pause函数返回-1,然后调用alarm(0)取消闹钟,调用sigaction恢复SIGALRM信号以前的处理动作
接下来说明关于mysleep函数的几个问题:
问:信号处理函数sig_alrm
什么都没干,为什么还要注册它作为SIGALRM
的处理函数?不注册信号处理函数可以吗?
答:很显然,注册sig_alrm
函数是很有必要的,因为绑定了自定义的处理函数则会从内核态切换到用户态运行sig_alrm
函数,这样才不至于回到主控制流程,pause函数使调用进程挂起直到有信号递达,如果不注册SIGALRM
处理函数,当有信号SIGALRM
信号产生时会执行默认动作,终止进程
问:为什么在mysleep函数返回前要恢复SIGALRM
信号原来的sigaction
? 不恢复会怎样?
答:必须要恢复信号处理方式,因为sleep函数是不会修改SIGALRM信号的,将SIGALRM
不恢复会使alarm()
失效
问:mysleep函数的返回值表示什么含义?什么情况下返回非0值?
答:mysleep的返回值是在信号SIGALRM
信号传来时闹钟还剩余的秒数;当闹钟结束前有其他信号发送给该进程,并该进程对其进行了相关的处理时,alarm(0)
取消闹钟会使返回值非零
可重入函数
这个概念不难理解,现在假设一个进程正陷入内核态,现在正好要返回用户态执行到一个函数function的时候,现在呢进程收到了一个信号,进程当然要处理这个信号,于是执行自定义动作,在用户自定义处理该信号的函数中,恰好又调用了function函数,那么这就叫做该函数被重入了!
下面这个例子很详细,非常能说明可重入函数的概念:
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
不可重入的函数的条件
- 其中有static、全局变量等的函数也是不可重入函数
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
volatile
volatile关键字的作用是保证内存的可见性,确保本条指令不会因编译器的优化而省略,且要求每次直接读值,volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了!
1 |
|
在本例中:假设我们不加volatile修饰变量flag,那么在gcc编译器优化级别为2的时候,main执行流是不会从内存中拿数据的,即使已经进程收到SIGINT信号之后改了flag的值,main执行流也是直接从寄存器上面拿数据,所以Ctrl C也不会结束进程!
使用volatile修饰之后无论编译器的优化级别是怎么样的,CPU都可以从内存中拿flag的值,所以只需要将flag用volatile修饰便可以的到我们预期的结果!
竞态条件与sigsuspend函数
最难处理的问题很多都是时序问题!!!
设想上述mysleep这样的时序:
1.注册SIGALRM信号的处理函数
2.调用alarm(nsecs)设定闹钟
3.内核调度优先级更高的进程取代当前进程执行,并且优先级更高的进程有很多个,每个都要执行很长时间
4.nsecs秒钟之后闹钟超时了,内核发送SIGALRM信号给这个进程,处于未决状态
5.优先级更高的进程执行完了,内核要调度回这个进程执行。SIGALRM信号递达,执行处理函数sig_alrm之后再次进入内核
6.返回这个进程的主控制流程,alarm(nsecs)返回,调用pause()挂起等待
7.可是SIGALRM信号已经处理完了,还等待什么呢?
出现这个问题的根本原因是系统运行的时序(Timing)并不像我们写程序时所设想的那样。 虽然alarm(nsecs)紧接着的下一行就是pause(),但是无法保证pause()一定会在调用 alarm(nsecs)之 后的nsecs秒之内被调用。由于异步事件在任何时候都有可能发生(这里的异步事件指出现更高优先级的进程),如果我们写程序时考虑不周密,就可能由于时序问题 而导致错误,这叫做竞态条件 (Race Condition)
很显然,我们需要解决的问题就是:
从解除信号屏蔽到调用pause之间存在间隙,SIGALRM仍有可能在这个间隙递达。 要消除这个间隙, 我们把解除屏蔽移到pause后面可以吗?很显然不行,还没有解除屏蔽信号就调用pause将会导致根本等不到SIGALRM信号,我们需要的是将”解除信号屏蔽”和”挂起等待信号”这两步能合并成一个原子操作,这就是sigsuspend函数的功能。sigsuspend包含了pause的挂起等待功能,同时解决了竞态条件的问题,在对时序要求严格的场合下都应该调用sigsuspend而不是pause
1 |
|
和pause一样,sigsuspend没有成功返回值,只有执行了一个信号处理函数之后 sigsuspend才返回,返回值为-1,errno设置为EINTR
调用sigsuspend时,进程的信号屏蔽字由sigmask参数指定,可以通过指定sigmask来临时 解除对某个信号的屏蔽,然后挂起等待,当sigsuspend返回时,进程的信号屏蔽字恢复为原来的值,如果原来对该信号是屏蔽的,从sigsuspend返回后仍然是屏蔽的
下面是使用示例:
1 |
|
SIGCHLD信号
在之前学过的进程中,父进程通过wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义函数捕获SIGCHLD信号:父进程在信号处理函数中调用wait清理子进程即可
由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用:
1 |
|
信号部分总结完毕,接下来看看常用的普通信号(慢慢遇到了再补充):
| 编号 | 信号 | 含义 | 缺省动作 |
| —- | ——- | ——————————– | —————————————- |
| 1 | SIGHUP | 终端挂起或者控制进程终止 | 终止进程 |
| 2 | SIGINT | 键盘中断(如Ctrl C) | 终止进程 |
| 3 | SIGQUIT | 键盘的退出键被按下 | 终止进程并核心转储(dump core) |
| 6 | SIGABRT | 由abort()发出的退出指令 | 终止进程并核心转储(dump core) |
| 8 | SIGFPE | 浮点异常,比如错零错误 | 终止进程并核心转储(dump core) |
| 9 | SIGKILL | Kill信号 | 终止进程、信号不能被捕获 、信号不能被忽略 |
| 11 | SIGSEGV | 无效的内存引用,比如野指针 | 终止进程并核心转储(dump core) |
| 13 | SIGPIPE | 管道破裂: 写一个没有读端口的管道 | 终止进程 |
| 14 | SIGALRM | 由alarm函数发出的信号 | 终止进程 |
| 17 | SIGCHLD | 子进程结束信号 | 忽略此信号 |
| … | … | … | … |
- 本文作者: Tim
- 本文链接: https://zouchanglin.cn/2018/12/25/3266980630.html
- 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 许可协议。转载请注明出处!