☰
计算机算术经典第二版:从补码到浮点舍入的工程避坑指南
2026/10/14 3:38:08 网站建设 项目流程

简介:《Computer Arithmetic》第二版是Behrooz Parhami教授的经典教材,面向计算机科学与电子工程专业学生、硬件设计及嵌入式系统工程师,系统讲解计算机算术运算的算法与硬件实现。内容覆盖数制转换与IEEE 754浮点表示、加减法中的进位借位与溢出处理、Karatsuba与FFT快速乘法、长除法及除法器设计、平方根迭代算法与硬件加速器,并延伸至ALU构造优化、流水线、并行计算与向量处理等提升运算性能的关键技术。资源为1个PDF文件,压缩包约3.9MB,篇幅完整、结构清晰,便于按章节检索学习。目前已有1685人学习下载,适合作为课程教材、科研参考或工程实践中的案头工具书,帮助读者建立从基础算法到硬件设计的完整知识体系。

1. 为什么一本讲计算机算术的老书,今天还在被一线工程师翻出来

做芯片验证、做编译器后端、做数值库优化的人,迟早会撞上同一堵墙:浮点结果对不上、定点溢出、除法器时序收敛不了、舍入模式一改整条流水线全崩。这时候翻手册只能查到“是什么”,查不到“为什么这么设计”。而《Computer Arithmetic》第二版这类经典,讲的恰恰是“为什么”——从补码加减法的硬件代价,到Booth编码怎么把乘法器面积砍掉一半,再到IEEE 754舍入模式背后的边界条件。它不教你调库,它教你判断一个算术单元该不该这么搭。适合谁?适合已经能写RTL或汇编、但一遇到精度和时序问题就靠试参数的人。这篇笔记不逐章翻译,而是把这本书里最常被工程复用的几条线抽出来,配上能跑的验证代码和参数表,让你看完能自己搭一个最小算术单元并测出它的边界。

2. 从补码到溢出判断:定点加减法的最小可复现单元

2.1 补码加减法的硬件本质与溢出条件

补码不是“一种表示法”那么简单,它是把减法变成加法的工程妥协。核心事实只有一条:A - B = A + (~B) + 1。硬件上这意味着一个加法器加一排反相器就能同时支持加减,控制信号只多一根。但代价是溢出判断不能只看最高位进位。

对于n位补码,溢出发生在“同号相加结果变号”时。更精确的硬件判据是:overflow = C_n XOR C_{n-1},其中C_n是符号位向外的进位,C_{n-1}是次高位向符号位的进位。这个判据在书里反复出现,因为它直接对应一个异或门,几乎零成本。

我一般会先写一个行为级模型验证判据,再落到RTL。下面这段Python把4位补码的所有加减组合跑一遍,标出溢出:

def twos_comp_add(a, b, bits=4): mask = (1 << bits) - 1 # 转成无符号参与运算,模拟硬件位宽 ua, ub = a & mask, b & mask raw = ua + ub result = raw & mask # 溢出判据:同号相加结果异号 sign_a = (ua >> (bits - 1)) & 1 sign_b = (ub >> (bits - 1)) & 1 sign_r = (result >> (bits - 1)) & 1 overflow = (sign_a == sign_b) and (sign_r != sign_a) # 转回有符号解释 if result >= (1 << (bits - 1)): result -= (1 << bits) return result, overflow # 遍历4位所有组合,打印溢出案例 for a in range(-8, 8): for b in range(-8, 8): r, ov = twos_comp_add(a, b) if ov: print(f"{a} + {b} = {r} (overflow)")

逻辑说明:mask保证位宽截断,raw保留进位信息但这里只用结果。溢出判据用的是“同号相加结果异号”,这比进位异或更直观,两者等价。参数bits改成8或16可以直接复用,但注意Python的负数取模行为,所以先& mask再运算。

2.2 用Verilog搭一个带饱和的加减器并跑仿真

