进程信号(Linux)

本文详细介绍了Linux进程信号的原理,从生活角度类比解释了信号的概念,接着探讨了信号的发送方式,包括通过终端按键、系统函数和软件条件。还讲解了信号的处理方式,包括默认处理、自定义处理和忽略。此外,文章还讨论了阻塞信号、核心转储、信号集操作函数和信号捕捉机制,以及volatile关键字在信号处理中的作用。最后,简要介绍了SIGCHLD信号及其处理。
摘要由CSDN通过智能技术生成

信号入门

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++程序崩溃的原因是收到了除0、野指针、越界等信号,其错误最终一定会在硬件层面上有所表现,进而被OS识别到。

阻塞信号

1、信号其他相关常见概念

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

2、在内核中的表示

信号在内核中的表示示意图
在这里插入图片描述

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞
  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理? POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。我们讨论的是普通信号。

这三张表的解释:

  1. 在block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞
  2. 在pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号
  3. handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。
  4. block、pending和handler这三张表的每一个位置是一一对应的
  5. 00表示未发送,11表示发送了被阻塞,10表示一发送就被阻塞。

3、sigset_t

每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。sigset_t类型如下:

typedef struct {
    unsigned long sig[_NSIG_WORDS]} sigset_t

这个类型可以表示每个信号的“有效”或“无效”状态

  • 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞
  • 而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态

特别说明:阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略

4、信号集操作函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印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清零,表示该信号集不包含 任何有效信号。成功返回0,失败返回1。
  • 函数sigfillset初始化set:所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。成功返回0,失败返回1。
  • 函数sigaddset:在set所指向的信号集中添加某种有效信号。成功返回0,失败返回1。
  • 函数sigdelset:在set所指向的信号集中删除某种有效信号。成功返回0,失败返回1。
  • sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1

注意:在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

sigprocmask

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

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1

1、如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
2、如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
3、如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。

假设当前的信号屏蔽字为mask,下表说明了how参数的可选值:
在这里插入图片描述
4、如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达

sigpending

#include <signal.h>
sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1

下面用刚学的几个函数做个实验。步骤如下:
1.先把2号信号屏蔽
2. kill ||键盘发送2号信号,可以预见,2号信号不会被递达
3. 2号将会一直被阻塞,一定一直在pending中
4. 使用sigpending获取当前进程的pending 信号集

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

void printPending(sigset_t *pending)
{
    int i = 1;
    for(; i <= 31; i++){
        if(sigismember(pending, i)){
            printf("1 ");
        }
        else{
            printf("0 ");
        }
    }
    printf("\n");
}
int main()
{
    sigset_t set, oset; //创建信号集  相当于int a
    sigemptyset(&set);//初始化   相当于a = 0
    sigemptyset(&oset);
    sigaddset(&set, 2); //SIGINT,添加2号信号到set中,对应Ctrl+C
    sigprocmask(SIG_SETMASK, &set, &oset);//2号信号被阻塞 
    sigset_t pending;
    while(1){
        sigemptyset(&pending);
        sigpending(&pending);//读取当前进程的未决信号集,通过set参数传出
        printPending(&pending);//判断一个信号集中是否含有该信号
        sleep(1);
    }
    return 0;
}

在程序刚刚运行时,因为没有收到任何信号,所以信号集中全部为0,当我们用kill命令向该进程发送2号信号时,此时2号信号被阻塞,一直处于未决状态,所以kill后一直显示为1。
在这里插入图片描述
为了方便观察,一个信号被阻塞后又恢复,我们这里可以设置一个自定义处理方法,对2号信号进行捕获,这样我们就能观察到变化。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void printPending(sigset_t *pending)
{
    int i = 1;
    for(; i <= 31; i++){
        if(sigismember(pending, i)){
            printf("1 ");
        }
        else{
            printf("0 ");
        }
    }
    printf("\n");
}
void handler(int signo)
{
    printf("hanndler signo: %d\n", signo);
}
int main()
{
    signal(2, handler);//捕抓,自定义处理 
    sigset_t set, oset; 
    sigemptyset(&set);
    sigemptyset(&oset);
    sigaddset(&set, 2); //SIGINT
    sigprocmask(SIG_SETMASK, &set, &oset);//2号信号被阻塞 
    sigset_t pending;
    int count = 0;
    while(1){
        sigemptyset(&pending);
        sigpending(&pending);
        printPending(&pending);
        sleep(1);
        count++;
        if(count == 10){
            sigprocmask(SIG_SETMASK, &oset, NULL); //恢复2号信号 
            printf("恢复信号屏蔽字\n");
        }
    }
    return 0;
}

