ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

计算机算术经典巨作:从补码、超前进位到IEEE 754与FMA实战

计算机算术经典巨作:从补码、超前进位到IEEE 754与FMA实战

简介:《计算机运算》第二版是帕拉米教授的经典著作,英文原版PDF电子书,系统覆盖计算机运算核心算法与硬件实现,包括数的表示与进制转换、IEEE 754浮点标准、二进制补码加减运算及溢出处理、乘法与除法算法、快速乘法器设计、迭代开方与硬件加速器设计,以及ALU构造、流水线与并行向量处理等主题。适合计算机科学与电子工程专业学生、硬件设计工程师和嵌入式开发者作为理论结合实践的参考书。压缩包内仅含1个PDF文件(大小3.9MB),内容为完整英文原版,目前已有1685人学习并获使用验证。书中从位操作深入到高性能数值计算,详细讲解Karatsuba与FFT乘法、长除法硬件实现、平方根迭代算法等进阶内容,能够帮助读者理解现代CPU算术单元的设计逻辑,指导算法优化与硬件加速实践。

1. Computer Arithmetic:为什么一本"算术书"能成为硬件工程师的案头经典

流片前一周,验证工程师报了一个看起来违反直觉的 case:两个很小的浮点数相乘再加第三个数,RTL 结果和 C 参考模型对不上。查了两天才定位,是编译器把浮点乘加合并成了 FMA,而 RTL 里根本没有 FMA 单元。这类问题在读过 computer arithmetic 之前只能靠玄学猜,读完之后就是按图索骥。这本第二版教材把从数制、补码、快速加法器到乘除法器,再到 IEEE 754 浮点标准与舍入的完整链条讲透了,标题里"经典巨作"四个字不是溢美,是它真的决定了后面几代硬件的设计语言。它适合三类人:写 RTL 的、写编译器后端的、做数值算法移植的。下面按这条主线把可落地的部分拆开讲,每一步有原理、有代码、有参数,最后一章给排查手记。

2. 从补码到超前进位:加法器这条主线为什么绕不开

2.1 补码成为默认选择,不是因为"好看"而是因为"统一"

计算机算术的第一课永远是数制。补码之所以成为默认选择,关键性质是:A - B 不需要单独的减法器,只需要对 B 按位取反再加 1,然后走同一套加法逻辑。这个性质的工程价值被初学者低估了:它让 ALU 的控制逻辑最省,符号位不用单独判断,溢出检测也有统一表达。教材在第二版里反复强调补码表示范围是 [-2^(n-1), 2^(n-1)-1],这个不对称性就是后面很多 bug 的源头。

与原码和反码相比,补码的加法和减法完全统一,硬件上只需要一个加法器加一圈选择逻辑。反码的问题是有两个零(+0 和 -0),每次加减还要处理循环进位;原码的问题则是符号位和数值位分开运算,做加法时要先判断符号,电路面积直接翻倍。工程上选补码不是因为数学上更优雅,而是因为晶体管更省、关键路径更短,这是教材在选型讨论里最核心的一个理由。

2.2 加法器三阶段演进:串行、进位链、进位生成

加法器的演进是一条清晰的工程主线。最朴素的是行波进位加法器(RCA),每一位的和依赖上一位的进位,n 位加法器关键路径约等于 n 级进位延迟。超前进位加法器(CLA)把进位生成逻辑并行化:组内每一级的进位由输入直接组合产生,不再逐级传递。再往上还有进位选择加法器(CSA)和进位保存加法器,后者专门用于压缩乘法器里的多个部分积,是树形乘法器的地基。

实际工程里很少用纯 CLA 做 64 位加法,因为 64 位全展开的进位逻辑扇入扇出太大,布线后反而更慢。常见做法是 4 位或 8 位一个 CLA 小组,组间用行波进位或者第二层 CLA 连接。这个分层思路就是教材最核心的设计哲学:在延迟、面积、布线复杂度之间取折中,而不是追求单一指标极致。综合工具里通过约束时钟周期,工具会自动在这几种结构之间重新选择,但理解每一层的延迟来源,才能看懂报告里关键路径为什么在那里。

2.3 用 Verilog 手写一个 16 位超前进位加法器

下面这个 4 位 CLA 单元是整条加法器主线的核心模块。把它实例化四次,组间用行波进位连起来,就是一个能综合、能仿真的 16 位加法器。