行为模型对了,下一步是RTL。定点DSP里常见需求是“溢出饱和”而不是回绕,因为回绕会让滤波器输出炸掉。下面是一个参数化模块,支持加/减、饱和/回绕两种模式:

module add_sub #(parameter WIDTH = 8) ( input wire [WIDTH-1:0] a, b, input wire sub, // 1=减法, 0=加法 input wire sat_en, // 1=饱和, 0=回绕 output reg [WIDTH-1:0] y, output reg overflow ); wire [WIDTH-1:0] b_eff = sub ? ~b : b; wire cin = sub; wire [WIDTH:0] sum = {1'b0, a} + {1'b0, b_eff} + cin; wire ov = (a[WIDTH-1] == b_eff[WIDTH-1]) && (sum[WIDTH-1] != a[WIDTH-1]); always @(*) begin overflow = ov; if (ov && sat_en) y = a[WIDTH-1] ? {1'b1, {(WIDTH-1){1'b0}}} : // 负饱和 {1'b0, {(WIDTH-1){1'b1}}}; // 正饱和 else y = sum[WIDTH-1:0]; end endmodule

逻辑说明:b_eff在减法时取反,cin补1,完成补码减法。sum多一位用来捕获进位。ov判据和Python版一致。饱和值:负溢出取最小负数(符号位1,其余0),正溢出取最大正数(符号位0,其余1)。参数WIDTH改位宽,sat_en切换模式。仿真时重点看ov在边界值(如127+1、-128-1)是否拉高,以及饱和输出是否停在极值。

2.3 三个必调参数:位宽、饱和阈值、流水线级数

位宽不是越大越好。位宽每加1,加法器面积约线性增长,但乘法器面积平方增长。我一般按“信号动态范围+6dB余量”估位宽,再用仿真确认。饱和阈值要和系统协议对齐,比如音频16位常用-32768到32767,但中间累加器往往用32位甚至40位,最后再饱和截断。流水线级数取决于时序:一个n位加法器在常见工艺下大约能跑几百MHz,如果目标频率更高就得插寄存器,但插了就要处理流水线延迟对齐。书里对进位链延迟的分析在这里直接决定你插几级。

3. Booth乘法与Wallace树:把乘法器面积和时序同时压下来

3.1 基2 Booth编码为什么能减少部分积

朴素乘法器对n位乘数产生n个部分积,每个部分积要么是0要么是被乘数。Booth编码观察到一个事实:连续的1可以用“加一次、减一次”代替多次加。基2 Booth把乘数每两位一组看,遇到01结尾就加被乘数,遇到10结尾就减被乘数,00和11不动。这样部分积数量从n降到约n/2,面积和功耗都降。

代价是编码逻辑变复杂,而且需要处理“借位”带来的符号扩展。书里给了一张表,我把它整理成可查的形式:

乘数位对 (当前,前一位)操作说明
000无操作
01+被乘数一串1的开始
10-被乘数一串1的结束
110连续1中间

注意前一位初始为0。这个表是Booth的核心,所有变体(基4、改进Booth)都是它的扩展。

3.2 用Python验证基2 Booth编码的正确性

在写RTL之前,先用Python把编码和部分积生成跑通,避免在波形里抓瞎:

def booth_encode(multiplier, bits=8): # 在最低位后补0,方便两两分组 m = multiplier & ((1 << bits) - 1) m_ext = (m << 1) | 0 ops = [] for i in range(bits): pair = (m_ext >> i) & 0b11 if pair == 0b01: ops.append(('+', i)) elif pair == 0b10: ops.append(('-', i)) else: ops.append(('0', i)) return ops def booth_multiply(mcand, mplier, bits=8): # 有符号乘法,用Booth编码累加 ops = booth_encode(mplier, bits) acc = 0 for op, shift in ops: if op == '+': acc += mcand << shift elif op == '-': acc -= mcand << shift # 截断到2*bits位有符号范围 mask = (1 << (2*bits)) - 1 acc &= mask if acc >= (1 << (2*bits - 1)): acc -= (1 << (2*bits)) return acc # 随机验证 import random for _ in range(1000): a = random.randint(-128, 127) b = random.randint(-128, 127) assert booth_multiply(a, b) == a * b, (a, b) print("Booth编码验证通过")

