「前言」文章是关于Linux进程信号方面的知识,本文的内容是Linux进程信号第一讲,讲解会比较细,下面开始!
「归属专栏」Linux系统编程
「笔者」枫叶先生(fy)
「座右铭」前行路上修真我
「枫叶先生有点文青病」
「每篇一句」
人生天地间,忽如远行客。
——《乐府·青青陵上柏》
目录
一、认识信号
1.1 生活中的信号
1.2 将1.1的概念迁移到进程
1.3 信号概念
1.4 查看系统定义信号列表
1.5 man 7 signal
1.6 解释1.2的代码样例
1.7 信号处理常见方式概览
二、产生信号
2.1 signal函数
2.2 通过终端按键产生信号
2.3 调用系统函数向进程发信号
2.3.1 kill函数
2.3.2 raise函数
2.3.3 abort函数
2.4 硬件异常产生信号
2.4.1 除0操作产生的异常
2.4.2 空指针异常
2.5 由软件条件产生信号
一、认识信号
1.1 生活中的信号
日常生活中,常见的信号有:发令枪、红绿灯、消息提醒、电话铃声、闹钟等等,以快递为例。
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”;
- 识别快递包含了两个信息:1、认识:你是认识 “快递” 的 2、行为产生:“快递” 到来,你会产生相应的行为;
- 当 “快递” 到来,快递小哥给你打电话,但是你正在打游戏,需5min之后才能去 “取快递”。那么在在这5min之内,你并没有去取快递,但是你是知道有快递到来了。也就是 ”取快递” 的行为并不是一定要立即执行,可以理解成 “在合适的时候去取”;
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你 “记住了有一个快递要去取”;
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你的女朋友)3. 忽略快递(快递拿上来之后,放在一旁,继续开一把游戏);
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。
快递便可视为信号,对信号的处理大致分以下三步:
- 识别信号:你认识这个信号,信号到来你会产生相应的行为;
- 信号到来(即将处理信号):不一定立即处理信号,也可以在某个合适时间进行处理信号,但是必须要把这个信号记住;
- 拿到信号(处理信号):拿到信号后分三种行为 (1、默认动作:拿到信号后使用信号 2、忽略动作:拿到信号后忽略信号,什么事也不干 3、自定义动作:拿到信号去干别的事)
解释异步概念:
以生活例子为例:你在煮着面条。当你在煮面条时,你可以同时做其他事情,例如准备酱料或者切菜。你不需要一直站在炉子旁边等待面条煮熟,而是可以在面条煮熟之前做其他事情,这就是异步;
而同步则是:你必须等待面条煮熟了,你才能干其他事,这是同步。
以信号为例:假设你正在等待信号的到来。在同步中,你会等待信号到达后再继续下一个任务,这意味着你会阻塞其他任务直到信号到达。
另一方面,在异步中,你不会等待信号到达,这意味着在等待信号到来的时可以做其他任务。
1.2 将1.1的概念迁移到进程
首先知道,信号是给进程发送的,比如我们之前的 kill -9 ,给进程发送9号信号终止进程。
- 进程是如何识别信号:也是 认识信号 + 行为动作(进程之所以能够认识信号,是因为程序员将对应的信号种类和逻辑已经写好了);
- 进程收到信号时:进程不一定立即处理这个信号,进程可能干着其他更重要的事情;
- 进程立即处理 或 不一定立即处理这个信号的时候,进程本身必须要有对信号的保存能力;
- 进程处理信号的时候,一般有三个动作(默认、忽略、自定义),进程处理信号称为信号被捕捉。
进程本身必须要有对信号的保存能力,信号保存在哪里?答案是保存在进程的PCB里面,即task_struct。
信号如何保存?是否收到了指定的信号,是否即两态:二进制表示非0即1,即用1代表收到了信号,0则代表没有收到信号
这种结构称为位图结构,之前有过相关解释,C++专栏也有
指定的信号在Linux是:
用 kill -l 命令可以察看系统定义的信号列表
kill -l
其中 [1, 31] 号信号是普通信号,[34, 64] 号信号是实时信号(信号学习中,这里我们只学习普通信号)
即普通信号就可以用32个比特位来表示,即四字节的整型,即在 task_struct 里面一定存在一个字段 unsigned int:
struct task_struct
{
//进程属性
//.......
unsigned int signal;
//.......
}
这个字段可以表示所有的普通信号:
第一个比特位代表 1号信号,以此类推(位图结构)
如何理解信号的发送?也就是发送信号的本质
发送信号的本质就是:修改PCB中的信号位图,即task_struct 的信号位图,也就是上面图中所说的位图,比如发送1号信号,发送1号信号就是把信号位图的第一个比特位由0置1
而 task_struct 是内核维护的一种数据结构对象,所以 task_struct 的管理者是OS,只有OS才有权利修改 task_struct 里面的内容,所以以此推导:无论在未来学习多少种发送信号的方式,本质都是通过OS向目标进程发送信号(谁都没有权利修改OS内的数据结构,只有OS自己可以)
所以我们用户要操作信号,OS必须提供发送信号、处理信号的相关系统调用
比如之前一直使用的 kill 命令,底层一定调用了对应的系统调用
以代码展示信号:
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
while(true)
{
cout << "我是一个进程,pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
该程序的运行结果就是死循环地进行打印,而对于死循环来说,常用方式就是使用 Ctrl+C 终止进程
为什么使用 Ctrl+C 后,该进程就终止了?
实际上当用户按Ctrl+C时,这个键盘输入会产生一个硬中断,被操作系统获取并解释成信号(Ctrl+C被解释成2号信号),然后操作系统将2号信号发送给目标前台进程,当前台进程收到2号信号后就会退出
2号信号是:SIGINT
这里只是简单介绍,下面详细解释
1.3 信号概念
信号是进程之间事件异步通知的一种方式,属于软中断
1.4 查看系统定义信号列表
用 kill -l 命令可以察看系统定义的信号列表
kill -l
1.2 有过大概解释,这不介绍了,我们可以使用信号数字的编号,也可以直接使用宏定义,比如2号信号,我们可以使用 2 也可以使用 SIGINT
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在 signum.h 中找到,例如其中有定 义 #define SIGINT 2
查看 signum.h
1.5 man 7 signal
普通信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明,直接看文档即可
man 7 signal 查看:
普通信号的默认动作:
1.6 解释1.2的代码样例
man 7 signal 查看二号信号的作用:
Interrupt from keyboard:从键盘获取中断
默认动作:Term
Term: Default action is to terminate the process.
翻译:默认操作是终止进程
所以,到这里我们就知道为什么 2号信号可以终止进程
我们可以使用signal函数对2号信号进行捕捉,证明当我们按 Ctrl+C 时进程确实是收到了2号信号。使用 signal 函数时,我们需要传入两个参数,第一个是需要捕捉的信号编号,第二个是对捕捉信号的处理方法,该处理方法的参数是int,返回值是void
注:signal 函数这里是使用,详细下面才解释,这里只是演示
下面的代码中将2号信号进行了捕捉,当该进程运行起来后,若该进程收到了2号信号就会打印出收到信号的信号编号
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "捕捉到一个信号,该信号编号是:" << signo << endl;
}
int main()
{
signal(2, handler);
while(true)
{
cout << "我是一个进程,pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
此时当该进程收到2号信号后,就会执行我们给出的handler方法,而不会像之前一样直接退出了,因为此时我们已经将2号信号的处理方式由默认改为了自定义了(默认行为:结束进程 -> 变成 自定义行为:handler方法)
进程是无法 Ctrl+C 结束进程,直接发送 9 号信号终止进程,kill -9 进程pid
注意:
- Ctrl+C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
- Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl+C 这种控制键产生的信号。
- 前台进程在运行过程中用户随时可能按下 Ctrl+C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的
1.7 信号处理常见方式概览
信号处理动作有以下三种,也就是上面概念所提到的
- 默认:执行该信号的默认处理动作;
- 忽略:忽略此信号;
- 自定义:提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号
二、产生信号
前面第一大点都是信号预备知识,这里第二大点讲的是信号产生
2.1 signal函数
对上面使用signal函数进行补充:signal函数的作用是用于处理信号(自定义行为),对信号进行捕捉
man 2 signal 查看一下
signal
头文件:
#include <signal.h>
函数原型:
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数:
第一个参数signum:需要捕捉的信号编号
第二个参数handler:对信号自定义的行为,对捕捉信号的处理方法(函数),handler是一个回调函数,该处理方法的参数是 int,返回值是void
sighandler_t是一个函数指针
2.2 通过终端按键产生信号
测试代码
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
while(true)
{
cout << "我是一个进程,pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
该代码是一个死循环,前面已经说过可以使用 Ctrl+C 来终止进程,发送的信号是 2号信号 SIGINT
小提示:进程运行了,该进程就变成了前台进程(命令行在当前进程无效),bash就会变成后台进程,进程结束后,bash又会变回前台进程,命令行生效
实际上除了按 Ctrl+C 之外,按 Ctrl+\ 也可以终止该进程
按 Ctrl+C 和按 Ctrl+\ 都可以终止进程,但是两者有什么区别?
Ctrl+C 发送的信号是2号信号 SIGINT,Ctrl+\ 发送的信号是3号信号 SIGQUIT
这两个信号的默认行为(Action)不一样:2号信号默认行为是 Term,3号信号默认行为是 Core
Term 上面解释过了,不解释了
Core 在终止进程的时候会进行一个动作,那就是核心转储
Default action is to terminate the process and dump core (see core(5)).
dump core:核心转储
什么是核心转储?
Term 把进程终止了就不做其他工作了,核心转储Core 把进程终止后还做其他的工作
注意:在云服务器中,核心转储是默认被关掉的,我们需要打开才能观察到现象
以通过使用 ulimit -a 命令查看当前资源限制的设定
其中,第一行显示core文件的大小为0,即表示核心转储是被关闭的
我们可以通过 ulimit -c size
命令来设置core文件的大小,即打开核心转储
core文件的大小设置完毕后,就相当于将核心转储功能打开了
再次运行上面的程序,Ctrl+\把进程终止(发送3号信号,默认动作Core),现象就会出现:core dumped
并且会在当前路径下生成一个core文件,该文件以一串数字为后缀,而这一串数字实际上就是发生这一次核心转储的进程的PID
核心转储就是:当进程出现异常的时候,我们将进程在对应的时刻,在内存中的有效数据转储到磁盘中,也就是上面的文件
那么核心转储有什么用??
当我们的代码出错了,我们最关心的是我们的代码是什么原因出错的。如果我们的代码运行结束了,那么我们可以通过退出码来判断代码出错的原因,而如果一个代码是在运行过程中出错的,那么我们也要有办法判断代码是什么原因出错的
当我们的程序在运行过程中崩溃了,我们一般会通过调试来进行逐步查找程序崩溃的原因。而在某些特殊情况下,我们会用到核心转储,核心转储指的是操作系统在进程收到某些信号而终止运行时,将该进程地址空间的内容以及有关进程状态的其他信息转而存储到一个磁盘文件当中,这个磁盘文件也叫做核心转储文件(支持调试)
可以使用gdb进行调试
为了方便演示,使用 2.4.2 的空指针例子演示,代码如下:
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
while(true)
{
cout << "我是一个进程,正在运行中,pid: " << getpid() << endl;
sleep(2);
//空指针(野指针)
int *p = nullptr;
*p = 10;
}
return 0;
}
注:Linux默认是release,调试需要增加 -g选项
运行结果
使用gdb对当前可执行程序进行调试
gdb 可执行程序, 进入调试
然后直接使用 core-file 核心转储文件 命令加载 core文件,即可判断出该程序在终止时收到了11号信号,并且定位到了产生该错误的具体代码,错误信息也详细列出
core-file core.11467
事后用调试器检查core文件以查清错误原因,这种调试方式叫做事后调试
这就是 Term 和 Core 的区别,Term是正常终止进程
2.3 调用系统函数向进程发信号
2.3.1 kill函数
kill函数是一个系统调用,kill命令就是通过 kill函数来实现的
测试 kill命令
使用kill命令向一个进程发送信号时,我们可以用 kill -信号编号 进程pid 的形式进行发送信号
kill函数的作用是:向目标进程发送指定信号
man 2 kill 查看:
函数:kill
头文件
#include <sys/types.h>
#include <signal.h>
函数原型:
int kill(pid_t pid, int sig);
参数
第一个参数pid就是进程的pid
第二个参数sig是信号的编号
返回值
发送成功,返回0,否则返回-1
使用 kill函数模拟 kill命令(mykill):
#include <iostream>
#include <sys/types.h>
#include <signal.h>
#include <string>
#include <unistd.h>
using namespace std;
//使用手册
static void Usage(const string& proc)
{
cout << "\nUsage: " << proc << " pid signo\n" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
//把字符串转整型
pid_t id = atoi(argv[1]);
int signo = atoi(argv[2]);
int n = kill(id, signo);
if(n != 0)
{
perror("kill");
}
return 0;
}
另一个测试代码,死循环,test.cc
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
while(true)
{
cout << "我是一个进程,pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
选运行死循环测试代码,再使用 mykill杀掉死循环,这样就实现了一个 kill命令
2.3.2 raise函数
raise函数的作用是:给自己发送信号
man 3 raise 查看:
函数:raise
头文件
#include <signal.h>
函数原型
int raise(int sig);
参数:sig是要发送的信号编号
返回值
发送成功,则返回0,否则返回一个非零值
这个函数也可以通过 kill函数实现:kill(getpid(), sig)
测试代码:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
int main()
{
int cnt = 0;
while(true)
{
cout << "我是一个进程,pid: " << getpid() << " cnt: " << cnt++ << endl;
sleep(1);
//cnt>=5,就给自己发送3号信号
if(cnt >= 5)
raise(3);
}
return 0;
}
运行结果
2.3.3 abort函数
abort函数用于给自己发送指定信号:6号信号SIGABRT,6号信号的默认动作也是终止进程
man 3 abort 查看
函数:abort
头文件
#include <stdlib.h>
函数原型
void abort(void);
这个函数也可以通过 kill函数实现:kill( getpid(), SIGABRT )
测试代码:
#include <iostream>
#include <cstdlib>
#include <unistd.h>
using namespace std;
int main()
{
int cnt = 0;
while(true)
{
cout << "我是一个进程,pid: " << getpid() << " cnt: " << cnt++ << endl;
sleep(1);
//cnt>=5,就给自己发送指定信号
if(cnt >= 5)
abort();
}
return 0;
}
运行结果
abort函数总是会成功的,所以没有返回值
进程收到的大部分信号,默认动作都是终止进程
2.4 硬件异常产生信号
信号的产生,不一定非得用户显示发送,有些信号会在OS内部自动产生,比如硬件异常产生的信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号
2.4.1 除0操作产生的异常
比如进行进程除0操作,VS会直接报错终止进程
下面在g++下进行测试,测试代码如下
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
while(true)
{
cout << "我在运行中..." << endl;
sleep(1);
//除0操作
int a = 10;
a /= 0;
}
return 0;
}
运行结果,编译警告不用理会
为什么除0会终止进程??
因为进程收到了来自OS的信号,该信号是SIGFPE,8号信号
该信号的默认动作也是 Core,也是终止进程
Floating point exception:浮点异常
下面对该信号进行捕捉,捕捉后进行自定义动作
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
//自定义行为
void handler(int signo)
{
cout << "捕捉到一个信号,该信号编号是:" << signo << endl;
}
int main()
{
signal(8, handler);
while(true)
{
cout << "我在运行中..." << endl;
sleep(1);
//除0操作
int a = 10;
a /= 0;
}
return 0;
}
运行结果
运行结果说明,进程确实收到了8信号,可是为什么一直对该信号一直捕获??OS为什么一直发送8号信号???
下面讲解对于除0的理解:
在CPU中有很多的寄存器,例如eax,ebx,eip等等
CPU会将代码中的变量拿到寄存器中进行运算,如果有需要,运算结果需要返回
进行对两个数进行算术运算时,我们是先将这两个操作数分别放到两个寄存器当中,然后进行算术运算并把结果写回寄存器当中
(这里就简单略过具体的寄存器,方便理解,图也是简略化)
CPU当中还有一组寄存器叫做状态寄存器,它可以用来标记当前指令执行结果的各种状态信息,如有无进位、有无溢出等等
除0操作,对于计算机来说,是除一个无穷小的数,得到的结果是无穷大的数,寄存器存不下这个数,这时候状态寄存器的溢出标志位由0置1,这时CPU就会发出运算异常的信号,OS就会识别到这个异常,OS就会给指定的进程发送这个信号,这个信号就是8号信号,CPU报的这个异常归属于硬件异常,OS检测到,由OS主动发送给目标进程
接下来解释为什么会一直死循环捕获到8号信号??
代码在CPU中执行的时候,此时CPU内的寄存器的内容属于该进程的上下文数据,因为寄存器只有一份,代码没有运行完,当该进程的时间片到了,就会从CPU上切下去,该进程的上下文数据也被会保存,包括溢出标志位
进程被来回切换,就有无数次寄存器的内容被保存会恢复的过程,所以每次恢复的时候,OS都会识别到CPU内部的状态寄存器溢出标志位为1,OS就会给该进程发送8号信号
由于我们把该信号捕获了,执行自定义行为,该信号的默认行为就不会执行,每次恢复的时候,进程还没有被终止,OS依旧会识别到CPU内部的状态寄存器溢出标志位为1,就又发8号信号给该进程,以此往复,就陷入死循环,所以我们会看到8号信号一直被捕获
2.4.2 空指针异常
空指针(野指针)问题在程序中可能会遇到,空指针VS直接崩溃,终止程序,下面在g++下测试
测试代码:
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
while(true)
{
cout << "我是一个进程,正在运行中,pid: " << getpid() << endl;
sleep(2);
//空指针(野指针)
int *p = nullptr;
*p = 10;
}
return 0;
}
运行结果
结果发现,空指针操作进程直接被终止了,为什么进程会被终止?
因为进程收到了来自OS的信号,该信号是11号信号 SIGSEGV
该信号的默认动作也是 Core,也是终止进程
Invalid memory reference:无效内存引用
Segmentation fault:段错误
下面对该信号进行捕捉,捕捉后进行自定义动作
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
//自定义行为
void handler(int signo)
{
cout << "捕捉到一个信号,该信号编号是:" << signo << endl;
}
int main()
{
signal(11, handler);
while(true)
{
cout << "我是一个进程,正在运行中,pid: " << getpid() << endl;
sleep(2);
//空指针(野指针)
int *p = nullptr;
*p = 10;
}
return 0;
}
运行结果
OS怎么知道空指针异常??
我们必须知道的是,当我们要访问一个物理内存时,一定要先经过页表的映射,将虚拟地址转换成物理地址,然后才能进行相应的访问操作
在从虚拟地址映射到物理地址的过程中,必须经过页表,页表上有一个硬件叫做MMU,MMU被集成在CPU里面,MMU用于计算映射关系,在MMU算出物理内存的映射关系之后,CPU可以直接进行物理内存访问
当我们要访问不属于我们的虚拟地址时(访问空地址,空指针的使用,空地址是不允许访问的),MMU在进行虚拟地址到物理地址的转换时就会出现错误,MMU就会出现异常,OS在这时也会识别这个异常,然后OS就会向目标进程发送11号信号SIGSEGV,该信号的默认动作就是终止进程
死循环捕捉信号,解释与上面除0的类似,不解释了
2.5 由软件条件产生信号
SIGPIPE信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到13号信号SIGPIPE 进而被操作系统终止
该信号的默认行为是 Term,也是终止进程,这个就解释到这
下面介绍 alarm函数 和 SIGALRM信号
alarm 函数的作用是:设定一个闹钟,也就是告诉内核在 seconds秒之后给当前进程发 SIGALRM信号, 该信号的默认处理动作是终止当前进程
man 2 alarm 查看
函数:alarm
头文件:
#include <unistd.h>
函数原型:
unsigned int alarm(unsigned int seconds);
参数:传入一个时间,单位秒
返回值:
若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置
如果调用alarm函数前,进程没有设置闹钟,则返回值为0
测试代码
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
//1秒后才发送信号
alarm(1);
int cnt = 0;
while(true)
{
cout << "cnt: " << cnt << endl;
cnt++;
}
return 0;
}
运行结果
下面进行捕捉该信号
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
int cnt = 0;
//自定义行为
void handler(int signo)
{
cout << "捕捉到一个信号,该信号编号是:" << signo << endl;
cout << "cnt: "<< cnt << endl;
exit(1);//自定义行为,退出进程
}
int main()
{
signal(14, handler);
//1秒后才发送信号
alarm(1);
while(true)
{
cnt++;
}
return 0;
}
运行结果
14号信号默认动作是 Term,也是终止进程
两次cnt实验结果数据级别相差较大,由此也证明了,与计算机单纯的计算相比较,计算机与外设进行IO时的速度是非常慢的
注意:9号信号不支持捕捉,这是禁止的,这是一个管理员信号
信号产生,完结,下一篇进入信号保存和处理
--------------------- END ----------------------
「 作者 」 枫叶先生
「 更新 」 2023.4.4
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
或有谬误或不准确之处,敬请读者批评指正。