// 4位超前进位加法器(cla4) // 输入: 两个4位操作数a/b, 最低位进位cin // 输出: 和sum, 进位cout, 组产生信号G, 组传播信号P module cla4 ( input [3:0] a, input [3:0] b, input cin, output [3:0] sum, output cout, output G, output P ); wire [3:0] g = a & b; // generate: 本位必定产生进位 wire [3:0] p = a | b; // propagate: 本位传播低位进位 wire [3:0] c; // 各位的实际进位 // 关键路径: c1~c3并行生成, 不再逐位传递 assign c[0] = cin; assign c[1] = g[0] | (p[0] & cin); assign c[2] = g[1] | (p[1] & g[0]) | (p[1] & p[0] & cin); assign c[3] = g[2] | (p[2] & g[1]) | (p[2] & p[1] & g[0]) | (p[2] & p[1] & p[0] & cin); assign sum = {a[3]^b[3]^c[3], a[2]^b[2]^c[2], a[1]^b[1]^c[1], a[0]^b[0]^cin}; assign cout = g[3] | (p[3] & c[3]); // 组级信号, 供更高层做并行进位连接 assign G = g[3] | (p[3] & g[2]) | (p[3] & p[2] & g[1]) | (p[3] & p[2] & p[1] & g[0]); assign P = &p; endmodule

逻辑说明:g[i] 表示 a[i] 和 b[i] 同时为 1,本位必然向高位产生进位;p[i] 表示 a[i] 或 b[i] 为 1,低位进位可以穿过本位继续向上传。c[1] 到 c[3] 全部由 g、p、cin 的组合直接生成,这就是"超前进位"的含义:进位不串行等待,而是并行算好。sum 每一位是 a、b、对应进位的异或,索引一定要对齐,否则高位和低位进位移位错位,仿真时会出非常难查的 bug。

参数说明:这个模块固定 4 位,想扩展到 16 位时,把四个 cla4 的 G、P 用一层并行进位逻辑连起来,效果比组间行波进位更好。但要注意 G、P 只在组间做第二层 CLA 时才需要,单级使用可以悬空。cin 在做加法时置 0;做减法时,对 b 按位取反并且 cin 置 1,加法器就同时完成了减法,这正好对应 2.1 里补码统一加减法的结论。

下面是组间行波进位的 16 位加法器,代码简单直观,适合仿真和作为参考结构。

// 16位加法器: 4个cla4组间行波进位 module add16 ( input [15:0] a, input [15:0] b, input cin, output [15:0] sum, output cout ); wire [4:0] c; wire [3:0] g, p; assign c[0] = cin; cla4 u0 (a[3:0], b[3:0], c[0], sum[3:0], c[1], g[0], p[0]); cla4 u1 (a[7:4], b[7:4], c[1], sum[7:4], c[2], g[1], p[1]); cla4 u2 (a[11:8], b[11:8], c[2], sum[11:8], c[3], g[2], p[2]); cla4 u3 (a[15:12], b[15:12], c[3], sum[15:12], c[4], g[3], p[3]); assign cout = c[4]; endmodule

这个 16 位版本的关键路径是四组 CLA 各自内部的三级门延迟,加上组间四级行波进位,总共约等于 4 倍单组延迟,而不是 16 倍位延迟。如果追求更快的组间连接,就把 g、p 接入第二层 CLA 逻辑,代价是顶层扇出从 4 变成 12 以上,综合时钟频率有时反而下降。我一般会在 16 位以下用组间行波,32 位以上用第二层 CLA,这条经验比纯粹背结构更有用。常见误用是把 CLA 当 RCA 用:明明写了超前进位公式,综合时却因为输出负载过大被工具退化成逐级进位,时序报告里路径仍然很长。遇到这种情况,先看报告里的单元类型是 CLA 相关的复杂门,还是一堆缓冲器,再做结构决定。

3. 乘除法:从移位相加到 Booth 编码与 SRT 除法

3.1 乘法器设计的两个核心思路:并行度与编码

教材讲乘法,最朴素的做法是移位相加:n 位乘 n 位,迭代 n 次,每次看乘数的一位决定加不加被乘数。控制简单,但延迟是线性的,不适合高性能场景。改进方向有两条:一是把多个部分积同时算出来,再用进位保存加法器压缩,这就是 Wallace 树和 Dadda 树的思路;二是减少需要相加的部分积个数,这就是 Booth 编码。

