一、信号捕捉处理的概述
1、信号捕捉处理全过程
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。过程比较复杂:首先我们在执行主控制流程的某条指令时因为系统调用等原因会进入内核,然后内核处理完成后发送信号,如果信号的处理动作是自定义的信号处理函数就回到用户区执行信号处理函数,执行完之后因为信号处理函数的特殊性,它要再次进入内核区,然后回到用户模式继续执行。
我们在用户区和内核区来回切换的时候,操作系统负责做我们的身份(用户身份和内核身份)切换工作,用户态陷入内核态是通过汇编指令 int 80 完成的。
在进程从内核态返回用户态时进行信号的检测和处理。
并且 main 函数和自定义信号捕捉处理函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系
2、用户态和内核态的区别
用户态和内核态是操作系统中 CPU 的两种运行状态,它们在访问权限、资源使用等方面存在显著差异,以下是对这两种状态的标准解释:
(一)用户态
用户态是操作系统为普通用户程序提供的一种运行模式,在用户态下运行的程序拥有较低的特权级别,只能访问受限的系统资源和执行特定的操作,大部分用户编写的应用程序(如文本编辑器、浏览器等)都是在用户态下运行的。
用户态程序只能访问自己的内存空间,不能直接访问系统的核心资源,如硬件设备、操作系统内核的数据结构等,并且只能执行一部分指令,一些具有高风险或对系统影响较大的指令(如修改系统时钟、控制硬件中断等)是被禁止执行的。
由于用户态程序的权限受到限制,即使程序出现错误(如内存越界、死循环等),也不会对整个操作系统造成严重影响,只会影响到该程序自身。
(二)内核态
内核态是操作系统内核运行的模式,具有最高的特权级别,操作系统内核负责管理系统的核心资源,如内存、进程、文件系统、设备驱动等,因此需要在高特权的内核态下运行。
内核态程序可以访问系统的所有资源,包括硬件设备、内核数据结构、所有进程的内存空间等,并且可以执行所有的 CPU 指令,包括那些在用户态下被禁止执行的特权指令,这些特权指令可以用于实现系统的关键功能,如进程调度、内存管理、中断处理等。
由于内核态程序具有最高的权限,一旦内核态程序出现错误,可能会导致整个操作系统崩溃或出现严重的系统故障。
(三)用户态与内核态的切换
在操作系统的运行过程中,程序需要在用户态和内核态之间进行切换,以完成不同的任务,常见的切换场景包括:
- 系统调用:当用户态程序需要访问系统资源或执行特权操作时,会通过系统调用(如
open、read、write等)请求操作系统内核的服务,在执行系统调用时,程序会从用户态切换到内核态,由操作系统内核来处理请求,处理完成后再切换回用户态。 - 中断处理:当系统发生硬件中断(如时钟中断、键盘中断等)或软件中断(如异常、陷阱等)时,CPU 会自动从用户态切换到内核态,由操作系统内核来处理中断事件,处理完成后,根据情况决定是否切换回用户态。
但是操作系统是不相信用户的,所以用户态和内核态进行切换的时候是操作系统完成的。
(四)硬件条件
在 CPU 中有一个 CS 寄存器,它的后两个 bit 位就标记了当前是处于用户态还是内核态,其中 00 表示处于内核态,11 表示处于用户态,int 80 指令本质上就是将 11 修改成 00。
二、再谈进程地址空间

