【Hello Linux】进程信号

作者:@小萌新
专栏:@Linux
作者简介:大二学生 希望能和大家一起进步!
本篇博客简介:简单介绍linux中的进程信号

信号概念

信号是表示消息的物理量

我们这篇博客学的信号和前面学的信号量之间的关系就像是老婆和老婆饼之间的关系 也就是没有半点关系 大家注意不要搞混了

生活中的信号

计算机的产生是远远晚于人类的诞生的 所以说计算机中的一些概念或多或少的都会借鉴于我们的生活中的一些概念

我们可以使用生活中的一些概念去解释理解计算机中的信号

  • 当每天早上闹钟响起的时候我们就知道该起床了 其中闹钟就是信号 起床这个动作就是我们接收到信号之后执行的动作
  • 当红绿灯的绿灯亮起的时候我们就知道可以穿过马路了 其中绿灯亮起就是信号 穿过马路就是我们接收到信号之后执行的动作

但是实际上呢 我们并不是当闹钟响起的时候才知道要起床的 比如说今天的闹钟坏掉了 可是我依然知道要在早上7点起床去上学

所以说我们这里就得出一个十分重要的结论 信号只是起到一个提醒的作用

同样的 当我们收到闹钟响起这个信号 是不是就一定要去上学呢? 有没有可能我今天身体不舒服 不想上早八了

所以说这里我们又能得出一个结论 我们接受到信号之后不一定会立即执行

但是作为一个积极向上的好学生我们肯定是不能缺课的 所以说要去上课这个信号就被保存在了我的脑子里 我们需要在身体好一点之后补上课程

这就是一个信号在外面生活中产生作用的例子

计算机中的信号

首先我们要明确一点 : 信号是由操作系统发送给进程的!

那么此时知道这个概念之后我们就可以将生活中的信号和计算机中的信号关联起来 将进程类比成我们 将生活中的各种信号类比成计算机中的各种信号 我们下面一一映射下它们之间的关联

  • 当操作系统向进程发送某种信号的时候 一般来说进程会去执行相应的动作
  • 进程具有识别并处理信号的能力是远远早于信号的产生的 这是因为计算机科学家们早就设置好了
  • 进程在收到信号之后可能不会立即处理而是会保存到PCB中 在合适的时候再进程处理

信号

信号的产生到结束大致可以抽象为这样子的一个过程

在这里插入图片描述

信号产生前

键盘发送信号

我们下面从会产生前 产生中 产生后这三个过程详细的介绍信号

首先我们会写出下面的这样一段代码

  1 #include <stdio.h>
  2 #include <unistd.h>
  3                                                                              
  4 int main()
  5 {
    
    
  6   while(1)
  7   {
    
    
  8     printf("hello world!\n");
  9     sleep(1);
 10   }
 11   return 0;
 12 }

这段代码的意思是我们会不停的打印hello world消息 并且每次打印一条消息之后会睡眠一秒钟

在这里插入图片描述

直到我们按下ctrl + C之后进程才会终止 这是为什么呢?

我们可以打出 kill -l 指令来展示所有的进程信号

在这里插入图片描述
后面的所有信号我们全部忽略掉 只看前面的31个 我们按下ctrl + C的时候实际上就是向操作系统发送了2号信号

那我们我们怎么证明这一点呢?

我们这里可以使用 signal 函数来证明

  sighandler_t signal(int signum, sighandler_t handler);

它的作用是修改进程对信号的默认处理动作

返回值

  • 它的返回值是一个函数指针 它返回先前信号处理的信号指针 如果有错误则返回SIG_ERR(-1)

参数

  • int signum 它代表了我们要处理的信号
  • sighandler_t handler 它代表了我们要替换的信号处理函数

那么接下来我们就可以写出下面的代码

    1 #include <stdio.h>
    2 #include <unistd.h>
    3 #include <signal.h>
    4 
    5 void handler(int signo)
    6 {
    
    
W>  7   printf("get a signal: signal no is:%d",signo , getpid());
    8 }
    9 
   10 
   11 int main()
   12 {
    
    
   13   //replace 2 signal                                                                                            
   14   signal(2 , handler);
   15 
   16 
   17   while(1)
   18   {
    
    
   19     printf("hello world! my pid is : %d\n" , getpid());
   20     sleep(1);
   21   }
   22   return 0;
   23 }

这段代码的意思是每隔一秒钟打印一句话并且打印出该进程的pid

并且对于2号信号进行了注册

这里要注意的是 对于2号信号的注册并不会执行2号信号

怎么理解呢? 比如说上小学的时候老师教你七点钟闹钟响了你就要来上学 并不是让你现在就执行上学这个动作 而是给你注册了闹钟响这个信号发送之后你的执行动作

接下来我们看运行结果

在这里插入图片描述
我们发现 当我们现在按下ctrl + c的时候 进程并不会退出而是会打印出我们注册的语句

