在上一篇博客中我们了解了关于进程间通信的内容,现在我们在来了解一下Linux中关于信号的内容,在之前我们了解了关于信号量的内容,知道信号量是一种同步机制,用来控制多个进程或者线程对共享资源的访问,本质就是以一个计数器。而我们今日想要了解的信号是操作系统中用于进程间通信的一种机制,通常用于通知进程发生了某种事件。
信号则分为三部分:信号产生 ----------> 信号保存----------->信号执行。
这是什么意思呢?,不要着急,我帮大家列举一个生活中的例子,大家就可以明白了,相信大家都点过外卖吧,尤其正值烈日炎炎的周末,到了饭点了,这个时候你看见外面红扑扑的太阳,直接劝退了你下楼买午餐的心,于是你拿出手机就点了一份外卖,于是你就开始了一把快乐的王者荣耀,而当外卖电话到来的时候(信号产生),这个时候你正忙着和队友进行对线着,生死保卫战,这个你立马说,你把外卖放门口就行了,然后你转头继续对线(这个时候外卖到了这个信号已经保存到你的脑海中了),当你结束完这把游戏之后,你知道你的外卖到了,这个时候你就下楼取外卖了(这就是信号执行)。所以这就是信号的三部分。
当然了,我们生活中有许多的关于信号的例子,就比如上下课铃声,红绿灯,闹钟,动物求偶释放的信号等等,都是我们生活中形形色色的例子。那么在开始了解信号之前,我们先来想几个问题来帮助我们理解信号。
- 我们是怎么认识这些信号的?(就比如刚生下来的婴儿,他们肯定不知红绿灯亮了是什么意思,甚至于压根连红绿灯是什么都不知道。)这里大家可能就会说这不是有人教我么,但是我想说的是有人叫我们,我们不一定就会,就好像我们学了这么多年的英语,依旧是学不明白英语,所以如何认识这些信号呢?其实不仅仅是有人教我们,关键在于我们将这个东西记忆在我们的脑子中,这是认识信号的关键。
- 即使现在信号没有产生,但是一旦信号产生了,我们一定直到我们应该干什么。(就比如即使我们现在不开车,但是我们也知道红灯停,绿灯行,黄灯亮了等一等)。
- 信号产生了,我们可能不能够立即就处理这个信号,因为我们可能正在处理更重要的事情,所以可能会推迟到合适的时候再处理(就比如说每天上课时的早八课,我们的闹钟响了的时候,我们大部分人都会多少赖几分钟的床,不会一听到闹钟响就蹦起来去上课,还是就是在上高中的时候,我们的一听到放学铃声响了,就知道我们可以回家了,但是有时我们的老师还需要几分钟才能将这个重要的知识点讲解完毕,所以听到放学铃声响之后,我们并不会立刻就回家,直到老师讲解完这个知识点之后我们才可以回家)
所以,信号产生后---一段时间间隔---信号处理(也就是信号在产生之后会有一段的时间间隔,才会去进行信号的处理。)
认识信号
我们认识红绿灯这种信号是因为有人教我们,那么现在我们站在进程的角度来看,进程是如何认识信号的呢?
- 能够识别信号(就比如认识这是红灯,这是绿灯,或者这是黄灯)。
- 进程知道这个信号之后,知道该信号的处理方式
所以其实在上面中信号产生之后到信号处理的那段时间间隔之内,我们必须记住信号的到来,也就是信号保存。
所以我们可以得出的结论就是:
-
进程必须是能够识别+处理信号,即使信号没有产生,也要具备在信号产生之后能够处理信号,所以信号的处理能力,属于进程的内置功能的一部分,所以在写操作系统的大佬,一定会明确在代码中设置清楚,进程可以识别哪些信号,以及收到信号之后的默认处理动作,这是一个操作系统必须提供的。
-
当进程收到一个具体的信号的时候,进程可能并不会立即处理这个信号,在合适的时候再去处理,就比如操作系统正在读取外设的数据时,我们可以让信号等一下再处理,毕竟用户体验是第一位的,不然用户输入两个汉字,电脑半天没反应,去处理信号要执行的默认处理动作了,这就导致用户的体验感极差。
-
还有一点就是我们既然会有信号保存这个阶段,这就表明进程一定会具有临时保存这些信号的能力。
#include <iostream>
#include <unistd.h>int main()
{
while (1)
{
std::cout << "this is a process" << std::endl;
sleep(1);
}
return 0;
}

