这一篇在干嘛?

内核平时”被动服务”——硬件一按铃(中断),内核就得立刻响应。本章讲清楚铃从哪来(PIC/APIC)、CPU 怎么接(IDT 与控制单元)、Linux 怎么分工(紧急的马上做、不急的靠软中断/tasklet/工作队列延后做),以及中断处理完怎么安全地回到被打断的世界。这是理解设备驱动、系统调用、调度时机的共同基础。

图 1:第 4 章题图(Understanding the Linux Kernel, 3rd Edition)

中断信号的角色与分类 | IRQ 与中断控制器 | 硬件如何处理中断与异常 | 初始化 IDT 与异常处理 | I/O 中断处理 | 处理器间中断 | 软中断与 tasklet | 工作队列与中断返回

中断信号的角色与分类

中断(interrupt) 是改变处理器指令执行顺序的事件,对应 CPU 芯片内外硬件电路产生的电信号。按与 CPU 时钟的关系分两类:

  • 同步中断:CPU 控制单元在执行指令的过程中产生,而且只在一条指令执行终止后发出——Intel 手册称之为异常(exception)
  • 异步中断:其他硬件设备在任意时刻(相对 CPU 时钟)产生——Intel 手册称之为中断(interrupt)

本书沿用 Intel 分类,偶尔用”中断信号”统称两者。中断由间隔定时器和 I/O 设备发出(比如一次按键);异常的来源则是两类:编程错误(内核给肇事进程发一个 Unix 信号,如除零的 SIGFPE)或必须由内核处理的异常情况(缺页、int/sysenter 请求内核服务等——内核执行恢复所需的全套动作)。

中断处理与上一章的进程切换形似而神异,关键区别一句话:中断和异常处理程序执行的代码不是进程,而是一条内核控制路径,开销记在”中断发生时正在跑的那个进程”头上。它是内核控制路径,比进程轻得多——上下文小、建立拆除都快。

中断处理是内核最敏感的任务之一,要同时满足三条约束:

  1. 中断随时可能来,内核必须”先把中断打发走、能拖的尽量拖”:响应动作分成立刻执行的紧急部分和稍后执行的可延后部分(比如网卡数据到达,中断里只标记”有数据”,搬运进缓冲区、唤醒接收进程都往后放);
  2. 中断处理期间允许另一种中断进来(这样 I/O 设备才能保持忙碌),所以中断处理程序必须写成可嵌套的内核控制路径;最后一条控制路径结束时,内核要么恢复被打断的进程、要么因重新调度切换到别的进程;
  3. 尽管如此,内核仍存在少数必须关中断的临界区——它们必须尽量短,内核大部分时间应开着中断。

Intel 对中断与异常的正式分类

中断分两种:

  • 可屏蔽中断(maskable):所有 I/O 设备的 IRQ 都属于此类,被屏蔽(masked)期间控制单元置之不理;
  • 非屏蔽中断(NMI):只有硬件失效等极少数危急事件产生,CPU 无条件识别。

异常分两种:

  • 处理器检测的异常,按异常时保存在内核栈里的 eip 值再分三组:
    • Fault(故障):通常可纠正,纠正后程序无断点地重新执行——保存的 eip 是引发故障的指令的地址,处理程序结束后重跑同一条指令(缺页异常就是这么”复活”指令的,见 /linux内核/lk09);
    • Trap(陷阱):报告发生在陷阱指令执行之后,保存的 eip 是下一条指令的地址,无需重执行;主要用于调试(断点通知调试器”某指令已执行”,检查完从下一条继续);
    • Abort(异常中止):严重错误,控制单元自身陷入麻烦,可能连出错指令的位置都存不准(硬件故障、系统表值非法或不一致),处理程序只能强制终止相关进程;
  • 编程异常(programmed exceptions):程序员主动触发,由 int 或 int3 指令产生(into、bound 在条件不成立时也会触发),按 trap 处理,常称软件中断——两大用途:实现系统调用、通知调试器。

每个中断/异常由 0~255 的 8 位无符号数**向量(vector)**标识。非屏蔽中断和异常的向量固定;可屏蔽中断的向量可通过编程中断控制器改变。

IRQ 与中断控制器

能发中断请求的设备控制器各有一条输出线,叫 IRQ(Interrupt ReQuest)线,全部接到**可编程中断控制器(PIC)**的输入引脚。PIC 的工作循环:

  1. 监视 IRQ 线;多条同时升起时选引脚号最小的
  2. 把升起的信号转换成对应向量,存入 PIC 的 I/O 端口供 CPU 经数据总线读取;向 CPU 的 INTR 引脚发信号(发出中断);等 CPU 应答(向 PIC 的 I/O 端口写确认)后清掉 INTR 线;
  3. 回到第 1 步。

IRQ 线从 0 开始编号(IRQ0、IRQ1……),Intel 默认把 IRQn 映射到向量 n+32(映射可改)。每条 IRQ 线可单独禁用:被禁用期间产生的中断不会丢失,PIC 在重新启用后立刻补发。多数中断处理程序靠这个特性把同类 IRQ 串行化处理。注意区分:禁用某条 IRQ 是 PIC 层面的选择性操作;全局屏蔽可屏蔽中断是 CPU 层面的——eflags 的 IF 标志清 0 后 CPU 暂时无视 PIC 发来的一切可屏蔽中断,cli/sti 指令分别清/置该标志。

传统 PIC 由两片 8259A 级联而成(每片 8 条 IRQ 输入;从片的 INT 输出接主片的 IRQ2),总共 15 条可用 IRQ 线。

APIC:多处理器的中断架构

单 CPU 系统里主 PIC 直接接 INTR 引脚即可;两个以上 CPU 时就不行了——把中断送达每一个 CPU 是榨干 SMP 并行能力的关键。Intel 从 Pentium III 起引入 I/O APIC(8259A 的高级替代品;为兼容老操作系统,新主板两种芯片都装),而所有当代 80x86 CPU 内部都集成了本地 APIC(32 位寄存器、内部时钟、本地定时器、LINT0/LINT1 两条本地 IRQ 线)。所有本地 APIC 连到外部 I/O APIC,构成多 APIC 系统