因为我们注册的是2号信号 所以这也就证明了ctrl + c其实就是向进程发送了2号信号

那么我们的键盘还可以发送哪些指令呢?

想知道的话我们可以直接将所有的信号注册然后每次发送信号就将它打印出来

    1 #include <stdio.h>
    2 #include <unistd.h>
    3 #include <signal.h>
    4 #include <stdlib.h>
    5 void handler(int signo)
    6 {
    
    
W>  7   printf("get a signal: signal no is:%d\n",signo , getpid());
    8   exit(0);
    9 }
   10 
   11 
   12 int main()
   13 {
    
    
   14   //replace 2 signal
   15   int i = 0;
   16   for(i = 0; i < 32; i++)
   17   {
    
    
   18      signal(i , handler);
   19   }                                                                                                                                
   20 
   21 
   22   while(1)
   23   {
    
    
   24     printf("hello world! my pid is : %d\n" , getpid());
   25     sleep(1);
   26   }
   27   return 0;
   28 }

我们可以发现

  • ctrl+C 是二号信号
  • ctrl+\ 是三号信号
  • ctrl+Z 是二十号信号

在这里插入图片描述

所以说这样我们也就证明了信号的一种产生方式是通过键盘产生

kill指令发送信号

如果我们的进程放到后台运行的话 我们使用键盘发送信号就没有效果了

我们可以做出下面的测试

在这里插入图片描述

这是因为我们键盘发送的信号只会对于前台程序生效 而对于后台程序则是不生效的

如果我们想要终止这个程序我们只能使用kill指令发送信号

在这里插入图片描述

我们可以发现 当我们将进程放到后台运行时 使用2号信号不能杀死进程

这个时候只能使用ctrl+9信号来终止进程

处理信号的三种方式

我们下面会用一个生活中的例子来解释这个行为

  1. 默认动作

当进程收到信号时执行的默认动作 比如说终止自己暂停等

生活例子: 比如说你每天早上会问你的爸爸要十块钱 而你的爸爸一直是点头然后给你钱 这就是默认动作

  1. 忽略动作

这里有同学可能会产生疑问 忽略请求也是一种动作吗 答案当然是是的 只不过它的动作就是什么也不干

生活例子:有一天早上你问你爸爸要十块钱的时候 它看了看你 没有说话也没有给钱 这就是忽略动作

自定义动作

我们刚刚用signal方法 就是在修改信号的处理动作由默认动作到自定义动作

生活例子:你妈跟你爸说 今天孩子来要钱的时候多给五块钱 这就是自定义动作

不能被捕捉的信号(现象)

我们现在对于所有的信号全部自定义也就是全部捕捉

    1 #include <stdio.h>
    2 #include <unistd.h>
    3 #include <signal.h>
    4 #include <stdlib.h>
    5 void handler(int signo)
    6 {
    
    
W>  7   printf("get a signal: signal no is:%d\n",signo , getpid());
    8  
    9 }
   10 
   11 
   12 int main()
   13 {
    
    
   14   //replace 2 signal
   15   int i = 0;
   16   for(i = 0; i < 32; i++)
   17   {
    
    
   18      signal(i , handler);
   19   }                                                                                                                                
   20 
   21 
   22   while(1)
   23   {
    
    
   24     printf("hello world! my pid is : %d\n" , getpid());
   25     sleep(5);
   26   }
   27   return 0;
   28 }

在这里插入图片描述

我们运行上面这段代码之后可以发现 2号信号 3号信号都被成功的捕捉了 但是9号信号并没有被成功的捕捉 这个原因我们会在后面的内容中详细讲解

操作系统发送信号(异常问题)

我们尝试写出下面的代码

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

这里我们写出了一个很明显的bug 空指针的非法访问

运行之后结果如下

在这里插入图片描述

这种现象在我们的vs下叫做程序崩溃 现在我们学了进程的相关知识之后应该就能理解了 这种现象叫做进程的崩溃

为什么进程会崩溃呢?

我们在原先的代码上加上这几行代码

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <signal.h>
  4 
  5 void handler(int signo)
  6 {
    
    
  7   printf("signo is : %d\n",signo);                                                                         
  8 }
  9 
 10 int main()
 11 {
    
    
 12   int i = 0;
 13   for( i = 0; i < 32; i++)
 14   {
    
    
 15     signal(i , handler);
 16   }
 17 
 18 
 19   int* p = NULL;
 20   *p = 100;
 21   return 0;
 22 }

解释下上面的代码

我们捕获了所有的信号 自定义了它们的处理方式

并且在后面我们故意写出了一个空指针的访问

运行这段代码

在这里插入图片描述

我们发现和原来的情况不一样了进程并没有崩溃而是向屏幕中不停的输出11号信号

