← 文章 / 芯片硬件
Hacker News 8小时前 · 2026-09-13 14:33:49 · 2 阅读

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。点击查看大图。英特尔 8087 浮点单元芯片的裸片,主要功能模块已做标注。裸片尺寸为 5mm×6mm,点击查看大图。

放大芯片底部,可以看到数据通路电路;下文高亮显示了相关部分。1 指数 ROM 存储着各种常量。 指数转换器是一种专用电路,用于检测特殊值、检查指数,并在不同的指数格式间进行转换。2 移位器是一个大型组件,允许对 64 位3数值进行任意位数的左移或右移。(我曾撰文介绍过 8087 的移位器电路这里。) 加法器是 8087 计算的核心;它在乘法、除法和开方运算中通过循环使用。 B 寄存器保存加法器的一个输入,而多个源可以提供另一个输入。和寄存器保存加法器的输出。 八个堆栈寄存器和临时寄存器用于存放浮点数。

8087 数据通路的特写,展示了 FSCALE 指令使用的功能模块。8087 数据通路的特写,展示了 FSCALE 指令使用的功能模块。

8087 的细节

本节将介绍 8087 中几项对 FSCALE 微代码至关重要的特性。 使用 8087 时,程序员将数值存入其八个内部寄存器,这些寄存器按堆栈组织。每个寄存器保存一个 80 位的浮点数。 为优化性能,堆栈中每个数值都关联一个“标签”(tag),该标签对程序员大多不可见。4 标签将数值标记为有效(valid)、特殊(special)、零(zero)或空(empty)。 正常的浮点数值标记为 有效。若浮点数值是无穷大、非数(NaN)或非规格化值,则标记为 特殊。 零值标记为 。 最后,若寄存器为空(例如数值已出栈),则该寄存器标记为

8087 还拥有一些内部使用的临时寄存器:tmpAtmpBtmpC。 与堆栈寄存器类似,tmpAtmpB 也是 80 位寄存器,并附带两个标签位。然而,tmpC 仅保存 64 位尾数。

8087 支持多种数据类型:不同大小的浮点数、整数以及二进制编码十进制数。 但在内部,所有数据均存储为一种称为“临时实数”(temporary real)的 80 位浮点数。在本文后续内容中,我将只讨论临时实数。 一个数字由三部分组成:符号位、15 位指数和 64 位尾数(即小数部分)。 通常情况下,浮点数由 符号 × 尾数 × 2exponent 表示。 尾数是一个 64 位二进制数,形式为 1.bbb...: 以 1 开头,接着是小数点(二进制的点,相当于十进制的小数点),然后是剩余的比特位。5 浮点数的实用之处在于其范围极广,从极小值到天文数字都能覆盖,这得益于指数,其范围从 -16382 到 16383。 一个重要细节是,存储的指数加上了 16383 的“偏置”(bias)。因此,即使实际指数为负,存储的指数始终为正。6

The 80-bit temporary real format. The triangle indicates the binary point, analogous to the decimal point. From the Intel Numerics Supplement.80 位临时实数格式。三角形表示二进制小数点,类似于十进制的小数点。图片来源:Intel Numerics Supplement

8087 支持几种以特殊指数表示的特殊数值类型,如下所示。零和无穷大都分正负两种。"非数"(NaN)表示没有意义的值,比如 0/0 或 sqrt(-1);NaN 的表示形式有很多种,并不是单一的一个值。此外,8087 还支持非规格化(denormalized)和非规范化(unnormalized)数值,即有效数字没有前导 1 的极小数值。

The encoding of special values. Based on Table S-31 in the Intel Numerics Supplement, but highly simplified. The "x" bits are arbitrary, as long as they don't conflict with another type.特殊数值的编码方式。根据 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 位则用于处理各种特殊情况。 第二种是移位操作,利用桶形移位器对数值进行左移或右移。 第三种微指令控制加法器(也可以执行减法)。 杂项指令包括栈指针操作、状态标签修改、异常处理和子程序返回。 远跳转和远调用微指令会跳转到固定列表中的目标微地址,执行跳转或子程序调用。 条件字段允许根据多种条件执行有条件的跳转/调用/返回,最后一位用于反转该条件。 局部跳转则是相对于当前位置跳转到附近的微指令。

Structure of an 8087 micro-instruction.8087 微指令的结构。

FSCALE 的微代码

当 8087 开始执行一条指令时,指令译码器电路会确定该指令对应微代码的起始地址。 这个 11 位地址被加载到微代码引擎中,微代码便开始执行。7 下方展示了 FSCALE 的微代码,其起始十进制地址为 748。8

FSCALE 的思路很直白:想对一个浮点数乘以 2NN 为整数), 只需把 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。)

Converting a float to an int by shifting.通过移位将浮点数转换为整数。