我们再来拿出我们的老图,我们知道,每个进程都有一个 PCB,然后 PCB 中有一个结构体叫做 mm_struct,又被称为进程地址空间,就是我们常说的虚拟地址,虚拟地址通过页表和 MMU 映射到物理内存上,可以说页表就是虚拟内存和物理内存之间的桥梁,每个进程都会有一个页表,但是,这是对于用户区空间来说的,我们的内核空间,实际上也是有一个内核页表,这个内核页表是唯一的,内核空间中的数据也是唯一的,我们不同的进程,它们用户区空间的代码和数据不同,但是内核空间的数据一定相同,所有进程共享一份内核空间以及一份内核页表。
对于进程来说,去调用系统调用接口,就是在我自己的地址空间中执行的,对于操作系统来说,任何一个时刻都有进程执行,我们想执行操作系统的代码就可以随时执行。
操作系统本质
操作系统的本质就是一个基于时钟中断的一个死循环,这里的时钟中断与 STM32 中的定时器类似,都是每隔很短的时间向计算机发送时钟中断,操作系统在收到时钟中断后,就去中断向量表中执行相应的进程调度之类的方法。
三、系统调用函数
sigaction 用于设置或检查信号处理行为的系统调用函数。
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
返回值:成功返回 0,失败返回 -1。
signo:需要处理的信号编号。
act:输入型参数,指向 struct sigaction 结构体,用于指定新的信号处理动作。
oact:输出型参数,指向 struct sigaction 结构体,用于保存原来的信号处理动作。
其中结构体 sigaction 是这样的:
struct sigaction {
void (*sa_handler)(int); // 信号处理函数指针,或 SIG_IGN(忽略信号)、SIG_DFL(使用默认处理)
void (*sa_sigaction)(int, siginfo_t *, void *); // 另一种信号处理函数指针,用于处理带附加信息的信号
sigset_t sa_mask; // 在信号处理函数执行期间需要阻塞的信号集
int sa_flags; // 控制信号处理行为的标志位
void (*sa_restorer)(void); // 已弃用,通常设为 NULL
};
其中我们主要研究两个成员:sa_handler,sa_mask。
这里我们说一个结论,就是信号处理函数在处理信号时,是不再接受新的信号的,在该信号处理函数被调用时,在刚要调用的某个时间,内核会自动将当前信号加入到进程的信号屏蔽字中,这就可以实现在处理某个信号的过程中,不会被这个信号反复打扰,如果这个信号再次产生,它会被阻塞到当前函数处理结束,如果在当前信号被阻塞以外,还想要屏蔽其他的一些信号,则动用 sa_mask 说明要额外屏蔽的信号。
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
using namespace std;
void PrintPending() {
sigset_t set; // 将当前被阻塞且处于未决状态的信号集存储到 set 信号集中(输出型参数)
sigpending(&set); // 打印 set 位图(即 pending 位图)
for (int signo = 31; signo >= 1; signo--) {
if (sigismember(&set, signo)) cout << "1";
else cout << "0";
}
cout << endl;
}
void handler(int signo) {
// 收到 2 号信号打印一句收到了,然后循环调用 PrintPending
cout << "catch a signal, signal number : " << signo << endl;
while (true) {
PrintPending();
sleep(1);
}
}
int main() {
// 创建并初始化 act 和 oact
struct sigaction act, oact;
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
// 设置 sa_mask 为全 0
sigemptyset(&act.sa_mask);
// 设置信号屏蔽
sigaddset(&act.sa_mask, 1);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
// 设置信号处理函数为 handler
act.sa_handler = handler; // SIG_IGN SIG_DFL
// 需要处理的就是 2 号信号
sigaction(2, &act, &oact);
while (true) {
cout << "I am a process: " << getpid() << endl;
sleep(1);
}
return 0;
}


正常情况下,我们没有发送任何信号,1 号信号会将进程终止,当我们发送 2 号信号,sigaction 函数将信号捕捉后,我们进入到 handler 函数,开始打印全 0 的 pending 位图,此时属于信号处理函数处理信号的过程,然后我们发送 1234 号信号,因为 134 号信号我们提前设置进 sa_mask 当中了,所以我们在发送信号的时候对应的比特位就会由 0 置 1,表示阻塞通过。
四、其他补充内容
1、可重入函数

这是一个正常的单链表,node1 和 node2 是两个待插入节点,其中,我们要头插 node1,其方法就是将 next 指针指向现在的头节点,再将自己赋值给头节点。
insert(struct Node* node) {
node->next = head;
head = node;
}
但是在 node1->next = head; 执行完毕后,还没来得及执行 head = p; 突然来了一个信号,这个信号刚好被捕捉了,执行自定义动作,刚好自定义动作是将 node2 头插。
void handler(int signo) {
insert(&node2);
}
insert(struct Node* node) {
node->next = head;
head = node;
}
执行完这个信号处理函数后再返回执行 main 函数中的代码。
这样一来我们实际上的代码就变成了:
node1->next = head;
node2->next = head;
head = &node2;
head = &node1;

这样上面 node1 是头插进去了,但是 node2 是不可能通过单链表的接口访问到了,这个节点就丢失了,这样就会导致内存泄漏。
如果一个函数像上面一样被重复进入的情况下,出错或者可能出错,就叫做不可重入函数,否则就叫做可重入函数,我们学习到的大部分函数都是不可重入函数,因为只要是涉及指针改指向的问题,基本上都是不可重入的。
2、volatile 关键字
volatile 是一个类型修饰符,用于告知编译器它修饰的内容拒绝优化。
该程序在收到 2 号信号之前一直在 while 循环中啥也不干,收到 2 号信号执行 handler 打印,设置 flag 为 1,然后继续 while 判断,从这里开始就变得不一样了。

我们可以看到我们编译的方法加了 -O1 选项,这个叫做优化,一共有 -O0 -O1 -O2 -O3 四种优化等级,其中 O0 是无优化。

这是加了 volatile 的关键词产生的结果,按下 ctrl+c 打印执行后,flag 置 1,while 判断继续向后执行,打印,程序结束。

这是不加 volatile 的关键词产生的结果,按下 ctrl+c 打印执行后,flag 置 1,怎么不往下执行了呢,我再次按下 ctrl+c 打印再次执行。

这里就是优化的问题,优化其实是 CPU 管理资源的一种方式,优化后,CPU 在第一次读取 flag 的时候,将其加载到 CPU 寄存器中,handler 结束后进行 while 的判断时,直接判断 CPU 上的这个 flag,不再去物理内存当中寻找,因为这样的寻找是耗费资源的,加了 volatile 关键字后,不再产生优化,在判断时判断的就是物理内存中的 flag 值。
所以,涉及到要随时了解某些变量的状态的时候,这些变量在优化的时候最好用 volatile 修饰一下。