接下来我们试验另一种情况

  1 #include <stdio.h>                          
  2 #include <unistd.h>                         
  3 #include <signal.h>                         
  4                                             
  5 void handler(int signo)                     
  6 {
    
                                               
  7   printf("signo is : %d\n",signo);          
  8 }                                           
  9                                             
 10 int main()                                  
 11 {
    
                                               
 12   int i = 0;                                
 13   for( i = 0; i < 32; i++)                  
 14   {
    
                                             
 15     signal(i , handler);                    
 16   }                                         
 17                                             
 18                                             
 19   int a = 100; a /= 0;                                                                                           
 20   //int* p = NULL;
 21   //usr*p = 100; 
 22   return 0;
 23 }

我们编译运行之后会发生这样子的情况

在这里插入图片描述

进程会不停的向屏幕中打印8号信号

我们可以打出 kill -l 指令来查看所有的信号

在这里插入图片描述

之后我们将信号捕捉部分代码删除 只留下除0操作

编译运行后查看结果

在这里插入图片描述

我们发现 进程会直接终止并且会报出FPE错误

所以说 我们这里就可以大胆的做出一个推论

在Linux中 进程崩溃的本质是进程收到了对应的信号 然后进程执行信号的默认动作(杀死进程)

那么我们的进程为什么会收到信号呢?

现在我们假设你是一个学校机房的管理员 在某一天这个机房中突然有一台电脑的显示器被偷走了 那么这个时候你是否要检查监控看看谁偷走了这个显示器 什么时候偷走的 怎么追回损失

你为什么要这么做? 因为你是这个机房的管理员 你要对于这个机房负责

同样的 操作系统是软硬件的管理者 操作系统需要对于它们负责

在这里插入图片描述
我们都知道 虚拟地址会经过页表和mmu的映射到物理地址上面

而像我们上面使用空指针解引用的时候实际上就是在访问一个非法的地址

此时mmu硬件在cpu中的运算就会出错

出错之后是不是管理员就应该上场了

此时操作系统就会寻找 是谁(哪个进程)引发了这个错误

当操作系统找到这个进程之后就会向这个进程发送信号 杀死这个引发错误的进程

进程崩溃的原因

我们在前面的进程控制中学习了 进程终止的方式按照是否正常退出可以分为两种

一种是运行完毕所有代码 程序自己退出 还有一种就是程序异常终止

而我们在进程控制这篇博客中 我们讲解了waitpid这个函数 其中这个函数有一个输出型参数叫做status 这个函数的低七位就是我们的终止信号

在这里插入图片描述

如何解决

我们了解了进程崩溃原因之后肯定要想办法解决它 最好是能告诉我们进程是在哪一行崩溃的

而在linux系统中 如果上图中的 core dump标志位开启 如果进程崩溃 我们的系统会将一些错误信息储存到磁盘文件当中让我们调试

而在默认情况下我们的core dump标志位是关闭的 core file文件的大小为0

在这里插入图片描述

如果我们想要开启它 保存调试信息只需要打出下面的指令

在这里插入图片描述

接下来我们运行崩溃的代码会发现这样子的现象

在这里插入图片描述
这就代表着我们崩溃进程的错误信息被保存了

我们按下 ll指令 我发现这里多出了一个叫做 core.589 的文件
在这里插入图片描述

那么这个文件有什么用呢?

我们可以使用gdb调试这个可执行文件

之后打出 core-file + core dump文件

在下面我们就可以看到文件的各种错误信息包括收到的终止信号 错误行数等等

我们把这种debug方式叫做事后调试
在这里插入图片描述

所有的进程崩溃时core dump标志位都会产生效果嘛?

答案是否定的

我们可以使用下面的代码做个试验

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <signal.h>
  4 
  5 
  6 int main() 
  7 {
    
    
  8   while(1)                              
  9   {
    
                                                       
 10     printf("pid is %d\n", getpid());                           
 11     sleep(2);
 12   }                
 13   return 0;                                    
 14 } 

接着我们分别对该进程发送2号和3号信号 我们可以发现2号信号终止时core dump标志位并没有产生效果

但是当发送3号信号时缺产生效果了
在这里插入图片描述

这个试验就能证明 当我们的进程被信号杀死的时候不一定会产生core-file文件

但是只要我们的进程是被信号杀死的 那么这个进程就一定会设置低七位的终止信号

在这里插入图片描述

系统调用发送的信号

除了上面所说的发送信号的方式以外我们还可以通过系统调用函数来发送信号

kill函数

kill函数的本质是调用kill指令来向进程发送信号来终止进程 它的函数原型如下

  int kill(pid_t pid, int sig);

实际上不用过多的介绍 只要看过之前的博客就大概能猜到这个系统调用函数怎么用

第一个参数是进程的pid 第二个参数是我们要发送的信号

如果调用函数成功则返回0 如果失败则返回-1

