进程信号(Linux)

Posted 雨轩(爵丶迹)

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了进程信号(Linux)相关的知识,希望对你有一定的参考价值。

Linux进程信号

信号入门

1、生活角度的信号

  • 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
  • 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
  • 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取
  • 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:
     1. 执行默认动作(幸福的打开快递,使用商品)
     2. 执行自定义动作(快递是零食,你要送给你你的女朋友)
     3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
  • 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

2、技术应用角度的信号

先看下面的代码,一眼就看出来是死循环。

1 #include <stdio.h>
2 int main()
3 
4   while(1)                                                                                                                                                                     
5   printf("I am a process, I am waiting signal!\\n");
6   sleep(1);
7   
8 

我们直接Ctrl+C终止这个进程

我们知道可以终止这个进程,但是为什么能终止?

我们输入Ctrl+C,在Shell下启动一个前台进程 ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号(Ctrl+C是2号信号),2号信号发送给目标前台进程。前台进程因为收到2号信号,进而引起进程退出。

上面是系统默认的处理方式,输入Ctrl+C就会终止掉进程,就跟你默默的打开快递一样,没有做任何思考(进程就是你,操作系统就是快递员,信号就是快递)。
那么我们是否可以自定义进行处理?当然是可以的,这里先介绍一个函数:signal函数

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signal()将信号符号设置为处理程序,处理程序可以是SIGIGN、SIG-DFL,也可以是程序员定义的函数(“信号处理程序”)的地址。
signum:信号编号
如果配置设置为SIG_IGN,则忽略该信号。
如果配置设置为SIG_DFL,则发生与信号关联的默认动作。
如果配置设置为一个功能,然后首先要么配置重置到SIG_DFL,或sianal被阻塞,然后处理程序是用signum参数调用。如果对处理程序的调用导致信号被阻塞,那么信号在从处理程序返回时被解除阻塞。
handler:对应自处理方法

我们写来用signal函数自定义捕抓这个信号,signal函数对2号信号进行捕捉,如果我们捕抓到了2号信号,证明Ctrl+C确实是收到了2号信号

    1 #include <stdio.h>
    2 #include <signal.h>
    3 void handler(int signo)                                                                                                                                                         
    4 
    5   printf("this signo is %d\\n",signo);
    6 
    7 int main()
    8 
    9   signal(2, handler);
   10 	while(1)
   11 		printf("I am a process, I am waiting signal!\\n");
   12 		sleep(1);
   13 
   14 

当该进程收到2号信号时,我们在输入Ctrl+C就不会终止进程,而是执行我们对应的自定义方法,捕抓到了该信号,并打印出来。由此证明,Ctrl+C确实是收到了2号信号。

这里要退出这个进程可以使用Ctrl+\\(Ctrl+\\对应3号信号SIGQUIT,代表退出信号) 。

3、注意

  1. Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
  2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
  3. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。

4、信号概念

信号是进程之间事件异步通知的一种方式,属于软中断。

5、用kill -l命令可以察看系统定义的信号列表

下面是每个信号编号对应的信号。

其中1-31号信号是普通信号,34-64号是实时信号。每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义#define SIGINT 2(Ctrl+C对应的信号)

我们可以通过命令,找出对应信号编号宏定义的路径,在查看其宏定义。

[dy@VM-12-10-centos rumen]$ sudo find / | grep 'signum.h'
/usr/include/bits/signum.h

下面是信号对应的宏定义

6、信号是如何发送的以及如何记录?

  1. 信号如何发送:进程收到信号,本质是进程内对应结构体里面的信号位图被修改了,只有OS有这个资格去修改,所以是OS直接去修改了目标进程task_struct中的信号位图。例外信号发送的方式有多种,但是只有OS有资格发送(掌管生杀大权)。
  2. 信号如何记录:当一个进程接受到信号后,该信号记录在进程对应的task_struct(PCB)中,PCB本质就是一个结构体变量,我们可以用32位的位图来记录一个信号是否产生。

    其中比特位的位置代表信号编号,比特位的内容就代表是否收到信号,比如第6个比特位是1就表明收到了6号信号。