可以看到我们上面只是简单的写了一个循环的代码,但是我们可以通过ctrl+c的方式就可以终止掉我们的进程,我相信大家在VS中进行写代码中肯定都会有这样的操作,那么ctrl+c为什么就能够杀死我们的当前进程呢?
其实这就是因为我们按下的ctrl + c被进程解释为收到了2号信号,现在我们就来看看Linux中的信号有哪些。


- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义 #define SIGINT 2
- 编号34以上的是实时信号,咱们只讨论编号34以下的普通信号,不讨论实时信号
这就是Linux中的信号,而我们ctrl+c 就是其中的2号信号,进程在收到2号信号之后,根据Linux的设计者的默认动作行为就终止了我们的进程。
现在我们都了解了进程在收到信号之后不会立即处理,而是会在合适的时间再处理,那么信号是如何处理的呢?也就是信号的处理方式是什么?
- 默认动作行为,也就是Linux的设计者在操作系统中设计的收到相应的信号之后会有什么动作。(也就是我们在过马路的时候,看到红灯我们就等,看到绿灯我们就走)。
- 忽略掉这个信号(也就是根本不管这个红绿灯,走就对了,管他红灯绿灯)。
- 自定义动作,这就是信号发生时,我们不按照默认的行为动作走,按照我们自己的方式执行(也就是有些人在过马路的时候,尤其是在一些监控死角或者没有监控的道路上,正常人在绿灯亮了的时候赶紧走过去就好了,但是有些心思不正的人,偏偏就挑一些贵重的车直接躺下,嘴里嘟囔着,今天又来一单。这些就是自定义行为。)
所以我们就可以得出一个结论就是进程在收到2号信号之后,处理方式就是终止自己。
那么现在就有人好奇了,你凭什么说这一定就是2号信号呢?上面那么多的信号,你是怎么确定它一定就是2号信号呢?现在我们就来验证一下ctrl+c就是2号信号。
在验证之前,我们先来看一个系统调用接口signal

#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);
参数说明:
-
signum:信号编号(如SIGINT、SIGTERM),就是SIGINT前面对应的数字2 -
handler:信号处理函数-
系统默认动作
-
忽略
-
自定义动作
-
现在我们来通过代码验证上面的三种情况:
系统默认动作
默认处理方式 ------ SIG_DFL
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
while (1)
{
signal(SIGINT, SIG_DFL);
std::cout << "this is a process" << std::endl;
sleep(1);
}
return 0;
}

可以看到默认就是终止了。
忽略信号
忽略信号 ------ SIG_IGN
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
while (1)
{
signal(SIGINT, SIG_IGN);
std::cout << "this is a process" << std::endl;
sleep(1);
}
return 0;
}

可以看到这次即使我们按下ctrl+c之后,我们的进程忽略掉了该进程,并没有终止该进程。
自定义动作
#include <iostream>
#include <unistd.h>
#include <signal.h>
void myhandler(int signum)
{
std::cout << "这是一个2号信号" << std::endl;
}
int main()
{
while (1)
{
signal(SIGINT, myhandler);
std::cout << "this is a process" << std::endl;
sleep(1);
}
return 0;
}