Booth 编码利用的是二进制里连续 1 的压缩。比如乘数 0011110,中间有连续四个 1,按定义可以改写成 0100000 - 0000010,把四次加变成一次加一次减。radix-4 Booth 每次看乘数的三位,把部分积个数从 n 减半,这是硬件乘法器性价比最高的起点。编码需要生成 {-2, -1, 0, 1, 2} 五种倍数,其中 2 倍只是左移一位,-2 倍是左移加取反,在硬件里代价极小。这个"用编码换电路"的思路,是读完乘法器章节最值得带走的设计模式。

3.2 除法为什么比乘法难一个量级

除法难在每一步商都是猜出来的:要拿当前部分余数和除数比较,决定商这位是 0 还是 1。这意味着每一步都依赖上一步的比较结果,串行度极高。最简单的恢复余数除法,发现减过头了要加回去,导致一步可能做两次加法,延迟不确定。非恢复余数除法把"恢复"改成"下次根据符号选择加或减",步子固定了,但商的形式需要最后修正。

SRT 除法在非恢复余数法基础上引入冗余余数表示,让商选择不需要精确比较,可以用查找表快速猜一个商位,然后靠冗余表示兜底。现代处理器里的除法器几乎都是 SRT 的变种,radix-4 和 radix-8 靠商选择表加部分余数的冗余编码把延迟压到可以接受的范围。写 RTL 的人至少应该知道,除法不是循环移位相加那么简单,而是商选择逻辑、冗余余数、最后修正三件套的组合。

3.3 恢复余数除法跑通:一段可以对着 RTL 改的参考代码

恢复余数法最容易写对,我一般先用 Python 验证整个流程,再照着写 RTL。这段代码和硬件的移位寄存器结构一一对应,可以直接作为参考模型:

def restore_divide(dividend, divisor, bits=8): """恢复余数除法: 每一步先减, 减错就加回去 返回 (quotient, remainder), 与RTL移位流程对应 """ if divisor == 0: raise ValueError("divisor == 0") R = 0 # 部分余数寄存器 Q = dividend # 被除数寄存器 for _ in range(bits): # 左移并入被除数的最高位 R = (R << 1) | ((Q >> (bits - 1)) & 1) Q = (Q << 1) & ((1 << bits) - 1) # 被除数同步左移 t = R R = R - divisor if R < 0: # 减错了, 恢复 R = t else: Q |= 1 # 商当前位为1 return Q, R # 验证: 13 / 3 = 4 ... 1 q, r = restore_divide(0b1101, 0b0011, bits=8) print(q, r, 13 // 3, 13 % 3)

逻辑说明:每一轮先把部分余数左移一位,并把被除数的最高位补进余数的最低位,相当于把被除数逐位移入余数寄存器;然后做一次减法,减不动就撤销,减得了就给商位置 1。整个流程和硬件里的"移位寄存器加加减法器"完全一致,调 bits 就是调被除数和除数的位宽。

参数说明:bits 必须大于等于被除数的实际位宽,否则高位数据会在左移时被截掉;除数等于 0 要在进入循环前拦截,硬件里这是异常输入,真实设计会直接返回一个错误标志而不是继续运算。这个版本每步最坏要做两次操作,硬件不会直接用;改成非恢复版本后每步固定一次加或减,SRT 再进一步用冗余余数消除商选择的精确比较,那是另一个量级的话题了。

4. IEEE 754 与浮点单元:规格化、舍入和 FMA 的真实取舍

4.1 浮点不是"带小数点的数",而是一套三点约定

浮点数的标准结构是符号、指数、尾数三段,但教材强调的是这三个字段背后的设计意图。规格化数要求尾数最高位是 1,这个 1 在存储时被省略,单精度 23 位尾数实际携带 24 位精度。指数采用偏置表示,单精度偏置 127,这么做的好处是浮点数的大小比较可以直接用整数比较器完成,硬件省掉一大块逻辑。

非规格化数(subnormal)的存在让很多人第一次读时觉得奇怪:为什么指数全 0 时 hidden bit 失效?答案是为了下溢平滑。允许非规格化数时,结果在逐步变小的过程中不会突然跳到 0,精度是慢慢丢的;代价是处理非规格化数的乘法和除法需要额外的移位和规范化逻辑。嵌入式和 GPU 领域常见做法是 flush-to-zero,直接把非规格化结果刷成 0,牺牲精度换吞吐,这是工程取舍,不是标准错误。

4.2 舍入和异常:最容易在参考模型里翻车的地方

