目录
- 1. 信号的处理
- 1.1 内核态 && 用户态
- 1.2 进程地址空间第三弹
- 1.1 内核态 && 用户态 (续)
- 2. 信号捕捉
1. 信号的处理
我们一直在说,进程收到信号了,可能会因为各种原因无法即使处理信号,而后选择一个合适的时机去处理。所以所谓的 “合适的时候” 是什么时候?以及进程到底是如何处理信号的?(操作系统只负责对进程发送信号,不参与信号的处理)
处理信号的前提条件是进程知道自己收到信号,而进程想要知道自己是否收到信号,就必须在合适的时候查一查自己 PCB 中的 pending 位图、block 位图和 handler 表。而 pending 位图、block位图 和 handler 表都属于内核数据结构,处于用户态身份的进程无法直接访问,这就说明进程一定要处于内核状态,才能够对访问操作系统的内核数据结构,然后对信号做处理。
那进程什么时候对信号处理呢?----- 当进程从内核态返回到用户态时,对信号做检测并处理。
1.1 内核态 && 用户态
我们需要改变一个认知:因为我们的代码中可能存在系统调用、库函数等接口的调用,所以 cpu 在调度运行我们的进程时,不仅仅执行我们用户所写的代码,还可能会执行语言库的代码和操作系统的代码。
当进程在执行用户写的代码和语言库的代码,大部分都是以用户态的身份直接执行的。还有一些情况,进程在操作系统的调度下,需要陷入到操作系统内部去执行代码。最典型的就是当我们进程调用了系统接口,在 “调用” 这个动作上,我们不仅仅是进程调用了系统函数,陷入到系统内部去执行系统函数的代码,能够陷入到操作系统的前提是,这个进程得有资格、有权限陷入到操作系统内,去执行操作系统的代码。所以对于进程调用系统调用这件事,操作系统会先把进程从用户身份切换为内核身份,由操作系统自己把系统调用的代码执行完毕后,在返回时把内核身份重新切换为用户身份。总而言之,进程在调用系统函数时,操作系统是会自动做 “身份” 切换的。
而我们曾经说过,当我们做键盘输入时,键盘会向 cpu 发送硬件中断,然后让操作系统识别,对于 cpu 来说,这是来自于外部的中断。而 cpu 也可以在自己内部产生中断,即 int 80。这是 一条汇编语句,也是一条能够被 cpu 识别的指令。一旦 int 80 触发了,进程就会从用户态切换为内核态,接着进程就有资格去访问操作系统的代码和数据了。
1.2 进程地址空间第三弹
在进程地址空间中,0 ~ 3GB 范围属于用户地址空间,3 ~ 4GB 属于内核地址空间。诸如我们编写各种 C/C++ 程序,以及自制动静态库等上层所做的各种操作,都是在用户空间所进行的。而对于内核空间的那一部分,它映射的是操作系统的代码和数据。而在之前谈论进程地址空间时,我们知道,虚拟地址与物理地址必然需要通过页表映射关联起来。像我们平时加载的各种上层应用,都是加载到用户空间的,因此用于映射用户空间的页表,我们称为用户级页表。而对于内核空间,虚拟到物理地址映射所需要的页表结构,称为内核级页表。
对于用户级页表,操作系统中有几个进程,就有几个用户级页表,因为进程具有独立性。而对于每个进程的进程地址空间,0 ~ 3 GB 范围都是属于自己这个进程独有的,但由于 3 ~ 4GB 是给操作系统内核分配的地址空间,操作系统只有一个,因此内核级页表也只有一个。换言之,每个进程所看到的进程地址空间中的 3 ~ 4GB 范围的内容,都是一样的!因为它们用的都是同一个内核级页表。
当我们的进程在调用系统接口时(即在正文代码区内做调用动作),然后转而跳转到自己的进程地址空间内的内核空间去调用该系统接口,系统函数的代码执行完后再返回到原调用处。换言之,站在进程视角,调用系统函数,就是在自己的地址空间中执行的调用(与进程调用动态库是相似的,动态库加载到物理内存后,通过页表建立虚拟与物理地址的映射关系,当进程调用动态库的方法时,只是转而到地址空间中的共享区调用,再通过页表映射访问物理内存中的动态库代码数据,只不过调用共享库不存在权限问题,共享库的本质是上层语言的代码,因此用户态身份依旧可以执行访问,但系统调用存在权限问题)。站在操作系统视角,任何一个时刻,都有进程在运行(如 1 号进程),所以我们想执行操作系统的代码,随时都可以执行(因为任意时刻都有进程在运行,而任意一个进程的地址空间 + 内核级页表都能访问到操作系统的代码和数据)。
-
所以操作系统的本质是什么? ----- 基于时钟中断的一个死循环
大家有没有想过这么一个问题,操作系统启动后,用户启动一个程序,操作系统就会自动创建一个进程,然后去执行任务,而且它还会定期地做进程调度等工作。操作系统也是一个软件,而我们写的程序也是软件,将来运行起来,就等同于一个软件在调度另一个软件。我们的进程如果不被操作系统调度,就无法推进运行,那问题是,操作系统这个软件谁来调度?谁来执行呢?
在我们的计算机里面,有一个 CMOS 芯片单元,它以非常高的频率定期向操作系统发送时钟中断。也就是说,有一个芯片 CMOS 每隔非常短的时间,就会向 cpu 发送一次时钟中断,当 cpu 收到了中断,就要在中断向量表中执行相应的中断方法,即操作系统的代码。所以在时钟中断的推动下,操作系统的代码就一直被动执行着(操作系统前期启动时,把它该做的工作全做完,然后就处于一个类似于死循环的情况,不断的检测当前的时钟中断,一旦收到了时钟,cpu 即执行对应的中断方法)。
下面举个例子方便大家理解所谓的时钟中断,比如我现在正在愉快的玩着黑神话悟空,但是当我收到评论区催更,我就立马停下来,去检查一下我上一篇文章的发布时间,如果到了可以发布下一篇文章的时间,我就发布,如果没到,我就什么都不做,回去继续玩我的黑神话悟空。这就相当于,当 cpu 收到时钟中断,就会检查进程调度等工作,当正在运行的进程时间片到了,操作系统将其换下,然后切上另一个进程执行,如果时间片没到,那么原地返回。再次收到时钟中断,再次重复检查。。。。以此循环往复。。。。。
因此,操作系统是一个由时钟驱动下的,被动执行的软件!而正是 CMOS 这个硬件推动着操作系统的运行,操作系统才能一直推动着进程的调度,所以我们用户的代码才能得到执行。 当操作系统没有收到时钟中断的空闲期内,在内核中是将操作系统做了暂停
for(;;) pause();
,直到下一次收到时钟,然后执行相应的任务。 -
拓展:每台计算机都有一个计数器,记录开机到此刻已经收到的时钟数,以及处理的时钟中断数,从而计算出系统开机时长。而当我们的计算机彻底断电之后,隔了一段时间后重新开机,启动操作系统,但是系统时间并不会因此停滞,即便我们的计算机没有联网,系统时间也依旧正确,这是因为计算机内有一个纽扣电池,用于给主板的某些元器件续电,加上内部有一个时钟信号,它以固定的频率产生脉冲,每个脉冲可以被视为一个时间滴答,因此可以维持系统的时间。
1.1 内核态 && 用户态 (续)
回归到内核态与用户态,当我们的进程调用系统调用,在进程的视角上是在自己的地址空间上完成的调用。现在问题是,进程调用动态库,最终也是转换为在自己的地址空间上来回跳转调用,而与调用动态库不同的是,调用动态库时没有所谓的权限问题,而系统调用是操作系统的代码,即便它被每一个进程所共享(都位于每一个进程的地址空间的 3 ~ 4GB 范围),但是作为用户,依旧无法直接访问操作系统的代码,所以这就涉及到权限问题。
每一个当前正在调度的进程,它有自己的页表(用户级页表)。而在 CPU 里面有一个 CR3 寄存器,该寄存器直接指向当前进程的用户级页表,即 CR3 寄存器存储的就是页表的地址(页目录地址)。当进程被操作系统调度了,操作系统会把当前进程的 task_struct、地址空间、页表等信息都放在 CPU 的寄存器内,以便让让操作系统能够快速找到它们。
CPU 两种工作模式,一种用户态,一种内核态。为了让操作系统区分当前进程现在是用户态还是内核态,在 CPU 内还存在一个 esc 寄存器(当进程处于用户态时,esc 寄存器就会执行用户层的代码,处于内核态则指向内核的代码),esc 寄存器的低两位,排列组合一共四种情况,操作系统使用 0(00) 来表示 CPU 处于内核态的工作模式,3(11) 来表示处于用户态。换言之,当进程想要访问内核空间内的代码,就需要把 esc 的低两位由 3 置 0,进入内核态(将 CPU 的工作模式切换为内核态),作用即进程被允许访问操作系统的数据。而改变 cpu 的工作模式,是由 int 80 这条汇编语句所触发的,int 80 即陷入内核。
- 内核态:允许访问操作系统的代码和数据
- 用户态:只能访问用户(进程) 的代码和数据(包括库函数)
- 调用系统接口的整条链路:在进程方面,跳转到地址空间内核区系统函数的入口地址,同时在操作系统方面,修改 esc 寄存器的低两位,由 3 置 0 进入内核态,当执行完系统调用返回到用户区的原调用处, 同时 cpu 的工作模式切换为用户态,即 esc 寄存器的低两位由 0 置 3,然后继续执行用户代码。
信号处理流程解读:当我们的代码调用了系统调用(先不管有没有信号),那么就需要陷入内核执行操作系统的代码,在切换为内核态后,需要处理完异常等工作之后,在返回时做信号检测,即检查进程 PCB 中的 pending 位图有没有收到信号,如果没有收到信号直接跳过,有信号则再检查 block 位图,如果 block 被置为 1,代表该信号被屏蔽,无法处理,那就接着下一个信号编号检查,直到遇到 pending 为 1,block 为 0 的信号,再执行对应的信号处理方法。如果处理动作为 default,那就按照信号的默认动作处理信号(对于清除 pending 表中特定位(由 1 变 0),是在即将处理信号之前就完成的工作),然后释放进程代码和数据,进程状态改僵尸;如果信号的处理动作是 ignore,就忽略信号的处理,直接返回;而如果是自定义信号处理动作,那么就需要跳转到 sighandler 方法处理信号,这一步需要将进程从内核态切换回用户态(因为操作系统不相信任何用户,所以它不想执行用户的任何代码,一旦以内核的身份去执行,难免被有些用户 “背刺” / “下毒”,比如盗取其它用户密码,盗取数据等操作,那操作系统就不嘻嘻了) ,而当自定义方法处理信号完毕之后,是需要通过 sigreturn 重新回到内核态的,然后以内核态的身份执行 sys_sigreturn() 这样的方法,返回到用户调用系统调用的地方(可以理解为,系统调用是进入内核完成的,因此完成调用后返回也就需要从内核返回),之后再继续执行进程的后续代码。
-
有时候我们的进程根本就没有系统调用,为什么也会陷入内核(比如 Crtl + c 能够给进程发送信号,然后进程处理信号(即终止进程),但是我们现在已经能够知道,信号的处理必然需要陷入内核,以内核态的身份去执行的信号处理)
永远不要忽略一个事实:进程是会被操作系统调度的。比如当我们写个死循环,进程代码一直被执行,那么进程就一直会被 cpu 调度运行,只要调度,那么进程的时间片就会有消耗完的那一刻,消耗完毕就需要被 cpu 切换下去。当进程被二次调度时,就需要把进程 pcb、地址空间、页表,包括硬件上下文全部恢复到 cpu 上执行,这个工作肯定是以内核态去完成的(因为涉及到的都是内核数据结构,用户态没有权限执行这些任务),然后再执行进程的代码(即用户的代码),这就注定了,进程一定会从刚刚的内核态切换回用户态。所以不要认为只有系统调用才会陷入到内核,从进程被调度运行到整个进程运行结束,它会有无数次机会从用户到内核,从内核到用户。
结论:关于信号的产生到保存到处理的整个流程,即信号产生时,可能不会立即被处理,因此需要保存到进程 pcb 中的 pending 位图中,然后选择在合适的时候处理它,即在内核态返回用户态时做信号的检测与处理。
2. 信号捕捉
在信号章节,我们介绍了信号的产生,保存和处理,在信号处理一环中,如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
举个例子:用户程序注册了 SIGQUIT 信号的处理函数 sighandler。 当前正在执行 main 函数,这时发生中断或异常切换到内核态。 在中断处理完毕后,要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达,那么内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数。sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态,然后由内核态返回程序原系统调用处继续往后执行。 如果没有新的信号要递达,在返回用户态时就是恢复 main 函数的上下文,然后继续往后执行了。
NAME
sigaction - examine and change a signal action // 捕捉和修改信号处理动作
SYNOPSIS
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
RETURN VALUE
sigaction() returns 0 on success; on error, -1 is returned, and errno is set to indicate the error.
参数分析:
signum:修改指定信号编号
act:输入型参数,一个自定义捕捉方法的结构体,底层本质是修改 pending、block、handler 这三张表。若 act 指针非空,则根据 act 修改该信号的处理动作
oldact:输出型参数,若 oact 指非空,则通过 oact 传出该信号原来的处理动作。如不想保存,可设置为 null。
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *); // 不关心,处理实时信号的
sigset_t sa_mask;
int sa_flags; // 不关心,默认设置为 0 即可
void (*sa_restorer)(void); // 不关心,现代系统中通常不使用
};
void (*sa_handler)(int) :
将 sa_handler 赋值为常数 SIG_IGN 传给 sigaction 表示忽略信号;赋值为常数 SIG_DFL 表示执行系统默认信号处理动作;
赋值为一个函数指针,表示用自定义函数捕捉信号,即向内核注册了一个信号处理函数。
该函数返回值为 void,可以带一个 int 参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。
sa_mask:可添加自动屏蔽另一些信号。
进程收到信号,进程的 pending 表的特定位由 0 -> 1,那么信号处理完成后,pending 表肯定要重新由 1 -> 0,而我们之前提到过,对于清除 pending 表中特定位(由 1 -> 0),是在即将处理信号之前就完成的工作,接下来我们使用 sigaction 系统调用对信号捕捉并自定义处理的同时,验证一下 pending 表的清除工作到底是何时完成的。
void PrintPending()
{
sigset_t pending;
sigpending(&pending);
for(int signo = 31; signo > 0; --signo)
{
if(sigismember(&pending, signo)) cout << "1";
else cout << "0";
}
cout << "\n\n";
}
void handler(int signo)
{
PrintPending();
cout << "process get a signal: " << signo << endl;
}
int main()
{
struct sigaction act, oact;
memset(&act, 0, sizeof(act)); // 清空初始化
memset(&oact, 0, sizeof(oact));
act.sa_handler = handler; // 设置自定义处理字段
sigaction(2, &act, &oact); // 注册2号信号
while(true)
{
cout << "I am a child process, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
进程捕捉到 2 号信号,在自定义处理函数内打印 pending 表,此时信号的处理并未结束,但 pending 表中的 2 号下标处已经由 1 -> 0,由此可见,在信号处理环节中,是先把 pending 表中的 1 -> 0,再调用 handler 自定义处理方法。
在处理一个信号时,会在信号捕捉方法之前就清除 pending 位图的特定位。同时,信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字(即 block 表由 0 -> 1),当信号处理函数返回时自动恢复原来的信号屏蔽字(即自动解除当前信号的屏蔽),这样就保证了在处理某个信号时,如果该信号再次产生,那么它会被阻塞到当前本次信号处理结束为止。换言之,当 x 信号正在被进程处理,在 x 信号的处理期间,即便再次收到 n 次 x 信号,那么 x 信号也无法被递达,只能被保存在 pending 位图中,并且收到的 n 次 x 信号,都只计为 1 次,即收到信号,不记录收到了 n 次,将来信号处理时也只处理一次。
-
一个信号处理期间,为什么要屏蔽该信号的递达呢?
实际上,在信号处理时,是有极大的可能性会陷入内核的。例如信号的捕捉方法 handler 方法中加入一些打印信息等,那么本质就是在调用系统调用,而在调用捕捉方法之前,pending 的特定位就已经被清0了,因此如果在信号处理期间再次收到了同样的信号,加上 handler 方法内本身就能够陷入内核,所以它是具备信号检测的条件的!所以此时就会导致不断的陷入 handler 方法进行信号的捕捉,即不断的对 handler 方法进行调用,那这其实就是某种意义上的死循环了,你说操作系统能允许吗?而加入自动屏蔽当前信号的机制,就能够防止当前信号捕捉函数被嵌套调用。
验证自动屏蔽当前信号机制:
void handler(int signo) { cout << "process get a signal: " << signo << endl; while(1) PrintPending(); }
而如果在调用信号处理函数时,除了当前信号会被自动屏蔽之外,还希望自动屏蔽另外一些信号,则可以用 sa_mask 字段说明这些需要额外屏蔽的信号。同样的,这些额外设置自动屏蔽的信号,当信号处理函数返回时,都会自动恢复原来的信号屏蔽字,即取消屏蔽。
int main()
{
....
// 信号捕捉处理期间自动阻塞1 3 4信号
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 1);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
....
}
如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!
感谢各位观看!