⚠ 转载请注明出处:作者:ZobinHuang,更新日期:Dec.4 2021
本作品由 ZobinHuang 采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,在进行使用或分享前请查看权限要求。若发现侵权行为,会采取法律手段维护作者正当合法权益,谢谢配合。
目录
有特定需要的内容直接跳转到相关章节查看即可。
Section 1. 概念解释: 解释了本文中会使用到的概念;
1.1 中断与异常
1.2 内核控制通路 (Kernel Control Path)
Section 2. IRQs 和 Interrupts:解释了异步中断 (Interrupt) 的一些概念和硬件处理过程;
2.1 Programmable Interupt Controller (PIC)
2.2 Advance Programmable Interupt Controller (APIC)
Section 3. Interrupt Descriptor Table: 介绍了中断描述符表;
3.1 Task Gate
3.2 Interrupt Gate
3.3 Trap Gate
Section 4. 中断和异常发生时的硬件处理过程: 详细的罗列了中断信号发生的时候的硬件处理过程;
Section 5. 对嵌套的支持: 简单地总结了内核对嵌套 Handler 的支持情况;
注明:
- 我们在 内中断、外中断 和 中断和异常的处理与抢占式多任务 曾经介绍过在汇编语言下的中断理解,如果你还没有看过这些文章,我强烈建议你先阅读它们。
- 本文使用 进程环境 一词来指代在基于某个特定的 TSS 结构下的 CPU 运行环境,进程的切换将导致进程环境的切换,而从用户态到内核态、陷入中断上下文等则不会改变进程环境;
1. 概念解释
1.1 中断与异常
在 Linux 中,我们常说的中断 (Interrupt) 可以分为两种:
- 同步中断 (Synchronous Interrupt): 由 CPU 的控制单元产生,产生的原因是因为 CPU 遭遇到了编程的错误 (e.g. 除以 0) 或者必须要由内核来处理的异常条件 (e.g. 页表错误或者基于
int指令发出的对内核服务的软中断请求 (request)):对于前者,内核会将相关的信号 (signal) 传递给产生错误的进程; 对于后者内核将会执行一系列需要的代码来对异常条件进行恢复 (recover)。控制单元会在暂停一条指令的执行之后再发出同步中断; - 异步中断 (Asynchronous Interrupt): 由内部计时器或者外部硬件设备产生,产生的时间不定,异步中断由外部硬件设备根据在硬件时钟的约束之下进行产生。
在 Intel 的语境中,把 同步中断 又称为 异常 (Exception); 把 异步中断 又称为 中断 (Interrupt)。我们在本文中将沿用这样的称法来区分它们。注意,我们将用 中断信号 (Interrupt Signal) 来统一称呼导致 CPU 指令执行顺序被打乱的事件,即 中断 + 异常。
简单以进行区分就是:Interrupt Controller 将 IRQ 信号转化为 CPU Interrupt,以触发 CPU 执行相应的 Interrupt Handler; CPU Control Unit 捕获程序运行过程中的异常,以触发 CPU 执行相应的 Exception Handler。
对于中断和异常,Intel 的文档中做了如下说明:
Interrupt 和 Exception
Interrupt 和 Exception 的具体分类的 Overview 如下,我们在后面做详细说明。
1
2
3
4
5
6
7
8
9
10
11
12
13
14|- Interrupt
| |
| |- Maskable Interrupts
| |- Nonmaskable Interrupts
|
|- Exception
|
|- Processor-detected exception
| |
| |- Fault
| |- Trap
| |- Abort
|
|- Programmed exceptions
(a) Interrupt
- Maskable Interrupts: 所有的外部设备发出的 Interrupt Requests (IRQs) 都会导致 CPU 发生 Maskable Interrupts。每个 Maskable Interrupt 都包含两种状态: masked 和 unmasked。只有 unmasked 的 Interrupt 才会被 CPU 响应;
- Nonmaskable Interrupts, NMI: 只有一些比较重要的诸如外部硬件错误等的事件才会导致触发 Nonmaskable Interrupt。CPU 上有独立的 NMI 引脚,CPU 必须响应所有的 Nonmaskable Interrupts;
(b) Exception
-
Processor-detected Exceptions: 当处理器在执行指令的过程中发现异常事件的时候被触发。根据处理器在跳转到 Handler 之前存储到 Kernel Stack 中的程序计数器 (i.e.
cs和eip) 的值,该类异常又可以被分为:
Fault: 这种异常通常都是可以被恢复的,因此存储到 Kernel Stack 中的程序计数器值是发生异常的指令的地址,常见的该类异常比如访问一个暂时不在内存中的页面的时候产生的 Page Fault;
Trap: 在 Trapping Instruction 被执行之后被立即报告的异常,存储到 Kernel Stack 中的程序计数器值是 Trapping Instruction 之后的一条指令的地址。该类异常的主要功能是为了方便程序进行调试;
Abort: 当 CPU 的控制单元陷入严重错误时,该类异常会被报告。此时 CPU 可能已经无法将具体的导致发生异常的程序地址存储到 Kernel Stack 中,CPU 将会切换到紧急的 Abort Exception Handler 中去执行相关代码,此时将不得不终止导致发生异常的进程; - Programmed Exceptions: 异常可能是由程序中的一些指令引发的,比如
int、int3、into(用于检查是否溢出)、bound(用于检查地址范围) 等,后两个指令将会在它们检查的条件不为真时触发异常。Programmed exceptions 会被处理器当作 Trap Exception 来看待。值得注意的是,在 Intel 的语义下,Programmed Exceptions 通常被叫做 Software Interrupt,这与我们在后面的文章中将要讨论的 Deferrable Interrupt 要区分开,Deferrable Interrupt 包含了 softirq 和 tasklet 两种形式。在后面的讨论中,我们将用 Programmed Exception 来称呼由程序引发的异常,用 Deferrable Interrupt 来统称 softirq 和 tasklet。
每个 Interrupts 和 Exceptions 都被一个在 `[0, 255]` 区间内的数唯一标识。在 Intel 的语义下把这个 8-bits 的标识数称为 Interrupt Vector。对于 Nonmaskable Interrupts 和 Exception 来说,它们的标识是固定的; 对于 Maskable Interrupts 来说,它们的标识可以通过对 Interrupt Controller 进行编程来进行修改。
Linux Kernel 的 Interrupt Vector 的分配情况如下所示:
Vector 范围 |
用途 |
|---|---|
0-19 (0x00-0x13) |
Nonmaskable Interrupts 和 Exceptions |
20-31 (0x14-0x1f) |
Intel 保留 |
32-127 (0x20-0x7f) |
外部 Interrupts (IRQs) |
128 (0x80) |
Programmed Exception for System Calls |
129-238 (0x81-0xee) |
外部 Interrupts (IRQs) |
239 (0xef) |
Local APIC Timer Interrupt |
240 (0xf0) |
Local APIC Thermal Interrupt |
241–250 (0xf1–0xfa) |
Linux 保留 |
251-253 (0xfb-0xfd) |
Interprocessor Interrupts |
254 (0xfe) |
Local APIC Error Interrupt (当 Local APIC 检测到错误条件 (erroneous condition) 时被发出) |
255 (0xff) |
Local APIC Spurious Interrupt (在 CPU 屏蔽 (mask) 了一个 Interrupt 的情况下,当对应该 Interrupt 的外部设备发出 Interrupt 时被拉起) |
从上述表中我们可以看到,0-31 号 Interrupt Vector 是预留给处理器架构相关的 Interrupt 和 Exception 的,而 32-255 号 Interrupt Vector 是可以供处理器用户 (i.e. OS) 进行自定义使用的。
1.2 内核控制通路 (Kernel Control Path)
我们把用于处理 System Call、Exception 和 Interrupt 的代码段称为 内核控制通路 (Kernel Control Path, KCP)。KCP 是在同一个进程环境中被执行的。通常来说,当有上述三种情况发生时,KCP 将相应的代码从头执行到尾,但是当有如下三种情况发生时,内核又会在处理的过程中插入新的内容:
- 当一个用于处理 System Call 的 KCP 被启动的时候,如果 KCP 发现系统调用的相关请求没法被立刻响应,则 KCP 会调用 Schedule 来调度一个新的进程来运行。CPU 可能会去执行其它的 KCP,前后新老的 KCP 是在不同进程环境中被执行的;
- 当 CPU 在执行 KCP 的时候发生 Exception (e.g. Page Fault) 时,此时 CPU 会暂停当前 KCP 的执行,转而启动新的用于应对当前新异常的 KCP。注意前后新老 KCP 是处在同一个进程环境中被执行的;
- 在内核抢占 (Preemption) 被使能的条件下,内核在运行一个 KCP 的时候有可能会遭遇中断,以运行更高优先级的进程,此时则会予以暂停并发生进程的切换;
2. IRQs 和 Interrupts
本章主要解释 Interrupt 的硬件原理。
2.1 Programmable Interupt Controller (PIC)
大部分位于外部硬件设备上的控制器可以在与中断控制器相连的 Interrupt ReQuest (IRQ) Line 上发出 IRQ。IRQ line 是一种片上资源,每条 IRQ line 都会有一个序号,我们下面用 IRQx 来指代这个序号。一条 IRQ line 上并不一定只有一个设备,每个设备都可以被配置为使用序号为 IRQx 的 IRQ line,即使有其他的设备也同样的在使用这条 IRQ line。因此,内核必须考虑 IRQ line 共享的问题。
另外,对于一个设备来说,它可以拥有多条 IRQ line,例如 PCI 设备来说,它可以拥有四条 IRQ Line。
外部设备的中断信号线会接到一个叫做 Programmable Interupt Controller (PIC) 的硬件设备的的引脚上。PIC 的工作流程是:
- 监控所有连接着的 IRQ lines 是否有使能信号,如果有,跳转到
2。并且如果有两个或以上的 IRQ line 被外部设备使能,则选择引脚标识数最小的那个 IRQ line 作为接收到的中断; - 通过查表,将产生使能信号的 IRQ line 的
IRQx转化为对应的 Interrupt Vector; - 将 Interrupt Vector 存储在 PIC 的某个 I/O Port 上,以可以让 CPU 来进行读取;
- 将处理器的
INTR引脚置为高电平,即中断处理器; - 处理器通过读取 I/O 端口获得对应的中断向量以进行处理。处理器通过写入 PIC 的 I/O 端口来告知中断已经被 handled,接收到 Acknowledgement 后 PIC 会把
INTR引脚重新复位; - 回到
1
PIC 可以选择性地被使能/关闭序号为 IRQx 的 IRQ line 上的中断信号,这是通过对 PIC 进行编程来实现的。一个 IRQx 被屏蔽 (masked) 之后,PIC 之后便不会把使用该 IRQx 的 IRQ lines上产生的中断通报给 CPU。
注意,这些 IRQx 被屏蔽并不代表着对应的 IRQ 就被丢失了,它们会在这些 IRQx 被解除屏蔽之后由 PIC 重新发送给 CPU。这种 feature 是为 Interrupt Handler 设计的,这使得它们可以串行地对同种 IRQ 进行处理。
另外值得注意的是,上述的通过对 PIC 编程以屏蔽中断的方法,要和通过置位 CPU 的 EFLAGS 寄存器的 IF 位以屏蔽中断区分开。前者是屏蔽特定 IRQx 的 IRQ,后者是屏蔽所有 Maskable Interrupt。
2.2 Advance Programmable Interupt Controller (APIC)
上面我们讨论 PIC 的时候把眼光局限在了单处理器的系统上。对于 Symmetric Multi-Processing (SMP) 系统,我们必须予以优化,我们把我们这一节讨论的中断处理器称为 Advance Programmable Interupt Controller (APIC)。
如上图所示,在 SMP 系统中,首先各个 CPU 内部都有一个 Local APIC,每个 Local APIC 都有一个 32-bits 的寄存器,一个内部时钟,并且还有两条 IRQ lines LINT 0 和 LINT 1。其次,在主板上还会有一个 I/O APIC 以接收来自外部设备的 IRQs 并将它们路由到对应的 CPU 的 Local APIC,I/O APIC 有 24 条 IRQ lines,一个可以容纳 24 条表项的 Interrupt Redirect Table。Local APIC 和 I/O APIC 通过 Interrupt Controller Communication (ICC) bus 进行连接。I/O APIC 的 Interrupt Redirect Table 中存储的每一条表项都记录了 vector、priority、目标 CPU (for static distribution,见下面) 以及 目标 CPU 的选择方式 (static or dynamic distribution) 的关系。
当有一个来自外部设备的新的 IRQ 被 I/O APIC 接收到时,它有两种方案来将这个 IRQ 路由到相应的 CPU 的 Local APIC 中去:
- Static Distribution: 根据 Interrupt Redirect Table 中记录的关系来进行路由,可能路由到一个、多个甚至所有的 CPU 的 Local APIC 中去;
- Dynamic Distribution: 将 IRQ 路由到正在处理优先级最低的进程的 CPU 的 Local APIC 中去。每个 Local APIC 中都有一个 Task Priority Register (TPR),这个寄存器在每次发生进程切换的时候都会被更新,以存储当前 CPU 正在运行的进程的优先级。I/O APIC 就是根据这个数值来决定路由的目的的
基于上述设计,Interprocessor Interrupts (IPIs) 也是被支持的。当一个 CPU 想要给另一个 CPU 发送中断时,它会在它自己的 Local APIC 的 Interrupt Command Register (ICR) 中存储 vector、目标 CPU Local APIC 的标识码等信息,然后通过 Interrupt Controller Communication (ICC) bus 将相关信息发送给目标的 Local APIC,以触发目标 CPU 的中断。
3. Interrupt Descriptor Table
IDT 用于存储对应每一个 vector 的 Handler 的相关地址信息,以便于处理器在知晓中断信号对应的 vector 后跳转到相关的 Handler 去执行。每条 IDT 表项需要占用 8 bytes,而 80x86 最高支持 256 个 vector,因此 IDT 需要占用 `8 \times 256 = 2048` Bytes 的空间。
IDT 中可以存储如下三类的段描述符:
3.1 Task Gate
| 长度 | 16b | 1b | 2b | 5b | 8b |
| 含义 | [不使用] | P | DAL | 0 0 1 0 1 | [不使用] |
| 长度 | 16b | 16b |
| 含义 | TSS 选择子 | [不使用] |
当 vector 指向的 IDT 的表项是一个任务门时,CPU 需要执行任务切换。如上所示是任务门的格式,本质是一个段描述符,其中包括了目标 Handler 任务的 TSS 选择子,用于恢复 Handler 任务的现场。(p.s. 如果不清楚 TSS 相关内容,请查阅我的另一篇文章中的相关部分: TSS (Task State Segment))
关于更多任务门相关的内容,可以参考我的另一篇文章: 基于中断的任务切换。
3.2 Interrupt Gate
| 长度 | 16b | 1b | 2b | 5b | 3b | 5b |
| 含义 | 中断处理过程在目标代码段内的偏移量 [31~16] | P | DAL | 0 D 1 1 0 | 0 0 0 | [不使用] |
| 长度 | 16b | 16b |
| 含义 | 目标代码段描述符选择子 | 中断处理过程在目标代码段内的偏移量 [15~0] |
当 vector 指向的 IDT 的表项是一个中断门时,CPU 将在当前进程环境内发生执行代码段的切换,因此我们可以看到在中断门中包括了 Handler 代码段选择子和以及段内的偏移量。并且值得注意的是,对于基于中断门发生的任务内控制流切换,在程序跳转后,处理器会将 EFLAGS 的 IT 位清零,以禁止当前的中断处理程序被新的 Maskable Interrupt 所打断,也即禁止了中断的嵌套,在中断程序返回时,将会从栈中恢复 EFLAGS 的中断前状态。
3.3 Trap Gate
| 长度 | 16b | 1b | 2b | 5b | 3b | 5b |
| 含义 | 中断处理过程在目标代码段内的偏移量 [31~16] | P | DAL | 0 D 1 1 1 | 0 0 0 | [不使用] |
| 长度 | 16b | 16b |
| 含义 | 目标代码段描述符选择子 | 中断处理过程在目标代码段内的偏移量 [15~0] |
陷阱门的原理和中断门大都类似,只不过陷阱门不会像上述中断门一样去修改 EFLAGS 寄存器的 IF 位。
值得注意的是,中断门和陷阱门只能被安装在 GDT 中。
我们在后面会看到,Linux 使用中断门来处理 Interrupt,使用陷阱门来处理 Exception。
4. 中断和异常发生时的硬件处理过程
在执行一条指令之后,cs 和 eip 寄存器会指向下一条待执行的指令的逻辑地址。在执行下一条指令之前,CPU 的控制单元会检查在执行上一条指令时,是否有 Interrupt 或者 Exception 产生,如果有的话,那么 CPU 的控制单元会做如下操作:
- 确定相关中断信号的 vector 值 `i` (i.e. 通过读取 Local APIC 中的 I/O 端口获取);
- 通过
igtr寄存器找到 IDT,然后找到第 `i^{th}` 条表项,我们假设我们读取到的表项不是中断门就是陷阱门; - 根据 IDT 表项中的选择子,找到位于 GDT 中的相关段描述符,读取目标 Handler 代码段的段基地址 (i.e. 是一个线性地址);
-
确定 (verify) 中断信号来自可靠的源:
- 首先,CPU 会比较 CPL 和目标 Handler 代码段描述符中记录的 DPL 值,如果发现目标 Handler 代码段的特权级甚至低于当前的运行特权级 (i.e. 数值上 DPL 比 CPL 大),则会产生 "General Exception" 异常;
- 其次对于 Programmed Exception,即软中断,除了上面的这一步检查以外,CPU 还会继续比较 CPL 和当前 IDT 表项 (i.e. 中断门或陷阱门,本质上是一个段描述符) 的 DPL,如果发现当前 IDT 表项的特权级比当前的运行特权级要高 (i.e. 数值上 DPL 比 CPL 小),则同样也会引发 "General Exception" 异常。这一步检查是为了防止低特权级的用户程序访问某些特殊的中断门和陷阱门;
-
检查在跳转前后是否有特权级的变化,如果有的话则需要在当前进程环境中执行栈的切换,步骤如下:
- 首先,CPU 通过读取
tr寄存器来访问到当前进程的 TSS 结构; - 然后,CPU 从 TSS 中找到对应目标 Handler 代码段特权级的栈的地址,将它们装填到
ss和esp寄存器中去 - 最后,在新的栈中压入老的
ss和esp的值,它们代表了旧栈的地址信息,以方便后续在目标 Handler 代码段执行完成后当前进程能恢复到旧栈去;
- 首先,CPU 通过读取
- 如果引发当前中断信号处理的是一个 fault 异常的话,CPU 会向栈中压入引发该异常的指令的地址,即
cs和eip值,方便在 Handler 执行完成后对那条指令的重新执行; - 在栈中保存当前
eflags、cs和eip寄存器的值; - 如果引发当前中断信号处理的是一个携带 Hardware Error Code 的异常,则 CPU 还会把这个 Hardware Error Code 压入栈中;
- 将 Handler 的程序地址加载到
cs和eip寄存器中去,Handler 的程序地址是由 GDT 中的描述符提供的基地址和 IDT 表项中提供的偏移量相加得到的
值得注意的是,再跳转到 Interrupt/Exception Handler 的过程中,硬件并没有保存各种通用寄存器等的值,所以需要在进入 Handler 之后再手动进行保护。同样的,在 Handler 退出之前也需要手动的进行恢复。
在 Handler 执行完毕之后,Handler 必须把 CPU 的控制权交还给受中断影响的当前进程代码。通常来说 Handler 在最后都会执行 iret 指令,此时 CPU 控制单元将会执行如下步骤:
- 从栈中加载原先
eflags、cs和eip寄存器的值。注意!如果在调用 Handler 之前向栈中压入了 Hardware Error Code,则在 Handler 中必须在执行iret指令之前手动地将它从栈中 pop 出来; - 比较当前执行的 Handler 代码段的 CPL 和之前被中断的代码段的运行特权级 (i.e. 存储在压在栈中的旧
cs寄存器的低 2 位中)。如果前后特权级不变,那么iret指令执行结束; 如果前后特权级发生改变,则继续执行如下步骤; - 从栈中恢复旧的
ss和esp的值,以切换到旧栈中去; - 对
ds、es、fs和gs寄存器中存储的选择子进行检查,如果某个寄存器存储的存储的选择子对应的段描述符的特权级高于切换之后的运行特权级 (i.e. 数值上 DPL 比 CPL 小),则该寄存器将会被清空,这是为了防止用户程序有机会在 中断/异常 返回之后能够借机访问到内核的相关地址空间,因为段访问的保护只会在引用一个段 (i.e. 将选择子装入相应的段基址寄存器) 时进行安全检查,后续基于该选择子的对段的访问都不会再有安全检查
5. 对嵌套的支持
对于 Interrupt Handler 来说,旧版的内核支持 Interrupt Handler 的嵌套,但是自从 2010 年的如下更新开始,Linux 内核中去除了对嵌套 Interrupt Handler 的支持,原因是用于应对堆栈溢出的代码过于复杂难以维护。值得注意的是 Interrupt Handler 仍然可以抢占 Exception Handler。
当一个进程遭遇 Interrupt 时,它会转移到 Interrupt Handler 中去运行。在进入 Interrupt Handler 的第一条指令到最后一条指令的时候,当前 CPU core 是无法被新的 Interrupt 抢占的,也无法发生进程环境的切换。
对于 Exception Handler (e.g. page fault, system call) 来说,它无法抢占 Interrupt Handler,但是可以被 Interrupt Handler 抢占。
对于 Interrupt、Exception 的抢占过程,其实只要记住,在 Linux 中使用的是中断门来指向 Interrupt Handler,陷进门来指向 Exception Handler (大部分情况下),就可以很好的理解。我们在下一篇文章: Linux 内核对中断和异常的处理 中将会分析内核中的 Interrupt Handler 和 Exception Handler
附录:参考源
- Develop Paper, Can Linux interrupts be nested?
- University POLITEHNICA of Bucharest, Interrupt handling in Linux
- kernelnewbie, Disabling interrupts and masking interrupts
- Linux Inside, Interrupts and Interrupt Handling. Part 7