下面是使用示例

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <stdlib.h>
  4 #include <signal.h>
  5 #include <sys/types.h>
  6 
  7 int main()
  8 {
    
    
  9   int count = 3;
 10   while(1)
 11   {
    
    
 12     if (count == 0)
 13     {
    
    
 14       kill(getpid() , 9);                                                                
 15     }
 16     printf("my pid is: %d\n",getpid());
 17     sleep(1);
 18     count--;
 19   }
 20   return 0;
 21 }

上面的代码会在打印三次进程的pid之后被kill命令发送的9号信号杀死

运行结果如下

a

raise函数

raise函数可以给当前进程发送指定信号 即自己给自己发送信号 raise函数的函数原型如下:

  int raise(int sig);

它的作用是对于当前进程发送信号

下面是我们的示例代码

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <stdlib.h>                                    
  4 #include <signal.h>                                    
  5 #include <sys/types.h>                                 
  6                                                        
  7 int main()                                             
  8 {
    
                                                          
  9   printf("my pid is %d\n",getpid());                     
 10   sleep(3);                                            
 11   raise(9);                                                                              
 12   return 0;                
 13 }

这段代码的意思是在在休眠三秒之后对自己发送9号信号

演示效果如下 符合我们的预期
在这里插入图片描述

abort函数

abort函数可以给当前进程发送SIGABRT信号 使得当前进程异常终止 abort函数的函数原型如下:

  void abort(void);

我们直接使用代码演示

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <stdlib.h>
  4 #include <signal.h>
  5 #include <sys/types.h>
  6  
  7 int main()
  8 {
    
    
  9   printf("my pid is %d\n",getpid());
 10   sleep(3);
 11   abort();                                                                               
 12   return 0;                                               
 13 } 

这段代码的意思是 休眠三秒之后向自己发送 SIGABRT信号

此外SIGABRT信号是无法被捕捉的 只要调用了abort函数 进程一定会异常终止

演示结果如下

在这里插入图片描述

软件条件产生的信号

我们之间在管道部分讲到这么一个现象

当两个进程进行管道通信的时候 如果我们关闭读端那么写端会被自动关闭

进程间通信

这个时候实际上就是操作系统向写端发送了13号信号

(解释下这个现象 因为当没有人读数据的时候往管道里面写数据实际上就是一个浪费资源的行为 而作为资源的管理者操作系统不会允许这种行为的存在)

实际上kill中的14号信号也是由软件产生的

这里我们首先要介绍一个函数 alarm函数

我们调用该函数可以产生一个闹钟 即在若干时间后告诉系统发送给进程14号信号 它的函数原型如下

  unsigned int alarm(unsigned int seconds);

参数介绍

它的参数是一个无符号整数 表示闹钟设定多少秒 这个很好理解

返回值介绍

它的返回值也是一个无符号整数有两种情况

  • 如果进程在之前没有设置闹钟 则返回值为0
  • 如果进程在之间设置了闹钟 则返回值为上一个闹钟的所剩时间 并且本次闹钟会覆盖上次闹钟的设置

我们使用一个生活中的例子来解释这个概念

比如说我们中午想要午睡 设置了一个30分钟的闹钟 (此时返回值为0) 而过了20分钟我们就睡醒了 看了看闹钟还有10分钟的睡眠时间 (此时返回值为10)但是我们还想再睡15分钟 于是就再设置了一个15分钟的闹钟覆盖了上次的闹钟 (上次闹钟关掉了)

我们下面使用两段代码来带大家更加详细的认识下闹钟和系统IO

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <stdlib.h>
  4 #include <signal.h>
  5 #include <sys/types.h>
  6 
  7 int main()
  8 {
    
    
  9   int count = 0;
 10   alarm(1);
 11   while(1)
 12   {
    
    
 13     printf("count is :%d\n", count++);
 14   }                                                                                  
 15   return 0;
 16 }

上面这段代码的意思是统计一秒内能打印多少count语句

我们直接运行看结果

在这里插入图片描述

大概是四万多次

现在我们改变下思路 捕捉下14号信号 让他结束的时候打印下count值是多少 而我们不在while循环中打印了

    1 #include <stdio.h>
    2 #include <unistd.h>
    3 #include <stdlib.h>
    4 #include <signal.h>
    5 #include <sys/types.h>
    6 
    7 int count = 0;
W>  8 void handler(int signo)
    9 {
    
    
   10   printf("count is : %d\n" ,count);
   11   exit(0);
   12 }
   13                                                                            
   14 int main()
   15 {
    
                                                                              
   16 
   17   signal(14 , handler);
   18   alarm(1);
   19   while(1)
   20   {
    
    
   21     count++;
   22   }
   23   return 0;
   24 }

之后我们再次编译运行代码
在这里插入图片描述
我们发现这次的数字变成了五亿多 这是为什么呢?

基础IO