逻辑说明:booth_encode把乘数扩展一位后逐位取两位,生成操作列表。booth_multiply按操作累加被乘数的移位值。注意移位是算术左移,负数被乘数左移后仍是负数,Python的<<对负数也成立。参数bits控制位宽,验证时用8位随机数跑1000次,覆盖正负组合。

3.3 Wallace树压缩:从部分积到最终和的时序优化

Booth减少了部分积数量,但把它们逐个加起来仍然是一条长进位链。Wallace树用进位保存加法器(CSA)把部分积按列压缩,每级把三个数压成两个(和与进位),直到剩两个数再用一个普通加法器收尾。这样进位链长度从O(n)降到O(log n)。

书里对Wallace树的描述偏理论,工程上我一般用“3:2压缩器”搭。一个3:2 CSA就是sum = a ^ b ^ c,carry = (a & b) | (b & c) | (a & c),然后carry左移一位参与下一级。下面是一个4个部分积的压缩示例:

def csa(a, b, c): # 3:2压缩器,返回(sum, carry),carry需左移一位 s = a ^ b ^ c carry = (a & b) | (b & c) | (a & c) return s, carry << 1 # 假设4个部分积p0..p3,先压p0,p1,p2 p0, p1, p2, p3 = 0b1010, 0b0110, 0b1100, 0b0011 s1, c1 = csa(p0, p1, p2) # 再压s1,c1,p3 s2, c2 = csa(s1, c1, p3) # 最后普通加法 result = s2 + c2 print(bin(result))

逻辑说明:csa实现一位全加器的和与进位,但按位并行。carry << 1是因为进位要加到高一列。实际硬件里所有位同时做,这里用整数模拟。参数上,部分积数量决定压缩级数,4个部分积只需两级。注意符号扩展要在压缩前处理好,否则高位会错。

4. 除法器的取舍:恢复余数、不恢复余数与SRT的工程边界

4.1 三种除法算法的迭代次数与硬件代价对比

除法是算术单元里最不“规整”的。恢复余数法每步试减,若不够减就恢复,逻辑简单但慢。不恢复余数法(加减交替法)根据当前余数符号决定下一步加还是减,省掉恢复操作,迭代次数固定为n次。SRT算法每次看多位商,迭代次数降到n/2或n/4,但需要查表决定商位,表的大小和精度直接相关。

算法每步操作迭代次数硬件代价适用场景
恢复余数减、判、可能恢复n低教学、低速
不恢复余数加或减n中通用
SRT基4查表+加减n/2高高性能CPU

书里对SRT的查表边界讲得很细,尤其是“商位选择表”的冗余设计,允许一定误差来简化表项。工程上如果目标频率不高,不恢复余数法往往是最优解,因为面积小、验证简单。

4.2 用不恢复余数法实现8位无符号除法

下面这段Python把不恢复余数法的每一步打印出来,方便对照硬件状态机:

def restoring_divide(dividend, divisor, bits=8): # 不恢复余数法,返回商和余数 if divisor == 0: raise ValueError("除数为0") # 余数寄存器初始为被除数高半部分,这里简化用全宽 rem = dividend quo = 0 for i in range(bits - 1, -1, -1): # 左移余数,低位补被除数当前位(这里被除数已在rem里) rem = (rem << 1) & ((1 << (bits + 1)) - 1) # 根据余数符号决定加或减 if rem & (1 << bits): # 余数为负 rem = (rem + divisor) & ((1 << (bits + 1)) - 1) else: rem = (rem - divisor) & ((1 << (bits + 1)) - 1) # 商位:若余数非负则置1 if not (rem & (1 << bits)): quo |= (1 << i) # 最后若余数为负,恢复 if rem & (1 << bits): rem = (rem + divisor) & ((1 << (bits + 1)) - 1) return quo, rem # 测试 for d in [1, 3, 7, 15, 255]: q, r = restoring_divide(200, d) assert q * d + r == 200, (d, q, r) print("除法验证通过")

