简介:《计算机运算》第二版是Behrooz Parhami教授关于计算机算术算法与硬件设计的经典著作,适合计算机科学、电子工程专业学生及硬件设计、嵌入式系统工程师研读。全书系统覆盖数的表示与进制转换、IEEE 754浮点格式、补码加减运算及溢出处理、乘法与除法器硬件实现,并延伸至平方根迭代算法、ALU构造、流水线与并行处理等高级主题,兼顾理论基础与工程实践。资源为PDF格式,共1个文件,压缩包大小3.9MB,内容为原版电子书,便于检索、标注与反复查阅。已吸引1674人学习下载,足见其在计算机体系结构领域的参考价值。通过本书读者可系统掌握计算机算术底层原理与硬件设计思路,为深入理解CPU运算单元、优化数值计算性能打下扎实基础。
1. computer arithmetic 开篇:从浮点数的“不精确”说起
写 C 或 Rust 做数值计算的人,迟早会撞上一个反直觉现象:0.1 + 0.2在 double 里并不等于0.3。这不是语言实现的问题,而是二进制浮点表示在底层带来的约束。IEEE 754 标准只是这整套规则里最容易被感知的一层,真正困难的在于:浮点运算单元里加法器如何传播进位、除法器如何迭代余数、舍入发生在哪一步、下溢时硬件该做什么。这一整块内容,体系结构领域习惯统称为 computer arithmetic。网上能搜到、也被从业者当作经典参考的第二版《Computer Arithmetic》,核心价值就是把表示、运算算法与硬件映射这条线完整串起来。本文顺着这条线,从舍入规则出发,讲到加法器、乘法器、SRT 除法与平方根迭代,最后落回一套可执行的验证方法,把理论和落地路径一次讲透。
2. computer arithmetic 的表示层:补码、舍入模式与下溢边界
2.1 补码溢出与同一加法器完成加减法
computer arithmetic 不只在讲浮点,整数运算同样属于这个范畴。任何处理器都要同时支持整数加减乘除与移位,而这些运算基于定点二进制表示。补码表示有一个很值得注意的特性:最高位的权值是负的。对 n 位补码,范围是-2^(n-1)到2^(n-1)-1,而无符号数范围是0到2^n-1。两者范围不同,但加减法共用一个加法器,区别只在于是否有符号扩展和溢出判断。下表以 8 位为例给出边界:
| 表示方式 | 最小值 | 最大值 | 典型使用场景 |
|---|---|---|---|
| 无符号数 | 0 | 255 | 地址、计数器、位掩码 |
| 补码 | -128 | 127 | 有符号整数运算 |
| 二进制原码 | -127 | 127 | 少数 DSP 定点格式 |
| 偏置码 | -128 | 127 | IEEE 754 指数部分 |
补码的优势在于“减法是加补码”,这意味着一套加法器电路就能同时完成加减法。溢出判断规则很直接:两个正数相加得到负数,或两个负数相加得到正数,即发生溢出。具体到电路上,只要看最高位进位C_out与次高位进位C_n-1是否不同,也就是V = C_out XOR C_n-1。这个表达式在综合工具里很常见,但也容易被随手写错,尤其是做有符号与无符号混合运算时。实际工程中更稳妥的做法是在 RTL 里显式区分输入是否有符号,而不是靠综合器自动推断。
提示:补码的
-2^(n-1)没有对应的正数,这是取绝对值时需要单独处理的边界情况。很多数值库在计算abs(INT_MIN)时踩坑,根就在这。
2.2 IEEE 754 舍入模式:RNE 是默认值但不是唯一选择
浮点数不是无限精度的。两个浮点数做乘法时,尾数位宽会翻倍,结果必须舍入回目标格式。IEEE 754 定义了四种舍入模式:就近偶舍入(RNE)、就近远离零舍入(RNA)、向零舍入(RZ)、向上舍入(RP)、向下舍入(RM)。其中 RNE 是默认模式,它的规则是“结果恰好落在两个可表示数中间时,选尾数为偶数的那一个”。这个细节常被忽视,但它直接影响数值稳定性,也为后面判断硬件实现是否符合规范提供了抓手。
四种模式的具体行为差异如下:
| 舍入模式 | 正值示例 | 负值示例 | 实现倾向 |
|---|---|---|---|
| RNE 就近偶 | 2.5 -> 2 | -2.5 -> -2 | 大多数 CPU ALU 默认 |
| RNA 就近远离零 | 2.5 -> 3 | -2.5 -> -3 | 需要额外判定逻辑 |
| RZ 向零截断 | 2.5 -> 2 | -2.5 -> -2 | 实现最简单,DSP 常用 |
| RP 向上 | 2.1 -> 3 | -2.1 -> -2 | 区间运算中常用 |
| RM 向下 | 2.1 -> 2 | -2.1 -> -3 | 区间运算中常用 |
要用足够直观的方式验证舍入行为,可以用 Python 的struct把单精度浮点拆成位模式,直接观察尾数低位的变化:
import struct def f2bits(x: float) -> str: # 单精度转 32 位无符号整数,再按符号/指数/尾数切分 b = struct.unpack('<I', struct.pack('<f', x))[0] sign = (b >> 31) & 0x1 exp = (b >> 23) & 0xFF frac = b & 0x7FFFFF return f"sign={sign} exp={exp:#010b} frac={frac:023b}" for v in [1.5, 2.5, 3.5, 0.1, 0.2, 0.3]: print(f"{v:5.1f} -> {f2bits(v)}")这段代码里,struct.pack('<f', x)先把 Python float 截成单精度,struct.unpack再解析成一个整数,最后用位运算切分三个字段。运行后能清楚看到0.1在二进制里并不是 0.1,而是0x3DCCCCCD这个近似值;0.3则是0x3E99999A。这两个近似值相加后再舍入,和0.3的比特位存在偏差,于是 0.1+0.2 不等于 0.3 的根源就被定位到位模式层面了。
2.3 下溢与亚正常数:不可忽略的边界层
浮点格式里指数全 1 表示无穷大与 NaN,指数全 0 则用来表示零和亚正常数。亚正常数的作用是让绝对值小于最小正规数的数值仍然具备一定的精度。正规数的最小正数是2^-126,在它之下如果没有亚正常数,半步就会直接跳到 0,运算结果会产生一个很大的相对误差。引入亚正常数之后,下溢路径变成“先渐进到亚正常数,再变成零”,代价是硬件需要额外处理这种非规格化输入。
C 标准提供fpclassify宏来区分这些类别,在写底层数值库时很实用:
#include <stdio.h> #include <math.h> int main(void) { double vals[] = {0.0, -0.0, 1e-310, 1e-320, INFINITY, NAN}; for (int i = 0; i < 6; i++) { int cls = fpclassify(vals[i]); // 依次判断正规数、亚正常数、零、无穷、NaN if (cls == FP_NORMAL) printf("normal\n"); else if (cls == FP_SUBNORMAL) printf("subnormal\n"); else if (cls == FP_ZERO) printf("zero\n"); else if (cls == FP_INFINITE) printf("infinite\n"); else if (cls == FP_NAN) printf("nan\n"); } return 0; }这段代码的要点是:1e-310在大多数实现中是亚正常数,1e-320可能已经是 0,运行后不同架构的结果会略有差异,正好能用来确认目标平台对下溢的处理是否符合 IEEE 754 的渐进下溢要求。实际项目里,如果某段算法频繁产生亚正常数,性能会显著下降,因为很多硬件遇到亚正常数会走慢速路径。真正要区分“精度足够但没有性能”,还是“性能拉满但精度不足”,就要在定义浮点格式时把亚正常数的开关策略想清楚。
3. 从加法器到乘法器:computer arithmetic 的运算单元怎么搭
3.1 进位链决定的性能:行波进位与超前进位
加法器是计算机算术最基本的运算单元,所有复杂运算最终都可以化归为若干次加法。最朴素的行波进位加法器把每一位的进位串行传递,n 位加法的关键路径长度是 O(n)。在这个结构里,高位的和必须等低位的进位稳定下来才能计算,组合逻辑延迟随位宽线性增长。对于 32 位或 64 位运算,这个延迟在高速处理器里是不可接受的。
解决办法是让“进位生成”和“进位传播”信号并行推导。定义两个信号:g_i = a_i & b_i表示第 i 位一定产生进位,p_i = a_i ^ b_i表示第 i 位会传播低位进位。那么进位可以写成展开式:
module cla4( input [3:0] a, b, input cin, output [3:0] s, output cout ); wire [3:0] g = a & b; // 进位生成 wire [3:0] p = a ^ b; // 进位传播 wire [3:0] c; assign c[0] = cin; assign c[1] = g[0] | (p[0] & c[0]); assign c[2] = g[1] | (p[1] & c[1]); assign c[3] = g[2] | (p[2] & c[2]); assign cout = g[3] | (p[3] & c[3]); assign s = p ^ c; // 和位 = 传播位异或进位 endmodule这段代码里g、p是组合信号,每位进位不再等待前一位完成,而是由底层输入直接算出来。c[1]只依赖g[0]、p[0]和cin,c[3]虽然表达式更长,但同样只依赖原始输入,因此逻辑深度基本固定。实际芯片不会直接展开到 64 位全超前进位,因为扇出太大,常见的做法是 4 位或 8 位一组做 CLA,组间再用行波或更高一级 CLA 连接。工程上这叫“组内超前进位、组间行波”,是一个面积和延时的折中。这里给出一组参考数据:
| 加法器类型 | 典型位宽 | 关键路径量级 | 面积代价 |
|---|---|---|---|
| 行波进位 RCA | 32 | O(n) | 最小 |
| 超前进位 CLA | 32 | O(log n) | 中等 |
| 进位选择 CSLA | 32 | O(sqrt(n)) | 较大 |
| 进位跳跃 CSkA | 32 | O(sqrt(n)) | 中等 |
选择依据不只看延迟,还要看布局布线约束。对 FPGA 来说,LUT 结构本身自带快速进位链,直接用行波进位综合出来的结果往往比手写 CLA 更快,因为 FPGA 的专用进位路径是硬核资源。对 ASIC 设计则要老老实实做逻辑综合和时序分析。手写加法器前先看一下目标工艺和综合策略,否则可能做了无用功。
3.2 Booth 重编码:减少部分积才是乘法器面积的关键
乘法器的直接实现方式是把被乘数左移若干位后求和,每一行部分积对应乘数的一位。n 位乘 n 位会产生 n 个部分积,求和树的面积和布线随之增大。Booth 重编码的思路是:与其让乘数逐位决定“加被乘数”还是“加 0”,不如把连续的多位一起看,用一次移位或取负来等价表示。最常用的是基 4 Booth 编码,每次看乘数的三位(y_{2i+1}, y_{2i}, y_{2i-1}),产生一个系数{-2, -1, 0, 1, 2},于是部分积数量从 n 个降到 n/2 个。系数 2 可以用左移一位实现,系数 -2 则先取反再加 1,硬件上只多一个符号扩展逻辑。
看一个最小化的例子,假设寄存位宽被限制,则每轮迭代表达式为:
// 基4 Booth编码器,输入相邻3位,输出控制信号 module booth_enc( input [2:0] y, // y[1:0] 当前位, y[2] 低位 output reg [2:0] sel // 0: 0, 1: +x, 2: +2x, 3: -x, 4: -2x ); always @(*) begin case (y) 3'b000, 3'b111: sel = 3'd0; 3'b001, 3'b010: sel = 3'd1; 3'b011: sel = 3'd2; 3'b100: sel = 3'd4; // -2x 3'b101, 3'b110: sel = 3'd3; // -x endcase end endmodule这个编码器的输入y是乘数相邻三位,输出sel决定当前部分积是被乘数、两倍被乘数,还是它们的负数。实现里还要处理符号扩展:负数部分积必须把符号位扩展到乘法结果全宽再求和,否则部分积相加时会出错。这是很多人第一次写 Booth 乘法器时最容易漏掉的地方。漏符号扩展的典型症状是:正数乘法完全正确,一旦乘数是负数,结果低位正确但高位多出全 1。定位方法很简单,把中间部分积打印出来,对照《Computer Arithmetic》里关于符号扩展的推导检查即可。
3.3 乘法结果截断与粘滞位:精度控制在硬件层收口
浮点乘法器尾数部分通常把 n 位乘 n 位结果保留 2n 位,再舍入回 n 位。舍入时需要知道三个信息:保留位、舍入位、粘滞位。保留位是结果中将被丢弃部分的最高位,舍入位是它的下一位,粘滞位则是指示被丢弃部分是否含任何非零位。粘滞位的计算很直接:所有保留位以下的位做逻辑或。如果没有粘滞位,往“偶尾数”舍入时就无法判断精确中间值,结果会偏随机,导致同一套输入在不同实现里产生不同结果。硬件中用粘滞位压缩电路把这些低位归并成一个位,能显著减少加法树宽度。这条规则在编写浮点仿真模型时同样适用:先用高精度累加结果,再手动按保留位、舍入位、粘滞位实现 RNE 逻辑,对比硬件 RTL 输出的位模式,可以快速定位实现错误。
4. 除法与平方根:computer arithmetic 里最难啃的迭代算法
4.1 SRT 除法:用冗余余数换掉进位传播等待
除法在处理器里一向是出故障最多的模块。直接试商法需要计算减去除数的余数并判断符号,每步都要等进位链稳定,延迟与位宽成正比,这在现代处理器中不可接受。SD 有余数法,也就是 SRT 除法,核心技巧是允许余数有冗余,也就是不要求每次迭代的余数严格落在某个小区间里,而是把它放在一个冗余范围里,下一步可以纠偏。这样每次迭代可以用少量位来查表决定商位,避开全宽加法器的进位传播等待。
基 4 SRT 除法的商位选取用部分余数和除数的高位查表。典型的查找表规模在 4KB 到 16KB 之间,具体行数取决于冗余度和部分余数的位数。常用参数大致如下:
| 参数名称 | 典型取值 | 说明 |
|---|---|---|
| 基数 r | 4 | 每次迭代产生 2 个二进制商位 |
| 商位集 | {-2,-1,0,1,2} | 冗余表示,允许选择非精确商位 |
| 部分余数位数 | 6~8 | 参与查表的高位截断 |
| 除数位数 | 3~4 | 只需除数前几位参与查表 |
| 商选择表行数 | 2^7 到 2^9 | 每行对应一组余数高位区间 |
实现时有个关键约束:商选择表必须保证选择后的余数满足|R_next| <= (D * rho),其中 rho 是冗余系数。如果查找表太小或截断太狠,会出现“错误的商位但再也无法恢复”的情况,这在测试中表现为随机输入偶发错误结果。定位这类问题最直接的方法,是把算法级模型里每次迭代的余数区间打印出来,对比 RTL 仿真的部分余数,第一处超出边界的位置就是出错根源。
4.2 平方根迭代:与除法同源的递推
平方根本质上和除法共享同一套减去除法的框架,差别在于除数变成了不断扩展的根值。每次迭代,当前根 Q 会更新为Q_next = Q + q * r^-j,余数递推式与除法高度相似,只是减去的项带上了 2Q 这个因子。硬件实现通常直接复用除法器的大部分数据通路,只需额外增加一个“根值拼接寄存器”。
软件层面的模拟可以用逐位求根法,下面是一个简单的基 2 迭代框架:
def isqrt_fixed(n: int, bits: int = 16) -> int: # 用定点整数模拟逐位平方根:每次试根1位,判断余数是否非负 root = 0 rem = 0 for i in range(bits - 1, -1, -1): rem = (rem << 2) | ((n >> (2 * i)) & 3) cand = (root << 2) | 1 # 试商位为 1 时的候选值 if rem >= cand: rem -= cand root = (root << 1) | 1 # 该位确认为 1 else: root = root << 1 # 该位确认为 0 return root这段代码每轮把余数左移两位,取被开方数高两位拼入,得到当前余数,再与候选值比较。它演示的是平方根除法算法家族里“试商”的基本形态。参数bits控制被开方数的位宽,输出root是整数平方根。逐位法可以验证正确性,但性能不高;实际软浮点环境更常用牛顿迭代,下一节展开。
4.3 软件除法与牛顿迭代:没有硬件除法器时的兜底路径
在缺少硬件除法器的嵌入式平台或软浮点环境里,Newton-Raphson 迭代是常见替代方案。它把除法a/b转化为求1/b的根x,迭代公式x_{n+1} = x_n * (2 - b*x_n),每次迭代精度翻倍。初值通常用一个小的查找表或浮点近似指令获得,迭代一到三次就能达到 double 精度:
double soft_div(double a, double b) { // 初值用 1/b 的粗略近似,实际工程里应查一个 8 或 16 项的表 double x0 = 1.0 / b; // 仅演示收敛路径,不是最终结果 // 第一轮牛顿迭代:修正相对误差 double x1 = x0 * (2.0 - b * x0); // 第二轮迭代后精度接近 double 上限 double x2 = x1 * (2.0 - b * x1); return a * x2; }这段代码里x0直接用除法得到一个近似值,工程实现应换成查表或位操作来避免递归依赖硬件除法。每轮迭代包含两次乘法和一次减法,没有除法指令依赖,所以能在只支持乘法浮点运算的核上运行。要注意的是牛顿迭代无法直接处理除数为 0、结果为 NaN 或无穷的这些分支,调用前必须检查b的类别,否则异常会顺着乘法链被“修正”成错误的有限值。另一个常见误区是一上来就迭代三次,实际上 double 的 52 位尾数只要初值精度达到 13 位左右,三轮迭代后误差就低于 ULP,再加轮次只会引入舍入噪声。
5. 验证一套 computer arithmetic 实现:边界向量、黄金参考与异常标志
5.1 关键边界向量与预期行为
验证浮点实现的第一件事不是跑随机数,而是构造一组能触发特殊路径的边界向量。IEEE 754 里最容易出错的输入都集中在零、无穷、NaN 与指数边界附近。我一般会先用下面这张表做冒烟测试:
| 输入组合 | 预期行为 | 对应检查点 |
|---|---|---|
| 0.0 + 0.0 | +0.0 | 符号位处理 |
| -0.0 + -0.0 | -0.0 | 零的符号规则 |
| +inf + -inf | NaN | 异常输入检测 |
| 最大正规数 + 最大正规数 | +inf | 溢出标志 |
| 最小亚正常数 / 2 | 0 或渐进下溢 | 下溢标志 |
| NaN 参与任一运算 | NaN | NaN 传播规则 |
| 0.0 * +inf | NaN | 无效操作标志 |
5.2 用黄金参考向量做逐位回放
边界冒烟通过后,还要验证普通数值路径的逐位一致性。常见做法是用 Python 或 C 的高精度参考实现生成一段测试向量,然后把 DUT 输出按位比对。比对的不是 double 值是否相等,而是最终输出与参考结果的位模式完全一致,因为 IEEE 754 要求运算结果必须像先精确计算再舍入一样。生成向量的一种简单方法是使用线性同余随机数发生器,保证每次运行序列一致:
python3 gen_ref.py 100000 > ref.txt ./dut_runner < ref.txt | sha256sum sha256sum ref_expected.txt这里gen_ref.py输出的是每个用例的原始输入和参考位模式,dut_runner读取同样输入并把 DUT 结果以十六进制写出。两条 sha256 值一致说明整条路径的舍入行为没有偏差。有一点要提醒:不要在测试用例里用memcmp直接比较double的二进制表示,除非你已经预先确定了 NaN 的负载位格式。NaN 的尾数位在不少处理器上会被修改,比较时应该先掩掉尾数低位,或者只比较符号位与指数位。
5.3 浮点异常标志:验证不可只对数值
有些错误只有数值正确性但异常标志错误,这种情况在边界用例中很常见,比如溢出结果是对的,但 FE_OVERFLOW 没有置位。抓这类问题的标准做法是读写浮点环境:
#include <fenv.h> #include <stdio.h> int main(void) { feclearexcept(FE_ALL_EXCEPT); double r = 1e308 * 10.0; // 应当触发溢出 int raised = fetestexcept(FE_OVERFLOW | FE_INEXACT); if (raised & FE_OVERFLOW) printf("overflow raised\n"); if (raised & FE_INEXACT) printf("inexact raised\n"); printf("result: %f\n", r); return 0; }编译运行用gcc -std=c11 fenv_test.c -lm && ./a.out。feclearexcept先清空所有标志,fetestexcept再检查指定标志位。这套接口在 RTL 验证里对应异常输出端口的断言,把“结果值”和“标志位”分开检查,才能在算法级模型里尽早发现硬件控制逻辑的错误。验证到这一步,一套 computer arithmetic 实现的正确性边界才算被完整覆盖过一遍。
本文还有配套的精品资源,点击获取