【关于Linux中----信号】


一、信号入门

1.1 信号概念

信号是UNIX和Linux系统响应某些条件而产生的一个事件,接收到该信号的进程会相应地采取一些行动。信号是软中断,通常信号是由一个错误产生的。但它们还可以作为进程间通信或修改行为的一种方式,明确地由一个进程发送给另一个进程。一个信号的产生叫生成,接收到一个信号叫捕获。

信号是发送给具体的进程的,告诉进程要在合适的时候执行相应的动作。这要求进程具有识别信号的能力,并且知道收到一个信号时,应该做出什么反应,这些是在操作系统的源代码中就已经设置好了的。

但是,当进程收到某种信号时,该信号并不一定是被进程立刻处理的,因为进程此时可能在做更重要的事情。

当进程不能立刻处理接受到的信号时,该信号会被进程保存起来,以便该信号在合适的时候被进程处理。而这个信号是被进程保存在struct task_struct中的,信号的本质也是数据。也就是说,信号的发送本质上就是向进程的PCB中写入信号数据。

PCB是一个内核数据结构,用来定义进程对象。但是操作系统不相信任何人,那么信号数据是怎么写入PCB的呢?

答案是操作系统本身。所以无论信号如何发送,本质都是在底层通过操作系统发送的。

1.2 用 kill-l命令查看信号列表

[sny@VM-8-12-centos practice]$ kill -l
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX	

每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define SIGINT 2
编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal

在这里插入图片描述

1.3 信号处理常见方式预览

一般而言,进程收到信号的处理方案有三种。

①默认动作:终止进程、暂停进程等。
②忽略动作:不对进程做任何改变。
③自定义动作:用自己定义的接口替换到某一个进程原本应该让进程做出的反应(后文详细说明)。

二、产生信号

2.1 通过终端按键产生信号

当我们在Linux中编写的程序中包含死循环时,我们输入任何操作指令都不能让进程退出,除非输入ctrl+c这类指令,如下:
在这里插入图片描述

执行结果如下(该程序对应的Makefile太简单,这里就不粘贴了):

[sny@VM-8-12-centos practice]$ make clean;make
rm -f mytest
gcc -o mytest test.c
[sny@VM-8-12-centos practice]$ ls
Makefile  mytest  test.c
[sny@VM-8-12-centos practice]$ ./mytest
hello world!
hello world!
hello world!
^C

这里使用Ctrl +C的本质就是从键盘向进程发送2号信号,接下来简单地证明一下这个结论。

先了解一个函数接口:
在这里插入图片描述
这里的typedef void (*sighandler_t)(int)是一个函数指针,指针指向的函数可以完成对指定的信号在进程中执行动作的替换。
下面的sighandler_t signal(int signum, sighandler_t handler);是一个回调函数,第一个参数是某一个信号的编号,第二个参数是函数指针。使用这个函数就可以完成对于自己设定的信号执行动作的替换(类似qsort)。

举个例子:
在这里插入图片描述
执行结果如下:

[sny@VM-8-12-centos practice]$ ./mytest
hello world!
hello world!
hello world!
^Cget the signal: signal number:2 pid:28845
hello world!
hello world!
hello world!
^\Quit

可以看到,我们在代码中将2号信号的处理动作换为打印一条语句,而当我们输入Ctrl C的时候,进程没有退出,而是打印了这条语句,所以上面的结论是正确的!
注意,键盘产生的信号只能终止前台的进程,后台进程可以使用kill -9命令终止!9号信号不可被捕捉。

举个例子:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
   
 void handler(int signo)
 {
   switch(signo)
   {
    case 2:                             
      printf("捕捉2号信号\n");          
	break;
    case 3:                             
      printf("捕捉3号信号\n");          
      break;                                                                                                                                         
    case 9:                             
      printf("捕捉9号信号\n");          
      break;                            
    default:                            
      break;                            
  }                                                                  
}                                       
                                        
int main()                              
{                                       
  for(int i=1;i<=31;i++)                
  {//可以捕捉所有信号                   
    signal(i,handler);                  
  }                                     
  while(1)                                                                                                                                  
  {                                                                                                                                             
    printf("hello world!\n");                                                                                                               
    sleep(2);                                                                                                                               
  }
  return 0;
}        

