SO2 课程 04——中断

查看幻灯片

keywords:中断, 异常, PIC, 可屏蔽中断, 不可屏蔽中断, x86 架构

查看幻灯片

课堂目标

  • 中断和异常(x86)
  • 中断和异常(Linux)
  • 可延迟的工作
  • 定时器

什么是中断?

中断是一种特殊的事件,它会打断程序的正常执行流程。中断可以由硬件设备甚至 CPU 自身触发。当发生中断时,当前程序的执行流程被暂停,然后运行中断处理程序。中断处理程序运行完毕后,之前程序的执行流程会被恢复。

根据其来源,中断可以分为两类。根据是否可以推迟或临时禁用中断,中断也可以分为另外两类:

  • 同步中断,由执行指令触发
  • 异步中断,由外部事件触发
  • 可屏蔽(maskable)中断
    • 可以被忽略
    • 通过 INT 引脚(pin)发出信号
  • 非可屏蔽中断
    • 无法被忽略
    • 通过 NMI 引脚发出信号

同步中断通常被称为异常(exceptions),用于处理处理器在执行指令过程中检测到的条件。除以零和系统调用都是异常的例子。

异步中断通常被称为中断,是由输入/输出设备产生的外部事件。例如,网络卡会触发中断来通知有一个数据包到达。

可屏蔽中断占了中断的大多数,它们允许我们暂时禁用中断,推迟中断处理程序的运行,直到我们重新开启中断。但是,也有一些重要的中断是不能被禁用或推迟的。

异常

异常有两种来源:

  • 处理器检测到的异常
    • 故障(fault)
    • 陷阱(trap)
    • 中止(abort)
  • 程序编程
    • int n

当执行指令时,如果检测到异常情况,就会引发处理器检测到的异常。

故障是一种在指令执行之前报告的异常。故障通常可以被修正。保存的 EIP 是导致故障的指令的地址,因此在故障修正后,程序可以重新执行有问题的指令(例如页面故障)。

陷阱是一种特殊类型的异常,它在计算机执行了产生异常的指令之后才被报告出来。所保存的 EIP(即指令指针寄存器)是引发陷阱的那条指令之后的那条指令的地址。举个例子,调试陷阱就是这样一种情况。

测验:中断术语

对于左侧的每个术语,请从右侧选择最能描述它们的术语。

  • 看门狗
  • 需求分页
  • 零除错误
  • 定时器
  • 系统调用
  • 断点
  • 异常
  • 中断
  • 可屏蔽
  • 不可屏蔽
  • 陷阱
  • 故障

硬件概念

可编程中断控制器

 

../_images/ditaa-7463743488288f459aff2069728984374b5bba76.png

支持中断的设备具有用于发出中断请求(Interrupt ReQuest)的输出引脚。IRQ 引脚连接到名为可编程中断控制器(PIC)的设备上,而 PIC 则连接到 CPU 的 INTR 引脚。

PIC 通常配备了一组端口,用于与 CPU 进行信息交换。当某一个连接到 PIC 的 IRQ 引脚所属的设备需要引起 CPU 的注意时,会启动以下流程:

  • 设备在相应的 IRQn 引脚上触发中断
  • PIC 将 IRQ 转换为向量号(vector number),并将其写入 CPU 读取的端口
  • PIC 在 CPU INTR 引脚上触发中断
  • PIC 在触发另一个中断之前应等待 CPU 确认此中断
  • CPU 确认中断后,开始处理中断

稍后将了解 CPU 如何处理中断。请注意,按设计,PIC在CPU确认当前中断之前不会触发另一个中断。

注解

CPU 在确认中断后,不管之前的中断是否处理完毕,中断控制器都能发出新的中断请求。因此,根据操作系统如何控制 CPU,可能会出现嵌套中断的情况。

中断控制器允许单独禁用某个 IRQ 线。这简化了设计,确保中断处理程序始终按顺序执行。

在 SMP 系统中的中断控制器

在 SMP 系统中,可能会有多个中断控制器存在。

例如,在 x86 架构中,每个核心(core)都有一个本地 APIC 用于处理来自本地连接设备(如定时器或温度传感器)的中断。此外,还有一个 I/O APIC 用于将来自外部设备的中断请求分发给 CPU 核心。

 

../_images/ditaa-9d23d02ebdff6eeb6bec8044480f055de9852ecc.png