我们在基础IO中讲解过了 cpu的操作是非常快的 而外设是非常慢的 所以说打印的count值要远远小于不打印的count值 更别说我们使用的是云服务器还要再隔一个网络了

总结

不管是什么方式产生的信号 最后都是由操作系统发送的 因为操作系统是整个系统的管理者

操作系统怎么发信号

在进程的结构体中我们需要一个空间来保存收到的信号 为什么这么说呢

还记不记得我们上面举的闹钟响了但是你还想继续睡觉的例子 也就是说在信号发送的时候我们有可能有更加重要的事情(在计算机眼里就是更高优先级)要去做 所以说信号必须要被暂存下来

那么信号应该怎么保存呢? 我们使用kill -l指令查看所有的信号
在这里插入图片描述
我们可以看到 其实这里信号的编号是十分有规律的 它的编号是1~31

看到这么规律的数字我们一定能第一时间想到数组下标

当然使用数组的下标来保存信号是完全可行的

可是这里我们只需要知道信号是否存在就可以了不需要知道其他的事情 所以说这里使用比特位来标志一个信号是否存在是一种更好的做法

实际上在linux系统中也确实是用一个32位的无符号整数来标志每个信号是否存在的

概念图如下

在这里插入图片描述

我们将最低位定义为为第一位 依次往高位递增

如果该位的比特位为1则表示收到信号 如果该位的比特位为0则表示未收到信号

现在我们再来理解操作系统是如何发送信号的 本质就是操作系统将PCB中信号位图对于位置置1

所以说我们现在对于操作系统发送信号的理解应该是操作系统写入信号

信号产生中

基本概念

在了解信号产生中之前我们要了解几个基本概念

  • 实际执行信号的处理动作 称为信号递达(Delivery)
  • 信号从产生到递达之间的状态 称为信号未决(pending)
  • 进程可以选择阻塞(Block)某个信号

我们下面使用一个例子来让大家更深入的理解这三个概念

假设你现在在一个小学里面担任纪律委员 你的任务就是把每天上课时不遵守纪律的人名字全部记下来 然后在晚上送给班主任 让班主任处理

其中班主任处理你小本本上名字的过程就叫做递达

老师处理(递达)可能有三种方式默认动作 忽略动作和自定义动作

而在你记下名字到晚上交给班主任的这段时间叫做信号未决

而信号未决的时候 你们班上有个同学 法外狂徒张三 它下课的时候跟你说 你不准把他的名字交给老师 不然他就揍你 这个时候你害怕了 你就没有把他的名字交给老师 那么这个时候我们就可以说这个信号被阻塞了

信号阻塞i和信号递达的忽略有什么不同呢?

信号阻塞就是这个信号没有被递达 相当于你小本本上的名字没有给老师

信号递达忽略则是这个信号递达了但是处理的操作就是什么都不做 相当于你小本本上的名字交给老师了 但是老师很忙 看了一眼就做自己的事情去了

linux中的信号结构

实际上在linux的进程PCB中 有下面的三张图

在这里插入图片描述
它们分别是block位图 pending位图 handler函数表

下面我将会一一介绍它们

block位图

block位图标志着该信号是否被阻塞

1表示该位图被阻塞 0则表示未被阻塞

pending位图

pending位图标志这是否接受到该信号

1表示收到该信号 0则表示未收到信号

handler函数表

handler函数表里面是信号接受时处理的各种函数地址

我们通过这些地址去调用函数

现在我们来回答一个之前的问题 为什么就算我们没有看见红绿灯过 我们也知道红灯停绿灯行呢 本质上是因为handler函数表中注册了红绿灯这个函数

sigset_t

实际上在我们的操作系统中 除了像是int double等原生类型 还有一些操作系统给我们提供的类型

像是共享内存中的key_t类型

为了解决未决和阻塞的位问题 操作系统向我们提供了一个类似位图的数据类型 sigset_t

他在linux中的实现方式如下

#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
    
    
	unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;

typedef __sigset_t sigset_t;

我们将sigset_t称为信号集 该类型可以表示有效和无效两种状态 其中:

  • 在阻塞集中的有效和无效标志该信号是否被阻塞
  • 在未决信号集中的有效和无效标志该信号是否被打印

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

信号集操作函数

由于我们位图使用的是操作系统提供的结构体来保存位图结构 所以说我们并不能将它当作单纯的整型类型来看待直接进行各种位操作

操作系统为我们操作信号集提供了以下的函数

#include <signal.h>

int sigemptyset(sigset_t *set);

int sigfillset(sigset_t *set);

int sigaddset(sigset_t *set, int signum);

int sigdelset(sigset_t *set, int signum);

int sigismember(const sigset_t *set, int signum);  

