80386 早期启动内存访问

Hacker News Top 新闻

摘要

本文解释了 Intel 80386 中的早期启动内存访问技术,该技术通过将地址生成与前一条指令的最后一个周期重叠来隐藏内存延迟。文章描述了该技术在 z386 FPGA 核心中的实现,达到了 ao486 级别的性能,并在 Doom FPS 上提升了 39%。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/23 16:45

# 80386 早期启动内存访问 来源:https://nand2mario.github.io/posts/2026/80386_early_start/ 英特尔在设计 80386 时,加入了一项隐藏内存延迟的技巧:**早期启动(Early Start)**。该技术并非等待指令到达其内存微操作,而是在当前指令的最后一个周期就开始下一条指令的地址工作——有效地址、段重定位、总线周期。英特尔称它贡献了大约 9% 的整体性能。它也是著名的 POPAD 错误的根源。 我在五月 (https://nand2mario.github.io/posts/2026/z386/) 发布的 Thez386 (https://github.com/nand2mario/z386) FPGA 核心运行了原始的 386 微码,但没有早期启动功能。在过去一个月里,我添加了该功能以及一系列其他优化,z386 现在达到了 ao486 级别的性能: | 核心 | Doom (FPS) | 3DBench | Landmark | |------|------------|---------|----------| | z386 0.1 (五月) | 16.6 | 33.7 | 147 | | **z386 0.4 (六月)** | **23.0** | **44.5** | **170** | | ao486 | 21.0 | 43.8 | 204 | Doom(原始、最高画质)提升了约 39%(16.6 → 23.0),超过了 ao486 的 21.0,而 16 位 3DBench 也略微超越了 ao486。开发板时钟频率与 v0.1 的 85 MHz 相同,因此性能提升完全来自降低 **CPI**,即每个时钟周期完成更多工作。按指令计算,z386 的周期数从远高于 80386 变为几乎在所有方面都等于或低于它: 指令时序:z386 vs 80386 指令时序:z386 0.1 → 0.4 与原始 80386 的对比。 本系列 (https://nand2mario.github.io/tags/386/) 早期的 内存流水线文章 (https://nand2mario.github.io/posts/2026/80386_memory_pipeline/) 中介绍了早期启动的概念。这篇文章将讨论如何用 FPGA 实现它,以及使 z386 达到同等性能水平的其余 CPI 工作。 英特尔在 Slager 的 ICCD '86 论文《80386 的性能优化》中讨论了早期启动。其工作原理的线索隐藏在微码中。以下是读取内存操作数的 ALU 指令(`ADD reg, [mem]`)的入口: ``` ; ADD/OR/ADC/SBB/AND/SUB/XOR m,r 04A EFLAGS -> FLAGSB FLGSBA RD 9 04B DLY 04C OPR_R -> TMPB WRITE_RESULT JMP UNL 04D TMPB SRCREG +-&|^ ``` 有趣的是,**第一条**微指令 `04A` 已经发出了 `RD`——它开始了内存读取。在此之前没有任何微指令计算有效地址、添加段基址或检查段限。地址生成是**隐式的**,由硬连线逻辑完成。一个具体的例子能更清楚地说明这一点: ``` add eax, 16 mov ebx, [eax+4] ``` 在执行顺序中,微码按下表运行。第 `023` 行执行 ALU(`EAX + 16`)并断言 `RNI`——“执行下一条指令”——因此机器已承诺接下来启动 `MOV r,m`。第 `024` 行将结果写回 `EAX`,而同一个 `024` 周期就是下一条指令(加载)的早期启动窗口: | 周期 | `add eax, 16` | `mov ebx, [eax+4]` | |------|---------------|---------------------| | 1 | `023`: ALU 中 `EAX + 16`,`RNI` | | | 2 | `024`: 写入 `EAX`(= old `EAX` + 16) | **早期启动窗口**:窥视下一条指令,转发刚产生的 `EAX`,计算 `EA = EAX + 4`,重定位,发出 `RD` | | 3 | | `019`: `RD` 微码 | | 4 | | `01A`: `DLY` 数据到达,写入 OPR_R | | 5 | | `01B`: `RNI` | | 6 | | `01C`: `OPR_R -> EBX` | 这种重叠使内存访问至少提前一个周期启动,从而减少了加载/存储延迟。微妙之处在于,上一条指令的最后一条微指令可能写回一个寄存器,从而产生数据冒险。这里 `EAX` 正是在那个周期内被**写入**,因此其新值尚不在寄存器文件中。解决办法是常规的——一个转发网络,以便早期启动能看到最新值。80386DX 的转发网络有一个边界情况错误,导致了著名的 **POPAD bug**:当 `POPAD` 之后紧跟着一条使用 `[EAX+...]` 的指令时,早期启动机制会转发错误的值。 另一种理解早期启动的方式是:在宏指令粒度上进行粗粒度流水线操作,上一条指令的最后一个周期(RNI 延迟槽)是该指令的写回阶段,它与下一条指令的第一个周期(早期启动周期)重叠。 ## 实现早期启动 z386 通过一个小的生命周期来追踪每条指令。这里相关的两个事件是 **`i_pop`**——指令从预取队列中提取的周期,也就是*上一条*指令的 `RNI` 延迟槽——以及 **`i_first`**,它自身微码的第一个周期。`i_pop` 正是上述第 2 周期中 386 的早期启动窗口。 因此,在 z386 中,早期启动就是:**在 `i_pop` 时组合性地计算有效地址和线性地址,并转发正在执行的寄存器写入。** 解码器产生基址/变址/位移选择器,并且: ``` wire [31:0] ea_early = calc_ea_core(fwd_onehot_gpr(ea_dec_base_sel_r), fwd_onehot_gpr(ea_dec_index_sel_r), ...); ``` `fwd_onehot_gpr` 是旁路。如果上一条指令的延迟槽写回目标指向 EA 的基址或变址寄存器,它会用写回值(`dest_value`)替换寄存器文件中的副本——分别处理字节、字和双字写入,因为部分写入只更新寄存器的部分: ``` FWD_BLO: fwd_onehot_gpr = {cur[31:8], dest_value[7:0]}; // AL FWD_W: fwd_onehot_gpr = {cur[31:16], dest_value[15:0]}; // AX default: fwd_onehot_gpr = dest_value; // EAX ``` 栈指针通过 `forwarded_esp` 获得相同处理,因此紧跟调整 `ESP` 的指令之后的 `push` 仍能看到新值。`ea_early` 在 `i_pop` 时被寄存到 `ea_reg` 中,为 `i_first` 时的加载/存储微码做好准备。从功能上讲,这完全等同于 386 的硬连线 EA 生成器,并且它重现了相同的转发边界情况——包括 POPAD 的那个——微码安静地依赖于这些情况。 在转发后的 GPR 和栈指针准备就绪后,早期启动周期计算有效地址,然后进行重定位(添加段基址)以产生线性地址。这条路径被证明是一个时序热点——为了将其长度控制在周期预算内,经历了多次迭代。 ## 进一步加速内存访问 早期启动带来的约 9% 提升很重要,但要超过 30% 需要更多工作——其中大部分是缩短内存访问流水线: **收紧存储队列**。存储操作需要 3 个周期,而 386 仅需 2 个。在 CPU 中减少写入延迟的常用方法是*存储队列*:CPU 不直接写入内存,而是将待处理的写入缓冲在一个小型队列中。z386 已经有一个 3 条目的存储队列,但其接口过于保守,浪费了一个周期。提前释放延迟(`DLY` 微操作)可以恢复那个周期。 **在 i_first 时发出读/写**。`i_first` 是指令的第一个周期,通过早期启动,大多数读取和写入自然在这里发出。但旧的内存流水线有时会将 TLB 查找和内存/缓存请求拆分成两个周期。将它们都合并到 `i_first` 周期中,可以在早期启动的基础上再节省一个周期。 以下是内存流水线工作的示例,如果缓存命中则没有停顿: ``` ; ADD/OR/ADC/SBB/AND/SUB/XOR r,m i_pop forward GPR, set IND(early-EA), relocate 027 RD TLB, cache request 028 DLY tag compare, write OPR_R 029 OPR_R->TMPB OPR_R ready 02A RNI 02B SIGMA->DSTREG ``` **拆分缓存**。在尝试缓存几何结构后,我发现 16KB+16KB 的拆分设计效果最佳——是 ao486 缓存大小的两倍,并且通过新的内存流水线,一个更简单的 PIPT(物理索引、物理标记)缓存更适合,因此我也采用了这种设计。拆分缓存利用了这样一个事实:代码(由预取器读取)和数据(由数据路径读取)很少重叠;而指令缓存是只读的,面积效率更高。一个复杂之处在于通过监听协议保持两者的一致性——当程序被加载,或更罕见地,当程序修改自身时,代码*确实*由数据路径写入。 ## 早期分支重定向 z386 的目标*不是* 100% 的 80386 周期精确性。相反,目标是精确的行为,而原始微码完成了大部分工作。当一点点额外的逻辑能带来大量性能提升,或者当 FPGA 原语能承担繁重工作时,我会选择更快的设计。乘法器就是一个例子,其中 FPGA DSP 模块节省了大量周期。早期分支重定向则是一个用少量面积实现比 386 更快的例子。 对于直接相对分支,如 `jmp rel`、`call rel` 或已取用的 `jcc`,目标就是 `EIP + 位移量`,在解码时完全已知,没有寄存器或内存依赖。这些通常对性能至关重要,然而 386 只在微码解析分支后才重定向预取器。因此,z386 现在在 `i_first` 时早期计算目标并立即重定向。 这不是分支预测。`jcc` 条件在 `i_first` 时根据已稳定的标志位解析,因此目标是精确的。它只是更早地开始重填。一条已取用的 `jnz`/`jmp` 现在只需 **6** 个周期,远低于 386 的 9.25 个周期,这对 CPI 有显著帮助。 ## 更宽的前端 这是一个有趣的部分。在 CPI 优化的过程中,很明显一个主要瓶颈已经转移到了*前端*:解码队列在每次分支取用后变空,而解码队列空置是 #1 停顿原因,约占 20% 的周期。由于执行侧现在快得多,它消耗解码指令的速度超过了前端供应的能力——仅 Dhrystone 就需要约 35,000 次分支刷新,每次约 6 个周期,而其中大部分周期都是前端在追赶。 因此,我重建了前端,使其更宽更浅。内存和执行之间有两个队列——一个由原始指令字节组成的*预取队列*,和一个由执行单元弹出的*解码指令队列*——中间有一个解码器。三者都变得更快了。 **一个 32 字节的预取队列,每次重填一整行。** 该队列由八个 32 位字(32 字节)的原始代码组成,并且在指令缓存命中时,一个完整的 **16 字节行**在单个周期内写入队列——一次最多四个队列字。关键在于重填*带宽*:分支刷新队列后,它能在几个周期内回填完毕,而不是一次一个双字地缓慢输入。 **一个单周期结构解码器。** 解码器查看队列头部的 4 字节窗口(`操作码, modrm, sib, ...`),在常见情况下,在一个周期内*组合性地*产生一条解码指令。仅操作码和操作码+ModR/M 形式一起解码。只有较罕见的操作码+ModR/M+SIB 形式需要第二个周期。因此,解码指令队列的填充速度与预取器传递字节的速度一样快;没有深度解码流水线需要在每次分支时清空和重填。 这基本上是将预取器和解码器移向 486 前端设计。486 解码器(D1)可以在单个周期内解码“最多 3 个指令字节”(《i486 CPU:一个时钟周期执行指令》(https://dl.acm.org/doi/abs/10.1109/40.46766)),包括操作码+modrm+sib 的情况。所以 z386 仍然稍慢一些。 这些优化一起将解码队列空置率从约 20% 降低到了不到 10% 的周期。 ## 维持高时钟频率 优化会消耗面积,更糟糕的是,可能损害 Fmax——而如果时钟下降太多,优化就适得其反了。z386 在完成上述所有优化后保持了时钟频率,在板上以 85 MHz 运行,与 v0.1 相同。 有一种时序技术值得讨论:**加法器进位链融合**。原始的 386 对“复杂有效地址”有一个特殊情况。当所有三个项都存在时——`EA = base + index<<scale + disp`——它必须分两个周期计算,而不是一个,因为相同的组合路径还承载了一个用于段重定位的 32 位加法器,而三个 32 位加法器串联起来对于一个时钟来说太慢了。DE10-Nano 上的幸运之处在于,Altera ALM(FPGA 逻辑单元)通过“共享算术链”支持快速的 3 输入加法器:三输入加法使用一条进位链,仅比二输入加法稍慢。利用这一点,z386 在一个周期内计算复杂 EA,且不损失时钟速度。 除此之外,稳定 85 MHz 还需要一系列较小的清理工作,每条都缩短了一个路径:在 ROM 的输出寄存器周期中预解码微码控制位,使它们不在执行路径上解码;复制寄存器以打破大的扇出;将微码停顿逻辑从约 5 级 LUT 扁平化到约 2 级。这些不会改变逻辑行为,但对于维持 CPI 改进是必要的。 ## 结论 80386 的早期启动特性是延迟隐藏设计的一个好例子。它用少量转发逻辑换取了 9% 的性能提升,可被视为 486 五级流水线设计的先驱。 在实现方面,z386 0.1 完成了使 386 微码能够(基本)正确执行所需的基本机制。我项目的另一个目标始终是让它与另一个开源 x86 核心 ao486 一样快——或者更快——并且我认为 z386 0.4 达到了这一目标。因此,现在有两个快速的开源 x86 核心:一个流水线式(ao486)和一个非流水线式(z386)。理论上流水线应该更优,但考虑到 x86 的复杂性,很难做到既正确又高度优化。 在正确性方面,z386 目前尚未启动 Windows——没有根本性的理由不能,只是 x86 很复杂。因此非常欢迎帮助 (https://github.com/nand2mario/z386) 修复错误。也请向 z386_MiSTer (https://github.com/nand2mario/z386_MiSTer) 报告游戏兼容性问题,因为这些问题也有助于改进 CPU 核心。 感谢阅读。您可以在 X (@nand2mario (https://x.com/nand2mario)) 上关注我获取更新,或使用 RSS (https://nand2mario.github.io/feed.xml)。 ## 致谢 本文中对 80386 的分析借鉴了 reenigne (https://www.reenigne.org/blog/)、gloriouscow (https://github.com/dbalsom)、smartest blob (https://github.com/a-mcego) 和 Ken Shirriff (https://www.righto.com/) 的微码反汇编和芯片逆向工程工作。

相似文章

z386:基于原始微码构建的开源80386

Hacker News Top

本文详述了z386,一款基于原始Intel微码构建的开源FPGA 80386 CPU。它能引导DOS 6/7、运行保护模式程序,并玩经典游戏如Doom,既是一种教育性重构,也是一个可用的FPGA CPU。

80386 微码反汇编

Hacker News Top

一篇博客文章,详细介绍了成功反汇编和分析 Intel 80386 微码的过程,揭示了215条指令入口点以及其复杂的内部架构。

8086分段内存是一个好主意

Hacker News Top

一项回顾性分析认为,8086分段内存架构是一个巧妙的设计,本可以优雅地扩展,但软件开发者坚持将内存视为平坦空间,导致了其被认为存在缺陷。