IEEE 754 定义了五种舍入模式,默认是最近偶数。最难理解的是 ties to even:两个候选值距离一样近时,选尾数最低位是偶数的那一个。参考模型如果只做截断,与硬件对拍必然失败。另一个高频坑是 FMA,先算无限精度乘法,最后只舍入一次。没有 FMA 的模型和有 FMA 的硬件在边界场景下可能差 1 ulp,而这个差值往往在验证最后期才暴露。

异常方面,invalid 操作产生 NaN,除零产生无穷。NaN 还有一个常被忽略的参数是 payload,硬件往往会对 NaN 做规范化处理,如果参考模型直接忽略 payload,对拍时会出现假失败。处理这类问题我的习惯是:参考模型里把 NaN 的符号位和 payload 一起建模,哪怕只比对有限数,也要保证 NaN 路径不被误报。

4.3 用 Python 搭一个可对照验证的浮点参考模型

下面这段代码演示 FMA 与"先乘后加"的真实差异,可以直接跑。用 Decimal 模拟无限精度是验证浮点单元时最常用的参考做法:

from decimal import Decimal, getcontext def mul_then_add(a, b, c): """非FMA路径: 乘法结果先舍入到double, 再加c""" return float(Decimal(a) * Decimal(b)) + c def fused_mul_add(a, b, c): """FMA路径: 无限精度乘法, 与c相加后只舍入一次""" getcontext().prec = 50 exact = Decimal(a) * Decimal(b) + Decimal(c) return float(exact) # 构造一个乘法结果恰好落在两个double之间需要舍入的case x = 1.0000000000000002 # 1 + 2^-53 y = 2.0 z = -2.0 print(mul_then_add(x, y, z)) # 非FMA: 0.0 print(fused_mul_add(x, y, z)) # FMA: 2.220446049250313e-16

逻辑说明:Decimal(a) 把一个 double 精确转成十进制数,Python 的 float 到 Decimal 转换是精确的,所以 Decimal(a)Decimal(b)+Decimal(c) 没有舍入误差,最后一次 float(...) 才做单次舍入。xy 在非 FMA 路径下要先舍入到 2,再加 -2 得到 0;FMA 路径保留精确乘积,加 -2 后剩 2^-53,转成 double 正好是 2.220446049250313e-16。这个对照就是硬件里两条指令路径的差异。

参数说明:getcontext().prec = 50 是 Decimal 的运算精度,50 位十进制在这个 case 里足够,因为 double 的有效精度约 16 位十进制。如果验证更高精度的自定义浮点格式,按有效位数加指数位数动态调大 prec。对拍时不要用 == 判断结果,要么按 ulp 差值小于等于 1 接受,要么显式传舍入模式后逐位比较;直接比相等会把合法的舍入差异全部报成错误,这是参考模型最常见的使用误区。

5. Computer Arithmetic 落地排查:5 个高频翻车现场

5.1 溢出判断写错:两个负数相加结果却判成正数

现象:验证 8 位加法器时,a = -3,b = -5,sum = +8,溢出标志位却输出 0,测试直接失败。

原因:很多人用进位输出 Cout 来判断有符号溢出,但无符号进位和有符号溢出根本不是一回事。两个负数相加,最高位进位可以是 1,同时符号位被吃掉,结果看起来是正数,这时有符号溢出的正确判断要看最高位进位和次高位进位的异或。

解决:把溢出判断统一写成 C_out XOR C_{n-1}。补码下 -3 + -5 = -8 不溢出,两个最高位进位值相同;-5 + -6 = -11 溢出,最高位进位 1、次高位进位 0,异或为 1。写 RTL 时把这个表达式单独拉成一个 wire,不要混在 sum 的逻辑里,综合时路径更清晰。

5.2 补码最负数的取反:INT_MIN 的 abs 是它自己

现象:把 -128 送进 abs 逻辑,输出还是 -128。测试用例里加了这个边界,仿真直接 fail,有人觉得是取反电路坏了。

原因:8 位补码表示范围是 [-128, 127],没有 +128 这个位型。取反加一对 -128 来说正好还是 0x80,这不是电路 bug,是补码表示本身不对称。数学上 abs(-128)=128,但这个结果在 8 位里写不进去。

解决:要么在 abs 之前判断操作数是否为最小值,单独走异常路径;要么把结果位宽放宽到 n+1 位,用 9 位表示 128。验证用例里必须固定加上这个边界,尤其是做除法、求余、坐标变换这类 abs 高频场景,漏掉这个 case 后面多半要返工。

5.3 舍入模式不一致:C 库、硬件、参考模型各算各的

