Linux Kernel - Interrupts, Top Halves

Table of Contents

任何操作系统内核的核心任务,都包含有对连接到计算机上的硬件设备进行有效管理,如硬盘、蓝光碟机、键盘、鼠标、3D 处理器,以及无线电等。而想要管理这些设备,首先要能和它们互通音信才行。众所周知,处理器的速度跟外围硬件设备的速度往往不在一个数量级上,因此,如果内核采取让处理器向硬件发出一个请求,然后专门等待回应的办法,显然差强人意。既然硬件的响应这么慢,那么内核就应该在此期间处理其他事务,等到硬件真正完成了请求的操作之后,再回过头来对它进行处理。

那么到底如何让处理器和这些外部设备能协同工作,且不会降低机器的整体性能呢?轮询(polling)可能会是一种解决办法。它可以让内核定期对设备的状态进行査询,然后做出相应的处理。不过这种方法很可能会让内核做不少无用功,因为无论硬件设备是正在忙碌着完成任务还是已经大功告成,轮询总会周期性地重复执行。更好的办法是由我们来提供一种机制, 让硬件在需要的时候再向内核发出信号,这就是中断机制。 在本章中,我们将先讨论中断,进而讨论内核如何使用所谓的中断处理函数处理对应的中断。

1. 中断

中断使得硬件得以发出通知给处理器。例如,在你敲击键盘的时候,键盘控制器(控制键盘的硬件设备)会发送一个中断,通知操作系统有键按下。中断本质上是一种特殊的电信号,由硬件设备发向处理器。处理器接收到中断后,会马上向操作系统反映此信号的到来,然后就由操作系统负责处理这些新到来的数据。硬件设备生成中断的时候并不考虑与处理器的时钟同步一一换句话说就是中断随时可以产生。因此,内核随时可能因为新到来的中断而被打断。

从物理学的角度看,中断是一种电信号,由硬件设备生成,并直接送入中断控制器的输入引脚中——“中断控制器”是个简单的电子芯片,其作用是将多路中断合并为单路中断,发送给处理器。当接收到一个中断后,中断控制器会给处理器发送一个电信号。处理器一经检测到此信号,便中断自己的当前工作转而处理中断。此后,处理器会通知操作系统已经产生中断,这样,操作系统就可以对这个中断进行适当地处理了。

不同的设备对应的中断不同,而每个中断都通过一个唯一的数字标志。因此,来自键盘的中断就有别于来自硬盘的中断,从而使得操作系统能够对中断进行区分,并知道哪个硬件设备产生了哪个中断。这样,操作系统才能给不同的中断提供对应的中断处理程序。

这些中断值通常被称为中断请求(IRQ)线。毎个 IRQ 线都会被关联一个数值量——例如,在经典的 PC 机上,IRQ 0 是时钟中断,而 IRQ 1 是键盘中断。但并非所有的中断号都是这样严格定义的。例如,对于连接在 PCI 总线上的设备而言,中断是动态分配的。而且其他非 PC 的体系结构也具有动态分配可用中断的特性。重点在于特定的中断总是与特定的设备相关联,并且内核要知道这些信息。实际上,硬件发出中断是为了引起内核的关注:嗨,我有新的按键等待处理呢,读取并处理这些调皮鬼吧!

异常

在操作系统中,讨论中断就不能不提及“异常”。 异常与中断不同,异常在产生时必须考虑与处理器时钟同步。实际上,异常也常常称为同步中断。在处理器执行到由于编程失误而导致的错误指令(如被 0 除)的时候,或者是在执行期间出现特殊情况(如缺页),必须靠内核来处理的时候,处理器就会产生一个异常。 因为许多处理器体系结构处理异常与处理中断的方式类似,因此,内核对它们的处理也很类似。本章对中断(由硬件产生的异步中断)的讨论,大部分也适合于异常(由处理器本身产生的同步中断)。

2. 中断处理程序

在响应一个特定中断的时候,内核会执行一个函数,该函数叫做中断处理程序(interrupt handler)或中断服务例程(interrupt service routine, ISR)。产生中断的每个设备都有一个相应的中断处理程序。例如,由一个函数专门处理来自系统时钟的中断,而另外一个函数专门处理由键盘产生的中断。一个设备的中断处理程序是它设备驱动程序(driver)的一部分——设备驱动程序是用于对设备进行管理的内核代码。

