起因

上一篇拿齐家产、退出了启动服务,但 GDT/IDT 还是固件的——住在"别人留下的房子"里,位置内容不受控,指向的内存随时可能被复用。这篇接管 CPU 底层机制:重建 GDT、给安全中断配专用栈、装上自己的 IDT 和异常处理。这一篇做完,调度篇定义的 isr_safety/isr_discharge/isr_motion 才有地方挂。照例,细节讲到可以直接抄的程度。要重建的这几张表没有一样是新鲜东西,全是八十年代的层积:分段和描述符表是 80286 保护模式的遗产,386 引入分页后段机制退居幕后,x86-64 长模式干脆把基址和限长拍平,只留 FS/GS 还在干线程局部存储这类杂活;中断向量表从 8086 固定在地址 0 处的 1KB IVT,进化成 80286 起可以挪窝的 IDT;TSS 生来是硬件任务切换的载体,后来没有人真用硬件切任务,只剩 IST 专用栈这一个功能还在服役。重建它们不是因为它们好用,是 CPU 上电就只认这几张表。

一、重建 GDT:五个描述符

长模式下段基址和限长被"拍平"(系列前文讲过原理),但 GDT 必须存在且合法。全 ring 0 的单特权级设计(专用系统没有用户态,没有"应用害内核"的威胁模型,内存安全交给 Rust 编译期)让表缩到五项:

类型 属性 64 位描述符值
0 null 0
1 代码段 L=1, D=0, P=1, DPL=0, 类型=代码可读可执行 0x00AF9A000000FFFF
2 数据段 P=1, DPL=0, 类型=数据可读写 0x00CF92000000FFFF
3–4 TSS 16 字节描述符占两项 按基址和限长填

加载代码(lgdt 吃的是一个 10 字节伪描述符:16 位限长 + 64 位基址):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
static mut GDT: [u64; 5] = [0, 0x00AF9A000000FFFF, 0x00CF92000000FFFF, 0, 0];

#[repr(C, packed)]
struct Descriptor { limit: u16, base: u64 }

unsafe {
    let gdtr = Descriptor { limit: 8 * 5 - 1, base: GDT.as_ptr() as u64 };
    core::arch::asm!("lgdt [{}]", in(reg) &gdtr);
    // 数据段寄存器统一加载到新的数据段
    core::arch::asm!("mov ds, {0:x}", out(reg) 0x10);
    core::arch::asm!("mov es, {0:x}", out(reg) 0x10);
    core::arch::asm!("mov ss, {0:x}", out(reg) 0x10);
    // CS 只能靠远跳换:压新 CS + 目标地址,lretq
    core::arch::asm!(
        "push 0x8; lea {tmp}, [rip + 2f]; push {tmp}; lretq; 2:",
        tmp = out(reg) _,
    );
    core::arch::asm!("ltr {0:x}", in(reg) 0x18);   // 加载 TSS
}

二、TSS 与 IST:给安全中断配专属栈

64 位模式下 TSS 现实用途只剩两块:RSP0(特权级切换用,全 ring 0 用不上)和 IST1–IST7(每向量专用栈)。TSS 结构在长模式下是 rsp0rsp1rsp2 各 8 字节,然后是 ist1ist7 各 8 字节。用法:

1
2
3
4
5
6
7
8
9
#[repr(C, packed)]
struct Tss {
    reserved0: u32,
    rsp0: u64, rsp1: u64, rsp2: u64,
    reserved1: u64,
    ist: [u64; 7],        // ist[0]=IST1: 急停/断丝;ist[1]=IST2: 放电;ist[2]=IST3: 插补
    reserved2: [u64; 5],
    iopb_offset: u16,
}
  • IST1 → 安全联锁:哪怕主循环把内核栈写穿,安全中断照样进来——它不依赖任何当前栈,CPU 硬件直接切到 ist[0] 指向的栈
  • IST2 → 放电脉冲、IST3 → 插补节拍:互相打断也不踩对方的栈

栈内存从移交的内存映射里划,每栈 16KB,终身专用不进分配器。TSS 描述符(GDT 第 3–4 项)的限长填 sizeof::<Tss>() - 1,基址填 TSS 地址,ltr 0x18 加载。

三、IDT:256 项门 + 汇编桩 + Rust 分发表

每个 IDT 项(16 字节)的字节布局:

字节 内容
0–1 处理函数偏移低 16 位
2–3 段选择子(0x8,我们的代码段)
4 IST 索引(0 = 不用 IST,1–7 对应 ist[0]–ist[6])
5 标志(0x8E = present + 中断门 + DPL0)
6–7 偏移中 16 位
8–11 偏移高 32 位
12–15 保留 0