可以看到我们的进程在收到2号信号之后,不是默认的终止行为,而是我们自定义的打印一条语句的行为。
那么键盘数据是如何输入给内核的呢?ctrl+c是如何变为信号的呢?也就是操作系统是如何知道键盘上是有数据的呢?
这个知识点就是计算机组成原理中我们I/O部分的内容,这里我们来简单介绍一下,在单核CPU中,我们许多的进程都需要使用CPU才能运行,这个时候,CPU就是一个很重要的资源,当一个进程正在运行的时候,如果这个进程需要等待用户输入数据,这个时候这个进程就会被切换下去,因为CPU讲究的就是一个高效,一个进程在等待用户输入数据,这不能让其他不需要用户输入数据的进程也一起等待,这个时候这个进程就会被阻塞,让其他就绪进程先运行。
那么现在就有人好奇了,如果让其他进程先运行的话,万一等待用户输入的进程输入数据结束了,那个进程是如何知道的呢?
当用户输入数据之后,我们会先将数据放在内核的缓冲区之中,然后,当我们因等待用户输入的进程再次被调度的时候,该进程就可以获得到这个数据。
那么数据是如何从键盘到达我们的内核缓冲区的呢?其实这就与我们的中断机制有关了,在我们的计算机组成原理中有三种检测I/O是否就绪的手段分别是(程序查询方式,程序中断方式,DMA方式)。现在我们用程序中断方式来解释这一现象,当我们的运行其他进程的时候,CPU是不会管I/O是否准备好,只有I/O准备好之后,有I/O的控制器发送中断请求,会通过数据总线传送中断类型号,在我们的内核中会有一张中断向量表(这里存放的都是各种中断请求的处理方法的地址,包括像键盘,鼠标,网卡等等),而内核就会根据这个中断类型号在这个中断向量表中查找对应的中断处理方式的地址(其实这个中断向量表就相当于一个数组,而这个中断类型号就是下标,这样就可以快速找到键盘对应的中断处理方式)。最后,将用户输入的数据放到内核缓冲区,等到下一次调用该进程时,方便该进程使用。
现在我们就明白了操作系统是如何得知ctrl+c已经被输入了,那么ctrl+c是如何变为信号的呢?其实这也是很简单的,因为我们在键盘中输入的数据是有分类的,比如我们输abcd这些字母的时候,我们的操作系统就将这些字母识别为数据直接交给内核,但是如果是ctrl+c这些组合键,我们的操作系统就会将其识别为2号信号,这样我们的操作系统就可以识别到信号了。(就比如说,大家都写过论文吧,我们在写入的时候按下ctrl+s是保存,AIt+F4是退出,而按下其他字母的时候才会正确的打字,大家应该不会见到按下AIt+F4被识别为汉字的吧,如果有的话,这边建议大家下次写论文的时候,尤其是快写完的时候直接AIt+F4。不要轻易尝试,除非你按下AIt+F4被识别为汉字。)
了解了以上内容,相信大家都已经明白了我们的进程接收到信号之后,都会做出一定的行为,那么现在可能就会有一些好奇心中的同学想如果将所有的普通信号都自定义了,那么是不是这个进程是不是就杀不死了呢?
其实不是的,如果那么简单,每个人都可以成为黑客了,所以直接告诉大家结论就是对于这前31号进程,只有9号杀死进程和19号暂停进程不可以被自定义以外,其余的信号是都可以被自定义的。现在我们就来验证一下。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void myhandler(int signum)
{
std::cout << "这是一个" << signum << "号信号" << std::endl;
}
int main()
{
for (int i = 1; i <= 31; i++)
{
signal(i, myhandler);
}
while (1)
{
signal(SIGINT, myhandler);
std::cout << "this is a process , pid : " << getpid() << std::endl;
sleep(1);
}
return 0;
}


剩下的信号就不一一验证了,总而言之就是在前31号信号中,除了9号信号和19号信号不能被自定义之外,剩下的信号都是可以被自定义的。
产生信号的系统调用
kill
在 Linux 中,kill 是一个用于向进程发送信号(signal)的系统调用函数 。
它并不只是"杀死进程",而是向指定进程或进程组发送某种信号,由进程自己决定如何响应该信号。
函数原型
#include <signal.h>
int kill(pid_t pid, int sig);
参数说明
-
pid:目标进程 PID -
sig:要发送的信号(如SIGKILL、SIGTERM等)
看完这个函数调用,现在我们结合之前进程控制的内容,做一个像上面命令行一样功能的自己的kill命令。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <string>
void Usage(std::string str)
{
std::cout << str << " : " << "Signum Pid" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
kill(atoi(argv[2]), atoi(argv[1]));
return 0;
}

可以看到,通过这样的方式,我们就成功做出了一个简易的kill命令。
raise
在 Linux(POSIX)中,raise 用于向"当前进程自身"发送一个信号 。
它本质上是进程给自己发信号的一种方式。
函数原型
#include <signal.h>
int raise(int sig);
参数说明
-
sig:要发送的信号编号(如SIGINT、SIGTERM等)#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <string>void myhandler(int signum)
{
std::cout << "这是一个" << signum << "号信号" << std::endl;
}int main()
{
signal(2, myhandler);
int count = 1;
while (1)
{
std::cout << "this is a process , pid : " << getpid() << std::endl;
if (count % 2 == 0)
{
raise(2);
}
count++;
sleep(1);
}return 0;}

从结果来看,我们可以很好的看到每当count为偶数时就调用raise函数,进一步验证了raise函数是进程给自己发送信号。
abort
在 Linux中,abort() 用于使当前进程"异常终止" 。
它通常用于在程序检测到不可恢复的严重错误时,立即终止程序运行。
📌 核心特点:
abort = 主动触发异常终止(生成 SIGABRT)
函数原型
#include <stdlib.h>
void abort(void);
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <string>
#include <stdlib.h>
void myhandler(int signum)
{
std::cout << "这是一个" << signum << "号信号" << std::endl;
}
int main()
{
signal(SIGABRT, myhandler);
printf("before abort\n");
abort();
printf("after abort\n"); // 不会执行
return 0;
}