函数解释

  • sigemptyset函数:初始化set所指向的信号集 使其中所有信号的对应bit清零 表示该信号集不包含任何有效信号
  • sigfillset函数:初始化set所指向的信号集 使其中所有信号的对应bit置位 表示该信号集的有效信号包括系统支持的所有信号
  • sigaddset函数:在set所指向的信号集中添加某种有效信号
  • sigdelset函数:在set所指向的信号集中删除某种有效信号
  • sigemptyset、sigfillset、sigaddset和sigdelset函数都是成功返回0 出错返回-1
  • sigismember函数:判断在set所指向的信号集中是否包含某种信号 若包含则返回1 不包含则返回0 调用失败返回-1

sigprocmask

sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集) 该函数的函数原型如下:

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

返回值说明:

如果调用函数成功则返回0 如果失败则返回01

参数说明:

  • how参数一般我们使用宏来表示 它标志着设置的模式
  • 如果oset为非空指针 则这个函数的作用是接受当前进程的信号屏蔽字
  • 如果set为非空指针 则这个函数的作用是修改当前进程的信号屏蔽字
  • 如果两个指针都为非空则 则这个函数的作用是修改当前进程的信号屏蔽字并接受之前的信号屏蔽字

int how参数的宏和它们代表的意义表如下

选项 含义
SIG_BLOCK set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask
SIG_UNBLOCK set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask
SIG_SETMASK 设置当前信号屏蔽字为set所指向的值,相当于mask=set

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

也就是说如果我们之前阻塞了2号信号 现在解除阻塞 进程就会立刻收到2号信号 并且执行注册函数

为什么我们一接触阻塞进程就会收到信号这个我们后面会深入讲解

不能被阻塞的信号(现象)

我们使用刚刚学到的sigprocmask可以阻塞信号 下面我们使用一段代码来试试阻塞2号和9号信号

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <stdlib.h>
  4 #include <signal.h>
  5 #include <sys/types.h>
  6 
  7 int main()
  8 {
    
    
  9   sigset_t iset;
 10 
 11   sigemptyset(&iset);
 12   sigaddset(&iset , 2);
 13   sigaddset(&iset , 9);
 14   sigprocmask(SIG_SETMASK , &iset , NULL);
 15 
 16   while(1)
 17   {
    
    
 18     printf("hello world , my pid is:%d\n",getpid());                                                
 19     sleep(1);
 20   }
 21   return 0;
 22 }

我们不断地向该进程释放2号信号

在这里插入图片描述

我们发现该进程并没有反应

之后我们重新开启一个进程并且发送一个9号信号给它

在这里插入图片描述

我们发现此时的进程就被直接杀死了

可是我们上面明明阻塞了9号信号

根据上面的现象我们可以推断 9号信号是不可以被阻塞的

sigpending

sigpending函数可以获取进程的未决信号集合 它的函数原型如下

  int sigpending(sigset_t *set);

返回值:

如果函数调用成功返回0 失败则返回-1

参数:

该函数的参数是一个输出型参数 我们只能使用该函数来获取进程的pending位图 而并不能使用pending位图来向进程直接发送信号 (发送信号的方式在前面已经介绍了 这里就不再赘述)

接下来我们做一个简单的试验:我们看看当我们发送信号的时候pending位图中式什么样子的

试验代码如下

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

void printPending(sigset_t *pending)
{
    
    
	int i = 1;
	for (i = 1; i <= 31; i++){
    
    
		if (sigismember(pending, i)){
    
    
			printf("1 ");
		}
		else{
    
    
			printf("0 ");
		}
	}
	printf("\n");
}
int main()
{
    
    
	sigset_t set, oset;
	sigemptyset(&set);
	sigemptyset(&oset);

	sigaddset(&set, 2); //SIGINT
	sigprocmask(SIG_SETMASK, &set, &oset); //阻塞2号信号

	sigset_t pending;
	sigemptyset(&pending);

	while (1){
    
    
		sigpending(&pending); //获取pending
		printPending(&pending); //打印pending位图(1表示未决)
		sleep(1);
	}
	return 0;
}

解释下上面的代码

我们阻塞2号信号 之后每隔1秒 打印当前进程的pending位图

运行之后结果如下

在这里插入图片描述
我们发现收到二号信号之后pending位图的2号位置变成1了

如果我们想要观察到2号信号位置从1变0的过程单纯的解除阻塞是不行的

因为2号信号的默认处理方式是让进程退出 所以说如果我们要观察到2号信号从1变0的过程 我们需要对2号信号进行捕捉 代码表示如下

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