逻辑说明:rem用bits+1位,最高位是符号位。每步左移后根据符号决定加或减除数。商位在余数非负时置1。最后若余数为负要恢复。参数bits控制位宽,注意dividend要小于2^bits。这个版本为了清晰没有做被除数逐位输入,实际硬件里被除数是从高位逐位移入的。

4.3 除零与溢出:两个必须硬连线的异常

除零在硬件里不能靠软件捕获,必须在除法器里直接拉异常。判据是divisor == 0,输出商全1或全0取决于协议,但异常信号必须有效。溢出发生在有符号除法的最小负数除以-1,结果超出正数范围。这两个异常在书里都有专门小节,工程上我一般把它们做成组合逻辑,和除法迭代并行,一旦触发就旁路迭代结果。

5. 浮点舍入与精度:IEEE 754边界条件的避坑清单

5.1 四种舍入模式在硬件里的实现差异

IEEE 754定义了四种舍入:就近舍入到偶数(默认)、朝零、朝正无穷、朝负无穷。就近舍入到偶数最难,因为它需要看保护位、舍入位和粘滞位。硬件上通常用“ guard/round/sticky”三位来判断:guard是结果最低位后一位,round是再后一位,sticky是剩余所有位的或。判据是:若guard=1且(round=1或sticky=1或结果最低位=1),则进位。这个逻辑直接对应几个门,但粘滞位的计算需要把所有低位或起来,位宽大时会有延迟。

5.2 用Python模拟浮点加法的舍入过程

下面这段代码手动实现单精度浮点加法的对齐、相加、规格化和舍入,重点展示粘滞位怎么算:

import struct def float_add_manual(a, b): # 拆解单精度 def unpack(x): u = struct.unpack('>I', struct.pack('>f', x))[0] sign = (u >> 31) & 1 exp = (u >> 23) & 0xFF frac = u & 0x7FFFFF return sign, exp, frac sa, ea, fa = unpack(a) sb, eb, fb = unpack(b) # 隐含1 if ea != 0: fa |= 0x800000 if eb != 0: fb |= 0x800000 # 对齐指数,小指数右移 if ea < eb: shift = eb - ea fa >>= shift ea = eb else: shift = ea - eb fb >>= shift eb = ea # 相加(同号加,异号减,这里简化只处理同号) if sa == sb: frac = fa + fb sign = sa else: if fa >= fb: frac = fa - fb sign = sa else: frac = fb - fa sign = sb # 规格化 exp = ea while frac & 0xFF000000: frac >>= 1 exp += 1 while not (frac & 0x800000) and frac: frac <<= 1 exp -= 1 # 舍入:取低24位,看guard/round/sticky if frac & 0x800000: guard = (frac >> 22) & 1 round_bit = (frac >> 21) & 1 sticky = 1 if (frac & 0x1FFFFF) else 0 if guard and (round_bit or sticky or ((frac >> 23) & 1)): frac += 0x400000 if frac & 0x1000000: frac >>= 1 exp += 1 # 组装 frac &= 0x7FFFFF u = (sign << 31) | (exp << 23) | frac return struct.unpack('>f', struct.pack('>I', u))[0] # 测试边界 print(float_add_manual(1.0, 1.0)) print(float_add_manual(1.0, 2**-24))