可以看到abort确实是发送了SIGABRT信号,并且当执行abort函数之后,程序会立即中止。
接下来我们再来深入了解一下由异常产生信号的这部分内容
由异常产生信号
整数除零
我们先来看一段代码:
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
int a = 10;
std::cout << "div before" << std::endl;
a = a / 0;
std::cout << "div after" << std::endl;
return 0;
}


其实我们可以通过这张信号表就可以推断出整数除0这个异常,对应的信号就是SIGFPE,大家可能会说我不能只根据抛出异常的首字母就断定呀,必须的有说服力,现在我们就来验证一下,整数除0就是给进程发送了8号信号。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void myhandler(int signum)
{
std::cout << "这是一个" << signum << "号信号" << std::endl;
}
int main()
{
signal(SIGFPE, myhandler);
int a = 10;
std::cout << "div before" << std::endl;
a = a / 0;
std::cout << "div after" << std::endl;
return 0;
}

可以看到我们的结论是正确的,确实整数除0之后,进程会收到一个8号信号,但是现在就有一个问题就是,我们明明只是除了一次0,按照正常的逻辑,即使我们自定义行为了,它也因该只打印一条语句就好了呀,但是从结果来看,这个程序反而一直在打印,这是什么原因呢?难道是进程一直收到8号信号吗?但是我明明只是除了一次0,而且自定义的行为也已经执行了,为什么还会一直打印呢?
这是一个问题,还有一个问题就是相信大家都写过代码吧,作为学计算机的码农,应该多多少少都写过一点程序,而我们在刚开始学习的时候,经常就会写出一些野指针的程序,现在我们就来看看野指针异常的情况。
野指针异常
下面这是一个野指针异常的程序
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
std::cout << "point error before" << std::endl;
int *p = nullptr;
*p = 10;
std::cout << "point error after" << std::endl;
return 0;
}


同样的,其实我们可以很容易就猜到野指针异常会触发SIGSEGV信号(11号信号),现在我们继续来验证一下。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void myhandler(int signum)
{
std::cout << "这是一个" << signum << "号信号" << std::endl;
}
int main()
{
signal(SIGSEGV, myhandler);
std::cout << "point error before" << std::endl;
int *p = nullptr;
*p = 10;
std::cout << "point error after" << std::endl;
return 0;
}

所以其实从结果来看,当一个程序发生异常的时候,一定会退出吗?其实不一定,可以看到当我们将信号的默认行为自定义捕捉之后,进程并不会退出。
根据上面两个例子,我们可以得出一个结论就是,我们一般写的野指针行为,以及整数除零等等异常,其实在系统层面,都是操作系统给对应的进程发送信号,从而让该进程执行对应的信号的默认处理动作,那么现在有一个问题就是,操作系统是如何检测到这些异常的呢,也就是如何检测到我们有野指针,如何得知我们整数除零了呢?进程能够执行对应的信号的默认处理动作前,是要求要收到信号,那操作系统是如何检测到这些异常的,不然如何才能够发送对应的信号?
其实这些问题的答案涉及到的就是计算机组成原理的内容,现在我们先来回答一下整数除零的问题,当我们的进程被调度到CPU上的时候,CPU中集成了PC寄存器和psw(程序状态字寄存器),我们的程序时顺序执行的,PC指针负责的就是执行当前执行的程序地址,CPU根据PC指针的指向一条条的执行我们的程序,而在当我们执行到a = a / 0这条语句是,就是通过CPU中的运算器中的核心部件ALU计算之后就相应的状态标记位反馈到psw中,而其中有一个标记位就是溢出标记位,当发生异常的时候,这个标记位就由0变为1,然后操作系统就是检测CPU中的psw中的这个标记位知道这个程序发生了除零错误,这个时候就会给这个进程发送对应的8号信号(SIGFPE),这个信号的默认行为是终止这个错误的进程,而当我们将这个信号的默认行为更改为我们的自定义行为之后,进程就会执行我们的自定义行为,这样进程就不会退出了。同时,这个程序状态字寄存器属于硬件资源,但是其中的内容是属于进程的,当发生进程切换的时候,这些内容会被保存到进程的PCB的进程上下文中,这样,当下次再次调度该进程的时候,会将PCB中的进程上下文再次换上CPU,而操作系统依旧可以检测到CPU的psw中的溢出标记位为1,检测要硬件异常,因此就会再次发送8号信号,而我们将我们的默认行为进行了捕捉,因此就会在我们的显示器中一直看到打印语句的刷屏。
了解清楚整数除零异常的问题,现在我们再来看看野指针异常的情况。
至于野指针异常的原因,也是同样发生了硬件异常,当我们的进程上CPU进行运行的时候,CPU使用的都是虚拟地址,当我们出现一个指针的时候,我们的操作系统依旧会将其当作一个正常的虚拟地址进行处理,通过MMU和页表进行映射,进而转化为物理地址,但是此时对于野指针而言,当进行MMU+页表进行映射的时候,由于没有内存中是没有该地址的,这就导致当MMU在检测该虚拟地址的合法性时无法完成地址转化,这时候硬件就会触发页故障,导致硬件异常,操作系统就给给对应的进程发送11号信号。这就是野指针异常的情况。
大家看到这里都知道了整数除零和野指针都会导致硬件异常,那么就没有不触发硬件异常的异常吗?答案当然是有的,这就是之前管道那里遗留的问题,就是当我们的两个进程,一个进程负责往管道中写数据,另一个进程负责向管道中读数据,当我们的读进程关闭的时候,我们的写进程还往管道中写数据的时候,这个时候就会发生异常,触发SIGPIPE信号,因为读进程已经没有了,都没有观众了,那么也就没有表演的意义了,所以这个时候操作系统就会给写进程发送SIGPIPE信号,进而终止该进程。

