《操作系统真像还原》操作系统实现——中断

Posted on May 23, 2021

中断这个东西说起来不是很难,实现起来比较麻烦,主要是和硬件有一定联系,会略显复杂。

宏观视角

宏观地来看,一个中断的过程就是 CPU 接受中断信号,然后执行对应的处理函数。这里的中断分外部中断和内部中断两种。

外部中断

所谓外部中断,顾名思义就是外部设备产生的中断。CPU 中有两条型号线 INTRINTeRrupt) 和 NMINon Maskable Interrupt)来接受外部中断信号。前者接受的是可屏蔽中断,后者则是不可屏蔽中断

可屏蔽中断

这种中断是各类外设向 CPU 发出的 ,由于不甚危急,CPU 可以选择不予理会。对于许多中断,也可以将对中断的响应和处理分开(Linux 就是这样做的)以加快对中断的响应速度。

对中断的屏蔽是通过 eflags 寄存器的 IF 位来实现的。此位为 1 的时候,CPU 不会再响应中断。

不可屏蔽中断

不可屏蔽中断往往代表非常严重的错误,往往会造成宕机,这种型号不能被屏蔽,任何时候 CPU 收到此型号都需要立即进行处理。

内部中断

内部中断往往是由软件产生的,分为软中断和异常。

软中断大多由软件自己调用,一般的语法为 int 8 位立即数,此指令较常用,Linux 中就是通过 int 0x80 来进行系统调用的。类似的指令还有 int3(实现下断点)等。

异常则是运行时的错误,比如缺页异常、除数为零等。

内部中断往往不可屏蔽,因为如果屏蔽了这些中断往往会影响正常运行。比如如果屏蔽用户发出的软中断,就可能造成用户希望进行的系统调用无法执行,又比如屏蔽了缺页异常,就可能造成无法正确执行代码,所以这些中断不受 IF 控制。

底层视角

CPU 对中断的响应必然需要通过特定的函数来响应,由于中断数量非常多(处理器支持 256 个中断,编号 0 ~ 255),自然需要一个数据结构来维护响应的函数并在中断到来时进行响应,这个结构就是中断描述符表。

中断描述符表

和 GDT 类似,中断描述符表IDTInterrupt Descriptor Table)也是一个类似于数组的数据结构,通过中断号就可以寻址到每个特定的描述符项。特别的,一般情况下中断都会伴随着特权级的切换(比如系统调用进入内核态),为了实现这个切换,需要用到门,四个门中我们选用中断门(事实上,中断门只允许放在 IDT 中,可见两者有多么搭配),其结构为

高 32 位:

31~ 161514 ~ 131211 ~ 87654 ~ 0
中断处理程序在段中的偏移的
31 ~ 16 位
PDPLS = 0TYPE000未使用

低 32 位:

31 ~ 1615 ~ 0
中断处理程序的段选择子值中断处理程序在段中的偏移的 15 ~ 0

我们就在中断描述符表的每一项里放入中断门(事实上 IDT 中只存放门),这样就可以在中断到来时通过中断门来进行相应的处理。

每个中断门的结构可以用这样一个结构体来表示

/* interrupt gate descriptor */
struct INT_gate_desc
{
    uint16_t function_offset_low_word;
    uint16_t selector;
    uint8_t dword_count; /* fixed value */
    uint8_t attribute;
    uint16_t function_offset_high_word;
};

通过建立一个结构体数组就可以建立起 IDT

static struct INT_gate_desc IDT[IDT_DESC_SUM]; /* Interrupt Descriptor Table */

通过 lidt 48 位数 可以设置 LDTR 寄存器,类似于 GDTR,存储了 LDT 表的基址。

    /* load IDT */
    uint64_t idt_operand = ((sizeof(IDT) - 1) | ((uint64_t)((uint32_t) IDT << 16)));
    __asm__ volatile ("lidt %0": : "m" (idt_operand));

这样就设置好了 LDT 表。

中断处理过程

光有 IDT 肯定不够,还需要一个动态的过程来处理整个中断。下面先总结一下中断处理的过程

  • 外中断:外设向 CPU 发送中断信号,通过中断代理芯片的转换调度后使 CPU 收到中断,CPU 对 IDT 查表后执行对应的中断处理程序
  • 内中断:CPU 对 IDT 查表后执行对应的中断处理程序

若暂且不考虑硬件层面的处理,可知中断的处理过程主要就是查表执行,这个过程具体如下

  1. 处理器根据中断号查表得到中断门。这个过程比较容易,只要将中断号 * 8 加上 IDTR 就可以了。
  2. 特权级检查:对于软中断,保证数值上 CPL <= 中断门的 DPL,数值上 CPL >= 目标代码的 DPL;对于外中断,只要满足数值上 CPL >= 目标代码的 DPL 就可以了。
  3. 执行中断处理程序