执行结果如下:

至于为什么,下文再解释。

2.2 由于程序中存在异常产生信号

下面来写一个有明显错误的代码:
在这里插入图片描述
毫无疑问,最后一定会因为这个错误,导致该进程崩溃。
崩溃提示如下:

[sny@VM-8-12-centos practice]$ make clean;make
rm -f mytest
gcc -o mytest test.c
test.c: In function ‘main’:
test.c:34:6: warning: division by zero [-Wdiv-by-zero]
     a/=0;
      ^
[sny@VM-8-12-centos practice]$ ./mytest
Floating point exception

这是一个浮点数错误。
接下来,将代码还原为2.1中最后的样子,并在handler函数稍作修改:
在这里插入图片描述

查看一下最后该进程收到的是几号信号。

[sny@VM-8-12-centos practice]$ make clean;make
rm -f mytest
gcc -o mytest test.c
test.c: In function ‘main’:
test.c:36:6: warning: division by zero [-Wdiv-by-zero]
     a/=0;
      ^
[sny@VM-8-12-centos practice]$ ./mytest
捕捉8号信号

也就是说,这个浮点数错误导致进程收到了8号信号,进而导致进程退出。为什么?

答:因为程序中的任何计算都会有CPU的参与,如果程序中出现了问题,CPU都会将其记录在案。而软件上的错误最终会体现在硬件或者其他软件上。而操作系统时硬件的管理者,当硬件出现问题时,操作系统会找到出错的进程,并向进程发送信号,导致进程终止。

当一个进程崩溃时,我们最关心的是什么?

毫无疑问一定是崩溃的原因,这个原因根据上一个问题就可以知道。
除此之外,我们往往还想知道,到底是进程中的那一部分出错导致了崩溃。

在Linux中,当一个进程推出的时候,它的退出码和退出信号都会被设置;当一个进程异常的时候,进程的退出信号会被设置,表明当前进程推出的原因。如果有必要,操作系统会设置推出信息中的core dump标志位,并将进程在内存中的数据转储到磁盘中,方便我们后期调试。

可是好像在上面的例子中,并没有看到进程运行结束后,有提示程序中那一部分出错的现象。这是因为在云服务器上,关于core dump的文件是被关掉的,其他的机器情况可能不同。

[sny@VM-8-12-centos practice]$ ulimit -a
core file size          (blocks, -c) 0//core dump相关文件
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 7908
max locked memory       (kbytes, -l) unlimited
max memory size         (kbytes, -m) unlimited
open files                      (-n) 100001
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 7908
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

将其打开只需执行如下指令:
在这里插入图片描述

接下来再运行程序,最后显示地结果就会不一样,而且会在当前目录下生成一个该程序的可以支持调试的二进制文件,如下:

[sny@VM-8-12-centos practice]$ ./mytest
Floating point exception (core dumped)
[sny@VM-8-12-centos practice]$ ll
total 136
-rw------- 1 sny sny 249856 Jan 17 20:45 core.26796
-rw-rw-r-- 1 sny sny     63 Jan 17 17:02 Makefile
-rwxrwxr-x 1 sny sny   8408 Jan 17 20:45 mytest
-rw-rw-r-- 1 sny sny    607 Jan 17 20:45 test.c

但是现在仍然没有显示到底是那个地方出错了,为了知道出错的地方,要调试起来,将Makwfile文件内容稍作修改:
在这里插入图片描述

然后运行程序,并开始调试,用生成的二进制文件进行调试:

[sny@VM-8-12-centos practice]$ gdb mytest
GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-120.el7
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/sny/code/practice/mytest...done.
(gdb) core-file core.26796
[New LWP 26796]
Core was generated by `./mytest'.
Program terminated with signal 8, Arithmetic exception.
#0  0x0000000000400595 in main () at test.c:36
36	    a/=0;
Missing separate debuginfos, use: debuginfo-install glibc-2.17-326.el7_9.x86_64

但是并不是所有的程序都会生成core文件,具体取决于该进程的core dump是否为1,举个例子查看core dump:
在这里插入图片描述
结果如下:

[sny@VM-8-12-centos practice]$ ./mytest
子进程!
exit code:0 exit signal:8 core dump:1

2.3 系统接口调用产生信号

我们也可以直接调用系统接口kill来产生信号:
在这里插入图片描述
样例代码如下:
在这里插入图片描述

接下来做以下操作:
在这里插入图片描述

可以看到,刚开始右边有一个sleep进程,但是当我们用左边的程序给sleep进程发送了9号信号之后,sleep进程就不存在了。因为它接收到了9号信号,所以退出。
以上就是通过系统接口发送信号的全过程。

第二个接口为raise,它的作用是给进程本身发送信号:在这里插入图片描述

样例代码如下:

int main()
{
  while(1)
  {
    printf("This is a process!\n");
    sleep(2);
    raise(9);                                                                                                                                        
  }                                                                                                                                  
}                                                                                                                                    

结果如下:

[sny@VM-8-12-centos practice]$ ./mytest
This is a process!
Killed

第三个接口abort是给进程本身发送6号信号,这个读者可以用捕捉信号的代码自己验证一下,由于代码大量相同,这里就不粘贴了。

2.4 软件条件产生信号

这种信号一般是通过某种软件(操作系统),来触发信号的发送,系统层面设置定时器,或者某种操作而导致条件不就绪等这样的场景下,触发的信号发送。比如:进程间通过管道通信时,读端关闭,写端还在写时,就会触发信号发送,关闭写端。

这里用到的接口是alarm:
在这里插入图片描述
这个接口的参数是时间,表示在一段时间之后向进程发送一个信号(14号信号),返回值为剩余的时间(秒)。

举个例子:
在这里插入图片描述

结果如下:

[sny@VM-8-12-centos practice]$ make clean;make
rm -f mytest
gcc -o mytest test.c -g
[sny@VM-8-12-centos practice]$ ./mytest
This is a process,ret:0
res:25
This is a process,ret:0
res:0

如果没有设置alarm的模拟动作,则会执行默认动作,终止进程。
看一个例子:
在这里插入图片描述

这段代码是要统计一秒内能对count递增到多少,结果如下:
在这里插入图片描述
虽然信号产生的方式很多,但最终一定都是通过操作系统向目标进程发送的信号。

如何理解操作系统向进程发送信号?

前面说过,在PCB中一定有对应的数据变量,来保存进程是否收到了对应的信号。
其实,这个数据变量是一个位图结构,也就是说用一个数字的每一个比特位是否为1表示该进程是否收到某一个信号。
所以,操作系统向进程发送信号本质就是,向PCB中的信号位图写入比特位1。


三、阻塞信号

3.1 信号相关常见概念补充

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

3.2 在内核中的表示

在这里插入图片描述

上图中的pending就是前面解释过的位图结构,用来表示是否接收到某信号,而handler本质上是一个函数指针数组,其中每一个指针都指向一个参数为一个整形值,无返回值的函数,就类似于我们前面实现的handler函数。
当函数参数为0或1时,进程就对该信号进行默认和忽略操作,如果是其他值,就把指向的函数地址填入handler,以便做出对应的反应。

操作系统下定义的SIG_DFL和SIG_IGN如下:

[sny@VM-8-12-centos practice]$ grep -ER 'SIG_DFL|SIG_IGN' /usr/include/
/usr/include/asm-generic/signal-defs.h:#define SIG_DFL	((__sighandler_t)0)	/* default signal handling */
/usr/include/asm-generic/signal-defs.h:#define SIG_IGN	((__sighandler_t)1)	/* ignore signal */

而block表本质上也是一个位图结构,比特位的位置代表信号的编号,比特位内容表示是否收到了该信号。

进程在处理某一个信号的时候,首先要确定block和pending是否为1,同时为1则表示该进程被阻塞,若block为0,pending为1表示该信号可以被递达,也就可以进行具体的操作。

所以,上面的那张表应该横着看。

3.3 sigget_t及其操作函数

每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

虽然sigset_t是一个位图结构,但是不同的操作系统的具体实现是不一样的,但都不能让用户直接修改该变量,所以就需要使用一些特定的函数。

#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做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

sigprocmask:

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

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

返回值:若成功则为0,若出错则为-1。

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

在这里插入图片描述
举个例子:

#include <stdio.h>
#include <signal.h>
#include <unistd.h> 

int main()
{
  sigset_t iset,oset;
  sigemptyset(&iset);
  sigemptyset(&oset);
  sigaddset(&iset,2);//屏蔽2号信号                                                                                                                   
  sigprocmask(SIG_SETMASK,&iset,&oset);  
  while(1)                               
  {                                      
    printf("hello world!\n");            
    sleep(3);                            
  }                                      
  return 0;                              
}  

结果如下:

[sny@VM-8-12-centos practice]$ make clean;make
rm -f mytest
gcc -o mytest test.c -g
[sny@VM-8-12-centos practice]$ ./mytest
hello world!
hello world!
^Chello world!//输入2号信号对应操作,继续打印信息,2号信号被阻塞
^\Quit

注意,这样的操作同样对9号信号不起作用!

sigpending:

在这里插入图片描述
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

举个例子:
在这里插入图片描述

上面代码的意思是先屏蔽2号信号,这样2号信号就不会被递达。
当进程跑起来时,pending中将全为0,而当向进程发送2号信号之后,pending中第二个比特位将会为1。

结果如下:

[sny@VM-8-12-centos practice]$ make clean;make
rm -f mytest
gcc -o mytest test.c -g
[sny@VM-8-12-centos practice]$ ./mytest
0000000000000000000000000000000
0000000000000000000000000000000
^C0100000000000000000000000000000
0100000000000000000000000000000
^\Quit

可见,结果和预计相同。

四、处理信号

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

前面说过,信号被收到之后,进程会在合适的时候进行相关的操作,那么什么时候才是合适的时候呢?
答案是当进程从内核态转变为用户态的时候,这个时候,进程就会先在PCB中的pending位图里检测收到的信号,并做出处理。

内核态:执行操作系统的代码和数据时所处的状态
用户态:执行用户的代码和数据时所处的状态

它们的区别在于,内核态的权限要远远大于用户态的权限。

下面来详细地解释一下内核态和用户态:
在我之前的文章关于Linux中----进程优先级、环境变量和进程地址空间中解释过:每一个i昵称都有自己的虚拟空间,这些虚拟地址会经过页表和MMU映射到具体的物理内存。而每一个进程都有自己的虚拟空间和页表,这也保证了不同的进程不会占用同一块资源。

但是,在虚拟地址空间中除了有3GB的用户空间之外,还有1GB的内核空间,如下:
在这里插入图片描述
虽然每一个进程都有4GB的虚拟地址空间,但是并不意味着它们都可以随时随地访问这些空间。

上面说的页表指的是用户级页表,每个进程都有。实际上,还有一个内核级页表。这个页表被所有进程共享,但是共享不代表可以访问

只有当进程处于内核态时,才能经过那1GB的内核空间和内核级页表的映射访问到物理内存中属于操作系统的代码和数据。这也就是所谓的“权限的不同”。

当进程从用户态转变为内核态时,操作系统处理完所有的异常之后准备返回用户态之前,就会检测当前进程中的PCB的pending位图。如果某一信号的处理动作时自定义的,则返回用户态执行信号处理函数,然后再通过特殊的系统调用进入内核态,最后再返回用户态中上次中断的地方继续向下执行。

如图:
在这里插入图片描述
为什么一定要切换为用户态才能执行信号捕捉方法呢?

理论上来说,操作系统是可以执行用户的代码的。但是操作系统不相信任何人,为了防止操作系统执行的用户代码里存在恶意危害操作系统的代码的情况出现,每次执行信号捕捉代码都需要切换为用户态。

4.2 sigaction

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体。
在这里插入图片描述

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

举个例子:
在这里插入图片描述

结果如下:

[sny@VM-8-12-centos practice]$ make clean;make
rm -f mytest
gcc -o mytest test.c -g
[sny@VM-8-12-centos practice]$ ./mytest
hello world!
hello world!
^C捕捉到2号信号
hello world!
hello world!
^\Quit

可以看到,这个方法跟之前介绍过的方法没有什么不同。

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

  • 20
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 16
    评论
评论 16
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Undefined__yu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值