进程定时器机制
接下来我们在来介绍一下进程的定时器机制,就是alarm函数和SIGALRM信号,这其实就是一个操作系统内核中的一种定时机制,就是到达指定时间之后,操作系统就会像进程发送SIGALRM信号,而不是触发硬件异常,完全由操作系统的软件实现。
alarm函数调用
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
作用:
- 为当前进程设置一个定时器
- seconds 秒后,内核向该进程发送 SIGALRM
行为特点
-
一个进程同一时间只能有一个 alarm
-
新的
alarm()会覆盖旧的 -
返回值是 上一个闹钟剩余的秒数
#include <iostream>
#include <unistd.h>
#include <signal.h>int main()
{
alarm(5);
while (1)
{
std::cout << "wait ......." << std::endl;
}
}

可以看到5s之后,SIGALRM的默认行为就是终止进程,这并不是定时器的正确打开方式,接下来我们就结合上面所学知识,来见证一下闹钟的正确使用方式。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void myhandler(int signum)
{
std::cout << "这是一个" << signum << "号信号" << std::endl;
}
int main()
{
signal(SIGALRM, myhandler);
alarm(5);
while (1)
{
std::cout << "wait ......." << std::endl;
sleep(1);
}
}

这才是我们的闹钟正确的使用方式,目的就是让我们的进程正常运行,等到5秒之后,让进程执行一下我们的定时任务之后,让进程可以继续执行自己的任务,这才是进程定时器的正确打开方式。
Core Dump
接下来我们再来谈谈之前进程等待中遗留的问题,什么是Core Dump。


这个就是进程等待那里我们了解的内容,其中当进程正常终止的时候,我们用系统调用waitpid中的输出性参数status中低16位中的次低8位(8-15)表示进程退出时的退出码,还有一个就是低7位(0-6)表示终止信号,而其中的第7位就是core dump标志位,他就是表示我们是term终止的方式还是core终止的这种方式,接下来我们现在来测试一下。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
while (1)
{
std::cout << "this is child process , pid : " << getpid() << std::endl;
sleep(1);
}
exit(1);
}
int status;
int rid = waitpid(id, &status, 0);
if (rid == id)
{
printf("child process quit, rid : %d, exit code : %d, exit signal : %d, core dump:%d\n", rid, (status >> 8) & 0xFF, status & 0x7F, (status >> 7) & 0x1);
}
return 0;
}

从结果中我们可以看到,如果发送信号的行为是Term的话,这个进程就直接被中止了,但是,如果发送信号的行为是Core的话,我们的当前目录就会出现一个core.pid的文件,这是什么呢?
其实这是因为操作系统将进程在内存中运行的信息给dump(转储)到进程的当前目录,形成core.pid文件:核心转储(core dump)。那为什么要形成这样一个文件呢?
因为当我们的进程发生错误的时候,我们一定想要知道我们的进程是怎么了,他是哪里出错了,并且这个进程是在程序的哪一行有错误,而core.pid文件就可以帮助我们快速定位我们程序中的错误位置,更好的帮助我们修改bug。下面来帮大家展示一下。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int a = 10;
a /= 0;
std::cout << "a : " << a << std::endl;
return 0;
}

可以看到当我使用简单的整除除零的错误程序时,通过核心转储和gdp调试程序就可以快速找到我们出错的行数以及内容,这样可以帮助我们快速找到使我们程序异常的代码,这就是核心转储(core dump)的功能。
这就是信号是什么以及信号是如何产生的简单概述,在下一篇博客中我们再继续详谈,这篇博客就到此结束,谢谢大家!!!