中断控制

为了在中断处理程序和其他可能的并发活动(如驱动程序初始化或驱动程序数据处理)之间同步对共享数据的访问,通常需要以受控的方式启用和禁用中断。

这可以在几个级别上实现:

  • 在设备级别
    • 通过编程设备控制寄存器
  • 在 PIC(可编程中断控制器)级别
    • 可以通过编程 PIC,来禁用给定的 IRQ(中断请求)线路
  • 在 CPU 级别;例如,在 x86 架构上可以使用以下指令:
  • cli(清除中断标志)
  • sti(设置中断标志)

中断优先级

大多数体系结构还支持中断优先级。启用中断优先级机制后,只有比当前优先级高的中断才允许嵌套当前中断。

 

../_images/ditaa-8b00a68b494f72d54b5fad38c88f7265aadaaa0e.png

中断优先级并不是所有架构都支持的功能。对于通用的操作系统来说,要设计一个通用的中断优先级方案非常困难,所以一些内核(比如 Linux)就没有采用中断优先级。但是,大多数实时操作系统(RTOS)都使用了中断优先级,因为它们的应用场景更加有限,中断优先级的定义也更加简单。

测验:硬件概念

下列哪些陈述是正确的?

  • 当前中断完成前,CPU 可以开始处理新的中断
  • 可以在设备级别上禁用中断
  • 低优先级中断不能抢占高优先级中断的处理程序
  • 可以在中断控制器级别上禁用中断
  • 在 SMP 系统中,相同的中断可以路由到不同的 CPU
  • 可以在 CPU 级别上禁用中断

x86 架构上的中断处理

本节将介绍 x86 架构上,CPU 如何处理中断。

中断描述符表

中断描述符表(IDT)将每个中断或异常标识符与处理相关事件的指令的描述符关联起来。我们将标识符称为向量号,并将相关指令称为中断/异常处理程序。

IDT 具有以下特点:

  • 当触发给定向量时,CPU 将中断描述符表用作跳转表
  • 它是由 256 个 8 字节条目组成的数组
  • 可以位于物理内存中的任何位置
  • 处理器通过 IDTR 来定位 IDT

下面是 Linux IRQ 向量布局。前 32 个条目保留用于异常,向量号 128 用于系统调用接口,其余大多用于硬件中断处理程序。

 

../_images/ditaa-5b3c93f6e612d0cc0e4d4837d92a443627405262.png

在 x86 架构中,每个 IDT 条目占据 8 个字节,被称为“门(gate)”。IDT 条目可以分为三种类型的门:

  • 中断门(Interrupt Gate):保存中断或异常处理程序的地址。跳转到处理程序时,会禁用可屏蔽中断(IF 标志被清除)
  • 陷阱门(Trap Gate):与中断门类似,但在跳转到中断/异常处理程序时不会禁用可屏蔽中断
  • 任务门(Task Gate):Linux 中不使用

让我们看一下 IDT 条目的几个字段:

  • 段选择符(Segment Selector):用于索引全局描述符表(GDT)或者本地描述符表(LDT),以找到中断处理程序所在的代码段的起始位置
  • 偏移量(Offset):代码段内的偏移量
  • T:表示门的类型
  • DPL:使用段内容所需的最低特权级

 

../_images/ditaa-eff5e0e3b58ce239d5310b22b89c0927be5853bd.png

中断处理程序地址

要找到中断处理程序的地址,我们首先需要确定中断处理程序所在代码段的起始地址。我们可以通过使用段选择符来索引 GDT/LDT,以找到对应的段描述符。段描述符会提供存储在“base”字段中的起始地址。现在,结合基地址和偏移量,我们就可以定位到中断处理程序的起始位置。

 

../_images/ditaa-b2023fce22479e20bbe08fd76eed87e9a0527688.png

中断处理程序的栈

与控制转移到普通函数类似,控制转移到中断或异常处理程序也使用栈来存储返回到被中断代码所需的信息。

如下图所示,中断在保存被中断指令的地址之前,会将 EFLAGS 寄存器压入栈中。某些类型的异常还会在栈上压入产生错误的代码,以帮助调试异常。

 

../_images/ditaa-85b69602726fa6143fc3ba0ffdb492454864aacf.png

处理中断请求

在生成中断请求后,处理器会运行一系列事件,最终执行内核中断处理程序:

处理中断请求的步骤如下:

  • CPU 检查当前特权级别

  • 如果需要更改特权级别

    • 使用与新特权级别相关联的堆栈
    • 在新堆栈上保存旧堆栈信息
  • 在堆栈上保存 EFLAGS,CS,EIP

  • 在发生程序中止时,在堆栈上保存错误代码

  • 执行内核中断处理程序

从中断处理程序返回

大多数体系架构都提供了特殊的指令,用来在执行中断处理程序后清理堆栈并恢复被中断程序执行。在 x86 架构中,使用 IRET 指令从中断处理程序返回。IRET 类似于 RET 指令,但 IRET 会将 ESP 增加额外的四个字节(因为堆栈上有标志位),并将保存的标志位移动到 EFLAGS 寄存器。

在中断处理程序执行后恢复执行的过程如下(x86 架构):

  • 弹出错误代码(如果发生中止)
  • 调用 IRET 指令
    • 从堆栈弹出值并恢复以下寄存器的值:CS,EIP,EFLAGS
    • 如果特权级别发生了更改,则返回到旧堆栈和旧特权级别

检查 x86 中断处理过程

 

测验:x86 中断处理

下面的 gdb 命令用于确定基于 int80 的系统调用异常的处理程序。请正确顺序以下命令以及命令的输出。

(void *) 0xc15de780 <entry_SYSENTER_32>

set $idtr_addr=($idtr_entry>>48<<16)|($idtr_entry&0xffff)

print (void*)$idtr_addr

set $idtr = 0xff800000

(void *) 0xc15de874 <entry_INT80_32>

set $idtr = 0xff801000

set $idtr_entry = *(uint64_t*)($idtr + 8 * 128)

monitor info registers

Linux 中的中断处理

在 Linux 中,中断处理分为三个阶段:关键阶段、立即阶段和延迟阶段。

在第一阶段,内核将运行通用中断处理程序,确定中断号、处理该特定中断的中断处理程序以及中断控制器。此时还会执行任何时间紧迫的关键操作(例如,在中断控制器级别上确认中断)。在该阶段,本地处理器中断被禁用,并在下一个阶段继续禁用。

在第二阶段,将执行与该中断相关联的所有设备驱动程序处理程序。在该阶段结束时,将调用中断控制器的“中断结束”方法,以允许中断控制器重新断开此中断。此时,对本地处理器中断的禁用将解除。

注解

一个中断可能与多个设备相关联,在这种情况下,该中断被称为共享中断。通常,在使用共享中断时,由设备驱动程序负责确定中断是否针对其设备。

在中断处理的最后阶段,将运行中断上下文可延迟操作。我们有时也将其称为中断的“下半部分”(上半部分是在禁用中断的情况下运行的中断处理部分)。此时,可以进行本地处理器上的中断。

 

../_images/ditaa-da31e3d17a4d55e5c3dbc0bd5903306418a896ca.png

嵌套中断和异常

Linux 曾经支持嵌套中断,但由于解决堆栈溢出问题的方案变得越来越复杂(例如,允许一级嵌套、允许多级嵌套,级数由内核堆栈深度决定等),这一功能在一段时间前被取消了。

然而,在异常和中断之间仍然可以实现嵌套,但规则相当严格:

  • 异常(如页错误、系统调用)不能抢占中断;如果发生这种情况,则被视为漏洞(bug)
  • 中断可以抢占异常
  • 中断不能抢占另一个中断(以前是可能的)

以下图表展示了嵌套的可能情景:

 

../_images/ditaa-2e49ca6ac606dab4b2b53231cfbe85ff06312d36.png

中断上下文

处理中断时(从 CPU 跳转到中断处理程序一直到中断处理程序返回(例如发出 IRET 指令)),这段时间内代码运行在“中断上下文”中。

中断上下文中运行的代码具有以下特点:

  • 它是作为 IRQ 的结果而运行的(不是异常)
  • 没有明确定义的进程上下文与之关联
  • 不允许触发上下文切换(不能睡眠、调度或访问用户内存)

可延迟操作

可延迟操作用于稍后运行回调函数。如果从中断处理程序中调度可延迟操作,相关的回调函数将在中断处理程序完成后运行。

可延迟操作分为两大类:在中断上下文中运行的操作和在进程上下文中运行的操作。

