FSCALE 是 8087 里最不起眼的指令之一:把浮点数按 2 的整数次幂缩放,比走完整浮点乘法快。它的数学含义就是"给指数加个整数"。

Ken Shirriff 在 Opcode Collective 里把这条指令的微码翻了个底朝天,结论是它用了 140 多条微指令、三层子程序调用。他原本以为这是个简单到可以拿来练手的指令。

8087 的规模

要理解为什么一条简单指令会这么复杂,得先知道 8087 承载了什么。

芯片 5mm × 6mm,中央那块大的就是微码 ROM,存着 1648 条微指令,控制整个芯片。左侧是微码引擎,负责步进、处理跳转和子程序调用。下半部分是数据通路,注意它是分裂的:16 位指数通路 加 64 位有效数字通路,两套独立硬件。

数据通路里有几个关键部件:

  • 指数 ROM:存各种常量
  • 指数转换器(exponent converter):检查指数、检测特殊值、在指数格式间转换。这是个专用电路,后面会看到它替微码干了很多活
  • 移位器(shifter):能把 64 位值左移或右移任意位数
  • 加法器:8087 计算的核心,乘法、除法、开方都在循环里用它
  • B 寄存器 / sum 寄存器:加法器的输入和输出
  • 八个栈寄存器 + 临时寄存器

内部数据一律是 80 位"临时实数"(temporary real):1 位符号 + 15 位指数 + 64 位有效数字。指数带 16383 的偏移量,所以真实指数范围是 −16382 到 16383。

每个栈寄存器还带一个对程序员不可见的 tag,取值是 valid / special / zero / empty。infinity、NaN、非规格化数统归为 special。这个 tag 是 FSCALE 微码里大量分支的来源。

微指令长什么样

理解微码前要先接受一个事实:这里有两层指令。程序员看到的是 FSCALE 这样的汇编指令,芯片内部执行的是 16 位宽的微指令。

前 3 位 = 类型 → 决定剩余 13 位的含义
类型1: transfer  寄存器间数据搬运(源/目标字段 + 3 位特殊标志)
类型2: shift     驱动桶形移位器左移/右移
类型3: adder     控制加法器(也能做减法)
misc:             栈指针操作、tag 修改、异常、子程序返回
far jump/call:    跳到微地址列表中的固定目标
local jump:       相对跳转到附近的微指令

条件字段支持多种条件跳转/调用/返回,最后一位用来取反条件。

FSCALE 的正常路径:22 条微指令

FSCALE 的微码从十进制地址 748 开始。核心思路很简单:第一个参数在栈顶,第二个参数是要缩放的 2 的幂次;把第二个参数转成整数,加到第一个参数的指数上。

正常路径的骨架(去掉特殊值分支):

#0748  st(0) -> tmpA          第一个参数从栈顶搬到 tmpA
#0754  expconst 0x403e        选择常量 403e
#0763  tmpB:exp -> Breg       第二个参数的指数进 B 寄存器
#0764  tmpB:sign,exp -> expConv
#0765  expConst -> tmpC       常量 403e 进 tmpC
#0766  adder: tmpC - Breg cin=1   403e - exp
#0767  sumreg:frac -> shiftcount  作为移位量
#0768  shift tmpB:frac R count byte bit   执行移位
#0769  shift R -> Breg        B 寄存器现在存的是整数形式的缩放量
#0771  adder: tmpA:exp + Breg cin=0       加指数
#0772  sumreg:frac -> expConv             交给指数转换器查溢出
#0773  sumreg:frac -> tmpA:exp
#0775  tmpA -> st(0)          结果写回栈顶
#0776  RNI (Run Next Instruction)

那个 0x403e 常量值得单独说。移位量是 63 减去指数。指数带 16383 偏移,所以实际要移的位数是 63 - (exp - 16383),化简就是 0x403e - exp(因为 63 + 16383 = 16446 = 0x403E)。一条看起来简单的浮点转整数,在这里展开了四五条微指令:把 0x403e 加载进 tmpC、用加法器做减法、结果存进移位控制寄存器、驱动移位器、再把结果读进 B 寄存器。