图 2:多 APIC 系统(原书图 4-1)——设备 IRQ 线接 I/O APIC,它经 APIC 总线把中断消息路由给各 CPU 的本地 APIC

I/O APIC 有 24 条 IRQ 线、一张 24 项的中断重定向表、可编程寄存器和收发 APIC 总线消息的单元。与 8259A 不同,中断优先级与引脚号无关:重定向表每一项都可单独编程设定中断向量与优先级、目标处理器及处理器的挑选方式。

外部 IRQ 分发给 CPU 的两种方式:

  • 静态分发:IRQ 送达重定向表项列出的本地 APIC——某一个 CPU、一个子集、或全体(广播模式);
  • 动态分发:送达”正在执行最低优先级进程”的处理器的本地 APIC。每个本地 APIC 的可编程**任务优先级寄存器(TPR)**用于计算当前运行进程的优先级,Intel 期望内核在每次进程切换时改写它。

两个以上 CPU 同为最低优先级时用仲裁(arbitration)分摊:每个 CPU 有 0~15 的仲裁优先级;某 CPU 每收到一次中断,自己的仲裁优先级自动清 0、其他 CPU 的加 1(超过 15 则取”上次获胜者的值+1”)——同优先级 CPU 之间按轮转分中断。

多 APIC 系统还让 CPU 能互发处理器间中断(IPI):把中断向量 + 目标本地 APIC 标识写进自己本地 APIC 的中断命令寄存器(ICR),消息经 APIC 总线送达目标。IPI 是 SMP 架构的关键组件,Linux 用它做 CPU 间的消息交换(本章后面和 /linux内核/lk02 的 TLB 刷新都靠它)。

单处理器系统装了 I/O APIC 芯片时可以两种方式配置:当作标准 8259A 外部 PIC 用(本地 APIC 禁用,LINT0/LINT1 分别充当 INTR/NMI 引脚);或当作标准 I/O APIC 用(本地 APIC 启用,全部外部中断经 I/O APIC 接收)。

硬件如何处理中断与异常

设内核已初始化、CPU 处于保护模式。控制单元每执行完一条指令,就检查上一条指令执行期间是否发生了中断或异常。若有,依次执行九步:

  1. 确定中断/异常关联的向量 i(0~255);
  2. 读 idtr 寄存器所指 **IDT(中断描述符表)**的第 i 项(设为中断门或陷阱门);
  3. 从 gdtr 取 GDT 基址,按 IDT 项里的段选择符读出段描述符——它给出中断/异常处理程序所在段的基址;
  4. 权限检查:比较 cs 低两位的 CPL 与段描述符的 DPL——若 CPL 低于 DPL(处理程序特权级竟比引发者还低)触发”General protection”异常;编程异常还要再查一层:CPL 与 IDT 门描述符的 DPL 比较,DPL 低于 CPL 就异常——这道检查防止用户应用滥用特定的陷阱/中断门;
  5. 检查是否发生特权级切换(CPL ≠ 段描述符 DPL)。若是,必须换成新特权级关联的栈:读 tr 寄存器找到当前进程的 TSS;从 TSS 装载新特权级对应的 ss 和 esp;把旧特权级栈的 ss:esp 存进新栈;
  6. 若发生的是 fault,把 cs:eip 装上引发异常的指令地址,好让这条指令稍后重执行;
  7. 把 eflags、cs、eip 压栈;
  8. 异常若带硬件错误码,一并压栈;
  9. 用 IDT 第 i 项门描述符的段选择符和偏移装载 cs:eip——这就是处理程序的第一条指令

处理完之后,处理程序以 iret 指令把控制权交还被打断的进程,iret 强制控制单元:恢复栈上的 cs/eip/eflags(栈顶若有硬件错误码必须先弹出);若处理程序 CPL 与栈上 cs 低两位不同(特权级变了),从栈上恢复 ss:esp 回到旧栈;最后检查 ds、es、fs、gs——哪个段寄存器装的选择符对应的段 DPL 低于当前 CPL,就清零哪个寄存器。这步防的是:CPL=3 的用户程序借用内核例程(DPL=0)用过的段寄存器摸进内核地址空间。

嵌套执行

每条中断/异常都对应一条内核控制路径,控制路径可以任意嵌套——中断处理程序可能被另一个中断处理程序打断(图 3)。后果是:照顾中断的控制路径的最后几条指令未必把当前进程送回用户态——嵌套层数大于 1 时,它们只是恢复上一条被中断的控制路径,CPU 继续待在内核态。

图 3:内核控制路径嵌套执行的例子(原书图 4-3)

允许嵌套的代价是一条铁律:中断处理程序绝不能阻塞——中断处理程序在跑时不能发生进程切换,因为恢复嵌套控制路径所需的全部数据都存在内核栈里,而内核栈和当前进程死死绑在一起。

假设内核无 bug,多数异常只会在用户态出现(编程错误或调试器引起)。例外是 Page Fault 也可能发生在内核态:进程访问自己地址空间里暂不在 RAM 的页时;内核处理缺页时可以挂起当前进程换别人上,等页就绪后这条控制路径再恢复。由于缺页处理程序自身不再引发新异常,与异常关联的控制路径至多堆叠两条(一条系统调用 + 一条缺页)。

中断则不同:I/O 中断虽然”代表”当前进程执行,但不涉及该进程特有的数据结构——根本无法预测中断到来时跑的是哪个进程。中断处理程序可以抢占其他中断处理程序和异常处理程序;反过来,异常处理程序从不抢占中断处理程序——内核态唯一可能触发的异常就是缺页,而中断处理程序从不做会引发缺页(进而可能引发进程切换)的操作。

Linux 让控制路径交错,图两件事:① 提高 PIC 和设备控制器的吞吐——设备拉响 IRQ 后 PIC 和设备控制器都在干等 CPU 应答,交错执行让内核”一边处理上一个中断一边发出应答”;② 实现无优先级的中断模型——每个处理程序都可能被别的推迟,硬件设备之间就不必预定优先级,内核代码更简单、可移植性更好。多处理器上多条控制路径可并发执行,异常路径甚至能因进程切换迁移到另一个 CPU。

