做嵌入式、信号处理或者FPGA的兄弟,估计都吃过有符号数乘法的亏。ADC吐出来的FF、FE这些十六进制数据,看着是255、254,实际可能是-1、-2;两个“负值”乘在一起,结果还能被截断成一个大正数。这些问题全都绕不开“有符号数乘法”那套基本规则。这篇文章我会从补码存数的原理讲起,把原码乘法、补码乘法、部分积符号扩展和Booth算法都过一遍,然后重点演示在MATLAB里怎么把十六进制有符号数据安全转成真值、怎么做乘法不翻车,最后整理几个我实际调试中踩过的坑,希望能帮新手少走弯路。
1. 先搞懂有符号数在计算机里到底长什么样
1.1 原码、反码、补码:三个概念一次讲清
很多朋友刚接触有符号数时,总被原码、反码、补码这三兄弟绕晕。我打个比方:原码就是“人眼看数”的方式,最高位当符号位,0代表正、1代表负,其余位直接表示绝对值。比如用一个8位二进制数表示+5,就是00000101,表示-5,就是10000101。这个方式人看一眼就懂,但对计算机和电路非常不友好——因为如果直接用原码做加减法,电路要先判断符号位,再决定是加还是减,还要处理绝对值大小比较,硬件复杂度直接起飞。
反码是在原码基础上,符号位不变、数值位逐位取反。比如-5的原码是10000101,反码就是11111010。反码的意义在于,它把“减去一个数”变成了“加上它的反码”,但反码有个老毛病:会出现正零00000000和负零11111111两个零,逻辑上不干净。
补码就是最终解决方案。正数的补码跟原码一样;负数的补码是“原码除符号位外取反,再加1”,或者换一个更好记的说法——负数的补码等于“它对应的正数按位取反,再加1”。比如+5是00000101,取反得11111010,再加1得11111011,这就是-5的8位补码。补码最大的好处就是:减法可以统一变成加法,符号位参与运算,不需要额外判符号。计算机里存有符号整数,几乎无一例外都用补码。
知道这个背景以后,再看“有符号数乘法”就会顺很多。因为在补码体系下,乘法结果如何解释、溢出怎么判断,都跟位宽和补码的表示范围直接挂钩。
1.2 位宽就是“地价”:可表示范围决定乘法结果上界
有符号数的可表示范围是固定的:n位有符号数(补码)能表示的范围是-2^(n-1)到2^(n-1)-1。举个例子,8位有符号数范围是-128到127;16位范围是-32768到32767。这个边界看着简单,但真正做乘法时,很多人会忽略一个关键点:两个n位有符号数相乘,积最多需要2n位才能完整表示。
为什么是2n位?因为两个8位有符号数,绝对值的最大情况是-128乘以-128,结果是16384,这个数已经超过8位甚至15位能表示的正数上限(15位上限是16383),必须用16位才能装下。更一般的结论是:两个n位有符号数相乘,如果结果要“完整、不溢出”,寄存器至少要有2n位宽。我在实际项目里见过太多因为省位宽导致结果莫名其妙变负数的案例,这句话几乎能解决一半的乘法问题。
顺带提一下:这里说的“完整表示”是指数学意义上的精确结果,不是舍入或饱和之后的结果。你在MATLAB里写int8(100)*int8(3),得到的是一个被截断或饱和的值,这就是位宽不够的结果。后面我会专门讲这一类坑。
2. 有符号数乘法的原理:从竖式到Booth算法
2.1 原码乘法:最符合直觉的符号位分离法
如果我们用原码做乘法,逻辑最直观:先不管符号,把两个数的绝对值(也就是数值位)当无符号数相乘,然后单独算符号位——符号位相同得正,符号位不同得负。用公式写就是:
result_sign = sign_a XOR sign_b
result_magnitude = |a| * |b|
举个最简单的例子:计算-5乘以3。先算绝对值:5乘3等于15;再看符号位,一个是负、一个是正,异号得负,所以结果是-15。这个流程人脑很容易接受,考试的时候老师也喜欢让这么算。
但在真实硬件里,原码乘法很少直接采用,原因有两个:第一,计算机存储的是补码,不是原码,如果用原码做乘法,得先把补码转换成原码,算完再转回补码,中间多了一大堆转换电路;第二,补码的符号位在最高位,如果按原码的思路“符号位单独算、数值位当无符号”,那乘出来的部分积符号扩展规则会非常麻烦。所以硬件乘法器基本都走补码路线。
不过原码乘法这个思路并没有消失,它被用在很多软件算法的前置处理里。比如你拿到一串十六进制数据,先判断它是正是负,再把绝对值提取出来做无符号乘法,最后根据符号位调整结果——这在做定点数模拟、算法原型验证的时候特别常见,思路清晰还不容易出错。
2.2 补码乘法:部分积符号扩展是成败关键
补码乘法的本质,是把补码当成一个“模2^n”的整数来做无符号乘法,最后再对结果做正确的解释。但这里有一个特别的坑:直接套无符号乘法竖式,会得到错误的结果,除非我们把每个部分积都做符号扩展。
我拿4位补码来演算:计算-5乘以3。-5的4位补码是1011,3的4位补码是0011。如果像无符号数那样直接列竖式:
1011 x 0011 --------- 1011 1011 --------- 100001按4位截断,结果是0001,也就是1,这显然是错的,正确答案应该是-15。问题出在哪?出在部分积没有做符号扩展。正确的竖式应该是这样:
11111011 <- -5符号扩展成8位 1111011_ <- -5左移1位后继续符号扩展 000000__ 00000___ --------- 11110001结果是11110001,这是-15的8位补码,完全正确。看到区别了吗?关键在于:参与乘法的每一个部分积,只要来自负数,就要把符号位向左扩展,一直扩展到最终结果位宽为止。用大白话说,就是把“符号信息”随部分积一路传播下去,不然高位就丢了。
这个“部分积符号扩展”是补码乘法最容易出错也最核心的地方。在FPGA或ASIC里写乘法器时,几乎所有初学者都栽在这里:位宽定小了、符号扩展没做全、扩展位数和结果位宽不一致,最终仿真全错。如果你用Verilog写有符号乘法,我建议直接声明signed wire,让综合工具去处理符号扩展,而不是手写一堆拼接逻辑,后面我会给示例。
2.3 硬件里常用的Booth算法:用加减法换部分积数量
前面说的是补码乘法的基本思路,但硬件工程师在芯片里实现的乘法器,很多会用Booth算法。Booth算法的核心思想是:把乘数看成一系列连续1的“边沿”,利用“连续k个1等价于2的k次方减1”这个性质,把一串加法变成少数几次加法或减法。
它的操作规则很简单:在乘数最低位的右边补一个0作为隐藏位,然后从最低位开始,依次比较当前位y_i和前一位y_{i-1}:
| y_i | y_{i-1} | 操作 |
|---|---|---|
| 0 | 0 | 无操作 |
| 0 | 1 | 加被乘数,结果左移i位 |
| 1 | 0 | 减被乘数,结果左移i位 |
| 1 | 1 | 无操作 |
举个例子,还是用被乘数5(0101)和乘数3(0011)。乘数3的二进制是0011,最低位右边补0后得到00110,从低位往高位扫描:
- 第0位:当前位1、前一位0,对应10边界,做“减被乘数”,贡献-5×2^0=-5;
- 第1位:当前位1、前一位1,对应11,无操作;
- 第2位:当前位0、前一位1,对应01边界,做“加被乘数”,贡献+5×2^2=+20;
- 第3位:当前位0、前一位0,无操作。
加起来就是-5+20=15。注意,这里“减被乘数”的项是按左移i位后的权值来算的。整个过程只用了两次运算,比竖式里处理三个部分积要省逻辑。Booth算法经过改进还有基4、基8版本,能在硬件里进一步压缩部分积数量,这是芯片设计里的经典课题。
对大多数做应用层开发的兄弟来说,Booth算法可能不会直接出现在代码里,但了解它能帮你理解为什么硬件乘法器在“某些特定数值”下速度会有差异,也能在你用硬件描述语言做乘法器优化时,多一个方向可选。
3. MATLAB实操:从16进制数据到有符号乘法一把梭
3.1 16进制字符串转有符号数:手写转换和typecast两条路
MATLAB里最常见的有符号数场景,就是把十六进制字符串转成有符号的十进制真值。很多设备导出的数据都是0xFF、0xFE这种补码形式,直接用hex2dec转出来是255、254,但实际代表的是-1、-2。这里我给出两个稳妥的做法。
第一个做法是手写补码转换。原理很简单:如果无符号值大于等于2^(n-1),说明符号位是1,这个数就是个负数,需要减去2^n。我用一行核心逻辑给你:
function s = hex2signed(hexStr, nbits) % hexStr可以是'FF'或'0xFF'等形式 hexStr = regexprep(hexStr, '^0x', '', 'ignorecase'); u = hex2dec(hexStr); s = u - (u >= 2^(nbits-1)) * 2^nbits; end测试一下:
hex2signed('7F', 8) % 127 hex2signed('80', 8) % -128 hex2signed('FF', 8) % -1 hex2signed('FFFE', 16)% -2这个函数的关键是最后一行:逻辑判断u >= 2^(nbits-1)会返回0或1,乘上2^nbits,就把符号位为1的数拉回负数区间。我用这个方法处理过几百兆的ADC日志,速度很快,逻辑也透明,适合批量处理。
第二个做法是用typecast。typecast不会改变内存里的二进制位,只是换一种数据类型去解释它,所以对补码来说非常直接:
s = typecast(uint16(hex2dec('FFFE')), 'int16'); % 结果是 -2注意,typecast按当前MATLAB所在平台的字节序做解释。如果你的数据来自某个固定字节序的外部设备,比如网络抓包、单片机串口上传,就得先做一次字节序调整:
data_u16 = uint16(hex2dec('FFFE')); data_u16 = swapbytes(data_u16); % 外部大端数据在x86小端机器上要调换 s = typecast(data_u16, 'int16');两种方法各有优劣。手写转换适合处理字符串数组、批量十六进制转储数据,还能随时改位宽;typecast适合处理bit流场景,比如你把原始内存数据load进MATLAB,希望直接“按另一套规则解释”。我自己的习惯是:处理文本日志用手写转换,处理二进制文件用typecast。
3.2 用int8/int16做有符号乘法:先弄明白溢出的“国籍”
转成有符号数之后,最直接的乘法就是在MATLAB里写int8、int16的乘法。比如:
a = hex2signed('F6', 8); % -10 b = hex2signed('05', 8); % 5 p = int16(a) * int16(b); % -50这里我用int16来放乘积,因为两个8位有符号数相乘,积要用16位才安全。很多人图省事直接写成:
p8 = int8(a) * int8(b); % 这里就有隐患如果a和b的乘积超出int8范围,MATLAB的整数运算是“饱和”的——结果会被压到-128或127,而不是像C语言那样低位截断。比如int8(100)*int8(3),数学结果是300,MATLAB里直接得127:
int8(100) * int8(3) % 127,不是300,也不是44这个细节特别容易坑人。如果你在写算法原型,希望结果接近真实的数学乘积,务必先把操作数提升到足够宽的整数类型,或者干脆转成double做乘法、最后再统一量化。如果你在模拟某个硬件行为,比如一个8位乘法器且结果也存8位,那你还得手动模仿“截断”或“回绕”逻辑,而不是直接依赖MATLAB的饱和规则。
我给一个通用建议:在MATLAB里做定长有符号乘法时,先想清楚“这一步是数学计算、还是硬件仿真”。数学计算就把位放宽;硬件仿真就按硬件的真实行为来模拟。两类目标对应的代码写法完全不同。
3.3 fi对象模拟硬件定点乘法:做DSP/FPGA的人一定要会
如果你做的是DSP算法或者FPGA的算法仿真,建议直接用Fixed-Point Designer工具箱里的fi对象。fi对象可以精确指定字长、符号、小数位,比单纯用int8/int16更接近硬件行为。
定义一个8位有符号整数,小数位为0:
a = fi(-10, 1, 8, 0); % 1位符号,总共8位,小数0位 b = fi(5, 1, 8, 0); p = a * b;默认情况下,fi对象做乘法会按完整精度输出,也就是两个8位相乘得到16位结果,p的值是-50。这个行为很适合做“不损失精度”的算法验证。但如果你想模拟硬件里“乘完只保留8位结果”,就要自己指定输出精度:
f = fimath('ProductMode', 'SpecifyPrecision', ... 'ProductWordLength', 8, 'ProductFractionLength', 0); a = fi(-10, 1, 8, 0, f); b = fi(5, 1, 8, 0, f); p = a * b; % -50在8位范围内,没问题再看100乘3这个例子。用fi做完整精度乘法,结果是300;如果输出限定为8位,MATLAB默认饱和,得到127。如果硬件是回绕(wrap),也就是直接截断低位,要这样模拟:
a = fi(100, 1, 8, 0); b = fi(3, 1, 8, 0); p_full = a * b; % 300 p_sat = fi(p_full, 1, 8, 0); % 127,饱和 p_wrap = reinterpretcast(p_full, numerictype(1, 8, 0)); % 44,取低8位我曾经给一个定点FFT做bit-accurate验证,就是靠fi对象和reinterpretcast把每个乘法、加法都建模成硬件的行为,最后比对RTL仿真结果,效率比手写一堆位操作高很多。做通信物理层或者电机控制算法的朋友,强烈建议把这套工具用起来。
3.4 一个完整的验证流程:从ADC原始数据算到乘积
我拿一个真实的调试场景来做完整演示。假设某款单片机的12位ADC以二进制补码输出,寄存器原始值是十六进制字符串,我一次性读回了4个采样点:
rawHex = {'FFF', '800', '7FF', 'FFE'}; nbits = 12; u = hex2dec(rawHex); % 无符号解释 signedVal = u - (u >= 2^(nbits-1)) * 2^nbits; disp(signedVal);输出应该是:
-1 -2048 2047 -2然后我想计算这组采样值乘以一个校准系数0.5,并假设结果仍然要用16位有符号数存储:
coef = 0.5; prodDouble = signedVal * coef; % 数学结果 prodInt16 = int16(round(prodDouble)); % 量化到16位有符号注意中间用了round,因为把浮点结果量化到整数时,四舍五入是需要显式表达的。很多人一上来就int16(signedVal*0.5),MATLAB会做四舍五入?其实针对浮点到整数的转换,MATLAB默认是四舍五入的,但为了代码可读性,我习惯显式写round。
最后再用两个不同的方法交叉验证一下,确保补码解释没有错误:
% 方法二:typecast方式(12位数据存在16位容器里,所以要左移4位) container = uint16(hex2dec(rawHex)); container = bitshift(container, 4); % 对齐到16位高12位 signedViaTypecast = typecast(container, 'int16');这个“左移4位再typecast”的做法,在处理输出为12位、14位这种非标准位宽的ADC时非常实用。本质是把数据放进一个标准16位容量的高位,让符号位落在正确的位置。
4. 有符号数乘法的常见坑与排查技巧
4.1 溢出之后:饱和、截断还是未定义行为
有符号数乘法最经典的坑就是溢出后的行为不一样。同样的表达式,在不同语言里结果完全不同,这里我做一张速查表:
| 环境 | a=100, b=3, 结果存8位有符号 | 原因 |
|---|---|---|
| MATLAB int8运算 | 127 | 整数运算默认饱和到最大值 |
| C语言 | 44(常见平台) | 整数提升到int计算后截断为8位 |
| Verilog | 44 | 按结果位宽直接截断低位,不回绕也不饱和 |
| fi对象默认 | 127 | 默认饱和,可配置为回绕 |
我做跨语言联合调试时遇到过一模一样的数据,MATLAB模型仿出来127,硬件测出来44,两边折腾了半天才定位到是“结果位宽和溢出策略不一致”。所以开写代码之前,先确定这几件事:乘法结果的寄存器位宽是多少?溢出是饱和还是回绕?中间要不要截断?把这三条定下来,再写代码,能省掉大量排错时间。
4.2 符号扩展错误:移位、位拼接和类型提升的连环雷
有符号数在移位和位拼接时特别容易翻车。先说右移:对有符号负数,右移必须做算术右移,也就是高位补符号位,而不是补0。比如-4的8位补码是11111100,算术右移1位得到11111110,正好是-2;如果按逻辑右移补0,就变成01111110,变成126了,完全错误。用MATLAB的bitshift对int8输入做右移时,它默认按算术右移处理,一般不会错;但如果你先把负数转成uint8再去shift,就会出问题。
再说位拼接。在Verilog里把两个有符号数拼在一起时,如果目标位宽大于操作数位宽,必须让操作数先做符号扩展,再拼接。我见过一个错误写法是把16位有符号数[11:0]直接拼到32位总线上,高位全是0,结果负数全部变成正数。正确做法是用Verilog的signed声明让综合工具自动符号扩展,或者在拼接前手动复制符号位。
MATLAB里类似的问题是类型提升。数组切片、cat函数、甚至只是把int8和double放在同一个数组里,都可能触发隐式类型转换。一旦负的int8被转成double再拼到某个矩阵,符号位信息已经“面目全非”了。我的经验是:涉及有符号数位操作时,每一行都检查一下当前变量的类型和位宽,不要依赖隐式转换。
4.3 MATLAB和C/Verilog结果不一致怎么办
如果你在做算法原型和硬件设计的联合验证,经常会发现MATLAB算出来的结果和C模型、Verilog仿真结果对不上。排查顺序我建议从四个维度来查:
第一,查位宽。确认两边乘法结果都用多少位存放,MATLAB的double模型没有位宽概念,很容易在“数学上正确”却“硬件上不存在”。
第二,查溢出策略。前面那张表已经说明,不同环境默认策略不同。MATLAB饱和、C截断、Verilog截断、硬件可能是饱和也可能是回绕,必须逐一统一。
第三,查符号扩展。负数参与乘法时,部分积的符号扩展是否做到位,尤其手动拼接位时最容易漏。
第四,查转换点。比如MATLAB的fi对象转成普通整数、C语言里int8_t赋给int32_t、Verilog里reg signed赋给wire signed,这些“类型边界”最容易出幺蛾子。
我自己的排查习惯是:在MATLAB里先做一个“纯整数位精确”的模型,完全用fi对象或者int类型建模,不出现double;然后让它和C模型跑同样的测试向量,用脚本自动比对输出;最后再上RTL仿真。这样一个链条拉下来,绝大多数不一致都能定位到具体那一步运算。别一上来就拿着示波器或者板子调,先把“软件模型”和“硬件模型”对齐,效率高得多。
最后再分享一个小经验:如果你在MATLAB里做有符号数乘法,遇到“结果就是不对”的诡异现象,先别怀疑底层算法,先把你自己的程序里所有int8、int16、double的混用理一遍,把每个中间变量的位宽打印出来,九成问题都出在类型隐式转换和位宽缩水上面。有符号数这东西,一个符号位错了,整个结果就完全不是一回事了。