在 Linux 中,中断处理程序就是普普通通的 C 函数。只不过这些函数必须按照特定的类型声明,以便内核能够以标准的方式传递处理程序的信息,在其他方面,它们与一般的函数别无二致。中断处理程序与其他内核函数的真正区别在于,中断处理程序是被内核调用来响应中断的,而它们运行于我们称之为中断上下文的特殊上下文中(关于中断上下文,我们将在后面讨论)。需要指出的是, “中断上下文”偶尔也称作“原子上下文”,因为正如我们看到的,该上下文中的执行代码不可阻塞。 不过在本书中我们使用中断上下文这个称谓。

中断可能随时发生,因此中断处理程序也就随时可能执行。所以必须保证中断处理程序能够快速执行,这样才能保证尽可能快地恢复中断代码的执行。因此,尽管对硬件而言,操作系统能迅速对其中断进行服务非常重要;当然对系统的其他部分而言,让中断处理程序在尽可能短的时间内完成运行也同样重要。

最起码的,中断处理程序要负责通知硬件设备中断已被接收:嗨,硬件,我听到你了,现在回去工作吧!但是中断处理程序往往还要完成大量其他的工具。例如,我们可以考虑一下网络设备的中断处理程序面临的挑战。该处理程序除了要对硬件应答,还要把来自硬件的网络数据包拷贝到内存,对其进行处理后再交给合适的协议栈或应用程序。显而易见,这种工作量不会太小尤其对于如今的千兆比特和万兆比特以太网卡而言。

3. 上半部与下半部的对比

又想中断处理程序运行得快,又想中断处理程序完成的工作量多,这两个目的显然有所抵触。鉴于两个目的之间存在此消彼长的矛盾关系,所以我们 一般把中断处理切为两个部分。中断处理程序是“上半部”(top halves)——接收到一个中断,它就立即开始执行,但只做有严格时限的工作,例如对接收的中断进行应答或复位硬件,这些工作往往在所有中断被禁止的情况下完成的。能够被允许稍后完成的工作会推迟到“下半部”(bottom halves)去。此后,在合适的时机,下半部会被开中断执行。

让我们考察一下上半部和下半部分割的例子,还是以我们的老朋友——网卡作为实例。当网卡接收来自网络的数据包时,需要通知内核数据包到了。网卡需要立即完成这件事,从而优化网络的吞吐量和传输周期,以避免超时。因此,网卡立即发出中断:嗨,内核,我这里有最新数据包了。内核通过执行网卡已注册的中断处理程序来做出应答。

中断开始执行,通知硬件,拷贝最新的网络数据包到内存,然后读取网卡更多的数据包。这些都是重要、紧迫而又与硬件相关的工作。内核通常需要快速的拷贝网络数据包到系统内存,因为网卡上接收网络数据包的缓存大小固定,而且相比系统内存也要小得多。所以上述拷贝动作一旦被延退,必然造成缓存溢出一进入的网络包占满了网卡的缓存,后续的入包只能被丢弃。当网络数据包被拷贝到系统内存后,中断的任务算是完成了,即“上半部”已经完成,这时它将控制权交还给系统被中断前原先运行的程序。处理和操作数据包的其他工作在随后的“下半部”中进行。本章,我们重点介绍“上半部”。

4. 注册中断处理程序

中断处理程序是管理硬件的驱动程序的组成部分。每一设备都有相关的驱动程序,如果设备使用中断(大部分设备如此),那么相应的驱动程序就注册一个中断处理程序。

驱动程序可以通过 request_irq() 函数注册一个中断处理程序(它被声明在文件<linux/interrupt.h>中),并且激活给定的中断线,以处理中断:

/* request_irq: allocate a given interrupt line */
int request_irq(unsigned int irq,
                irq_handler_t handler,
                unsigned long flags,
                const char *name,
                void *dev)

第一个参数 irq 表示要分配的中断号。对某些设备,如传统 PC 设备上的系统时钟或键盘这个值通常是预先确定的。而对于大多数其他设备来说,这个值要么是可以通过探测获取,要么可以通过编程动态确定。

第二个参数 handler 是一个指针,指向处理这个中断的实际中断处理程序。只要操作系统接收到中断,该函数就被调用。

第三个参数 fags 是标志位。 IRQF_DISABLED 是其中一个重要标志,该标志被设置后,意味着内核在处理中断处理程序本身期间,要禁止所有的其他中断。如果不设置,中断处理程序可以与除本身外的其他任何中断同时运行。多数中断处理程序是不会去设置该位的,因为禁止所有中断是一种野蛮行为。这种用法留给希望快速执行的轻量级中断。

第四个参数 name 是与中断相关的设备的 ASCI 文本表示。例如,PC 机上键盘中断对应的这个值为“keyboard”。这些名字会被 /proc/irq 和 /proc/interrupts 文件使用,以便与用户通信,稍后我们将对此进行简短讨论。

