文章目录
- ☀️前言
- ☀️一、信号阻塞
- 🌻1.信号其他相关常见概念
- 🌻2.信号在内核中的表示
- ☀️二、信号捕捉(重点)
- 🌻1.用户态 & 内核态
- 🌻2.如何判断进程处于用户态或内核态
- 🌻3.OS接口的访问方法
- 🌻4.信号的捕捉过程
- ☀️三、信号保存1
- 🌻1.sigset_t
- 🌻2. 信号集操作函数
- 🌻3.sigprocmask
- 🌻4.sigpending
- 🌻5.代码示例
- ☀️四、信号保存2
- 🌻1.sigaction
- 🌻2.代码示例 - sigaction
- 🌻3.可重入函数
- 🌻4.volatile关键字
- ☀️五、信号总结
- ☀️结语
☀️前言
通过我们上一篇文章的学习,我们知道信号的生命周期包括四个阶段:预备、信号产生、信号保存、信号处理
。同时我们还接触到了信号的保存位置:信号被保存在进程的 task_struct
中。知道了信号发送的本质就是修改进程 task_struct中的位图结构
。
在本片文章中,我将带领大家更加深入学习信号阻塞、捕捉、保存的知识。
☀️一、信号阻塞
🌻1.信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block ) 某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
对于信号的发送我们还要树立两点共识:1.信号发送是以操作系统为载体,向目标进程发送信号的。2.因为我们的信号不会被立即处理,因此信号产生和信号递达之间就会产生一个简单的时间窗口,在这个时间窗口中,信号已经收到了但是没有被立即处理,因此我们需要将信号保存起来。
🌻2.信号在内核中的表示
图示1:
- task_struct中有两张位图和一个指针,它们分别是
pending位图
、block位图
、指向 hander函数指针数组的指针
。 pending位图
默认为0,它可以表示为32个比特位,比特位的位置表示信号编号,比特位的内容(0 or 1)表示的是是否收到该信号。block位图
默认也为0,它也可表示32个比特位,比特位的位置表示信号编号,比特位的内容表示 是否阻塞该信号。指针指向 hander函数指针数组
,我们可以把它简称为hander表
,数组的下标表示信号的编号,数组下标对应的函数内容表示 对应信号的处理方法。- 图中右上角为信号的递达的伪代码,它告诉我们:如果一个信号阻塞了,信号就不会递达。
- 图的最上方有一个 signal函数,我们可以通过信号内核表示加以理解:signo代表信号编号,handler表示修改函数数组对应信号的处理方法。
总结:1.如果一个信号没有产生,并不妨碍它被阻塞。2.进程为什么能识别信号?因为每个信号都有自己对应的 pending位图、block位图 和 hander表。
图示2:
☀️二、信号捕捉(重点)
通过上面的学习,我们知道:信号产生的时候,不会被立即处理,而是会在合适的时候被处理。
那么问题来了,究竟合适的时候是什么时候呢?答案是:从内核态返回用户态的时候,进行处理。
🌻1.用户态 & 内核态
- 进程在运行时,有两种状态(运行级别),它们分别是:
用户态
和内核态
。 - 用户态:运行我们在电脑上自己写的代码(包括数据结构等),都是在用户态下完成的。
- 内核态:运行系统调用,就是在内核态完成的。
- 用户为了访问某些资源(OS or 硬件),必须通过系统调用完成,因此在访问过程中需要 状态改变。
- 系统调用比较费时间,因此我们应尽量避免频繁调用系统调用。举一个简单的例子:当我们使用 vector进行扩容的时候,计算机往往会为我们多申请一些空间,这就是为了避免频繁调用系统调用。
🌻2.如何判断进程处于用户态或内核态
- CPU中有很多可见和不可见的寄存器,它们保存有当前运行进程的上下文数据。
- CPU有专门的寄存器可以指向进程的 task_struct(PCB),和页表(用户级页表 & 内核级页表)的起始地址。
- CPU内有一个名为
CR3
的寄存器,它表征当前进程的运行级别:0-内核态,3-用户态。
🌻3.OS接口的访问方法
- 进程的 task_strcut有指向该进程的地址空间(mm_strcut)的指针,地址空间分为内核空间(1G)和用户空间(3G),因此页表也有两个:
用户级页表
和内核级页表
。 - 内核级页表指向 内核对应的虚拟地址空间。
- 每个进程都有自己的地址空间,由于不同进程共用同一个内核,每个进程的地址空间中的内核空间都是同一个,因此内核级页表只要有一份就够了,它指向同一份虚拟地址空间和物理内存。
- 进程要访问OS的接口,因为每个进程的地址空间中都带有同一个内核空间,因此只需要在地址空间中自行跳转到内核空间访问即可。
总结:1.用户访问OS的过程:运行到特定代码 -> 系统调用(起始位置会更改CR3寄存器)-> 查看CR3寄存器(确认运行状态) -> 跳转到内核空间进行访问 -> 访问完成 -> 更改CR3寄存器 -> 返回并继续执行下一行代码。
🌻4.信号的捕捉过程
- 在有需要的时候陷入内核(
用户态 -> 内核态
)。 - 由于陷入内核会会产生一定的成本(系统调用比较费时间),因此内核处理完对应的工作或异常的时候,不会立即返回运行下一条代码,而是会以内核的身份处理一些只有内核才能完成的额外的工作。
- 内核处理完对应的工作后会进行信号检测和递达处理。
- 信号处理分为3种:默认、忽略、自定义。大部分信号的处理方式为终止对应进程,忽略即不需要处理,自定义就是如同 signal函数一样执行我们定义的方法。
- OS会检测是否收到某一信号、该信号是否阻塞、处理方法是哪个。
- 如果确认收到某一信号,该信号没有阻塞,且为自定义处理方法,则:回到用户态,执行对应的自定义处理方法。(
内核态 -> 用户态
) - 注意:我们不能用用户态执行内核的代码(权限不足),也不能用内核态执行用户态的代码(避免用户对内核的恶意访问)。
- 执行完自定义处理方法之后,不能直接跳转回代码部分运行下一条代码。这是因为在我们使用系统调用时,我们的部分数据(代码运行位置)是由OS保存的,因此我们需要使用OS的身份进行恢复,再跳转回去运行下一条代码。
- 执行完自定义处理方法之后,需要重新回到内核态,恢复数据(
用户态 -> 内核态
)。 - 再使用特定的系统调用,回到代码运行的地方(
内核态 -> 用户态
)。 - 至此,完成了信号捕捉的全过程,然后继续运行下一条代码。
信号捕捉巧记图:红色圆圈代表操作,绿色圆圈代表状态切换(4个操作 + 4次状态切换),如果信号的执行方法为默认或者忽略,则不会再沿图示路径进行下去。
通过学习信号的捕捉过程,我们就可以更加深入理解到本节开头时候的话:信号产生的时候,不会被立即处理,而是会在合适的时候被处理,即从内核态返回用户态的时候。
☀️三、信号保存1
综合我们学习的知识,我们可以得出:信号产生之后不会立即递达,而是会在合适的时候递达,因此我们的信号在这个时间周期内需要被保存。信号被保存在进程的 task_struct
中,信号发送(保存)的本质就是修改进程 task_struct中的位图结构
。
这里我们再复习一下信号递达和信号未决的知识点,方便后面的学习:
- 实际执行信号的处理动作称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
🌻1.sigset_t
- 从上图来看,每个信号只有一个bit的未决标志(判断是否收到该信号),非0即1,不记录该信号产生了多少次,阻塞标志(判断信号是否阻塞)也是这样表示的。
- 因此,未决和阻塞标志可以用相同的数据类型
sigset_t
来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。 - 下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
🌻2. 信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set,int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
- 函数
sigemptyset
初始化 set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。 - 函数
sigfifillset
初始化 set所指向的信号集,使其中所有信号的对应bit置为1,表示该信号集的有效信号包括系统支持的所有信号。 - 注意,在使用sigset_ t类型的变量之前,一定要调 用
sigemptyset
或sigfifillset
做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset
和sigdelset
在该信号集中添加或删除某种有效信号。 - 这四个函数都是成功返回0,出错返回-1。
sigismember
是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
🌻3.sigprocmask
调用函数 sigprocmask
可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
- 如果
oset
是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。 - 如果
set
是非空指针,则更改进程的信号屏蔽字。 - 参数
how
指示如何更改。 - 如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
- 假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
- 如果调用 sigprocmask解除了对当前若干个未决信号的阻塞,则在 sigprocmask返回前,至少将其中一个信号递达。
🌻4.sigpending
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
总结:sigprocmask - 修改block位图(阻塞信号集/信号屏蔽字),sigpending - 获取pending位图(未决信号集),signal - 修改信号处理方法。
🌻5.代码示例
- 下面代码讲述的是如何调整信号屏蔽字
#include <iostream>
#include <vector>
#include <signal.h>
#include <unistd.h>
// #define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31
using namespace std;
// static vector<int> sigarr = {2,3};
static vector<int> sigarr = { 2 };
static void show_pending(const sigset_t& pending)
{
for (int signo = MAX_SIGNUM; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
{
cout << "1";
}
else cout << "0";
}
cout << "\n";
}
static void myhandler(int signo)
{
cout << signo << " 号信号已经被递达!!" << endl;
}
int main()
{
for (const auto& sig : sigarr) signal(sig, myhandler);
// 1. 先尝试屏蔽指定的信号
sigset_t block, oblock, pending;
// 1.1 初始化
sigemptyset(&block);
sigemptyset(&oblock);
sigemptyset(&pending);
// 1.2 添加要屏蔽的信号
for (const auto& sig : sigarr) sigaddset(&block, sig);
// 1.3 开始屏蔽,设置进内核(进程)
sigprocmask(SIG_SETMASK, &block, &oblock);
// 2. 遍历打印pengding信号集
int cnt = 10;
while (true)
{
// 2.1 初始化
sigemptyset(&pending);
// 2.2 获取它
sigpending(&pending);
// 2.3 打印它
show_pending(pending);
// 3. 慢一点
sleep(1);
if (cnt-- == 0)
{
sigprocmask(SIG_SETMASK, &oblock, &block); // 一旦对特定信号进行解除屏蔽,一般OS要至少立马递达一个信号!
cout << "恢复对信号的屏蔽,不屏蔽任何信号\n";
}
- 运行结果如下
总结:我们可以通过信号集操作函数初始化信号集,并将需要屏蔽的信号加入屏蔽信号集中,然后用 sigprocmask函数将信号集内容射入内核,然后通过 sigpending函数查看 pending信号集。上面的示例显示,当我们屏蔽2号信号之后,我们输入 ctrl+C 后会将信号存储于 pending信号集中,而不会递达,即不会执行 signal函数中的 myhandler方法。
☀️四、信号保存2
🌻1.sigaction
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
知识点1:
sigaction
函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo
是指定信号的编号。- 若
act
指针非空,则根据act修改该信号的处理动作。 - 若
oact
指针非 空,则通过oact传出该信号原来的处理动作。 - act和oact指向sigaction结构体;
- 将
sa_handler
赋值为常数SIG_IGN
传给sigaction表示忽略信号,赋值为常数SIG_DFL
表示执行系统默认动作,赋值为一个函数指针
表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
知识点2:
- 当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
- 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用
sa_mask
字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flflags/sa_flags
字段包含一些选项,本章的代码都把sa_flflags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段,有兴趣的同学可以在了解一下。
🌻2.代码示例 - sigaction
- 下述代码用于验证:某个信号在递达时,该信号会被屏蔽。
#include <iostream>
#include <cstdio>
#include <signal.h>
#include <unistd.h>
using namespace std;
void Count(int cnt)
{
while(cnt)
{
printf("cnt: %2d\r", cnt);
fflush(stdout);
cnt--;
sleep(1);
}
printf("\n");
}
void handler(int signo)
{
cout << "get a signo: " << signo << "正在处理中..." << endl;
Count(20); //调用计时程序
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask); // 当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中
sigaddset(&act.sa_mask, 3); //对3号信号也添加屏蔽
sigaction(SIGINT, &act, &oact); //SIGINT为2号信号
while(true) sleep(1);
return 0;
}
- 运行结果如下
总结:代码运行时,在第一个信号递达过程中(计数器开始计时),我们再向该进程发送2号信号则无法递达,第二次发送的2号信号将被保存在 pending位图中,等待第一次发送的信号递达完成之后才会执行对应方法,第3、4…次的信号发送均会失效/丢失。
🌻3.可重入函数
- main函数调用
insert函数
向一个链表head中插入节点node1,插入操作分为两步(如上图insert代码所示),刚做完第一步的 时候,因为硬件中断(该进程的时间片到了)使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数
,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。 - main 执行流 和 handler执行流(信号捕捉执行流)是两个不同的执行流,它们之间相互独立。
- 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为
不可重入函数
,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数
。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
🌻4.volatile关键字
- 该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下
[ldx@localhost code_test]$ cat sig.c
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while (!flag);
printf("process quit normal\n");
return 0;
}
[ldx@localhost code_test]$ cat Makefile
sig : sig.c
gcc -o sig sig.c #-O2 #使用#号屏蔽优化,02为优化级别
.PHONY : clean
clean :
rm - f sig
[ldx@localhost code_test]$ ./sig
^ Cchage flag 0 to 1
process quit normal
标准情况下,键入
Ctrl-C
,2号信号被捕捉,执行自定义动作,修改 flag=1 , while 条件不满足,退出循环,进程退出。
[ldx@localhost code_test]$ cat sig.c
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while (!flag);
printf("process quit normal\n");
return 0;
}
[ldx@localhost code_test]$ cat Makefile
sig : sig.c
gcc -o sig sig.c -O2 #放开屏蔽,设置优化级别02
.PHONY : clean
clean :
rm - f sig
[ldx@localhost code_test]$ ./sig
^ Cchage flag 0 to 1
^ Cchage flag 0 to 1
^ Cchage flag 0 to 1
我们的代码在编译过程中,编译器会对其进行优化,优化有不同级别,优化情况下,键入
Ctrl-C
,2号信号被捕捉,执行自定义动作,修改flag=1
,但是while
条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?很明显while
循环检查的flag,并不是内存中最新的flag,这就存在了数据二异性的问题。while
检测的flag其实已经因为优化,被放在了CPU寄存器当中。如何解决呢?很明显需要volatile
。
- 优化过程中,编译器认为while循环中的flag不会被修改,因此它默认提前将flag的值加载到cup中去了,然后让出资源执行其他代码去了,即对于 flag只做了检测,没有做修改。
- handler中修改的值是内存中的flag值,和已经 load到cpu中的 flag数据并不相同,只要cpu(寄存器)中的flag不变,那么循环就会一直进行下去。
[ldx@localhost code_test]$ cat sig.c
#include <stdio.h>
#include <signal.h>
volatile int flag = 0; //在全局变量前加volatile关键字
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while (!flag);
printf("process quit normal\n");
return 0;
}
[ldx@localhost code_test]$ cat Makefile
sig : sig.c
gcc - o sig sig.c - O2
.PHONY : clean
clean :
rm - f sig
[ldx@localhost code_test]$ . / sig
^ Cchage flag 0 to 1
process quit normal
volatile
作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
我们需要根据实际应用场景(优化级别比较高且存在需要更新的判断变量),判断我们是否需要添加
volatile
关键字。
☀️五、信号总结
- 下面是我在前面的信号文章中给出的信号生命周期图
-
下面是信号的知识点汇总,方便大家对应回顾
-
信号的预备,信号的基本概念。
-
信号的产生,信号的产生方法,发送本质。
-
信号捕捉(用户态内核态 & OS接口的访问方法 & 捕捉过程)
-
信号的保存,保存位置,保存方法,未决与递达的概念,信号阻塞,信号集及其操作,修改信号屏蔽字的方法,查看pending位图的方法,多次发送同一信号的现象。
-
信号处理,信号递达。
☀️结语
🌹🌹 信号阻塞 & 信号捕捉 & 信号的保存 的知识大概就讲到这里啦,博主后续会继续更新更多C++ 和 Linux的相关知识,干货满满,如果觉得博主写的还不错的话,希望各位小伙伴不要吝啬手中的三连哦!你们的支持是博主坚持创作的动力!💪💪