前言
本篇将讲述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方式叫做事后调试
《Linux从练气到飞升》No.24 Linux中的信号(二)+https://developer.aliyun.com/article/1389851