注意:中断门(0x8E)进入时自动关中断,陷阱门(0x8F)不关——硬实时 ISR 用中断门天然防重入,异常处理用哪种看是否要在处理中收中断。

汇编桩每个向量一份(宏批量生成),干两件事:压向量号、跳公共入口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
.macro ISR_VEC n
    .align 8
    push qword ptr 0          // 无错误码的向量:补一个假 0,栈布局对齐
    push qword ptr \n         // 向量号
    jmp isr_common
.endm
.macro ISR_VEC_EC n          // 有错误码的异常(#PF/#GP/#SS…):CPU 已先压真错误码
    .align 8
    push qword ptr \n
    jmp isr_common
.endm

isr_common:
    push rax; push rbx; push rcx; push rdx
    push rsi; push rdi; push rbp; push r8..r15
    mov rdi, [rsp + 15*8]      // 向量号作第一个参数(SysV 约定)
    call rust_dispatch         // 查 Rust 函数指针表调用
    pop r15..r8; pop rbp; pop rdi; pop rsi
    pop rdx; pop rcx; pop rbx; pop rax
    add rsp, 16                // 弹掉向量号 + 错误码(真的假的都在这)
    iretq

15 个通用寄存器压完,向量号正好躺在 rsp + 15*8 的位置——这个栈布局是汇编桩和 Rust 侧的契约,改一边必须同步另一边。这里有个天生的不对称:有错误码的异常(#PF/#GP)CPU 在进桩之前就自动压了一个错误码,硬件中断和多数异常没有这码。与其让公共出口分两条退路(add rsp, 16 / add rsp, 8,每个向量还得记住自己走哪条),不如在入口抹平——无错误码的宏先补压一个假 0,所有向量到达 isr_common 时栈长得一模一样,出口统一 add rsp, 16 弹掉"向量号 + 错误码"。配错的症状很干脆:iretq 从错位 8 字节的栈上取回垃圾返回地址,当场三重故障复位。

Rust 侧就是一张表:

1
2
3
4
5
6
static mut HANDLERS: [Option<fn(u64)>; 256] = [None; 256];

#[no_mangle]
extern "sysv64" fn rust_dispatch(vector: u64) {
    if let Some(h) = unsafe { HANDLERS[vector as usize] } { h(vector) }
}

注册即挂载——后面 APIC 篇的定时器、EHCI/NVMe 的设备中断都往这张表挂。异常的最低配置是 #PF(14)/#GP(13)/#DF(8) 各配一个 panic 处理器,把寄存器现场吐到串口(16550,端口 0x3F8,out 一个字节一个字节写,QEMU -serial stdio 直接看)——ExitBootServices 之后固件文字输出已死,串口是唯一的命根子。#DF 的门单独挂 IST4(ist[3]):双重故障爆发时主栈多半已经废了,专用栈是它最后一次说话的机会;IST4IST7 就留给这类系统级兜底,不跟安全/放电/插补的 13 混。

lidt 的加载跟 lgdt 同构(伪描述符:限长 = 256×16−1,基址 = IDT 地址)。

四、分页:直接沿用固件的 identity 映射

决策:不重建页表,“物理地址 = 虚拟地址"作为系统不变式写死。理由:identity 映射已覆盖全部内存和 MMIO;专用系统没有 ASLR/higher-half 需求;重建存在"切换瞬间新旧映射不一致"的裸奔窗口,零收益正风险。NX 位和缓存属性用固件设好的(帧缓冲改写合并属显示性能调优,显卡篇再说)。cli/sti 从这一刻起归我们管:移交刚完成时中断还关着,等 APIC 篇配置完再开。

五、编译器假设收尾

  • 双 ABI 边界固定在 efi_main 一处,内核内部统一 SysV
  • 关 red zone.cargo/config.toml 里写死:
1
2
[target.x86_64-unknown-uefi]
rustflags = ["-Zno-red-zone"]        # -Z 系列要 nightly

SysV 的 128 字节红线区在中断压帧时必被踩——症状是"某次中断返回后局部变量莫名变值”,极难定位,关掉免除这一整类栈损坏

  • 内核不写 SIMD,省掉 XSAVE 状态区管理;图形填充优化真要用,只放协作层并配上下文保存,ISR 永不碰

总结

实战清单:五项 GDT 写死值、lgdt+远跳换 CS、TSS 填三个 IST 专用栈、IDT 门布局 + 汇编桩宏 + Rust 分发表、lidt 加载、关 red zone、串口 panic 处理器接好。跑通标志:int 0x80 一类的软中断能进表里的处理函数并安全返回。下一篇给框架接上心跳和时钟——APIC、定时器、还有工业系统天天用的系统时间。

参考链接