Linux信号通识

来源:互联网 发布:视频质量诊断算法 编辑:程序博客网 时间:2024/04/29 19:55

何为信号

信号是操作系统提供的一种向进程通知发生了某种特定事件的机制。它是一种异步的通知机制,用来提醒进程一个事件已经发生。当一个信号发送给一个进程,操作系统中断了进程正常的控制流程,此时,任何非原子操作都将被中断。

直接看概念可能还是很模糊,所以从最简单的例子开始:

  1. 用户输入命令,在Shell下启动一个前台进程
  2. 用户按下Ctrl-C,这个键盘输入产生一个硬件中断
  3. 如果CPU当前正在执行这个进程的代码,则该进程的用户空间代码暂停执行,CPU从用户态切换到内核态处理硬件中断
  4. 终端驱动程序将Ctrl-C解释成一个SIGINT信号,记在该进程的PCB中(也可以说发送了一 个SIGINT信号给该进程)
  5. 当某个时刻要从内核返回到该进程的用户空间代码继续执行之前,首先处理PCB中记录的信号,发现有一个SIGINT信号待处理,而这个信号的默认处理动作是止进程,所以直接终止进程而不再返回它的用户空间代码执行

这里面要注意的是Ctr-C产生的信号只能发给前台进程。前台进程在运行过程中用户随时可能按下Ctrl-C而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。

在Linux中可以通过命令来查看信号列表:

         

可以看到,不存在0,32,33号信号,所以总共有62个信号。前31个信号称为普通信号,后31个信号称为实时信号。我只了解了一些常见的普通信号。比如2号SIGINT,8 SIGFPE,9 SIGKILL,14 SIGALRM,19 SIGSTOP,15 SIGTERM, 17 SIGCHLD信号。前边的数字是信号的编号,后边的名称是它对应的宏常量,都可以使用。

通过man 7 signal命令可以查看信号的解释,就不再列出。

信号的产生

信号的产生方法大致有四种:

  1. 硬件异常产生信号。这些条件由硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程
  2. 键盘按键产生信号。用户在终端按下某些键时,终端驱动程序会发送信号给前台进程,例如Ctrl-C产生SIGINT信号,Ctrl-\产生SIGQUIT信号,Ctrl-Z产生SIGSTOP信号
  3. 通过命令产生信号。例如kill -9 进程id
  4. 通过函数产生信号。例如kill函数,raise函数,abort函数,alarm函数

信号的处理动作

对于产生的信号,有三种处理方式:

  1. 忽略该信号
  2. 执行该信号默认处理动作
  3. 注册一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号

Core Dump与事后调试

比如SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,那么Core Dump(核心转储)是个什么鬼呢?

当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core.进程id或者core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器(gdb)检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。

一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。

举个栗子:

编写一个除0的程序:

int main(){int a = 1;int b = 0;int c = a / b;return 0;}
由于除数b的值是0,所以运行到这条语句会产生一个SIGFPE信号,默认动作是终止该进程并Core Dump。运行结果:

         

可见确实有这样一个异常信号,但是当前却没有产生Core文件,这是因为现在是线上环境,一般将core文件大小设置为0.为什么这样?因为如果不是这样,比如说有一个线上的服务器,不断的挂掉,不断的自我重启,不断的产生core文件,那么存储空间很快会被吃完,严重影响到了整个系统,怎么能允许呢?所以一般都是线下开发调试中允许使用。那么如何修改呢?

查看所有限制:

         

修改core默认大小:

         

然后再次运行测试程序:

         

现在提示核心已转储,并生成了一个core文件,如何调试。先gdb a.out,再core-file core 就可以直达令程序奔溃的语句了:

         

alarm函数

         

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。

         

signal函数用来注册信号处理函数,第一个参数为要注册的信号编号,第二个为函数指针。

#include <stdio.h>#include <signal.h>#include <unistd.h>#include <stdlib.h>void handler(int sig){printf("Is timeout...\n");exit(0);}int main(){signal(SIGALRM, handler);alarm(2);while (1);return 0;}

运行结果:

         

信号的阻塞

之前总结了信号产生的各种原因,但是,实际上,信号产生之后不是立即被处理的。

实际执行信号的处理动作称为信号递达(Delivery)。信号从产生到递达之间的状态叫做信号未决(Pending)进程可以选择阻塞(Block)某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才可能指向递达的动作。注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递
达之后 可选的一种处理动作。

信号在内核中的表示

         

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。

每个进程都有自己对PCB,在里面可以找到三张表:block表,pending表,handler表。下标+1对应信号编号,block表表示对应信号是否被阻塞,pending表示是否有信号处于未决状态,handler则表示对应信号的处理动作。可以理解,之前所讲的发送信号给某个进程,其实就是将它的PCB中的pending表的对应的位置给置1了(每个信号只有一 个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的)。所谓的阻塞某个信号,就是将block表中对应的位置置1.而注册信号处理函数其实就是修改了对应的handler表。

未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

在上图中,
1. SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
2. SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
3. SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。

在Linux中:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。

信号集操作函数:

         

sigempty用来将参数set的值全部清0.sigfillset则是全部置1.sigaddset和sigdelset分别向set中添加和删除某一个信号标记。sigismember用来判断某个信号是否在set信号集中。注意:以上这些函数只是设置当前参数set的内容,并没有直接修改那三张表。要修改这三张表,还需要其它函数/。

sigprocmask函数:

         

第一个参数how表明要执行何种操作,第二个参数set表明将set这个信号集执行对应操作,第三个参数为旧的信号集,用以留作副本,以备不时之需。

how的取值:

         

SIG_BOLCK表示将第二个参数设置进阻塞信号集。

SIG_UNBLOCK则将set中对应信号取消在block中的设置。

