[转载] Intel 8087 浮点芯片内部的微码:寄存器交换
原文出处: https://www.righto.com/2026/05/microcode-inside-intel-8087-floating.html
作者: Ken Shirriff
原文发布日期: 2026 年 5 月
许可协议: 原文未声明显式开源协议,本文为其中文翻译,所有权利归原文作者所有。原文配图已本地化托管。
1980 年,Intel 推出了 8087 浮点芯片——一款能让浮点运算速度提升最高 100 倍的协处理器。这颗芯片影响深远,如今绝大多数处理器采用的浮点标准都由 8087 开创。
8087 使用复杂的算法来精确计算平方根、正切、指数等函数。这些算法在芯片内部以一层名为「微码(microcode)」的低级代码实现。我所在的一个小组——Opcode Collective——正在对这套微码进行逆向工程。本文将聚焦其中一条指令 FXCH 的微码,解释它究竟是如何工作的。FXCH(Floating-point Exchange,浮点交换)指令用于交换两个浮点寄存器。你大概以为这条指令再简单不过了,但实际发生的事情比你想象的多——实现这次交换用到了 14 条微指令。

为了研究这套微码,我剖开了一颗 8087 芯片,用显微镜拍下高分辨率图像。巨大的微码 ROM 占据了芯片中央,存放着控制整颗芯片的微指令。左侧的微码引擎负责依次执行微指令,处理跳转和子程序调用。芯片下半部分是「数据通路(datapath)」,也就是执行浮点运算的电路;它被分成两条数据通路——一条是处理数字指数的 16 位通路,另一条是处理小数部分(也称有效数字 significand)的 64 位通路。

本文重点关注上图中标红的临时寄存器(temporary register)和栈寄存器(stack register)。芯片内部有两个临时寄存器和八个栈寄存器,每个都存放一个数字的指数和小数。每个寄存器还配有两个 tag(标签)位,用来标记寄存器里值的类型。右侧的栈控制电路负责管理这个栈——当数值被压入或弹出时,持续跟踪栈顶的位置。
8087 的微码
执行一条 8087 指令(比如反正切 arctan)需要几百个内部步骤才能算出结果。这些步骤由微码实现,每条微指令(micro-instruction)规定了算法中的一个步骤。(请注意这两个层级的指令:一层是程序员使用的汇编语言指令,另一层是芯片内部未公开的底层微指令。)微码 ROM 里存着 1648 条微指令,实现了 8087 的整套指令集。每条微指令长 16 位,执行一个简单的操作,比如在芯片内部搬运数据、把两个值相加、或者移位。我正与 Opcode Collective 一起逆向这些微指令,以完全理解这套微码(链接)。
8087 的微指令相当复杂,有大量边界情况和临时性的专门功能,但我先给出一个简化的概览。每条微指令由 16 个比特组成,如下图所示。前三个比特指定微指令的类型,决定了其余比特的含义。第一类是传送(transfer)操作,把数据从一个内部寄存器搬到另一个,两个字段分别指定数据的源和目的,剩下的三个比特用于各种特殊情况。接下来是移位(shift)操作,用桶形移位器(barrel shifter)把一个值左移或右移。第三类微指令使用加法器/减法器(adder/subtractor),也可以放在循环里实现乘法或除法。第四类是各种算术控制微指令,用来配置加法器、设置舍入模式等等。远跳转(far jump)和远调用(far call)微指令会跳转或子程序调用到一个固定列表里的微地址;条件字段允许基于众多条件进行条件跳转/调用,而最后一个比特用来反转条件。本地跳转(local jump)则允许向附近的一条微指令做条件跳转。最后是杂项(miscellaneous)类微指令,功能从子程序返回、抛出异常,直到结束微码执行。

