目录
认识信号
信号是信息传递的承载方式,一种信号往往代表着一种执行动作,用来通知进程系统中发生了一个某种类型的事件。比如:
- 鸡叫 => 天快亮了
- 闹钟 => 起床、完成任务
- 红绿灯 => 红灯停,绿灯行
- 在生活中,“信号”是一种触发特定反应的提示或标志。比如红绿灯信号会让我们做出停车或通行的动作
- 在计算机中,“信号”(Signal)是一个特殊的软件中断,用于通知进程发生了某些事件。它是一种进程间通信机制,用于在进程之间传递简短的同步信息
当前系统中的 进程信号,一共62个,其中1-31号信号为 普通信号,用于 分时操作系统;剩下的34-64号信号为 实时信号,用于 实时操作系统。
- 普通信号(1-31号):这些信号是传统的标准信号,主要用于分时操作系统。它们是不可靠的,因为当信号多次产生时,系统只记录信号的存在,而不会排队保存多次信号。例如,SIGINT(信号2)用于处理用户按下Ctrl+C的情况,而SIGTERM(信号15)用于请求进程终止。
- 实时信号(34-64号):实时信号从34号开始,用于实时操作系统。这些信号是可靠的,支持排队,即多次信号可以被依次保存在队列中。实时信号主要用于需要高响应性的场景,例如嵌入式系统、汽车电子等。
- 关于32号和33号信号:32号和33号信号被跳过,是因为它们被旧版POSIX线程库(LinuxThreads)保留用于内部通信。
- 分时操作系统:适合处理不需要严格实时性的任务,信号数量有限,主要通过时间片调度实现公平性。
- 实时操作系统:适合处理对时间敏感的任务,如汽车车机、火箭发射控制台等,要求高响应性。
详细解析:普通信号“不可靠的,因为当信号多次产生时,系统只记录信号的存在,而不会排队保存多次信号”
1.信号的“合并”
如果一个普通信号在被处理之前多次触发,系统只会记录信号存在一次,而不会记录信号触发的次数。例如:
假设一个进程正在运行,用户连续按下多次Ctrl+C(会触发SIGINT信号)。系统只会记录一次SIGINT信号,而不会记录多次。
2. 信号的“覆盖”
如果一个普通信号在被处理之前被另一个信号覆盖,那么系统只会记录最后一次触发的信号。例如:
假设一个进程同时收到SIGINT和SIGTERM信号。如果SIGINT先触发,但SIGTERM后触发,系统只会记录SIGTERM信号,而忽略之前的SIGINT信号。
3. 普通信号的不可靠性
由于普通信号不会排队保存,它们在某些场景下可能会导致信号丢失或行为不可预测。例如:
如果一个进程正在处理一个信号,而另一个信号同时触发,系统可能会丢失后一个信号。如果信号处理函数执行时间过长,可能会导致后续信号被忽略。
- 也就是说普通信号只会处理最新的信号
信号特征
进程信号由 信号编号 + 执行动作 构成,一个信号对应一种动作,创造信号的目的不只是控制进程,还要便于管理进程,进程的终止原因有很多种,如果一概而论的话,对于问题分析是非常不友好的,所以才会将信号细分化,搞出这么多信号,目的就是为了方便定位、分析、解决问题。并且普通信号就31个,这就是意味着所有普通信号都可以通过位图数据结构存储在一个 int 中,表示是否收到该信号(信号的保存)。
信号细分化的目的
信号的细分化确实不仅仅是为了控制进程,更重要的是为了便于管理和调试进程。不同的信号代表了不同的事件或问题,通过细分信号,可以实现以下目标:
-
精确定位问题:不同的信号对应不同的事件或错误类型。例如:
-
SIGSEGV
(段错误)表示进程访问了非法内存。 -
SIGFPE
(浮点异常)表示发生了算术运算错误。 -
SIGTERM
和SIGKILL
分别表示“请求终止”和“强制终止”,前者允许进程优雅退出,后者直接终止进程。
-
-
灵活的处理策略:进程可以根据接收到的信号类型采取不同的处理策略。例如,对于
SIGINT
(用户中断),进程可以选择保存当前状态并优雅退出;而对于SIGKILL
,进程则无法捕获,只能被强制终止。 -
调试和分析:细分化的信号为调试和分析提供了便利。通过查看进程接收到的信号,可以快速定位问题的根源。
普通信号的存储:位图数据结构
你提到的“位图数据结构”是一个非常关键的实现细节。在Linux系统中,普通信号的存储确实利用了位图(Bitmap)的方式,具体如下:
-
位图存储原理:位图是一种非常高效的数据结构,通过位(bit)来表示某种状态。由于普通信号的编号范围是1-31,总共31个信号,可以用一个32位的整数(
int
)来存储这些信号的状态。-
每个信号对应一个位。例如,信号1对应第1位,信号2对应第2位……信号31对应第31位。
-
如果某位为1,则表示对应的信号已经触发;如果为0,则表示未触发。
-
-
存储位置:在Linux内核中,每个进程的信号状态存储在进程的
task_struct
结构中,具体是sigpending
和signal
字段。这些字段使用位图的方式存储信号的状态。 -
效率优势:位图存储方式非常高效,占用空间小(仅需一个
int
),并且操作简单(通过位运算即可完成信号的设置、清除和检查)。
详解信号的数据结构
signal
是一个指向signal_struct
的指针,signal_struct
是线程组(进程组)级别的信号状态结构体。
signal_struct
的作用
线程组共享的信号状态:
signal_struct
用于管理线程组级别的信号状态,而不是单个线程的状态。共享的未决信号集:
signal_struct
包含一个共享的未决信号集(shared_pending
),记录了线程组中所有线程共享的未决信号。信号处理的负载均衡:
signal_struct
提供了信号处理的负载均衡机制,选择当前线程组中用于信号处理的目标线程。线程组退出管理:
signal_struct
包含线程组退出时的状态码和退出任务指针,用于管理线程组的退出行为。
sighand
是一个指向sighand_struct
的指针,sighand_struct
是信号处理函数表的结构体。
sighand_struct
的作用
信号处理函数表:
sighand_struct
包含一个信号处理函数表,记录了每个信号的处理方式。信号处理方式:每个信号可以设置为默认行为、忽略或用户自定义的处理函数。
blocked
是一个sigset_t
类型的位图,记录了当前进程阻塞的信号集。作用
阻塞信号集:
blocked
记录了哪些信号被当前进程阻塞。如果某个信号被阻塞,它不会被立即递达,而是进入未决状态。信号屏蔽:进程可以通过
sigprocmask
系统调用修改blocked
集,从而屏蔽或解除屏蔽某些信号。
pending
是一个sigpending
结构体,记录了当前进程的未决信号集。
sigpending
的作用
未决信号集:
pending
记录了哪些信号已经产生但尚未被处理。未决信号可以是线程组共享的(shared_pending
)或线程私有的。信号递达:当信号解除阻塞时,内核会从
pending
中取出信号并递达。
signal_struct
:线程组(进程组)级别的信号状态结构体,管理线程组共享的信号状态。
sighand_struct
:信号处理函数表的结构体,记录每个信号的处理方式,通常在线程组中的所有线程之间共享。
blocked
:每个线程独立的阻塞信号集,记录当前线程阻塞的信号。
sigpending
:每个线程独立的未决信号集,记录当前线程的未决信号。
sigset_t信号集
无论是 block
表 还是 pending
表,都是一个位图结构,依靠 除、余 完成操作,为了确保不同平台中位图操作的兼容性,将信号操作所需要的 位图 结构封装成了一个结构体类型,其中是一个 无符号长整型数组:
/* A `sigset_t' has a bit for each signal. */
# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
#endif
- _SIGSET_NWORDS 大小为 32,所以这是一个可以包含 32个 无符号长整型 的数组,而每个 无符号长整型 大小为 4 字节,即 32比特,至多可以使用 1024 个比特位。
- sigset_t 是信号集,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,可以通过信号集操作函数进行获取对应的信号集信息;信号集 的主要功能是表示每个信号的 “有效” 或 “无效” 状态:在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
block
表通过信号集称为 阻塞信号集或信号屏蔽字(屏蔽表示阻塞),pending
表 通过信号集中称为 未决信号集
为什么需要 1024 个比特位?
实时信号的支持:
- 实时信号(从
SIGRTMIN
到SIGRTMAX
)的数量可以远超普通信号。在 Linux 系统中,实时信号的数量通常在 32 到 64 之间,但为了确保兼容性和扩展性,sigset_t
被设计为能够容纳更多的信号。sigset_t
的大小(1024 个比特位)是为了确保能够处理所有可能的信号,包括普通信号和实时信号。兼容性和扩展性:
sigset_t
的设计目标是确保在不同平台上具有兼容性。通过使用一个较大的位图结构,可以避免因信号数量增加而导致的兼容性问题。1024 个比特位可以容纳最多 1024 个信号,这为未来的扩展提供了足够的空间。
如何根据 sigset_t 位图结构进行比特位的操作?
假设现在要获取第 127 个比特位
- 首先定位数组下标(对哪个数组操作):127 / (8 * sizeof (unsigned long int)) = 3
- 求余获取比特位(对哪个比特位操作):127 % (8 * sizeof (unsigned long int)) = 31
对比特位进行操作即可:
假设待操作对象为 XXX
- 置 1:XXX._val[3] |= (1 << 31)
- 置 0:XXX._val[3] &= (~(1 << 31))
所以可以仅凭 sigset_t 信号集,对 1024 个比特位进行任意操作。
信号集操作函数
对于 信号 的 产生或阻塞 其实就是对 block
和 pending
两张表的 增删改查:
- 增:
|
操作,将比特位置为1
- 删:
&
操作,将比特位置为0
- 改:
|
或&
操作,灵活变动 - 查:判断指定比特位是否为
1
即可
函数 | 返回值 | 参数 | 备注 |
int sigemptyset(sigset_t *set); //初始化信号集
| 成功返回 失败返回 | 参数set:待操作的信号集变量 参数signum:待操作的比特位 | 函数sigemptyset初始化 set 所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号
注意:在使用sigset_t t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t 变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。 |
int sigprocmask(int how, const sigset_t *restrict set, sigset_t *restrict oldset); | 成功时返回 失败时返回 | 参数how: 指定对信号掩码的操作方式,可选值包括:
| |
int sigpending(sigset_t *set); | 成功时返回 失败时返回 |
|
|
代码演示:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
// 打印信号掩码
void printSignalMask(const sigset_t *mask) {
printf("Signal Mask: ");
for (int i = 1; i <= 31; i++) {
if (sigismember(mask, i)) {
printf("1");
} else {
printf("0");
}
}
printf("\n");
}
// 打印待处理信号
void printPendingSignals() {
sigset_t pending;
sigpending(&pending);
printf("Pending Signals: ");
for (int i = 1; i <= 31; i++) {
if (sigismember(&pending, i)) {
printf("1");
} else {
printf("0");
}
}
printf("\n");
}
// 信号处理函数
void sig_handler(int sig) {
printf("Signal %d delivered\n", sig);
}
int main() {
sigset_t set, oldset, current_mask;
// 初始化信号集
sigemptyset(&set);
sigaddset(&set, SIGINT); // 添加 SIGINT (Ctrl+C) 到信号集
sigaddset(&set, SIGQUIT); // 添加 SIGQUIT (Ctrl+\) 到信号集
// 设置信号处理函数
signal(SIGINT, sig_handler);
signal(SIGQUIT, sig_handler);
// 打印初始信号掩码
sigprocmask(SIG_BLOCK, NULL, ¤t_mask);
printf("Initial Signal Mask:\n");
printSignalMask(¤t_mask);
printPendingSignals();
// 1. 使用 SIG_BLOCK:阻塞 SIGINT 和 SIGQUIT
printf("\nBlocking SIGINT and SIGQUIT...\n");
sigprocmask(SIG_BLOCK, &set, &oldset);
sigprocmask(SIG_BLOCK, NULL, ¤t_mask); // 获取当前信号掩码
printSignalMask(¤t_mask);
printPendingSignals();
// 发送 SIGINT 和 SIGQUIT 到自身
printf("Sending SIGINT and SIGQUIT to myself...\n");
kill(getpid(), SIGINT);
kill(getpid(), SIGQUIT);
// 再次打印信号掩码和待处理信号
printSignalMask(¤t_mask);
printPendingSignals();
// 2. 使用 SIG_UNBLOCK:解除阻塞 SIGINT 和 SIGQUIT
printf("\nUnblocking SIGINT and SIGQUIT...\n");
sigprocmask(SIG_UNBLOCK, &set, NULL);
sigprocmask(SIG_BLOCK, NULL, ¤t_mask); // 获取当前信号掩码
printSignalMask(¤t_mask);
printPendingSignals();
// 等待信号处理
sleep(1);
// 3. 使用 SIG_SETMASK:直接设置信号掩码为初始状态
printf("\nSetting signal mask to the old state...\n");
sigprocmask(SIG_SETMASK, &oldset, NULL);
sigprocmask(SIG_BLOCK, NULL, ¤t_mask); // 获取当前信号掩码
printSignalMask(¤t_mask);
printPendingSignals();
return 0;
}
演示结果:
先将信号 阻塞,信号发出后,无法 递达,始终属于 未决 状态;当阻塞解除后,信号可以 递达,信号处理之后,未决表中不再保存信号相关信息,因为已经处理了。
信号在发出后,在处理前,都是保存在 未决表 中的
信号的生命周期管理
信号产生
(一)通过终端按键产生信号
通俗来说就是命令行操作。
- 在Linux下输入命令可以在Shell下启动一个前台进程,当我们想要终止一个前台进程时,我们可以按下 Ctrl + C 来进行终止这个前台进程,其实这个 Ctrl + C 也是一个信号,它对应的信号的2号信号SIGINT,这个信号对应的默认处理动作就是终止当前的前台进程。
- 用户按下 Ctrl + C,这个键盘输入产生一个硬件中断 ,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。
硬件中断的处理过程
键盘按下事件
- 当你按下键盘上的键(如 Ctrl + C)时,键盘硬件会产生一个中断请求信号。这个信号会通过硬件线路发送到中断控制器。
中断控制器的作用
- 中断控制器的作用是管理多个硬件中断请求,并将这些请求传递给 CPU。它会根据中断的优先级和配置,将中断信号发送到 CPU 的中断引脚。
CPU 处理中断
- CPU 接收到中断信号后,会暂停当前正在执行的任务,并保存当前的上下文(如寄存器状态)。
- 中断控制器会向 CPU 提供一个中断向量(中断号),这个中断号是一个编号,用于指示是哪个硬件设备发起的中断请求。
中断向量表的作用
- CPU 根据中断向量号在中断向量表中查找对应的中断处理程序(中断服务例程,ISR)。
- 中断向量表是一个存储在内存中的表,其中每个条目指向一个特定的中断处理程序。
执行中断处理程序
- CPU 跳转到中断向量表中指定的地址,执行对应的中断处理程序。
- 对于键盘中断,这个程序通常由操作系统内核提供,用于处理键盘输入。
键盘中断处理程序的工作
- 键盘中断处理程序会读取键盘硬件的状态寄存器,获取按键信息(如按键的扫描码)。
- 处理程序会将扫描码转换为字符(如 Ctrl + C 被识别为特定的按键组合。
- 如果是特殊组合键(如 Ctrl + C),操作系统会进一步解析这个按键组合,并将其映射为一个信号(如 SIGINT)。
信号的生成和传递
- 操作系统内核会根据按键组合生成一个信号(如 SIGINT,编号为 2)。
- 操作系统将这个信号发送到当前的前台进程(通常是终端中的用户程序)。
(二)系统调用产生信号
函数 | 返回值 | 参数 | 备注 |
int kill(pid_t pid,int sig); | 成功返回 0 ,失败返回 -1 并设置错误码 | 参数1:待操作进程的 参数2:待发送的信号 | |
int raise(int sig); | 成功返回 0 ,失败返回 非0 | 参数:待发送的信号 | 可以这样理解:raise 是对 kill 函数的封装,每次传递的都是自己的 PID |
void abort(void) | abort 函数使当前进程接收到信号而异常终止,abort 函数其实是向进程发送6号信号SIGABRT ,就像exit函数一样,abort 函数总是会成功的,所以没有返回值,值得注意的是就算6号信号被捕捉了,调用abort 函数还是会退出进程 abort() 函数通过调用 raise(SIGABRT) 向当前进程发送 SIGABRT 信号 | ||
![]() |
(三)由软件条件产生信号
例如:管道读写时,如果读端关闭,那么操作系统会发送信号终止写端,这个就是 软件条件 引发的信号发送,发出的是 13
号 SIGPIPE
信号。
闹钟产生信号:
函数 | 返回值 | 参数 | 备注 |
unsigned int alarm(unsigned int seconds); | 返回值是上次设置的定时器剩余时间(单位为秒),如果之前没有设置定时器,则返回 0。 如果调用失败,返回值通常为 0,但可以通过检查 errno 来获取错误信息。 | 指定定时器的时长,单位为秒。定时器将在 seconds 秒后到期。如果传入的值为 0,则会取消当前已设置的任何定时器。 |
如果在定时器到期之前再次调用 闹钟是一次性的,只能响一次 |
(四)硬件异常产生信号
硬件异常产生信号是指硬件产生了错误并以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如:在写程序最常遇到的各种报错,比如 除 0、野指针。
除0导致异常
代码演示:
void handler(int signo)
{
cout << "程序除0,但是不终止进程" << endl;
sleep(1);
}
int main()
{
// 除0异常
signal(SIGFPE, handler);
int a = 10;
a /= 0;
return 0;
}
演示结果:
解析:
- 在 CPU 中,存在很多 寄存器,其中大部分主要用来存储数据信息,用于运算,除此之外,还存在一种特殊的 寄存器 =》 状态寄存器,这个 寄存器 专门用来检测当前进程是否出现错误行为,如果有,就会把 状态寄存器(位图结构)中对应的比特位置 1,意味着出现了 异常。
- 比如上面的 除 0 代码,发生异常后,CPU 将 状态寄存器 修改,变成 异常状态,操作系统检测到 异常 后会向进程发送 8 号信号,即使我们将原来 8 号信号默认的终止进程动作修改了打印动作,但 因为状态寄存器比特位仍然为1,处于异常状态,所以操作系统才会不断发送 8 号信号,所以才会死循环式的打印。
野指针导致异常
代码演示:
int main()
{
int* ptr = nullptr;
*ptr = 10;
return 0;
}
演示结果:
解析:
系统提示我们发生了段错误,对于野指针问题,其实也是我们进程收到了操作系统发送的信号而崩溃的,这个信号是
11
号信号SIGSEGV,而这一次硬件异常的是MMU单元(内存管理单元):野指针问题主要分为两类:
1.指向不该指向的空间
- 指向不该指向的空间:这很好理解,就是页表没有将 这块虚拟地址空间 与 真实(物理)地址空间 建立映射关系,此时进行访问时 MMU 识别到异常,于是 MMU 直接报错,操作系统识别到 MMU 异常后,向对应的进程发出终止信号。
备注:
- C语言中对于越界读的检查不够严格,属于抽查行为,因此野指针越界读还不一定报错,但越界写是一定会报错的。
2.权限不匹配,比如只读的区域去写
- 页表中除了保存映射关系外,还会保存该区域的权限情况,比如
是否命中 / RW
等权限。当发生操作与权限不匹配时,比如nullptr
只允许读取,并不允许其他行为,此时解引用就会触发 MMU 异常,操作系统识别到后,同样会对对应的进程发出终止信号。所以对于
0
地址可能操作系统根本没有给0
地址建立映射关系,或者建立了映射关系但是操作系统不会允许0
地址处发生写入!而当我们进行*p = 10
时,是需要进行写入的,MMU 在地址转换时发现权限不一致,进而引发给异常,报告给了操作系统,然后操作系统向我们的的进场发送SIGSEGV信号。
核心转储
Linux中提供了一种系统级别的能力,当一个进程在出现异常的时候,OS可以将该进程在异常的时候,核心代码部分进行 核心转储,即将内存中进程的相关数据,全部 dump 到磁盘中,一般会在当前进程的运行目录下,形成 core .pid 这样的二进制文件(核心转储 文件)。
对于某些信号来说,当终止进程后,需要进行 core dump ,产生核心转储文件
比如:3号 SIGQUIT、4号 SIGILL、5号 SIGTRAP、6号 SIGABRT、7号 SIGBUS、8号 SIGFPE、11号 SIGSEGV、24号 SIGXCPU、25号 SIGXFSZ、31号 SIGSYS 都是可以产生核心转储文件的。
不同信号的动作
- Trem-> 单纯终止进程
- Core-> 先发生核心转储,生成核心转储文件(前提是此功能已打开),再终止进程
通过指令 ulimit -a
查看当前系统中的资源限制情况
- 当前系统中的核心转储文件大小为
0
,即不生成核心转储文件
通过指令手动设置核心转储文件大小
ulimit -c [指定大小]
核心转储文件是很大的,而有很多信号都会产生核心转储文件,所以云服务器一般默认是关闭的
- 云服务器上是可以部署服务的,一般程序发生错误后,需要立刻被云服务器中的检测程序发现,并及时重启。
- 如果打开了核心转储,一旦程序 不断挂掉、又不断重启,每一次进程挂掉都会生成一个 core 文件,进而导致硬盘占满,导致系统 IO 异常,最终会导致整个服务器挂掉的。
- 还有一个重要问题是 core 文件中可能包含用户密码等敏感信息,不安全。
核心转储作用-调试
核心转储文件可以调试,并且直接从出错的地方开始调试,这种调试方式叫做 事后调试
调试方法:
- gcc / g++ 编译时加上 -g 生成可调试文件
- 运行程序,生成 core-dump 文件
- gdb 程序 进入调试模式
- core-file core.file 利用核心转储文件,快速定位至出错的地方
备注:
当进程异常退出时(被信号终止),不再设置退出码,而是设置
core dump
位及终止信号 ,父进程可以借此判断子进程是否产生了 核心转储 文件,当系统生成 core 文件时,标志位就被置 1 ,否则被置 0 。
信号保存
信号从产生到执行,并不会被立即处理,这就意味着需要一种 “方式” 记录信号是否产生,对于 31 个普通信号来说,一个 int 整型就足以表示所有普通信号的产生信息了;信号还有可能被 “阻塞”,对于这种多状态、多结果的事物,操作系统会将其进行描述、组织、管理,这一过程称为信号保存阶段。
信号传递过程
信号产生 -> 信号未决 -> 信号递达
1.信号产生(Produce
):由四种不同的方式发出信号。
信号产生是指信号被建并发送到目标进程的过程。信号可以通过以下四种方式产生:
硬件异常
- 硬件检测到异常(如除以零、非法内存访问等)并通知 CPU,操作系统随后向进程发送信号(如 SIGSEGV 或 SIGFPE)
软件事件
- 操作系统内部检测到某些事件(如超时、资源限制等)并向进程发送信号(如 SIGALRM 或 SIGXCPU)
用户操作
- 用户通过终端操作(如按下 Ctrl+C 触发 SIGINT 或 Ctrl+\ 触发 SIGQUIT)向进程发送信号。
进程间通信
- 一个进程通过 kill()、raise() 或 pthread_kill() 等系统调用向另一个进程发送信号。
2.信号未决 (Pending
):信号从产生到递达之间的状态。
信号未决是指信号已经被产生,但尚未递达目标进程的状态。在这个阶段,信号被记录在目标进程的未决信号集中。未决信号可能因为以下原因尚未递达:
信号被阻塞
- 如果信号被目标进程阻塞(通过 sigprocmask() 设置),它会被记录在未决信号集中,直到被解除阻塞。
进程处于不可中断状态
- 如果目标进程处于某些不可中断的状态(如等待 I/O 操作),信号会被延迟递达。
信号队列已满
- 对于实时信号,如果信号队列已满,新产生的信号会被记录为未决状态,直到队列中有空间。
3.信号递达(Delivery
):进程收到信号后,实际执行信号的处理动作。
信号递达是指信号被实际传递给目标进程并执行其处理动作的过程。递达过程包括以下步骤:
检查未决信号集
- 当进程从阻塞状态恢复或进入内核态时,操作系统会检查未决信号集。
解除阻塞
- 如果信号已被解除阻塞,操作系统会从未决信号集中移除该信号,并准备递达。
执行信号处理
- 如果进程设置了信号处理函数,操作系统会调用该处理函数。
- 如果进程没有设置信号处理函数,操作系统会执行默认行为(如终止进程、生成核心转储文件等)。
恢复进程执行
- 信号处理完成后,进程会恢复执行。
信号阻塞 是一种手段,可以发生在 信号处理 前的任意时段。进程可以选择阻塞 (Block )某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
备注:
阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。二者的效果差不多:什么都不干,但前者是 干不了,后者则是 不干了,需要注意区分。
信号存在状态
对于传递中的信号来说,需要存在三种状态表达:
- 信号是否阻塞
- 信号是否未决
- 信号递达时的执行动作
在内核中,每个进程都需要维护这三张与信号状态有关的表:block
表、pending
表、handler
表:
- block表:该位图结构里面的对应位置的比特位是否为1,代表了该信号是否被阻塞。
- pending表:该位图结构里面的对应位置的比特位是否为1,代表了该信号是否是未决状态。
- handler表:该表里面存放的是函数指针,对应下标里面的函数指针表示收到该信号要调用的函数是哪一个。
状态修改
对于信号的状态修改,其实就是修改 位图 中对应位置的值(0/1)。
- 假设已经获取到了信号的 pending 表
- 只需要进行位运算即可:pending |= (1 << (signo - 1))
- 其中的 signo 表示信号编号,-1 是因为信号编号从 1 开始,需要进行偏移
- 如果想要取消未决状态也很简单:pending &= (~(1 << (signo - 1)))
- 阻塞 block表,与 pending 表 一模一样。
整个三张表里面,数据在逻辑上是横向传递的。每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。
解析:
- SIGHUP 信号未被阻塞,未产生,一旦产生了该信号,pending 表对应的位置置为 1,当信号递达后,执行动作为默认。
- SIGINT 信号被阻塞,已产生,pending 表中有记录,此时信号处于阻塞状态,无法递达,一旦解除阻塞状态,信号递达后,执行动作为忽略该信号。
- SIGQUIT 信号被阻塞,未产生,即使产生了,也无法递达,除非解除阻塞状态,执行动作为自定义。
信号在 产生 之前,可以将其 阻塞,信号在 产生 之后(未决),依然可以将其 阻塞
信号处理
信号的处理时机
- 普通情况:指信号没有被阻塞,直接产生,记录未决信息后,再进行处理;在这种情况下,信号是不会被立即递达的,也就无法立即处理,需要等待"合适"的时机。
- 特殊情况:当信号被阻塞后,信号 产生 时,记录未决信息,此时信号被阻塞了,也不会进行处理;当阻塞解除后,信号会被立即递达,此时信号会被立即处理。
合适的时机:进程从内核态返回到用户态时,会在操作系统的指导下,对信号进行检测及处理。至于处理动作,分为:默认动作、忽略、用户自定义。
“信号的产生是异步的”
也就是说,信号可能随时产生,当信号产生时,进程可能在处理更重要的事,此时贸然处理信号显然不够明智。因此信号在产生后,需要等进程将更重要的事忙完后(合适的时机),才进行处理。
内核态与用户态的转化
用户态切换为内核态:
- 当进程时间片到了之后,进行进程切换动作
- 调用系统调用接口,比如 open、close、read、write 等
- 产生异常、中断、陷阱等
内核态切换为用户态:
- 进程切换完毕后,运行相应的进程
- 系统调用结束后
- 异常、中断、陷阱等处理完毕
信号的处理时机就是 内核态 切换为 用户态,也就是 当把更重要的事做完后,进程才会在操作系统的指导下,对信号进行检测、处理。
重谈进程地址空间
进程地址空间 是虚拟的,依靠 页表+MMU
机制 与真实的地址空间建立映射关系,并且每个进程都有自己的进程地址空间,不同 进程地址空间 中地址可能冲突,但实际上地址是独立的。
在谈论用户空间时提到,用户空间的地址要经过页表映射到物理地址,这个用户空间的页表其实其真实名称是用户级页表。
对于内核空间来说也有一张页表,也负责将内核空间的地址映射到物理地址中,这个页表的名称是内核级页表。
内核空间里面存放的是操作系统代码和数据, 所以执行操作系统的代码及系统调用,其实就是在使用这 1 GB 的内核空间。
- 内核空间比较特殊,所有进程最终映射的都是同一块区域,也就是说,进程只是将 操作系统代码和数据 映射入自己的 进程地址空间 而已。
- 而 内核级页表 不同于 用户级页表,专注于对 操作系统代码和数据 进行映射,是很特殊的。
当我们执行诸如 open 这类的系统调用时,会跑到内核空间中调用对应的函数,而跑到内核空间 就是用户态切换为内核态 了。(用户空间切换至内核空间)
由于操作系统的代码和数据是不能够被轻易访问的,所以在代码中如果要执行操作系统的代码和数据,需要先进行状态转化,由用户态转化为内核态,才能成功执行
在 CPU 中,存在一个 CR3 寄存器,这个 寄存器 的作用就是用来表征当前处于 用户态 还是 内核态:
- 当寄存器中的值为 3 时:表示正在执行用户的代码,也就是处于用户态。
- 当寄存器中的值为 0 时:表示正在执行操作系统的代码,也就是处于内核态。
通过一个寄存器,表征当前所处的状态,修改其中的值,就可以表示不同的状态
- 所有进程的用户空间 [0, 3] GB 是不一样的,并且每个进程都要有自己的 用户级页表 进行不同的映射。
- 所有进程的内核空间 [3, 4] GB 是一样的,每个进程都可以看到同一张内核级页表,从而进行统一的映射,看到同一个 操作系统。
- 无论进程如何切换,[3,4]GB不变,看到的都是OS的内容,与进程切换无关,也就是说进程切换其实切换的是[0, 3]G的用户空间里面的内容和用户级页表。
- 操作系统运行的本质:其实就是在该进程的 内核空间内运行的。(最终映射的都是同一块区域)
- 系统调用 的本质其实就如同调用动态库中的函数,通过内核空间中的地址进行跳转调用
信号的处理流程
当在内核态完成某种任务后,需要切回用户态,此时就可以对信号进行检测并处理 了
情况1:信号被阻塞,信号产生/未产生
信号都被阻塞了,也就不需要处理信号,此时不用管,直接切回用户态就行了。
下面的情况都是基于信号未被阻塞且信号已产生的前提
情况2:当前信号的执行动作为 默认
大多数信号的默认执行动作都是 终止进程,此时只需要把对应的进程干掉,然后切回用户态就行了
情况3:当前信号的执行动作为忽略
当信号执行动作为忽略时,不做出任何动作,直接返回用户态
情况4:当前信号的执行动作为用户自定义
这种情况就比较麻烦了,用户自定义的动作位于 用户态 中,也就是说,需要先切回用户态,把动作完成了,重新坠入内核态,最后才能带着进程的上下文相关数据,返回用户态。
注意: 用户自定义的动作,需要先切换至用户态中执行,执行结束后,还需要坠入内核态。
通过一张图快速记录信号的 处理 过程:
当内核态中任务完成,准备返回用户态时,检测到信号递达,并且此时为用户自定义动作,需要先切入用户态 ,完成用户自定义动作的执行;因为用户自定义动作和待返回的函数属于不同的堆栈空间,它们之间也不存在调用与被调用的关系,是两个独立的执行流,需要先坠入内核态(通过 sigreturn() 坠入),获取待返回的函数的上下文,再返回用户态 (通过 sys_sigreturn() 返回)。
信号处理方式
进程的对于信号的执行动作是可自定义的,默认为系统预设的 默认动作
- 执行默认动作(即操作系统给信号设定的默认动作)
- 忽略信号
- 执行自定义动作(用户修改了操作系统设定的默认动作,改成了自己想要的动作),操作系统为我们提供一个信号处理函数signal,可以要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch) 一个信号。
信号捕捉
函数 | 返回值 | 参数 | 备注 |
sighandler_t signal(int signum, sighandler_t handler); | signal() 的返回值是之前设置的信号处理函数指针。如果调用失败,返回 SIG_ERR | 参数1signum:
|
|
int sigaction(int signum, const struct sigaction *restrict act, struct sigaction *restrict oldact); | 调用成功则返回0 出错则返回-1 |
| struct sigaction {
第三个字段
|
代码演示1:
void handler(int signo)
{
cout << " catch a signal " << signo << endl;
}
int main()
{
// 给所有普通信号设定自定义方法
for(int i=1;i<32;i++) signal(i, handler);
while (true)
{
cout << "我是一个进程,我正在运行..... | pid: "<< getpid() << endl;
sleep(1);
}
return 0;
}
演示结果1:
代码演示2:
#include <iostream>
#include <cassert>
#include <cstring>
#include <signal.h>
#include <unistd.h>
using namespace std;
static void showPending(const sigset_t pending)
{
// 打印 pending 表
cout << "当前进程的 pending 表为: ";
int i = 1;
while (i < 32)
{
if (sigismember(&pending, i)) cout << "1";
else cout << "0";
i++;
}
cout << endl;
}
static void handler(int signo)
{
cout << signo << " 号信号确实递达了" << endl;
// 最终不退出进程
int n = 10;
while (n--)
{
// 获取进程的 未决信号集
sigset_t pending;
sigemptyset(&pending);
int ret = sigpending(&pending);
assert(ret == 0);
(void)ret; // 欺骗编译器,避免 release 模式中出错
showPending(pending);
sleep(1);
}
}
int main()
{
cout << "当前进程: " << getpid() << endl;
//使用 sigaction 函数
struct sigaction act, oldact;
//初始化结构体
memset(&act, 0, sizeof(act));
memset(&oldact, 0, sizeof(oldact));
//初始化 自定义动作
act.sa_handler = handler;
//初始化 屏蔽信号集
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
//给 2号 信号注册自定义动作
sigaction(2, &act, &oldact);
// 死循环
while (true);
return 0;
}
演示结果2: