☰
Linux 内核中断处理(八):非早期 IRQ 初始化 —— 从 vector_irq 填充到中断门建立全解析
2026/9/30 7:02:55 网站建设 项目流程
  • 文档
  • 教程
  • 操作系统

【免费下载链接】linux-insides

A book-in-progress about the Linux kernel and its insides.

项目地址:https://gitcode.com/gh_mirrors/li/linux-insides
点击查看免费下载

导读:本文是 linux-insides 仓库《Interrupts and Interrupt Handling》章节第八篇,深入讲解 x86_64 架构下外部硬件中断的非早期初始化流程。上篇我们通过early_irq_init完成了irq_desc中断描述符的早期初始化,本篇将沿着init/main.c的启动路径,逐行剖析init_IRQ如何填充 per-CPU 的vector_irq数组、native_init_IRQ如何完成 ISA 中断与 Local APIC 初始化、以及中断门(interrupt gate)如何通过alloc_intr_gate与used_vectors位图批量建立。读完本文,你将掌握 x86 中断向量空间的分配规则、legacy 中断(0x30~0x3f)的登记机制、test_bit的编译期/运行期优化原理,以及 IRQ2 级联线与setup_irq的完整工作链路。

从 early_irq_init 到 init_IRQ:非早期初始化登场

在 Linux 内核启动流程中,init/main.c里紧接着early_irq_init函数之后调用的就是init_IRQ函数。如果说early_irq_init(位于kernel/irq/irqdesc.c)负责的是irq_desc结构的早期初始化——它是 Linux 内核中断管理代码的基石,代表一个中断描述符——那么init_IRQ则把工作推进到了"非早期"阶段:它初始化vector_irq这个 per-CPU 变量,为后续外部硬件中断的实际处理铺路。

关于早期部分,可以回顾本仓库 Interrupts/linux-interrupts-7.md:那里展示了irq_desc的数组定义(struct irq_desc irq_desc[NR_IRQS],预置handle_bad_irq处理器、depth = 1与自旋锁)、NR_IRQS在CONFIG_X86_IO_APIC下的取值逻辑(例如dmesg | grep NR_IRQS常见输出NR_IRQS:4352),以及CONFIG_SPARSE_IRQ开启时arch_probe_nr_irqs对预分配 IRQ 数目的探测。本篇承接的就是这个时间点之后的工作。

init_IRQ是架构相关函数,定义在arch/x86/kernel/irqinit.c。它要初始化的vector_irqper-CPU 变量同样定义在该文件中:

... DEFINE_PER_CPU(vector_irq_t, vector_irq) = { [0 ... NR_VECTORS - 1] = -1, }; ...

vector_irq_t类型定义于arch/x86/include/asm/hw_irq.h,展开后是一个长度固定的 int 数组:

typedef int vector_irq_t[NR_VECTORS];

其中NR_VECTORS是向量号的总数。正如本仓库 Interrupts/linux-interrupts-1.md 中介绍的理论:向量号是 IDT(Interrupt Descriptor Table)的索引,取值范围为 0~255:

#define NR_VECTORS 256

也就是说,每个 CPU 都拥有一份 256 个 int 的vector_irq数组,初始值全部为-1,用于记录"某个中断向量当前对应哪个 IRQ 号"。关于 per-CPU 变量的实现机制(DEFINE_PER_CPU、per_cpu宏、__per_cpu_offset等),可进一步阅读本仓库 Concepts/linux-cpu-1.md。

填充 vector_irq:legacy 中断向量登记

init_IRQ函数开头的核心动作,是把 legacy(传统)中断的向量号写入 CPU0 的vector_irq数组:

void __init init_IRQ(void) { int i; for (i = 0; i < nr_legacy_irqs(); i++) per_cpu(vector_irq, 0)[IRQ0_VECTOR + i] = i; ... ... ... }

这里有两个关键点需要展开:

1.nr_legacy_irqs()的取值来源。它定义在arch/x86/include/asm/i8259.h,只是一个转发函数,返回legacy_pic结构体中的nr_legacy_irqs字段:

static inline int nr_legacy_irqs(void) { return legacy_pic->nr_legacy_irqs; }