数值在 8087 芯片内部如何存储
8087 支持多种数据类型:不同大小的浮点数、整数、以及二进制编码的十进制数(BCD)。但在芯片内部,一切都以 80 位浮点数的形式存储。一个数由三部分组成:64 位的有效数字(小数部分)、15 位的指数、以及一个符号位。芯片有两条独立的数据通路:一条给有效数字,一条给指数和符号。
芯片有八个寄存器用于在计算过程中存放数字,即下图上方的栈寄存器。然而这些寄存器的组织方式很不寻常:它们被组织成一个栈,数字被压入栈、弹出栈。你不是访问「3 号寄存器」,而是访问「从栈顶往下数第 3 个寄存器」,记作 ST(3);随着数值被压入或弹出,ST(3) 指向的实际寄存器也会变化。这种基于栈的架构本意是改善指令集、简化编译器设计、并让函数调用更高效——尽管实际效果并不如预期。

许多 8087 指令作用于栈顶。例如,平方根指令会把栈顶的值替换为它的平方根。但如果你想对栈中间的某个值开平方根呢?解决办法就是本文要聚焦的 FXCH 指令。这条指令把栈顶的值与栈中某个指定位置交换,从而让你能访问到栈内部的值。
关于 8087 还有一个特性对本讨论很重要:寄存器栈中的每个值都带有一个「tag(标签)」值,标明它是 valid(有效)、special(特殊)、zero(零)还是 empty(空)。一个「正常」的浮点值被标记为 valid。如果浮点值是无穷、Not a Number(非数)或非规格化值,则被标记为 special。零值被标记为 zero。最后,如果一个寄存器是空的(比如它的值已被弹出栈),就标记为 empty。8087 用 tag 来优化性能并检测错误。¹ 例如,如果程序员从栈里弹出过多的值,试图读取一个标记为 empty 的栈寄存器,8087 就会抛出「无效操作(invalid operation)」异常。
这八个栈寄存器对程序员是可见的,但 8087 内部还有供自己使用的临时寄存器。其中两个临时寄存器对本文很重要:tmpA 和 tmpB。和栈寄存器一样,每个临时寄存器都是一个 80 位寄存器,外加两个 tag 位。
FXCH 的微码
本节解释 FXCH 交换指令的微码是如何工作的。这条指令把栈顶寄存器与栈中某个指定位置的寄存器交换。如果其中任何一个寄存器为空,指令就会抛出「无效操作」异常,并用特殊值「Not a Number」(NaN)替换缺失的值。
这条指令的微码如下,由 14 条微指令组成。² 第一条微指令是一次传送,源是栈顶值 ST(0),目的是临时寄存器 A。源操作数的设定会让 64 位有效数字送上小数总线、16 位指数和符号送上指数总线、两个 tag 位送往 tag 电路;目的 tmpA 则让总线上的值存入该临时寄存器。于是,微指令中的比特位让这次传送得以发生。第三条微指令类似,但使用栈内部的一个寄存器 ST(i),其索引由机器指令指定。
FXCH 入口:
#0200 ST(0) -> tmpA 读取栈顶
#0201 nop 等待一个周期
#0202 ST(i) -> tmpB 读取指定的栈寄存器
#0203 if !(tmpA 或 tmpB 为空) jmp #0210 若两个寄存器都存在则跳转
#0204 set invalid exception 抛出无效异常
#0205 if (unmasked) jmp #0213 若需中断,则结束
#0206 if !(tmpA 为空) jmp #0208 检查 tmpA 是否为空
#0207 NaN -> tmpA 若是,把 NaN 送入 tmpA
#0208 if !(tmpB 为空) jmp #0210 检查 tmpB 是否为空
#0209 NaN -> tmpB 若是,把 NaN 送入 tmpB
正常路径与错误路径在此汇合:
#0210 tmpB -> ST(0) 把 tmpB 存入栈顶
#0211 nop 等待一个周期
#0212 tmpA -> ST(i) 把 tmpA 存入指定的栈寄存器
#0213 RNI 例程结束:运行下一条指令(Run Next Instruction)
#0214 nop 未使用
#0215 nop 未使用
#0216 nop 未使用
接下来,微地址 #0203 处的相对跳转展示了另一类微指令:条件跳转。这条微指令指定了一个条件——本例中是检测是否有任一临时寄存器为空(也就是说,硬件会检查与临时寄存器关联的 tag 位,看其中是否有「empty」标签)。这条微指令有一个比特位用来反转条件。最后,这条微指令带有一个 +6 的偏移量,从而得到跳转目标 #0210。用相对偏移量而不是给出完整微地址的好处是:偏移量只需要 6 个比特。(关于条件如何求值的更多细节,见我的文章《Conditions in the Intel 8087 floating-point chip’s microcode》。)
如果其中任一寄存器为空,下一条微指令就抛出「invalid」异常。正如下一节要解释的,你可以编程让 8087 在异常时要么触发中断、要么继续处理。再下一条指令是条件跳转,检测异常是否「未屏蔽(unmasked)」,表示已经触发了中断。此时微码结束,由主 8086 处理器来处理中断。
假设中断已被屏蔽,微码现在把空值替换为特殊的 Not a Number 值,先检查 tmpA 再检查 tmpB。源操作数 NaN 会让电路把指数总线拉成全 1、小数总线拉成全 0(最高两位除外)。这个特定的比特模式就表示 Not a Number。³
在微地址 #0210 处,空寄存器路径与正常路径汇合,把临时寄存器的值存回栈寄存器。真正的交换就发生在这里,因为 tmpA 和 tmpB 被写入了与读取时相反的栈位置。最后,RNI(Run Next Instruction,运行下一条指令)标志着这段微码例程的结束。它会停止微码引擎,让 8087 准备好执行下一条指令。
nop(空操作)微指令很有意思。每两次栈读取或写入之间都夹着一条 nop,大概是由于寄存器的时序约束。微码例程末尾还有三条 nop,之后才是下一段微码例程。这些指令看起来像是微码里被浪费的空间——也许 FXCH 的微码在开发期间被缩短了三个字,从而留下了这个空隙。
异常
8087 有一套复杂的异常系统来处理各种问题。异常分为六类:无效操作、非规格化操作、除零、上溢、下溢、精度。例如,对负数开平方、或对空寄存器执行操作,都会触发无效操作异常。当一个值大到无法表示时触发上溢异常,小到无法表示时触发下溢异常。除以零时触发除零异常。⁴ 当一个数无法精确表示为浮点数(这种情况极其常见)时,触发精度异常。最后,当一个值离零太近以至于无法以完整精度表示时,触发非规格化异常。
异常发生时会怎样?8087 允许程序员为每种异常类型选择处理方式。第一种选择是让异常触发 CPU 中断,由软件来处理问题——比如软件可以尝试绕过问题、记录错误,或干脆终止程序。另一种选择是程序员「屏蔽(mask)」某种异常。此时 8087 会以一种「合理」的方式继续执行操作。例如,上溢的值会被设为无穷,无效的值会被设为特殊值「Not a Number」(NaN);对于精度异常(比如 1/3),则对值进行舍入。8087 的设计者在屏蔽异常后如何以最佳方式继续执行上花了大量功夫,手册里用了好几页来罗列所有特殊情况。⁵
异常条件的处理在微码和硬件之间做了切分。例如,当 FXCH 微码检测到空寄存器时,它会执行一条 set invalid exception 微指令。这条微指令会置位一个表示无效异常的锁存器。8087 的控制寄存器里有六个屏蔽位,每种异常类型一个,用于阻断该类异常的中断。硬件把异常触发器的信号、控制寄存器里的屏蔽位、以及状态寄存器里的异常标志组合起来,判断是否触发了新的、未屏蔽的中断。如果是,8087 电路就向 8086 处理器发送中断。
反之,如果中断被屏蔽了,微码就继续执行。在 FXCH 的情况下,微码把空寄存器替换为 Not a Number 值。最后,微码例程以 RNI(Run Next Instruction)结束。这会触发许多硬件活动,但与异常相关的一项是:把异常触发器的状态复制到状态寄存器,这样如果程序员想检查就能看到异常位。当下一个 8087 指令开始时,异常触发器会被清零。由于触发器、状态寄存器、控制寄存器和中断线都由硬件管理,微码可以更简单、更小。
提取微码
8087 的微码 ROM 容量为 26368 比特,存放着 1648 条 16 位微指令。在当时,这是一个非常大的 ROM;为了把它塞进裸片,Intel 使用了一种特殊的 ROM——每个晶体管存放两个比特,容量是标准 ROM 的两倍。这种 ROM 是半模拟的,用四种尺寸的晶体管产生四个电压电平,再由比较器把电压电平转换成一对比特。

