🌎 Linux信号详解
文章目录:
🚀 信号入门
现实生活中的信号:
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能"识别快递"
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成"在合适的时候去取"。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你"记住了有一个快递要去取"
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品) 2. 执行自定义动作(快递是零食,你要送给你你的女朋友) 3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
- 快递到来的整个过程,对你来讲是 异步 的,你不能准确断定快递员什么时候给你打电话
🚀技术应用角度的信号
用户输入命令,在Shell下运行一个前台进程,用户键盘输入 Ctrl C
(2号信号)则会产生一个硬件中断,被OS获取,解释成为信号,发送给目标前台进程,前台进程收到信号之后,引起进程退出。
运行程序,生成前台进程,我们使用信号杀死前台进程:
注意:
- Ctrl-C 产生的信号只能发给前台进程 。一个命令后面加个
&
可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。 - Shell可以 同时运行 一个前台进程 和任意 多个后台进程,只有 前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
- 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是 异步(Asynchronous) 的。
🚀信号及信号的产生
✈️信号的概念
信号是进程之间事件异步通知的一种方式,属于 软中断。
Linux中存在许多信号,我们可以使用 kill -l
命令查看Linux中有哪些信号:
Linux中,有 62种信号 ,前31种(1~31)信号被称为 标准信号 ,每个信号都有特殊的含义及用途。后31种(34~64)信号被称为 实时信号 ,可用于实时应用程序的拓展信号,提供了更多的灵活性。注意中间并没有32和33号信号。
我们可以通过man手册来查询不同信号的含义 man 7 signal
:
并且每个信号的编号都有自己的名字,这些 名字 其实就 是 C 语言的 宏,如果调用信号,既可以通过信号的名称调用,也可通过信号的编号调用。当然,这么多的信号并不需要你全部记下来,我们在运用的过程中就会知道哪些信号常用,哪些不常用。
✈️信号的处理方式
信号的 常见处理方式 有以下几种:
- 忽略此信号。
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
我们以信号的方式来终止进程,我们通过一下代码进行测试:
c
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
int main()
{
while(true)
{
std::cout << "I'am a proc, I'm running now..., pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
我们使用9号信号和2号信号杀死了进程,这种发命令做出对应行为的方式 就是进程执行了信号的默认动作。好似阿熊在十字路口知道红灯停、绿灯行一样。
但是今天我们以 信号捕捉 的方式来处理发来的信号,我们就需要用到 signal 接口:
cpp
typedef void (*signhandler_t)(int);
signhandler_t signal(int signum, sighandler_t handler);
- signum参数 :传入需要捕捉的信号(名字或编号),当进程收到与其相匹配的信号时则会调用第二个参数,否则不会有任何动作。
- handler参数 :handlder方法,此方法为自定义方法,当收到signum信号则不会执行该信号的默认动作,变为执行该方法。
- 返回值 :返回前一个信号处理方法。
值得注意的是,我们在设置信号捕捉时,并不需要将此接口放入循环之中,只需要调用该接口一次,在整个程序中则一直循环有效。我们设置一个signal方法做测试:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
void handler(int signo)// 回调函数,收到信号则执行handler方法
{
std::cout << "get a sig, number is: " << signo << std::endl;
}
int main()
{
signal(SIGINT, handler);// 设置信号捕捉
while(true)
{
std::cout << "I'am a proc, I'm running now..., pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
signal函数执行时并不会立刻对handler执行回调,只有当收到对应的信号时,才会执行回调。比如,阿熊跟室友打赌输了,接下来一周等红绿灯需要听室友的,本来红灯停变为了红灯唱歌,但是我们并不知道室友什么时候让阿熊唱歌。也就是说,因为 信号是异步触发,所以只有收到信号时才会执行回调。
信号的默认处理方式还剩下忽略信号,也就是信号发送到进程,但是进程对其不管不顾。在程序中,使用捕捉的方式实现信号忽略,那么signal第二个参数应该调用 SIG_IGN(signal ignore):
在底层,SIG_IGN 是将整形1强转为 __sighandler_t 类型,于是就可以告诉OS,进程运行时以忽略的方式将 signal 函数的第一个参数忽略:
✈️信号的产生方式
🚩键盘产生信号
上面我们已经将以kill命令产生信号的方式介绍完了,而信号的产生方式还有一种,键盘产生信号,我们在上面也说了,使用 Ctrl-C 的方式可以终止进程,这就是一种键盘产生信号的方式,键盘产生信号的流程:
键盘特定输入 ------> OS解释为信号 ------> 向目标进程发送信号 ------> 进程收到信号 ------> 进程做出响应
当我们把二号信号进行捕捉,并且回调函数只对信号进行打印动作,我们再使用 Ctrl-C 就杀不死该进程了:
而键盘中并不只有 Ctrl-C 组合,还有 Ctrl-\ 组合,这里我使用的就是 Ctrl-\ 退出进程的,其对应信号的 3号信号。
🚩系统调用产生信号
除了键盘产生信号,我们还可以使用系统调用产生信号,Linux中存在 kill 接口:
c
int kill(pid_t pid, int signo);
- 功能 :给指定的进程发送指定的信号。
- pid参数 :传入进程pid。
- signo参数 :传入对进程发送的信号。
- 返回值 :发送成功返回0,否则返回-1,并设置错误码。
我们编写一段代码测试kill调用:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
int main(int argc, char* argv[])
{
if(argc != 3)// 参数控制为三个
{
std::cout << "Usage: " << argv[0] << " -signumber pid" << std::endl;
return 1;
}
// argv[0]: ./testSig 运行可执行程序
// argv[1]: 发送的信号
// argv[2]: 进程pid
int signumber = std::stoi(argv[1]);// argv[1]
int pid = std::stoi(argv[2]);// argv[2]
int n = kill(pid, signumber);
if(n < 0)
{
std::cerr << "kill error, " << strerror(errno) << std::endl;
}
return 0;
}
除了kill 系统调用之外,Linux还提供了一个 raise 接口:
c
int raise(int sig);
- 功能 :传入信号给当前进程(调用此接口的进程)。
- sig参数 :需要传入的信号。
- 返回值 :0表示成功,非0为失败。
我们编写一段代码测试raise调用:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cerrno>
#include <string>
#include <cstring>
#include <sys/types.h>
int main(int argc, char* argv[])
{
int cnt = 0;
while(true)
{
std::cout << "cnt: " << cnt++ << std::endl;
sleep(1);
if(cnt == 5)// 5s 后退出进程
{
std::cout << "send 9 signal to caller" << std::endl;
raise(9);
}
}
return 0;
}
除了raise系统调用之外,Linux还提供了一个 abort 接口:
c
void abort(void)
- 功能 :对自己发送指定的信号,为6号信号 SIGABRT。
我们编写一段代码测试abort调用:
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cerrno>
#include <string>
#include <cstring>
#include <sys/types.h>
int main(int argc, char* argv[])
{
int cnt = 0;
while(true)
{
std::cout << "cnt: " << cnt++ << std::endl;
sleep(1);
if(cnt == 5)// 5s 后abort进程
{
std::cout << "send 6 signal to caller" << std::endl;
abort();
}
}
return 0;
}
🚩软件条件产生信号
我们曾经学过管道,而管道四种特性有一种是 当没有读端时,写端会被终止。管道不具备写的软件条件了,所以触发了终止信号,这种信号为 SIGPIPE(13号信号)。
除此之外,由软件条件产生的信号还有 alarm 函数 和 SIGALRM(14号信号) 信号:
c
unsigned int alarm(unsigned int seconds);
- 功能 :用于试着进程闹钟,指定时间(以秒为单位)后,向调用它的进程发送 SIGALRM 信号。
- seconds参数 :表示在多少秒后发送14号新号,如果为0,则任何未响应的 闹钟被取消。
- 返回值 :无符号整形,表示上次设置的闹钟还剩余的秒数。之前未设置闹钟,则返回0。
我们在代码上直观感受一下闹钟的相应:
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cerrno>
#include <string>
#include <cstring>
#include <sys/types.h>
int main(int argc, char* argv[])
{
// 设置一个闹钟
alarm(1);
int cnt = 0;
while(true)
{
std::cout << "cnt: " << cnt++ << std::endl;
}
return 0;
}
大概有3万七千次左右,虽然我们云服务器配置不高,但是3万7千次未免太少了。我们把打印信息注释掉,并且设置一个全局变量,让其在循环内一直做++,对14号信号再进行捕捉,捕捉回调方法打印全局变量:
这次运行居然有5亿多次累加,至于为什么我们前面打印次数如此的少,这里我给出两个原因:
1.在Linux下,一切皆文件,前面对屏幕打印文字的行为,本质上是对显示器文件进行疯狂打印。在前后对比下,我们能直观的发现其实 IO很慢 。
2.由于我们是使用了云服务器,真正运行并不在本地运行,我们向云服务器发送命令,以及云服务器将信息从远端发到本地,都是经过网络的。
闹钟在被设置的时候,其默认动作只会响一次!如果我们想要设置多个闹钟,我们可以在回调handler方法里再加上n秒的闹钟,这样,第一次闹钟响了之后,进程收到闹钟信号执行回调方法,而main函数是被循环卡死的,所以往后就每隔n秒响一次闹钟。
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cerrno>
#include <string>
#include <cstring>
#include <sys/types.h>
void handler(int signo)// 收到闹钟信号进行回调
{
std::cout << "get a sign: " << signo << std::endl;
int n = alarm(2);
// exit(0);
}
int main(int argc, char* argv[])
{
signal(SIGALRM, handler);
// 设置一个闹钟
alarm(4);
int cnt = 0;
while(true)
{
sleep(1);
std::cout << "cnt: " << cnt++ << std::endl;
}
return 0;
}
如果我们设定一个闹钟需要很久之后才会响应,但是我在此期间发送14号信号提前对SIGALRM进行捕捉,执行handler方法的回调,返回值是什么呢?
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cerrno>
#include <string>
#include <cstring>
#include <sys/types.h>
void handler(int signo)// 收到闹钟信号进行回调
{
std::cout << "get a sign: " << signo << std::endl;
unsigned int n = alarm(2);
std::cout << "闹钟剩余时间: " << n << std::endl;
// exit(0);
}
int main(int argc, char* argv[])
{
signal(SIGALRM, handler);
// 设置一个闹钟
alarm(100);
int cnt = 0;
while(true)
{
sleep(1);
std::cout << "cnt: " << cnt++ << ", pid is: " << getpid() << std::endl;
}
return 0;
}
我们只对闹钟进行了一次kill信号,我们第一个闹钟设置了100s,而提示信息是每隔一秒打印一次,算下来刚好过去5s,打印出的返回值为95s,而第二次回调的时候闹钟剩余时间就变为0了,这也就证实了alarm接口返回值是上一次闹钟剩余时间。
接下来我来解释一下为什么闹钟也能作为软件条件?我们知道,alarm接口是系统调用接口,也就是说,设定闹钟,实际上是在操作系统内部设定的。而操作系统中存在的闹钟定然不止一个,所以OS一定要对这些闹钟做管理,如何管理?先描述,再组织!
根据以往经验,闹钟一定是有自己的结构体,结构体内有着必要的属性成员,那么在结构体的属性成员当中,就必定有闹钟的过期时间成员,用来记录闹钟的过期时间!
c
struct alarm
{
uint64_t expired_time;// 闹钟过期时间
// ...其他属性字段
}
而闹钟的过期时间实际上是一个时间戳,一个线性增长的时间。那我们应该以什么样的结构组织起来这些闹钟呢?经常看我博客的小伙伴第一反应很可能是链表。设置一个双链表,按照闹钟过期时间来排序,之后我只要找到第一个过期的闹钟,那么在此之后必然全部都是过期闹钟。
虽然这种想法很好,但是我们有更优解,我在很早之前写过一篇博客:堆与堆排序, 而操作系统就是采用最小堆的方法组织闹钟结构!以最小堆的堆顶一定是最近一次即将超时的闹钟。所以往后,OS只需要查找堆顶元素,过期了就释放掉,再通过堆调整,将次要过期的闹钟调整到堆顶。
异常产生信号
第五种信号的产生方式,程序出了异常,操作系统定然不会在放任这个问题进程不管,会采取一定的措施,OS为了能让程序员知道程序出了问题,于是设置了一些常出现的异常信号,当进程出现异常时,OS将会对进程发送异常信号,爆出异常缘由。
异常并不一定是由程序的语法、逻辑问题带来的,可很有可能是外部设备出了问题,所以异常又被分为 软件异常 和 硬件异常。
软件异常通常有除零错误或者溢出错误引起的。而硬件异常通常是有进程访问无效地址引起的,一般有段错误等。具体情况具体分析,我们先对除零错误进行模拟:
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cerrno>
#include <string>
#include <cstring>
#include <sys/types.h>
int main(int argc, char* argv[])
{
int a = 10;
a /= 0;
while(1) sleep(1);
return 0;
}
除零错误会在Shell上爆出 Floating point exception 错误信息,并且除零错误对应的信号是 SIGFPE(8号信号)。我们再来模拟一下野指针异常:
c
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cerrno>
#include <string>
#include <cstring>
#include <sys/types.h>
int main(int argc, char* argv[])
{
int* p = nullptr;
*p = 50;
return 0;
}
野指针异常通常会爆 Segmentation fault 错误,想必学C/C++的小伙伴对这种报错会经常见到,而其对应的异常信号为 SIGSEGV(11号信号)。
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
🚀对信号产生方式的理解
✈️键盘产生信号
键盘产生数据毫无问题,但是操作系统是如何对组合键做出特殊处理的,OS怎么知道我使用Ctrl C
就是要发送2号信号给进程呢?我们都知道,操作系统向下管理软硬件资源,那么键盘硬件资源当然也属于操作系统管理范畴。
所以,我们从键盘输入组合键,是由操作系统说的算的,而你使用的组合键是被OS解释为了命令。输入方在与键盘,解释方为操作系统,也就是说,键盘输入的到底是普通数据还是特殊命令,是由键盘驱动和OS联合解释的。
这么看来操作系统只需要解释我们输入的信息即可,但是对于OS来说,用户输入的动作一定是异步的。而我们在日常使用键盘的时候,操作系统似乎是优先处理的。这样对于操作系统的压力是不是有些大了?要知道OS可不止需要监测键盘,还有其他异步硬件,网卡就是典型的例子。OS并不能保证何时会不会有信息从网络发来。
显然操作系统如果一直对这些异步发生的硬件进行监视的话,会将操作系统的性能拉低,而操作系统存在的意义就是,向下对软硬件资源管理,向上为用户提供良好服务。所以定然不会无时无刻监视这些硬件,于是就有人提出了 硬件中断技术。
早在我们电脑开机的时候,操作系统就给我们生成了一张 中断向量表 ,这张表提前注册对软硬件资源操作的方法。比如此表的二号下标的方法就是用来读取键盘输入的数据。而所谓的中断向量表实际上是一个 函数指针数组。那么其究竟是如何实现通过程序来访问硬件资源的呢?
通过冯诺依曼结构,我们知道,CPU在数据层面只和内存级打交道。但是在每个CPU上都存在许多的针脚,这些针脚是物理性的,在主板上可以和各个硬件相连接,包括键盘也是通过CPU的针脚连接的。每个针脚都有自己的编号。而未来我们在按键盘时,通过针脚,使 CPU触发硬件中断 !当然也没这么简单,他们之间可能存在 8259可中断控制器,这个我们目前不需要了解。
不管如何,键盘和CPU可以通过针脚相互连接,而用户在键盘上输入数据时(发送高电平),就会触发硬件中断,此时CPU就可以检测到这个针脚有高电平,从而识别到键盘。而这时,CPU中的寄存器会将中断号(针脚编号)保存在寄存器内部,至此,硬件的动作就完成了!
寄存器收到中断号后,被操作系统检测到,此时操作系统就会停下手头的工作。拿着这个中断号从中断向量表中查询(中断号就是中断向量表的下标索引)对应处理键盘资源的方法,进而调用这个方法去收集键盘发来的数据了。于是就可以把从键盘输入的数据读取到内存当中了。严格意义上来说,键盘文件也是文件,OS会先将数据读入到键盘文件的缓冲区里。
读取到的键盘数据经过操作系统对字符的判定,判定为数据则发送到当前进程打开的键盘文件缓冲区中,而被判定为控制命令的组合键,则会被解释为信号,比如我输入了Ctrl C,那么OS就会将其解释为2号信号,而并非普通字符信息。这时,这个信号就会发送给调用键盘文件的进程,从而执行对应的动作。
那么操作系统如何解释控制命令?实际上,信号在到来时,我们在处理更重要的事情,暂时不能处理到来的信号,所以我们一定需把信号保存到PCB中 !要知道信号可是有整整62种,一个进程可能会存在多个信号,所以OS定要对这些信号做管理,如何管理?先描述,再组织!
而这些信号则是由位图这结构描述组织的!并且这个位图只需要32位比特位,因为标准信号只有31种,所以32位比特位完全够用。如果用户通过键盘对当前用户输入了Ctrl C,则会被操作系统解释为2号信号,通过位图,将第对应的比特位由0置1即可完成OS对进程发送信号。也就是说,操作系统向进程发送信号的本质是对进程PCB的位图进行写入操作!
✈️异常信号的理解
前面出现了 除零错误(SIGFPE),以及野指针错误(SIGSEGV)都属于异常产生的信号,首先我们来分析除零错误。
计算错误:
除零错误,实际上也就是计算错误,在硬件方面,计算错误表现在CPU的寄存器上,我们知道,程序的计算都是在寄存器内完成的,寄存器可以存贮少量数据,而当计算发生错误,CPU停止对进程的操作,转而告诉操作系统当前处理的进程发生了计算错误。
在CPU中存在一个标志寄存器,EFLAGS/RFLAGS标志寄存器 ,其 用于检测有符号数运算是否发生了溢出 。这个寄存器存在一个名为 OF(OverFlow) 的标志位。当发生溢出错误时,OF被置为1,否则被置为0。
操作系统收到CPU发来的信息,发现进程不再被调度了,于是操作系统就会检查EFLAGS/RFLAGS寄存器的溢出标记位OF,从而检测出当前进程出了计算异常,于是 OS就对当前进程发送(向pcb内写入)8号信号!
当我们对除零错误的异常信号进行捕捉,并且保持进程一直在运行状态:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cerrno>
#include <string>
#include <cstring>
#include <sys/types.h>
void handler(int signo)
{
std::cout << "get a sig: " << signo << std::endl;
}
int main(int argc, char* argv[])
{
signal(SIGFPE, handler);
int a = 10;
int b = 0;
a = a / b;
while(true) sleep(1);
return 0;
}
我们把异常信号的默认执行方式进行了捕捉,从而执行我们的handler方法回调。而handler方法我们仅仅打印了一句话,所以这个异常信号依旧存在。我们都知道,在CPU中寄存器只有一套,而 寄存器的数据可以有很多 ,这些数据我们称为 进程的上下文数据。
但是当前进程被我们设置为一直在运行,异常在进程中仍然存在,这个时候OS又会向OF读取异常数据,进而再一次的对当前进程发送8号信号,这样不断的循环,就导致了上图的结果。
野指针异常:
野指针异常是因为非法访问地址,而我们通过之前的学习,我们知道野指针内的地址一定是虚拟地址,而虚拟地址要映射到物理内存需要经过 由OS和CPU(MMU) 的 转化。而既然存在转化,就一定存在转化成功或者失败,我们来讨论一下转化失败的情况。
在CPU中还存在两个很重要的寄存器:CR2 和 CR3 寄存器,其中cr2寄存器 用于存储导致页表映射错误的虚拟地址 。而cr3寄存器用于 存储页表的基地址,指向当前页表 。现代电脑上的 MMU单元 都是被集成的CPU上的,其用于 虚拟到物理地址之间的转换。
当程序中发生了野指针错误,比如对空地址解引用赋值。此时当CPU执行到该语句的时候,会将指针内保存的虚拟地址由MMU和OS经过页表转化为物理地址,但是在转化的时候,页表中可能没有该虚拟地址的映射或者该映射的物理地址不可被写入(无w权限)。此时MMU就会转化失败,于是就向cr2寄存器写入出错的虚拟地址,cr2将虚拟地址保存。
当进程出现了野指针异常时,当前进程就会停止调度,OS就会来检查为何当前进程停止调度,而CPU对cr2寄存器进行读取,发现当前进程出现了野指针错误,于是OS就对当前进程发送11号信号(SIGSEGV)从而终止进程!
总结:
所以产生信号不论是系统调用还是软件条件,亦或者是键盘、异常产生的信号,都是由操作系统同一发送的,因为OS作为软硬件资源的管理者,当进程出现异常时,需要对进程做相应的处理,这也就是为什么我们在windows下运行一些带有错误的程序时,进程会直接终止。
今天的文章到此结束,感谢您的观看,如果对您有帮助的话还望给博主一个小小的三连呀~~