🍎作者:阿润菜菜
📖专栏:Linux系统编程
文章目录
- 一、预备知识
- 二、信号产生
- 1. 通过终端按键产生信号
- 1.1 signal()
- 1.2 core dump标志位、核心存储文件
- 2.通过系统调用向进程发送信号
- 3.由软件条件产生信号
- 3.1 alarm函数和SIGALRM信号
- 3.2 使用alarm()系统接口 验证 IO的效率 --- 很慢
- 4. 硬件异常产生信号
一、预备知识
我们想要杀死某个后台进程的时候,无法通过ctrl+c热键终止进程时,我们就会通过kill -9的命令来杀死信号。
查看信号也比较简单,通过kill -l
命令就可以查看所有信号的种类,虽然最大的信号编号是64,但实际上所有信号只有62个信号,1-31是普通信号,34-64是实时信号,本文不对实时信号做讨论,只讨论普通信号,感兴趣的老铁可以自己下去研究一下。
以过红绿灯为例,其实在红绿灯出现之前,我们大脑里已经有了红灯停绿灯行的意识了,对于进程来说也是如此,进程在收到信号之前就已经知道怎么处理对应信号了,已经保存了对应处理的方法;那绿灯亮起,此时有朋友打来视频电话,我们也可以选择忽略该绿灯信号,暂时不通行,去忙别的事情,对应进程来说如果不能里面处理这个信号,那进程就要有信号保存的能力!
我们Linux操作时用户输入命令,在Shell下启动一个前台进程。.用户按下Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程, 前台进程因为收到信号,进而引起进程退出。这就是技术角度的信号处理过程。
注意:
- Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程
结束就可以接受新的命令,启动新的进程。 - Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生
的信号。 - 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行
到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步
(Asynchronous)的。
所以有了下面的预备知识:
两个先导结论:
- 进程在没有收到信号之前,就已经知道怎么处理信号了
- 进程不能立马处理这个信号,就需要进程具有信号保存的能力
二、信号产生
1. 通过终端按键产生信号
1.1 signal()
我们最常用的发送信号方式就是一个热键ctrl+c,这个组合键其实会被操作系统解释成2号信号SIGINT,通过man 7 signal
命令就可以查看到对应的信号和其默认处理行为等等信息。
这里SIGINT的默认处理动作是终止进程 — Term,上图介绍了各种处理动作。但进程不是立即处理该信号的,而是先将信号保存下来,信号保存部分后面讲解;同时其实我们可以定义收到信号后的处理动作的 ---- 我们利用signal()系统调用
signal()是一个系统调用,用于设置或处理信号。
我们知道信号是一种通知进程发生了某种事件的机制。当进程收到一个信号时,它可以执行以下三种操作之一:
- 忽略信号,不做任何处理。(后面讲解)
- 执行默认动作,通常是终止进程或忽略信号。
- 调用一个自定义的信号处理函数,执行特定的操作。
signal()函数的原型是:
#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);
它接受两个参数:signum
是要处理的信号的编号,handler
是要执行的信号处理函数的指针。它返回一个函数指针,指向之前设置的信号处理函数,或者SIG_ERR表示出错。所以这里我们可以自定义一个函数方法,当进程接收到某个信号后,利用signal函数调用我们的自定义信号处理函数。
注意:我们显示写signal函数其实相当于注册了一个信号处理时的自定义行为,然后这个自定义行为handler不会平白无故被调用的,只有当对应信号发送给进程时,这个handler才会被调用,否则这个函数是永远不会被调用的。
那么handler方法什么时候调用? 当2号信号产生的时候,就被调用,相当于提前设定好了方法。
同时注意调用singal并没有调用handler方法,只是更改了2号信号函数指针的映射关系。
例如,如果要捕获SIGINT信号(由键盘中断产生),并调用一个名为my_handler的函数来处理它,可以这样写:
#include <signal.h>
#include <stdio.h>
void my_handler(int signum) {
printf("Received signal %d\n", signum);
}
int main() {
signal(SIGINT, my_handler); // 设置SIGINT的处理函数为my_handler
while (1) {
// do something
}
return 0;
}
signo:自动填充对应的信号
如果要恢复SIGINT的默认动作,可以这样写:
signal(SIGINT, SIG_DFL); // 设置SIGINT的处理函数为默认动作
如果要忽略SIGINT信号,可以这样写:
signal(SIGINT, SIG_IGN); // 设置SIGINT的处理函数为忽略
注意:9号信号不可被自定义!!
Linux支持多种信号,每种信号都有一个名称和一个编号。我们可以在signal.h头文件中查看它们的定义。一些常见的信号有:
- SIGTERM:终止信号,通常用来请求进程正常退出。
- SIGKILL:杀死信号,强制进程立即退出,不能被忽略或捕获。
- SIGSEGV:段错误信号,由于无效的内存访问产生。
- SIGALRM:闹钟信号,由alarm()函数设置的定时器到期时产生。(后面讲解)
- SIGCHLD:子进程终止信号,当一个子进程退出或停止时产生。
我们可以使用kill()函数或kill命令来向一个进程发送一个信号。例如,如果要向进程1234发送SIGTERM信号,可以这样写:
#include <signal.h>
#include <unistd.h>
int main() {
kill(1234, SIGTERM); // 向进程1234发送SIGTERM信号
return 0;
}
在命令行中输入:
kill -TERM 1234 # 向进程1234发送SIGTERM信号
一个热键是ctrl+\,这个组合键会被操作系统解析为3号信号SIGQUIT,这个信号的默认处理行为是Core,除终止进程外还会进行核心转储。Core于Term有什么不同?
1.2 core dump标志位、核心存储文件
core dump 标志位是一个退出信息中的一个位,用来表示进程是否产生了core dump文件。core dump文件是一个包含进程内存内容的文件,当进程意外终止时,由操作系统生成,用于后期调试。core dump标志位为1,表示进程产生了core dump文件;为0,表示没有产生。core dump标志位可以通过kernel.core_pattern这个系统参数来设定,这个参数指定了core dump文件的保存位置和格式。例如,执行命令sysctl -w kernel.core_pattern=/var/crash/core.%u.%e.%p
,就可以将core dump文件保存在/var/crash目录下,文件名为core.用户ID.可执行文件名.进程ID。
什么是核心存储文件?
核心存储文件,也叫core dump文件,是一个当程序运行过程中发生异常,程序异常退出时,由操作系统把程序当前的内存状况存储在一个文件中的文件。核心存储文件包含了程序运行时的内存状态、寄存器状态、堆栈指针、内存管理信息以及各个函数使用堆栈信息等等。核心存储文件的作用是用于后期调试,可以通过gdb等工具分析核心存储文件,定位程序出错的原因和位置。
所以core dump文件有什么用? 事后调试!
生产环境中服务器为什么要关闭核心存储文件,有以下几个原因:
- 核心存储文件通常很大,占用大量的磁盘空间,如果不及时清理,可能会影响服务器的性能和稳定性³。
- 核心存储文件可能包含敏感的数据,比如用户信息、密码、密钥等,如果被恶意获取或泄露,可能会造成安全风险³。
- 生产环境中的程序应该经过充分的测试和优化,不应该频繁出现异常退出的情况,如果出现了,应该及时修复,并通过日志或监控等手段进行分析和定位³。
简单说服务器重启可是会疯狂写core文件的,所以要关!
控制core dump文件开关的方法我们一般使用ulimit命令的-c选项
,可以设置core文件的生成开关和大小限制。例如,ulimit -c 0
表示关闭core dump功能,ulimit -c unlimited
表示不限制core文件的大小,ulimit -c 1024
表示限制core文件的大小为1024KB。这种方法是临时的,系统重启后会失效。
另外补充一个知识点,linux规定,当用户在和shell交互时,默认只能有一个前台进程,所以当我们自己编写的程序运行时,bash进程就会自动由前台进程转换为后台进程。
2.通过系统调用向进程发送信号
其实除上面那种用组合键或者是手动的通过kill指令加信号编号的方式给进程发送信号外,我们还可以通过系统调用的方式给进程发送信号。操作系统有向进程发送信号的能力,但是他并没有这个权力,操作系统的能力是为用户提供的,用户才有发送信号的权力,操作系统通过给用户提供系统调用赋予用户让OS向进程发送信号的权力。就像我们将来可能都会变成程序员,我们有写代码的能力,我们的能力是服务于公司或老板的,让我们写代码的权力来自于老板。
kill系统调用和raise、abort库函数都是用来发送信号的函数,但是有一些区别:
- kill系统调用可以向任意进程或进程组发送任意信号,只要发送者有足够的权限。它的原型是:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
它接受两个参数:pid是要发送信号的进程或进程组的标识,sig是要发送的信号的编号。它返回0表示成功,-1表示失败,并设置errno。
- raise库函数可以向自己发送一个信号,相当于kill(getpid(), sig)。它的原型是:
- 参数sig是要发送的信号的编号,可以参考kill -l命令查看信号列表
#include <signal.h>
int raise(int sig);
它接受一个参数:sig是要发送的信号的编号。它返回0表示成功,非0表示失败。
- abort库函数可以向自己发送一个SIGABRT信号,通常用来表示程序异常终止。它的原型是:
#include <stdlib.h>
void abort(void);
它没有参数和返回值,也不能被阻塞或忽略。如果SIGABRT信号没有被捕获或处理,程序会终止并生成一个core文件。
我们上面所说的raise()和abort()
都在man 3号手册,这代表他们都是库函数,而kill在2号手册,是纯正的系统调用。但3号手册的库函数可以分为两类,底层封装了系统调用的库函数和没有封装系统调用的库函数,很明显,raise和abort库函数就是底层封装了kill系统调用的库函数。就连kill指令底层其实也是封装的kill系统调用来实现的。
6号信号和2号信号的区别是什么?
- 6号信号是SIGABRT,表示进程异常终止,通常由abort()函数产生。它的默认动作是终止进程并生成一个core文件,用于保存进程的状态信息,方便调试。它不能被忽略或捕获。
- 2号信号是SIGINT,表示键盘中断,通常由Ctrl+C产生。它的默认动作是终止进程,但是它可以被忽略或捕获,并执行自定义的处理函数。
如果你在循环中使用abort()函数,那么程序会立即终止,并且不会再执行循环。如果你在循环中使用raise(SIGINT)函数,那么程序会收到2号信号,如果你没有设置信号处理函数,那么程序也会终止;如果你设置了信号处理函数,那么程序会执行你的处理函数,并且可能会继续执行循环。abort用于终止进程,类似于exit(),因为有了exit(),所以实际中我们很少用abort()
3.由软件条件产生信号
3.1 alarm函数和SIGALRM信号
- alarm函数是用来设置一个定时器,告诉内核在指定的秒数之后给当前进程发送一个SIGALRM信号。它的原型是:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
它接受一个参数:seconds表示定时器的时间间隔,单位为秒。如果seconds为0,则取消之前设置的定时器。它返回0或者是以前设定的闹钟时间还剩余的秒数。
- SIGALRM信号是由alarm函数产生的定时信号,表示定时器时间到达。它的默认动作是终止当前进程,但是它可以被忽略或捕获,并执行自定义的处理函数。
我们可以使用alarm函数和SIGALRM信号来实现定时任务,例如定时执行某个操作或定时检查某个状态。需要注意的是,由于alarm函数只能设置一个定时器,因此如果需要同时设置多个定时器,需要使用其他方法,例如使用select()或poll()函数来等待多个信号。
3.2 使用alarm()系统接口 验证 IO的效率 — 很慢
通过alarm闹钟,我们可以计算出1s内CPU能够累加数据多少次,下面测试的代码中其实分了两种情况进行测试,一种是每次将累加数据之后的结果打印到显示器上,一种是在1s内只进行数据的累加,等到1s到了的时候,我们捕捉信号在handler里面进行累加后数据的值的打印
实验结果二者差了大概1w多倍数,可以看到一旦访问外设CPU的执行速度就会慢下来,因为等待硬件就绪很慢,硬件就绪的时间和CPU计算的时间根本不在一个量级。
注意:我们可以设置剩余的alarm时间 — 一个进程只能设置一个闹钟 — 会覆盖,闹钟会通过pid标识是谁设置的
那为什么alarm闹钟是软件条件异常呢?
闹钟实际就是软件,他不就是数据结构和属性的集合吗?所以闹钟本身就是软件,当前进程可以设定闹钟,那么其他进程也可以设定闹钟,所以操作系统内部一定会存在很多的闹钟,那么操作系统要不要对这些闹钟进行管理呢?当然要,管理的方式就是先描述,再组织。所以闹钟在操作系统中实际就是内核数据结构,此内核数据结构用于描述闹钟,组织的最常见方式就是通过链表,但闹钟的组织方式也可以通过堆,也就是优先级队列来实现。
下面是闹钟内核数据结构的伪代码,其内部有一个闹钟响铃的时间,表示在当前进程的时间戳下,经过所传参数second秒后,闹钟就会响铃,这个响铃时间即为当前进程时间戳+second参数大小。
另外闹钟还需要一个PCB结构体指针,用于和设置闹钟的进程进行关联,在闹钟响了之后,便于操作系统向对应进程发送14号信号SIGALRM,此信号默认处理动作也是终止当前进程。
OS会周期性的检查这些闹钟,也就是通过遍历链表的方式,检查当前时间戳超过了哪个闹钟数据结构中的when时间,一旦超过,说明此闹钟到达设定时间,那么这个时候操作系统就该给闹钟对应的进程发送14号信号,如何找到这个进程呢?通过alarm类型的结构体指针便可以拿到alarm结构体的内容,其结构体中有一个字段便是PCB指针,通过PCB指针就可以找到闹钟对应的进程了。
除链表这样经典的组织方式之外,另一种组织方式就是优先级队列,priority_queue,实际就是堆结构,按照闹钟结构体中的when的大小建大堆,如果堆顶闹钟的时间小于当前进程时间戳,则说明整个堆中所有的闹钟均为达到响铃的条件。如果堆顶闹钟的时间大于当前进程时间戳,那就要给堆顶闹钟对应进程发送14号信号了,检查过后再pop堆顶元素,重新看下一个堆顶闹钟是否超时,大概就是这么一个逻辑。
4. 硬件异常产生信号
为什么代码除0会收到警告?其实作系统检测到后会立即向进程PCB位图中写入信号(通俗叫发送信号) — 8号信号 SIGFPE
int main()
{
int n =3;
cout << n /0 <<endl;
return 0;
}
代码除0会收到警告,是因为除0是一种硬件异常,也就是由处理器检测到的非法操作。当处理器执行除0指令时,它会产生一个中断信号,通知操作系统发生了错误。操作系统收到中断信号后,会根据中断向量表找到对应的中断处理程序,然后执行它。在这个过程中,操作系统会向进程的PCB位图中写入信号(通俗叫发送信号),这个信号就是8号信号 SIGFPE**,表示浮点异常**。如果进程没有捕捉或忽略这个信号,那么它的默认动作是终止进程,并打印出错误信息。所以,代码除0会收到警告,是因为操作系统根据硬件异常产生了一个信号,并执行了默认的终止动作。
那这样那我们利用signal()系统调用捕捉8号信号,为什么循环打印呢?因为MMU硬件报错一直都存在,没有被修复;那为什么没有退出进程?因为我们捕捉信号自定义处理方法了
MMU在哪里呢? 被集成到CPU里了,硬件!MMU就是帮助地址转换,帮助页表映射的硬件!
空指针会引起程序崩溃 – 11号信号
所以程序崩溃的根本原因不一定是硬件异常,也可能是软件异常,比如内存访问越界,空指针解引用,非法指令等。操作系统向目标进程发送信号,只是一种通知进程发生了异常的方式,并不一定导致程序崩溃。如果进程能够捕捉或忽略信号,或者执行自定义的处理动作,那么程序可能还能继续运行。所以,程序崩溃的根本原因是进程无法处理异常的情况。
那既然这么多信号默认处理都是干掉进程,那要这么多信号干什么?进程的死法不一样,我们人类要知道啊,就像是你定义的退出码一样。
总结思考一下:
1.上面所说的所有信号产生,最终都要有OS来进行执行,为什么?OS是进程的管理者
2.信号的处理是否是立即处理的?在合适的时候
3.信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
4.一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
5.如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
未完待续