注意"激活部件"和"读取结果"是两个独立微指令——先 shift ...,再 shift R -> Breg。

即使在最顺的路径上,这也花了大约 22 条微指令。

SPECIAL_TMPS:处理空栈和特殊值

如果参数访问了空栈位置(下溢)或者是特殊值呢?交给一个叫 SPECIAL_TMPS 的子程序,起始地址 1484。

这个子程序是通用件,基本算术运算、FSCALE、FTST、FPREM 全都调用它。它的控制流相当绕,因为必须给优先级排序——比如一个参数是空的、另一个是非规格化数,得先处理哪个。

流程大意是:先把非规格化数(denorm)转成未规格化数(unnorm),然后检查空栈访问,再重新检查两个参数、遇到 NaN 就触发异常或中断。返回一个状态值表明参数类型。

其中有一段很有意思:

#1517  except:invalid
#1518  tmpB:frac -> Breg       两个参数都是 NaN,找更大的那个
#1519  adder: tmpA:frac - Breg cin=1
#1520  jmp #1522 if adder sign
#1521  tmpB -> tmpA            取较大的

如果两个参数都是 NaN,代码会比较它们并返回较大的那个。 这看起来很怪,但确实是有文档的行为。NaN 不是一个值,而是一个巨大的值族,设计者的意图是让程序员用不同的 NaN 标记不同位置的问题——比如给未初始化数组的每个位置放不同的 NaN,就能看出访问的是哪个下标。既然要区分,就得定义两个 NaN 运算的结果,Intel 选了"返回较大者"。

还有一处硬件小技巧:#1485 的 xchg tmp 微指令并不真的搬运数据,而是翻转一个触发器,交换 tmpA 和 tmpB 的含义。如果触发器置位,对 tmpA 的引用就落到 tmpB 上。8080 的 XCHG(交换 DE 和 HL)和 Z80 的 EX/EXX 用的是同一招。

CREATE_DENORM 与三个舍入位

非规格化数是 8087 里一个有争议的特性。当指数据已经到最小值 −16382,还想表示更小的数,就打破"有效数字以 1 开头"的规则,把有效数字右移,指数写成特殊值 0标记为 denorm。范围能再往下扩 2⁶³ 倍,代价是精度变差、运算明显变慢。

CREATE_DENORM 子程序(地址 522)干这件事。但它的微码里有个看着多余的步骤:

#0528  shift tmpA:frac R count byte bit    按指定量右移
#0529  shift R -> Breg                     结果进 Breg
#0530  shift tmpA:frac L ~count byte bit   又移回去
#0531  NOP                                 等移位器
#0532  rounding(h) -> Breg[grs]            三个舍入位存进 Breg

右移完为什么又左移回去?目的是取出舍入位。

浮点舍入比看起来难。1 + 0 向上舍入得 1,但 1 + 2⁻¹⁰⁰⁰⁰ 向上舍入应该比 1 略大——问题是在舍入前你不可能真的存几千个比特来做区分。

8087 的解法是保留三位:guard、round、sticky。把有效数字右侧的比特串看作"尾巴",guard 是尾部的最高位,round 是次高位,sticky 比较特殊——它是剩余所有位的 OR 结果,表示"后面还有没有 1"。这样 1 + 2⁻¹⁰⁰⁰⁰ 的 sticky 位置位,1 + 0 不置位,两者就能舍入到不同结果。硬件上这是一个 64 位 NOR 门并行测试尾部比特。

#0530 的这次左移,就是把最终会被丢弃的尾部比特全保留下来,再去掉最终有效数字里的位。第 62 位变成 guard,第 61 位变成 round,剩下 64 位 OR 起来生成 sticky。其实这个模式的核心思想是:移位时丢失的比特不能真的丢,得先反向移一次把它们捞回来。

