Linux信号详解
信号入门
技术应用角度的信号
信号及信号的产生
信号的概念
信号的处理方式
信号的产生方式
键盘产生信号
系统调用产生信号
软件条件产生信号
异常产生信号
对信号产生方式的理解
键盘产生信号
异常信号的理解
现实生活中的信号:
用户输入命令,在Shell下运行一个前台进程,用户键盘输入 Ctrl C
(2号信号)则会产生一个硬件中断,被OS获取,解释成为信号,发送给目标前台进程,前台进程收到信号之后,引起进程退出。
运行程序,生成前台进程,我们使用信号杀死前台进程:
注意:
&
可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。信号是进程之间事件异步通知的一种方式,属于 软中断。
Linux中存在许多信号,我们可以使用 kill -l
命令查看Linux中有哪些信号:
Linux中,有 62种信号,前31种(1~31)信号被称为 标准信号,每个信号都有特殊的含义及用途。后31种(34~64)信号被称为 实时信号,可用于实时应用程序的拓展信号,提供了更多的灵活性。注意中间并没有32和33号信号。
我们可以通过man手册来查询不同信号的含义 man 7 signal
:
并且每个信号的编号都有自己的名字,这些 名字 其实就 是 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 接口:
typedef void (*signhandler_t)(int);
signhandler_t signal(int signum, sighandler_t handler);
值得注意的是,我们在设置信号捕捉时,并不需要将此接口放入循环之中,只需要调用该接口一次,在整个程序中则一直循环有效。我们设置一个signal方法做测试:
#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 的方式可以终止进程,这就是一种键盘产生信号的方式,键盘产生信号的流程:
当我们把二号信号进行捕捉,并且回调函数只对信号进行打印动作,我们再使用 Ctrl-C 就杀不死该进程了:
而键盘中并不只有 Ctrl-C 组合,还有 Ctrl-\ 组合,这里我使用的就是 Ctrl-\ 退出进程的,其对应信号的 3号信号。
除了键盘产生信号,我们还可以使用系统调用产生信号,Linux中存在 kill 接口:
int kill(pid_t pid, int signo);
我们编写一段代码测试kill调用:
#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 接口:
int raise(int sig);
我们编写一段代码测试raise调用:
#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 接口:
void abort(void)
我们编写一段代码测试abort调用:
#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号信号) 信号:
unsigned int alarm(unsigned int seconds);
我们在代码上直观感受一下闹钟的相应:
#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秒响一次闹钟。
#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方法的回调,返回值是什么呢?
#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一定要对这些闹钟做管理,如何管理?先描述,再组织!
根据以往经验,闹钟一定是有自己的结构体,结构体内有着必要的属性成员,那么在结构体的属性成员当中,就必定有闹钟的过期时间成员,用来记录闹钟的过期时间!
struct alarm
{
uint64_t expired_time;// 闹钟过期时间
// ...其他属性字段
}
而闹钟的过期时间实际上是一个时间戳,一个线性增长的时间。那我们应该以什么样的结构组织起来这些闹钟呢?经常看我博客的小伙伴第一反应很可能是链表。设置一个双链表,按照闹钟过期时间来排序,之后我只要找到第一个过期的闹钟,那么在此之后必然全部都是过期闹钟。
虽然这种想法很好,但是我们有更优解,我在很早之前写过一篇博客:堆与堆排序, 而操作系统就是采用最小堆的方法组织闹钟结构!以最小堆的堆顶一定是最近一次即将超时的闹钟。所以往后,OS只需要查找堆顶元素,过期了就释放掉,再通过堆调整,将次要过期的闹钟调整到堆顶。
第五种信号的产生方式,程序出了异常,操作系统定然不会在放任这个问题进程不管,会采取一定的措施,OS为了能让程序员知道程序出了问题,于是设置了一些常出现的异常信号,当进程出现异常时,OS将会对进程发送异常信号,爆出异常缘由。
异常并不一定是由程序的语法、逻辑问题带来的,可很有可能是外部设备出了问题,所以异常又被分为 软件异常 和 硬件异常。
#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号信号)。我们再来模拟一下野指针异常:
#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号信号)。
键盘产生数据毫无问题,但是操作系统是如何对组合键做出特殊处理的,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号信号!
当我们对除零错误的异常信号进行捕捉,并且保持进程一直在运行状态:
#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对cr2寄存器进行读取,发现当前进程出现了野指针错误,于是OS就对当前进程发送11号信号(SIGSEGV)从而终止进程!
总结:
所以产生信号不论是系统调用还是软件条件,亦或者是键盘、异常产生的信号,都是由操作系统同一发送的,因为OS作为软硬件资源的管理者,当进程出现异常时,需要对进程做相应的处理,这也就是为什么我们在windows下运行一些带有错误的程序时,进程会直接终止。
今天的文章到此结束,感谢您的观看,如果对您有帮助的话还望给博主一个小小的三连呀~~