7、信号处理常见方式概览

可选的处理动作有以下三种:

  1. 忽略此信号。(忽略并不是不处理,比如你拿了快递丢到一边,继续玩手机)
  2. 执行该信号的默认处理动作。
  3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号

可以通过命令man 7 signal查看各个信号默认的处理动作

注意:SIGKILL和SIGSTOP命令是不能捕捉的,捕捉了,系统就不能杀掉一个进程和停止一个进程了。

产生信号

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

刚开始,我们就已经展示了Ctrl+C能终止一个进程。其实Ctrl+\\也可以终止掉一个进程。我们继续拿第一次的代码来展示。

1 #include <stdio.h>
2 int main()
3 
4   while(1)                                                                                                                                                                     
5   printf("I am a process, I am waiting signal!\\n");
6   sleep(1);
7   
8 

终止掉了该进程。

那么Ctrl+C和Ctrl+\\ 有什么区别?

1、Ctrl+C对应2号信号(SIGINT),SIGINT的默认处理动作是终止进程。
2、Ctrl+\\ 对应3号信号(SIGQUIT),SIGQUIT的默认处理动作是终止进程并且Core Dump。

这个两个命令都是终止进程,但是SIGQUIT多出了一个操作为Core Dump(核心转储)。

Core Dump

那么什么是核心转储(Core Dump)?我们先来看一下这个两个命令对应的信号的默认处理方式。


从上面这张图,我们看到两个都是终止进程,但是对应的行为Action是不一样的,一个对应Term,一个对应Core,我们在来看看这两个的区别。