第五个参数 dev 用于共享中断线。当一个中断处理程序需要释放时(稍后讨论),dev 将提供唯一的标志信息(cookie),以便从共享中断线的诸多中断处理程序中删除指定的那一个。如果没有这个参数,那么内核不可能知道在给定的中断线上到底要別除哪一个处理程序。如果无须共享中断线,那么将该参数赋为空值(NULL)就可以了,但是,如果中断线是被共享的,那么就必须传递唯一的信息(除非设备又旧又破且位于 ISA 总线上,那么就必须支持共享中断)。另外,内核每次调用中断处理程序时,都会把这个指针传递给它。实践中往往会通过它传递驱动程序的设备结构:这个指针是唯一的,而且有可能在中断处理程序内被用到。

request_irq() 成功执行会返回 0。如果返回非 0 值,就表示有错误发生,在这种情况下,指定的中断处理程序不会被注册。最常见的错误是 -EBUSY ,它表示给定的中断线已经在使用。

4.1. 释放中断处理程序

卸载駆动程序时,需要注销相应的中断处理程序,并释放中断线。这时需要调用:

void free_irg(unsigned int irg, void *dev)

如果指定的中断线不是共享的,那么,该函数删除处理程序的同时将禁用这条中断线。如果中断线是共享的,则仅删除 dev 所对应的处理程序,而这条中断线本身只有在删除了最后一个处理程序时才会被禁用。由此可以看出为什么唯一的 dev 如此重要。

5. 编写中断处理程序

以下是一个中断处理程序声明:

static irgreturn_t intr_handler(int irg, void *dev)

注意,它的类型与 request_irq() 参数中 handler 所要求的参数类型相匹配。第一个参数 irq 就是这个处理程序要响应的中断的中断号。如今,这个参数已经没有太大用处了,可能只是在打印日志信息时会用到。而在 2.0 版以前的 Linux 内核中,由于没有 dev 这个参数,必须通过 irq オ能区分使用相同駆动程序,因而也使用相同的中断处理程序的多个设备。例如,具有多个相同类型硬盘驱动控制器的计算机。

第二个参数 dev 是一个通用指针,它与在中断处理程序注册时传递给 request_irq() 的参数 dev 必须一致。如果该值有唯一确定性(这样做是为了能支持共享),那么它就相当于一个 cookie,可以用来区分共享同一中断处理程序的多个设备。另外 dev 也可能指向中断处理程序使用的一个数据结构。因为对每个设备而言,设备结构都是唯一的,而且可能在中断处理程序中也用得到,因此,它也通常被看做 dev

5.1. 重入和中断处理程序

Linux 中的中断处理程序是无须重入的。 当一个给定的中断处理程序正在执行时,相应的中断线在所有处理器上都会被屏蔽掉,以防止在同一中断线上接收另一个新的中断。 通常情况下,所有其他的中断都是打开的,所以这些不同中断线上的其他中断都能被处理,但当前中断线总是被禁止的。由此可以看出,同一个中断处理程序绝对不会被同时调用以处理嵌套的中断。这极大地简化了中断处理程序的编写。

6. 中断上下文(原子上下文)

当执行一个中断处理程序时,内核处于中断上下文(interrput context)中。 让我们先回忆下进程上下文。进程上下文是一种内核所处的操作模式,此时内核代表进程执行——例如,执行系统调用或运行内核线程。在进程上下文中,可以通过 current 宏关联当前进程。此外,因为进程是以进程上下文的形式连接到内核中的,因此,进程上下文可以睡眠,也可以调用调度程序。

与之相反,中断上下文和进程并没有什么瓜葛。与 current 宏也是不相干的(尽管它会指向被中断的进程)。因为没有后备进程,所以中断上下文不可以睡眠,否则又怎能再对它重新调度呢?因此, 不能从中断上下文中调用某些函数。如果一个函数睡眠,就不能在你的中断处理程序中使用它——这是对什么样的函数可以在中断处理程序中使用的限制。

中断上下文具有较为严格的时间限制,因为它打断了其他代码。中断上下文中的代码应当迅速、简洁,尽量不要使用循环去处理繁重的工作。有一点非常重要,请永远牢记:中断处理程序打断了其他的代码(甚至可能是打断了在“其他中断线”上的另一中断处理程序)。正是因为这种异步执行的特性,所以所有的中断处理程序必须尽可能的迅速、简洁。尽量把工作从中断处理程序中分离出来,放在下半部来执行,因为下半部可以在更合适的时间运行。

中断处理程序栈的设置是一个配置选项。注:历史上,中断处理程序并不具有自己独立的栈(而是共享所中断进程的内核栈)。