SIG_SETMASK直接将block整个表设置为set。

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

sigpending函数:

         


这个函数用来获取pending表。

举个栗子:

这个小程序不断打印pending表,然后阻塞二号信号。通过发送2号信号,观察pending表变化过程。

#include <stdio.h>#include <unistd.h>#include <signal.h>void print(sigset_t *pending){int i = 1;for (; i <= 31; i++){if (sigismember(pending, i))printf("1");elseprintf("0");}printf("\n");}int main(){sigset_t set, oset;sigemptyset(&set);sigaddset(&set, SIGINT/*2*/);sigprocmask(SIG_BLOCK, &set, &oset);sigset_t pending;sigemptyset(&pending);while (1){sigpending(&pending);print(&pending);sleep(1);}return 0;}

运行结果:

         

开始一直打印全0,当我用kill命令给这个进程发送一个2号信号后,它的pending表中的2号信号相应位置被置1.

再修改一下这个程序,让接收到信号后在取消2号信号的阻塞,查看信号处理的过程:

#include <stdio.h>#include <unistd.h>#include <signal.h>void print(sigset_t *pending){int i = 1;for (; i <= 31; i++){if (sigismember(pending, i))printf("1");elseprintf("0");}printf("\n");}int main(){sigset_t set, oset;sigemptyset(&set);sigaddset(&set, SIGINT/*2*/);sigprocmask(SIG_BLOCK, &set, &oset);sigset_t pending;sigemptyset(&pending);int i = 0;while (1){sigpending(&pending);print(&pending);sleep(1);if (i++ == 10){sigprocmask(SIG_SETMASK, &oset, NULL);}}return 0;}

运行结果:     

           

我在前几秒发送了一个2号信号,pending信号对应为被设为1,然后10s后取消对2号信号的阻塞,2号信号被递达,执行默认处理动作,终止掉了该进程。

信号的捕捉

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

         

当程序在执行过程中,遇到中断或者陷井或者异常或者系统调用而陷入内核态,将控制流转移给内核,由内核处理完中断或者陷井或者异常或者系统调用,然后打算返回到用户态继续执行之前断点处的程序时,会先做一件事情:

检查那三张表:

看是否存在处于未决状态,且未被阻塞的信号,如果不存在,则直接返回到用户态,执行断点后的程序。如果存在:

  1. 如果handler表对应信号处理方式为忽略,则将pending表中对应信号清0,然后返回用户态断点继续执行
  2. 如果handler表对应信号处理方式为缺省,则(大部分情况下)终止掉该进程。进程已终止,便无需返回
  3. 如果handlet表对应信号处理方式为注册了信号处理函数,切换到用户态,执行对应的信号处理函数。

对于第三种情况:当对应信号处理函数执行完毕后,又从用户态切换到内核态(通过执行特殊的系统调用),然后再从内核态切换回用户态,从主控制流程中上次被中断的地方继续向下执行。

为什么这里要切换到用户态才去执行信号处理函数呢?内核态不能执行吗?答案是:内核态当然有权限执行用户程序,但是不能这样做。因为用户的程序有可能有bug或者是恶意程序,有可能对我们的操作系统造成破坏,所以必须以用户自己的权限来执行,防止其造成更大破坏。

signal函数之前已写过。但有缺陷,因为如果正在执行信号处理函数时,有可能被其它的信号中断当前的信号处理函数,可能引发一些错误,相比而言,sigaction就不存在这个问题。

sigaction函数

         

         

signum为信号编号,act为一个结构体,其中:

sa_handler为要注册的信号处理函数,第二个字段与实时信号有关,这里不关心。sa_mask就表明在执行当前信号处理函数时要暂时屏蔽的信号,sa_flags我们设置为0即可,最后一个字段也不关心。oldact可以见名知意。

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

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

pause函数:

         

pause函数使调用进程挂起直到有信号递达。如果信号的处理动作是终止进程,则进程终止,pause函数没有机会返回;如果信号的处理动作是忽略,则进程继续处于挂起状态,pause不返回;如果信号的处理动作是捕捉,则调用了信号处理函数之后pause返回-1,errno设置为EINTR, 所以pause只有出错的返回值。错误码EINTR表示示“被信号中断”。

举个栗子:

实现my_sleep函数

#include <stdio.h>#include <unistd.h>#include <signal.h>void sig_sleep(){}int my_sleep(int timeout){struct sigaction act, oact;act.sa_handler = sig_sleep;sigemptyset(&act.sa_mask);act.sa_flags = 0;sigaction(SIGALRM, &act, &oact);alarm(timeout);pause();int left = alarm(0); // 清空闹钟sigaction(SIGALRM, &oact, NULL); // 恢复旧的处理动作return left;}int main(){while (1){printf("I am go to sleep...\n");my_sleep(1);}return 0;}

运行结果:

         

捕获SIGCHLD信号

利用信号处理函数一次性wait所有退出子进程:

#include <stdio.h>#include <signal.h>#include <stdlib.h>#include <unistd.h>#include <sys/wait.h>#include <sys/types.h>void handler(int sig){int status;while (waitpid(-1, &status, 0) > 0){printf("wait success: exit code: %d, sig: %d\n",(status>>8)&0xff, status&0xff);}}int main(){struct sigaction act;act.sa_handler = handler;sigemptyset(&(act.sa_mask));act.sa_flags = 0;sigaction(SIGCHLD, &act, NULL);pid_t id = fork();if (id < 0){perror("fork");return 1;}else if (id == 0) // child{printf("child go die...pid: %d, ppid: %d\n", getpid(), getppid());sleep(13);exit(2);}else  // father{printf("father...pid: %d, ppid: %d\n", getpid(), getppid());sleep(15);}return 0;}



2 0