之所以设计中断上下文可延迟操作,其目的是避免在中断处理程序函数中执行过多的工作。长时间禁用中断可能会产生不良影响,例如增加延迟或由于未及时处理其他中断而导致系统性能下降(例如因为 CPU 未及时从网络接口出列数据包而导致网络数据包丢失,因为网络卡缓冲区已满)。

可延迟操作具有以下 API:**初始化**实例、**激活**或**调度**操作以及**屏蔽/禁用**和**取消屏蔽/启用**回调函数的执行。后者用于回调函数和其他上下文之间的同步目的。

通常,设备驱动程序将在设备实例初始化期间初始化可延迟操作结构,并将从中断处理程序中激活/调度可延迟操作。

软中断(Soft IRQ)

软中断是一种在中断上下文中实现延迟处理中断处理程序工作的低级机制。

软中断的 API 包括:

  • 初始化:open_softirq()
  • 激活:raise_softirq()
  • 屏蔽:local_bh_disable()local_bh_enable()

一旦被激活,回调函数 do_softirq() 会在以下情况下运行:

  • 在中断处理程序完成之后,或者
  • 从内核线程 ksoftirqd 中运行

由于软中断可以重新调度自身并且其他中断可能会导致对它们重新调度,如果不进行检查,它们可能会导致(临时的)进程饥饿。目前,Linux 内核不允许运行超过 MAX_SOFTIRQ_TIME 数量的软中断,也不允许连续重新调度超过 MAX_SOFTIRQ_RESTART 次。

一旦达到这些瓶颈,一个特殊的内核线程,ksoftirqd 会被唤醒,并且所有其他待处理的软中断将在该内核线程的上下文中运行。

Linux 系统限制软中断的使用,其仅由少数具有低延迟要求和高频率的子系统使用:

/* 请尽量避免分配新的软中断(softirqs),除非你确实需要非常高频率的线程作业调度。
   对于几乎所有的需求,任务(tasklets)其实已经足够了。例如,所有串行设备的底半部(BHs)等都应该转换为任务(tasklets),而不是软中断。
*/

enum
{
   HI_SOFTIRQ=0,
   TIMER_SOFTIRQ,
   NET_TX_SOFTIRQ,
   NET_RX_SOFTIRQ,
   BLOCK_SOFTIRQ,
   IRQ_POLL_SOFTIRQ,
   TASKLET_SOFTIRQ,
   SCHED_SOFTIRQ,
   HRTIMER_SOFTIRQ,
   RCU_SOFTIRQ,    /* 最好将 RCU 设为最后一个软中断 */

   NR_SOFTIRQS
};

网络包泛洪示例

下面的屏幕录像将展示我们向系统发送大量数据包时会发生什么。由于数据包处理的一部分发生在软中断中,CPU 预计会花费大部分时间运行软中断,但其中大部分应该是在 ksoftirqd 线程的上下文中进行的。

 

任务(tasklet)

任务是在中断上下文中运行的一种动态类型(不限于固定数量)的延迟工作。

任务的 API:

  • 初始化:tasklet_init()
  • 激活:tasklet_schedule()
  • 屏蔽:tasklet_disable()tasklet_enable()

任务是基于两个专用软中断实现的:TASKLET_SOFITIRQHI_SOFTIRQ

任务也是串行化的,即同一个任务只能在一个处理器上执行。

工作队列

工作队列是一种在进程上下文中运行的延迟工作。

它们是在内核线程的基础上实现的。

工作队列 API:

  • 初始化:INIT_WORK
  • 激活:schedule_work()

定时器

定时器是建立在:c:macro:`TIMER_SOFTIRQ`之上的。

定时器 API:

  • 初始化:setup_timer()
  • 激活:mod_timer()

可延迟操作摘要

以下是总结了 Linux 可延迟操作的速查表:

  • 软中断(softIRQ)
    • 在中断上下文中运行
    • 静态分配
    • 同一个处理程序可以在多个核心上并行运行
  • 任务(tasklet)
    • 在中断上下文中运行
    • 可以动态分配
    • 同一个处理程序运行是串行化的
  • 工作队列(workqueues)
    • 在进程上下文中运行

测验:Linux 中断处理

以下哪个中断处理阶段在 CPU 级别上禁用了中断?

  • 临界(Critical)
  • 立即(Immediate)
  • 延迟(Deferred)