在这里插入图片描述
程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会 使SIGINT信号处于未决状态,按Ctrl-\仍然可以终止程序,因为SIGQUIT信号没有阻塞。

捕捉信号

1、内核空间与用户空间

每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成:

  • 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
  • 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。

内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。

在这里插入图片描述
进程切换:

  • 在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。
  • 执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。

注意:所有进程无论如何切换,都能看到操作系统,但是不一定都能访问。

2、内核态与用户态

内核态:通常用来执行OS代码,是一种权限非常高的状态
用户态:是一种用来执行普通用户代码的状态,是一种受监管的普通状态

所以进程收到信号之后,不是立即处理信号的,而是在合适的时候,这个时候就是从内核态切换回用户态的时候,进行信号处理。

用户态切换到内核态:

  1. 系统调用
  2. 时间片到了导致进程切换
  3. 异常、中断、陷阱

内核态切回用户态:

  1. 系统调用执行完毕
  2. 进程切换完毕
  3. 异常终端陷阱处理完毕

3、内核如何实现信号的捕捉

默认处理或者忽略

  • 我们在执行程序的时候,有可能会进行系统调用而陷入内核,内核处理完毕后就要返回用户,此时需要检查pending的状态(即未决信号集)。如果此时发现pengding中有未决信号,并且没有阻塞,则需要去处理该信号,如果该信号是默认或者忽略,就执行默认的处理动作,执行完毕后,如果没有其他信号递达,则返回用户态。

自定义处理

  • 但是如果我们需要对该信号进行捕抓,就要执行我们对应的自处理动作。即用户自定义函数,在信号递达时就调用这个函数,就称为捕捉信号。比如此时用户程序定义了自处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。内核决定返回用户态后,不是恢复main函数的上下文执行,而是执行sighandler函数。sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态,内核再次检查是否有新的信号递达,如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行。

在这里插入图片描述
上面的图可以当初∞来记。

在这里插入图片描述
存在的问题:

1、识别到信号可以被捕捉(自定义),当前的身份是内核态,可以直接访问用户态的代码吗? ?

   理论上是可以的,但是绝对不能这样设计!

原因:用户执行的动作有可能是非法操作,内核去处理这个信号的话,出现问题的话OS就管不了,比如清空数据库,随便更改其代码和数据。
也就是说不能让操作系统直接去执行用户的代码,因为操作系统无法保证用户的代码是合法代码,即操作系统不信任任何用户。

4、sigaction

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结构体:
struct sigaction {
	void(*sa_handler)(int);
	void(*sa_sigaction)(int, siginfo_t *, void *);
	sigset_t   sa_mask;
	int        sa_flags;
	void(*sa_restorer)(void);
};

sa_handlers

  • 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号
  • 赋值为常数SIG_DFL表示执行系统默认动作
  • 赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数

该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用

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

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。sa_flags字段包含一些选项,本章的代码都把sa_flags设为0。

接下来,我们使用sigaction函数对2号信号进行捕捉,我们这里捕捉一次,然后修改参数为NULL,就执行其默认的处理动作。

  1 #include <stdio.h>
  2 #include <string.h>
  3 #include <unistd.h>
  4 #include <signal.h>                                                                 
  5 struct sigaction act, oact;
  6 
  7 void handler(int signo)
  8 {
  9     printf("get a signal: %d\n", signo);
 10     sigaction(SIGINT, &oact, NULL);//执行默认处理动作
 11 }
 12 
 13 int main()
 14 {
 15     memset(&act, 0, sizeof(act));
 16     memset(&oact, 0, sizeof(oact));
 17 
 18     act.sa_handler = handler;自处理函数
 19     act.sa_flags = 0;没有其他选项
 20     sigemptyset(&act.sa_mask);
 21
 22     sigaction(SIGINT, &act, &oact);
 23     while(1){
 24         printf("I am a process!\n");
 25         sleep(1);
 26     }
 27     return 0;
 28 }