初始化 IDT 与异常处理

IDT 把每个向量映射到对应处理程序的地址,格式类似第 2 章的 GDT/LDT:256 项、每项 8 字节描述符(共 2KB),位置由 idtr 寄存器指定(用 lidt 指令初始化,必须在开中断之前完成)。三种门描述符:

  • 任务门(task gate):含中断发生时要换上的进程的 TSS 选择符;
  • 中断门(interrupt gate):含段选择符 + 处理程序段内偏移;转移控制时清 IF 标志(关可屏蔽中断);
  • 陷阱门(trap gate):同中断门,但转移控制时不改 IF

Linux 用中断门处理中断、陷阱门处理异常。用户态进程能用 int 指令发出 0~255 任意向量的中断信号,所以 IDT 初始化必须防伪造:把不希望用户触发的门的 DPL 置 0,进程一试探就”General protection”;反过来,必须允许用户态触发的编程异常把 DPL 置 3。Linux 的门分类(比 Intel 的细):

Linux 门类型实质DPL用途
中断门Intel 中断门0所有中断处理程序(仅内核态可触发)
系统门Intel 陷阱门3向量 4、5、128(into、bound、int $0x80 用户态可用)
系统中断门Intel 中断门3向量 3(int3 用户态可用)
陷阱门Intel 陷阱门0大多数异常处理程序
任务门Intel 任务门0”Double fault” 异常

插门的架构相关函数:set_intr_gate(n,addr)(DPL=0)、set_trap_gate(n,addr)(DPL=0)、set_system_gate(n,addr)(DPL=3)、set_system_intr_gate(n,addr)(DPL=3)、set_task_gate(n,gdt)。

两遍初始化

BIOS 在实模式时代就建过 IDT;Linux 接管后弃用 BIOS,把 IDT 搬到 idt_table(256 项)重新初始化(idt_descr 变量存大小和地址,lidt 装进 idtr)。第一遍:setup_idt() 汇编函数把 256 项全部填成指向 ignore_int() 的同一个中断门:

setup_idt:
    lea ignore_int, %edx
    movl $(__KERNEL_CS << 16), %eax
    movw %dx, %ax      /* selector = 0x0010 = cs */
    movw $0x8e00, %dx  /* interrupt gate, dpl=0, present */
    lea idt_table, %edi
    mov $256, %ecx
 
rp_sidt:
    movl %eax, (%edi)
    movl %edx, 4(%edi)
    addl $8, %edi
    dec %ecx
    jne rp_sidt
    ret

ignore_int() 是个”空处理程序”:存寄存器 → printk 打印 “Unknown interrupt” → 恢复寄存器 → iret。它本不该被执行——控制台/日志里出现 “Unknown interrupt” 意味着硬件出了问题(设备在发意料之外的中断)或内核出了问题(中断/异常没被正确接管)。第二遍:把有意义的陷阱/中断门换上去(异常由 trap_init()、中断由 init_IRQ(),见 s5)。

异常处理

多数异常被 Linux 当作错误条件:发生时给肇事进程发信号(见表 4-1:0 号 Divide error→SIGFPE、3 号 Breakpoint→SIGTRAP、13 号 General protection→SIGSEGV、14 号 Page Fault→SIGSEGV、16 号浮点错误→SIGFPE……)。但也有两个”化异常为资源”的妙用:Device not available 异常配合 cr0 的 TS 标志实现 FPU 寄存器的懒加载(第 3 章);Page Fault 异常把页框分配推迟到最后一刻(第 9 章)。

异常处理程序的标准三段式:

  1. 在内核栈上保存大多数寄存器(汇编部分);
  2. 用高级 C 函数处理异常;
  3. 经 ret_from_exception() 退出。

trap_init() 把最终处理程序填进 IDT 的非屏蔽中断和异常表项:

set_trap_gate(0,&divide_error);
set_trap_gate(1,&debug);
set_intr_gate(2,&nmi);
set_system_intr_gate(3,&int3);
set_system_gate(4,&overflow);
set_system_gate(5,&bounds);
set_trap_gate(6,&invalid_op);
set_trap_gate(7,&device_not_available);
set_task_gate(8,31);
set_trap_gate(9,&coprocessor_segment_overrun);
set_trap_gate(10,&invalid_TSS);
set_trap_gate(11,&segment_not_present);
set_trap_gate(12,&stack_segment);
set_trap_gate(13,&general_protection);
set_intr_gate(14,&page_fault);
set_trap_gate(16,&coprocessor_error);
set_trap_gate(17,&alignment_check);
set_trap_gate(18,&machine_check);
set_trap_gate(19,&simd_coprocessor_error);
set_system_gate(128,&system_call);

注意 8 号 “Double fault” 用任务门:它意味着内核严重失常,处理程序(要打印寄存器值)不敢信任当前 esp——CPU 改从 IDT[8] 的任务门找到 GDT 第 32 项里的专用 TSS,用 TSS 里存的 eip/esp 在自己的私有栈上执行 doublefault_fn()。

处理程序入口的汇编模式(handler_name 泛指任一处理程序):

handler_name:
    pushl $0               /* 仅部分异常需要 */
    pushl $do_handler_name
    jmp error_code

控制单元不会自动压硬件错误码的异常,先用 pushl $0 占位;然后压入 C 函数地址(名字 = 处理程序名加 do_ 前缀)。公共标号 error_code 的活:把 C 函数可能用到的寄存器压栈;cld 清方向标志;把 esp+36 处的硬件错误码复制进 edx、原位置写 -1(第 11 章用它区分 0x80 异常和其他异常);把 esp+32 处的 C 函数地址取进 edi、原位置写入 es;把内核栈顶装入 eax;给 ds/es 装用户数据段选择符;最后调 edi 指向的 C 函数——参数从 eax、edx 寄存器传入(和第 3 章 __switch_to() 的 regparm 手法同源)。

