自制工业控制系统:CPU自举实战——重建GDT/IDT、TSS专用栈与中断框架
Contents
起因
上一篇拿齐家产、退出了启动服务,但 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 位基址):
|
|
二、TSS 与 IST:给安全中断配专属栈
64 位模式下 TSS 现实用途只剩两块:RSP0(特权级切换用,全 ring 0 用不上)和 IST1–IST7(每向量专用栈)。TSS 结构在长模式下是 rsp0、rsp1、rsp2 各 8 字节,然后是 ist1 到 ist7 各 8 字节。用法:
|
|
- 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 用中断门天然防重入,异常处理用哪种看是否要在处理中收中断。
汇编桩每个向量一份(宏批量生成),干两件事:压向量号、跳公共入口:
|
|
15 个通用寄存器压完,向量号正好躺在 rsp + 15*8 的位置——这个栈布局是汇编桩和 Rust 侧的契约,改一边必须同步另一边。这里有个天生的不对称:有错误码的异常(#PF/#GP)CPU 在进桩之前就自动压了一个错误码,硬件中断和多数异常没有这码。与其让公共出口分两条退路(add rsp, 16 / add rsp, 8,每个向量还得记住自己走哪条),不如在入口抹平——无错误码的宏先补压一个假 0,所有向量到达 isr_common 时栈长得一模一样,出口统一 add rsp, 16 弹掉"向量号 + 错误码"。配错的症状很干脆:iretq 从错位 8 字节的栈上取回垃圾返回地址,当场三重故障复位。
Rust 侧就是一张表:
|
|
注册即挂载——后面 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里写死:
|
|
SysV 的 128 字节红线区在中断压帧时必被踩——症状是"某次中断返回后局部变量莫名变值”,极难定位,关掉免除这一整类栈损坏
- 内核不写 SIMD,省掉 XSAVE 状态区管理;图形填充优化真要用,只放协作层并配上下文保存,ISR 永不碰
总结
实战清单:五项 GDT 写死值、lgdt+远跳换 CS、TSS 填三个 IST 专用栈、IDT 门布局 + 汇编桩宏 + Rust 分发表、lidt 加载、关 red zone、串口 panic 处理器接好。跑通标志:int 0x80 一类的软中断能进表里的处理函数并安全返回。下一篇给框架接上心跳和时钟——APIC、定时器、还有工业系统天天用的系统时间。
参考链接
- OSDev Wiki: GDT(含 64 位 TSS 描述符格式)/ TSS / IDT
- OSDev Wiki: Paging / Serial Ports
- 本系列第一篇:自制工业控制系统:从UEFI到内核;上一篇:UEFI固件层实战——手写FFI绑定与ExitBootServices权力交接
Author 软件开发大郭
LastMod 2026-09-11