看完这两个的区别之后,我们在来看核心转储的概念。

  1. 首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做CoreDump。
  2. 进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortemDebug(事后调试)。
  3. 一个进程允许产生多大的core文件取决于进程的Resource Limit(资源限制)(这个信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: $ ulimit -c 1024

了解概念之后我们继续往下走,刚刚我们知道一个进程默认是不允许产生core文件的,即核心转储被关闭了,我们可以通过使用ulimit -a命令查看当前资源限制的设定。
可以看到,core file size的大小为0,因为核心转储被关闭了。我们可以通过命令ulimit -c size来修改其大小,这就代表着核心转储被打开了。

注意:ulimit命令改变了Shell进程的Resource Limit,test进程的PCB由Shell进程复制而来,所以也具有和Shell进程相同的Resource Limit值,这样就可以产生Core Dump了


我们在运行刚开始的文件,输入Ctrl+\\ 就会显示Core Dump,并且会产生一个Core文件(保存进程的用户空间内存数据),core文件后面还带有一个进程编号pid


我们在对应的程序里面获取对应的pid,来验证一下是否是对应的进程编号。

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 int main()
  4 
  5   while(1)
  6     printf("pid:%d\\n",getpid());                                                                                                                                                  
  7   printf("I am a process, I am waiting signal!\\n");
  8   sleep(1);
  9   
 10 

我们这里可以看到是一样的,说明就是对应的进程编号

上面我们的程序异常终止了,我们肯定会去想为什么终止,这时候就需要去调试代码。
核心转储作用:核心转储的目的就是为了在调试时,方便问题的定位。

我们写一段代码来展示:

  #include <stdio.h>    
  #include <unistd.h>    
  int main()    
      
    printf("hello world\\n");    
    sleep(3);                                                                                                                                                                         
    int a = 1/0;    
    return 0;    
    

除0错误,生成了一个core文件。

我们通过命令gdb对当前的可执行程序进行调试,然后直接使用core-file core文件命令加载core文件,即可判断出该程序在终止时收到了8号信号,并且定位到了错误的代码处,显示行数。

这样的操作就叫事后调试,以后我们的程序崩溃了,就可以打开核心转储,gdb调试即可找到对应报错的行数。

我们之前学进程等待的时候,其中的waitpid函数和wait函数:

pid_t waitpid(pid_t pid, int *status, int options);
pid_t wait(int*status);

status是一个输出型参数,用于获取子进程的退出状态。status的不同比特位所代表的信息不同(可以当位图理解,0表示没有,1表示有),具体如下图。

如果进程是正常退出,高八位表示其退出状态,如果进程是异常终止,则低七位表示该进程收到对应的终止信号,第八位是core dump标志,表示该进程是否进行了核心转储。

我们编写一个进程等待的程序,在子进程中进行一个非法访问,比如野指针异常、除0、异常终止等。

  #include <stdio.h>    
  #include <stdlib.h>    
  #include <unistd.h>    
  #include <sys/wait.h>    
  #include <sys/types.h>    
  int main()    
      
    pid_t pid = fork();    
    if(pid < 0)    
        
      perror("fork fail");    
      return 1;    
        
    else if(pid == 0)    
        
      printf("i am child\\n");    
      int a = 1/0;    
        
    else    
        
      int status = 0;    
      int ret = wait(&status);    
      printf("exitCode:%d, core dump:%d, exitSignal:%d\\n",(status>>8)&0xFF,(status>>7)&1, status & 0x7F);                                                                             
        
    return 0;    
     

通过下面的运行结果,我们可以看到core dump标志位为1,即第八位为1,说明对应子进程发生了核心转储(为0则表示没有发生核心转储)。

子进程没有发生核心转储,core dump标志位为0。

所以我们现在知道了core dump标志位的作用:表示一个进程崩溃的时候,是否进行了core dump。

组合键

通过终端按键产生信号,并不只有Ctrl+C、Ctrl+\\,还有其他的组合键。我们这里写一个程序,将1-31号信号进行捕获,并将收到信号后的默认处理动作改为我们自定义的动作。

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

void handler(int signal)

	printf("catch signal:%d\\n", signal);

int main()

	int signo;
	for (signo = 1; signo <= 31; signo++)
		signal(signo, handler);
	
	while (1)
		sleep(1);
	
	return 0;



通过运行结果,我们可以看到Ctrl+C、Ctrl+\\、Ctrl+Z都被捕获了,然后执行我们的自定义动作,需要注意的是,我们去杀死这个程序时,执行kill pid,我们可以捕抓到对应的信号,但是kill -9 pid我们是捕获不到的,刚开始就说过,如果9号信号捕获了,那么一个进程就永远都杀不死了,即便是操作系统本身。

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

我们先在后台执行一个死循环程序,然后执行kill -SIGSEGV pid命令发送SIGSEGV信号给该进程。

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

kill命令:调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。

#include <signal.h>
int kill(pid_t pid, int signo);
成功返回0,错误返回-1

模拟kill命令

#include <stdio.h>    
#include <stdlib.h>    
#include <sys/types.h>    
#include <signal.h>    
void Usage(char* proc)    
    
  printf("Usage: %s pid signo\\n", proc);                                                
    
int main(int argc, char* argv[])    
    
  if(argc != 3)    
    Usage(argv[0]);    
    return 1;    
      
  argv是命令行参数指针数组
  pid_t pid = atoi(argv[1]);    
  int signo = atoi(argv[2]);    
  kill(pid, signo);    
  return 0;    

我们开起一个后台进程

[dy@VM-12-10-centos SignalGeneration]$ sleep 1000 &
[1] 30379

然后我们通过自己编写的代码,通过程序名 进程pid 信号编号杀死该后台进程。

raise函数:raise函数可以给当前进程发送指定的信号(自己给自己发信号)

int raise(int signo);
成功返回0,错误返回-1

通过程序,我们来看一下raise的使用:

  1 #include <stdio.h>
  2 #include <signal.h>
  3 #include <unistd.h>
  4 void handler(int signo)
  5 
  6   printf("this signo is %d\\n",signo);
  7 
  8 int main()
  9 
 10   signal(2, handler);
 11   while(1)
 12   
 13     sleep(1);
 14     raise(2);                                                                                                               
 15   
 16 

每隔一秒给自己发送一个2号信号

当然,9号信号不能自己给自己发,会直接终止掉进程的,至于原因,这里就不在冗余。

abort函数:使当前进程接收到信号而异常终止。

#include <stdlib.h>
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值

通过程序,我们来看一下abort的使用:

  1 #include <stdio.h>
  2 #include <signal.h>
  3 #include <stdlib.h>
  4 #include <unistd.h>
  5 void handler(int signo)
  6 
  7   printf("this signo is %d\\n",signo);
  8 
  9 int main()
 10 
 11   signal(6, handler);                                                                                                       
 12   while(1)
 13   
 14     sleep(1);
 15     abort();
 16   
 17 

发送一个指定信号(SIGABRT:6)就异常终止。

abort函数的作用是异常终止进程,而abort本质是通过向当前进程发送SIGABRT信号而终止进程的,但使用abort函数终止进程总是成功的。
exit函数的作用是正常终止进程,使用exit函数终止进程可能会失败,

3、由软件条件产生信号

进行通信中的SIGPIPE信号:

  • SIGPIPE信号是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。

我们来看例外的一种软件条件产生的信号,SIGALRM信号:

#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm 函数可以设定一个闹钟
也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号
该信号的默认处理动作是终止当前进程

alarm函数的返回值:
若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置。
如果调用alarm函数前,进程没有设置闹钟,则返回值为0

比如,我们可以看看在自己的终端上面,一秒钟可以将一个变量累加到多少。

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 int main()
  4 
  5   int count = 0;
  6   alarm(1);
  7   while(1)
  8   
  9     count++;
 10     printf("count = %d\\n",count);                                                   
 11   
 12 

在我自己的服务器上,可以加到24000左右。

其实,count累加的值远远大于上面的运行结果。由于我们每进行一次累加就进行了一次打印操作,而与外设之间的IO操作所需的时间要比累加操作的时间更长。再加上自己网络传输等耗时,所以累加的比较少。

我们可以让count一直累加,然后捕获SIGALRM信号,最后在输出count看看是多少。

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <signal.h>
  4 #include <stdlib.h>
  5 int count = 0;
  6 void handler(int sig)
  7 
  8   printf("catch a signal:%d\\n", sig);                                               
  9   printf("count = %d\\n",count);
 10   exit(-1);
 11 
 12 int main()
 13 
 14   signal(SIGALRM, handler);
 15   alarm(1);
 16   while(1)
 17   
 18     count++;
 19   
 20   return 0;
 21 


由此证明:与计算机单纯的计算相比较,计算机与外设进行IO时的速度是非常慢的。

4、硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

模拟一下野指针异常

    1 #include <stdio.h>
    2 #include <unistd.h>
    3 #include <signal.h>
    4 #include <stdlib.h>
    5 int main()                                                                        
    6                                     
    7   int *p;                            
    8   *p = 100;
    9   return 0;
   10 


通过运行结果我们可以得知,程序异常了,操作系统是如何识别到的?

首先,学进程的时候我们就知道,访问一个变量,需要经过页表的映射,虚拟地址转换成物理地址,最终找到对应的数据位置。


总结:

1、其中页表属于一种软件映射关系,而实际上在从虚拟地址到物理地址映射的时候还有一个硬件叫做MMU,它是一种负责处理CPU的内存访问请求的计算机硬件,因此映射工作不是由CPU做的,而是由MMU做的,但现在MMU已经集成到CPU当中了。

2、当需要进行虚拟地址到物理地址的映射时,我们先将页表的左侧的虚拟地址导给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问。

3、而MMU既然是硬件单元,那么它当然也有相应的状态信息,当我们要访问不属于我们的虚拟地址时,MMU在进行虚拟地址到物理地址的转换时就会出现错误,然后将对应的错误写入到自己的状态信息当中,这时硬件上面的信息也会立马被操作系统识别到,进而将对应进程发送SIGSEGV信号。

所以C/C++程序崩

以上是关于进程信号(Linux)的主要内容,如果未能解决你的问题,请参考以下文章

linux信号处理总结

[linux] 详解linux进程信号

[linux] 详解linux进程信号

Nodejs 进程信号

进程管理类命令

进程管理类命令