C 函数多数会调 do_trap() 把硬件错误码和异常向量存进 current 的描述符,再向进程发信号:

current->thread.error_code = error_code;
current->thread.trap_no = vector;
force_sig(sig_number, current);

信号要等异常处理程序结束后才被处理——用户态有自己的信号处理程序就交给它,没有则内核通常杀掉进程。处理程序还要区分异常发生在用户态还是内核态:内核态异常若源于传给系统调用的非法参数,用”修正代码”兜底(第 10 章);其余内核态异常一律是内核 bug——处理程序调 die() 在控制台打印所有 CPU 寄存器(即著名的 kernel oops)并 do_exit() 终止当前进程,免得继续跑坏磁盘数据。

I/O 中断处理

异常可以”发信号了事”(动作推迟到进程收到信号后),中断不行:中断到达时,相关进程(比如发起数据传输的那个)多半早已挂起、跑着的是毫不相干的进程——给”当前进程”发信号毫无意义。

中断处理按类型分三路:I/O 中断(设备求关注,处理程序须询问设备决定行动)、定时器中断(本地 APIC 定时器或外部定时器报时,第 6 章细讲)、处理器间中断(见 s6)。

灵活性:共享与动态分配

PCI 架构下多个设备可以共享同一条 IRQ 线——光看中断向量不知道是谁干的(表 4-3 的例子里 USB 端口和声卡共用向量 43;老 ISA 设备则往往不能共享)。两种补救:

  • IRQ 共享:处理程序依次执行多个中断服务程序(ISR),每个 ISR 对应共享该 IRQ 的一个设备;事先不知道是谁发的,每个 ISR 都执行一遍检查”是不是我的设备”,是就处理;
  • IRQ 动态分配:IRQ 线在最后一刻才绑定设备驱动(软盘的 IRQ 线只在用户访问软盘时才分配)——同一 IRQ 向量可以先后给不同设备用,只是它们不能同时使用。

中断后的动作按紧急度分三档,直接决定了 Linux 的软件架构:

档次例子执行时机
紧急(critical)向 PIC 应答、重编程 PIC/设备控制器、更新设备与 CPU 共享的数据结构中断处理程序内立即执行,关可屏蔽中断
非紧急(noncritical)更新仅 CPU 访问的数据结构(如读键盘扫描码)中断处理程序内立即执行,开中断
非紧急可延后(noncritical deferrable)把缓冲区内容拷进进程地址空间(把键盘行缓冲交给终端处理进程)单独的函数延后执行(软中断/tasklet,见 s7)

中断处理程序在跑时对应 IRQ 线上的信号被暂时忽略,所以长而无关紧要的操作必须推迟;更要命的是:中断处理程序所代表的进程必须始终处于 TASK_RUNNING,否则系统冻结——因此中断处理程序绝不能执行任何阻塞操作(如磁盘 I/O)。

中断向量的分配

物理 IRQ 可用 32~238 的向量,但 128 被 Linux 留给系统调用。部分设备被 PC 架构静态绑死在特定 IRQ 上:间隔定时器 → IRQ0(第 6 章)、从 8259A → IRQ2、外部数学协处理器 → IRQ13(为了照顾老 80386)。IRQ 可配置设备的接线方式:老卡片硬件跳线;随卡工具程序询问/探测;或开机时由硬件协议协商(PCI 驱动用 pci_read_config_byte() 读设备配置空间拿到分配的 IRQ)。内核必须在开中断前弄清 IRQ 号与设备的对应关系——在初始化每个设备驱动时建立。

IRQ 数据结构

图 4:I/O 中断处理的软硬件全景(原书图 4-4)

图 5:IRQ 描述符(原书图 4-5)——irq_desc_t、irqaction 与 PIC 对象的关系

每个中断向量一个 irq_desc_t 描述符,全部放进 irq_desc 数组。关键字段:handler 指向服务该 IRQ 线的 PIC 对象(hw_irq_controller);action 指向该 IRQ 关联的 irqaction 描述符链表;status 是 IRQ 线状态标志集(IRQ_INPROGRESS 处理中、IRQ_DISABLED 被驱动禁用、IRQ_PENDING 已应答未服务、IRQ_REPLAY 线被禁但上一次 IRQ 尚未应答、IRQ_AUTODETECT/IRQ_WAITING 探测中……);depth 是禁用计数(0 = 启用,正数 = 禁用过至少一次;disable_irq() 加、enable_irq() 减);lock 是串行化访问的自旋锁;irq_count/irqs_unhandled 是诊断计数。

意外中断(unexpected):IRQ 线上没有关联 ISR、或没有 ISR 认领这次中断。内核不会一见意外就禁线(线是共享的),而是计数——第 100000 次中断时,若意外数超过 99900(即十万次里认领的不足 101 次),才判定有设备发疯,禁掉该线。

PIC 对象用面向对象手法统一多种控制器(8259A、SMP IO-APIC、PIIX4 内部 8259、SGI Cobalt(IO-)APIC……):驱动不必知道系统装的是哪种 PIC。结构 hw_interrupt_type 含 PIC 名字加 7 个标准方法,以 8259A 为例:

struct hw_interrupt_type i8259A_irq_type = {
    .typename = "XT-PIC",
    .startup = startup_8259A_irq,
    .shutdown = shutdown_8259A_irq,
    .enable = enable_8259A_irq,
    .disable = disable_8259A_irq,
    .ack = mask_and_ack_8259A,
    .end = end_8259A_irq,
    .set_affinity = NULL
};

ack 方法向 8259A 的 I/O 端口写正确字节完成应答(并屏蔽该线);end 方法在处理程序结束时调用(8259A 上重新启用 IRQ 线);set_affinity 只在多处理器系统上用(声明 CPU 对 IRQ 的亲和性,8259A 没有,置 NULL)。