为了提取微码,我溶解掉金属层后拍下了 ROM 的高分辨率图像。Gloriouscow 用一个神经网络对每个晶体管的尺寸进行了分类。(你可以在这里探索完整的图像和晶体管。)下一步是确定如何把晶体管映射成比特。你也许以为晶体管的网格就直接对应微码比特的网格,但由于各种硬件优化,行和列被打乱、镜像了,我通过研究电路把这些理清了。最终得到了以一张 0 和 1 的表格形式表达的微码。
下一步是赋予微码以含义。对于 8086 处理器,专利提供了大量关于微码结构和硬件的细节,但 8087 的专利并没有解释微码。于是我们通过几种手段弄清楚了这些微指令:检查电路、在微码里寻找规律、以及思考指令可能如何实现。
微码通常都很复杂,而 8087 比大多数还更甚。8087 当年处于可能性的边缘,所以设计者在必要处求助于特殊情况和小技巧。例如,某些条件跳转会带有副作用,比如更新寄存器;还有一些指令会置位触发器,改变后续操作的行为。我们仍在继续努力,力求在硬件层面完全理解这些微指令。
我计划继续逆向 8087 的微码。如需更新,请在 Bluesky(@righto.com)、Mastodon(@[email protected])或 RSS 上关注我。我一直与「Opcode Collective」的成员们合作,尤其是 Smartest Blob 和 Gloriouscow——他们把 ROM 图像转换成了微码数据,并对内容做了大量分析。更多信息见 GitHub 上的 8087 仓库。
注释与参考文献
tag 通常对程序员是不可见的,但可以通过特殊操作访问。程序员可以把 8087 的状态转储到内存来访问 tag——它们存放在一个 16 位的「tag word(标签字)」里。↩
原始的 8087 微码由 Smartest Blob 解码,可在此获取。为清晰起见,我对微码格式做了一些调整。↩
8087 用一个特殊的「Not a Number」(NaN)值来表示坏值。系统允许许多种不同的 NaN 表示:任何指数为全 1、有效数字非零(有效数字为零表示负无穷)、且符号任意的值都是 NaN。对于无效操作,8087 使用一个特定的 NaN 值,称为 real indefinite(实数不定值)。对于内部的 80 位实数,这个值在内部把有效数字的最高两位置 1、其余置 0,而指数位和符号位全部置 1。(见手册第 87 页(S-73)和第 90 页(S-76)。)32 位或 64 位实数对 NaN 使用略有不同的比特模式;这些数字格式有一个隐含的「1」比特作为有效数字,所以 real indefinite NaN 只显式地设置一个有效数字比特。↩
除以零通常触发除零异常,但 0 ÷ 0 触发无效操作异常,而无穷 ÷ 0 是合法的,结果为无穷。这只是微码之所以如此复杂的众多原因之一。↩
关于 8087 异常的更多信息,见《8086 Family Numerics Supplement》。异常系统在第 32 页(S-18)描述;异常标志和异常屏蔽在第 24 页(S-10)描述;异常处理的细节在第 89 页(S-75)描述。↩