![cover](https://img-blog.csdnimg.cn/d46fa0fcbf044d34ab37e3f6b63ddb5d.png)
《Linux从练气到飞升》No.24 Linux中的信号
本篇将讲述Linux中信号的相关知识,信号是什么?信号是怎么产生的?信号是怎么传递的?怎么捕捉信号?当你看完这篇文章,你将得到答案!!你在网上买了很多件商品,在等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但
🕺作者: 主页
我的专栏 C语言从0到1 探秘C++ 数据结构从0到1 探秘Linux 菜鸟刷题集 😘欢迎关注:👍点赞🙌收藏✍️留言
🏇码字不易,你的👍点赞🙌收藏❤️关注对我真的很重要,有问题可在评论区提出,感谢阅读!!!
文章目录
前言
本篇将讲述Linux中信号的相关知识,信号是什么?信号是怎么产生的?信号是怎么传递的?怎么捕捉信号?当你看完这篇文章,你将得到答案!!
1 信号量
我们先来感性认识一下信号量。
当我们看电影时,电影院里面有座位(放映厅里面的一个资源)对吧?
那这个座位什么时候真正属于我呢?
是不是我自己坐在这个位置上,这个座位才属于我?
并不是!我们需要先买票,只要我买了票,我就拥有了这个座位。
我们买票的本质就是对座位的预定。
那么对应到系统中当一个进程想去访问临界资源时,是想访问就能访问吗?
如果真的是想访问就访问,那现在有这么一种情况:
{
在讲之前需要一个前置知识:
CPU执行指令的时候需要经历三个步骤:
1.将内存中的数据加载到cpu内的寄存器中
2.执行指令
3.将CPU执行后的结果写回内存
【在此都进行了简化】
}
假设已知一个变量控制着某种资源的数量,假设它为5
- 假如进程A要让用这种资源,那么就需要让5,减一
就在A将5加载到内存中的时候,它被中断了(可能是因为时间片用完了又或者是其他的什么原因),此时它就需要将现场保存下来,等待恢复,再继续后面的步骤,此时它的值为4
- 就在此时,进程B也来用这种资源,那么就要和上面一样,让剩余数量减一,但是此时的资源数量还是5,因为进程A还没来得及写回内存中,所以它再减一,剩下4写回内存
- 然后,进程A重新恢复现场开始继续未完成的任务,向内存写回4
- 此时资源的实际值是5-1-1=3,但是因为进程A并不知道B做了什么,此时的资源数量就再正确,在之后的进程想要使用这种资源时会发现,明明记录着还有,却使用不了这种资源
以上就是一种访问的情况,它告诉我们,不是想要访问就能够去访问,也不是访问的时候被中断是无所谓。
像这样的一些进程需要一气呵成,不能被中断,在操作系统中,它叫原子性
这里还有临界资源和临界区的概念
临界资源就是说一个时间段内只允许一个进程使用的资源。各进程需要互斥地访问临界资源。
而临界区则可以理解为访问临界资源的那段代码。
所以临界区就分为了内核程序临界区和普通的临界区
内核程序临界区访问的临界资源如果不尽快释放的话,极有可能影响到操作系统内核的其他管理工作。因此在访问内核程序临界区期间不能进行调度与切换
普通临界区访问的临界资源不会直接影响操作系统内核的管理工作。因此在访问普通临界区时可以进行调度与切换。
讲了这么久还没说到信号量是什么,其实显而易见,它就是那个变量啊哈哈
它就是用来管理资源的一个变量
在进程想要访问临界资源之前需要申请信号量
如果信号量申请成功说明临界资源里就为进程预留了想要的资源
申请信号量的本质就是对临界资源的预订
而且它必须是原子的,不能被中断
2 什么是信号
2.1 生活中的信号
- 你在网上买了很多件商品,在等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”当你时间合适,顺利拿到快递之后,就要开始处理快递了。
- 而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
从以上来看信号的本质是一种通知机制,用户或者操作系统通过发送一定的信号通知进程,某些事件的发生,可以后续进行处理。
而且通过上面的例子我们也了解到一些结论:
- 进程要处理信号,必须具备信号识别的能力(看到+处理)
- 信号产生是随机的,进程可能在忙自己的事情,所以信号的后续处理可能不是立即处理的
- 进程那边会临时记录下对应的信号,方便后续处理
- 什么时候处理?合适的时候
- 一般而言,信号的产生对于进程而言是异步的,进程并不确定信号什么时候产生
2.2 信号的概念
信号是进程之间事件异步通知的一种方式,属于软中断。
信号的产生到结束大致可以抽象为这样子的一个过程
3 信号产生前
3.1 前置知识
3.1.1 信号处理常见的方式
在讲述ctrl c怎么中断之前先插叙一段知识
之前讲过信号处理常见的方式有三种:
- 默认处理(进程自带)
- 忽略(收到信号但是不去管它)
- 自定义动作(捕捉信号)
更加详细的说法是:
- 忽略此信号。
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉
(Catch)一个信号。
在操作系统中默认的信号可以通过kill -l
查看
- 在这之中,131号信号是普通信号,3464号信号是实时信号,也就是要求马上处理的信号,在本篇只讨论普通信号
- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define SIGINT 2
- 这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal
使用man 7 signal
查看发现SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一下。
3.1.2 了解Core Dump
那么 什么是Core Dump 呢?
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。
一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。
在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K:ulimit -c 1024
然后写一个死循环程序
运行它
可以发现的是使用ctrl c 和ctrl \ 都可以中断,但是ctrl \ 会进行核心转储,生成了 core.22533文件
ulimit命令改变了Shell进程的Resource Limit,test进程的PCB由Shell进程复制而来,所以也具 有和Shell进程相
同的Resource Limit值,这样就可以产生Core Dump了。
你可能会碰到的问题有:
在当前目录下看不到核心转储的文件
这个时候,你就需要查看核心转储的目录,并做修改
查看:cat /proc/sys/kernel/core_pattern
修改:echo core > /proc/sys/kernel/core_pattern
修改需要使用 root 权限
使用core文件
需要在gdb下使用命令core-file [core.进程号]
如下:
核心转储主要出现在:进程出现某种异常的时候,是否由操作系统讲当前进程在内存中的相关核心数据转存到磁盘中,它的主要应用场景是程序崩溃后的调试,用来找到崩溃原因。
为什么生产环境中一般都会关闭 core dump ?
在生产环境中关闭 core dump 可以提高系统的安全性和稳定性。以下是一些原因:
- 安全性问题:core dump 是操作系统将进程的内存转储到磁盘中的一个文件,包含了程序运行时的所有信息。如果敏感信息被写入了 core dump 文件中,例如密码、私钥等,那么该信息可能会被黑客窃取。关闭 core dump 可以避免这种情况发生。
- 稳定性问题:在有些情况下,core dump 可能会引起 IO 和 CPU 问题,影响系统的稳定性和可用性。例如,如果频繁地发生 core dump,可能会导致磁盘空间不足,需要清理旧的 core dump 文件。此外,生成大型的 core dump 文件也会占用系统的资源,从而影响其他正在运行的进程。
- 调试工具:在生产环境中,通常不需要进行调试,因为代码已经经过充分测试和验证。关闭 core dump 可以防止核心转储文件被非法使用来分析代码或进行调试,从而增加系统的安全性。
总之,在生产环境中关闭 core dump 可以提高系统的安全性和稳定性,避免数据泄漏和系统稳定性问题。如果需要进行调试,则可以在需要时打开 core dump。
3.2 通过终端按键产生信号
3.2.1 ctrl c
以下面程序为例:
#include<iostream>
using namespace std;
int main()
{
while(1)
{
cout<<"my linux"<<endl;
}
return 0;
}
没错!这个程序是个死循环!
一般来说在命令行中运行后我们是怎么中断的?
ctrl c
按下ctrl c以后,发现进程终止了
没错吧,但是为什么按ctrl c就能终止循环呢?
实际上这里是向操作系统发送了2号信号
怎么证明?
这里需要用到signal
函数
3.2.1.1 signal 函数
sighandler_t signal(int signum, sighandler_t handler);
- 作用:修改进程对信号的默认处理动作
- 返回值:它的返回值是一个函数指针,返回 指向 前一个此信号的处理(回调)函数 的指针,或者返回SIG_ERR。
- 参数:
- int signum 需要处理的信号
- sighandler_t handler 要替换的信号处理函数
然后我们就可以写出下面代码来验证ctrl c是不是发送了2号信号
#include<iostream>
#include<unistd.h>
#include<signal.h>
void handler(int signo)
{
printf("get a signal: signal no is:%d",signo , getpid());
}
int main()
{
signal(2,handler);
while(1)
{
printf("hello world! my pid is : %d\n" , getpid());
sleep(1);
}
return 0;
}
这段代码的意思就是每隔一秒打印一句话并打印该进程的pid
并且使用signal函数对2号信号进行了注册
注意:
对2号信号注册并不代表执行2号信号,就比如说老师和你说上课铃响了就要去上课,但是并不是让你去上课,只是教你什么时候干什么事情
下面是运行结果:
我们发现 当我们现在按下ctrl + c的时候 进程并不会退出而是会打印出我们注册的语句
因为我们注册的是2号信号 所以这也就证明了ctrl + c其实就是向进程发送了2号信号
3.2.2 怎么理解ctrl c组合键变成信号的呢?
也就是说它怎么就可以终止进程了呢?
因为键盘通过中断方式工作,它意味着键盘和计算机之间的通信是异步的。当键盘上的某个键被按下时,键盘会将这个按键信息发送给计算机。计算机会在接收到这个信息后,对键盘进行响应。这种方式提高了计算机的响应速度,同时让键盘和计算机之间的通信更加高效。简单来说,键盘的中断方式工作类似于“按下键 - 发送信息 - 计算机接收并处理”这样一个过程。
因此它就可以识别组合键 ctrl c ,操作系统会去解释组合键,它代表的含义然后去查找进程列表,找到前台运行的进程,这个进程也就是你按ctrl c的地方,操作系统写入对应的信号。
3.2.3 怎么理解信号被进程保存了呢?
既然进程可以暂时不管信号,还能后续再操作,也就说明进程中有个“东西”将信号保存了,是什么呢?会是什么结构呢?前面讲过普通信号有31种,而信号只有两种状态:发生,没发生,所以只需要用位图的方式来存储信号即可,那么之前所说的操作系统写入对应的信号,实际上做的就是修改位图。
而信号发送的本质就是:操作系统向目标进程写信号,也就是修改PCB中的指定的位图结构,这就是完成发送的过程
3.2.4 前台运行与后台运行
通过键盘发送的信号只对前台运行的程序产生作用
如果将程序放到后台运行就需要使用kill指令来发送信号
验证
3.3 调用系统接口向进程发信号
首先在后台执行死循环程序,然后用kill命令给它发SIGSEGV信号。
kill -SIGSEGV [pid]
- 24548是mycode3进程的id。之所以要再次回车才显示 (段错误)Segmentation fault ,是因为在24548进程终止掉之前,已经回到了Shell提示符等待用户输入下一条命令,Shell不希望Segmentation fault信息和用户的输入交错在一起,所以等用户输入命令之后才显示。
- 指定发送某种信号的kill命令可以有多种写法,上面的命令还可以写成 kill -SIGSEGV 24548 或 kill -11 24548 , 11是信号SIGSEGV的编号。以往遇 到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发SIGSEGV也能产生段错误。
kill命令是调用kill函数实现的。
3.3.1 kill函数
kill函数可以给一个指定的进程发送指定的信号。
#include <signal.h>
int kill(pid_t pid, int sig);
//pid_t pid:指定进程
//int sig:指定信号
//成功返回0,错误返回-1。
使用示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
int main()
{
int count = 3;
while(1)
{
if (count == 0)
{
kill(getpid() , 9);
}
printf("my pid is: %d\n",getpid());
sleep(1);
count--;
}
return 0;
}
上面的代码会在打印三次进程的pid之后被kill命令发送的9号信号杀死
运行结果如下
3.3.2 raise函数
raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
#include <signal.h>
int raise(int sig);
//参数是想要发送的信号
//成功返回0,错误返回-1。
使用示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
int main()
{
printf("my pid is %d\n", getpid());
sleep(3);
raise(9);
return 0;
}
这段代码的意思是在在休眠三秒之后对自己发送9号信号
运行结果如下
3.3.3 abort函数
abort函数使当前进程接收到信号而异常终止。
#include <stdlib.h>
void abort(void);
//就像exit函数一样,abort函数总是会成功的,所以没有返回值。
//向自己发送 SIGABRT信号
//此外SIGABRT信号是无法被捕捉的 只要调用了abort函数 进程一定会异常终止
使用示例:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
int main()
{
printf("my pid is %d\n", getpid());
sleep(3);
abort();
return 0;
}
这段代码的意思是 休眠三秒之后向自己发送SIGABRT
信号
运行结果
3.3.4 如何理解系统调用接口
用户调用系统接口 → 程序运行是就会执行操作系统对应的系统调用代码 → 操作系统会提取参数或者设置特定的数值 → 操作系统向目标进程写信号 → 修改对应进程的信号标记位 → 进程后续会处理信号 → 执行对应的处理动作
3.4 由软件条件产生信号
3.4.1 alarm函数
之前在进程通信的管道部分讲过这么一个现象:
当两个进程进行通信时,如果我们关闭读端,写端会自动关闭,这个时候就是操作系统向写端发送了13号信号 SIGPIPE
(解释下这个现象 因为当没有人读数据的时候往管道里面写数据实际上就是一个浪费资源的行为 而作为资源的管理者操作系统不会允许这种行为的存在)
而现在要介绍的14号信号也是由软件产生的
首先要介绍一个函数:alarm
函数
我们调用该函数可以产生一个闹钟 即在若干时间后告诉系统发送给进程14号信号 它的函数原型如下
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
//参数:是一个无符号整数,表示设置多少秒
它的返回值也是一个无符号整数有两种情况
- 如果进程在之前没有设置闹钟 则返回值为0
- 如果进程在之间设置了闹钟 则返回值为上一个闹钟的所剩时间 并且本次闹钟会覆盖上次闹钟的设置
我们使用一个生活中的例子来解释这个概念
比如说我们中午想要午睡 设置了一个30分钟的闹钟 (此时返回值为0)
而过了20分钟我们就睡醒了 看了看闹钟还有10分钟的睡眠时间 (此时返回值为10)
但是我们还想再睡15分钟 于是就再设置了一个15分钟的闹钟覆盖了上次的闹钟 (上次闹钟关掉了)
使用示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
int main()
{
int count = 0;
alarm(1);
while(1)
{
printf("count is :%d\n", count++);
}
return 0;
}
这段代码的意思是统计一秒内能打印多少count语句
运行结果
大概是41万多次
现在我们改变下思路 捕捉下14号信号 让他结束的时候打印下count值是多少 而我们不在while循环中打印了
下面是删除行号并优化格式的代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
long long count = 0;
void handler(int signo)
{
printf("count is : %d\n", count);
exit(0);
}
int main()
{
signal(14, handler);
alarm(1);
while(1)
{
count++;
}
return 0;
}
运行结果
这次直接超出了long long
所能表达的范围,变成了负数
为什么?
我们在基础IO中讲解过了 cpu的操作是非常快的 而外设是非常慢的 所以说打印的count值要远远小于不打印的count值
3.4.2 怎么理解软件条件给进程发送信号
- 操作系统识别到某种软件条件触发或者不满足
- 操作系统构建信号发送给指定的进程
3.5 硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
3.5.1 除0异常
例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE
信号发送给进程。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("signo is : %d\n", signo);
}
int main()
{
int i = 0;
for (i = 0; i < 32; i++)
{
signal(i, handler);
}
int a = 100;
a /= 0;
return 0;
}
运行结果
进程会不停的向屏幕中打印8号信号
3.5.2 野指针异常
再比如当前进程访问了非法内存地址,,MMU会产生异常,内核将这个异常解释为SIGSEGV
信号发送给进程。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("signo is : %d\n", signo);
}
int main()
{
int i = 0;
for (i = 0; i < 32; i++)
{
signal(i, handler);
}
int* p = NULL;
*p = 100;
return 0;
}
我们捕获了所有的信号 自定义了它们的处理方式,并且故意写出了一个空指针的访问
运行结果
我们可以打出 kill -l 指令来查看所有的信号
之后我们将信号捕捉部分代码删除 只留下除0操作
编译运行后查看结果
我们发现 进程会直接终止并且会报出FPE错误
所以说 我们这里就可以知道
在Linux中 进程崩溃的本质是进程收到了对应的信号 然后进程执行信号的默认动作(杀死进程)
3.5.3 如何理解除0异常
我们都知道进行计算的是CPU这个硬件,而在COU内部有寄存器,在寄存器中有一种状态寄存器(原理:位图),状态寄存器里面会有对应的状态标志位,包括溢出标志位等,操作系统会自动进行计算完毕之后的检测。
以溢出标志位来说,操作系统会识别溢出标志位如果是1,就代表有溢出问题,立即找到当前是哪个进程在运行,提取PID,操作系统完成信号发送的过程,进程会在合适的时候进行处理。
一旦出现硬件异常进程一定会退出吗?不一定,一般默认是退出,但是不退出我们也做不了什么事情
为什么会死循环?寄存器中的异常一直没有被解决
3.5.4 如何理解野指针或者越界问题
在我们的程序运行时都是通过地址来找到目标位置
而我们语言上的地址全部都是虚拟地址
而虚拟地址会经过页表和MMU(Memory Manager Unit 硬件)然后映射到物理地址上
而将空指针解引用的本质就是在访问一个非法的地址
此时MMU硬件在CPU的运算中就会报错
出错以后操作系统就会寻找是哪个进程引发了这个错误
操作系统找到这个进程后就会想这个进程发送信号,杀死这个错误的进程
3.5.5 进程崩溃的原因
我们在前面的进程控制中学习了 进程终止的方式按照是否正常退出可以分为两种
一种是运行完毕所有代码 程序自己退出 还有一种就是程序异常终止
之前讲解进程控制中的waitpid这个函数,讲过有一个输出型参数叫做status 这个函数的低七位就是我们的终止信号
在前文我们讲到过core dump
相关的知识,现在就可以派上用场了
先将它的空间设置一下
然后再次运行
core dumped
代表着我们崩溃进程的错误信息被保存了
然后还发现这里多出了一个叫做 core.28652 的文件
那么这个文件有什么用呢?
我们可以使用gdb调试这个可执行文件
之后打出 core-file + core dump文件
在下面我们就可以看到文件的各种错误信息包括收到的终止信号 错误行数等等
我们把这种debug方式叫做事后调试
3.5.6 操作系统怎么发信号
在进程的结构体中我们需要一个空间来保存收到的信号 为什么这么说呢
还记不记得我们上面举的闹钟响了但是你还想继续睡觉的例子 也就是说在信号发送的时候我们有可能有更加重要的事情(在计算机眼里就是更高优先级)要去做 所以说信号必须要被暂存下来
那么信号应该怎么保存呢? 我们使用kill -l指令查看所有的信号
我们可以看到 其实这里信号的编号是十分有规律的 它的编号是1~31
看到这么规律的数字我们一定能第一时间想到数组下标
当然使用数组的下标来保存信号是完全可行的
可是这里我们只需要知道信号是否存在就可以了不需要知道其他的事情 所以说这里使用比特位来标志一个信号是否存在是一种更好的做法
实际上在linux系统中也确实是用一个32位的无符号整数来标志每个信号是否存在的
概念图如下
我们将最低位定义为为第一位 依次往高位递增
如果该位的比特位为1则表示收到信号 如果该位的比特位为0则表示未收到信号
现在我们再来理解操作系统是如何发送信号的 本质就是操作系统将PCB中信号位图对于位置置1
所以说我们现在对于操作系统发送信号的理解应该是操作系统写入信号
3.6 总结思考
上面所说的所有信号产生,最终都要有OS来进行执行,为什么?
所有信号产生,最终都要由操作系统(OS)来进行执行,这是因为操作系统是进程的管理者。操作系统负责调度和控制进程的执行,包括处理信号。
信号的处理是否是立即处理的?
在合适的时候,信号的处理通常是异步的,即信号可以在任何时候发生并被传递给进程。进程在收到信号后,根据信号的类型和进程当前的状态,可以选择立即处理信号或者将信号暂时记录下来等待合适的时机进行处理。
信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
如果进程不立即处理信号,操作系统会将信号信息记录在进程的进程控制块(PCB)中。进程控制块是操作系统用来管理进程的数据结构,其中包含了进程的状态、上下文和其他相关信息。记录信号的目的是为了确保在适当的时候能够通知进程有未处理的信号需要处理。
一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
一个进程在没有收到信号的时候,无法知道自己应该对合法信号作何处理。进程只能在收到信号后才能根据信号的类型和自身的状态来选择相应的信号处理方式。在信号传递给进程之前,进程也无法确定是否会收到某个特定的信号。
当进程收到信号时,它可以通过信号处理函数或者默认处理动作进行处理。如果进程没有设置自定义的信号处理函数,则会执行信号的默认处理动作。默认处理动作是由操作系统规定的,不同类型的信号可能有不同的默认处理动作。
需要注意的是,即使进程设置了自定义的信号处理函数,也不能保证该处理函数一定会被执行。例如,如果进程正在阻塞某个系统调用,则在信号到达时处理函数可能无法立即执行,而是要等待系统调用返回后再执行。
因此,进程无法在收到信号之前预先知道如何处理合法信号。它只能在收到信号后根据信号的类型和自身的状态来选择相应的处理方式。
如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
完整的发送处理过程大致如下:
- 操作系统接收到信号并确定要发送给哪个进程。
- 操作系统检查目标进程对该信号的处理方式。
- 如果进程忽略该信号,则操作系统不进行任何处理。
- 如果进程设置了自定义的信号处理函数,操作系统将信号传递给该处理函数。
- 处理函数执行相关的操作,并可以根据需要修改信号处理方式或者其他状态。
- 如果进程没有设置自定义的信号处理函数且信号有默认处理动作,操作系统执行该默认处理动作。
- 处理完信号后,控制权返回给目标进程继续执行原来的程序。
4 信号产生中
4.1 基本概念 : 信号递达、信号未决、阻塞信号、忽略信号
在了解信号产生中的时候,我们需要了解几个基本概念:
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞/屏蔽 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
怎么理解呢?
就拿这么一个例子来说,假设你设定了一个明天早上起床的闹钟,它会提醒你要起来上课了,到了第二天,会有这么几种情况:
- 你听到闹钟然后起来上课,这就是信号递达,执行的是默认动作
- 从你听到闹钟响到你去上课之间的状态就是信号未决
- 你室友听到闹钟然后把它关了,导致你没有听见继续睡,这就是信号屏蔽/阻塞
- 你听到了闹钟响但是不去管它,继续睡,这就是信号忽略
- 你听到闹钟响然后起来关了以后不去上课而是干其他的事情,这就是执行自定义动作
- 阻塞和忽略的区别就在于你是否收到了信号,阻塞是你没有收到,忽略则是你收到后不进行处理的行为。
4.2 内核中信号的结构 > block位图 pending位图 handler函数表
信号在内核中的表示示意图:
它们分别是block位图 pending位图 handler函数表
下面我将会一一介绍它们
block位图
block位图标志着该信号是否被阻塞
1表示该位图被阻塞 0则表示未被阻塞
pending位图
pending位图标志这是否接受到该信号
1表示收到该信号 0则表示未收到信号
handler函数表
handler函数表里面是信号接受时处理的各种函数地址
我们通过这些地址去调用函数
现在我们来回答一个之前的问题 为什么就算我们没有看见红绿灯过 我们也知道红灯停绿灯行呢 本质上是因为handler函数表中注册了红绿灯这个函数
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。
- 信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
- 在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
- 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
- 允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
4.3 sigset_t 类型
实际上在我们的操作系统中 除了像是int double等类型 还有一些操作系统给我们提供的类型
像是共享内存中的key_t类型
为了解决未决和阻塞的位问题 操作系统向我们提供了一个类似位图的数据类型 sigset_t
而从上图来看,每个信号只有一个bit的未决标志,非 0 即 1 ,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_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;
这个类型可以表示每个信号的**“有效”或“无效”**状态。
- 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞。
- 在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
下一节将详细介绍信号集的各种操作。
阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
4.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 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
4.5 sigprocmask 函数
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:
- 若成功则为0,若出错则为-1
参数说明:
- how参数一般我们使用宏来表示 它标志着设置的模式
- 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
- 如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。
- 如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
- 假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
| 选项 | 含义 |
| — | — |
| SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask | set |
| SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask && ~set |
| SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
注意:如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
也就是说如果我们之前阻塞了2号信号 现在解除阻塞 进程就会立刻收到2号信号 并且执行注册函数
为什么我们一解除阻塞进程就会收到信号这个我们后面会深入讲解
4.5.1 不能被阻塞的信号(现象)
我们使用刚刚学到的sigprocmask可以阻塞信号 下面我们使用一段代码来试试阻塞2号和9号信号
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
int main()
{
sigset_t iset;
sigemptyset(&iset);
sigaddset(&iset , 2);
sigaddset(&iset , 9);
sigprocmask(SIG_SETMASK , &iset , NULL);
while(1)
{
printf("hello world , my pid is:%d\n",getpid());
sleep(1);
}
return 0;
}
运行后我们使用ctrl c来终止程序,发现无效
我们重新开启一个进程并且发送一个9号信号给它
我们发现此时的进程就被直接杀死了
可是我们上面明明阻塞了9号信号
根据上面的现象我们可以推断 9号信号是不可以被阻塞的
4.6 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位图
演示结果如下
5 信号产生后
我们回顾之间讲解的例子,闹钟响了以后,你起床了,现在你要选择一个合适的时候去上课,这个合适的时候就是你洗漱、吃完早餐,在上课铃响之前就要去上课。
那么对于计算机来说这个合适的时候是什么呢?
我们首先要知道 因为信号是保存在进程的PCB当中的pending位图里的 如果要处理需要检测pending位图里面是否有信号、是否被阻塞、信号的处理方式是什么
我们这里直接下一个结论
信号从**内核态**
返回**用户态**
的时候进行上面的检测并处理
什么是内核态 什么又是用户态 我们下面去认识一下它们。
5.1 内核态和用户态
定义:
- 内核态是操作系统内核运行的特权级别,具有最高的权限和访问权。在内核态下,操作系统可以直接访问和控制系统的全部硬件资源和核心功能,执行特权指令并处理关键的系统任务,如管理进程、文件系统、设备驱动等。内核态可以执行具有潜在危险和影响系统稳定性的操作,因此只有操作系统内核可以进入内核态。
- 用户态是应用程序运行的一种受限权限的执行模式。在用户态下,应用程序只能访问受限的资源和执行受限的指令,不能直接访问和控制系统的底层硬件。用户态下运行的应用程序必须通过系统调用(System Call)来请求内核提供特权功能和服务,如创建进程、文件读写、网络通信等。用户态下的应用程序无法执行一些关键的特权指令和进行系统级的操作。
它们之间的区别在于权限,内核态的权限比用户态的权限大。
用户态和内核态之间的转换发生在什么时候?
就比如 系统调用 时使用系统的接口就会由用户态进入内核态,还有就是运行程序发生缺陷、陷阱、异常等情况也会由用户态进入内核态。
能否再具体?
当然
我们知道当我们运行用户写的程序的时候,操作系统会创建进程
此时会有一个叫PCB的数据结构在经由页表和mmu(硬件)的映射后加载到内存中,
那么操作系统的代码和数据是否也要加载到内存中呢?
当然了,实际上我们的电脑在开机等待的时间中就包括了操作系统的代码和数据加载到内存的时间。
那么现在我们就了解到操作系统和用户的数据和代码都会加载到内存中了。
那么用户态和内核态是如何区分的呢?
它们实际的调用过程如下图
可以看到的是用户态和内核态的代码和数据用的并不是一个页表
操作系统使用的是内核级页表
用户使用的是用户级页表
用户空间我们可以理解为一个临时变量 它是属于当前进程的
而内核空间则是一个全局变量 所有进程看到的都是一样的
从这里我们就能得到一个结论
不管我们如何切换进程 我们找到的操作系统代码和数据都是同一个
那么站在现在的角度我们如何理解进程切换?
实际上就是当前进程进入内核态并且找到操作系统的数据和代码 再之后利用操作系统的权限替换用户空间中的数据和代码 这样子就完成进程切换了
那么在系统中用户态和内核态的切换是大概是一个什么样子的呢?
大概变换如下图所示
需要注意的是 如果信号的处理动作是终止 那么当前进程就不需要再回到用户态直接终止了
如果觉得不好记我们可以将这张图再抽象一下
其中该图形和中间的线有几个交点就说明有几次切换
该图形中间的交点表示检查pending表的时刻
需要注意的是 我们检查pending表时是在内核态检查,也就是说该图形的交点必须要在内核态
前面我们讲过内核态的权限比用户态高,那么可以不进行用户态内核态切换直接在内核态处理数据嘛?
理论上是可以的 内核态的权限非常高可以执行用户态的各种函数
但是实际上是不能这么设计的 还是因为内核态的权限非常高 如果用户写出一些很危险的代码比如说删库等操作 放在内核态执行的话就会造成很不好的影响。
5.2 捕捉信号
5.2.1 内核如何实现信号的捕捉
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
用户程序注册了SIGQUIT信号的处理函数sighandler。
当前正在执行main函数,这时发生中断或异常切换到内核态。
在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
内核决定返回用户态后不是恢复main函数的上下文继续执行,
而是执行sighandler函数,
sighandler和main函数使用不同的堆栈空间,
它们之间不存在调用和被调用的关系,是两个独立的控制流程。
sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
5.2.2 sigaction 函数
捕捉信号除了用前面用过的signal函数之外 我们还可以使用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);
};
我们先了解前4个成员
结构体的第一个成员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即可
接下来我们尝试来使用一下它:
代码如下:
#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号信号,然后再让它在第二次收到的时候将它恢复
效果演示如下
5.3 可重入函数
我们通过一个链表插入的例子了解它。
下面主函数中调用insert函数向链表中插入结点node1
某信号处理函数中也调用了insert函数向链表中插入结点node2
这是该链表
下面是这个链表的变化过程
- 我们调用函数进行头插 并在此时发送信号 (该信号中也用到了头插函数) 在完成头插的第一步之后进程中断进入内核态
- 内核态检查信号 并且调用自定义处理方式头插链表(回到用户态)
- 回到内核态检查没有信号 继续插入完成链表的头插 回到用户态
- 继续被中断的的头插node1,结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
看到这里熟悉链表的同学可能会发现问题了 这里会造成内存泄漏
而我们没有任何办法可以找到node2的地址
在上面的操作中可能在第一次调用还没返回时就再次进入该函数 我们将这种现象称之为重入
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free 因为malloc也是用全局链表来管理堆的
- 调用了标志I/O库函数 因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构
5.4 关键字 volatile
volatile是C语言的一个关键字 该关键字的作用是保持内存的可见性
有人可能会问,这个有什么用?
别说,还真有用
现在的市面上有很多编译器,它们都会对程序进行各种优化来减少CPU的消耗,但是不是所有的优化都是合适的,比如说下面这段代码:
#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;
}
在上面的代码中 我们对2号信号进行了捕捉 当该进程收到2号信号时会将全局变量flag由0置1 也就是说 在进程收到2号信号之前 该进程会一直处于死循环状态 直到收到2号信号时将flag置1才能够正常退出
我们来运行一下
这里也符合我们的预期
那么上面的这段代码一定是正确的嘛? 答案是否定的,在编译器优化后就不是了。
因为flag在main函数当中并没有做任何的修改 如果在优化级别比较高的情况下 编译器可能会将flag这个变量放到寄存器中去
而handler执行流只是将内存中flag的值置为1了 那么此时就算进程收到2号信号也不会跳出死循环
我们可以试验下
在使用优化编译器(如 -O3)时,编译器会对代码进行各种优化,以提高程序的执行效率。其中一种常见的优化是在循环中消除空闲等待,即编译器可能会检测到while (!flag);这个循环实际上没有任何实质性的计算或操作,它只是在等待flag变为非零。编译器可能会认为这个循环是一个空循环,并将其优化掉,因此导致没有看到预期的信号处理输出。
此时我们只需要在flag的前面加上 volatile关键字 就可以避免这种情况了
volatile int flag = 0;
我们加上volatile关键字之后falg就对内存可见了,自然它变化之后我们的main执行流就能跳出死循环
5.5 SIGCHLD信号
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。
- 采用第一种方式,父进程阻塞了就不 能处理自己的工作了;
- 采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
- 其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
我们等会可以编写一个程序完成以下功能:
- 父进程fork出子进程,子进程调用exit()终止
- 父进程自定义SIGCHLD信号的处理函数
- 在其中调用wait获得子进程的退出状态并打印。
#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函数对子进程进行了清理
注意:
- SIGCHLD属于普通信号,记录该信号的pending位只有一个,如果在同一时刻有多个子进程同时退出,那么在handler函数当中实际上只清理了一个子进程,因此在使用waitpid函数清理子进程时需要使用while不断进行清理
- 使用waitpid函数时 需要设置WNOHANG选项,即非阻塞式等待,否则当所有子进程都已经清理完毕时,由于while循环,会再次调用waitpid函数 此时就会在这里阻塞住
5.6 两种不能被捕捉和被阻塞的信号
SIGKILL(9)和SIGSTOP (19)
更多推荐
所有评论(0)