每个共享该 IRQ 的设备各有一个 irqaction 描述符:handler 指向该设备的 ISR(这是多设备共享 IRQ 的关键)、flags(SA_INTERRUPT 处理程序须关中断执行、SA_SHIRQ 允许共享、SA_SAMPLE_RANDOM 可作内核随机数发生器的事件源——/dev/random 的饲料)、name(/proc/interrupts 里显示的设备名)、dev_id(设备私有标识)、next(链表指针)。irq_stat 数组每 CPU 一项(irq_cpustat_t),记各 CPU 的待处理软中断位图、空闲时间戳、NMI 计数、APIC 定时器中断计数。

多内核栈

第 3 章说过 thread_union 可以是 8KB 或 4KB。8KB 时所有内核控制路径(异常、中断、可延后函数)都用当前进程的内核栈;4KB 时内核改用三种栈:异常栈(就是各进程的 thread_union 内栈,处理异常和系统调用)、硬中断栈(每 CPU 一个、单页框,处理中断)、软中断栈(每 CPU 一个、单页框,处理可延后函数)。硬/软中断栈放 hardirq_stack / softirq_stack 数组,每元素是单页的 irq_ctx 联合体——页底放 thread_info、余下空间给栈(向低地址生长);与异常栈唯一的区别是配对的 thread_info 关联 CPU 而非进程

从汇编到 do_IRQ()

IRQn 的中断处理程序入口在 interrupt[n] 数组里(NR_IRQS = 224 用 I/O APIC 时 / 16 用 8259A 时),内容只有两行:

pushl $n-256
jmp common_interrupt

先把 IRQ 号减 256 压栈——内核用负数表示所有 IRQ,正数留给系统调用(第 10 章)。公共部分:

common_interrupt:
    SAVE_ALL
    movl %esp,%eax
    call do_IRQ
    jmp ret_from_intr

SAVE_ALL 把处理程序可能用到的寄存器全部压栈(eflags/cs/eip/ss/esp 控制单元已自动压过),然后把用户数据段选择符装入 ds、es。栈顶地址存进 eax 后调用 do_IRQ()(regparm(3),参数 regs 从 eax 取),返回后进 ret_from_intr()。

do_IRQ() 的步骤:① irq_enter() 把嵌套计数加 1(存于当前进程 thread_info 的 preempt_count 字段);② 4KB 栈模式下切到硬中断栈:先 current_thread_info() 算出当前线程描述符,与 hardirq_ctx[smp_processor_id()] 比较——相等说明已经在硬中断栈上(处理上一个中断时又来了 IRQ),直接跳 3;否则把当前进程描述符指针存进本地 irq_ctx 的 task 字段(保证硬中断栈上 current 宏照常工作)、旧 esp 存进 previous esp 字段(kernel oops 时还原调用轨迹用)、esp 装上本地硬中断栈顶(旧 esp 先存 ebx);③ 调 __do_IRQ();④ 换回原来的栈;⑤ irq_exit() 减计数并检查有无待执行的可延后函数;⑥ 转入 ret_from_intr()。

__do_IRQ() 是全部 IRQ 服务例程的总调度,等价于:

spin_lock(&(irq_desc[irq].lock));
irq_desc[irq].handler->ack(irq);
irq_desc[irq].status &= ~(IRQ_REPLAY | IRQ_WAITING);
irq_desc[irq].status |= IRQ_PENDING;
if (!(irq_desc[irq].status & (IRQ_DISABLED | IRQ_INPROGRESS))
    && irq_desc[irq].action) {
    irq_desc[irq].status |= IRQ_INPROGRESS;
    do {
        irq_desc[irq].status &= ~IRQ_PENDING;
        spin_unlock(&(irq_desc[irq].lock));
        handle_IRQ_event(irq, regs, irq_desc[irq].action);
        spin_lock(&(irq_desc[irq].lock));
    } while (irq_desc[irq].status & IRQ_PENDING);
    irq_desc[irq].status &= ~IRQ_INPROGRESS;
}
irq_desc[irq].handler->end(irq);
spin_unlock(&(irq_desc[irq].lock));

逐段读:先拿自旋锁——多处理器上同类中断可能又被触发、别的 CPU 可能接手,没有锁的话主 IRQ 描述符会被并发访问(绝不允许)。然后调 PIC 的 ack 方法应答:8259A 的 mask_and_ack_8259A() 应答并顺手屏蔽该 IRQ 线——保证处理程序结束前 CPU 不再接受同类中断。注意 __do_IRQ() 全程在本地中断关闭下运行(中断门自动清了 IF)。置 IRQ_PENDING(已应答未服务)。接着查三个”什么都不用做”的情形:IRQ_DISABLED 被置(IRQ 线被禁时 CPU 仍可能执行到 __do_IRQ——见下面”复活丢失中断”;有 bug 的主板也会在禁线后发假中断);IRQ_INPROGRESS 被置(多处理器上另一个 CPU 正在处理同一次中断的前一个发生——把它让给那个 CPU:既让驱动 ISR 无需可重入、又让本 CPU 不弄脏硬件缓存就回去干活);action 为 NULL(没有 ISR,通常是设备探测中)。否则置 IRQ_INPROGRESS 进入循环:清 IRQ_PENDING、放锁、执行 handle_IRQ_event() 跑全部 ISR、再拿锁、查 IRQ_PENDING——若被置,说明本 CPU 跑 ISR 期间别的 CPU 又收了一次同型中断(它只置标志不干活),循环再来一轮。收尾调 PIC 的 end 方法(8259A 重新启用 IRQ 线;I/O APIC 时 end 负责应答)并放锁。

复活丢失的中断

多处理器上有个刁钻场景:设备拉响 IRQ、多 APIC 系统选中了本 CPU;本 CPU 还没来得及应答,另一个 CPU 把 IRQ 线屏蔽了(IRQ_DISABLED 置位)。本 CPU 开始处理,发现 IRQ_DISABLED,应答完就返回——ISR 没执行,这次中断丢了。补救在 enable_irq() 里:重新启用 IRQ 线时检查 IRQ_PENDING 标志(离开处理程序时它必被清零;若线被禁却仍是 1,说明有中断”已应答未服务”),是则置 IRQ_REPLAY 并调 hw_resend_irq() 强迫本地 APIC 自中断一次,把丢掉的中断补发出来。IRQ_REPLAY 标志保证恰好只补发一次。

