简单过一下内核的中断流程。与此前写过的文章不同,笔者对中断子系统完全不感兴趣,只是要解决一个间接的问题需要先整理这方面的内容。所以这次不是自己翻源码了,就过下别人的演讲和 PPT,还有一些零散的资料。这里面涉及到的范围很窄,只提及简单的处理和分发流程,没有覆盖整个中断子系统,随便看看就好。用到的 Linux 版本是 6.7,体系结构是 x86-64。
中断处理概览
有两种中断控制器需要关注,一是 IOAPIC;二是 MSI 设备。前者使用的是传统的基于 pin 的中断(Pin-Based Interrupt),需要走重定向表进行路由;后者使用 MSI 中断(Message Signaled Interrupt),只需把数据写入到特殊的地址即可产生中断。
在上面的例子中,一个 acpi 设备产生了 irq = 9 的中断,这个中断被 IOAPIC 硬件所接收,并通过重定向表查找关联的 Vector Number 和 Destination ID 封装为中断消息。对应 Destination ID 的 CPU 接收中断消息后即执行通用中断例程,通过 Vector Number 找到对应的 vector_irq
数据结构(IRQ 描述符)。最终由设备驱动所注册的 ISR 会被调用。
对于 MSI 设备,如果想要产生中断(消息)的话,只需要在特定的地址写入数据,而无需查找重定向表。这个特定的地址是指 LAPIC 映射得出的偏移地址。
(注意本文并不区分 handler 和 ISR,通常前者指的是 ISR 跳板,比如通用服务例程。)
中断描述符表
中断描述符表(Interrupt Descriptor Table)简称 IDT。IDT 共有 256 个条目,可以拆成三个部分:
- 前 32 个条目:提供给异常使用。
- 后续 204 个条目:作为通用中断,由设备驱动注册。
- 末 20 个条目:多是 IPI,或者是 APIC 定时器等用途。
中断服务例程
CPU 是如何执行中断(异常)的?或者说,中断(异常)的到来是如何调用到具体的 ISR?以条目 0 为例,就是要找到 Divide Error Exception Handler 对应的地址。
右侧的图给出了 IDT 当中 Vector 0 对应条目的数据结构(中断门或者陷阱门,每个占用 16 字节),通过其中的 offset 域,加上 Base address (selector 域描述 GDT 里面的下标,通过下标拿到段描述符,这里面就存放着基址),就能定位得到对应的 ISR 的地址。(不用基地址左移?)
gdb 调试图左侧是 IDT 的地址,通过查看寄存器得知(哈?还有这个寄存器?啊想起来了是 IDTR)。右图按 8 字节 16 进制输出 Vector 0,因为刚好是在 IDT 起始地址。对照结构体布局,显然 selector = 0x 0010,而 offset 需要拼接一下,0f90 | 8160 | ffff | ffff
,所以是 0xffffffff81600f90。
总之 Base address 也是通过 selector 和 GDT 按图索骥。他这里算出是 0。
两者相加后即可定位到指定 ISR 的入口地址。对应这里和这里的代码。
入口以 asm_##func
的宏形式命名。这其实是一个 stub,实际会调用到 func
的 C 实现版本。
stub 主要是将 Vector Number 进行压栈处理,所以要用汇编来完成。后续就是直接跳到同名 C 函数。
(这段存疑,以后再调试调研 TODO)
注意到对于通用中断来说,func 函数名一定是 common_interrupt
。后面看下怎么进一步分发到不同的 ISR。
中断分发概览
刚才的图也提过分发,但这里还提到其实还有一个 IOMMU 用于中断的重新映射,略了。
具体到 irq_domain
大概是这样的层级,传递过去的上层控制器是作为 parent domain,反过来的就是 child domain。所以这里 CPU 内部的 Local APIC 是 parent,而 IOAPIC 和 MSI-X 都是 child。
中断分发:Pin-based
经过前面的一堆幻灯片,也知道了左半边侧在干什么,现在看右半侧。common_interrupt
首先要做的就是拿到 irq_desc
。因为前面压过栈了(应该是作为函数参数),所以能找到 Vector Number 所对应的 IRQ 描述符。既然拿到手了,那么里面注册好的 ISR (acpi_irq
,注册时机在这里)就能调用到。
这个过程又可以拆成两部分:初始化阶段和 request_irq
阶段。这里略过初始化阶段,比如 sparse IRQ 机制和 IRQ shutdown vector 等话题可以看下演讲。
request_irq
阶段在前面 acpi_irq
注册时机也提了一点。但是它除了负责注册 IRQ 以外,还有一个特性是选择最佳的 CPU 和 Vector Number。内核通过一个 Vector Matrix 来为每一个 CPU 跟踪可用的 Vector Number(见 available
和 allocated
,其总和恒定)。其选择 CPU 的算法是简单的贪心:寻找最早出现的最高 available 值所对应的 CPU。这里会返回 CPU = 1 和 Vector Number = 32。后续再说怎么找到这个 Vector Number。
后续会通过 apic_update_vector
函数更新 CPU 和 Vector Number。最后用设置好的 irq_cfg
写回到 IOAPIC 的重定向表:Vector Number = 2,Destination ID = 1 << 1(也就是 CPU #1)。
中断分发:MSI
当有一个 MSI-X 中断触发时,PCIe MSI-X 设备会去查找内部的 MSI-X 向量表(vector table),并且基于查找到的条目发出一个 memory write 的指令。
问题在于怎么找到这个目标 CPU。
向量表的地址位于 BAR 0,dump 后得知只有一个条目
hwirq=0,所以设备触发中断会找到向量表第 0 项;其消息地址包含目标 CPU 编号
消息数据包含向量号
对照 Message Address 和 Message Data 数据结构解析出 Destination ID 和 Vector Number 后,就可以走类似 Pin-based 的流程。可以使用 lspci
工具 dump 出向量表。
注意此前从来没有提过 irq 编号(这里是 24)是怎么得到的
irq 是经过内核抽象的概念,也可认为是虚拟 irq(virq)。其分配 ID 从 24 算起;分配方式可以是通过 bitmap,也可以是 maple tree。bitmap 被认为在构建时是固定大小的,没法扩容,所以新版本使用了可动态分配的 maple tree。
至于 hwirq,直接硬件 probe 就完事了。主要是通过 xarray 数据结构来为 msi_desc
和 hwirq 做关联。总之就是想办法把 irq 和 hwirq 绑在一起。
有了 irq 以后,剩下就是选择 CPU 和 Vector Number。选择CPU的操作和前面一样。
至于 Vector Number 的选择,则是三个 map 的或,接着寻找 alloc_start 之后的首个 0 位。
总结?
本文只是从 OneNote 拉过来的草稿,确实没啥好总结的。
References
[x86] Linux Kernel Interrupt Delivery Configuration – Youtube
Interrupts – Linux Kernel Teaching
Linux source code (v6.7) – Bootlin Elixir Cross Referencer