Intel 8087 浮点芯片的微码解析:FSCALE 指令
上世纪 70 年代,浮点运算一片混乱:各家计算机厂商有十几套互不兼容的运算标准。而且,当时的浮点系统设计以硬件实现简单为先,而不是数学上的严谨性,由此带来了数值稳定性方面的种种问题。1980 年 Intel 推出 8087 浮点协处理器芯片,情况才发生改变——它追求尽可能高的精度,即使是极端情况也不例外。8087 之所以流行,是因为它可以装进 IBM PC,让从电子表格到 CAD 等各类应用中的浮点运算速度提升上百倍。更重要的是,如今大多数计算机所采用的浮点标准,正是源于 8087。
8087 用一种称为微码的复杂底层代码来实现它的指令。我是 Opcode Collective 这个逆向工程小组的一员,最近在解析这些微码上取得了一些进展。本文将分析 8087 的其中一条指令——FSCALE——的微码,并说明它是如何工作的。FSCALE(Floating-point Scale,浮点缩放)指令提供了一种把数值按 2 的幂次快速缩放的方法,比乘法快得多。我原本以为 FSCALE 是条简单到近乎平凡的指令,理解和讲解起来都应该轻而易举。剧透一下:它并不简单。FSCALE 用了超过 140 条微指令和三层子程序调用,来处理各种特殊情况。不过,FSCALE 的微码恰好展示了 8087 的许多有趣部件,比如移位器、加法器和指数转换器,还揭示了一个隐藏特性,希望你会觉得有意思。
为了研究微码,我拆开了一颗 8087 芯片,用显微镜拍摄了高分辨率照片。芯片中央是巨大的微码 ROM,存放着控制芯片的 1648 条微指令。左侧的微码引擎负责逐条执行微码,处理跳转和子程序调用。芯片的下半部分是"数据通路",即执行浮点运算的电路;它分为两部分:16 位的数据通路处理数值的指数,64 位的数据通路处理数值的尾数(也称小数部分)。
英特尔 8087 浮点单元芯片的裸片,主要功能模块已做标注。裸片尺寸为 5mm×6mm,点击查看大图。
放大芯片底部,可以看到数据通路电路;下文高亮显示了相关部分。1 指数 ROM 存储着各种常量。 指数转换器是一种专用电路,用于检测特殊值、检查指数,并在不同的指数格式间进行转换。2 移位器是一个大型组件,允许对 64 位3数值进行任意位数的左移或右移。(我曾撰文介绍过 8087 的移位器电路这里。) 加法器是 8087 计算的核心;它在乘法、除法和开方运算中通过循环使用。 B 寄存器保存加法器的一个输入,而多个源可以提供另一个输入。和寄存器保存加法器的输出。 八个堆栈寄存器和临时寄存器用于存放浮点数。
8087 数据通路的特写,展示了 FSCALE 指令使用的功能模块。
8087 的细节
本节将介绍 8087 中几项对 FSCALE 微代码至关重要的特性。
使用 8087 时,程序员将数值存入其八个内部寄存器,这些寄存器按堆栈组织。每个寄存器保存一个 80 位的浮点数。
为优化性能,堆栈中每个数值都关联一个“标签”(tag),该标签对程序员大多不可见。4
标签将数值标记为有效(valid)、特殊(special)、零(zero)或空(empty)。
正常的浮点数值标记为 有效。若浮点数值是无穷大、非数(NaN)或非规格化值,则标记为 特殊。
零值标记为 零。
最后,若寄存器为空(例如数值已出栈),则该寄存器标记为 空。
8087 还拥有一些内部使用的临时寄存器:tmpA、tmpB 和 tmpC。
与堆栈寄存器类似,tmpA 和 tmpB 也是 80 位寄存器,并附带两个标签位。然而,tmpC 仅保存 64 位尾数。
8087 支持多种数据类型:不同大小的浮点数、整数以及二进制编码十进制数。
但在内部,所有数据均存储为一种称为“临时实数”(temporary real)的 80 位浮点数。在本文后续内容中,我将只讨论临时实数。
一个数字由三部分组成:符号位、15 位指数和 64 位尾数(即小数部分)。
通常情况下,浮点数由 符号 × 尾数 × 2exponent 表示。
尾数是一个 64 位二进制数,形式为 1.bbb...:
以 1 开头,接着是小数点(二进制的点,相当于十进制的小数点),然后是剩余的比特位。5
浮点数的实用之处在于其范围极广,从极小值到天文数字都能覆盖,这得益于指数,其范围从 -16382 到 16383。
一个重要细节是,存储的指数加上了 16383 的“偏置”(bias)。因此,即使实际指数为负,存储的指数始终为正。6
80 位临时实数格式。三角形表示二进制小数点,类似于十进制的小数点。图片来源:Intel Numerics Supplement。
8087 支持几种以特殊指数表示的特殊数值类型,如下所示。零和无穷大都分正负两种。"非数"(NaN)表示没有意义的值,比如 0/0 或 sqrt(-1);NaN 的表示形式有很多种,并不是单一的一个值。此外,8087 还支持非规格化(denormalized)和非规范化(unnormalized)数值,即有效数字没有前导 1 的极小数值。
特殊数值的编码方式。根据 Intel Numerics Supplement 中的表 S-31 绘制,并做了大幅简化。"x" 位为任意值,只要不与其他类型冲突即可。
8087 拥有一套复杂的异常处理机制,包含六种异常类型,用于指示算术运算是否出错。其中最严重的是“无效操作”异常,表示运算本身无意义,例如 0/0 或 ∞-∞。该异常也涵盖对空寄存器的访问(如堆栈溢出或下溢)以及对 NaN 值的操作。此外,如果数值过大无法存储,会触发溢出异常;数值过小则触发下溢异常;除以零(不包括 0/0)会触发除零异常。非规格化操作数异常表示结果太小,无法以规格化数值存储,但可以以非规格化数值形式保存。最后,精度异常表明数值无法精确表示,必须进行舍入。(精度异常非常普遍,即便是 1/10 也会产生此类异常。)
8087 通过控制寄存器中的位,对每种异常类型提供细粒度控制。如果某异常被设为未屏蔽状态,8087 会向 8086 处理器发送中断,由软件处理该问题,例如终止程序或记录错误日志。相反,如果异常被设为已屏蔽,8087 将尽可能继续执行。例如,无效结果会被替换为 NaN,而溢出或除零结果则会被替换为无穷大。精度异常则会导致舍入。屏蔽异常的意义在于让计算得以继续,提供尽可能准确的答案;在大多数情况下,这正是程序员所期望的。
这些特性使 8087 既灵活又精确,但也让微代码变得更加复杂,因为必须妥善处理各种特殊情况的组合。
8087 的微代码
执行一条 8087 指令可能需要数百个内部步骤来计算结果。这些步骤通过微指令实现的微代码来完成,每条微指令指定算法中的一个具体步骤。(需注意指令的两个层级:程序员使用的汇编语言指令,以及芯片内部未公开的低级微指令。)微代码 ROM 中存储了实现 8087 指令集的 1648 条微指令。我正在与 Opcode Collective 合作,逆向工程这些微指令,以全面理解微代码结构(链接)。
8087 的微指令比较复杂,涉及诸多边界情况和临时函数,这里先做一个简化的概述。 每条微指令由 16 位组成,结构如下所示。 前 3 位指定微指令的类型,从而决定其余各字的含义。 第一种类型是传输操作,用于将数据从一个内部寄存器传到另一个。 两个字段分别指定源和目标,剩余的 3 位则用于处理各种特殊情况。 第二种是移位操作,利用桶形移位器对数值进行左移或右移。 第三种微指令控制加法器(也可以执行减法)。 杂项指令包括栈指针操作、状态标签修改、异常处理和子程序返回。 远跳转和远调用微指令会跳转到固定列表中的目标微地址,执行跳转或子程序调用。 条件字段允许根据多种条件执行有条件的跳转/调用/返回,最后一位用于反转该条件。 局部跳转则是相对于当前位置跳转到附近的微指令。
FSCALE 的微代码
当 8087 开始执行一条指令时,指令译码器电路会确定该指令对应微代码的起始地址。
这个 11 位地址被加载到微代码引擎中,微代码便开始执行。7
下方展示了 FSCALE 的微代码,其起始十进制地址为 748。8
FSCALE 的思路很直白:想对一个浮点数乘以 2N(N 为整数),
只需把 N 加到该数的指数上。
这比完整的浮点乘法运算快得多。但 FSCALE 的微码出人意料地复杂,用到了好几个微码子程序。简单来说,微码先检查参数是否为零,再处理其他特殊参数,然后把缩放参数转成整数加到指数上,最后处理可能的溢出或下溢。
更详细地看,
微码例程一开始会把栈顶(st(0))的第一个参数移入 tmpA 临时寄存器。
如果该参数为零,例程立即返回(也就是说,0 乘以任何数的 2 的幂——哪怕是 NaN——结果都是 0)。
接着,栈中的第二个值(第二个参数)被移入 tmpB 临时寄存器。
同样,如果这个值是 0,代码直接返回,所以乘以 20 不会改变原值。9
然后选取一个常量值;选取常量和使用常量是两条独立的微指令。
(8087 有专门的 ROM 分别存放 16 位指数常量和 67 位尾数常量,这里用的是指数常量。)
正常情况下,执行会跳转到地址 #0763,跳过对子程序 SPECIAL_TMPS 的调用。
FSCALE: #0748 st(0) -> tmpA 从栈顶取输入参数 #0749 jmp #0776 if tmpA:tag ZERO 若为 0 则退出 #0750 stackPtr++ #0751 st(0) -> tmpB 从 stack(1) 取缩放参数 #0752 stackPtr-- #0753 jmp #0776 if tmpB:tag ZERO 若为 0 则退出 #0754 expconst 0x403e 常量 403e:指数偏移,用于转换为整数 #0755 jmp #0763 if not tmp empty/special/div #0756 call SPECIAL_TMPS 特殊处理 #0757 jmp #0762 if flag #0758 jmp #0761 if not tmpB:tag SPECIAL #0059 except:invalid 无效异常,使用 NaN #0760 NaN -> tmpA #0761 jmp #0776 if intr #0762 jmp #0775 if expConv 若 expConv 置位则返回 tmpA,否则继续 #0763 tmpB:exp -> Breg 正常路径 #0764 tmpB:sign,exp -> expConv ExpConv 将测试 tmpB 的符号 #0765 expConst -> tmpC 常量 403e #0766 adder: tmpC - Breg cin=1 403e-指数,即转换为整数所需的移位量 #0767 sumreg:frac -> shiftcount 存入移位控制寄存器 #0768 shift tmpB:frac R count byte bit 执行移位 #0769 shift R -> Breg Breg 保存缩放参数(整数形式) #0770 jmp #0777 if neg 负 Breg 需单独处理 #0771 adder: tmpA:exp + Breg cin=0 将缩放值加到指数上 #0772 sumreg:frac -> expConv 结果存入 expConv 以便检查 #0773 sumreg:frac -> tmpA:exp 更新指数为求和结果 #0774 call NONNORMAL_RESULT if not exp normal 处理溢出/下溢 #0775 tmpA -> st(0) 将结果写回栈 #0776 RNI 完成:执行下一条指令 #0777 adder: tmpA:exp - Breg cin=1 减去 Breg #0778 jmp #0772 继续处理
继续第 #0763 行,第二个参数从浮点数转换为整数,这需要几个步骤。
假设参数是 9,其浮点表示为 1.001×23。
尾数位 1000 是“左对齐”的,但要表示整数,这些位需要通过右移变为“右对齐”。
一般来说,如果指数是 n,尾数需要右移 63-n 位。
但别忘了指数有 16383 的偏置值。因此,尾数必须右移 63-(exp-16383) 位,即 0x403e-exp 位。
(这也解释了微代码中出现的常量 0x403e。)
在微代码中,减法需要分好几步完成。
在 #0763 处,第二个操作数的指数被移入 B 寄存器(加法器的一个输入端,与 tmpB 完全不同)。10
接着,符号位和指数被移入指数转换器——该电路除了其他功能外,还负责检测溢出。
随后,常量 0x403e(早在 #0754 处已选定)被移入 tmpC 寄存器。
在 #0766 处,加法器被激活,用常量减去指数。11
加法器将结果存入求和寄存器,随后该值被复制到移位计数寄存器,以控制移位器。
这个值指明了将第二个操作数转换为整数所需的移位位数。
在 #0768 处,移位器被激活以执行所需移位,同时调用位移位和字节移位两部分。
与加法器类似,激活移位器和读取结果是两条独立的微指令;结果最终存入 B 寄存器。
FSCALE 指令的核心操作终于在 #0771 处执行:将第二个操作数加到第一个操作数的指数上。
加法器被激活,将 B 寄存器中的值(缩放因子)加到指数上,更新后的值存入 tmpA 的指数位置。
(若缩放因子为负,则通过 #0777 路径执行减法。)12
该值同时被送往指数转换器电路,检查指数是否溢出或下溢;若发生,则调用子例程 NONNORMAL_RESULT。
但在正常情况下,更新后的值从 tmpA 复制到栈顶寄存器 st(0)。
最后,RNI(Run Next Instruction)表示微代码例程结束,指令执行完毕。
因此,即使在最简单的情况下,FSCALE 也需要大约 22 条微指令。
处理空值或特殊参数
如果参数访问了空的栈位置(即栈下溢),或者是一个特殊值(无穷大、非规格化数、NaN),会发生什么?
这些情况由一个微子程序处理,我把它称为 SPECIAL_TMPS15,因为它处理 tmpA 和/或 tmpB 中的特殊值。
这是一个通用例程,基本算术运算、FSCALE、FTST(测试)和 FPREM(部分余数)都会用到。
SPECIAL_TMPS 的控制流相当绕,因为代码必须对问题排定优先级——比如一个参数是空值、另一个是非规格化数的情况。
这里只做个简要总结,细节见脚注13。
首先,该子程序把所有非规格化数转换为 unnorm。然后检查是否访问了空的栈位置,如果是则触发异常或中断。
接着再次检查两个参数。只要其中一个是 NaN,就触发异常或中断;否则返回一个状态,标明参数的类型。
出乎意料的是,如果两个参数都是 NaN,代码会比较这两个 NaN 并返回较大的那个。
这个行为看起来很怪,但它是文档中明确记载的特性。14
你也许以为 NaN 是单一的值,实际上它是一个庞大的值家族。
设计意图是让程序员用不同的 NaN 值来标记问题发生的位置。比如,可以给未初始化数组的每个位置放一个不同的 NaN,这样就能知道访问的是哪个位置。
不知出于什么原因,8087 的设计者决定:如果对两个不同的 NaN 执行运算,结果取较大的那个。
因此,微码里需要一段代码来检测两个操作数是否都是 NaN 并算出较大者,比较用的是减法(#1518)。
SPECIAL_TMPS (J5): #1484 调用 SPECIAL_VAL 若 tmpA 标签为 SPECIAL(处理 tmpA 的特殊值) #1485 xchg tmp(交换寄存器) #1486 调用 SPECIAL_VAL 若 tmpA 标签为 SPECIAL(处理 tmpB 的特殊值) #1487 xchg tmp(交换寄存器) #1488 1 -> flag(默认置 Flag=1) #1489 若 tmp 非空/非特殊/非除零,则跳转 #1500,否则执行 expConv(处理正常 tmp) #1490 1 -> expConv #1491 若 tmpA 或 B 非空,则跳转 #1497 #1492 except:invalid(任一操作数为空则触发无效异常) #1493 若为比较指令,则跳转 #1525(比较指令不产生 NaN) #1494 若发生中断,则跳转 #1511(中断未屏蔽则直接返回) #1495 NaN -> tmpA(中断被屏蔽则置 NaN) #1496 return(返回) #1497 若 tmpA 标签为 SPECIAL,则跳转 #1502(处理特殊情况) #1498 若 tmpB 标签为 SPECIAL,则跳转 #1505 #1499 0 -> flag(除法正常路径:) #1500 零 -> expConv(返回 flag 0, expConv 0) #1501 return(返回) #1502 调用 SPECIAL_VAL(处理 TmpA 特殊值) #1503 若 flag 不为真,则跳转 #1512(NaN 则跳转,无穷则继续执行) #1504 若 tmpB 标签不为 SPECIAL,则跳转 #1509 #1505 xchg tmp(处理 TmpB 特殊值) #1506 调用 SPECIAL_VAL #1507 xchg tmp #1508 若 flag 不为真,则跳转 #1521(NaN 则跳转,无穷则返回) #1509 0 -> flag(清除 flag,准备返回) #1510 return(返回) #1511 RNI(以中断结束指令) #1512 若 tmpB 标签不为 SPECIAL,则跳转 #1522(TmpA 为 NaN,现检查 tmpB) #1513 xchg tmp #1514 调用 SPECIAL_VAL(检查 tmpB) #1515 xchg tmp #1516 若 flag 为真,则跳转 #1522(tmpB 非 NaN 则跳转) #1517 except:invalid(无效异常) #1518 tmpB:frac -> Breg(两参数均为 NaN,寻找较大者) #1519 adder: tmpA:frac - Breg cin=1 #1520 若加法器符号位为真,则跳转 #1522(判断 tmpA < tmpB) #1521 tmpB -> tmpA(取较大者) #1522 except:invalid(无效异常) #1523 若为比较指令,则跳转 #1525(比较指令不触发中断) #1524 若发生中断,则跳转 #1511(以中断结束指令) #1525 1 -> flag(带 flag 置位返回) #1526 return(J5 结束)
此子程序大量调用了一个辅助子程序 SPECIAL_VAL16 来处理单个参数。该辅助程序将非规格化参数转换为非归一化参数,并根据需要触发异常或中断。此外,它还会标记无穷大输入。
#1485 处用于交换 tmpA 和 tmpB 的微指令硬件设计颇具巧思。它并非在两个寄存器之间物理移动数值,而是通过切换一个触发器来互换 tmpA 和 tmpB 的逻辑含义。也就是说,若触发器置位,对 tmpA 的引用将指向 tmpB,反之亦然。这是微处理器中常见的标准技巧:Intel 8080 的 XCHG 指令便以类似方式交换 DE 和 HL 寄存器;Z80 也利用相同机制在常规寄存器组与备用寄存器组之间执行 EX 和 EXXX 指令。
Intel 8087 芯片封装于 40 引脚双列直插式封装(DIP)中,8080 和 Z80 亦采用相同封装。此照片旨在为密集的微指令内容提供视觉间歇。
处理非正规结果
若将一个极大数值进一步放大,可能引发溢出;若将一个极小数值进一步缩小,则可能产生非正规数或下溢。这些情况将触发溢出、非正规数或下溢异常,若未屏蔽相应标志,还会导致中断。此外,8087 支持四种舍入模式:舍入到最近有效值、向下舍入(趋向 -∞)、向上舍入(趋向 +∞)或向零舍入(截断)。根据舍入模式不同,溢出结果可能为 ∞ 或最大的浮点数;类似地,下溢结果可能为零或最小的浮点数。同时,依据无穷大模式(仿射或射影)的不同,无穷大可以是带符号的或无符号的。因此,FSCALE 微代码必须处理结果的诸多特殊情况。
下面是处理 tmpA 中非规格化结果的子程序。
其中一条有趣的微指令是 update overflow/underflow exceptions,它会在适当时触发异常。
对于大多数异常,都是直接由微指令触发(例如 #0346 处的 except:precision)。
但溢出和下溢异常不同,微码把这项任务交给了硬件。
具体来说,8087 的"指数转换器"电路会根据所选的浮点精度检查指数,判断是否存在溢出或下溢。
微指令再根据这些结果设置溢出和下溢标志位。
有了指数转换器的硬件支持,一个复杂的任务只需一条微码指令就能完成。
NONNORMAL_RESULT (J16): #0318 若 tmpA:tag 为零,则返回;处理非正常结果 #0319 更新溢出/下溢异常;若指数转换指示,则触发异常 #0320 expconst 0x6000 中断偏置常数 0x6000 #0321 若未中断,则跳转至 #0329 #0322 expConst -> Breg 中断路径 #0323 若为负,则跳转至 #0326 #0324 加法器: tmpA:exp + Breg cin=0 为下溢添加偏置 #0325 跳转至 #0327 #0326 加法器: tmpA:exp - Breg cin=1 为溢出减去偏置 #0327 sumreg:frac -> tmpA:exp 将新指数存入 tmpA #0328 返回 已中断,故结束 #0329 若为负,则跳转至 #0344 屏蔽的异常 #0330 tmpA:exp -> Breg 下溢 #0331 加法器: 1 - Breg cin=1 非规格化数需移动的位数 #0332 调用 CREATE_DENORM 创建非规格化数 #0333 加法器: zero + Breg cin=0, roundmode 加零以执行舍入 #0334 调用 ADJUST_PRECISION 调整至指定精度 #0335 若 Sum 寄存器为零,则跳转至 #0340 若为零,则按适当符号返回零 #0336 zero -> tmpA:exp 非规格化: 指数为 0 #0337 sumreg:frac -> tmpA:frac 保存非规格化数的尾数 #0338 special -> tmpA tag 标记非规格化为特殊值 #0339 返回 #0340 tmpA 符号 -> 符号锁存 返回 +/- 零 #0341 zero -> tmpA #0342 符号锁存 -> tmpA 符号 #0343 返回 #0344 NaN/Inf -> tmpA:exp 溢出: 可能返回无穷大 #0345 tmpA:frac -> tmpB:frac 将 tmpA 尾数保存至 tmpB #0346 except:precision 设置精度异常 #0347 Inf -> tmpA:frac 在尾数中放入无穷大 #0348 special -> tmpA tag 标记无穷大为特殊值 #0349 若未执行舍入或截断,则返回 若向上舍入,则返回无穷大 #0350 1 -> Breg 返回最大浮点数: 向下调整 #0351 加法器: tmpA:exp - Breg cin=1 #0352 sumreg:frac -> tmpA:exp 指数=7fff-1=7ffe #0353 加法器: zero - Breg cin=1 #0354 sumreg:frac -> tmpA:frac 尾数 0-1 = ff...ff #0355 norm -> tmpA tag 正常值 #0356 若 tmpB:frac[63],则返回 除非非规格化,否则返回最大浮点数 #0357 tmpB:frac -> tmpA:frac 返回原始的 tmpA 尾数 #0358 返回
当 8087 发生未屏蔽的溢出或下溢并触发中断时,其行为颇具特色。其设计意图是让中断处理程序知晓指数本应取何值。然而,正确的数值因过大或过小而无法直接填入指数字段(这正是异常发生的原因)。解决方案是加减常数 0x6000,从而得到一个在指数字段范围内的值。中断处理程序只需对该常数执行相反的加减操作,即可还原出正确的指数。代码行 #0322 至 0328 负责执行这一加减运算。
对于被屏蔽的下溢,子程序 CREATE_DENORM 会生成一个非规格化数(denorm)。随后,ADJUST_PRECISION 将该值舍入至指定精度。最后,若该值甚至小到无法表示为非规格化数,则根据符号返回 +0 或 -0。
对于被屏蔽的溢出,8087 根据指定的舍入模式,返回无穷大(Infinity)或最大可表示浮点数。无穷大由全 1 的指数和以 1000... 开头的尾数表示,这些值直接通过晶体管加载到总线上。而最大浮点数则是计算得出的:将无穷大的指数减 1,并将尾数部分视为全 1(即从 0 的尾数借位,等效于全 1)。
辅助子程序:生成非规格化数
8087 一项具有争议的特性是非规格化数(denormals),即比“常规”浮点数更小的数值。回顾一下,浮点数的尾数首位通常置为 1。那么,当指数达到最小值但仍需表示更小的数时该怎么办?8087 允许打破尾数必须以 1 开头的规则,从而生成更小的非规格化数(denorms)。非规格化数显著扩展了数值范围,可使数值范围向下延伸 263 倍。然而,由于高位“浪费”,非规格化数的精度较低。此外,由于需要特殊处理,涉及非规格化数的计算速度会大幅降低。
上图展示了一个具有最小可行指数的规格化数字(-16382,偏置后为 1)。将它除以 8(即缩放 -3)就会产生非规格化数,因为指数已经无法再减小了。此时只能把有效数字右移 3 位,并将指数替换为特殊值 0,表示这是一个非规格化数。
在 8087 中,非规格化数由一个微码子程序生成,我把它称为 CREATE_DENORM;它被许多算术运算调用,而不仅仅是 FSCALE。该子程序接收一个规格化数和一个移位量,通过移位(如上例所示)生成非规格化数。微码(如下)使用指数转换器检查移位量是否达到 64 或更多。如果是,移位后将不剩任何有效位,直接返回零;否则,将数值右移,并把非规格化结果存入 B 寄存器。
CREATE_DENORM (J20): #0522 sumreg:frac -> expConv Create denorm #0523 sumreg:frac -> shiftcount Number of bits to shift #0524 jmp #0528 if exponent[6:14] == 0 Jump if < 64 #0525 zero -> Breg No bits left, use zero #0526 shift tmpA:frac L 0 bytes, 0 bits Run through shifter? #0527 jmp #0532 #0528 shift tmpA:frac R count byte bit Shift right by the specified amount #0529 shift R -> Breg Result to Breg #0530 shift tmpA:frac L ~count byte bit Now shift back for sticky test #0531 NOP Wait for shifter #0532 rounding(h) -> Breg[grs] Store the three rounding bits in the Breg #0533 return
那为什么随后要把数值左移(#0530)?
这样做是为了获取舍入位。
8087 的一条核心原则是保证舍入正确,而这比看起来要难得多。
为了决定如何向上舍入一个数,你需要跟踪数量庞大得令人难以置信的位。
例如,计算 1 + 0 并向上舍入,结果是 1。
但如果你计算 1 + 2-10000 并向上舍入,得到的浮点数会比 1 略高。
问题在于,在不舍入的情况下,如何区分这两种和而不存储成千上万个位?
诀窍在于 8087 保留了三位用于舍入:守护位(guard)、舍入位(round)和粘滞位(sticky)。 如果考虑尾数右侧的位“尾巴”,守护位是尾巴中最高位,其次是舍入位。 粘滞位很特殊:它是尾巴中剩余所有位的 OR 结果,指示其中是否任何一位为 1。 因此,1 + 2-10000 的粘滞位被置位,而 1 + 0 没有,使得两个值可以以不同方式向上舍入。 为了生成粘滞位,8087 使用了一个非常大的 64 位 NOR 门,并行检测尾巴位。
一个示意图,展示了守护位、舍入位和粘滞位如何从右移中计算得出。此示例中的数字与前一个示例不同。
当数值向右移位时(例如生成非规格化数时),右侧的位会丢失。为了生成舍入位,数值会向左移位,保留最终将被丢弃的尾部所有位,同时丢弃最终有效数字中的位。最高两位分别进入保护位(guard)和舍入位(round),剩余位通过逻辑或运算生成粘滞位(sticky)。17 上图是该过程的示例。假设数值向右移位 4 位,尾部位 abcd(至少是 d)会在移位中丢失。舍入位是通过将原始有效数字向右移位 59 位(即 4 的补码)来计算的。第 62 位(a)成为新的保护位,第 61 位(b)成为新的舍入位,剩余 64 位的逻辑或结果成为新的粘滞位。(注意,原有的保护、舍入和粘滞位也会参与逻辑或运算,因此不会丢失。)将第一次移位后的有效数字与第二次移位产生的舍入位合并,即可得到期望的结果。
辅助子程序:调整精度
尽管 8087 支持三种长度的浮点数,但它使用 80 位“临时实数”执行所有计算。在指令结束时,结果会被转换为所需的长度。(因此,使用较短的浮点数通常不会让指令变得更快。)一个微代码子程序(我称之为 ADJUST_PRECISION)将结果转换为 8087 控制字中指定的精度,并使用指定的舍入模式。大多数算术指令都使用此子程序。
8087 支持三种类型的实数。引自 Intel 数值补充说明。
第一条代码路径处理 temporary real(精度为 64 位)。控制字可以指定四种舍入模式之一。但对特定的有效数字来说,实际只有两种操作可选:要么向下舍入(直接截断),要么向上舍入(截断后加 1)。这个判断由复杂的硬件电路完成——它会检查舍入位、舍入模式和符号,决定该向上还是向下舍入。这样设计简化了微码,但让硬件变得更复杂。微码执行条件返回:如果有效数字不需要向上舍入,就直接返回;否则通过带进位的加 0 操作给有效数字加 1。接着检查是否溢出,若溢出则把值替换为 Infinity,并设置一个特殊标志位。18
ADJUST_PRECISION (J11): #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 溢出时返回无穷大 #0304 2count++ 设置特殊标志位 #0305 return #0306 23/52 -> shiftcount 短实数或长实数:获取相应的移位量 #0307 shift sumreg:frac,rnd L count byte bit sticky 移位以生成舍入位 #0308 NOP 等待移位器完成 #0309 rounding(H) -> sumreg[grs] 保存舍入位 #0310 shift sumreg:frac R ~count byte bit 右移去掉多余的位 #0311 shift R -> sumreg:frac #0312 jmp #0314 if not round up, update CC1 更新条件码 #0313 adder: sumreg:frac + 0 cin=1 视情况向上舍入 #0314 shift sumreg:frac L ~count byte bit 左移重新对齐 #0315 shift L -> sumreg:frac,sign #0316 return if not sumreg[64] 未溢出则返回 #0317 jmp #0303 返回无穷大
当返回较低精度(short real 或 long real)时,代码会更复杂,因为需要缩短有效数字。首先,位于 #0306 的代码根据控制字中指定的精度,将移位器加载为 23 或 52,然后向左移位该值,以此生成上一节所述的就位比特。接着,该值向右移位,缩短至目标长度。与之前一样,根据是否需要向上取整,对有效数字进行递增或不递增。最后,该值再向左移回,使有效数字的最高有效位位于左侧。同样地,如果向上取整导致溢出,则返回无穷大。
一个奇特的特性是,带有“向上取整”条件的跳转指令还有一个副作用:它会更新 8087 对程序员可见的条件码寄存器(CC1),以指示结果是向上还是向下取整。也就是说,8087 有额外的电路来检测这一特定条件,并将值加载到条件码锁存器中。奇怪的是,8087 的文档并未描述此条件码操作;英特尔直到 1987 年的 387SX 浮点芯片才对此进行文档说明。19
结论
在 8087 之前,浮点数已经有了悠久的历史。例如,1964 年的 IBM System/360 大型机就支持 32 位和 64 位浮点数。1977 年,AMD 推出了 Am9511 浮点芯片,支持 16 位和 32 位浮点数,以及超越函数。8087 的革命性之处在于,它经过精心设计,旨在尽可能提高数学精度,这主要归功于数值专家 William Kahan。(8087 促成了IEEE 754 标准,该标准如今被几乎所有计算机采用,终结了不兼容的浮点标准纷争。)
8087 最终变得极其复杂,它包含三种不同大小的浮点数、四种大小的整数、四种舍入模式、无穷大模式,一组可屏蔽或不可屏蔽的异常,非规格化和未规格化数值,带符号和无符号的无穷大,带符号的零,以及整个 NaN(非数)家族。这些特性相互组合,产生了大量的边界情况。8087 通过专门的电路和错综复杂的微代码来处理这种复杂性。
8087 到底有多复杂?对于没有 8087 芯片的用户,Intel 销售了一个 8087 支持库,它能精确模拟 8087 的指令(但速度慢得多)。模拟器需要 16K 字节的 8086 代码,在当时一个完整的 BASIC 解释器只需 8K 的情况下,这相当庞大。换个角度看,8087 的硬件大幅减少了所需的软件量:8087 本身使用了 3.3K 的微代码,而模拟器在 8086 代码中则用了 16K。
我计划继续逆向工程 8087 的微代码;有关更新,请通过 Bluesky(@righto.com)、 Mastodon(@[email protected]) 或 RSS 关注我的动态。 我一直与 "Opcode Collective" 的成员一起研究这项工作,特别是 Smartest Blob 和 Gloriouscow,他们负责将 ROM 映像转换为微代码数据并对内容进行了深入分析。 更多资料请参见 GitHub 上的 8087 仓库。
注释与参考
-
8087 的专利文件提供了一些硬件细节,但不幸的是并未涉及微代码。 下方的专利图展示了 8087 的架构;我突出显示了相关部分。小数点总线和指数总线用红色表示。加法器及相关寄存器用黄色表示。(对于减法,B 寄存器选择器会选择补码。)移位器用绿色表示。指数常数 ROM 和指数转换器用橙色表示。临时寄存器和堆栈寄存器用蓝色表示。
8087 的架构图,基于专利绘制。点击图片(或其他任意图片)可放大。 -
指数转换器复杂得出人意料,因为 8087 支持三种浮点数格式,指数字段的宽度各不相同(8 位、11 位和 15 位)。而且不同宽度的指数在存储时使用不同的偏置值,所以它们之间的转换并不简单。指数转换器还要针对不同的指数宽度检测上溢和下溢,并处理无穷大、NaN 等特殊值。我打算以后再详细介绍指数转换器。 ↩
-
8087 的尾数名义上是 64 位宽,但它额外使用了三个低位用于舍入,分别叫 Guard、Round 和 Sticky。这些位保证数值总能朝正确的方向舍入。数据通路的某些部分还有额外的符号位或溢出位:移位器宽 68 位,加法器宽 69 位。下文大部分地方会忽略这些额外位,仍把数据通路称作 64 位宽。 ↩
-
标签通常对程序员不可见,但可以通过特殊操作访问。具体来说,程序员可以把 8087 的状态转储到内存,标签就保存在一个 16 位的"标签字"里。 ↩
-
浮点数的外部表示隐含了一个前导 1,只显式存储二进制小数点之后的位,相当于"免费"多获得一位精度。而 8087 内部的 80 位表示则显式保存这个前导 1,以简化计算。 ↩
-
指数采用偏移表示的一个原因是,比较两个浮点数值的大小时,可以直接将它们视为有符号整数进行字典序比较,而无需单独分析指数部分。 ↩
-
8087 的大部分指令由微代码实现,但少数指令是硬连线的。关于指令译码的更多细节,请参阅 Intel 8087 浮点芯片中的指令译码。 ↩
-
我使用十进制地址来表示微代码,因为 Opcode Collective 开始采用十进制地址,现在改变可能会造成混淆。 ↩
-
微代码显示,0 乘以任何数,或任何数乘以 0,结果都保持原值不变。我认为设计者在这里采取了捷径,而没有返回“正确”的值。鉴于 8087 规定 0×∞ 为 NaN,在我看来,0×2∞ 也应当是 NaN,因此
FSCALE(0, ∞)的结果应为 NaN,而非 0。设计者可能做出了合理决定,认为没人真正关心那个晦涩难懂的FSCALE指令的边缘情况。对于其他指令,关于非规格化数、次规格化数和零的行为均有文档记录(见 数值补充文档中的表格 S-24 至 S-26),但FSCALE被遗漏了。 ↩ -
8087 为指数和尾数设置了独立的数据总线,而加法器仅连接到尾数总线,那么指数是如何传递给加法器的?诀窍在于指数总线和尾数总线之间有一个 16 位的网关,允许将指数复制过去。 ↩
-
我在此处描述过 8087 的加法器。简而言之,减法是通过在 B 寄存器的值馈入加法器时将其取反来实现的。加法器的进位输入位被置为 1,从而实际上执行了补码减法。 ↩
-
为什么微代码需要为加正比例和减负比例设置不同的路径?原因在于数值是以符号位和无符号值的存储方式表示的,而非标准整数所用的补码形式。因此,加法器无法直接执行带符号加法。加法器电路必须被明确指示对 B 寄存器的值取补数,从而执行减法操作。 ↩
-
该流程图展示了
SPECIAL_TMPS子程序。由于路径的分支与汇合,该子程序的结构较为复杂。其中一条棘手的路径是判断是否存在 0、1 或 2 个 NaN 值,并在存在两个 NaN 时取最大值的代码。另一个复杂之处在于异常退出逻辑:若中断未被屏蔽,则触发中断,但比较指令除外。两个返回值通过flag和expConv返回。下文总结了
SPECIAL_TMPS的行为。它通过flag触发器(flip-flop)和指数转换寄存器(expConv)返回状态。其操作如下:输入 结果 flag expConv empty NaN,异常 1 1 NaN (较大的)NaN,异常 1 1 infinity infinity 0 1 denorm unnorm 1 0 div abnormal 无变化 0 0 (最后一行表示除法计算过程中的异常值;我仍在研究此问题。) ↩
-
指导 8087 开发的 William Kahan 教授曾感到失望:一些浮点特性因为恶性循环而无人使用——这些特性缺乏良好的编译器支持,程序员因此不去使用,编译器开发者又以需求不足为由拒绝实现支持。用多个 NaN 值来记录 NaN 是如何以及在何处产生的,就是一个缺乏软件支持的例子。关于 NaN 及其他问题的详细讨论,可参见《IEEE 754 二进制浮点运算标准现状讲义》。 ↩
-
8087 大量使用微子程序,为微码子程序调用提供了 6 级深度的栈。微码跳转和子程序调用的地址来自一张跳转表,其索引取自微指令中的 6 个位。我们当初图省事,直接按索引把跳转表的条目命名为
J0、J1等等,后来弄清含义时会陆续改成更有意义的名字。至于微指令的名称,Intel 官方用了什么名字我们无从得知(这点和 8086 不同),这些名字是我起的,参考了 Gloriouscow 的反汇编中的命名。 ↩ -
一个名为
SPECIAL_VAL的子程序负责处理非规格化数、无穷大和 NaN。 (该子程序主要供SPECIAL_TMPS使用,但也被FRNDINT(舍入到整数)和FSQRT调用。) 首先,子程序检查tmpA的指数;如果指数为零,则表示该值为非规格化数。 (该值也可能为零,但此前已处理过。) 若条件成立,则设置denorm异常标志。FCOM等比较指令对非规格化数的处理方式不同,此处暂且忽略。 位于#1576的代码检查denorm是否触发了中断;若是,指令将以中断结束。 若该中断被屏蔽,代码通过修改标签为norm并将指数改为 1,将非规格化数转换为未规格化数(由于存在指数偏置,这对应着一个极负的、最小有效值)。 子程序的执行结果通过一个特殊的标志触发器返回。SPECIAL_VAL (J12): #1572 tmpA:exp -> sumreg:frac 处理特殊值 #1573 jmp #1581 if not Sum register is zero 测试指数是否为非规格化 #1574 except:denorm #1575 jmp #1577 if compare instruction 比较指令无异常 #1576 jmp #1571 if DE (denormalized) interrupt RNI if exception #1577 norm -> tmpA tag 处理非规格化数:标签为空?还是有效? #1578 1 -> tmpA:exp 转换为未规格化数 #1579 0 -> flag 清除标志 #1580 return #1581 shift tmpA:frac L 0 bytes, 1 bits 移位以检查是无穷大还是 NaN #1582 shift L -> sumreg:frac #1583 jmp #1579 if not Sum register is zero 为 NaN 清除标志 #1584 1 -> flag 为无穷大设置标志 #1585 return
在
#1581处,代码检查该值是无穷大还是 NaN。 有趣的是,此测试并非直接执行,而是通过对值进行移位操作来实现。 回想一下,无穷大的尾数为10...00,而 NaN 至少还有一个额外的1比特位。 代码将尾数向左移位一位;若结果为零,则指示无穷大;若结果非零,则指示 NaN。 与之前一样,结果通过flag触发器返回。 ↩ -
计算舍入位的逻辑比描述的要复杂得多。根据
expConv的取值,有两条微指令的行为略有不同,这里就不展开讨论了。 ↩ -
ADJUST_PRECISION子程序在尾数向上舍入溢出时似乎会返回无穷大,但我对此并不完全满意。 例如,1.111...应该舍入为 2,而不是无穷大;尾数溢出了,但这并不意味着整个浮点数溢出。 推测这个问题在其他地方得到了修正。 ↩ -
我不清楚 Intel 为何没有记录“条件码指示数值是向上还是向下舍入”这一特性。 8087 的文档对边缘情况的描述非常详尽;通常,当我发现奇怪的电路时,都能在文档中找到一行解释其存在的理由。 也许条件码特性存在 Bug,所以干脆不予记录? 或者这个特性是故意隐藏起来,用来陷阱那些复制该芯片的竞争对手?(Intel 曾在 8086 中设置过一条秘密指令以此目的,但 NEC 版本的 8086 没有这条指令,这让 Intel 的律师们颇感失望。) 又或者是 Intel 不确定是否要在后续版本中支持该特性?(这也是 8085 处理器部分指令未被记录的原因。) 目前,这仍是一个谜。 ↩



