博客横幅图

自律是普通人最大的武器


系列专栏直达链接
🐧 Linux进入专栏 →
🔤 C语言进入专栏 →
🔧 C++进入专栏 →
🐬 MySQL进入专栏 →
🧩 算法刷题进入专栏 →

🏠 博客主页:虚无的纽扣
📦 作者仓库:github | gitee

引言

  上篇我们讲完了信号的产生与保存:五种产生方式殊途同归,最后都是操作系统去修改目标进程 task_struct 里的 pending 位图,而进程侧的三张表——pending、block、handler,构成了所有信号操作的骨架。但有一个问题一直悬着:信号的处理不是立即进行的,要等“合适的时候”——那这个“合适的时候”到底是什么时候?
  这就是下篇要回答的问题。这一篇会一路走到内核里去:先看信号到底在什么时机被处理,再看进程凭什么能进入内核,最后聊到硬件中断、时间片、系统调用这些操作系统的底层机制。内容比较硬核,我们一块一块来。

1、信号什么时候递达

  先明确一下:信号递达的方式有三种——执行默认动作、执行自定义捕捉、忽略,上篇讲处理方式时提过。那“合适的时候”是什么时候?看下面这张图:
在这里插入图片描述
  当进程从内核态返回用户态的时候,操作系统会进行信号检查:先查 pending 表是否接收到信号,如果接收到信号再查是否 block,如果没有 block 就处理信号。如果信号是默认动作怎么处理?忽略又怎么处理?因为获取信号的过程本身就在操作系统的内部,所以操作系统可以根据信号的处理类型直接进行处理:如果是忽略就直接略过,如果是默认就调用 handler 表中的处理方法,比自定义捕捉要简单很多。
  那在执行自定义方法时,操作系统要做身份切换吗?是以用户身份还是以操作系统内核身份执行的?答案是以用户身份执行的,不能用内核身份执行。因为如果用户在自定义方法里进行了非法操作,用内核身份执行就会导致非法操作真的被执行,所以必须以用户身份来执行自定义方法。
  把这个过程画成图,就是下面这条“无限符号”的曲线:
在这里插入图片描述
  横线上方是用户空间,下方是内核空间,整个过程总共要进行四次身份切换,切换的方向就是箭头指向的方向,中心的交点就是信号检测的时间点。讲完了进程在进入内核和返回用户层的过程,现在还有一个问题:我的进程,凭什么进入内核?不调用系统调用也会进入内核吗?答案是会。因为这份代码在运行时就是一个进程,只要是个进程它就会被调度,当我们的进程在 CPU 上运行,一个时间片里没有运行完,操作系统就会将进程从 CPU 剥离下来并让其进入内核。

2、内核态与用户态

从硬件中断说起

  要弄明白内核态,得先知道操作系统是怎么运行的,这就绕不开硬件中断。先看一个问题:操作系统怎么知道键盘输入数据了?外部设备在收到数据之后,就会向与 CPU 关联的特定针脚发起硬件中断;外部设备和 CPU 之间还有一个特殊的设备叫做中断控制器,这个工作就是由它来完成的:中断控制器给每一个硬件都提供对应的接口,外部设备一旦就绪,就会给中断控制器发送一个高电平,中断控制器根据接口的编号就会知道是哪个硬件就绪,在自己内部形成一个中断号。
  再引入一个概念:假设我们现在要向磁盘中写入数据,当我们将命令下发给磁盘时,并不是直接下发给磁盘,而是下发给磁盘的磁盘控制器;大部分的硬件设备包括内存,内部都会有自己的寄存器,有的寄存器接受命令信息,有的寄存器接受位置信息等等,寄存器这个概念在外设中也有。中断控制器也可以算是一个设备,所以它也有自己的寄存器:当某一个设备给它发送一个高电平,它就能识别到是哪个硬件发送的,并将编号记录到它的寄存器里,再由中断控制器给 CPU 的特定针脚发送一个高电平;CPU 收到高电平就知道有设备准备好了,然后访问我们的中断控制器,从寄存器中拿到中断号,就知道哪一个外部设
