1、简单介绍
Linux中的信号是一种机制,用于通知进程发生了某个事件。它可以由内核或一个进程发送给另一个进程。信号有默认行为,如终止进程或忽略信号,也可以被进程捕获并自定义处理。常见的信号包括SIGINT(中断信号)、SIGKILL(强制终止信号)和SIGTERM(终止信号)。信号的处理方式有忽略、捕获或执行默认操作。
2、信号的特点的引入
我们以生活之中的场景引入信号的概念。
<1> 在信号没有发生的时候,我们就已经知道了怎么处理信号 ,(类比我们看到绿灯时,就会通过人行道,看到红灯时,我们就会停下)。
<2>我们能够认识信号,是因为有人之前就在我们的大脑中设置了识别信号的方式(上学时,老师教我们识别红绿信号灯)。
<3>信号到来时,我们正在处理重要的时间,暂时不能处理这个信号,此时就要对信号进行保存。(比如在日常生活中,我们正在做饭,而手机上发来提醒你买票的消息,这时我们就要对该信号保存)
<4>信号的产生是随时的,我们无法准确的预测,所以信号是异步发送的。(异步:信号的产生都是由别人(用户、进程)决定的,我收到之前这个信号之前,我一直在忙我的事情,两者是并发处理的)
把上述场景的我变成进程,就是系统中进程面对信号的处理方式。
3、为什么要有信号?
进程在运行时,可能会出现错误,需要被删除,停止等等。系统要求进程有随时响应外部信号的能力,并且在随后能做出反应。
4、信号介绍前的准备
<1>查看信号的命令:kill -l
在这些信号中,没有0号、32号、33号信号,数字和字母等效,都表示信号。34号信号及以后的数据都是实时信号 -- 这种信号在被接受到后必须处理完后才能调度其他进程。本文对实时信号不做详细介绍。
下面是对每个信号较详细的介绍,稍后会对一些细节进行补充。
信号编号 | 信号名称 | 默认动作 | 描述 |
---|---|---|---|
1 | SIGHUP | 终止进程 | 挂起,通常因控制终端断开 |
2 | SIGINT | 终止进程 | 来自键盘的中断(Ctrl+C) |
3 | SIGQUIT | 创建核心转储并终止 | 来自键盘的退出(Ctrl+\) |
4 | SIGILL | 创建核心转储并终止 | 非法指令 |
5 | SIGTRAP | 创建核心转储 | 跟踪陷阱(调试用) |
6 | SIGABRT | 创建核心转储 | 进程发出的终止信号(abort) |
7 | SIGBUS | 创建核心转储 | 总线错误(内存访问错误) |
8 | SIGFPE | 创建核心转储 | 浮点异常 |
9 | SIGKILL | 终止进程 | 强制终止信号,不能被捕获或忽略 |
10 | SIGUSR1 | 终止进程 | 用户定义的信号1 |
11 | SIGSEGV | 创建核心转储 | 无效的内存引用 |
12 | SIGUSR2 | 终止进程 | 用户定义的信号2 |
13 | SIGPIPE | 终止进程 | 管道写出错误,没有读者 |
14 | SIGALRM | 终止进程 | 实时定时器到期 |
15 | SIGTERM | 终止进程 | 终止信号 |
16 | SIGSTKFLT | 终止进程 | 栈溢出(Linux 特定,不常用) |
17 | SIGCHLD | 忽略 | 子进程停止或终止 |
18 | SIGCONT | 忽略 | 如果进程已停止,则继续执行 |
19 | SIGSTOP | 停止进程 | 停止信号,不能被捕获或忽略 |
20 | SIGTSTP | 停止进程 | 终端停止信号(Ctrl+Z) |
21 | SIGTTIN | 停止进程 | 后台进程组尝试从终端读取 |
22 | SIGTTOU | 停止进程 | 后台进程组尝试写入终端 |
23 | SIGURG | 忽略 | 紧急情况,如网络带外数据到达 |
24 | SIGXCPU | 创建核心转储并终止 | 超过CPU时间限制 |
25 | SIGXFSZ | 创建核心转储并终止 | 超过文件大小限制 |
26 | SIGVTALRM | 终止进程 | 虚拟定时器到期 |
27 | SIGPROF | 终止进程 | Profile 定时器到期 |
28 | SIGWINCH | 忽略 | 窗口大小改变 |
29 | SIGIO | 终止进程 | 允许的I/O操作 |
30 | SIGPWR | 终止进程 | 电源故障(系统关机) |
31 | SIGSYS | 创建核心转储并终止 | 无效的系统调用 |
我们也可以使用” man 7 signal “命令查看对应信号的作用(具体以手册的解释为准)
<2>处理信号的简单接口:signal
当我们接受到信号时,默认有一下三个处理信号的方式:1、默认动作 2、自定义操作 3、忽略信号。
这个信号的接口的第一个参数就是传入对应的信号数,第二个参数是自定义函数指针,手册上对该函数的类型进行了重命名。我们在调用signal函数时,signal在内部就会调用handler方法,并将signum作为参数传递给handler。返回值,就是原来处理信号的方法。
下面简单地演示一下用法:
#include<stdio.h>
#include<stdbool.h>
#include<unistd.h>
#include<signal.h>
void handler(int sig)
{
printf("hello sign\n");
}
int main()
{
while(1)
{
signal(2,handler);
sleep(1);
printf("hello world\n");
}
return 0;
}
运行结果:
这里ctrl + c 默认地被系统解释成2号信号。
5、信号的产生
下面开始正式介绍信号的相关部分,先从信号的产生开始。信号的产生一般有许多方式,下面是主要的方式:
<1>kill 命令
前面简单介绍了kill命令,”kill + 指定信号 + 发送进程的pid“就可以向指定进程发送信号。
<2>键盘产生信号
-
SIGINT(中断信号,通常是 Ctrl+C):当用户在键盘上按下 Ctrl+C 组合键时,它会发送 SIGINT 信号给前台进程组中的所有进程,通常用于请求终止正在运行的程序。
-
SIGTSTP(终端停止信号,通常是 Ctrl+Z):当用户在键盘上按下 Ctrl+Z 组合键时,它会发送 SIGTSTP 信号给前台进程,导致进程停止(挂起)。
-
SIGQUIT(退出信号,通常是 Ctrl+\):当用户在键盘上按下 Ctrl+\ 组合键时,它会发送 SIGQUIT 信号给前台进程,通常会导致进程退出并生成核心转储文件。
-
SIGHUP(挂起信号,通常是挂起终端或网络断开):当终端会话关闭或网络连接断开时,前台进程会收到 SIGHUP 信号,默认行为是终止进程。
-
SIGWINCH(窗口大小改变信号):当终端窗口的大小发生变化时,如用户调整终端窗口的大小,会产生 SIGWINCH 信号,接收到该信号的程序可以据此调整其输出格式。
我们着重关注前3个信号即可。
<3>系统调用
系统中也有接口可以产生系统调用,下面介绍一下kill命令
1、kill
这里的参数pid有四种设法,不过这里只设为所要接受信号pid的进程,方便理解。sig就是所要发射的信号。
#include<cstdio>
#include<iostream>
#include<stdbool.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
int main(int argc, char* argv[])
{
if(argc != 3)
{
sleep(1);
printf("command false : kill + signal + pid\n");
}
else
{
int sig = std::stoi(argv[1] + 1);
pid_t pid = std::stoi(argv[2]);
if(sig > 0 && sig <= 31)
{
if(!kill(pid,sig))
{
printf("killed\n");
}
else
{
printf("killed failed\n");
}
}
}
return 0;
}
这里对结果就不做演示了,可自行运行。
2、raise
该接口用于给自己发送信号,sig就是需要发送的信号。
3、abort
这个接口也是用于终止进程的,效果与raise相差不大,不过还是有些许差别。
4、killpg
这个接口用于向所有某一个信号组发送消息
如果指定 pgrp 的值为 0,那么会向调用者所属进程组的所有进程发送此信号,这个接口我们一般不使用,了解即可。
<4>软件条件
举一个我们熟悉的例子,那就是管道,当我们的管道写端不断地写,而读端已经关闭,那此时进程就会收到SIGPIPE信号,终止进程,这是因为软件的条件不满足而发出了信号。
下面介绍另一个更为重要的软件条件产生信号的例子:闹钟
这里的闹钟和我们日常生活中的闹钟类似,都是在未来的某一段时间内发送一个信号。这里就是给OS发送信号。
我们可以传入一个无符号整型,表示要对在几秒后发射信号。这个信号为14号信号,也就是SIGALRM。返回值为0或上一个闹钟的剩余时间。这个闹钟函数只会响一次,如果我们要闹钟一直响,那么我们可以捕捉信号,在自定义处理中再设一次闹钟,这样就可以使闹钟一直响。如果闹钟的参数为零,那就表示取消闹钟。
在OS系统中有许多的闹钟,用于提醒系统进行某些操作,OS为了管理这些闹钟,就必须设定一个结构体对其进行描述与管理。其中这个结构体中包含了每个闹钟的过期时间,这个时间是以时间戳为基本单位的,时间戳在OS中是线性增长的,OS将当前时间与过期时间对比,就能判断闹钟是否过期。如果过期,OS就会释放这个闹钟的相关资源,同时闹钟也会向对应的进程发送信号。为了方便管理,OS依据过期时间的大小,将描述每个闹钟的结构体进行小根堆排序,这样就能高效地对闹钟资源进行管理。
<5>异常
当代码出现异常时,也会产生信号。
1、除零异常
当我们代码中出现除零错误时,进程会收到SIGFPE信号(8号)。
2、出现野指针
当我们的代码中出现野指针访问时,进程会收到SIGSEGV信号(11信号)
下面对上述两种情况的产生机制做一下简单的解释。
<1>除零异常
在cpu中存在许多的寄存器,参与运算的寄存器有很多,我们只简单地列出所需的几个。ebx和eax为常用的几个通用寄存器,这里我们假定它们存储了除数和被除数。当cpu在进行除零操作时,就会出现错误,此时cpu会将标志寄存器置为异常状态(OF)。然后cpu会通知OS,当OS读取到OF状态时,就会向对应的信号发送SIGFPE信号。
在实际的应用场景中,进程收到信号,可能并不作处理,进程还会一直运行,而寄存器中的上下文数据又无法修改,OS就会一直向进程发信号。所以在一些服务出现问题后,并不是终止进程,而是一直向日志输出消息。
<2>野指针
在使用野指针访问数据前,寄存器eax中会存储野指针的虚拟地址,再通过CR3寄存器获取页表目录页地址,通过这两个寄存器中的数据,MMU会将对应的虚拟地址转成物理地址,如果转换失败,cpu会在CR2寄存其中写入数据,然后OS检测到CR2寄存器中的数据异常,向对应的进程发送SIGSEGV信号。
除了上述几种异常产生的信号,还有别的异常会产生信号,这里不做详细的介绍。
6、总结
下面对上面的信号产生情况做一些总结
我们以键盘为产生信号为例,总结出一些规律。首先,我们需要知道的是键盘是一种字符设备,当我们敲击键盘时,获取的都是字符。那么OS是如何获取键盘中的字符的呢?
首先,我们需要知道的是,键盘通过主板上的对应线路能够连接到cpu上。当我们敲击键盘后,键盘就会发出信号,此时cpu上的针脚(连接着键盘,中间会经过很多线路,这里不详细说明,接受硬件的信号)就会被触发,需要注意的是,cpu上有许多的针脚,每个针脚有对应的编号,该过程也是硬件中断的一种。针脚被触发点亮后,特定寄存器就会存储针脚的编号。cpu的针脚被点亮后,cpu会检测到寄存器中存储了信息,此时会要求OS停止当前进程,去取出寄存器中的编号,查找对应的中断向量表(该表就是一张函数指针数组,里面存放了各种中断的处理方式)中查询对应的方法,并执行。在这里的方法就是读取键盘的数据。
读取到键盘数据以后,就需要对键盘数据进行解释判断。如果字符,那么就让数据刷新到内核缓冲区中,如果是别的组合键(例如ctrl + c 、ctrl + z等等)就解释成为信号,发送给对应的进程。那么信号是如何被信号发送与接收的呢?
当进程接收到信号时,可能在处理别的事物,此时,我们需要信号进行保存。而这个信号就被保存在进程的PCB中,内核中会使用一个无符号整型uint32_t对其进行保存。这里利用了位图方式,31种信号进行保存(进程收到哪个信号,就改对应比特位即可,最低位不参与存储信号)。
所以信号的发送,其实就是向PCB中写信号,而PCB属于内核数据结构,只有OS能对其进行修改,而我们普通用户想修改存储信号的位图,就必须通过系统调用来实现信号的发送。归根结底,上面几种产生信号的方式,底层都与系统调用挂钩。