在微代码中,减法需要分好几步完成。 在 #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 中的特殊值。 这是一个通用例程,基本算术运算、FSCALEFTST(测试)和 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 处用于交换 tmpAtmpB 的微指令硬件设计颇具巧思。它并非在两个寄存器之间物理移动数值,而是通过切换一个触发器来互换 tmpAtmpB 的逻辑含义。也就是说,若触发器置位,对 tmpA 的引用将指向 tmpB,反之亦然。这是微处理器中常见的标准技巧:Intel 8080 的 XCHG 指令便以类似方式交换 DEHL 寄存器;Z80 也利用相同机制在常规寄存器组与备用寄存器组之间执行 EXEXXX 指令。

Intel 8087 芯片封装于 40 引脚双列直插式封装(DIP)中,8080 和 Z80 亦采用相同封装。此照片旨在为密集的微指令内容提供视觉间歇。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,从而得到一个在指数字段范围内的值。中断处理程序只需对该常数执行相反的加减操作,即可还原出正确的指数。代码行 #03220328 负责执行这一加减运算。

对于被屏蔽的下溢,子程序 CREATE_DENORM 会生成一个非规格化数(denorm)。随后,ADJUST_PRECISION 将该值舍入至指定精度。最后,若该值甚至小到无法表示为非规格化数,则根据符号返回 +0-0

对于被屏蔽的溢出,8087 根据指定的舍入模式,返回无穷大(Infinity)或最大可表示浮点数。无穷大由全 1 的指数和以 1000... 开头的尾数表示,这些值直接通过晶体管加载到总线上。而最大浮点数则是计算得出的:将无穷大的指数减 1,并将尾数部分视为全 1(即从 0 的尾数借位,等效于全 1)。

辅助子程序:生成非规格化数

8087 一项具有争议的特性是非规格化数(denormals),即比“常规”浮点数更小的数值。回顾一下,浮点数的尾数首位通常置为 1。那么,当指数达到最小值但仍需表示更小的数时该怎么办?8087 允许打破尾数必须以 1 开头的规则,从而生成更小的非规格化数(denorms)。非规格化数显著扩展了数值范围,可使数值范围向下延伸 263 倍。然而,由于高位“浪费”,非规格化数的精度较低。此外,由于需要特殊处理,涉及非规格化数的计算速度会大幅降低。

Example of a normal number, reduced by a factor of 8, resulting in a denormal.一个规格化数字缩小 8 倍后变成非规格化数的示例。

