目录
- 一、快速认识信号
-
- [1.1 生活中的信号](#1.1 生活中的信号)
- 二、产生信号
-
- [2.1 信号的生命周期](#2.1 信号的生命周期)
- [2.2 见到信号](#2.2 见到信号)
- [2.3 signal 函数](#2.3 signal 函数)
- [2.4 信号的产生方式](#2.4 信号的产生方式)
- [2.5 关于除0或者野指针](#2.5 关于除0或者野指针)
- [2.6 再谈除0或者野指针](#2.6 再谈除0或者野指针)
- [2.7 系统调用产生信号](#2.7 系统调用产生信号)
-
- [2.7.1 kill](#2.7.1 kill)
- [2.7.2 raise](#2.7.2 raise)
- [2.7.3 abort](#2.7.3 abort)
- [2.8 软件条件条件产生信号](#2.8 软件条件条件产生信号)

个人主页:矢望
个人专栏:C++、Linux、C语言、数据结构、Coze-AI、MySQL
一、快速认识信号
1.1 生活中的信号
我们的日常生活中有很多我们认识的信号,例如红绿灯、闹钟、手机铃声、敲门声等等。这些信号产生之后,你是知道如何处理它的,例如响起敲门声,你就知道去开门。这些信号的处理方法,其实在信号产生之前就已经准备好了 。如何准备的呢?是日积月累的生活经验训练我们,在我们的大脑中构建了信号产生 和信号处理之间的映射。
此外,我们收到特定信号之后,不一定立即处理它,例如外卖小哥给你打电话,但你在上课,所以你只好等会儿再处理。因为你的手头有优先级更高的事情(上课)。也就是说信号产生之后会在合适的时候处理。在合适的时候处理的前提是需要将产生的信号保存下来。
所以我们人对信号的认识阶段有:认识信号、识别信号产生、动作处理信号这几个阶段。而对进程而言,识别信号是内置的,进程能够识别信号是内核程序员写的内置特性 。进程对信号的处理方式也早就有了,处理方式是可改变的。
信号的处理方式有三种 :1、默认处理 ,例如别人和你打招呼,你也和他打招呼。2、忽略 ,例如闹钟响了,你不管它继续睡觉。3、自定义动作,例如别人和你打招呼,你看着他,然后做了一个后空翻。
信号是外部或者其他人或者硬件给进程发送的一种异步的事件通知机制 。通知机制 :告诉进程发生了什么事情。异步 :多种事件,彼此不影响,同时发生。事件,如终止、异常等。
二、产生信号
2.1 信号的生命周期

2.2 见到信号
这里编写一个死循环的程序。
cpp
#include <iostream>
#include <unistd.h>
int main()
{
while(true)
{
std::cout << "Running ... , pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
运行测试 :

如上图,进程正在运行,收到二号信号之后进程就停止了。
查询所有信号kill -l 。

上面就是所有的信号,这些信号都是一个个的宏定义,例如#define SIGINT 2。

注意没有0号信号。另外,1~31为普通信号,34~64为实时信号。
2.3 signal 函数
上面的程序中,程序确实终止了,但是如何证明它是收到二号信号终止的呢?
Linux给我们提供了一个函数,signal 是一个系统调用,用来设置某个信号到达时,进程应该怎么做。

signal 就是用来改变收到信号之后的默认处理行为的,用自定义动作替换之前的默认处理动作。
这个函数的第一个参数就是要改变默认处理行为的信号是谁,第二个就是要进行自定义处理的函数。
函数的返回值也是函数指针类型,它返回的是这个信号之前的处理方式,也就是可以恢复到原来的处理方式。
编写代码:
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int signo)
{
std::cout << "收到了一个信号:" << signo << std::endl;
}
int main()
{
signal(SIGINT, handler);
while(true)
{
std::cout << "Running ... , pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
注意 :signal函数调用一次就可以了,不需要重复调用,所以放在开头处即可。一旦设置好,这个配置会一直生效,直到你再次调用signal修改它。
另外handler函数之所以有一个int类型的参数,是为了表明收到了哪个信号,用来区分是哪个信号触发了调用,因为可能有多个信号被自定义。
运行测试 :

如上图,我们发现在发送二号信号的时候,进程确实收到了二号信号,为什么进程没有终止呢? 因为默认2号信号是终止进程,但是自定义处理信号了,你在handler函数中没有写终止进程的逻辑,所以进程不再终止了。
另外,我们看到ctrl+c之后,进程也收到了2号信号,所以ctrl+c之所以能够终止进程,是因为ctrl+c通过键盘向目标进程发送了2号信号,默认处理动作就是终止进程。
几个细节:
1、细节1:信号自定义捕捉着后,进程受到相关的信号之后,如果你不对信号做退出进程处理,那么进程就可能不会退出了。如果将所有的信号都自定义捕捉且不退出,那进程还能退出吗?
cpp
void handler(int signo)
{
std::cout << "收到了一个信号:" << signo << ' ' << getpid() << std::endl;
}
int main()
{
for(int signumber = 1; signumber <= 31; signumber++)
signal(signumber, handler);
while(true)
{
std::cout << "Running ... , pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
如上代码,普通信号一共31个,我们对这31个信号都进行自定已处理,再次运行,并且对进程发送相关信号。

如上图,我们对信号进行自定义捕捉之后,再次收到信号,进程就不再退出了。
那还能有信号关闭进程吗? 如果这个进程是一个病毒进程怎么办,所以相关的人员考虑到了这个问题,有一个信号是9号信号,它是管理员信号,它不可以被自定义。
此时我们可以对进程发送9号信号,杀死进程。

2、细节2:信号处理是谁在处理呢?有没有创建新的进程呢?
从上面的第一个运行结果,我们看到自定义处理信号时的进程pid和正在运行时的进程的pid相同都是621084,所以信号处理是进程自己做的。
3、细节3:signal函数有两个选项一个是SIG_DFL,一个是SIG_IGN。

SIG_IGN是信号忽略,signal(signumber, SIG_IGN);表示忽略signumber信号,而SIG_DFL是信号的默认处理方式;signal(signumber, SIG_DFL);表示处理signumber信号时采用默认系统的处理方式,不再是自定义方式。
2.4 信号的产生方式
我们目前已知的有使用kill命令产生信号,还有使用键盘产生信号 。
其中使用键盘产生信号时,ctrl+c是向目标进程发送2号信号,默认动作是终止进程,而ctrl+\是向进程发送3号信号默认动作是终止进程,ctrl+z是向进程发送20号信号默认就是暂停进程。

如上图,它们都可以被捕获。而我们有两个信号不可以被捕获/忽略,就是9号和19号。

如上图,19号信号和20号信号一样也是暂停进程,但它不可以被捕获或者忽略。
如何知道信号的默认处理动作呢? 可以通过man 7 signal命令查看,往下翻你会发现一个表格。

将之前的代码注释掉:
cpp
void handler(int signo)
{
std::cout << "收到了一个信号:" << signo << ' ' << getpid() << std::endl;
}
int main()
{
signal(SIGINT, handler);
// for(int signumber = 1; signumber <= 31; signumber++)
// signal(signumber, handler);
while(true)
{
std::cout << "Running ... , pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
运行之后,向它发送ctrl+z也就是20号信号,它就会暂停。

暂停之后,我们可以向它发送18号信号,此时就可以让它再次运行,只不过此时它就默认变成后台进程了。

变成后台进程之后,如上图,它后面的+号就没有了。

如上图,使用键盘产生的信号只能控制前台进程,当它变成后台进程之后,键盘产生的信号就无法控制它了,因为只有前台进程才能获取键盘输入,这时候可以使用kill -9杀掉它。
细节 :为什么bash进程自己不对信号做响应呢?

因为bash忽略了所有的信号,但是9号信号是不可以被捕获或忽略的,所以我们可以使用9号信号杀死bash。

2.5 关于除0或者野指针
我们知道当我们的程序发生div0或者野指针时,程序就会崩溃,可是它为什么会崩溃呢?本质是因为程序出现了异常然后让进程终止的。
接下来,我会在程序中模拟div0和野指针错误,然后编译运行程序。
首先是div0:
cpp
int main()
{
while(true)
{
std::cout << "Running ... , pid: " << getpid() << std::endl;
sleep(1);
// 模拟除0
int a = 10;
a /= 0;
}
return 0;
}
编译运行:

细节:如果觉得warning不好看,可以在编译时加上-w选项,忽略告警。当然这种做法并不推荐。
接下来是野指针:
cpp
int main()
{
while(true)
{
std::cout << "Running ... , pid: " << getpid() << std::endl;
sleep(1);
// // 模拟除0
// int a = 10;
// a /= 0;
// 模拟野指针
int* p = nullptr;
*p = 100;
}
return 0;
}
编译运行:

它们这两个错误,是由于异常,然后div0收到的是SIGFPE(8号)信号,野指针收到的是SIGEGV(11号)信号。

如何证明呢?我们可以signal对这两种信号进行捕获。
对野指针进行捕获:
cpp
void handler(int signo)
{
std::cout << "收到了一个信号:" << signo << ' ' << getpid() << std::endl;
}
int main()
{
signal(SIGSEGV, handler); // 11号
while(true)
{
std::cout << "Running ... , pid: " << getpid() << std::endl;
sleep(1);
// // 模拟除0
// int a = 10;
// a /= 0;
// 模拟野指针
int* p = nullptr;
*p = 100;
}
return 0;
}
编译运行:
如上所示,野指针时,确实收到的是11号信号。
对div0信号进行捕获:
cpp
void handler(int signo)
{
std::cout << "收到了一个信号:" << signo << ' ' << getpid() << std::endl;
}
int main()
{
signal(SIGFPE, handler); // 8号
while(true)
{
std::cout << "Running ... , pid: " << getpid() << std::endl;
sleep(1);
// 模拟除0
int a = 10;
a /= 0;
}
return 0;
}
编译运行:

如上图,div0错误确实会收到8号信号。
那么为什么会收到信号呢?OS怎么知道当前进程发生除0或者野指针错误了呢?
进程能够收到信号,所以就需要对信号进行保存,信号就保存在进程的task_struct结构体中,普通信号一共31个,也就是[1, 31],它是使用位图保存起来的,也就是unsigned int sigs;二进制有32位,比特位的位置就是信号编号,比特位为0为1就表示进程是否收到该信号。
所以向目标进程发送信号的本质就是修改信号位图。发送信号就是写信号! 由于位图在task_struct结构体中,所以修改位图的本质就是修改task_struct内核数据结构,它只有OS才有权限进行修改,因此发送信号的方式有很多种,但是最终只能由OS向目标进程写信号,所以如果我们要写信号,OS就必须提供系统调用!
再次总结 div0或野指针都属于是硬件报错,而OS作为管理者,它就能够知道是硬件报错了,所以它就是找是谁把硬件搞坏了,此时它就知道是那个进程将它的硬件搞坏了,这个时候OS就会向相应的进程写信号终止进程。
详细介绍硬件报错和死循环问题:

如上图所示,当CPU进行除法运算时,eax寄存器会存储10,ebx寄存器会存储0,edx会存储eax/ebx的最终结果,而这个计算结果是否可信是由另一个寄存器记录的,这个寄存器是EFlags,它里面的OF标志位如果被设置为0,说明结果可信,被设置为1,说明出问题了。硬件这个时候就会报错,OS就知道硬件出错了,这个时候,OS就会找是那个进程引起的错误。
在Linux内核中,有一个struct task_struct *current指针,它会指向当前正在执行的进程,OS知道是当前进程引起的硬件错误,所以OS就会通过current指针找到相关进程,给它写信号终止掉进程。
那么为什么我们捕捉进程之后,会死循环打印消息呢? 因为寄存器里存储的内容是当前进程的硬件上下文,此时寄存器中的数据引起了硬件报错,而CPU执行到错误的代码之后,就不会向下走了,它会一直在这里等待处理错误,但是我们的进程收到相关信号之后,单单只是捕获到了信号,打印了一句消息,并没有终止进程,所以CPU再次回到该进程后,寄存器中的数据还是错误消息,因此,就会不断向进程发送信号,我们的进程就会循环打印了。
另外关于野指针报错,如上图,是虚拟地址经过cr3寄存器和MMU进行页表转化的时候,找不到相关的地址,因此就引发了MMU硬件报错。
为了验证我们的死循环打印,我们可以让进程收到信号之后终止进程,此时也就不会出现死循环打印的情况,此时进程终止了:
cpp
void handler(int signo)
{
std::cout << "收到了一个信号:" << signo << ' ' << getpid() << std::endl;
exit(10);
}
int main()
{
signal(SIGFPE, handler); // 8号
while(true)
{
std::cout << "Running ... , pid: " << getpid() << std::endl;
sleep(1);
// 模拟除0
int a = 10;
a /= 0;
}
return 0;
}
编译运行:

因为寄存器中的错误数据是当前进程的,而进程终止之后,寄存器中的错误数据自然就处理掉了。
2.6 再谈除0或者野指针
我们在之前进程退出时看到了core dumped,这个是什么呢?

如上图,进程异常退出,然后后面出现了core dumped,这个叫做核心转储。操作系统在进程收到特定信号(如SIGSEGV、SIGFPE、SIGQUIT等)并即将终止时,将该进程的用户空间内存映像(包括代码、数据、堆栈、寄存器状态等)完整复制并写入到磁盘文件(通常是core或pid.core)中的过程,以供事后调试分析 ,这就叫做核心转储。
简单而言就是为了方便调试debug,如何利用核心转储进行debug呢?
首先在云服务器上,默认情况下核心转储功能是关闭的 。

想要打开,就执行ulimit -c。

Ubuntu上现象不明显了,我们转到Centos再次编译运行:

如上图,此时就出现了core.xxx的文件,这个文件里面就是一堆的二进制信息。

那么如何进行调试呢?
我们编译时加上-g选项,然后cgdb启动,再然后core-file core.xxx就可以了,很方便。

如上,直接就指向了报错的地方。
那么这么好用为什么云服务器默认要把核心转储功能关闭呢? 很多公司里面在运行服务的时候,进程挂掉的时候损失是很大的,所以当公司里面的进程挂掉,公司里面会有人维护一个让进程迅速重启的服务,所以我们经常听到某某公司发生了多少多少秒的事故,这个时间就是重新启动进程的时间。而如果我们默认核心转储是开启的, 如果公司里面的进程是半夜挂掉的,然后它在一个挂掉重启,挂掉重启,那么就会不断形成core.xxx文件,这个文件会不断存储到磁盘中,等你明天一早发现时,说不定不仅是服务挂掉了,你的OS都挂掉了,核心转储文件把整个磁盘都塞满了。这就是为什么这个核心转储默认关闭。
那么我们怎么知道当前的进程发生了核心转储呢?或者说上面的core dumped那句话是谁打印的呢? 其实是bash打印的,bash在执行我们的代码时,当子进程发生异常,并且核心转储功能是开启的状态下,bash就可以知道这个子进程是否发生了核心转储。

如上图,bash可以通过子进程返回的信息中的core dump标志为0为1知道是否子进程发生了核心转储。
cpp
int main()
{
pid_t id = fork();
if(id == 0)
{
std::cout << "Running ... , pid: " << getpid() << std::endl;
// 模拟除0
int a = 10;
a /= 0;
exit(0);
}
int status = -1;
int n = waitpid(id, &status, 0);
printf("exit code: %d, exit signal number: %d, core dumped: %d\n", \
(status>>8)&0xFF, status&0x7F, (status>>7)&0x1);
return 0;
}
编译运行 :

如上图,当前核心转储功能是开启的,父进程知道子进程发生了核心转储。
2.7 系统调用产生信号
2.7.1 kill

它的参数第一个就是给哪个进程发,第二个就是发送什么。
我们对这个进行一下封装。
cpp
void Usage(const char* cmd)
{
printf("Usage:\n\t");
printf("%s signumber who\n", cmd);
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
int signumber = std::stoi(argv[1]);
pid_t pid = std::stoi(argv[2]);
int n = kill(pid, signumber);
(void)n;
return 0;
}
编译运行 :

这就是kill命令,给指定进程,发送指定的信号。
2.7.2 raise

这个命令的作用是谁调用我,我就给谁发sig信号。
cpp
void handler(int signo)
{
std::cout << "收到了一个信号:" << signo << ' ' << getpid() << std::endl;
}
int main(int argc, char* argv[])
{
signal(2, handler);
while (true)
{
std::cout << "进程正在运行: " << " pid: " << getpid() << std::endl;
sleep(1);
raise(2);
}
return 0;
}
编译运行 :

如上图,raise相当于kill(gitpid(), number);。
2.7.3 abort

这个函数是谁调用我,我就给谁发送指定的信号,它发送的是6号信号SIGABRT。

如何证明呢? 我们将这个6号信号捕获一下就可以验证了。
cpp
void handler(int signo)
{
std::cout << "收到了一个信号:" << signo << ' ' << getpid() << std::endl;
}
int main(int argc, char* argv[])
{
signal(6, handler);
while (true)
{
std::cout << "进程正在运行: " << " pid: " << getpid() << std::endl;
sleep(1);
abort();
}
注意,我们只是捕捉了信号,并没有做任何的操作。
编译运行:

如上图,这个信号就有所不同了,它确实可以被捕获,但是被捕获的同时,它还会执行它的默认处理方法,我们捕捉信号之后,并没有对信号做处理,但上图中还执行了信号的默认动作。
SIGABRT 的作用是让进程异常终止,通常由程序自己调用 abort() 或断言失败时触发,用于指示检测到内部逻辑错误,并可选地产生核心转储文件供调试。
2.8 软件条件条件产生信号
由软件条件产生信号 指的是:当程序运行过程中,某个"软件层面"的条件(而不是硬件故障,比如除零、野指针)被触发时,操作系统内核向该进程发送一个信号,以通知或中断它。
发送信号的方式有很多,围绕着用户、硬件、软件各种场景展开,但都是借助OS之手向目标进程写信号。
其中这里的软件条件我们了解一个alarm。

alarm的作用是向进程设置闹钟,闹钟的时间是seconds秒,时间到达之后,就会向调用alarm的进程发送SIGALRM(14号)信号。
代码示例:
cpp
int main(int argc, char* argv[])
{
alarm(1);
int cnt = 0;
while (true)
{
std::cout << "进程正在运行: " << " pid: " << getpid() << " cnt: "<< cnt++ <<std::endl;
}
return 0;
}
如上,我们给进程设置了1s的闹钟,编译运行:

如上,时间一到进程收到信号就停止了,这里cnt才到一万多是因为IO拖慢了速度。
接下来我们捕获一下信号:
cpp
void handler(int signo)
{
std::cout << "收到了一个信号:" << "signo: " << signo << ' ' << getpid() << std::endl;
}
int main(int argc, char* argv[])
{
signal(SIGALRM, handler);
alarm(2);
int cnt = 0;
while (true)
{
std::cout << "进程正在运行: " << " pid: " << getpid() << " cnt: "<< cnt++ <<std::endl;
sleep(1);
}
return 0;
}
编译运行:

如上图,确实在两秒之后收到一个信号,之后就没有再次收到了,所以alarm设定的是一次性闹钟。
那如果要让它常设闹钟应该怎么办呢? 可以在handler方法的末尾再次设置闹钟。
cpp
void handler(int signo)
{
std::cout << "收到了一个信号:" << "signo: " << signo << ' ' << getpid() << std::endl;
int n = alarm(2);
(void)n;
}
int main(int argc, char* argv[])
{
signal(SIGALRM, handler);
alarm(2);
int cnt = 0;
while (true)
{
std::cout << "进程正在运行: " << " pid: " << getpid() << " cnt: "<< cnt++ <<std::endl;
sleep(1);
}
return 0;
}
编译运行:

那么这个函数的返回值是什么呢? 返回值表明上一个闹钟的剩余时间!
我们可以设置一个长闹钟,然后使用14信号提前终止进程,再获取这个返回值。
cpp
void handler(int signo)
{
std::cout << "收到了一个信号:" << "signo: " << signo << ' ' << getpid() << std::endl;
int n = alarm(2);
(void)n;
// 获取返回值
std::cout << "n: " << n << std::endl;
}
int main(int argc, char* argv[])
{
signal(SIGALRM, handler);
alarm(100); // 设定长闹钟
int cnt = 0;
while (true)
{
std::cout << "进程正在运行: " << " pid: " << getpid() << " cnt: "<< cnt++ <<std::endl;
sleep(1);
}
return 0;
}
编译运行:

如上,获取到的确实是上一个闹钟的剩余时间,后面剩余时间为0,是又设定了一个2s的闹钟,然后触发了。
如果在while循环中设置alarm(3);那么就会导致闹钟一直被重置,就会导致闹钟一直不触发。alarm(0);表示取消闹钟。
如何理解闹钟?
闹钟本质就是一个定时器。在OS中会有很多个闹钟,所以OS就必须将闹钟通过先描述再组织管理起来,未来描述闹钟的结构体中一定会有如下几个字段。
cpp
struct timer
{
int expried; // 闹钟的过期时间
int cnt; // 要触发的次数
void (*callback)(); // 回调
//...
}
如果我们要实现一个闹钟,可以通过最小堆组织管理起来所有的闹钟,堆顶就是最近一个要过期的闹钟,如果时间到了我们可以调用描述结构体中的回调方法向目标进程发送14号信号。
总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~