7. 中断处理机制的实现

中断处理系统在 Linux 中的实现是非常依赖于体系结构的,想必你对此不会感到特别惊讶。实现依赖于处理器、所使用的中断控制器的类型、体系结构的设计及机器本身。

1 是中断从硬件到内核的路由。设备产生中断,通过总线把电信号发送给中断控制器(如 8259A)。如果中断线是激活的(它们是允许被屏蔽的),那么中断控制器就会把中断发往处理器。在大多数体系结构中,这个工作就是通过电信号给处理器的特定管脚发送一个信号。除非在处理器上禁止该中断,否则,处理器会立即停止它正在做的事,关闭中断系统,然后跳到内存中预定义的位置开始执行那里的代码。这个预定义的位置是由内核设置的,是中断处理程序的入口点。

linux_interrupt.gif

Figure 1: 中断从硬件到内核的路由

8. /proc/interrupts

procfs 是一个虚拟文件系统,它只存在于内核内存,一般安装于 /proc 目录。在 procfs 中读写文件都要调用内核函数,这些函数模拟从真实文件中读或写。与此相关的例子是 /proc/interrupts 文件,该文件存放的是系统中与中断相关的统计信息。下面是从某 PC 上输出的信息:

     CPU0
  0: 3602371 XT-PIC timer
  1: 3048    XT-PIC i8042
  2: 0       XT-PIC cascade
  4: 2689466 XT-PIC uhci-hcd, eth0
  5: 0       XT-PIC EMU10K1
 12: 85077   XT-PIC uhci-hcd
 15: 24571   XT-PIC aic7xxx
NMI: 0
LOC: 3602236
ERR: 0

第 1 列是中断线。在这个系统中,现有的中断号为 0~2、4、5、12 及 15。第 2 列是一个接收中断数目的计数器。每个 CPU 都存在这样的列,可见这个机器只有一个 CPU。我们看到,时钟中断已接收 3602371 次中断,这里,声卡(EMU10K1)没有接收一次中断(这表示机器启动以来还没有使用它)。第 3 列是处理这个中断的中断控制器。XT-PIC 对应于标准的 PC 可编程中断控制器。在具有 I/O APIC 的系统上,大多数中断会列出 IO-APIC-level 或 IO-APIC-edge,作为自己的中断控制器。最后一列是与这个中断相关的设备名字。这个名字是通过参数 devname 提供给函数 request_irq() 的,前面已讨论过了。如果中断是共享的(例子中的 4 号中断就是这种情况),则这条中断线上注册的所有设备都会列出来。

9. 中断控制

Linux 内核提供了一组接口用于操作机器上的中断状态。这些接口为我们提供了能够禁止当前处理器的中断系统,或屏蔽掉整个机器的一条中断线的能力,这些例程都是与体系结构相关的,可以在<asm/system.h>和<asm/irq.h>中找到。表 1 是这些接口的完整列表。

Table 1: 中断控制方法的列表
函数 说明
local_irg_disable() 禁止本地中断传递
local_irq_enable() 激活本地中断传递
local_irq_save() 保存本地中断传递的当前状态,然后禁止本地中断传递
local_irq_restore() 恢复本地中断传递到给定的状态
disable_irq() 禁止给定中断线,并确保该函数返回之前在该中断线上没有处理程序在运行
disable_irq_nosync() 禁止给定中断线
enable_irq() 激活给定中断线
irqs_disabled() 如果本地中断传递被禁止,则返回非 0;否则返回 0
in_nterrupt() 如果在中断上下文中,则返回非 0;如果在进程上下文中,则返回 0
in_irq() 如果当前正在执行中断处理程序,则返回非 0 否则返回 0

一般来说,控制中断系统的原因归根结底是需要提供同步。通过禁止中断,可以确保某个中断处理程序不会抢占当前的代码。此外,禁止中断还可以禁止内核抢占。然而,不管是禁止中断还是禁止内核抢占,都没有提供任何保护机制来防止来自其他处理器的并发访问。Linux 支持多处理器,因此,内核代码一般都需要获取某种锁,防止来自其他处理器对共享数据的并发访问。获取这些锁的同时也伴随着禁止本地中断。 锁提供保护机制,防止来自其他处理器的并发访问,而禁止中断提供保护机制,则是防止来自其他中断处理程序的并发访问。

10. 参考

本文摘自《Linux 内核设计与实现,第 3 版》

Author: cig01

Created: <2018-10-23 Tue>

Last updated: <2020-06-07 Sun>

Creator: Emacs 27.1 (Org mode 9.4)