现象:同一个浮点运算,C 代码输出和 RTL 结果对不上,始终差 1 ulp,而且只在部分数据上出现。

原因:三个层面的舍入上下文没有对齐。C 编译器可能在优化选项下把乘加融合成 FMA;硬件舍入模式寄存器可能被配置成 round-up;参考模型用了默认的 nearest-even 但没显式指定。

解决:逐一固定。编译时加 -ffp-contract=off 禁止融合,硬件初始化代码里把浮点控制寄存器读出来并和 RTL 配置比对,参考模型函数显式接收 rounding mode 参数,禁止用全局默认。对拍脚本里把 ulp 差大于 1 的报 error,差 1 的单独归档,这样既能抓真错误,又不会被合法的舍入差异淹没。

5.4 除法器延迟估算偏差:面积和时序相互拉扯

现象:综合报告里除法器关键路径 2.1ns,时序收敛,但整块芯片布局布线后最大的 timing violation 出现在除法器周围。

原因:除法器共享总线上做部分余数更新,布局后走线变长,扇出负载增加,路径延迟比综合阶段估算的明显恶化。非恢复除法的商选择逻辑在不同工艺角下的漂移也比加法器更敏感。

解决:估算除法器延迟时留 20% 到 25% 的布线余量,别按综合报告直接拍板。把除法器拆成两段流水,每段只放商选择的一部分逻辑,关键路径自然变短。结构上优先选 radix-2 的简单商选择,除非面积预算明确允许才上 radix-4。

5.5 移位指令的"假装乘法":无符号与有符号右移混用

现象:软件模型里 a >> 1 和硬件结果在 a 为负数时差很远,比如 -5 >> 1 软件算 -3,硬件输出 253。

原因:C 语言对负数右移是算术右移还是逻辑右移由实现决定,Verilog 里的 >> 则是纯逻辑右移,符号位用 0 填充。两边约定不一致,负数用例全部错位。

解决:RTL 里对有符号信号用 $signed(a) >>> 1 做算术右移,参考模型里显式用有符号类型或者先判断符号位再填充。测试向量必须覆盖 0x80、0xFF 这类符号位为 1 的边界地址,不要只拿正整数做移位测试,否则这个坑会在集成测试阶段才炸出来。

6. 验证算术块最顺手的一招:随机激励加参考模型对拍

算术单元验证的通用做法是随机激励加参考模型对拍,但真正决定效率的不是随机数量,而是参考模型和 DUT 在三个上下文上完全一致:位宽截断方式、舍入模式、符号扩展规则。任何一处不一致,随机量再大也是假失败。下面是一段最小可用的对拍骨架,参考模型用纯函数写,DUT 接口留给你自己的仿真环境去填:

import random def ref_add16(a, b, cin=0): """16位补码加法参考模型: 先截断再回符号""" s = (a + b + cin) & 0xFFFF return s - 0x10000 if s & 0x8000 else s def dut_add16(a, b, cin=0): # 替换成你的协同仿真/RTL调用, 返回已换算成有符号整数的结果 raise NotImplementedError("换成你的DUT调用") # 定向边界用例: 最负数、0、1、最大值 cases = [(v, v) for v in [-32768, -1, 0, 1, 32767]] cases += [(-32768, 32767), (32767, -1), (-1, -32768)] # 随机用例 for _ in range(5000): a = random.randint(-32768, 32767) b = random.randint(-32768, 32767) cases.append((a, b)) for a, b in cases: exp = ref_add16(a, b) got = dut_add16(a, b) if exp != got: print("MISMATCH", hex(a & 0xFFFF), hex(b & 0xFFFF), exp, got) break else: print("ALL PASS")

逻辑说明:先跑定向边界,再放随机,是避免随机用例覆盖不到最负数这类极端情况的习惯。参考模型里 a + b 用的是 Python 无限精度整数,最后 & 0xFFFF 模拟位宽截断,再根据最高位回符号,这个顺序和 RTL 里的补码加法器行为完全一致。

参数说明:5 到 8 行这两个随机区间控制的是测试输入分布,如果验证的是带舍入的浮点单元,要把区间改成指数跨度更大的浮点值组合,并额外枚举 NaN、无穷和非规格化数。经验是定向用例占两成、随机占八成,比纯随机更容易撞出边界问题。我自己的习惯是每个算术单元提交前先跑一遍 INT_MIN 与邻居的定向组合,再放五万随机用例,很多移位和比较的翻车案例都是这样在仿真阶段抓住的,没有一次拖到综合之后。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进