上图展示了一个具有最小可行指数的规格化数字(-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 控制字中指定的精度,并使用指定的舍入模式。大多数算术指令都使用此子程序。

The 8087 supports three types of real numbers. From the Intel Numerics Supplement.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 仓库

注释与参考

  1. 8087 的专利文件提供了一些硬件细节,但不幸的是并未涉及微代码。 下方的专利图展示了 8087 的架构;我突出显示了相关部分。小数点总线和指数总线用红色表示。加法器及相关寄存器用黄色表示。(对于减法,B 寄存器选择器会选择补码。)移位器用绿色表示。指数常数 ROM 和指数转换器用橙色表示。临时寄存器和堆栈寄存器用蓝色表示。

    8087 的架构图。基于专利文档绘制。点击图片(或其他任意图片)可放大。8087 的架构图,基于专利绘制。点击图片(或其他任意图片)可放大。

  2. 指数转换器复杂得出人意料,因为 8087 支持三种浮点数格式,指数字段的宽度各不相同(8 位、11 位和 15 位)。而且不同宽度的指数在存储时使用不同的偏置值,所以它们之间的转换并不简单。指数转换器还要针对不同的指数宽度检测上溢和下溢,并处理无穷大、NaN 等特殊值。我打算以后再详细介绍指数转换器。

  3. 8087 的尾数名义上是 64 位宽,但它额外使用了三个低位用于舍入,分别叫 Guard、Round 和 Sticky。这些位保证数值总能朝正确的方向舍入。数据通路的某些部分还有额外的符号位或溢出位:移位器宽 68 位,加法器宽 69 位。下文大部分地方会忽略这些额外位,仍把数据通路称作 64 位宽。

  4. 标签通常对程序员不可见,但可以通过特殊操作访问。具体来说,程序员可以把 8087 的状态转储到内存,标签就保存在一个 16 位的"标签字"里。

  5. 浮点数的外部表示隐含了一个前导 1,只显式存储二进制小数点之后的位,相当于"免费"多获得一位精度。而 8087 内部的 80 位表示则显式保存这个前导 1,以简化计算。

  6. 指数采用偏移表示的一个原因是,比较两个浮点数值的大小时,可以直接将它们视为有符号整数进行字典序比较,而无需单独分析指数部分。

  7. 8087 的大部分指令由微代码实现,但少数指令是硬连线的。关于指令译码的更多细节,请参阅 Intel 8087 浮点芯片中的指令译码

  8. 我使用十进制地址来表示微代码,因为 Opcode Collective 开始采用十进制地址,现在改变可能会造成混淆。

  9. 微代码显示,0 乘以任何数,或任何数乘以 0,结果都保持原值不变。我认为设计者在这里采取了捷径,而没有返回“正确”的值。鉴于 8087 规定 0×∞ 为 NaN,在我看来,0×2 也应当是 NaN,因此 FSCALE(0, ∞) 的结果应为 NaN,而非 0。设计者可能做出了合理决定,认为没人真正关心那个晦涩难懂的 FSCALE 指令的边缘情况。对于其他指令,关于非规格化数、次规格化数和零的行为均有文档记录(见 数值补充文档中的表格 S-24 至 S-26),但 FSCALE 被遗漏了。

  10. 8087 为指数和尾数设置了独立的数据总线,而加法器仅连接到尾数总线,那么指数是如何传递给加法器的?诀窍在于指数总线和尾数总线之间有一个 16 位的网关,允许将指数复制过去。

  11. 我在此处描述过 8087 的加法器。简而言之,减法是通过在 B 寄存器的值馈入加法器时将其取反来实现的。加法器的进位输入位被置为 1,从而实际上执行了补码减法。

  12. 为什么微代码需要为加正比例和减负比例设置不同的路径?原因在于数值是以符号位和无符号值的存储方式表示的,而非标准整数所用的补码形式。因此,加法器无法直接执行带符号加法。加法器电路必须被明确指示对 B 寄存器的值取补数,从而执行减法操作。

  13. 该流程图展示了 SPECIAL_TMPS 子程序。由于路径的分支与汇合,该子程序的结构较为复杂。其中一条棘手的路径是判断是否存在 0、1 或 2 个 NaN 值,并在存在两个 NaN 时取最大值的代码。另一个复杂之处在于异常退出逻辑:若中断未被屏蔽,则触发中断,但比较指令除外。两个返回值通过 flagexpConv 返回。

    A flowchart for the SPECIAL_TMPS subroutine. Click for a larger version.SPECIAL_TMPS 子程序的流程图。点击查看大图。

    下文总结了 SPECIAL_TMPS 的行为。它通过 flag 触发器(flip-flop)和指数转换寄存器(expConv)返回状态。其操作如下:

    输入结果flagexpConv
    emptyNaN,异常11
    NaN(较大的)NaN,异常11
    infinityinfinity01
    denormunnorm10
    div abnormal无变化00

    (最后一行表示除法计算过程中的异常值;我仍在研究此问题。)

  14. 指导 8087 开发的 William Kahan 教授曾感到失望:一些浮点特性因为恶性循环而无人使用——这些特性缺乏良好的编译器支持,程序员因此不去使用,编译器开发者又以需求不足为由拒绝实现支持。用多个 NaN 值来记录 NaN 是如何以及在何处产生的,就是一个缺乏软件支持的例子。关于 NaN 及其他问题的详细讨论,可参见《IEEE 754 二进制浮点运算标准现状讲义》

  15. 8087 大量使用微子程序,为微码子程序调用提供了 6 级深度的栈。微码跳转和子程序调用的地址来自一张跳转表,其索引取自微指令中的 6 个位。我们当初图省事,直接按索引把跳转表的条目命名为 J0J1 等等,后来弄清含义时会陆续改成更有意义的名字。至于微指令的名称,Intel 官方用了什么名字我们无从得知(这点和 8086 不同),这些名字是我起的,参考了 Gloriouscow 的反汇编中的命名。

  16. 一个名为 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 触发器返回。

  17. 计算舍入位的逻辑比描述的要复杂得多。根据 expConv 的取值,有两条微指令的行为略有不同,这里就不展开讨论了。

  18. ADJUST_PRECISION 子程序在尾数向上舍入溢出时似乎会返回无穷大,但我对此并不完全满意。 例如,1.111... 应该舍入为 2,而不是无穷大;尾数溢出了,但这并不意味着整个浮点数溢出。 推测这个问题在其他地方得到了修正。

  19. 我不清楚 Intel 为何没有记录“条件码指示数值是向上还是向下舍入”这一特性。 8087 的文档对边缘情况的描述非常详尽;通常,当我发现奇怪的电路时,都能在文档中找到一行解释其存在的理由。 也许条件码特性存在 Bug,所以干脆不予记录? 或者这个特性是故意隐藏起来,用来陷阱那些复制该芯片的竞争对手?(Intel 曾在 8086 中设置过一条秘密指令以此目的,但 NEC 版本的 8086 没有这条指令,这让 Intel 的律师们颇感失望。) 又或者是 Intel 不确定是否要在后续版本中支持该特性?(这也是 8085 处理器部分指令未被记录的原因。) 目前,这仍是一个谜。

原始来源: Hacker News

评论 (0)