信号的概念及产生
信号的概念
信号(signal)是一种软件中断机制,用于通知进程发生了特定的事件。信号可以由系统、其他进程或进程自身发送。
在现实生活中,也有许多的信号,比如说:红绿灯、闹钟、上课铃、父母喊你回家吃饭等等。这些信号是为了让我们做应该做的事情。
接下来我们以“红绿灯”这个信号来理解信号的本质。作为成年的我们知道红灯停,绿灯行,这是因为我们在小时候父母就经常告诉我们这句话,并教会我们如何处理这个“信号”;那么对于一个没人教过如何处理这个“信号”的小朋友来说,碰到红灯的话就很有可能直接闯红灯了,这是一个非常危险的动作。
所以第一个问题就是“我们为什么会认识红绿灯”。原因就是已经有人告诉过我们这个信号,让我们的大脑能够记住这个信号对应的行为和属性。
第二个问题就是当信号来临的时候,我们有可能并不会立即处理这个信号。比如你早早的点了一份外卖,而你现在正在和同学玩游戏,已经开始团战了,这时候,外卖电话响了,让你现在取外卖。此时你就会先把团战打完,等游戏结束之后才会取外卖。当然也有一种可能就是连续打了几局游戏都是胜利,你就会上头又开了一局游戏,而忽略了取外卖这个事情。因此可以得出一句话,信号可以随时产生,此时你忙着更重要的事情。从信号产生,到处理这个信号的时间段中,这个区间叫做时间窗口,而在这个窗口中,你会一直记住这个信号。
我们能够处理这个信号,是因为有人提前告诉了我们对应的处理方法,或者也可以说我们碰到不同信号的时候,会选择对应的动作来处理信号。在计算机中,也是如此,比如说我们运行了一个死循环程序,想要退出的时候,最简单的操作就是ctrl+c
来终止进程,为什么这样做就能终止程序呢?这是因为ctrl+c
是一个信号,产生信号的时候,OS系统会根据这个信号来执行对应的动作。
注意:
ctrl+c
产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。- Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像
Ctrl-C
这种控制键产生的信号。 - 前台进程在运行过程中用户随时可能按下
ctrl+c
而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT
信号而终止,所以信号相对于进程的控制流程来说是异步。
我们可以通过kill -l
来查看信号:
上面就是Linux中所有的信号,它们分为2个部分,[1,31]
叫做非实时信号,当进程收到这些信号后,可以自己选择合适的时候处理;[34,64]
叫做实时信号,当进程收到这些信号后,必须立马处理。注意,没有0,32,33这3个信号,所以实际上只有62个信号。
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h
中找到,例如其中有定义#define SIGINT 2
。由于现在的操作系统基本都是分时操作系统,因此实时信号其实是不符合设计理念的,几乎用不到实时信号,本博客只讲解非实时信号
。
收到信号之后,我们需要处理这个信号,信号处理的常见方式有3种。
- 执行该信号的默认处理动作。
- 忽略此信号
- 自定义动作。提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉。
我们可以通过7号手册来查看信号的默认处理方式。
其中Term、Ign、Core、Stop、Cont
是信号的默认处理动作,我们简单了解一下它们的作用。
Term
:默认动作是终止进程Ign
:默认动作是忽略该信号Core
:产生核心转储文件并终止进程Stop
:暂停该进程Cont
:如果当前进程被暂停,则继续该进程
其中Term
和Core
都是终止进程,不过2个有所区别,对于Core
我会在下面的学习中进行讲解。
Signal
:信号的名称Standard
:信号的执行标准Action
:信号的处理动作Commment
:对信号的简单描述
信号的保存
之前我们说过,当进程收到信号的时候,进程可能不会立即处理该信号,因为进程有可能执行更重要的任务。所以进行需要对信号有保存的能力。那么问题就是如何对信号进行保存呢?
信号保存在task_struct
结构体当中,在这个结构体当中,会有一个unsigned int signal
属性,其本质是一个位图结构,当我们收到信号时,对应信号的位置会由0变成1,就代表我们已经完成了信号的发送,并且进程会暂时的把这个信号保存起来。
每个进程都有一个未决信号集,用于记录该进程收到但尚未处理的信号。这个未决信号集是一个数据结构,通常是一个位掩码,其中每一位对应一个特定的信号。
当一个信号被发送给一个进程时,如果该信号没有被阻塞,那么它就会被添加到进程的未决信号集中。如果该信号被阻塞了,那么它会被暂时保留在发送信号的系统队列中,直到进程解除对该信号的阻塞,才会被添加到未决信号集中。
自定义处理函数
signal
函数,头文件是<signal.h>
,它的作用是可以自己定义信号的处理方式。
函数原型:sighandler_t signal(int signum, sighandler_t handler);
其中sighandler_t
,本质是一个void(*)(int)
类型的函数指针。也就是说自定义的信号处理函数必须是void (int)
的格式。其中这个处理函数的第一个参数int
,就是用来接收信号的编号的。返回值返回的是该信号处理的函数的函数指针。
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;void handler(int signo)
{cout << "process get a signal:" << signo << endl;
}int main()
{signal(2, handler);while (true){cout << "I am a process,pid:" << getpid() << endl;sleep(1);}return 0;
}
这个程序中,我们通过signal(2,handler)
把2号信号的处理动作变成了自定义动作,即handler
,原本2号信号是退出进程。运行这段代码,进程收到2号信号时,就会发送process get a signal:
这句话。那么该如何产生2号信号呢,最简单的方式就是ctrl+c
,我们在之前的学习中就知道ctrl+c
可以直接终止进程,因此当我们运行程序的时候,按下ctrl+c
就可以产生2号信号了。
可以看到我们按了2下ctrl+C
程序没有退出,因为我们将2号信号的默认处理动作变成了自定义动作。要想终止进程只能在另一个窗口当中使用kill -9 pid
来终止进程。
注意:signal
只需要设置一次,往后都有效。即就是不用多次设置,不管在程序中哪个地方有signal
函数,都能起作用。
信号的产生
在Linux中,信号有2种主要的产生方式,分别是软件产生和硬件产生
软件产生
在Linux中,软件条件发出的信号通常指的是“进程本身或者其它进程产生的信号”,刚才我们通过键盘往终端中输入ctrl+c
就是软件产生信号的方式之一。用户按下 Ctrl+C
会向当前正在前台运行的进程发送 SIGINT
(中断信号),通常用于请求进程终止。
还有一种也属于软件信号,就是ctrl+\
,用户在终端按下 ctrl+\
时,终端驱动程序向当前前台进程发送 SIGQUIT 信号。这也是一种软件信号,通常会导致进程终止并产生核心转储文件,可用于调试目的。
void handler(int signo)
{cout << "process get a signal:" << signo << endl;exit(0);
}int main()
{signal(SIGQUIT, handler);while (true){cout << "I am aprocess,pid:" << getpid() << endl;sleep(1);}return 0;
}
我们也可以通过系统调用的方式来产生信号,比如说kill、raise、abort、alarm
等四种接口,这4种方式我会一一讲解。
kill
这个函数的功能是可以给指定的进程产生信号。
函数原型:int kill(pid_t pid, int sig);
sig
:信号编号。pid
:进程的PID。
头文件:<sys/types.h>和<signal.h>
。
返回值:成功返回0,失败返回-1,并设置一个错误码。
void handler(int signo)
{cout << "process get a signal:" << signo << endl;exit(1);
}int main()
{int id = fork();signal(2, handler);if (id == 0) // 子进程{while (true){cout << "I am a process,pid:" << getpid() << endl;sleep(1);}}sleep(5);kill(id, 2);return 0;
}
在这段代码当中,父进程通过fork
创建子进程,子进程不断输出自身的进程 ID,父进程在等待 5 秒后向子进程发送信号编号为 2 的信号,子进程接收到该信号后会调用注册的信号处理函数 handler
,输出提示信息并终止自身。在handler
函数体中输出提示信息表明进程接收到了一个信号,并打印出信号编号,然后调用 exit(1)
终止进程.。
可以看到,kill
函数成功给进程发送了2号信号。我们也可以通过指令的形式来终止进程,即kill -sigo pid
,这个指令的使用方式就不再进行讲解了。其实kill命令是调用kill函数实现的,kill函数可以给一个指定的进程发送指定的信号。
raise
raise函数可以给当前进程发送指定的信号(自己给自己发信号),头文件:<signal.h>
函数原型:int raise(int sig);
返回值:成功返回0,失败返回-1。
void handler(int signo)
{cout << "process get a signal:" << signo << endl;
}int main()
{signal(2, handler);int count = 5;while (count--){cout << "I am a process,pid:" << getpid() << endl;sleep(1);}raise(2);return 0;}
先通过signal(2, handler);
修改信号的处理函数,随后循环五次,循环完成之后,通过raise(2)
给自己发送2号信号,终止进程。
abort
abort函数使当前进程接收到信号而异常终止。给自己发送6号信号SIGABRT
,头文件<stdlib.h>
,这个函数属于用户操作接口。
函数原型:void abort(void);
,就像exit函数一样,abort函数总是会成功的,所以没有返回值。
abort
函数会使程序异常终止,并向操作系统发送一个特定的信号(通常是SIGABRT
)。这个信号可以被操作系统捕获,并且可能会触发一些调试或错误处理机制,例如生成核心转储文件(core dump)以便后续分析程序崩溃的原因。
与正常的程序退出方式(如通过return
从main
函数返回或调用exit
函数)不同,abort
函数不会进行一些常规的清理操作,如关闭打开的文件、释放动态分配的内存等。
#include <stdio.h>
#include <stdlib.h> int main()
{ // 模拟一个错误条件 int error_condition = 1; if (error_condition) { printf("An error occurred. Aborting the program.\n"); abort(); } return 0;
}
在这个例子中,如果error_condition
为真,程序会打印一条错误消息,然后调用abort
函数终止程序。
谨慎使用
- 由于
abort
函数会突然终止程序,并且可能导致数据丢失或其他不良后果,所以应该谨慎使用。通常只在遇到严重的、不可恢复的错误情况时才考虑使用abort
。
alarm
这个函数用于设定一个闹钟,在一定的时间之后会向当前进程发送14号信号(SIGALRM)
,头文件是<unistd.h>
。
函数原型:unsigned int alarm(unsigned int seconds);
seconds
:在给定的seconds
秒之后发送信号。
返回值:这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
#include <stdio.h>
#include <unistd.h>void signal_handler(int signum) {if (signum == SIGALRM) {printf("Timer expired.\n");}
}int main() {signal(SIGALRM, signal_handler);alarm(5);printf("Waiting for the timer to expire...\n");pause();return 0;
}
在这个例子中,程序首先注册了一个信号处理函数signal_handler
来处理SIGALRM
信号。然后调用alarm(5)
设置一个 5 秒的定时器。接着,程序进入pause
等待信号的到来。当定时器超时时,会向当前进程发送SIGALRM
信号,信号处理函数会被调用,输出 “Timer expired.”。
注意事项:alarm
函数每次调用只能设置一个定时器。如果需要设置多个定时器,可以考虑使用更复杂的定时器机制,如setitimer
函数。
现在来看一个返回值不为0的例子。
void signal_handler2(int signum)
{if (signum == SIGALRM){printf("Timer expired.\n");}
}int main()
{// 先设置一个 5 秒的定时器unsigned int time_1 = alarm(5);printf("First alarm set for 5 seconds. Remaining time from previous alarm: %u\n", time_1);sleep(2);// 再设置一个 3 秒的定时器unsigned int time_2 = alarm(3);printf("Second alarm set for 3 seconds. Remaining time from previous alarm: %u\n", time_2);signal(SIGALRM, signal_handler2);pause();return 0;
}
在这个例子中,先设置了一个 5 秒的定时器,此时alarm
的返回值为 0(假设之前没有设置过定时器)。然后程序睡眠 2 秒后再次设置一个 3 秒的定时器,这时alarm
的返回值为 3,表示上一个定时器还剩余 3 秒。当第二个定时器超时时,会触发信号处理函数输出 “Timer expired.”。
硬件产生
硬件信号是由硬件事件决定的,不是由代码逻辑决定的。
常见的硬件信号有以下几种:
- 非法内存访问:当进程试图访问未分配给它的内存地址,或者以不适当的方式访问内存(如写入只读内存区域)时,会触发
SIGSEGV
(段错误信号)。 - 算术错误:例如除以零、整数溢出等算术运算错误可能会产生
SIGFPE
(浮点异常信号)。
在讲解硬件产生的信号之前,我们需要先了解一下硬件中断,硬件中断是由计算机硬件产生的中断信号,用于通知操作系统或正在运行的程序发生了特定的事件或需要立即处理的情况。
硬件中断产生的原因主要有2种: - 外部设备请求
- 例如,当键盘上有按键被按下时,键盘控制器会向 CPU 发送一个硬件中断信号,通知操作系统有键盘输入事件发生。类似地,鼠标移动、硬盘读写完成、网络数据包到达等事件也可以由相应的硬件设备产生硬件中断。
- 这些外部设备通过特定的硬件线路与 CPU 相连,当特定事件发生时,设备会触发中断信号。
- 硬件故障或错误
- 当计算机硬件出现故障时,如内存错误、电源故障等,硬件可能会产生中断信号以通知操作系统进行错误处理。
它的工作过程有4步:
- 当计算机硬件出现故障时,如内存错误、电源故障等,硬件可能会产生中断信号以通知操作系统进行错误处理。
- 中断请求
- 硬件设备通过中断控制器向 CPU 发送中断请求信号。中断控制器负责管理多个硬件设备的中断请求,并将它们优先级排序后发送给 CPU。
- CPU 在执行指令的过程中,会不断检查是否有中断请求。如果有中断请求,并且 CPU 允许中断(没有被屏蔽),则 CPU 会暂停当前正在执行的任务,保存当前的执行状态(如程序计数器、寄存器的值等)。
- 中断响应
- CPU 开始处理中断。首先,它会根据中断请求的类型确定相应的中断服务程序(Interrupt Service Routine,ISR)的地址。
- CPU 然后将程序计数器设置为中断服务程序的地址,开始执行中断服务程序。
- 中断处理
- 中断服务程序负责处理中断事件。这可能包括读取硬件设备的状态寄存器、处理输入数据、向硬件设备发送控制命令等。
- 中断服务程序通常需要尽快执行,以减少对系统性能的影响。在处理中断事件时,中断服务程序可能会屏蔽其他中断,以确保中断处理的完整性。
- 中断返回
- 当中断服务程序完成中断事件的处理后,它会执行中断返回指令。这个指令会恢复 CPU 在中断发生前的执行状态,使 CPU 继续执行被中断的任务。
接下来我用“除0错误”来分析硬件中断。
- 当中断服务程序完成中断事件的处理后,它会执行中断返回指令。这个指令会恢复 CPU 在中断发生前的执行状态,使 CPU 继续执行被中断的任务。
int main()
{int a = 10 / 0;return 0;
}
可以看到,结果是Floating point exception
,其实就是8号信号SIGFPE
。接着我们改下代码,再来验证一下是否是8号信号。
void handler(int signo)
{cout << "process get a signal:" << signo << endl;sleep(1);
}int main()
{signal(SIGFPE, handler);int a = 10 / 0;return 0;
}
从结果可以看出,“除0错误”就是8号信号。但是有一个问题,为什么会一直发送8号信号呢?在解决这个问题之前,我们需要先知道OS如何得知应该给当前进程发送8号信号呢?或OS怎么知道我除0了呢?
原因是CPU内会充满大量的计算,每一次CPU上进行对应的运算时,不仅要得到正确的运算结果,还要进行数据保存。因此CPU内部在进行对应的计算时,除了要把结果运算出来,还要保证这次运算没有问题,所以CPU内部有一个状态寄存器。
10/0
,0是一个无穷下的数,那么结果就会是无穷大,会溢出,于是就会引起CPU把状态寄存器中的溢出标记位由0变成1,这个运算结果没有意义,不会被采取。于是发生了硬件中断。
我们调用handler
函数之后,CPU内部的5/0没有被处理。也就是说,CPU
会被一直卡在5/0
,CPU
不知道怎么计算这个表达式,于是一直硬件中断,操作系统就一直发送8号信号,所以进程就一直执行handler
函数。
core核心转储
在最开始的时候,我们遗留了一个知识点没有解决,那就是core dump
,通过查看7号手册,我们可以发现有许多信号的Action
是core
,它与Term
一样都可以终止进程,那么功能一样,它们之间的区别是什么呢?我们首先来解释一下什么是core dump
,然后再通过例子来加深了解。
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。
我们在学习进程控制的时候,看到过core dump
这个标志。
其中第7位就是core dump
标志位,它们的本质就是位图原理。我们通过一段代码来看看Term
和core
有什么区别。
int main()
{pid_t id = fork();if (id == 0) //子进程{int cnt = 500;while (cnt--){cout << "I am a child process, pid:" << getpid() << ",cnt:" << cnt << endl;sleep(1);}exit(0);}// 父进程int status = 0;pid_t rid = waitpid(id, &status, 0);if (rid == id){cout << "child quit info, rid:" << rid << ",exit code:" << ((status >> 8) & 0xFF) << ",exit signal:"<< (status & 0x7F) << ",core dump:" << ((status >> 7) & 1) << endl; //? & (0000 0000 ... 0001)}return 0;
}
通过这段代码可以查看到子进程退出时的退出信息。
可以看到,通过不同的信号终止进程,core dump
也不相同。也与2号信号和8号信号的Action
一致,说明第7位的比特位就是core dump
标志位,当是core
时,比特位由0变成1。
现在我们就来深入学习一下核心转储文件,在云服务器上,默认如果进程是Core退出的,我们暂时看不到明显现象,如果想看到需要打开一个选项。我们需要使用ulimia -a
命令。
core file size
的大小为0,说明云服务器就是默认关闭了core file
文件选项。要想打开的话,ulimit
就带上你想要设置谁,比如说我们需要打开core file
文件,那么就带上-c
选项,大小为多少。这里我们设置大小为1024,然后执行下面这条指令ulimit -c 1024
。
这样,我们就成功开启了core file
功能。接着我们执行下面这段代码,并运行。
int main()
{int a = 10;int b = 0;a /= b;cout << "a = " << a << endl;return 0;
}
执行完这段代码后,我们发现结果符合我们的预期,并且后面跟了一个core dumped
字段,另外在当前目录下产生了一个core.1114391
的文件(后面的数字就是这段进程的pid)。
在当前目录产生了一个core.
文件,这个过程叫做核心转储
操作,核心转储(core dump)是当一个程序异常终止时,操作系统将程序当时的内存状态记录下来并存储到一个文件中的过程。这个文件通常被称为核心转储文件。它可以帮助我们分析程序崩溃的原因,通过分析核心转储文件,开发人员可以确定程序崩溃的原因,例如内存访问错误、未处理的信号、死锁等问题。这有助于修复程序中的错误,提高程序的稳定性和可靠性。
如果在当前目录下没有生成
core
文件,可以在root
用户下输入sudo bash -c "echo core.%p > /proc/sys/kernel/core_pattern"
。这样应该就可以生成了。
那么如何分析崩溃原因呢?最简单的方式就是进行调试,由于在Linux下,默认编译都是release
,不能进行调试,所以我们需要在makefile
文件中加上-g
选项,然后使用gdb
调试工具。
当进入调试界面之后,我们需要输入core-file
以及生成的核心转储文件,这样就能知道代码崩溃的原因在哪,例如图片中展示的错误位置是在mysignal.cc
文件中的第13行。这种直接快速定位到出问题的方式,我们称之为事后调试。
开始的时候,我们说过云服务器是默认关闭core dump
功能的。那么这么好的功能为什么要关闭呢?原因有以下几种:
- 节省磁盘空间
- 我们可以看到短短的10几行代码,生成的
core
文件大小就是552960
字节。想象一下,当你在一个大公司工作的时候,每天都要写很多的代码,如果打开了这个功能,要是出错一次,就会生成一个core
文件,一个小组都出错的话,就会生成大量的这种文件,而这种临时文件会占用大量的磁盘空间,特别是对于大型程序或在频繁崩溃的情况下。在云服务器上,磁盘空间通常是有限的资源,并且需要付费使用。
- 我们可以看到短短的10几行代码,生成的
- 减少系统负荷
- 生成核心转储文件可能会对系统性能产生一定的影响,特别是在高负载的情况下。当程序崩溃时,生成核心转储文件需要一定的时间和系统资源,这可能会导致其他正在运行的程序受到影响。
所以关闭核心转储文件可以减少系统负载,提高整体性能和稳定性。