说实话,补码的乘运算这门课,刚学时很多人都会被“符号位到底参不参与运算”“最后移位又移的是什么”这些细节绕晕。刷了无数遍王道视频、啃了唐朔飞的教材,最后发现核心其实就一句话:补码乘法不用单独处理符号位,符号位直接参与运算,而实现这个“直接参与”的算法,就是Booth一位乘法。这篇文章我不讲虚的,直接把这套算法的来龙去脉、手算流程、C语言模拟,以及我当年踩过的一堆坑全部端出来。
1. 先理清基础:补码、双符号位和算术右移
1.1 补码的编码逻辑:为什么二进制世界爱用补码
我们学原码、反码、补码的时候,总觉得补码就是个“取反加一”的操作。但补码真正的价值在于:它把减法统一成了加法。有了补码,计算机里就不需要专门的减法器了。这是硬件设计上极其重要的简化。
补码的定义可以用“模”来理解。对定点整数来说,n+1位补码(含符号位),它的含义是:
[x]补 = 2^(n+1) + x (mod 2^(n+1))
对定点小数来说:
[x]补 = 2 + x (mod 2)
这个“模”的概念一到位,很多问题就通了。比如为什么负数补码取反加一?因为要满足 [x]补 + [-x]补 ≡ 0 (mod 2^(n+1))。加法器里一旦产生最高位的进位,就直接丢弃,这也是补码运算“模”性质的体现。
补码还有一个很重要的性质,就是真值 x 和补码的关系可以写成:
x = -x0 + Σ(i=1 to n) xi * 2^(-i)
其中 x0 是符号位。这个公式在Booth算法的推导中会直接用到,先记住它。
1.2 双符号位到底是干嘛的
很多同学看到补码乘法里被乘数要用“双符号位”(也叫变形补码),就有点懵:符号位一个不够,还要两个?
双符号位的本质是给溢出判断留了空间。正常情况下,两个符号位应该相同:正数两个0,负数两个1。如果运算过程中两个符号位变成了01或10,那说明结果溢出了。注意,补码乘法的过程中部分积是会溢出的,如果没有第二个符号位兜底,一次进位就可能把符号位冲掉,导致后续计算全错。
举个例子,两个负数相加,符号位都是1,正常两个1相加会有进位。如果只有一个符号位,这个进位直接覆盖了符号位本身,得到的结果符号就反了。双符号位可以多接住一次进位,让结果保持正确。补码乘法过程中“先加后移”的操作模式,中间结果很容易出现需要双符号位才能正确表示的情况。
1.3 算术右移和逻辑右移的区别,千万别搞混
移位是补码乘法的核心操作,但很多同学手算时最爱在这里翻车。
右移分两种。逻辑右移就简单粗暴:高位一律补0。而对补码来说,逻辑右移会改变负数的真值,因为补码的负数是高位一连串1,你在左边补0,这个数的数值意义就变了。
算术右移不一样,它右移时左边补的是符号位本身。正数高位补0,负数高位补1,这样每右移一位就相当于真值除以2。补码乘法里的部分积右移,必须用算术右移。乘数寄存器Q在联合移位时,最高位接收的是ACC的最低位移过来的数据,而不是保持自己的符号位,这个细节后文再细讲。
2. 深入Booth一位乘法:从原码乘法的痛点说起
2.1 原码乘法的麻烦在哪
原码一位乘法大家应该都知道:符号位单独异或处理,数值位绝对值相乘,每步根据乘数当前最低位是1还是0决定加不减被乘数绝对值,然后右移一位。
最大的问题有两个。第一,符号位单独处理,那硬件上要么多一个符号判断逻辑,要么额外多一次异或。第二,乘数的符号位在移位过程中是特殊处理的,不能完全当作普通位来移。这在硬件实现上就得多出控制逻辑,不够统一。
补码乘法如果能做到符号位和数值位“一视同仁”,硬件实现就简洁多了。Booth一位乘法就是干这件事的。
2.2 Booth算法的核心推导:从相邻位的差值出发
我当初学Booth算法的时候,最不理解的就是:为什么每次要盯着乘数的最低两位,而且是Q0和附加位Q_(n+1)这一对“相邻位”来判决。
这里的逻辑推导很漂亮。设乘数 y 的补码是 y0.y1 y2 ... yn,根据1.1节那个补码真值公式:
y = -y0 + y12^(-1) + y22^(-2) + ... + yn*2^(-n)
现在我们来做一个小把戏,把这个式子改写一下。引入一个 y_(n+1) = 0(这就是附加位的来历),可以把 y 改写成:
y = (y1 - y0)*2^0 + (y2 - y1)*2^(-1) + (y3 - y2)*2^(-2) + ... + (0 - yn)*2^(-n)
你展开一下就能验证:y1的系数是 1 - 1/2 = 1/2,y2的系数是 1/2 - 1/4 = 1/4,以此类推,最后yn的系数还是2^(-n),而 -y0 项完整保留。所以这个展开式和原式完全等价。
最关键的是每个括号 (y_(i+1) - y_i) 只会是 -1、0、1 三种值。括号等于0就什么都不加;括号等于1就加被乘数;括号等于-1就减被乘数(也就是加被乘数的相反数,即加[-x]补)。判决依据就看当前相邻两位y_i y_(i+1):
- 00 或 11:差值0,加0
- 01:差值1,加[x]补
- 10:差值-1,加[-x]补
这就是Booth算法那张判决表的来历,完完全全从数学上推导出来的,不是拍脑袋定的规则。
2.3 寄存器布局:ACC、MQ、附加位
硬件或模拟过程中,我们需要三样东西:
- ACC(累加器):存放部分积,采用双符号位加n位数值位的格式。初始为0
- MQ(乘商寄存器):存乘数y的补码,单符号位加n位数值位。初始就是[y]补
- 附加位C:在MQ的最低位后面额外加一位,初始值固定为0
这里要特别注意位宽。假设数值位有n位,那么ACC是n+2位(双符号位+ n位数值),MQ是n+1位(1符号位 + n位数值),再加1位附加位。手算时经常写成:
ACC = 00.0000(这里假设n=4,双符号位+4位数) MQ = 1.0101(y的补码,单符号位+4位数) 附加位 = 0
2.4 算法流程:几轮判决,几次移位
补码一位乘法(Booth法)的完整流程:
- 初始化ACC=0,MQ=[y]补,附加位=0
- 重复以下过程n+1次:
- 检查MQ最低位Q0和附加位C的取值组合
- 按组合表对ACC加对应值(0、[x]补或[-x]补)
- 若还未到第n+1次,则ACC、MQ、附加位联合右移一位(算术右移)
- 最后一次(第n+1次)只做判决加法,不再右移
注意我这里特别写了“n+1次判决、n次移位”。这是很多资料里最容易让新手混乱的细节。有的教材写成“n次加法和n次右移”,那种写法通常默认最后一次加法已经含在初始状态里。咱们按n+1次判决来算,思路是干净的。为什么最后一次不右移?因为前面的n次右移已经把部分积放到了正确的位置,最后一步加完就是最终结果,如果再右移就等于整体多除了一个2,结果就错了。
3. 完整手算:x=0.1101,y=-0.1011
3.1 准备数据,确定码制
来看一个最经典的考研级例题。设:
x = +0.1101,y = -0.1011
先把所有码制都写清楚:
- [x]补 = 00.1101(双符号位)
- [-x]补 = 11.0011(这是 x 相反数的补码,也就是对[x]补整体再取一次相反数)
- [y]补 = 1.0101(单符号位,MQ初始值)
- 附加位初始 = 0
数值位n=4,所以整体流程是5次判决、4次右移。
这里有个容易出错的地方:[-x]补是怎么来的?是对 00.1101 连同符号位一起取反加一,得到 11.0010 + 1 = 11.0011。很多同学手算时只对数值位取反,符号位不动,那就错了。补码的求相反数操作,符号位是连坐的。
3.2 五位判决、四次移位的完整过程
我直接给一张我当年复习时自己整理的过程表,每一行对应一次判决加移位后的状态。
| 步骤 | 判决组合(Q0 C) | 操作 | ACC | MQ | 附加位C |
|---|---|---|---|---|---|
| 初始 | - | - | 00.0000 | 1.0101 | 0 |
| 1 | 10(Q0=1,C=0) | ACC+[-x]补,右移 | 11.0011 → 右移后 11.1001 | 1.1010 | 1 |
| 2 | 01(Q0=0,C=1) | ACC+[x]补,右移 | 11.1001+00.1101=00.0110 → 右移后 00.0011 | 0.1101 | 0 |
| 3 | 10(Q0=1,C=0) | ACC+[-x]补,右移 | 00.0011+11.0011=11.0110 → 右移后 11.1011 | 0.0110 | 1 |
| 4 | 01(Q0=0,C=1) | ACC+[x]补,右移 | 11.1011+00.1101=00.1000 → 右移后 00.0100 | 0.0001 | 0 |
| 5 | 10(Q0=1,C=0) | ACC+[-x]补,不移位 | 00.0100+11.0011=11.0111 | 0.0001 | 0 |
强调一下,表里的MQ我写的是移完之后的完整值,看起来“符号位”变了,但那已经不是乘数符号了,它只是ACC最低位移过来之后的数据,别再用乘数符号的视角看它。
3.3 结果拼接与验证
第5步结束后,ACC=11.0111,MQ=0.0001。最终结果怎么拼?
乘积总位数是2n+2位,即双符号位2位+8位数值位。取ACC完整的6位(11.0111),再接上MQ的低4位(因为MQ高1位是移位过程中从ACC挤过来的,不是有效数值位)。所以:
乘积 = 11.0111 + 0001 = 11.01110001
来验证一下。真值计算:0.1101 = 13/16,-0.1011 = -11/16,乘积 = -143/256。
看补码 11.01110001,这是负数。数值位 01110001,取反加一:10001110+1=10001111 = 143。确实是 -143/256。完全吻合。
所以整个手算过程验证下来,Booth算法每一步都自洽。建议你自己独立算一遍,尤其注意第2步和第4步加完之后的ACC进位情况,感受一下双符号位是怎么“吞掉”进位的。
4. C语言模拟Booth乘法:把算法跑起来
手算懂了之后,我建议你写个几十行的C语言程序把它模拟一遍。写代码的过程能把很多模糊的细节逼出来,比如位宽截断、右移方向、附加位更新时机。下面是我当时的一个精简版本,核心逻辑都在。
#include <stdio.h> // 用整数的低6位表示ACC(双符号位+4位数值) // 用整数的低5位表示MQ(单符号位+4位数值) // 为了清晰,这里每一步都打印状态 int main() { // 准备数据:x=+0.1101, y=-0.1011 int x = 0b001101; // [x]补 = 00.1101 int neg_x = 0b110011; // [-x]补 = 11.0011 int mq = 0b10101; // [y]补 = 1.0101 int acc = 0; // 部分积初始为0 int c = 0; // 附加位Q(n+1),初始0 int mask_acc = 0x3F; // 保留6位 int mask_mq = 0x1F; // 保留5位 printf("初始: ACC=%06b MQ=%05b C=%d\n", acc, mq, c); for (int step = 1; step <= 5; step++) { // n+1 = 5次判决 int q0 = mq & 1; int combo = (q0 << 1) | c; // 高1位是Q0,低1位是C if (combo == 0b01) { acc = (acc + x) & mask_acc; // 01:加[x]补 printf("步骤%d: 组合01,ACC+[x]补 -> %06b\n", step, acc); } else if (combo == 0b10) { acc = (acc + neg_x) & mask_acc; // 10:加[-x]补 printf("步骤%d: 组合10,ACC+[-x]补 -> %06b\n", step, acc); } else { printf("步骤%d: 组合00或11,ACC+0 -> %06b\n", step, acc); } if (step < 5) { // 前4次右移,最后一次不移 int sign = (acc >> 5) & 1; // 取ACC符号位 // 把ACC(6位)、MQ(5位)、C(1位)拼成一个12位数,再整体右移 int total = (acc << 6) | (mq << 1) | c; total = (total >> 1) | (sign << 11); // 算术右移,高位补符号位 total &= 0xFFF; acc = (total >> 6) & mask_acc; mq = (total >> 1) & mask_mq; c = total & 1; printf(" 右移后: ACC=%06b MQ=%05b C=%d\n", acc, mq, c); } } printf("最终: ACC=%06b MQ=%05b C=%d\n", acc, mq, c); return 0; }这段代码的思路就是严格按照表里的流程来走。关键点在于“联合右移”那一步:把ACC、MQ、附加位三个寄存器拼成一个连续的位串,整体右移一位,左端补的是ACC的符号位(sign),而不是补0。这就是算术右移在寄存器级别的实现。
运行结果你会看到每一步的状态变化,和第三章那个手算表一一对应。我当初调这个代码时就发现,如果右移时忘了补符号位,最后一位的结果必然对不上,这本身就是理解算术右移最好的实验。
5. 常见错误与排查技巧实录
5.1 附加位初始值的坑
附加位初始必须是0。很多人会问,为什么乘数补码后面还要人为加一个0?回到2.2节的推导,我们是引入 y_(n+1)=0 来做相邻位差的,所以这个初始0是推导过程的一部分,不是随便加的。但如果乘数是负数补码,比如1.0101,把它当成一个整体,附加位还是0,不要因为符号位是1就把附加位也置1。
5.2 移位时补0还是补符号位,一定要分清
部分积ACC右移时必须补符号位,也就是算术右移。很多同学在这里用逻辑右移,给负数部分积补了个0,结果每移一次,数值就错一次。最简单的方法就是用我代码里的思路:先取ACC最高位,然后整体右移时把这一位补到最前面。补码的算术右移等价于真值除以2,这是整个算法的数学基础,破坏了这一点,后续全部错。
还有一个易错点:MQ在联合右移时,最高位接收的是ACC移出来的最低位,不是保持MQ自身的符号位。MQ的符号位在第一次移位后就没有意义了,它只是乘积低位的临时存放处。手算时不要看到MQ里符号位变了就觉得是错。
5.3 最后一次到底要不要移位
答案是:最后一次判决后不右移。原因前面说过,Booth法的迭代公式决定了第n+1次加法之后恰好是最终结果。如果你多右移一次,结果等于整体除以2,符号位也可能被破坏。考试时算到最后一位,一定要停下来,别顺手又移一次。
5.4 结果拼接时MQ的位宽问题
这是最隐蔽的一个坑。我曾见过不少同学算完ACC=11.0111、MQ=00001后,直接把两者拼成11.011100001,多了一位,结果怎么验都不对。
正确做法是:最终乘积取ACC完整6位 + MQ的低4位(也就是去掉MQ的最高位)。因为MQ总共5位,最高位在移位过程中接收的是ACC的最低位移过来的值,它实际是乘积的中间扩展位,不构成最终乘积的有效数值。拼接后应该是11.01110001而不是11.011100001。多出来的那一位会让结果凭空多一个2的负幂次,误差就出来了。
5.5 双符号位溢出判断
运算过程中,如果ACC两个符号位不一致,比如变成了01或者10,说明这一步加法溢出了。但在Booth算法里,这种情况大多不是真正的溢出,而只是中间结果超出了单符号位的表示范围,双符号位正好兜住了。操作上不用特殊处理,继续移位就行,符号位会自动调整回来。真正需要警惕的是最终结果的两个符号位不一致,那说明乘积本身超出可表示范围,这时候才叫真溢出。
5.6 考研、期末常考题型速查
| 题型 | 考察点 | 易错点 |
|---|---|---|
| 给x、y补码,手算Booth乘法 | 判决表记忆、移位次数 | 最后一次移位、MQ位宽 |
| 判断补码乘法溢出 | 双符号位判决 | 把中间状态误判为溢出 |
| 补码右移一位求值 | 算术右移规则 | 负数补0的错误 |
| 求[-x]补 | 符号位一起取反加一 | 只对数值位取反 |
| 结果真值转换 | 补码转真值 | 忘了符号位影响 |
5.7 我的独家记忆技巧
最后分享几个我当年死磕出来的小技巧。
第一,判决表别死记。你就想“01”是从0变1,相当于乘数在这一位上从无到有,所以要加被乘数[x]补;“10”是从1变0,相当于从有到无,所以要减被乘数,加[-x]补。00和11就是没变化,加0。这样记,比背表稳得多。
第二,移位次数记“n次移位,n+1次判决”。数值位是4位就右移4次、判断5次,数值位是8位就右移8次、判断9次。这是通用规律。多出来的一次就是不右移的收尾加法。
第三,手算时把ACC、MQ、附加位三个东西竖着排,每做完一步就更新一整行,用箭头标出“右移”前后状态。这样最后出了错也能顺着表往回查,比闷头算到底再回头找错快得多。
Booth一位乘法只是补码乘法入门,后面还有补码两位乘法(Booth两位乘)、阵列乘法器、流水线乘法器等等,但核心的判决思维和移位思想是互通的。把这张表吃透、把例子亲手算三遍、把代码跑通,补码乘法这一块就稳了。