legacy_pic结构体同样定义在该头文件中,它抽象了一个"非现代的"可编程中断控制器(PIC),例如经典的 Intel 8259A:

struct legacy_pic { int nr_legacy_irqs; struct irq_chip *chip; void (*mask)(unsigned int irq); void (*unmask)(unsigned int irq); void (*mask_all)(void); void (*restore_mask)(void); void (*init)(int auto_eoi); int (*irq_pending)(unsigned int irq); void (*make_irq)(unsigned int irq); };

而 legacy 中断数目的实际上限由宏NR_IRQS_LEGACY给出,定义在arch/x86/include/asm/irq_vectors.h:

#define NR_IRQS_LEGACY 16

2.IRQ0_VECTOR为什么是 0x30。循环里以IRQ0_VECTOR + i为下标写入向量号,IRQ0_VECTOR定义在同一个irq_vectors.h头文件中:

#define FIRST_EXTERNAL_VECTOR 0x20 #define IRQ0_VECTOR ((FIRST_EXTERNAL_VECTOR + 16) & ~15)

即IRQ0_VECTOR为0x30(十进制 32)。原因要回到中断向量空间的分配约定(见本仓库 Interrupts/linux-interrupts-1.md 与 Interrupts/linux-interrupts-6.md):向量号0~31(0x00~0x1f)被处理器保留,用于处理架构定义的异常和中断;向量号0x30~0x3f则被保留给 ISA(Industry Standard Architecture)。因此这段循环实际上是把向量0x30~0x3f(即 IRQ0~IRQ15)登记进vector_irq[0],对应关系为vector_irq[0][IRQ0_VECTOR + i] = i。

补充说明:现代系统上的所有中断实际上都由 I/O APIC(Advanced Programmable Interrupt Controller 的 I/O 部分)处理。之所以称这些为 legacy 中断,是因为0x30~0x3f这段向量空间传统上由 8259A 这类老式 PIC 控制器服务;如果它们改由 I/O APIC 处理,这段向量空间会被释放并重新利用。

填充好的vector_irq会在外部硬件中断处理的第一步被消费——在do_IRQ函数(位于arch/x86/kernel/irq.c)中,内核通过__this_cpu_read读取当前 CPU 上该向量对应的 IRQ 号:

__visible unsigned int __irq_entry do_IRQ(struct pt_regs *regs) { ... ... ... irq = __this_cpu_read(vector_irq[vector]); if (!handle_irq(irq, regs)) { ... ... ... } exiting_irq(); ... ... return 1; }

可以看到vector_irq是"向量号 → IRQ 号"的映射表,是中断从硬件向量到内核 IRQ 语义的关键桥梁。

x86_init.irqs.intr_init():进入架构相关的中断初始化

init_IRQ函数末尾调用了:

x86_init.irqs.intr_init();

x86_init是x86_init_ops类型的结构体,定义于arch/x86/kernel/x86_init.c。如果你读过本仓库 Initialization 章节会记得:这个结构体包含多个指向平台设置函数的字段(resources与内存资源相关、mpparse与 MultiProcessor Configuration Table 解析相关等)。与中断相关的irqs字段包含三个成员:

struct x86_init_ops x86_init __initdata { ... ... ... .irqs = { .pre_vector_init = init_ISA_irqs, .intr_init = native_init_IRQ, .trap_init = x86_init_noop, }, ... ... ... }

我们关注的是native_init_IRQ。native_前缀表示它是架构特定实现:该函数定义在arch/x86/kernel/irqinit.c,负责执行 Local APIC 的通用初始化和 ISA 中断的初始化。它的执行路径大致如下:

native_init_IRQ ├── x86_init.irqs.pre_vector_init() → init_ISA_irqs() │ ├── legacy_pic->init(0) → init_8259A() │ └── irq_set_chip_and_handler(...) 设置 legacy 中断的 chip 与 handler ├── apic_intr_init() → 分配 SMP 专用中断门 ├── 循环设置 FIRST_EXTERNAL_VECTOR 起的中断门(irq_entries_start) ├── 用 spurious_interrupt 填充剩余未使用向量 └── setup_irq(2, &irq2) → 注册 IRQ2 级联(条件满足时)

init_ISA_irqs:ISA 中断与 Local APIC 初始化

