【Linux】进程信号 --- 信号处理
👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍
目录
- 一、信号的处理时机(什么时候处理)
- 二、用户态与内核态
- 2.1 概念
- 2.2 再谈进程地址空间
- 三、信号的处理过程(如何处理)
- 四、信号的捕捉
- 4.1 内核如何实现信号的捕捉
- 4.2 系统调用 --- sigaction
- 五、进程信号总结
- 六、相关代码
一、信号的处理时机(什么时候处理)
在前面的学习中,我们反复说过:当操作系统给进程发生信号后,进程可能不会立即去处理信号,而是等到合适的时候去处理,这是因为进程可能正在做更重要的事情!
但在特殊情况下,信号可能会立即被处理。比如:当信号被阻塞后,信号产生时,此时信号被阻塞了,没有立即被处理,那么信号就会记录在pending
表中,当阻塞解除后,信号会被立即递达,此时信号会被立即处理。
那么对于进程来说什么是合适的时候呢?
进程要处理一个信号,首先要知道自己收到信号了。那么进程可以通过查阅block
表、pending
表和handler
表。但这些表都是内核的数据结构,进程无法直接查看或修改它们,只能通过操作系统提供的系统调用接口来与之交互(进程处于内核状态)。因此,进程处理的合适时机是:进程从内核态返回到用户态时,会对信号进行检测及处理。
二、用户态与内核态
2.1 概念
-
用户态:执行用户所写的代码时,就属于用户态。
-
内核态:执行操作系统的代码时,就属于内核态。
我们知道,一旦程序被加载到内存中形成了一个进程,CPU
可以开始执行这个进程的代码。如果代码中调用系统调用,由于系统调用的实现在操作系统内核中,并且操作系统不相信任何人。因此,CPU
在这时会从用户态切换到内核态(“身份”变化,提升权限),才允许开始执行相应的操作系统内核代码。当内核代码执行完之后,就会从内核态切换回用户态继续执行用户写的代码。
总之,进程调用系统调用不仅仅是调用函数这么简单,操作系统还需要自动进行“身份”变换。
下面来结合进程地址空间深入理解操作系统的代码及状态切换等内容。
2.2 再谈进程地址空间
在32
位操作系统中,通常会将整个4GB
的虚拟地址空间划分为用户空间和内核空间两部分。
-
用户空间:这个部分包含了用户进程可以直接访问的虚拟内存地址范围。每个用户进程都有自己的独立的用户空间,使得它们之间的内存访问相互隔离,提高了系统的安全性和稳定性。
-
内核空间:这个部分是保留给操作系统内核使用的,存储的就是操作系统相关代码和数据。并且这块区域采用内核级页表与物理地址进行映射。进程所谓的执行操作系统的代码及系统调用,就是在使用这
1 GB
的内核空间(和动态库类似)。 -
内核级页表:用于管理操作系统内核的虚拟地址和物理地址之间的映射。它与用户级页表有所不同,用户级页表只管理用户进程的地址映射。这两张页表是相互独立的!
- 对于用户级页表,有几个进程,操作系统就要维护几份,因为要保证进程独立性。
- 对于内核级页表,操作系统只需维护一份,因为内核的代码和数据对所有进程来说都是相同的。
如何理解进程切换?
- 进程切换需要调用系统调用,
CPU
在这时会从用户态切换到内核态,在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。 - 执行操作系统的代码,将当前进程的用户空间的代码和数据剥离下来,从而换上另一个进程的代码和数据,但内核空间的内容永远不变。
操作系统的本质?
操作系统是管理软硬件资源的软件。当电脑开机时,它就已经被加载到内存,因此操作系统也是一个进程。但我们可能会疑惑,用户写的进程是通过操作系统去运行调度的。那是谁在推动操作系统去运行的呢?答案:硬件。
这里直接给出结论:操作系统是基于时钟中断的一个死循环!
其实在计算机硬件中,有一个时钟芯片,每隔很短的时间(纳秒级别),向CPU
发送时钟中断。CPU
收到中断请求后,会暂停当前正在执行的任务,并根据中断号找到对应的中断向量表(本质上就是数组)中对应中断号的条目。中断向量表是一个存储在内存中的数据结构,由操作系统维护,表中的条目是调用硬件设备驱动程序的方法的地址,找到后并执行。比如里面就会进行检查进程的时间片到了没有,如果到了,就把这个进程剥夺下去,换下一个进程。从而完成进程的调度。
如果没有进程需要调度,操作系统会陷入空闲状态,内核可能会执行一个类似for(;;) pause();
的死循环。这段代码的作用是让内核进入一种等待状态,等待系统中的某些事件发生,比如新的进程就绪或者外部中断的到来等。
用户态和内核态之间是如何转化?
CPU
有还2
个寄存器:
CR3
寄存器:是页表基址寄存器,用于存储页表的地址(物理地址)。在内核态和用户态身份切换时,操作系统会更新CR3
寄存器,以加载新的地址空间信息。ECS
寄存器:CPU
是执行内核态代码还是用户态代码,主要看ECS
寄存器最低2
位,排列组合式00
、01
、10
、11
。如果是00
代表是内核态,11
代表用户态。如果要调用系统调用接口,操作系统都会对此寄存器做检测。
三、信号的处理过程(如何处理)
- 说明:以下不考虑信号被阻塞的情况。如果信号被阻塞,信号不会立即被处理,一旦解除阻塞,信号就会立即被处理。
因此,进程处理的合适时机是:进程从内核态返回到用户态时,会对信号进行检测及处理。
通过一张图快速记录信号的处理过程
四、信号的捕捉
4.1 内核如何实现信号的捕捉
这里其实就是对下图第③步再进行详细讲解。
- 概念:如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,则称为捕捉信号。
- 因为用户自定义动作是位于用户空间中的,所以当内核态中任务(执行内核代码)完成,准备返回用户态时,会检测三表中的
pending
表为1
且没有被阻塞block
表为0
,并且此时信号处理动作handler
表为用户自定义动作,先将pending
表对应1
置为0
,再切入用户态 ,完成用户自定义动作的执行。 - 注意:用户自定义动作
sighandler
函数和main
函数属于不同的堆栈空间,它们之间也不存在调用与被调用的关系(是操作系统调用的sighandler
函数),是两个独立的执行流。 sighandler
函数返回后自动执行特殊的系统调用sigreturn
再次进入内核态,其中sigreturn
的目的是告诉操作系统当前进程的信号处理已经完成。- 最后当执行完
sighandler
函数后会到内核中后,如果发现有信号解除屏蔽,还会再次进行信号处理,否则自动执行特殊的系统调用sys_sigreturn
,这次再返回用户态就是恢复main
函数的上下文继续执行了。
4.2 系统调用 — sigaction
sigaction
函数也可以用户自定义动作,比signal
函数功能更丰富。
函数原型如下:
#include <signal.h>int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数解释:
signum
:要处理的信号的编号,比如SIGINT
等。act
:指向struct sigaction
结构的指针,用来设置新的信号处理方式。oldact
:指向struct sigaction
结构的指针,用来获取之前该信号的处理方式。或者可以设置为nullptr
表示不获取之前该信号的处理方式。- 返回值:成功时返回
0
,失败时返回-1
,并设置errno
表示具体错误原因。
以下是 struct sigaction
结构的定义
struct sigaction
{void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *);sigset_t sa_mask; int sa_flags; void (*sa_handler)(void);
};
字段解释:
void (*sa_handler)(int);
:指定的信号处理函数,可以是一个函数指针,用于处理指定的信号。可以设置为SIG_IGN
(忽略信号)或SIG_DFL
(默认处理方式)。sigset_t sa_mask;
:当信号在执行 用户自定义动作时,可以将部分信号进行屏蔽,直到用户自定义动作执行完成。也就是说,我们可以提前设置一批 待阻塞 的 屏蔽信号集,当执行用户自定义动作时,这些 屏蔽信号集 中的 信号将会被屏蔽(避免干扰用户自定义动作的执行),直到用户自定义动作执行完成。void (*sa_sigaction)(int, siginfo_t *, void *);
:不关心。int sa_flags;
:不关心。void (*sa_handler)(void);
:不关心。
可以简单用一下sigaction
函数
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
using namespace std;void DisplayPending(sigset_t pending)
{// 打印 pending 表cout << "当前进程的 pending 表为: ";int i = 1;while (i < 32){if (sigismember(&pending, i))cout << "1";elsecout << "0";i++;}cout << endl;
}// 自定义信号处理函数
void handler(int signo)
{cout << "捕捉到了一个信号:" << signo << endl;int n = 10;while (n--){// 获取进程的pending表sigset_t pending;sigemptyset(&pending);int n = sigpending(&pending);if (n < 0){continue;}DisplayPending(pending);sleep(1);}exit(0);
}int main()
{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){cout << "I am a process: " << getpid() << endl;sleep(1);}return 0;
}
【程序结果】
并且我们还发现了一个现象:当我们捕捉2
号信号后,对应的2
号信号的pending
位图是为0
,说明在执行handler
方法之前,就已经将其置为0
了,这就和我们之前的理论对应上了。
注意:除了sa_mask
可以在当信号在执行用户自定义动作时,将部分信号进行屏蔽,直到用户自定义动作执行完成。除此之外,当某个信号的处理函数被调用时,内核自动会将当前信号屏蔽,也就是将当前的pending
表和block
表对应位置为1
,直到用户自定义动作执行完成后恢复。如果在执行用户自定义动作的过程中,这种信号再次产生,那么它会被阻塞到当前处理结束为止。这是因为操作系统不允许对某个信号重复捕捉,最多只能捕捉一层,防止信号捕捉被嵌套调用。
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
using namespace std;void DisplayPending(sigset_t pending)
{// 打印 pending 表cout << "当前进程的 pending 表为: ";int i = 1;while (i < 32){if (sigismember(&pending, i))cout << "1";elsecout << "0";i++;}cout << endl;
}void handler(int signo)
{cout << "捕捉到了一个信号:" << signo << endl;while (true){// 获取进程的pending表sigset_t pending;sigemptyset(&pending);int n = sigpending(&pending);if (n < 0){continue;}DisplayPending(pending);sleep(1);}exit(0);
}int main()
{struct sigaction act, oldact;// 初始化结构体对象(可选)memset(&act, 0, sizeof(act));memset(&oldact, 0, sizeof(oldact));// 设置信号处理函数act.sa_handler = handler;// 给2号信号注册自定义动作sigaction(2, &act, &oldact);while (true){cout << "I am a process: " << getpid() << endl;sleep(1);}return 0;
}
【程序结果】
五、进程信号总结
截至目前,信号 处理的所有过程已经全部学习完毕了
-
信号产生阶段:有四种产生方式,包括 键盘键入、系统调用、软件条件、硬件异常
-
信号保存阶段:内核中存在三张表,
blcok
表、pending
表以及handler
表,信号在产生之后,存储在pending
表中 -
信号处理阶段:信号在 内核态 切换回 用户态 时,才会被处理
六、相关代码
本篇博客的代码:点击跳转