执行中断处理程序时,首先通过 IDT 和 GDT 协同的方式计算出目标代码的位置,也就是获得 IDT 中的段选择子后通过 GDT 计算出基地址再加上 IDT 中的偏移来计算。

跳转至目标函数的时候会进行多次压栈操作,首先处理特权级转移时栈寄存器的保存问题,即如果存在特权级转移,就将旧的 ss 和 esp 寄存器压入栈中。然后压入 eflags 进行备份,再压入 cs 和 eip 备份原来的代码地址,最后根据是否有异常错误码压入错误码,最后跳至异常处理函数处执行。注意在压入 eflags 备份之后、跳至异常处理函数执行之前 eflags 的 IF 位会被置 0,此时处理器不再接受可屏蔽中断,以避免相同中断嵌套造成 GP 中断。

对应于压栈过程,有 iret 语句来处理返回的情况,然而,iret 默认没有错误码的,也就是说,编写异常处理函数的人必须手动地清理掉错误码。一种解决方案是在函数开头时根据有无错误码进行一次压栈操作(异常有无错误码压栈是事先已知的,可以直接在编译期完成判断),这样在返回前就可以一并清空栈空间,避免代码冗余。

关于错误码:一般只有外部中断才会压入中断码。

关于特权级检查

对于软中断,经过了两步特权级检查:

  • 第一步是数值上 CPL <= 中断门的 DPL。CPL 大于 DPL 代表调用者有调用该门的权限,这里检查的是权限级别。
  • 第二步是数值上 CPL >= 目标代码的 DPL。CPL 大于目标代码的 DPL,代表特权级向高处转移,这里检查的是操作的合法性,是保证特权级只向高处转移(仅在返回时向低处转移)。

外部中断比较简单,可以和软中断的第二步类比。

总结

从内核外面向里面来看,一个中断的处理过程就是硬件或进程向 CPU 发送中断信号,CPU 通过 IDT 取得中断有关的信息(处理程序的段选择子和偏移地址以及权限信息),通过特权级检查后调用中断处理程序。而对应的中断处理程序需要处理好中断,并在返回前清理栈中的错误码。

硬件视角

这个主要是针对外部中断的,比较复杂。

CPU 自己无法接受所有的中断,因为每个中断都需要一条数据线接到 CPU 上的话,会导致 CPU 体积的大幅膨胀,为了解决这个问题,Intel 推出了一系列的中断代理芯片来进行对中断的缓冲,该芯片可以暂存并挑选中断后,将选中的中断发送给 CPU。这个系列就是 8259A 可编程中断控制器。书上花了大量篇幅介绍该芯片,我觉得不是很有必要,毕竟我们是在学习操作系统而不是硬件,而且花了很久看完我也基本上全忘了。我这里只随便说一下我记得的东西,具体操作方法不再赘述。

该芯片每一张都有 8 个接口来接入外设,一个接口来输出中断信号,通过级联(类似于电路中的串联,把一个芯片(从片)的输出口接入另一个芯片(主片)的输入口)的方法可以实现接口的扩展。每个外设都将其信号线接到芯片组的一个接口上,当有中断信号发出时,芯片组会接受信号并暂存(每个接口都对应了一个寄存器对中断信号进行处理),起到缓存的作用,并在合适的时机将信号发送给 CPU。

别的不记得了。需要用的时候再查吧。

内部中断在硬件层面上的工作方式我们不关心,因为其实这个不需要我们进行硬件层面的控制。

时钟

时钟分内部时钟和外部时钟,内部时钟在主板上,我们不可控,外部时钟往往由可编程芯片控制,我们通过与对应芯片 IO 可以设置时钟频率。

以 8253 芯片为例,其振晶发出的脉冲信号频率为 1.19318 MHZ,在芯片内部有一个计数器,计数器的结构这里不说了,每次芯片接受到脉冲信号时,计数器会减一,减到 0 时,会通过 OUT 端向 CPU 发出中断信号(实际上是由中断代理芯片代理接受的)。

此芯片有 6 中工作模式,我们使用其比率发生模式,设置其计数器起始值,然后芯片就会从起始值开始自减,减到 0 后会自动复位到我们设置的起始值进行下一轮自减。通过设置计数器起始值,我们可以控制中断的频率,公式为 起始值 = 1.19318MHZ / 中断频率。

实现

代码比较多,这里也就不放了,可以看笔者的 commit