native_init_IRQ的第一步是执行x86_init.irqs.pre_vector_init(),它指向init_ISA_irqs函数(定义在同一文件arch/x86/kernel/irqinit.c)。从函数名就能看出,它做的是与 ISA 相关中断的初始化。函数开头先从legacy_pic取出芯片描述:

void __init init_ISA_irqs(void) { struct irq_chip *chip = legacy_pic->chip; ... ... ...

irq_chip结构体定义于include/linux/irq.h,表示硬件中断芯片的描述符,包含多个关键回调字段:

  • name—— 设备名称,会出现在/proc/interrupts的最后一列。例如:
$ cat /proc/interrupts CPU0 CPU1 CPU2 CPU3 CPU4 CPU5 CPU6 CPU7 0: 16 0 0 0 0 0 0 0 IO-APIC 2-edge timer 1: 2 0 0 0 0 0 0 0 IO-APIC 1-edge i8042 8: 1 0 0 0 0 0 0 0 IO-APIC 8-edge rtc0
  • (*irq_mask)(struct irq_data *data)—— 屏蔽一个中断源;
  • (*irq_ack)(struct irq_data *data)—— 一个中断的开始确认(acknowledge);
  • (*irq_startup)(struct irq_data *data)—— 启动中断;
  • (*irq_shutdown)(struct irq_data *data)—— 关闭中断;
  • 以及其他若干字段。

注意irq_data结构体表示"传递给芯片函数的 per-irq 芯片数据集合",包含mask(预计算的、用于访问芯片寄存器的位掩码)、irq(中断号)、hwirq(硬件中断号,局部于中断域芯片的底层硬件访问编号)等字段。

接下来,取决于CONFIG_X86_64与CONFIG_X86_LOCAL_APIC这两个内核配置选项,init_ISA_irqs会调用init_bsp_APIC(定义于arch/x86/kernel/apic/apic.c):

#if defined(CONFIG_X86_64) || defined(CONFIG_X86_LOCAL_APIC) init_bsp_APIC(); #endif

init_bsp_APIC负责初始化引导处理器(bootstrap processor,即最先启动的那个 CPU)的 APIC。它首先检查是否发现了 SMP 配置、以及处理器是否具备 APIC:

if (smp_found_config || !cpu_has_apic) return;

若不满足条件则直接返回。否则接下来调用clear_local_APIC(同一源文件)关闭本地 APIC,然后通过设置APIC_SPIV(Spurious Interrupt Vector Register)的APIC_SPIV_APIC_ENABLED位来启用第一个处理器的 APIC:

value = apic_read(APIC_SPIV); value &= ~APIC_VECTOR_MASK; value |= APIC_SPIV_APIC_ENABLED;

并用apic_write写回寄存器:

apic_write(APIC_SPIV, value);

BSP 的 APIC 启用之后,回到init_ISA_irqs,下一步是初始化 legacy 可编程中断控制器,并为每个 legacy IRQ 设置芯片与高层处理器(high-level handler):

legacy_pic->init(0); for (i = 0; i < nr_legacy_irqs(); i++) irq_set_chip_and_handler(i, chip, handle_level_irq);

这里legacy_pic->init指向哪里?legacy_pic全局变量定义于arch/x86/kernel/i8259.c:

struct legacy_pic *legacy_pic = &default_legacy_pic;

而default_legacy_pic的init成员指向init_8259A:

struct legacy_pic default_legacy_pic = { ... ... ... .init = init_8259A, ... ... ... }

init_8259A定义在同一源文件中,执行 Intel 8259 可编程中断控制器的初始化(更完整的 8259A/APIC 细节将在专门章节展开)。注意此处调用参数为0(auto_eoi为 0,表示不使用自动 EOI 模式)。随后循环把chip(即legacy_pic->chip)与handle_level_irq处理器绑定到 0~15 号 legacy IRQ 上。

apic_intr_init 与 alloc_intr_gate:SMP 专用中断门的分配

init_ISA_irqs完成工作后,native_init_IRQ继续调用apic_intr_init,为 SMP 架构使用的处理器间中断(Inter-processor Interrupt,IPI)分配特殊中断门。分配动作通过alloc_intr_gate宏完成,它定义于arch/x86/include/asm/desc.h:

#define alloc_intr_gate(n, addr) \ do { \ alloc_system_vector(n); \ set_intr_gate(n, addr); \ } while (0)

首先展开为alloc_system_vector(n)调用:该函数在used_vectors位图中检查给定向量号是否已被使用,若未被置位则置位,并更新first_system_vector;若已被占用则触发 BUG:

if (!test_bit(vector, used_vectors)) { set_bit(vector, used_vectors); if (first_system_vector > vector) first_system_vector = vector; } else { BUG(); }

(used_vectors位图与first_system_vector在上一部分 Interrupts/linux-interrupts-7.md 中已有铺垫,这里它保证系统向量互不冲突。)随后set_intr_gate把向量n对应的 IDT 门设为入口地址addr。

test_bit 的两种实现:编译期常量与运行期变量的优化博弈

上文用到了test_bit宏,它定义于arch/x86/include/asm/bitops.h:

#define test_bit(nr, addr) \ (__builtin_constant_p((nr)) \ ? constant_test_bit((nr), (addr)) \ : variable_test_bit((nr), (addr)))

这是一个基于 GCC 内建函数__builtin_constant_p的三目运算符:如果nr在编译期是已知常量,就调用constant_test_bit;否则调用variable_test_bit。为了直观理解__builtin_constant_p,可以编译一个最小测试程序:

#include <stdio.h> #define PREDEFINED_VAL 1 int main() { int i = 5; printf("__builtin_constant_p(i) is %d\n", __builtin_constant_p(i)); printf("__builtin_constant_p(PREDEFINED_VAL) is %d\n", __builtin_constant_p(PREDEFINED_VAL)); printf("__builtin_constant_p(100) is %d\n", __builtin_constant_p(100)); return 0; }

编译并运行:

$ gcc test.c -o test $ ./test __builtin_constant_p(i) is 0 __builtin_constant_p(PREDEFINED_VAL) is 1 __builtin_constant_p(100) is 1

可见:普通局部变量i的值在编译期未知(返回 0),而宏常量与字面量在编译期已知(返回 1)。

回到test_bit。当__builtin_constant_p返回非零时走constant_test_bit:

static inline int constant_test_bit(int nr, const void *addr) { const u32 *p = (const u32 *)addr; return ((1UL << (nr & 31)) & (p[nr >> 5])) != 0; }

否则走variable_test_bit,它用 x86 的bt(bit test)指令配合setc实现单指令位测试:

static inline int variable_test_bit(int nr, const void *addr) { u8 v; const u32 *p = (const u32 *)addr; asm("btl %2,%1; setc %0" : "=qm" (v) : "m" (*p), "Ir" (nr)); return v; }

两者的目的相同,区别在于优化。用一个简单例子对比两者产生的汇编代码:

#define CONST 25 int main() { int nr = 24; variable_test_bit(nr, (int*)0x10000000); constant_test_bit(CONST, (int*)0x10000000) return 0; }

constant_test_bit版本生成的汇编(x86_64,System V AMD64 ABI 调用约定,参数依次放入edi/esi等寄存器):

pushq %rbp movq %rsp, %rbp movl $268435456, %esi movl $25, %edi call constant_test_bit

而variable_test_bit版本:

pushq %rbp movq %rsp, %rbp subq $16, %rsp movl $24, -4(%rbp) movl -4(%rbp), %eax movl $268435456, %esi movl %eax, %edi call variable_test_bit

两段代码开头相同:把当前栈帧基址保存到%rbp。随后分道扬镳:

  • 第一段:直接向%esi装入第二参数$268435456(即0x10000000),向%edi装入第一参数$25,然后调用constant_test_bit。因为参数是编译期常量,编译器可以直接代入值,无需任何栈操作。
  • 第二段:nr是运行期变量,编译器无法代入,必须把它放到程序的栈帧上:subq $16, %rsp为局部变量腾出 16 字节栈空间,把$24(nr的当前值)写到%rbp - 4的位置,再装入%eax,最后才把两个参数放入寄存器调用variable_test_bit。此时栈帧布局如下:
<- stack grows %[rbp] | +----------+ +---------+ +---------+ +--------+ | | | | | return | | | | nr |-| |-| |-| argc | | | | | | address | | | +----------+ +---------+ +---------+ +--------+ | %[rsp]

结论很清晰:当向量号是编译期常量时,编译器可零成本完成位测试;只有运行期变量才需要走寄存器 +bt指令的路径。这正是内核在test_bit上做两层封装的意义所在。

建立外部中断门:irq_entries_start 与 spurious_interrupt

apic_intr_init完成之后,下一步是从FIRST_EXTERNAL_VECTOR(即0x20)到0x100设置中断门:

i = FIRST_EXTERNAL_VECTOR; #ifndef CONFIG_X86_LOCAL_APIC #define first_system_vector NR_VECTORS #endif for_each_clear_bit_from(i, used_vectors, first_system_vector) { set_intr_gate(i, irq_entries_start + 8 * (i - FIRST_EXTERNAL_VECTOR)); }

这里的关键是使用了for_each_clear_bit_from辅助宏:它只遍历used_vectors位图中尚未置位的向量号,为每个空闲向量在 IDT 中设置门,入口地址为irq_entries_start加上偏移量(每个中断入口占 8 字节)。也就是说,那些已经被占用(例如前 32 个异常向量、或上面alloc_system_vector分配掉的系统向量)不会在这里被重复设置。

随后,在CONFIG_X86_LOCAL_APIC开启的情况下,用同样的for_each_clear_bit_from把中断表(IDT)中仍然空缺的门全部填充为spurious_interrupt(伪中断处理器,用于处理"虚假中断"):

#ifdef CONFIG_X86_LOCAL_APIC for_each_clear_bit_from(i, used_vectors, NR_VECTORS) set_intr_gate(i, spurious_interrupt); #endif

used_vectors是一个unsigned long位图,记录已经初始化的中断门。前 32 个中断向量是在trap_init函数(arch/x86/kernel/setup.c)中被预先置位的:

for (i = 0; i < FIRST_EXTERNAL_VECTOR; i++) set_bit(i, used_vectors);

这一过程正是本仓库 Interrupts/linux-interrupts-6.md 所描述的内容——0~31 号向量为处理器异常保留,因此在内核早期就已登记。

IRQ2 级联:setup_irq(2, &irq2)

native_init_IRQ函数末尾有一个值得单独讲解的条件与调用:

if (!acpi_ioapic && !of_ioapic && nr_legacy_irqs()) setup_irq(2, &irq2);

先拆解条件中的三个变量:

  • acpi_ioapic:表示是否存在 ACPI 描述的 I/O APIC,定义于arch/x86/kernel/acpi/boot.c。它会在处理 Multiple APIC Description Table(MADT)时由acpi_set_irq_model_ioapic函数置位。注意该变量受CONFIG_ACPI与CONFIG_X86_LOCAL_APIC配置影响;若两者未开启,它退化为常量 0:
#define acpi_ioapic 0
  • of_ioapic:表示是否使用 Open Firmware(设备树)描述的 I/O APIC,定义于arch/x86/kernel/devicetree.c,由dtb_ioapic_setup函数初始化。它同样依赖配置选项CONFIG_OF,未开启时也是 0:
#ifdef CONFIG_OF extern int of_ioapic; ... ... ... #else #define of_ioapic 0 ... ... ... #endif
  • nr_legacy_irqs():前文已介绍,返回 legacy 中断数量。

综合来看,该条件为真意味着:系统没有ACPI I/O APIC、没有设备树 I/O APIC,并且存在 legacy 中断控制器——即机器仍然依赖传统 PIC 架构。此时才需要注册 IRQ2 级联线。

irq2是一个irqaction结构体(定义于arch/x86/kernel/irqinit.c),代表 IRQ 2 这条用于查询级联设备的线路:

static struct irqaction irq2 = { .handler = no_action, .name = "cascade", .flags = IRQF_NO_THREAD, };

为什么需要"cascade"(级联)?在早期的双 8259A 架构中,第二块 PIC 芯片通过 IRQ2 线路挂接到第一块芯片上:第二块芯片服务 8~15 号线,之后才轮到第一块芯片的线路。以经典的 Intel 8259A 为例,其 IRQ 线分配如下:

  • IRQ 0—— 系统时钟(system timer);
  • IRQ 1—— 键盘;
  • IRQ 2—— 用于级联连接的设备;
  • IRQ 8—— RTC(实时时钟);
  • IRQ 9—— 保留;
  • IRQ 10—— 保留;
  • IRQ 11—— 保留;
  • IRQ 12—— PS/2 鼠标;
  • IRQ 13—— 协处理器;
  • IRQ 14—— 硬盘控制器;
  • IRQ 1—— 保留;
  • IRQ 3—— COM2 和 COM4;
  • IRQ 4—— COM1 和 COM3;
  • IRQ 5—— LPT2;
  • IRQ 6—— 软盘驱动器控制器;
  • IRQ 7—— LPT1。

(注意原表把IRQ 1同时列在键盘与保留两处,这正体现了传统 PC 中断分配历史上兼容性约定与文档叙述的差异;以键盘占用 IRQ1 为准,其他未列出明确设备的线路视为保留。)

setup_irq函数定义于kernel/irq/manage.c,接收两个参数:中断向量号与对应的irqaction结构。它首先通过向量号拿到中断描述符:

struct irq_desc *desc = irq_to_desc(irq);

然后调用__setup_irq完成实际设置,期间对描述符加锁保护:

chip_bus_lock(desc); retval = __setup_irq(irq, desc, act); chip_bus_sync_unlock(desc); return retval;

__setup_irq是一个工作量很大的函数:当提供了线程函数且该中断不会嵌套进其他中断线程时,它会创建 handler 线程;设置芯片标志;填充irqaction结构;等等。整个过程完成后,还会创建/proc/irq/<vector_number>目录并填充内容。不过在现代机器上,由于中断基本由 APIC 处理,这些 proc 文件中的值通常都是 0:

$ cat /proc/irq/2/node 0 $cat /proc/irq/2/affinity_hint 00 cat /proc/irq/2/spurious count 0 unhandled 0 last_unhandled 0 ms

这也从侧面印证了条件判断的意义:只有在系统真的依赖 legacy PIC 时,IRQ2 级联的注册才有实际价值。

小结与后续阅读

本篇(对应仓库 Interrupts/linux-interrupts-8.md)完整走通了外部硬件中断的非早期初始化链路:

  1. init_IRQ把 legacy 中断(向量0x30~0x3f)登记进 CPU0 的 per-CPUvector_irq数组,该数组在do_IRQ中承担"向量号 → IRQ 号"的映射职责;
  2. 通过x86_init.irqs.intr_init()进入native_init_IRQ:先由init_ISA_irqs完成 ISA 中断初始化(含init_bsp_APIC启用 Local APIC、init_8259A初始化 8259A、irq_set_chip_and_handler绑定芯片与处理器);
  3. apic_intr_init配合alloc_intr_gate/alloc_system_vector为 SMP 处理器间中断分配中断门,test_bit的常量/变量双实现保证了位图查询在编译期可优化;
  4. 用for_each_clear_bit_from从FIRST_EXTERNAL_VECTOR起批量建立外部中断门,并用spurious_interrupt兜底填充其余向量;
  5. 在无 ACPI/设备树 I/O APIC 的 legacy 环境下,通过setup_irq(2, &irq2)注册 IRQ2 级联线。

后续内容请继续阅读本仓库 Interrupts/README.md 中列出的后续部分:Softirq, Tasklets and Workqueues(Interrupts/linux-interrupts-9.md)将讲解软中断与下半部机制,最后一篇 Interrupts/linux-interrupts-10.md 会以一个真实硬件驱动收尾整个中断处理章节。整个章节在全书目录中的位置见 SUMMARY.md。

  • 文档
  • 教程
  • 操作系统

【免费下载链接】linux-insides

A book-in-progress about the Linux kernel and its insides.

项目地址:https://gitcode.com/gh_mirrors/li/linux-insides
点击查看免费下载

相关推荐

上一篇:NetBox IPSec Proposal 模型详解:IPSec 隧道加密与认证参数建模实战
下一篇:GitHub1s终极指南:为什么开发者纷纷抛弃传统GitHub浏览,改用VS Code界面?

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询