先说个真事。去年帮一个做数据采集的朋友排查问题,他拿MATLAB解析一串十六进制报文,里面有温度、有电压,结果所有负温度全变成了三百多度。代码翻来覆去看了半天,最后定位到一句hex2dec之后直接参与运算,一个0xFE被当成 254 处理,后面的加减乘除全跟着错了。这种问题,根子不在MATLAB,在于对“有符号数在机器里到底长什么样、乘法又是怎么算的”没建立起直觉。
这篇东西我想把二进制补码下的有符号数乘法一次说透。不管你是写C、写Verilog,还是拿MATLAB做数据解析,只要你跟底层数据打过交道,迟早会撞上补码乘法这个坎。我会从补码的数学本质讲起,把符号扩展法、Booth算法、代码实现和常见坑全部串一遍,配合完整的手算例子,争取让你看完之后,再遇到负数乘法不再“懵”。
1. 从一次线上事故说起:补码乘法为什么容易翻车
1.1 补码的本质:一个公式看懂补码
很多人学补码的时候,背的是“取反加一”。这个口诀没错,但只告诉你“怎么变”,没告诉你“为什么”。真正要理解有符号数乘法,必须回到补码的数学定义。
对n位二进制数 (x = x_{n-1}x_{n-2}...x_1x_0),它作为补码表示的真值是:
[ x = -x_{n-1} \cdot 2^{n-1} + \sum_{i=0}^{n-2} x_i \cdot 2^i ]
翻译成人话:最高位是符号位,但它不只是“符号”,它带的是负权值。一个4位补码1101,按这个公式算就是 (-1 \times 8 + 1 \times 4 + 0 \times 2 + 1 \times 1 = -3)。1111是 (-1 \times 8 + 7 = -1),1000是 (-8),也就是为什么4位补码能表示的范围是[-8, 7],负数比正数多一个。
这个公式是理解一切补码运算的钥匙。加法为什么直接加就行?因为补码把负数也编码成了模 (2^n) 下的一个数,减法变成加一个负数,硬件上统一用加法器处理。那乘法呢?如果你把两个补码按无符号数直接乘,就等于忽略了最高位的负权值,结果自然不对。补码乘法的所有实现技巧,本质上都是在处理这个“负权值最高位”。
1.2 乘法的真正难点:位宽与截断
乘法和加法最大的区别在于位宽。两个n位补码数相乘,结果的完整表示需要2n位。比如4位补码能表示的最大正数7,(7 \times 7 = 49),得用8位补码00110001才能装下。如果你非要把结果塞回4位,那得到0001,也就是1,这显然不对。但如果你算的是(-1) \times (-1) = 1,4位足够。所以“该用多少位存结果”取决于具体数值,而不是统一规则——初学者最容易在这里踩坑。
另一个坑来自截断。实际硬件和高级语言里,乘法结果经常被截断到固定位数。截断的实质是取模 (2^n)。问题在于:一个补码数经过取模之后,它代表的正负可能完全变掉。比如8位补码里的11111111是-1,截断到4位后变成1111,仍然是-1,数值没变;但8位补码里的01111111(127)截断到4位变成1111,-1,数值面目全非。截断是否安全,取决于目标位宽能不能容纳真实结果。
这里可以借用矩阵乘法里的观点来打个比方。矩阵乘法有“行观点”和“列观点”:行观点是结果矩阵的每个元素由左矩阵的一行和右矩阵的一列做内积得到,列观点则是右矩阵的每一列是左矩阵对该列列向量的线性组合。二进制乘法的“竖式计算”也有两种看法:行观点是把乘数按每一位拆开,每位产生一个移位后的被乘数(部分积),最后全加起来;列观点则是一次性排列所有部分积,按列累加。补码乘法的符号扩展法,本质就是先通过符号扩展把“负权值最高位”的问题转化成“无符号乘法”,再用行观点把部分积加在一起。
1.3 三条技术路线的总览
工程上有三种主流做法,各有适用场景:
- 符号扩展法:先把两个操作数都符号扩展成2n位,然后按无符号乘法直接算,最后截取低2n位。这是软件实现和手工推导最稳妥的方法,因为它的正确性最直观,不容易出错。
- 原码乘法:先取绝对值,按无符号乘法算,最后根据两个操作数的符号位异或结果,给结果补上符号位或取补码。这是早期硬件经常采用的方式,缺点是先取绝对值需要额外逻辑,而且有
-8这种绝对值算不出来的特殊情况(-8的绝对值8用4位表示不了)。 - Booth算法:直接在补码域里做乘法,通过扫描乘数的连续“0/1”游程来减少部分积数量。这是现代硬件乘法器的主流实现,也是很多计算机组成原理教材的必讲内容。
下面几节我把这三条路线逐一拆开,先讲最容易理解也最稳妥的符号扩展法,再讲硬件里面真正用的Booth算法。
2. 纯手算路线:符号扩展法为什么最稳妥
2.1 符号扩展的数学原理
先回答一个问题:为什么符号扩展之后,按无符号乘法算,结果就是对的?
设x和y都是n位补码数。把它们符号扩展成2n位后得到 (x') 和 (y'),注意 (x') 作为2n位补码的值,和x作为n位补码的值是相等的。符号扩展不改变数值,这是补码最优雅的性质之一:1011是-5,符号扩展成11111011,还是-5,因为高位的1在8位补码里的总贡献正好等于−128 + 112 = −16,恰好补上原来4位里最高位从−8移到−128时多出的部分,更严谨说,扩展后的高位1加在一起,数值上相当于多减了 (2^{2n-1}),又多加回 (2^{2n-1} - 2^{n-1}),净变化为零。
现在把 (x') 和 (y') 都当成无符号2n位整数相乘,得到的乘积在模 (2^{2n}) 意义下,恰好等于两个2n位补码的乘积在补码下的编码。也就是:无符号乘法器的位级结果,和补码乘法器完全一致,只要操作数已经符号扩展到足够的位宽。所以,只要把两个n位操作数符号扩展到2n位,做一次无符号乘法,截取低2n位,得到的就是正确的补码乘积。简单、直接、不需要任何修正逻辑。
这正是为什么很多处理器指令集里,MUL(无符号乘)和IMUL(有符号乘)在低半部分的结果是相同的,区别只在高半部分。x86的32位模式下,MUL用EDX:EAX装64位结果,IMUL同样用EDX:EAX,两个指令的低32位完全一样,差别在EDX。这个特性经常被用来做“无符号乘取低32位当成有符号用”的等价替换,原理就在上面这段。
2.2 手算实例:负乘正、正乘负、负乘负
光讲理论不够,我拿4位补码手算三个典型例子,你跟着走一遍就通了。
例子一:((-5) \times 6 = -30)
-5 的4位补码是1011,6 的4位补码是0110。符号扩展成8位:11111011和00000110。
把00000110拆成 (2^1 + 2^2),所以乘积是11111011 \ll 1 + 11111011 \ll 2:
111110110 + 1111101100 ------------ 10111100010得到11位结果10111100010。注意无符号视角下,这相当于 (251 \times 6 = 1506)。截取低8位11100010,这是无符号的226。226在8位补码里是多少?(226 - 256 = -30)。结果对了,-30。
例子二:(3 \times (-2) = -6)
3的4位补码0011,-2的4位补码1110。符号扩展8位:00000011 \times 11111110。
无符号视角是 (3 \times 254 = 762)。二进制是1011111010(10位)。截取低8位11111010,这个是补码,(250 - 256 = -6)。正确。
例子三:((-3) \times (-3) = 9)
-3的4位补码1101,符号扩展8位11111101,无符号视角是 (253 \times 253 = 64009),这个数二进制是1111101000101001(16位)。截取低8位00001001,就是9。正确。
三个例子跑完,你可能会发现一个规律:符号扩展法本质上是把负数映射到一个很大的“无符号替身”上,乘完再通过截断把结果拉回正确的模意义下。整个过程不需要判断符号,不需要修正,这才是它稳健的原因。
2.3 实战要点:位宽怎么定、溢出怎么判
符号扩展法在纸上算没问题,落到代码和硬件时要确认三件事。
第一,扩展到位宽必须足够。n位乘n位,至少扩展到2n位,然后截取低2n位作为结果。扩展少了不行,因为部分积之间的进位会从高位溢出,把结果高位吃掉。扩展多了没关系,只要最终截取的正确位宽保持不变即可。实际工程中我会先算好乘积的理论最大位宽,比如两个8位补码相乘,理论上是16位结果,那就用一个16位信号承接,绝不会拿8位去存。
第二,注意“负数比正数多一个”的问题。n位补码的负数是 (-2^{n-1}) 到 (-1),正数是 (1) 到 (2^{n-1}-1)。最极端情形是 ((-2^{n-1}) \times (-2^{n-1}) = 2^{2n-2}),这是一个正的 (2n-1) 位数值,2n位补码能装下(最大 (2^{2n-1}-1)),但1位符号位+2n-1位数值位刚好顶满。也就是说,2n位乘法结果不会溢出,这是补码乘法的一个关键性质——只要输出位宽是输入位宽的两倍,就一定不会溢出。很多硬件乘法器设计正是利用这一点,直接把两个n位输入接到2n位输出的乘法器上,省掉溢出判断逻辑。
第三,判断“中间结果要不要截断”。如果你的算法是逐步移位累加,中间累加器必须保持2n位宽度,不能贪图省寄存器用n位,否则进位丢了,神仙都救不回来。我在FPGA上调试时遇到过好几次这种问题:仿真波形看着某一步的值特别大,然后下一步突然变成一个毫无关联的小数,多半是中间累加器位宽不够被截断了。
3. 硬件视角:Booth算法与部分积的生成
3.1 从竖式乘法到部分积阵列
符号扩展法在软件和手工推导里很好用,但对硬件不友好。原因在于,两个n位数相乘,会产生n个部分积,每个部分积都要做符号扩展,然后全部相加。n=8的时候还好,n=64的时候,64个64位(甚至128位)数相加,面积和功耗都吃不消。所以硬件乘法器一般不直接做“竖式”,而是想尽办法减少部分积数量。
减少部分积的关键是:乘数里连续的1可以合并处理。还记得小学学乘法时,你用乘数的每一位去乘被乘数,每个“1”都会产生一个部分积。但如果你把乘数看成二进制的“0/1串”,一串连续的1比如011110,可以等价看成 (100000 - 000010),也就是一个高位的加,加一个低位的减。这样原本4个部分积,变成2个。Booth算法的核心思想就是这个。
3.2 一位Booth算法完整推导与实例
标准的一位Booth算法(也叫基2 Booth,radix-2 Booth)这样操作:在乘数最低位右边额外添一位 (Q_{-1}),初始为0。每一步看当前乘数最低位 (Q_0) 和它的右邻 (Q_{-1}):
| (Q_0) | (Q_{-1}) | 操作 | 含义 |
|---|---|---|---|
| 0 | 0 | 无操作 | 连续的0,不需要加 |
| 0 | 1 | 加被乘数M | 一个连续的1串开始,加M |
| 1 | 0 | 减被乘数M(加-M的补码) | 一个连续的1串结束,减M |
| 1 | 1 | 无操作 | 连续的1中间,不需要加 |
每处理完一步,把“累加器A + 乘数Q + 扩展位Q-1”这个组合整体算术右移一位,然后重复n次。算术右移的意思是最高位补符号位,而不是补0。
我拿前面例子二的 (3 \times (-2)) 来走一遍完整流程,被乘数 (M = 3 = 0011),乘数 (Q = -2 = 1110),n=4,初始 (A = 0000),(Q_{-1} = 0)。
第一步:(Q_0=0, Q_{-1}=0),无操作。组合{A,Q,Q-1}为0000 1110 0,算术右移一位,变成0000 0111 0。此时 (A=0000, Q=0111, Q_{-1}=0)。
第二步:(Q_0=1, Q_{-1}=0),执行 (A = A - M = 0000 - 0011 = 1101)。右移组合1101 0111 0,注意A的最高位是1,所以右移时A的高位补1,变成1110 1011 1。此时 (A=1110, Q=1011, Q_{-1}=1)。
第三步:(Q_0=1, Q_{-1}=1),无操作。右移1110 1011 1,A的高位是1,补1,变成1111 0101 1。(A=1111, Q=0101, Q_{-1}=1)。
第四步(最后一次移位完成):(Q_0=1, Q_{-1}=1),无操作。右移1111 0101 1变成1111 1010 1。此时 ({A,Q} = 11111010),这是8位补码,值为 (250 - 256 = -6)。和预期一致。
看到没,最后的{A,Q}组合就是完整的2n位乘积。整个过程中没有“先取绝对值再算”的绕路,也没有符号扩展一堆部分积的麻烦,硬件只需要一个加法器/减法器、一个移位寄存器和一个有限状态机。
Booth算法在实际硬件里通常用**基4(radix-4)**版本,即一次看乘数的3位,将部分积数量减少一半,同时采用编码后的乘数位({-2, -1, 0, 1, 2})来控制被乘数的移位倍数。基4 Booth设计起来更复杂,但思路完全一样。如果读者只是想做原型或者写作业,一位Booth足够理解原理;如果要做真正的芯片级乘法器,建议直接上基4 Booth加Wallace树压缩,那就是设计专题了。
3.3 硬件实现避坑:符号位扩展、倒数第二个部分积取反加一
手写硬件乘法器的时候,有三个坑我几乎每次都会踩一遍,列出来给大家省点时间。
坑一:部分积的符号扩展。你按竖式把n个部分积列出来,每个部分积在被乘数是负数的情形下,本身是负数。不用等号的“短竖式”写法,直接把有符号部分积按位相加是会出错的。正确做法是把每个部分积都符号扩展到2n位后再相加。一个常被忽视的简化技巧:除了最后一个部分积,其他部分积的符号扩展位可以只写作“所有扩展位都取符号位的值”,而最后一个部分积的符号位位置需要特殊处理(很多教材叫“符号位扩展技巧”,本质上是用 (\bar{s}) 和 (s) 的组合来替代一串相同的符号位,减少加数数量)。
坑二:倒数第二个部分积的取反加一。如果你用的是“先取绝对值相乘再加符号”的原码法,那么处理-8这种极端负数时,取绝对值会溢出。这是原码乘法的一个死穴。如果你用的是直接补码乘法(不是Booth),那么涉及到“减法”操作时,必须把减数取反加一,这时候常见的错误是只取反不加一,导致结果总差一个常数。记住,乘法的减法和加法一样,取反加一是一对操作,不能拆。
坑三:算术右移还是逻辑右移。Booth算法的移位必须是算术右移,也就是最高位补符号位。如果你在Verilog里用了>>(逻辑右移),正数没影响,负数直接错乱。正确写法见第4.3节。这个坑特别隐蔽,因为正数情况仿真全对,一换负数就炸。
4. 代码实现:MATLAB、C语言与Verilog里的有符号乘法
4.1 MATLAB:十六进制转有符号数的三种写法
MATLAB是数据解析的重灾区,因为hex2dec默认返回的是无符号值。这点必须时刻警惕。搜热词里就有“matlab 16进制转有符号数”,说明很多人卡在这。这里给出三种常用方法。
最直接的方法是手动判断最高位:
% 方法一:手动转换,适合任意位宽 hexStr = 'FE'; % 8位的十六进制 dec = hex2dec(hexStr); nBits = 8; if dec >= 2^(nBits-1) signedVal = dec - 2^nBits; else signedVal = dec; end % signedVal = -2第二种是利用typecast,适合已经装进字节数组的情况:
% 方法二:typecast 位级转换 bytes = uint8(hex2dec('FE')); % 必须先转成uint8类型 signedVal = typecast(bytes, 'int8'); % signedVal = -2第三种是直接用 Fixed-Point Designer 工具箱的fi对象:
% 方法三:fi对象,适合带小数位和自定义位宽 fiVal = fi(hex2dec('FE'), 1, 8, 0); % 1表示有符号,8位字长,0位小数 % fiVal = -2我最推荐方法一,因为它改位宽只需改nBits,不依赖工具箱,也不依赖平台字节序。typecast有个隐性坑:它完全按内存内容解释,如果你把数据先转成了double,那内存内容变了,typecast会得到垃圾值,所以必须保证输入是整型。这一点新手特别容易踩。
MATLAB里做有符号乘法,还有个重要细节:int8(3) * int8(254)会报错吗?不会,MATLAB的整数乘法不会静默溢出,它会在溢出时饱和到该类型上下限。比如int8(100) * int8(100)返回int8(127),而不是截断成某个补码值。这个行为和C语言完全不同。所以如果你想要的是“补码截断”效果,需要自己用typecast或者fi对象来模拟。做数据解析时,先确认MATLAB版本的行为,再决定用哪条路。
4.2 C语言:整型提升与隐式溢出
C语言的整数算术有个著名的规则叫“整型提升”(integer promotion)。简单说,int8_t、int16_t在做算术运算时,会先被提升成int(通常是32位)再运算,结果也是int。这意味着int8_t乘int8_t,实际上是用32位乘法器做的,然后赋值给int8_t时再截断。这种设计在int8_t场景下反而很安全,因为8位乘8位的最大乘积65536,32位完全装得下,截断到8位时如果正好在范围内就没问题。
真正的坑在int32_t上。32位乘32位,结果需要64位,但整型提升后还是在32位int里运算,溢出是未定义行为。编译器可能给你截断结果,可能优化掉整段代码,表现完全不可预测。这是一个真实性事故——很多做协议解析的程序,算时间戳差值或者乘法时用了long,结果在某个平台上一夜之间全是负数。正确的写法是显式转换到64位:
#include <stdint.h> int32_t a = -100000; int32_t b = 300000; // 错误:int32_t相乘会先提升为int(32位),溢出未定义 // int64_t result = a * b; // 正确:先转换为int64_t再乘 int64_t result = (int64_t)a * b;赋值阶段也有讲究。看这个例子:
int8_t a = -5; int8_t b = 6; int8_t c = a * b; // a*b作为int是-30,赋值给int8_t没溢出,结果-30 int8_t d = -8 * -8; // int运算结果是64,赋值给int8_t没问题,结果64?不对!-8 * -8 = 64,int8_t的范围是 -128 到 127,64放得下,没问题。但如果你写int8_t e = 127 * 127;,int结果是16129,赋值给int8_t时溢出,结果是未定义的,但大多数编译器会给出截断值1。这个截断行为在C标准里是“实现定义”的,不是“未定义”,所以不同编译器表现可能不同。写代码时务必要让目标类型能装下结果,或者干脆用int64_t中间承接。
这里我推荐一个经验法则:只要结果位宽可能超过当前类型,一律先升级类型再运算,永远不要依赖“这段代码只在XX编译器上跑”的侥幸。库函数的返回值、结构体字段、协议解析里的长度字段,都是溢出高发区。
x86汇编层面的细节也值得知道。MUL是无符号乘法,IMUL是有符号乘法。两条指令在低半部分结果完全相同,高半部分不同。编译器生成int32_t乘法代码时,会自动选择合适的指令。但如果你写内联汇编或者做底层移植,务必搞清楚当前操作是有符号还是无符号,以及结果要取多宽。
4.3 Verilog:$signed与手写乘法器
FPGA和ASIC设计里的有符号乘法,最标准的做法是直接使用*运算符,但要给它正确的“符号性”。Verilog-2001 引入了$signed()系统函数,可以用来标记一个信号为有符号:
module signed_mult #( parameter WIDTH = 8 )( input logic [WIDTH-1:0] a, // 虽然声明是无符号,但会被用作有符号 input logic [WIDTH-1:0] b, output logic [2*WIDTH-1:0] p ); wire signed [WIDTH-1:0] a_s = $signed(a); wire signed [WIDTH-1:0] b_s = $signed(b); assign p = a_s * b_s; // 2*WIDTH位结果,正确 endmodule注意两个细节。第一,乘法结果的位宽必须是2*WIDTH,否则综合工具会截断。第二,如果你想手动搭Booth算法或部分积阵列,移位必须用算术右移>>>,并且要保证所有累加寄存器是有符号类型:
// 算术右移示例 logic signed [2*WIDTH-1:0] acc; assign acc = acc >>> 1; // >>>是有符号算术右移,>>是逻辑右移手工搭Booth乘法器的时候,状态机每拍完成一次“加减+右移”,总共要WIDTH拍。寄存器声明为signed可以简化符号处理。还有一点:在SystemVerilog里,logic signed和wire signed都能用,但bit类型不能有符号,这算是最常见的语法坑。
另外,综合工具对*运算符的优化程度远高于手写乘法器。如果你不是专门做芯片底层库,老老实实用*让综合器去推断DSP单元,比手撸Booth靠谱得多。只有在你需要控制延迟、面积、功耗,或者做大规模矩阵乘法阵列不得不手工布局时,才值得手工搭建乘法器。这一点我认为特别重要:能交给工具的事,别自己硬扛。
5. 常见问题与排查技巧实录
5.1 症状、原因、排查方法速查表
我把这些年遇到的有符号乘法问题整理了张速查表,有类似症状直接对照:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 负数变为很大的正数 | 解析时没有做符号扩展,把补码当无符号 | 检查hex2dec/byte转int路径,手动判断最高位 |
| 乘法结果刚好差一个常数 | 取反加一少做了一步 | 重点检查减法操作,确认加法器/减法器是否配套 |
| 正数乘法对,一遇负数就乱 | 部分积符号扩展没做或做错 | 把所有部分积都符号扩展到2n位再相加 |
| 仿真正确,上板错误 | 数据路径位宽不够,进位被截断 | 检查中间累加器位宽,建议至少2n位 |
| MATLAB里结果被截到上限 | 整数运算饱和而非补码截断 | 改用fi对象或typecast手动处理 |
| C语言里乘法结果随机 | int32_t×int32_t溢出,未定义行为 | 显式转换为int64_t再乘 |
| Verilog仿真负数全变正 | 用了>>逻辑右移而不是>>> | 换成算术右移,并把寄存器声明为signed |
| Booth算法结果中间对、最后错 | 最后一次移位多余/缺失 | 检查循环边界,Booth算法需要n次移位后才能输出 |
5.2 数据解析中的“符号扩展”陷阱:一个完整案例
再说回文章开头的MATLAB案例。那位朋友报文的温度字段是一个字节,比如0xFE,代表-2摄氏度。他的代码是这样写的:
raw = hex2dec('FE'); % 得到 254 temp = raw * 0.1; % 得到 25.4,完全不对修正之后:
raw = hex2dec('FE'); if raw >= 128 raw = raw - 256; % raw 变成 -2 end temp = raw * 0.1; % 得到 -0.2,正确这个raw >= 128的判断,本质上就是最高位为1就减 (2^n),等价于前面补码公式的逆运算。对于任意位宽的数据字段,通用的写法是这样的:
function s = signedFromHex(hexStr, nBits) u = hex2dec(hexStr); if u >= 2^(nBits-1) s = u - 2^nBits; else s = u; end end只要是2的幂次位宽,这个方法永远成立。要是带小数的定点数,还需要额外除以 (2^f),其中f是小数位位数。除此之外,C语言里解析网络报文也经常需要手动符号扩展,比如一个协议字段是12位有符号数,你得先取出这12位,再判断第11位是否为1,是的话就把高20位全部填1。很多Bug就藏在这些“手动扩展”里。
5.3 仿真波形怎么看:定位乘法Bug的实操技巧
最后分享一个调试经验。FPGA或软件仿真里,定位乘法问题比定位加法难,因为乘法错误的“样式”更多样。我的习惯是三步走。
第一步,先分两步验证无符号乘法是否正常。把两个输入都改成正数,跑一遍,确认基本乘法通路没问题。正数下补码计算和无符号完全一致,所以这一步能把“乘法本身的问题”和“符号处理的问题”分离开。
第二步,打印中间所有部分积。如果你是手写阵列乘法器,把每个部分积的位宽、符号扩展后的值、累加值全都打出来。我之前碰到一个案例,某一位部分积因为取错信号,符号扩展位全成了0,导致负数相乘时结果差一个很大的2的幂次。打印部分积后一眼就发现了。
第三步,用基准值交叉验证。写一个函数,输入相同的a、b,用64位整数计算正确结果,再与你的FPGA仿真结果做全组合比对。全组合在小位宽(比如8位)时只有256×256=65536种可能,仿真器完全跑得动。这个测试向量跑通,基本可以宣布乘法器代码没问题。
这三步做完,90%的乘法Bug都已经现形了。剩下的10%,大概率是异步时序或复位问题,那就得用调试器看波形了。
6. 几个容易被忽略的扩展场景
聊到有符号乘法和补码,有几个外围概念容易被忽略,但它们在实际工程里经常和乘法纠缠在一起。
6.1 浮点数乘法与补码的关系
IEEE 754浮点数的乘法,尾数部分本质上是定点补码乘法。浮点数相乘时,符号位作异或运算,阶码相加,尾数相乘。尾数乘法占用的是浮点乘法器的大部分面积,而它处理的就是无符号定点数(通常在IEEE格式里尾数是原码表示)。理解整数补码乘法,对理解浮点乘法器的低层实现很有帮助。如果哪天你看到浮点乘法结果出现规律性偏差,除了舍入,也可以怀疑一下尾数乘法是否有符号扩展问题。
6.2 矩阵乘法里的行观点与列观点
前面提到过,矩阵乘法可以按“行观点”也可以按“列观点”来理解。这个类比放到二进制乘法里非常贴切:a × b的行观点是把b按二进制位拆开,每位贡献一个“a左移若干位”的部分积,最后求和;列观点则是把所有部分积纵向排列,按列统一累加进位。硬件里的Wallace树压缩树,本质就是列观点的加速实现——它不去逐行累加,而是把每一列的总和直接压缩成两行再相加。理解行、列两个视角,对看乘法器架构论文会很有帮助。
6.3 负数乘法在DSP和AI推理中的特殊处理
做DSP(数字信号处理)和AI推理部署的同学请注意:很多嵌入式芯片的DSP指令支持“饱和乘法”和“舍入乘法”,它们不是简单的截断。比如ARM的SMULxy指令做16位有符号乘法,结果直接是32位;而某些DSP做两个Q15数相乘时,结果左移一位再舍入,以避免双符号位。这里的“双符号位”(guard bit)概念,本质就是乘法结果位宽扩展的一种形式。如果算法仿真的浮点模型和定点模型在你处理负数时出现微小偏差,多半是乘法后的舍入策略不同,而不是补码本身有错。
最后分享一个小技巧
补码乘法说到底是“模算术”的一个实例。理解这一点,很多看起来玄乎的行为都有了解释:截断就是取模,溢出就是模回绕,符号扩展就是保值的位宽升级。这就像学做菜,理解了食材的特性,不需要背菜谱也能临场发挥。
我个人在写完代码之后,习惯性会做两步自查。第一步,拿边界值去测:最大值乘最大值、最小值乘最小值、最大值乘最小值,这三组如果结果都对,99%的边界情况都稳了。第二步,如果代码里有符号扩展相关的位操作,一定写注释,标注“这里是因为补码最高位是负权值,所以要扩展符号位”,防止三个月后的自己看着代码发呆。
从手算符号扩展,到Booth算法硬件实现,再到MATLAB、C、Verilog三种语言的落地,补码乘法这条线,到这里算是一条龙走完了。剩下的,就是多写几个例子,亲手验一遍负数的各种组合,等这些细节都变成肌肉记忆,你再看“有符号数乘法”这个题目,就会觉得它就像一加一那么简单了。