逻辑说明:unpack拆出符号、指数、尾数。对齐时小指数尾数右移,移出的位本应进入粘滞位,这里简化直接丢弃,实际硬件要保留。舍入部分用guard/round/sticky判据,frac >> 23 & 1是结果最低位。参数上,单精度是1+8+23,双精度改位宽即可。注意这个实现没有处理非规格化数和特殊值,只用于理解舍入流程。

5.3 精度损失的三个隐蔽来源

第一是对齐时的粘滞位丢失,如果右移超过尾数位宽,所有移出位都要或进粘滞位,漏掉就会导致舍入方向错。第二是规格化时的前导零计数,如果计数器有误,指数会偏。第三是舍入后的再次规格化,进位可能让尾数溢出,必须再右移一次并加指数。这三个点在书里都有波形级的描述,工程上我一般用随机测试覆盖,尤其是接近2的幂和接近舍入边界的值。

6. 避坑与排查:算术单元验证中最容易翻车的五件事

6.1 现象:仿真通过但上板结果随机错

原因:仿真时用了行为级+,综合工具把它映射成某种加法器,但上板时序不满足,进位链太长导致亚稳态。解决:在RTL里显式插入流水线寄存器,或者用综合工具报告的关键路径延迟反推最高频率,再决定是否拆分加法器。

6.2 现象:Booth乘法器在负数输入时结果偏小

原因:符号扩展没做对。Booth编码的部分积是带符号的,每个部分积都要扩展到最终位宽,且最高位要复制。漏掉扩展或扩展位数不够,负数就会算错。解决:在生成部分积时统一扩展到2*bits位,用被乘数的符号位填充高位。

6.3 现象:除法器迭代次数对但商最后一位总错

原因:最后一步余数为负时没有恢复。不恢复余数法在迭代结束后,如果余数为负,必须加一次除数恢复,同时商的最低位要修正。解决:在状态机最后加一个恢复状态,或者用余数符号直接修正商位。

6.4 现象:浮点加法在接近零时结果跳变

原因:非规格化数处理缺失。当指数为0时,隐含位是0不是1,对齐和规格化逻辑都要单独处理。解决:在拆解和组装时判断指数是否为0,非规格化数的规格化要左移直到隐含位为1,同时指数从1开始减。

6.5 现象:饱和加法在边界值上输出回绕

原因:饱和判据用了进位异或,但减法时b_eff取反后符号位变了,判据要基于b_eff而不是原始b。解决:统一用a和b_eff的符号位比较,如2.2节代码所示。

7. 把算术单元做成可复用的验证IP:一个具体技巧

书读到最后,最有价值的不是某个算法,而是把每个算术单元都配一套“边界值+随机+形式验证”的测试。我一般会写一个参数化的测试平台,用Python生成激励,用Verilog跑仿真,再用断言检查溢出、饱和、舍入标志。下面是一个用 cocotb 风格的伪代码示意,重点在激励生成策略:

# 伪代码:算术单元验证激励生成 def gen_add_stimulus(width): # 边界值:最大正、最小负、零、±1 max_pos = (1 << (width-1)) - 1 min_neg = -(1 << (width-1)) edges = [max_pos, min_neg, 0, 1, -1, max_pos-1, min_neg+1] for a in edges: for b in edges: yield a, b # 随机补充 import random for _ in range(1000): yield random.randint(min_neg, max_pos), random.randint(min_neg, max_pos)

逻辑说明:边界值覆盖了溢出、饱和、零等关键点,随机补充覆盖组合空间。参数width控制位宽。这个生成器可以复用到加法、减法、乘法、除法,只需改边界值定义。我自己的习惯是:每换一个工艺或频率目标,先跑边界值,再跑随机,最后用形式验证工具证明溢出判据等价于规范。这样即使换了综合选项,也有后悔药可吃。

最后说个血泪经验:算术单元的bug往往不在算法本身,而在符号扩展、粘滞位、恢复余数这些“边角料”上。我一般会把书里每张表都抄一遍,然后用代码验证表里的每一行。这个笨办法帮我省下了至少三次流片前的通宵排查。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询