在这里插入图片描述
  以上是硬件部分的运行过程,接下来是软件部分。CPU 光知道设备准备好了没有用,因为 CPU 并不知道该怎么处理这一批数据,只有软件清楚。所以为了能够对所有的外部设备进行处理,操作系统在自身内部提供一个叫做中断向量表(IDT)的东西:中断向量表就是一个函数指针数组,指针指向的就是一个方法,数组的下标就是 CPU 收到的中断号,CPU 就会用中断号在中断向量表中索引对应的方法,并执行方法。从此往后,操作系统再也不用关心外部设备是否准备好,而是外部设备准备好后就会通知操作系统;IDT 就是操作系统的一部分,对应执行中断处理的方法本身就是操作系统的一部分。
  这一系列操作和我们之前讲的信号机制很像:发中断就是发信号,保存中断号就是记录信号,中断号就是信号编号,处理中断号就是处理信号。但要注意,信号是纯软件机制,信号本质就是用软件来模拟硬件中断的!
  那当没有中断到来的时候,操作系统在做什么?什么都没做!那操作系统就一直在这里停着不动也不太好,所以操作系统的中断向量表中还注册了一个方法叫做进程调度,并且在计算机硬件上再引入一个新的东西叫时钟源:它会以固定的、特定的频率向 CPU 发送特定的中断,每次时钟源发送中断,操作系统都要进行一次中断方法——进程调度。从此以后,操作系统就在硬件时钟源的驱动下进行调度了,所以操作系统就是基于中断进行工作的软件。
  后来,因为时钟源如果作为外设效率会比较低,所以程序员就将时钟源集成在了 CPU 上:CPU 会每隔一段时间向自己发起一次中断,然后去调用对应的方法,这个概念就叫主频。每一台电脑的设置里可以看到电脑的处理器信息,上面的频率就是它的中断触发的频率。时间片的本质也是通过中断来实现的:task_struct 里会记录中断的次数,中断一定次数后该进程的此次运行时间就用尽了,这就是时间片耗尽了。所以有了这一系列操作,操作系统就可以完全躺平了:自己不做任何事情,需要什么功能,就向中断向量表里添加方法即可,操作系统的本质就是一个死循环,在时钟的推动下自动调度。

软中断

  上述外部硬件中断需要硬件设备触发,那有没有可能,因为软件的原因也触发上面的逻辑?有。当我们的进程在运行代码的时候出现了错误,CPU 将这种错误也规定成一种由 CPU 内部触发的中断,就像时钟源一样,CPU 自己会生成一个中断号并进行对应的中断处理,比如给目标进程发送信号。所以为什么操作系统会知道你的代码出问题了然后给你发送信号呢?就是因为 CPU 运行过程中出现了问题自动触发了中断,然后整个操作系统自动执行中断处理方法,执行我们的异常处理。所有我们之前曾说过的硬件层面的错误,都要在中断向量表中注册对应的方法进行处理,所以操作系统能够知道硬件出异常的原因,就是所有异常都会转化成中断,CPU 会自动执行中断处理方法。
  不过严格来说,这种中断并不是软件触发的:它是软件出错导致硬件出错触发的中断,还是硬件。那有没有可能让 CPU 通过软件主动触发中断?在 CPU 的指令集当中,有两个指令可以实现:在 x86 系统里是 int,在 x86_64 系统里是 syscall,用它们来让 CPU 主动触发中断。
  现在回到之前的一个问题:当我们进行系统调用的时候,具体是怎么进入操作系统、完成系统调用过程的?对于系统调用,操作系统在中断向量表中也注册了一个方法,另外系统里还有一张系统调用函数指针表,用于系统调用中断处理程序;从此往后,每一个系统调用都有一个唯一的下标,这个下标我们叫做系统调用号。
  当用户使用系统调用,CPU 触发软中断,在中断向量表对应的方法中,先获取系统调用号,然后调用系统调用方法。在用户层面是怎么实现的呢?简单来说就是系统调用的底层会先把该系统调用的编号保存在寄存器中,然后触发软中断;系统调用的中断方法就会把寄存器中存储的系统调用号存到自己的变量中,就可以实现用户设置的系统调用号让内核拿到,根据系统调用号索引到对应的方法然后调用,实现系统调用。
  那在我们之前的概念中,系统调用本身就是操作系统提供的接口,为什么还要进行这一系列的操作,不是有点多此一举吗?答案是操作系统其实不提供任何系统调用接口,只提供系统调用号,所有的系统调用本质上就是不同的调用号而已。从今天起我们就知道了,操作系统就是躺在中断处理例程上的代码块。这里再补充两个名词:像这种由 CPU 内部产生的中断,教材里统称为软中断——其中通过 int 或 syscall 这类指令主动触发的,叫做陷阱;除零、野指针这种出错引起的,叫做异常。另外,系统调用的过程也是在进程地址空间上进行的,所有的函数调用,都是地址空间之间的跳转,无非是距离长短的问题。