ADJUST_PRECISION 与一个未文档化的条件码

虽然 8087 支持三种长度的浮点数,所有计算都用 80 位临时实数完成,最后一步才转成目标精度。ADJUST_PRECISION(地址 299)负责这一步,大多数算术指令都会调用它。

有意思的是微码和硬件的分工。四种舍入模式对一个有效数字只有两种可能的动作:截断,或者截断后加 1。这个判断被放进专用逻辑电路,去检查舍入位、舍入模式、符号。微码因此简化了,硬件变复杂了——这是典型的微码设计取舍。

#0299  jmp #0306 if not precision64
#0300  return if not round up, update CC1     ← 注意这里
#0301  adder: sumreg:frac + 0 cin=1           进位加 1 实现向上舍入
#0302  return if not sumreg[64]
#0303  Inf -> sumreg:frac,sign                溢出则返回 infinity

看 #0300 那条。它是个带条件的返回,同时还更新 8087 对程序员可见的条件码寄存器 CC1,标记结果是向上还是向下舍入的。

这就是 Shirriff 说的"隐藏特性":8087 有额外电路专门检测这个条件并写进条件码锁存器,但8087 的文档完全没提这件事,Intel 直到 1987 年的 387SX 才补上文档。

一条跳转指令顺手改了对程序员可见的寄存器状态,而且改的是没写进手册的状态——这种发现只能靠逐条读微码,从行为反推设计意图。

为什么一条指令会变成 140 条

把 FSCALE 的全部复杂度摊开看,它处理的不是"加个指数",而是:

  • 参数是 0(缩放 0 得 0,即使缩放因子是 NaN)
  • 缩放因子是 0(任何数缩放 0 倍不变)
  • 参数是空栈 → 栈下溢异常
  • 参数是 NaN / infinity / denorm → 走 SPECIAL_TMPS
  • 两个 NaN 比大小
  • 缩放量是负数 → 走减法路径(#0777: adder: tmpA:exp - Breg cin=1)
  • 结果溢出 → 是返回 infinity 还是最大浮点数,取决于四种舍入模式
  • 结果下溢 → 是返回 denorm 还是 ±0
  • infinity 模式是仿射还是投影(决定 infinity 带不带符号)
  • 中断被屏蔽还是未屏蔽——未屏蔽时要给中断处理器一个能放进指数域的、加上/减去 0x6000 偏移的指数

再加上非规格化数的舍入、精度调整、CC1 更新。这些特性两两组合,就膨胀成了 140 条微指令和三层调用。

William Kahan 主导的这套设计目标是数学上尽可能精确,代价就是复杂度。它最后变成了 IEEE 754 的基础,终结了各家浮点格式互不兼容的乱局。从 FSCALE 的微码里能看到这个目标的真实成本——每一条为正确性加上的规则,都要在微码里展开成显式的分支和子程序。

这也解释了现代 CPU 为什么不会这么干。今天的浮点单元是流水线化的硬件,不是跑 rom 里的微码,特殊情况多走几个周期的惩罚靠深度流水和推测执行摊平。8087 那 1648 条微指令放在 1970 年代末,是"用有限晶体管换最大正确性"的可行解。

一点看法

这类微码逆向文章的价值不在于 8087 本身——那块芯片已经四十多年了。有价值的是一套方法论:把芯片开盖、拍高分辨率图、读 ROM、把微指令逐条翻译成可读的控制流,最后从代码模式反推出设计决策和未文档化行为。

Shirriff 那个 xchg tmp 翻转触发器的小发现、CC1 未文档化的条件码更新,都是这么挖出来的。做固件逆向、bootrom 分析、或者任何"只有二进制没有源码"的场景,走的是同一条路:先建立指令/微指令的语义表,再让控制流自己暴露设计者的取舍。