ISR 与动态分配

handle_IRQ_event() 做四件事:SA_INTERRUPT 未置时用 sti 开本地中断;沿 irqaction 链表逐个执行 ISR(retval |= action->handler(irq, action->dev_id, regs); action = action->next;);用 cli 关中断;返回 retval(有 ISR 认领为 1,否则 0——内核据此累计意外中断数)。ISR 的参数:irq 号、dev_id 设备标识、regs 指向内核栈上 pt_regs 结构(前 9 个字段是 SAVE_ALL 压的寄存器,orig_eax 字段编码 IRQ 号,其余是控制单元自动压的)。ISR 返回 1 表示”这次中断确实是我设备发的”。

动态分配的完整流程:驱动激活设备前调 request_irq() 建 irqaction 描述符并调 setup_irq() 插入 IRQ 列表——IRQ 线已被不允许共享的设备占用则报错中止;用完调 free_irq() 摘除释放。软盘驱动申请 IRQ6 的实例:request_irq(6, floppy_interrupt, SA_INTERRUPT|SA_SAMPLE_RANDOM, "floppy", NULL);——关中断执行(SA_INTERRUPT)、不许共享(无 SA_SHIRQ)。系统初始化时定时器也是这么挂的:

struct irqaction irq0 = {timer_interrupt, SA_INTERRUPT, 0, "timer", NULL, NULL};
setup_irq(0, &irq0);

多处理器的 IRQ 分发

Linux 坚持 SMP 对称原则:IRQ 信号应轮转分给所有 CPU,各 CPU 花 In I/O 中断的时间大致相同。引导时启动 CPU 执行 setup_IO_APIC_irqs() 填好 I/O APIC 重定向表的 24 项,按”最低优先级”方案路由;所有 CPU 执行 setup_local_APIC() 初始化本地 APIC——TPR 初始化成固定值后内核再也不改(所有 CPU 永远同优先级),平局靠仲裁优先级寄存器的自动轮转打破。总之设备拉响 IRQ 后,多 APIC 硬件自己挑一个 CPU 送达,其他 CPU 毫不知情——初始化完成后对内核完全透明。

遗憾的是有些主板(某些 Pentium 4 SMP 板)硬件分发不公平。Linux 2.6 用专门的内核线程 kirqd 纠偏:它周期性执行 do_irq_balance(),统计各 CPU 最近的中断量,负载失衡显著时把某个 IRQ”搬”到别的 CPU 或全体轮转。搬家的工具是 IRQ 亲和性:改写重定向表项即可把中断信号路由给特定 CPU(set_ioapic_affinity_irq() 函数),管理员也可往 /proc/irq/n/smp_affinity 写新的 CPU 位掩码手工调整。

处理器间中断

IPI 不走 IRQ 线,直接作为消息在连接所有本地 APIC 的总线上传输。Linux 用三种(向量见表 4-2):

向量名称作用
0xfbCALL_FUNCTION_VECTOR发给除发送者外的所有 CPU,强迫它们执行发送者指定的函数(如全部停机、设置 MTRR 寄存器);通常经 smp_call_function() 发出
0xfcRESCHEDULE_VECTOR接收方处理程序只做应答;真正的重新调度在中断返回时自动发生
0xfdINVALIDATE_TLB_VECTOR发给除发送者外的所有 CPU,强迫它们作废自己的 TLB 表项(第 2 章的惰性 TLB 机制就靠它)

低层处理程序的汇编由 BUILD_INTERRUPT 宏生成:存寄存器、压入”向量号 − 256”、调同名加 smp_ 前缀的高层 C 函数(如 call_function_interrupt() → smp_call_function_interrupt()),高层函数先在本地 APIC 上应答再执行具体动作。发 IPI 有四个现成函数:send_IPI_all()、send_IPI_allbutself()、send_IPI_self()、send_IPI_mask()(按位掩码发给一组 CPU)。

软中断与 tasklet

中断处理程序必须快进快出,剩下的事交给可延后函数(deferrable functions)。它们与 ISR 的本质区别:ISR 被串行化、同类中断往往要等处理程序结束才能再来;可延后函数执行时所有中断都是开着的——把它们从处理程序里剥出去,正是缩短内核响应时间、让时间敏感应用的中断请求几毫秒内得到服务的关键。Linux 2.6 的延后机制有两类:软中断/tasklet(合称可延后函数,运行在中断上下文)和工作队列(运行在进程上下文)。

tasklet 建立在软中断之上,源码里 “softirq” 一词常泛指两者。“中断上下文(interrupt context)”= 内核正在执行中断处理程序或可延后函数的状态。

两者最关键的差异在并发模型

  • 软中断静态分配(编译期定义),同一类型的软中断可以在多个 CPU 上并发运行——所以软中断函数必须可重入,要自己用自旋锁保护数据结构;
  • tasklet 可运行时分配初始化(比如加载模块时),同类型 tasklet 永远串行(不可能同时跑在两个 CPU 上),不同类型可以并发——串行化意味着 tasklet 函数不必可重入,驱动开发者省心。

对可延后函数的四种操作:初始化(定义)、激活(标记为”待执行”,任何时刻可做,包括中断处理中)、屏蔽(选择性禁用)、执行(在规定时间点执行所有同型待执行者)。激活和执行绑定在同一 CPU 上:某 CPU 激活的软中断必须在同 CPU 执行——理论上是为了硬件缓存友好(激活者摸过的数据可能还要用),虽然延迟执行后缓存行未必还在,但绑定规则就是这么定的。

软中断

Linux 2.6 只定义了 6 种软中断(表 4-9):HI_SOFTIRQ(0,高优先级 tasklet)、TIMER_SOFTIRQ(1,定时器相关 tasklet)、NET_TX_SOFTIRQ(2,网卡发包)、NET_RX_SOFTIRQ(3,网卡收包)、SCSI_SOFTIRQ(4,SCSI 命令后处理)、TASKLET_SOFTIRQ(5,普通 tasklet)。索引即优先级,执行从索引 0 开始。