在这里插入图片描述

可重入函数

在这里插入图片描述

1、main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数
2、sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步
3、结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了(node2这个结点就再也找不到了,造成内存泄漏)

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

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

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构

volatile

我们先来看一段代码:

    1 #include <stdio.h>
    2 #include <signal.h>
    3 int flag = 0;
W>  4 void handler(int sig)
    5 {
    6   printf("chage flag 0 to 1\n");
    7   flag = 1;
    8 }
    9 int main()
   10 {
   11   signal(2, handler);
   12   while(!flag);
   13   printf("process quit normal\n");
   14   return 0;                                                                       
   15 }

在这里插入图片描述
上面的运行结果在我们的预料之类,按住Ctrl+C发送2号信号,然后执行自定义处理动作,flag置1,结束进程。

通过man gcc命令,可查询到对应的优化参数
在这里插入图片描述

我们编译程序的时候,进行优化:
在这里插入图片描述
执行结果

[dy@VM-12-10-centos rumen]$
^Cchage flag 0 to 1
^Cchage flag 0 to 1
^Cchage flag 0 to 1

优化情况下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 ,但是 while 条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?

很明显, while 循环检查的flag,并不是内存中最新的flag,这就存在了数据二异性的问题。 while检测的flag其实已经因为优化,被放在了CPU寄存器当中。这时就需要用volatile解决。

    1 #include <stdio.h>
    2 #include <signal.h>
    3 volatile int flag = 0;                                                            
W>  4 void handler(int sig)
    5 {
    6   printf("chage flag 0 to 1\n");
    7   flag = 1;
    8 }
    9 int main()
   10 {
   11   signal(2, handler);
   12   while(!flag);
   13   printf("process quit normal\n");
   14   return 0;
   15 }

此时,就不会存在二异性的问题。
在这里插入图片描述
在这里插入图片描述
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。

SIGCHLD信号 - 选学了解

进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。

其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。

父进程自定义SIGCHLD信号的处理函数

    1 #include <stdio.h>
    2 #include <stdlib.h>
    3 #include <signal.h>
W>  4 void handler(int sig)
    5 {
    6   pid_t id;
E>  7   while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
E>  8     printf("father's pid: %d ,wait child success: %d\n", getppid(), id);          
    9   }
E> 10   printf("child is quit! %d\n", getpid());
   11   exit(1);
   12 }
   13 int main()
   14 {
   15   signal(SIGCHLD, handler);
   16   pid_t cid;
E> 17   if((cid = fork()) == 0){//child
E> 18     printf("child : %d\n", getpid());
E> 19     sleep(3);
   20     exit(1);
   21   }
   22   while(1){
   23     printf("father proc is doing some thing!\n");
E> 24     sleep(1);
   25   }
   26   return 0;
   27 }

拿到对应的17号(SIGCHLD)
在这里插入图片描述

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。请编写程序验证这样做不会产生僵尸进程。

    1 #include <stdio.h>
    2 #include <stdlib.h>
    3 #include <signal.h>
    4 void handler(int sig)
    5 {
    6   pid_t id;
E>  7   while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
E>  8     printf("father's pid: %d ,wait child success: %d\n", getppid(), id);
    9   }
E> 10   printf("child is quit! %d,sig is %d\n", getpid(), sig);
   11   exit(1);
   12 }
   13 int main()
   14 {
   15   //signal(SIGCHLD, handler);
   16   signal(SIGCHLD, SIG_IGN);//改为SIG_IGNji
   17   pid_t cid;
E> 18   if((cid = fork()) == 0){//child
E> 19     printf("child : %d\n", getpid());
   20     exit(1);                                                                                                              
   21   }
   22   while(1){
   23     printf("father proc is doing some thing!\n");
E> 24     sleep(1);
   25   }
   26   return 0;
   27 }

在这里插入图片描述

评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

雨轩(爵丶迹)

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

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

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

打赏作者

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

抵扣说明:

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

余额充值