【免费下载链接】linux-insides-zh
Linux 内核揭秘
中断描述符表(Interrupt Descriptor Table,简称 IDT)是 x86/x86_64 体系结构上连接 CPU 中断信号与内核处理程序的核心数据结构。本文以 linux-kernelstructure-1.md 为主体,结合本仓库 中断与中断处理、内核初始化早期中断设置 等章节,系统讲解中断与异常的来源与分类、向量号分配、两类错误代码格式、三种门描述符的位布局、Linux 内核中的gate_struct64表示,以及 NMI、IST(中断堆栈表)等进阶机制。读完本文,你将能够读懂 IDT 的每一个位域,理解 Linux 内核如何在启动早期用set_intr_gate等 API 构建 IDT,并掌握门描述符在真实内核代码中的落点。
中断与异常的来源和分类
处理器在处理正常指令流之外,还会响应来自软件或硬件的事件。这些事件统称为中断/异常,在 x86 体系结构上可以归纳为三个常见来源:
- 异常(Exception)——同步(synchronous)事件,由 CPU 在执行指令时检测到错误条件产生,例如除零、访问不存在的内存页;
- 软中断(Software Interrupt)——同步事件,由软件通过
INT n等指令主动触发,常用于系统调用或断点调试; - 外部中断(External Interrupt)——异步(asynchronous)事件,由外部硬件(键盘、网卡、时钟等)通过中断线或 APIC 总线发出。
异常与正在执行的指令严格同步,因此可以被细分为三类,其语义差别直接决定了被中断程序能否继续执行:
| 类型 | 报告时机 | %rip指向 | 被中断程序能否恢复 |
|---|---|---|---|
| 故障(Fault) | 在导致异常的指令之前被准确报告 | 指向故障指令本身 | 可以(指令被修正后重新执行) |
| 陷阱(Trap) | 在导致异常的指令之后被准确报告 | 指向故障指令 | 可以 |
| 终止(Abort) | 不明确的异常,无法定位精确指令 | —— | 通常不允许,程序不能可靠地再次启动 |
可屏蔽中断与不可屏蔽中断
根据是否受标志寄存器影响,中断又分为两类:
- 可屏蔽中断(Maskable Interrupt):只有在
RFLAGS.IF = 1时才会触发中断处理程序;只要IF位被清零(例如执行cli指令),它们就持续处于等待处理状态。Linux 内核中native_irq_disable/native_irq_enable正是封装了cli/sti指令来操作IF位,详见 深入 Linux 内核中的中断。 - 不可屏蔽中断(NMI):不受
RFLAGS.IF位影响,无论IF为何值都会触发。一个 NMI 发生时会进一步屏蔽后续的其他 NMI,直到执行IRET(中断返回)指令为止。这一"嵌套屏蔽"行为对内核的 NMI 处理栈设计影响深远,后文专门展开。
中断向量号与前 32 个保留向量
每一个异常和中断都被分配了一个固定的向量标识号(也称"中断向量"或简称"向量")。中断处理程序使用中断向量号在 IDT 中定位对应的门描述符,从而找到相应的系统软件处理程序。向量号范围从0到255,至多 256 个:
- 前 32 个向量(
0–31)由处理器保留,用于预定义的异常和中断条件; - 向量
32–255留给用户定义中断,通常分配给外部 I/O 设备。
Linux 内核在 arch/x86/include/asm/traps.h 中用枚举定义了前 32 个保留向量的助记符:
/* 中断/异常 */ enum { X86_TRAP_DE = 0, /* 0, 除零错误 */ X86_TRAP_DB, /* 1, 调试 */ X86_TRAP_NMI, /* 2, 不可屏蔽中断 */ X86_TRAP_BP, /* 3, 断点 */ X86_TRAP_OF, /* 4, 溢出 */ X86_TRAP_BR, /* 5, 超出范围 */ X86_TRAP_UD, /* 6, 操作码无效 */ X86_TRAP_NM, /* 7, 设备不可用 */ X86_TRAP_DF, /* 8, 双精度浮点错误 */ X86_TRAP_OLD_MF, /* 9, 协处理器段溢出 */ X86_TRAP_TS, /* 10, 无效的 TSS */ X86_TRAP_NP, /* 11, 段不存在 */ X86_TRAP_SS, /* 12, 堆栈段故障 */ X86_TRAP_GP, /* 13, 一般保护故障 */ X86_TRAP_PF, /* 14, 页错误 */ X86_TRAP_SPURIOUS, /* 15, 伪中断 */ X86_TRAP_MF, /* 16, x87 浮点异常 */ X86_TRAP_AC, /* 17, 对齐检查 */ X86_TRAP_MC, /* 18, 机器检测 */ X86_TRAP_XF, /* 19, SIMD (单指令多数据结构浮点)异常 */ X86_TRAP_IRET = 32, /* 32, IRET (中断返回)异常 */ };向量0–31与具体异常的对应关系(助记符、类型、是否有错误码、产生源)可以查阅本仓库 早期中断和异常处理 中的完整表格;那篇文章还展示了 Linux 内核在启动早期如何通过for (i = 0; i < NUM_EXCEPTION_VECTORS; i++)循环把early_idt_handler_array中的早期处理程序逐一填入 IDT。注意内核在写入中断门时会对向量号做合法性检查,例如set_intr_gate中的BUG_ON((unsigned)n > 0xFF),确保向量号不超过 255。
错误代码(Error Code):两种位格式详解
某些异常(例如一般保护故障#GP、页错误#PF、无效 TSS#TS等)会产生一个错误代码。在控制权转交给异常处理程序期间,处理器会把错误代码压入堆栈,供处理程序读取。错误代码存在两种格式:
选择子错误代码格式
多数异常(如段类异常)使用此格式报告出错的选择子信息:
31 16 15 3 2 1 0 +-------------------------------------------------------------------------------+ | | | T | I | E | | Reserved | Selector Index | - | D | X | | | | I | T | T | +-------------------------------------------------------------------------------+各位含义如下:
EXT(位 0)——若为 1,异常源在处理器外部;若为 0,异常源位于处理器内部;IDT(位 1)——若为 1,Selector Index字段引用**中断描述符表(IDT)**中的门描述符;若为 0,则引用 GDT 或 LDT 中的描述符(具体由TI位指示);TI(位 2)——若为 1,Selector Index引用LDT中的描述符;若为 0,引用GDT中的描述符;Selector Index(位 3–15)——指定索引为 GDT、LDT 或 IDT 的哪一个表项,由IDT和TI位共同决定。
页错误代码格式
页错误(#PF)使用专门的格式,用于报告错误访问的详细信息:
31 4 3 2 1 0 +-------------------------------------------------------------------------------+ | | | R | U | R | - | | Reserved | I/D | S | - | - | P | | | | V | S | W | - | +-------------------------------------------------------------------------------+各位含义如下:
I/D(位 4)——若为 1,造成页错误的访问是取指(instruction fetch);RSV(位 3)——若为 1,页错误是处理器从保留给分页表的区域中读取 1 的结果(页表项中的保留位被置位);U/S(位 2)——若为 0,由管理员模式(CPL = 0, 1 或 2)的访问导致页错误;若为 1,由用户模式(CPL = 3)的访问导致;R/W(位 1)——若为 0,导致页错误的是内存读取;若为 1,导致页错误的是内存写入;P(位 0)——若为 0,页错误由不存在的页面引起;若为 1,页错误由违反页保护引起。
中断控制传输:三种门描述符
IDT 的每一项称为一个"门(gate)"。与 GDT 不同(GDT 的表项叫描述符,且首项必须是 NULL 描述符),IDT 的第一项可以直接是一个门,这不是强制要求。IDT 可以包含以下三种门描述符中的任何一种:
- 任务门(Task Gate)——包含用于异常/中断处理程序任务的 TSS 段选择子;
- 中断门(Interrupt Gate)——包含段选择子和偏移量,处理器据此把程序执行转移到中断处理程序;
- 陷阱门(Trap Gate)——包含段选择子和偏移量,处理器据此把程序执行转移到异常处理程序。
中断门与陷阱门的唯一区别在于处理器处理IF标志的方式:经中断门进入处理程序时 CPU 会清除IF,从而屏蔽后续中断,直到iret返回时恢复;陷阱门不修改IF。关于这一差异以及中断处理的三步基本骨架(暂停当前任务 → 查找处理程序并转交控制权 → 恢复被中断进程),详见 中断和中断处理 Part 1。
门的一般格式(x86_64 的 16 字节门)
在 64 位模式下,IDT 的每一项是 16 字节(x86 下为 8 字节)。门的一般格式如下:
127 96 +-------------------------------------------------------------------------------+ | | | Reserved | | | +-------------------------------------------------------------------------------- 95 64 +-------------------------------------------------------------------------------+ | | | Offset 63..32 | | | +-------------------------------------------------------------------------------+ 63 48 47 46 44 42 39 34 32 +-------------------------------------------------------------------------------+ | | | D | | | | | | | | Offset 31..16 | P | P | 0 |Type |0 0 0 | 0 | 0 | IST | | | | L | | | | | | | -------------------------------------------------------------------------------+ 31 16 15 0 +-------------------------------------------------------------------------------+ | | | | Segment Selector | Offset 15..0 | | | | +-------------------------------------------------------------------------------+各字段说明:
Selector(位 16–31)——目标代码段的段选择子;Offset(位 0–15、32–47、64–95)——处理程序入口点的 64 位偏移量,被拆成三段存放;DPL(位 45–46)——描述符权限级别(Descriptor Privilege Level),0 为最高特权级,3 为用户级;P(位 47)——段存在标志(Present);IST(位 32–34)——中断堆栈表索引,x86_64新机制,用于无条件切换栈;TYPE(位 40–43)——描述符类型,可为 LDT 段描述符、TSS 描述符、调用门、中断门、陷阱门或任务门之一;- 其余为保留位,必须为 0。
Linux 内核中的gate_struct64
IDT描述符在 Linux 内核中由gate_struct64结构表示(仅适用于x86_64),它定义在 arch/x86/include/asm/desc_defs.h:
struct gate_struct64 { u16 offset_low; u16 segment; unsigned ist : 3, zero0 : 5, type : 5, dpl : 2, p : 1; u16 offset_middle; u32 offset_high; u32 zero1; } __attribute__((packed));可以看到该结构把 64 位处理程序地址拆成offset_low、offset_middle、offset_high三段,与上面门格式图中 Offset 的三个位置一一对应;segment对应 Segment Selector;ist、type、dpl、p是按位域紧凑排列的。__attribute__((packed))保证结构体严格按 16 字节布局,不做任何填充对齐。内核通过typedef struct gate_struct64 gate_desc;将gate_desc定义为 IDT 表项类型,整个 IDT 就是一个gate_desc idt_table[]数组。
任务门描述符:ldttss_desc64
任务门描述符不包含IST字段,其格式与中断/陷阱门不同,Linux 内核用ldttss_desc64结构表示:
struct ldttss_desc64 { u16 limit0; u16 base0; unsigned base1 : 8, type : 5, dpl : 2, p : 1; unsigned limit1 : 4, zero0 : 3, g : 1, base2 : 8; u32 base3; u32 zero1; } __attribute__((packed));该结构同样定义在 arch/x86/include/asm/desc_defs.h,其中limit0/limit1组合成 20 位段限长、g为粒度位、base0–base3组合成 64 位基地址——整体对应 LDT/TSS 描述符的经典布局,在 IDT 中被任务门引用时指向承担异常/中断处理任务的 TSS。
任务切换期间的异常
在传统 x86 模式下,任务切换在加载段选择子期间可能发生异常,页错误也可能在访问 TSS 时出现。在这些情况下,由硬件任务切换机构先从 TSS 加载新的任务状态,然后再触发适当的异常处理。
但需要特别注意:在长模式(long mode,x86_64)下,硬件任务切换机构被禁用,因此在任务切换期间不会发生此类异常。这也是 x86_64 上任务门实际上不再被用于常规中断处理、异常处理主要依赖中断门/陷阱门配合 IST 机制的原因之一。
中断描述符表的加载:IDTR、lidt与早期空 IDT
IDT 可以加载在线性地址空间的任意位置,x86 上要求 8 字节对齐,x86_64 上要求 16 字节对齐。IDT 的基址存储在一个特殊的寄存器——IDTR 中,与之配套的两条指令是:
LIDT——加载 IDT 基址到 IDTR;SIDT——把 IDTR 的内容读取/存储到指定操作数。
在 x86 上 IDTR 是 48 位寄存器,布局如下:
+-----------------------------------+----------------------+ | | | | Base address of the IDT | Limit of the IDT | | | | +-----------------------------------+----------------------+ 47 16 15 0Linux 内核启动早期(尚不需要响应任何中断时)用 NULL 门加载 IDT,见 arch/x86/boot/pm.c 的setup_idt:
static void setup_idt(void) { static const struct gdt_ptr null_idt = {0, 0}; asm volatile("lidtl %0" : : "m" (null_idt)); }其中gdt_ptr结构由u16 len和u32 ptr两个字段组成(共 48 位),与 IDTR 的"Limit + Base"布局完全一致;内核之所以复用gdt_ptr而没有单独定义idt_ptr,正是因为这个结构与 IDTR 相同而只是名称不同,没必要重复定义,详细讨论见 深入 Linux 内核中的中断。注意这里 32 位保护模式入口使用的是lidtl(32 位操作数形式)。
中断堆栈表(IST)与 x86_64 特殊栈
IST(Interrupt Stack Table)是x86_64引入的新机制,用于替代传统 x86 架构在响应中断时自动切换栈帧的机制。与传统模式相比,IST 在使能后可以无条件切换栈,并且只对与特定中断关联的 IDT 条目生效——并非所有中断都使用 IST,其他中断仍可沿用传统栈切换模式。
IST 机制在任务状态段(TSS)中提供了 7 个 IST 指针,每一个指针都可以被 IDT 中的中断门引用。当一个使用 IST 的中断发生时,硬件把IST索引读入rsp,自动切到对应的新栈,然后继续压入旧的ss、rsp、rflags、cs、rip(以及错误码)。在 64 位模式下,中断栈帧大小固定为 8 字节,入栈后的栈布局如下:
+---------------+ | | | SS | 40 | RSP | 32 | RFLAGS | 24 | CS | 16 | RIP | 8 | Error code | 0 | | +---------------+如果中断向量没有错误码,内核会压入一个虚拟错误码以保证栈的一致性。随后处理器从门描述符加载段选择子到CS,通过验证 GDT 中L位(第 21 位)确认目标代码是 64 位代码段,再从门描述符加载偏移到rip作为处理程序入口;处理程序结束后通过iret无条件弹出ss:rsp恢复被中断进程。
per-cpu 中断栈与irq_stack_union
x86_64 上每个活动的线程拥有一个由THREAD_SIZE定义的大内核栈(CONFIG_KASAN关闭时为 16KB,开启时为 32KB,KASAN 是运行时内存调试器)。除线程栈外,每个 CPU 还有 per-cpu 特殊栈,其中最典型的是供外部中断使用的中断栈:
#define IRQ_STACK_ORDER (2 + KASAN_STACK_ORDER) #define IRQ_STACK_SIZE (PAGE_SIZE << IRQ_STACK_ORDER)即 16KB。per-cpu 中断栈在 arch/x86/include/asm/processor.h 中用联合体irq_stack_union描述:
union irq_stack_union { char irq_stack[IRQ_STACK_SIZE]; struct { char gs_base[40]; unsigned long stack_canary; }; };irq_stack——16KB 的中断栈数组;gs_base——40 字节,gs寄存器总是指向irq_stack_union底部。x86_64 中 per-cpu 变量与 stack canary 共享gs寄存器:虽然段内存模式早已废弃,但内核可通过特殊模块寄存器(MSR)为fs/gs设置基址,MSR_GS_BASE(编号0xc0000101)配合wrmsr指令即可把gs指向 per-cpu 区域的开始;stack_canary——栈保护者(Stack canary),用于验证中断栈是否被修改。GCC要求 canary 位于固定偏移:x86_64 上gs值必须为 40,x86 上为 20,因此gs_base恰好是 40 字节。
irq_stack_union是 percpu 区域的第一个数据(System.map中__per_cpu_start与irq_stack_union同址)。内核用DECLARE_PER_CPU_FIRST(union irq_stack_union, irq_stack_union)声明它,并在 arch/x86/kernel/setup_percpu.c 的setup_per_cpu_areas中为每个可能 CPU 初始化irq_stack_ptr:
per_cpu(irq_stack_ptr, cpu) = per_cpu(irq_stack_union.irq_stack, cpu) + IRQ_STACK_SIZE - 64;irq_stack_ptr指向中断栈顶减去 64 字节的位置;另有 per-cpu 变量irq_count用于检查 CPU 是否已在中断栈上。栈 canary 的初始化由boot_init_stack_canary完成:它先用BUILD_BUG_ON(offsetof(union irq_stack_union, stack_canary) != 40)编译期校验偏移,再用随机数和时戳计数器(TSC)合成 canary 值并写入irq_stack_union.stack_canary,详见 深入 Linux 内核中的中断。
7 个 IST 入口与set_intr_gate_ist
每个 CPU 最多可有 7 个 IST per-cpu 入口,其中一部分包括:
#define DOUBLEFAULT_STACK 1 #define NMI_STACK 2 #define DEBUG_STACK 3 #define MCE_STACK 4所有需要 IST 切换新栈的中断门描述符都由set_intr_gate_ist函数初始化。例如 arch/x86/kernel/traps.c 的early_trap_init中:
void __init early_trap_init(void) { set_intr_gate_ist(X86_TRAP_DB, &debug, DEBUG_STACK); set_system_intr_gate_ist(X86_TRAP_BP, &int3, DEBUG_STACK); load_idt(&idt_descr); }这里为调试异常#DB和断点#BP设置了带DEBUG_STACKIST 索引的中断门:set_intr_gate_ist的 DPL 为 0(内核态),而set_system_intr_gate_ist的 DPL 为 0x3(用户态也可通过int3触发断点)。load_idt最终只是执行asm volatile("lidt %0"::"m" (*dtr))指令,其中idt_descr = { NR_VECTORS * 16 - 1, (unsigned long) idt_table }。异常/中断的汇编入口点则定义在 arch/x86/entry/entry_64.S,例如:
idtentry double_fault do_double_fault has_error_code=1 paranoid=2 read_cr2=1而nmi、double_fault等处理程序在 arch/x86/include/asm/traps.h 中声明为asmlinkage void nmi(void);、asmlinkage void double_fault(void);。
不可屏蔽中断(NMI):嵌套屏蔽与处理机制
NMI 是不能通过标准屏蔽技术忽略的硬件中断,产生方式有两种:外部硬件向 CPU 的 NMI 引脚置位,或处理器在系统总线/APIC 串行总线上收到投递模式为NMI的消息。NMI 的中断向量号为2,内核在trap_init中用set_intr_gate_ist(X86_TRAP_NMI, &nmi, NMI_STACK);为其设置带NMI_STACKIST 索引的中断门。
NMI 处理面临一个特殊的栈问题:CPU 在第一个 NMI 完成前不会执行另一个 NMI,而 NMI 处理程序以iret结束。如果 NMI 处理期间触发了页错误或断点等同样以iret返回的异常,CPU 会离开 NMI 上下文,从而允许新的 NMI 抢占正在运行的 NMI 处理程序——后到的 NMI 会覆盖被抢占 NMI 的栈顶,破坏栈数据。因此内核在 NMI 入口处先在栈上分配一个临时变量位来标记"当前是否正在执行 NMI":进入时压入1,若是嵌套 NMI(cs为内核段且标记已置位)则走特殊的嵌套处理路径,否则在first_nmi标签处建立新的标记。完整的nmi汇编实现(PARAVIRT_ADJUST_EXCEPTION_FRAME、pushq %rdx、cmpl $__KERNEL_CS, 16(%rsp)、jne first_nmi等)见 处理不可屏蔽中断。
Linux 内核的 IDT 编程 API
理解了 IDT 的硬件格式后,再看内核如何编程式地构建它。核心 API 集中在 arch/x86/include/asm/desc.h:
#define set_intr_gate(n, addr) \ do { \ BUG_ON((unsigned)n > 0xFF); \ _set_gate(n, GATE_INTERRUPT, (void *)addr, 0, 0, \ __KERNEL_CS); \ _trace_set_gate(n, GATE_INTERRUPT, (void *)trace_##addr, \ 0, 0, __KERNEL_CS); \ } while (0)BUG_ON先校验向量号 ≤ 255,随后_set_gate通过pack_gate填充gate_desc结构并写入idt_table(trace_*系列则写入trace_idt_table供 tracepoint 使用):
static inline void pack_gate(gate_desc *gate, unsigned type, unsigned long func, unsigned dpl, unsigned ist, unsigned seg) { gate->offset_low = PTR_LOW(func); gate->segment = __KERNEL_CS; gate->ist = ist; gate->p = 1; gate->dpl = dpl; gate->zero0 = 0; gate->zero1 = 0; gate->type = type; gate->offset_middle = PTR_MIDDLE(func); gate->offset_high = PTR_HIGH(func); }其中PTR_LOW、PTR_MIDDLE、PTR_HIGH三个宏分别截取处理程序地址的低 16 位、中间 16 位和高 32 位,正好填充gate_struct64的三段偏移字段;segment固定为内核代码段__KERNEL_CS,type可取GATE_INTERRUPT、GATE_TRAP、GATE_CALL、GATE_TASK之一。最终native_write_idt_entry通过memcpy(&idt[entry], gate, sizeof(*gate))把门复制进 IDT 数组。
有了这些 API,异常处理程序的真实实现才有落点。以 异常处理的实现 为例,Linux 用DO_ERROR宏批量生成divide_error、overflow、invalid_op、alignment_check等处理函数:
DO_ERROR(X86_TRAP_DE, SIGFPE, "divide error", divide_error) DO_ERROR(X86_TRAP_OF, SIGSEGV, "overflow", overflow) DO_ERROR(X86_TRAP_UD, SIGILL, "invalid opcode", invalid_op) DO_ERROR(X86_TRAP_TS, SIGSEGV, "invalid TSS", invalid_TSS)宏展开后每个处理函数形如dotraplinkage void do_##name(struct pt_regs *regs, long error_code),内部统一走do_error_trap→do_trap→ 向被中断进程发送信号(如除零发SIGFPE)的链路;若异常发生在内核态且无法修复,则调用die打印栈与寄存器信息并触发 kernel oops。
小结
中断描述符表是 x86/x86_64 中断处理的基础设施:处理器用向量号索引 IDT,门描述符中的段选择子与 64 位偏移指向处理程序入口,错误代码向处理程序提供出错细节,IST 机制则为 NMI、双重错误等关键异常提供独立栈。本文从硬件位的角度完整剖析了 IDT 的门格式、两类错误代码、三种门类型,并延伸到 Linux 内核的gate_struct64、pack_gate/set_intr_gateAPI、早期 IDT 加载、per-cpu 中断栈与 NMI 嵌套保护。与之配套的实操细节还可继续阅读本仓库的 中断和中断处理 Part 1(IDT 加载与 IST 概念)、早期中断和异常处理(早期 IDT 与缺页处理程序)、深入 Linux 内核中的中断(栈 canary 与early_trap_init)以及 处理不可屏蔽中断(NMI 完整实现)。
【免费下载链接】linux-insides-zh
Linux 内核揭秘
相关推荐
Linux内核揭秘:文件描述符管理的终极指南
Linux内核揭秘:文件描述符管理的终极指南 在Linux系统中,文件描述符是连接用户程序与内核资源的重要桥梁。无论是打开文件、创建管道还是建立网络连接,都离不
文档教程操作系统Linux 内核揭秘:中断与中断处理入门(Part 1)——从硬件事件到 IDT 与中断栈
Linux 内核揭秘:中断与中断处理入门(Part 1)——从硬件事件到 IDT 与中断栈 导读 中断(interrupt)是 Linux 内核与外部世界交互的
Linux generic IRQ 框架详解:中断描述符、流控处理器与 irq_chip 三层抽象
Linux generic IRQ 框架详解:中断描述符、流控处理器与 irq_chip 三层抽象 导读 本文以内核文档 Documentation/core
操作系统内核驱动驱动开发虚拟化嵌入式网络存储
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考