数据结构:softirq_vec 数组(32 个 softirq_action,只有前 6 项在用;每项含 action 函数指针和 data 指针);每 CPU 的 32 位待处理软中断位图(irq_cpustat_t 的 __softirq_pending 字段,local_softirq_pending() 宏读写本地位图);以及全局性的 preempt_count 字段(存于每个进程的 thread_info 里,32 位打包成三个计数器 + 一个标志):

含义
0–7抢占计数器(显式禁用内核抢占的次数)
8–15软中断计数器(可延后函数的禁用深度)
16–27硬中断计数器(本地 CPU 嵌套中断处理程序层数,irq_enter()/irq_exit() 增减)
28PREEMPT_ACTIVE 标志

字段名道破了它的用途:内核抢占被禁止 ⟺ 抢占计数器非 0 或正在中断上下文,所以判断”当前进程能不能被抢占”只需查 preempt_count 是否为 0。in_interrupt() 宏检查硬中断和软中断两个计数器。

open_softirq(nr, action, data) 初始化(填 softirq_vec 一项);raise_softirq(nr) 激活:local_irq_save 存 IF 并关本地中断 → 本地位图置位 → 若 in_interrupt()(已在中断上下文或软中断被禁)什么都不做,否则顺手唤醒本地 CPU 的 ksoftirqd 内核线程 → 恢复 IF。检查待执行软中断的检查点散布在内核若干处:local_bh_enable() 开软中断时、do_IRQ() 结束调 irq_exit() 时、本地 APIC 定时器中断处理完时、CPU 处理完 CALL_FUNCTION_VECTOR 型 IPI 后、ksoftirqd/n 被唤醒时。

do_softirq():in_interrupt() 为真则直接返回(中断上下文或软中断禁用中);local_irq_save 关中断;4KB 栈模式必要时切到软中断栈;调 __do_softirq();切回原栈;恢复 IF。

__do_softirq() 读本地位图、逐个执行置位对应的软中断函数;执行中可能又冒出新软中断,为了保证低延迟它持续循环,但最多迭代 10 次就收手——防止无限软中断流拖死用户态进程,剩下的交 ksoftirqd。步骤:迭代计数置 10;把本地位图拷进 pending;local_bh_disable() 增加软中断计数(看似反直觉实则必要:软中断大多开着中断跑,中途来了中断、irq_exit() 时会再启动一个 __do_softirq 实例——可延后函数在同一 CPU 上必须串行,所以先禁用,后来的实例会在 do_softirq 第 1 步自己退出);清本地位图(新软中断可以再激活);local_irq_enable 开中断;按 pending 逐个执行 softirq_vec[n]->action;local_irq_disable;重读位图、迭代计数减 1;pending 非零且计数未尽则回到清位图那步;还有残余就调 wakeup_softirqd();最后 local_bh_enable 恢复。

ksoftirqd/n 内核线程(每 CPU 一个)解决一个两难:软中断会自我再激活(网络软中断和 tasklet 软中断都这样),网卡包洪峰时软中断以极高频激活。只有两种原始策略:要么 do_softirq() 只处理启动时已 pending 的、不复查(包洪峰时新软中断可能拖到下一次时钟中断才执行——网络开发者不能接受);要么无限复查直到清零(机器等于瘫痪在内核里,用户态程序形同停摆)。ksoftirqd 是折中:do_softirq() 跑完固定迭代还有残余就唤醒内核线程然后返回——线程优先级低,用户程序有肉吃;机器空闲时待执行软中断也能迅速清空。线程主循环:

for(;;) {
    set_current_state(TASK_INTERRUPTIBLE);
    schedule();
    /* 此时处于 TASK_RUNNING 状态 */
    while (local_softirq_pending()) {
        preempt_disable();
        do_softirq();
        preempt_enable();
        cond_resched();
    }
}

tasklet

tasklet 是 I/O 驱动实现延后逻辑的首选,建立在 HI_SOFTIRQ 和 TASKLET_SOFTIRQ 两个软中断之上(区别仅是 do_softirq() 先跑 HI 的)。tasklet_vec / tasklet_hi_vec 数组每 CPU 一项(tasklet_head,指向 tasklet 描述符链表)。描述符 tasklet_struct 五个字段:next、state(两个标志:TASKLET_STATE_SCHED 已挂入待执行链表;TASKLET_STATE_RUN 正在执行——单处理器不用)、count(锁计数)、func、data。

驱动开发者用 tasklet 的完整流程:tasklet_init() 分配初始化(传描述符地址、tasklet 函数、可选整数参数);tasklet_disable()/tasklet_disable_nosync() 禁用(count 增 1,前者还等正在跑的实例结束)、tasklet_enable() 重新启用;tasklet_schedule() 或 tasklet_hi_schedule() 激活(查 TASKLET_STATE_SCHED防重复 → 关中断 → 把描述符插到本地 CPU 链表头 → raise_softirq_irqoff 激活对应软中断 → 恢复中断)。执行由 tasklet_action() / tasklet_hi_action() 完成(就是两个软中断的 action 函数):关中断 → 取本地 CPU 链表并清空数组项 → 开中断 → 逐个处理描述符:多处理器上查 TASKLET_STATE_RUN,已置(同型 tasklet 正在别的 CPU 上跑)就把描述符塞回链表、再激活软中断,推迟到别人跑完;否则置 RUN 标志独占;查 count 判断禁用与否,禁用则摘 RUN 标志塞回链表再激活软中断;启用则清 SCHED 标志、执行 func。注意:除非 tasklet 函数自我再激活,每次激活至多触发一次执行

工作队列与中断返回

工作队列(work queues)

工作队列是 2.6 新增(替代 2.4 的 task queue):内核函数像可延后函数一样被激活,但由**工作者线程(worker threads)**执行。与软中断/tasklet 的根本差异:可延后函数运行在中断上下文,工作队列函数运行在进程上下文——中断上下文里不能发生进程切换,所以会阻塞的函数(比如要从磁盘读数据块的)只能放工作队列。共同的限制:两者都不能访问进程的用户态地址空间(可延后函数不知道会撞上哪个进程;工作队列函数跑在内核线程里,压根没有用户态地址空间)。