void printPending(sigset_t *pending)
{
    
    
	int i = 1;
	for (i = 1; i <= 31; i++){
    
    
		if (sigismember(pending, i)){
    
    
			printf("1 ");
		}
		else{
    
    
			printf("0 ");
		}
	}
	printf("\n");
}
void handler(int signo)
{
    
    
	printf("handler 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;
	sigemptyset(&pending);

	int count = 0;
	while (1){
    
    
		sigpending(&pending); //获取pending
		printPending(&pending); //打印pending位图(1表示未决)
		sleep(1);
		count++;
		if (count == 20){
    
    
			sigprocmask(SIG_SETMASK, &oset, NULL); //恢复曾经的信号屏蔽字
			printf("恢复信号屏蔽字\n");
		}
	}
	return 0;
}

解释下上面的代码

该代码会在一开始阻塞进程的2号信号并且在20秒之后会解除对于2号信号的阻塞

每隔一秒会打印pending位图

演示结果如下
在这里插入图片描述

信号产生后

我们回顾之间讲解的例子 你是一个纪律委员 现在你已经将今天班级中违反纪律的人的名字全部记下来了

现在你要选择一个合适的时候将你的小本本交给班主任

对于你来说合适的时候就是你和班主任都有空的时候 比如说放学

那么对于计算机来说这个合适的时候是什么呢?

我们首先要知道 因为信号是保存在进程的PCB当中的pending位图里的 如果要处理需要检测pending位图里面是否有信号 是否被阻塞 信号的处理方式是什么

我们这里直接下一个结论

信号从内核态返回用户态的时候进行上面的检测并处理

同学们看到这句话肯定一脸懵 什么是内核态 什么又是用户态 我们下面将从感性和理性两个角度去认识一下它们

内核态和用户态

我们首先给内核态和用户态下个定义

  • 内核态:执行操作系统的代码和数据的时候 计算机所处的状态就叫做内核态
  • 用户态:执行用户的代码和数据的时候 计算机所处的状态就叫做用户态

它们之间的主要区别在于权限 内核态的权限比用户态的权限是大的多得多的

那么它们什么时候发生转换呢?

在这里插入图片描述

我们调用的一些系统接口函数是由操作系统实现的

当我们调用这些函数的时候它们会切换到内核态去看具体的系统函数实现

以上就是我们对于用户态和内核态的感性认知

下面是一个较为理性的认知

我们都知道当我们运行用户写的程序时候 操作系统会创建进程 进程会有一个叫做PCB的数据结构

在经由页表和mmu的映射之后加载到内存中

那么操作系统的代码和数据是否也需要加载到内存中呢?

答案当然是肯定的

实际上在我们的笔记本开机等待的这段时间就是操作系统的代码和数据加载到内存的时间

那么现在我们就知道了操作系统和用户的数据和代码都会加载到内存中了

在这里插入图片描述
如上图 实际上操作系统的代码和数据和用户的代码和数据使用的并不是同一个页表

操作系统使用的是内核级页表

用户使用的是用户级页表

用户空间我们可以理解为一个临时变量 它是属于当前进程的

而内核空间则是一个全局变量 所有进程看到的都是一样的

从这里我们就能得到一个结论

不管我们如何切换进程 我们找到的操作系统代码和数据都是同一个

站在现在的角度我们如何理解进程切换

实际上就是当前进程进入内核态并且找到操作系统的数据和代码 再之后利用操作系统的权限替换用户空间中的数据和代码 这样子就完成进程切换了

那么在系统中用户态和内核态的切换是大概是一个什么样子的呢?

大概变换如下图
在这里插入图片描述

需要注意的是 如果信号的处理动作是终止 那么当前进程就不需要再回到用户态直接终止了

如果觉得不好记我们可以将这张图再抽象一下

在这里插入图片描述

其中该图形和中间的线有几个交点就说明有几次切换

该图形中间的交点表示检查pending表的时刻

需要注意的是 我们检查pending表时是再内核态检查 也就是说该图形的交点必须要再内核态

可以不进行用户态内核态切换直接在内核态处理数据嘛?

理论上是可以的 内核态的权限非常高可以执行用户态的各种函数

但是实际上是不能这么设计的 还是因为内核态的权限非常高 如果用户写出一些很危险的代码比如说删库等操作 放在内核态执行的话就会造成很不好的影响

sigaction

捕捉信号除了用前面用过的signal函数之外 我们还可以使用sigaction函数对信号进行捕捉 sigaction函数的函数原型如下:

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

返回值说明:

调用成功返回0 失败返回-1

参数说明:

  • signum代表指定信号的编号
  • 若act指针非空,则根据act修改该信号的处理动作
  • 若oldact指针非空,则通过oldact传出该信号原来的处理动作

其中 参数act和oldact都是结构体指针变量 该结构体的定义如下:

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_handler:

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

结构体的第二个成员sa_sigaction:

  • sa_sigaction是实时信号的处理函数 我们不必理会置空即可

结构体的第三个成员sa_mask:

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

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

结构体的第四个成员sa_flags:

sa_flags字段包含一些选项 这里直接将sa_flags设置为0即可

结构体的第五个成员sa_restorer:

我们不使用该参数

下面是该代码的使用示例

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

struct sigaction act, oact;
void handler(int signo)
{
    
    
	printf("get a signal:%d\n", signo);
	sigaction(2, &oact, NULL);
}
int main()
{
    
    
	memset(&act, 0, sizeof(act));
	memset(&oact, 0, sizeof(oact));

	act.sa_handler = handler;
	act.sa_flags = 0;
	sigemptyset(&act.sa_mask);

	sigaction(2, &act, &oact);
	while (1){
    
    
		printf("I am a process...\n");
		sleep(1);
	}
	return 0;
}

在上面的代码中我们将2号信号进行捕捉 并且当第二次收到二号信号的时候将它复原

效果演示如下

在这里插入图片描述

可重入函数

下面主函数中调用insert函数向链表中插入结点node1 某信号处理函数中也调用了insert函数向链表中插入结点node2

在这里插入图片描述

这是该链表

在这里插入图片描述

下面是这个链表的变化过程

  1. 我们调用函数进行头插 并在此时发送信号 (该信号中也用到了头插函数) 在完成头插的第一步之后进程中断进入内核态

在这里插入图片描述

  1. 内核态检查信号 并且调用自定义处理方式头插链表(回到用户态)
    在这里插入图片描述

  2. 回到内核态检查没有信号 继续插入完成链表的头插 回到用户态
    在这里插入图片描述

  3. 继续被中断的的头插node1
    在这里插入图片描述

看到这里熟悉链表的同学可能会发现问题了 这里会造成内存泄漏

我们没有任何办法可以找到node2的地址

在上面的操作中可能在第一次调用还没返回时就再次进入该函数 我们将这种现象称之为重入

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

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

volatile

volatile是C语言的一个关键字 该关键字的作用是保持内存的可见性

在下面的代码中 我们对2号信号进行了捕捉 当该进程收到2号信号时会将全局变量flag由0置1 也就是说 在进程收到2号信号之前 该进程会一直处于死循环状态 直到收到2号信号时将flag置1才能够正常退出

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

int flag = 0;

void handler(int signo)
{
    
    
	printf("get a signal:%d\n", signo);
	flag = 1;
}
int main()
{
    
    
	signal(2, handler);
	while (!flag);
	printf("Proc Normal Quit!\n");
	return 0;
}

代码表示如上

我们实际运行下

在这里插入图片描述

这里也符合我们的预期

那么上面的这段代码是正确的嘛? 答案是否定的

因为flag在main函数当中并没有做任何的修改 如果在优化级别比较高的情况下 编译器可能会将flag这个变量放到寄存器中去

而handler执行流只是将内存中flag的值置为1了 那么此时就算进程收到2号信号也不会跳出死循环

我们可以试验下

在这里插入图片描述
我们发现确实2号信号是无效的

此时我们只需要在flag的前面加上 volatile关键字 就可以避免这种情况了

volatile int flag = 0;

在这里插入图片描述

我们加上volatile关键字之后falg就对内存可见了 自然他变化之后我们的main执行流就能跳出死循环

SIGCHLD信号

为了避免出现僵尸进程 父进程需要使用wait或waitpid函数等待子进程结束 父进程可以阻塞等待子进程结束 也可以非阻塞地查询的是否有子进程结束等待清理 即轮询的方式

采用第一种方式,父进程阻塞就不能处理自己的工作了 采用第二种方式 父进程在处理自己的工作的同时还要记得时不时地轮询一下 程序实现复杂

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

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>

void handler(int signo)
{
    
    
	printf("get a signal: %d\n", signo);
	int ret = 0;
	while ((ret = waitpid(-1, NULL, WNOHANG)) > 0){
    
    
		printf("wait child %d success\n", ret);
	}
}
int main()
{
    
    
	signal(SIGCHLD, handler);
	if (fork() == 0){
    
    
		//child
		printf("child is running, begin dead: %d\n", getpid());
		sleep(3);
		exit(1);
	}
	//father
	while (1);
	return 0;
}

上面代码中对SIGCHLD信号进行了捕捉 并将在该信号的处理函数中调用了waitpid函数对子进程进行了清理

注意:

  1. SIGCHLD属于普通信号 记录该信号的pending位只有一个 如果在同一时刻有多个子进程同时退出 那么在handler函数当中实际上只清理了一个子进程 因此在使用waitpid函数清理子进程时需要使用while不断进行清理
  2. 使用waitpid函数时 需要设置WNOHANG选项 即非阻塞式等待 否则当所有子进程都已经清理完毕时 由于while循环 会再次调用waitpid函数 此时就会在这里阻塞住

信号总结

两种不能被捕捉的信号

SIGKILL(9)和SIGSTOP (19)

两种不能被阻塞的信号

SIGKILL(9)和SIGSTOP (19)

猜你喜欢

转载自blog.csdn.net/meihaoshy/article/details/129729523