用户态与内核态的切换

  最后看内核空间在地址空间里是怎么摆放的:
在这里插入图片描述
  曾经我们在进程地址空间那篇里讲过,进程的虚拟地址空间会通过页表的映射与物理地址空间关联起来,我们的用户空间使用用户级页表,那内核空间是否也有自己的页表呢?答案是确实也有。内核页表在功能实现上与用户页表完全一样,不一样的是:用户页表会有多份,有多少个进程就有多少个用户页表;内核页表系统只有一份,所有进程共享,因为操作系统的代码和数据只有一份,而且每一个进程都把操作系统映射到自己的地址空间了,不管是进程地址空间还是物理内存,它们的位置都是固定的,所以只需要一份页表。这意味着:无论进程如何调度,我们总能找到操作系统。
  所以我们的用户和内核都在同一个 4GB 的地址空间上了。那现在有一个问题:如果用户随便拿一个属于内核空间的虚拟地址,用户不就可以随便访问内核中的代码和数据了吗?可是我们之前还讲过,操作系统为了保护自己,不相信任何人,必须采用系统调用的方式进行访问,这两个结论之间不是冲突了吗?为了保证操作系统本身的安全性,又要保证用户能够访问到操作系统,就引入了一种概念叫用户态和内核态:用户态,就是以用户身份,只能访问自己的用户空间;内核态,就是以内核的身份,运行你通过系统调用的方式,访问内核空间。
  那在系统中,用户或者操作系统怎么知道当前处于内核态还是用户态?CPU 里有一个寄存器专门用来记录当前处于内核态还是用户态,叫做 cs 寄存器,这个寄存器里最低的两位地址记录了我们当前处于内核态还是用户态:如果为 11 则表示为用户态,为 00 表示为内核态。所以我们之前讲的 int 和 syscall 指令,其实就是让代码跳转到内核区,同时将 cs 寄存器的 11 改为 00,此时就叫做陷入内核,并且必须提供系统调用号才能访问操作系统,不然一切都视为非法操作。所以我们当前处于用户态还是内核态,是由这个寄存器来决定的。

3、SIGCHLD 信号

  最后补一个自定义信号处理的典型应用。进程一章讲过用 wait 和 waitpid 函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式):采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
  其实,子进程在终止时会给父进程发 SIGCHLD 信号,该信号的默认处理动作是忽略。父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了——子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 清理子进程即可。

结语

  两篇合起来,信号的全貌就完整了:产生端五种方式殊途同归,落到 pending 位图;保存靠 pending、block、handler 三张表;处理则卡在内核态返回用户态的那个检查点上,先查 pending 再查 block,默认和忽略内核顺手就办了,自定义捕捉要回到用户态执行,一来一回正好四次身份切换。而为了讲清“凭什么进内核”,我们又把硬件中断、中断向量表、时钟源、系统调用号这条线走了一遍——操作系统的本质就是一个死循环,躺在中断处理例程上,被时钟源推着走。
  如果有哪里讲得不清楚,也欢迎各位大佬在评论区指正,我们一起把这些底层的东西啃透。以后我也会继续创作 Linux 方向的博客,如果觉得有帮助的话,希望各位大佬能三联关注支持一下,看到后我会进行回关和回访。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