数据结构:workqueue_struct 描述符(含 NR_CPUS 个 cpu_workqueue_struct 数组元素);cpu_workqueue_struct 关键字段有 worklist(待执行函数链表头)、more_work(工作者线程睡觉等的等待队列)、work_done(等刷队列的进程睡的等待队列)、thread(工作者线程描述符指针)。每个待执行函数是一个 work_struct:pending(已在队列中为 1)、entry(链表指针)、func、data、timer(延时执行用的软件定时器)。

API:create_workqueue(“foo”) 创建队列并为每个 CPU 建一个工作者线程(foo/0、foo/1……);create_singlethread_workqueue() 只建一个;destroy_workqueue() 销毁。queue_work(wq, work) 入队(查 pending 防重复 → 挂链表 → 必要时唤醒 more_work 上睡觉的工作者线程);queue_delayed_work() 带延迟参数(用 work_struct 的 timer 软件定时器推迟入队);cancel_delayed_work() 取消还没入队的。工作者线程在 worker_thread() 里无限循环:大部分时间睡觉等活,被唤醒后调 run_workqueue() 把链表上的 work_struct 逐个摘下执行——函数可以阻塞,工作者线程可以被换下甚至醒来后跑在另一个 CPU 上。flush_workqueue() 阻塞调用者直到队里已挂的全部函数执行完(用 remove_sequence/insert_sequence 区分”调用之后新加的”不等待)。

内核自带一个预定义队列 events(描述符在 keventd_wq,由 keventd 线程们服务,见 /linux内核/lk03),任何内核开发者免建线程直接用:schedule_work(w)、schedule_delayed_work(w,d)、flush_scheduled_work()。省资源的前提是函数别长时间阻塞——同一 CPU 上预定义队列的执行是串行的,一个函数拖太久会坑了所有共用者。块设备层还有专用的 kblockd 队列(第 14 章)。

从中断和异常返回

收尾代码要通盘考虑:并发内核控制路径的数目(只剩一条才切回用户态)、待决的进程切换请求(有则调度)、待决信号(有则处理)、单步模式(调试器跟踪中要先恢复单步)、虚拟 8086 模式(进程在跑实模式老程序,特殊处理)。这些状态记在 thread_info 的 flags 字段:TIF_SIGPENDING(有待决信号)、TIF_NEED_RESCHED(需要调度)、TIF_SINGLESTEP(回用户态前恢复单步)等。

收尾代码严格说不是函数(它从不返回给调用者),是一段有两个入口的代码:ret_from_intr()(中断处理完)和 ret_from_exception()(异常处理完)。流程见图 6(灰框是内核抢占相关指令,未编译抢占支持时可无视):

图 6:从中断和异常返回(原书图 4-6)

ret_from_exception:
    cli ; 不支持内核抢占时没有这条
ret_from_intr:
    movl $-8192, %ebp ; 用多内核栈时为 -4096
    andl %esp, %ebp
    movl 0x30(%esp), %eax
    movb 0x2c(%esp), %al
    testl $0x00020003, %eax
    jnz resume_userspace
    jmp resume_kernel

从异常返回时才需要 cli(从中断返回时 handle_IRQ_event 已经关了中断)。ebp 装上当前 thread_info 地址(还是”屏蔽 esp 低位”那招)。然后看栈上保存的 cs 和 eflags:被中断的程序在用户态、或 eflags 的 VM 标志置位(虚拟 8086 模式),跳 resume_userspace;否则跳 resume_kernel。

恢复内核控制路径(resume_kernel):若编译了内核抢占支持,先查 preempt_count——非 0 说明还有中断处理程序在嵌套栈里,直接 restore_all 弹寄存器 iret 重启被打断的内核路径;为 0 才进入 need_resched 检查。need_resched 处:current 的 thread_info.flags 里 TIF_NEED_RESCHED 为 0(无需切换)或被打断的路径本就关着本地中断运行(此时强行切换可能破坏内核数据结构,第 5 章),都跳 restore_all;需要切换则调 preempt_schedule_irq():置 PREEMPT_ACTIVE 标志、大内核锁计数临时置 -1、开本地中断、调 schedule() 换进程;原进程恢复后恢复大内核锁计数、清 PREEMPT_ACTIVE、关中断——只要 TIF_NEED_RESCHED 还置着就一直重复调度。

恢复用户态程序(resume_userspace):关中断后检查 thread_info.flags——除了 TIF_SYSCALL_TRACE、TIF_SYSCALL_AUDIT、TIF_SINGLESTEP 之外一个标志都没置,就 restore_all 直接弹栈回用户态;有工作要做则进 work_pending:TIF_NEED_RESCHED 置位就调 schedule() 换进程(原进程再醒时跳回 resume_userspace 重来一遍);否则 work_notifysig——用户程序 eflags 的 VM 标志置位则先调 save_v86_state() 在用户地址空间建好虚拟 8086 状态结构,然后 do_notify_resume() 处理待决信号和单步,最后 restore_all 弹寄存器 iret,被打断的进程继续跑。

通关标准:能说清中断门与陷阱门的差别(IF 标志)以及 Linux 各自用在哪儿;能默写中断处理的三档划分和"中断处理程序不能阻塞"的原因;能解释为什么 __do_IRQ() 要自旋锁 + IRQ_INPROGRESS/PENDING 两个标志;能对比软中断、tasklet、工作队列三者的上下文和并发模型并说出各自的典型用户。

常见坑:把"中断处理程序"当成进程上下文里的普通函数

中断处理程序(和软中断/tasklet)运行在中断上下文:它代表谁在跑完全不可预测,栈是借当前进程的(或专用硬中断栈),绝不能阻塞、不能睡眠、不能触发进程切换。凡是可能阻塞的清理工作必须推给工作队列(进程上下文)——分不清这条边界是驱动开发中最经典的死机来源。