☰
有符号数乘法:补码原理、代码陷阱与MATLAB转换全解析
2026/10/2 10:16:11 网站建设 项目流程

有符号数乘法这六个字,看着简单,实际是数字电路、C语言、Verilog、MATLAB 里最容易让人栽跟头的一块硬骨头。笔试爱考,面试爱问,写代码时稍不留神就得到一个大得离谱的数。我这些年调试过各种乘法溢出、符号位乱飞的问题,几乎每次追根溯源,最后都能回到同一个核心问题上:对补码和有符号乘法的本质理解得够不够。这篇文章我打算把“有符号数乘法”从数学原理到工程落地完整拆一遍,再把 MATLAB 里十六进制转有符号数这个高频需求一并讲透,既适合正在学计算机组成原理的学生,也适合做嵌入式、FPGA、信号处理的工程师参考。

1. 有符号数到底怎么存:补码背后的位权思想

1.1 补码不是“取反加一”,而是带符号权重

很多教材讲补码,直接给规则:负数的补码等于原码按位取反再加一。规则本身没错,但容易让人背完就忘。真正能指导实战的理解方式,是把补码看成一串带权重的二进制位。

一个 N 位补码 b[N-1] b[N-2] ... b[0],它的十进制值不是普通无符号那样从 2^{N-1} 加到 2^0,而是:

[ value = -b[N-1] \times 2^{N-1} + \sum_{i=0}^{N-2} b[i] \times 2^{i} ]

最高位被赋予了负权重,其余位仍然是正权重。这就是补码和“取反加一”能够对上的根本原因。

拿 4 位二进制来举例:

  • 1110 = -8 + 4 + 2 = -2
  • 1111 = -8 + 4 + 2 + 1 = -1
  • 0111 = 0 + 4 + 2 + 1 = +7

用生活化的方式理解:补码像是一个天平,最高位那一个是“负数砝码”,而且它比所有正数砝码加起来还要重一格。所以只要最高位是 1,整个数就是负的;其余位在 0 和 1 之间变化,决定了这个负数离 0 有多近。

1.2 为什么计算机要用补码

有人会问,既然原码看起来更直观,为什么不用原码直接存负数?原因主要有两个。

第一,原码的加减法特别难做。1 + (-1) 在数学上等于 0,如果用原码,0001 + 1001 在二进制里直接加,结果是 1010,不是一个零。但补码 0001 + 1111 = 10000,截断到 4 位就是 0000,正好是 0。补码让加法器可以同时处理正数和负数,不需要额外设计一套减法器。

第二,补码 0 的表示唯一。原码里 +0 是 0000,-0 是 1000,判断相等还得专门处理。补码则只有 0000 一种零。这在硬件比较时省了大事。

正是因为补码把符号位和数值位统一处理,乘法器在底层只需要把每一位都当成普通二进制位去操作,再配合符号扩展或者重编码,就能同时算对正数和负数。这一点是理解“为什么有符号数乘法不能直接像无符号数那样看结果”的前提。

1.3 十六进制怎么对应回有符号数

写代码的人经常和十六进制打交道,尤其是调试寄存器、通信协议和传感器数据时。十六进制的每一位对应 4 个二进制位,所以一个 8 位有符号数可以用两位十六进制表示,16 位用四位,32 位用八位。

判断一个十六进制字符串代表的正负,核心只有一个规则:看它最高位对应的二进制位是不是 1。用十六进制看,就是看它的最高十六进制字符是否大于等于 8。

位宽十六进制范围有符号十进制范围
8 位0x00 ~ 0x7F0 ~ 127
8 位0x80 ~ 0xFF-128 ~ -1
16 位0x0000 ~ 0x7FFF0 ~ 32767
16 位0x8000 ~ 0xFFFF-32768 ~ -1
32 位0x00000000 ~ 0x7FFFFFFF0 ~ 2147483647
32 位0x80000000 ~ 0xFFFFFFFF-2147483648 ~ -1

MATLAB 里默认的 hex2dec 函数只认无符号,输入 'FF' 会返回 255 而不是 -1。这就是为什么“16进制转有符号数”能成为高频搜索词。后面第 4 章我会专门给几种可靠的转换写法。

2. 补码乘法为什么不能“直接乘”:数学与硬件

2.1 一个反直觉的算例:-5 × 12

先看一个真实场景。假设我要计算 8 位有符号数 -5 乘以 12。数学结果显然是 -60。

-5 的 8 位补码是 11111011,12 的补码是 00001100。如果我把两个补码当成无符号数做乘法,11111011 按无符号读是 251,乘以 12,得到 3012。十六进制是 0x0BC4,二进制是 0000101111000100。

3012 和 -60 差了十万八千里。问题出在哪?

关键的数学推导是这样的。设一个负数 A 的补码按无符号读出的值为 A',那么

[ A' = A + 2^8 ]

因为补码把最高位的负权重 -128 换成了正权重 +128,正好相差 256。对于 -5,A' = -5 + 256 = 251,完全吻合。类似的,如果乘数 B 也为负,B' = B + 2^8。

那么无符号乘法实际算的是:

[ A'B' = (A + 2^8)(B + 2^8) = AB + 2^8 A + 2^8 B + 2^{16} ]

最后得到的 3012 = -60 + 256×12 = -60 + 3072。多出来的这一大截,正是把负数的高位按正权重累加造成的。如果两个都是负数,多出来的项更多。硬件乘法器要做有符号处理,本质上就是要把这些 2^8 的倍数项修正掉。

2.2 正确的竖式算法:符号扩展部分积

手工计算有符号乘法时,最可靠的还是补码乘法竖式,核心操作是符号扩展部分积。

继续用 -5 × 12。8 位补码乘法需要把部分积扩展到 16 位再累加,否则符号信息会丢失:

  • 乘数 12 的二进制是 00001100,其中位 2 和位 3 为 1,分别对应权重 4 和权重 8。
  • 位 2 为 1:被乘数 11111011 逻辑左移 2 位变成 1111101100,再符号扩展到 16 位,得到 1111111111101100,十六进制 0xFFEC。这个值正好是 -5 × 4 = -20。
  • 位 3 为 1:被乘数 11111011 逻辑左移 3 位变成 11111011000,再符号扩展到 16 位,得到 1111111111011000,十六进制 0xFFD8。这个值正好是 -5 × 8 = -40。

两个部分积相加:

[ 0xFFEC + 0xFFD8 = 0x1FFC4 ]

截断到 16 位,得到 0xFFC4,按补码解释:-32768 + 31744 + 4 = -60。结果正确。

这里必须强调一个新手常犯的错误。部分积不是简单地把被乘数原封不动地复制一遍,而是要先左移到与乘数位对应的权重位置,再做符号扩展。我见过有朋友直接把被乘数符号扩展 8 位就往下加,结果自然不对。符号扩展的本质,是把部分积从 N 位补码扩展成 2N 位补码,高位补 0 还是补 1,完全取决于原数的符号位。

2.3 Booth 算法:为什么能省事

如果乘法器直接用符号扩展部分积的方式实现,无非是多加几列。但问题是,乘数有多少位,就要生成多少个部分积,硬件面积和功耗都不划算。于是 Booth 算法出现了。

Booth 的思想可以理解为“用减法代替一串加法”。比如要算某个数乘以 0011 1110,按朴素算法要加 6 个部分积。但如果把 0011 1110 看成 0100 0000 - 0000 0010,即 64 - 2,就可以只做一次减去 2 倍被乘数、一次加上 64 倍被乘数,部分积数量从 6 个变成 2 个。

实际硬件里用的是 radix-4 Booth 或 radix-2 Booth。radix-4 每次扫描乘数的 3 位,把乘数重编码成 {-2, -1, 0, 1, 2} 五组选择,部分积数量减半。比如 8 位乘法用 radix-4 Booth,最多只需要 4 个部分积。而且因为 Booth 编码在处理乘数时已经把符号位的权重考虑进去了,部分积累加时不再需要额外的符号扩展工作,硬件实现非常干净。

很多 FPGA 里的 DSP 硬核乘法器,底层就用到了类似 Booth 的重编码思想。所以理解 Booth 不只是为了应付考试,它能直接解释为什么乘法器的延迟和资源消耗和乘数位宽的关系不是线性。

2.4 结果位宽怎么定

两个 N 位有符号数相乘,完整结果最多需要多少位?答案是 2N 位。

最极端的情况是 (-2^{N-1}) × (-2^{N-1}) = 2^{2N-2}。比如两个 8 位有符号数相乘,极端乘积是 (-128) × (-128) = 16384,正数 16384 需要 15 位二进制表示,16 位完全放得下。再比如 -128 × 127 = -16256,也需要 15 位加一个符号位,16 位同样足够。所以 2N 位一定不会溢出。

但在很多工程场景里,我们并不需要保留全部乘积位。DSP 里的 FIR 滤波器、卷积运算,结果最终要截断到累加器宽度或者输出位宽。这时候是直接截掉低位,还是保留高位,取决于小数点的位置。

以 Q15 定点数为例。Q15 表示一个数在 [-1, 1) 范围内,最低位权重是 2^{-15}。两个 Q15 相乘,结果是一个 Q30 的定点数,理论上需要 30 位小数。如果要把结果存回 Q15,需要右移 15 位,去掉多余的低位小数,同时要考虑舍入和饱和。直接右移相当于向下取整,在信号处理中可能引入直流偏置;常见做法是加上 2^{14} 再右移,也就是四舍五入到最近值。这些细节在实际工程项目中直接影响输出信噪比和波形质量。

3. 代码里的有符号乘法:C 与 Verilog 的坑

3.1 C 语言的类型提升与符号混用陷阱

C 语言的有符号乘法,表面上看很简单,但坑藏在类型转换规则里。

先看整型提升。char、short 类型的值在参与运算时,会先被自动提升为 int。int 至少是 16 位,在绝大多数平台上是 32 位。比如两个 int8_t 相乘:

#include <stdio.h> #include <stdint.h> int main(void) { int8_t a = -5; int8_t b = 12; int16_t c = a * b; // 两个操作数都提升为 int,相乘得 -60 printf("signed result: %d\n", c); uint8_t ua = (uint8_t)a; // 位模式 0xFB,无符号解释为 251 uint16_t wrong = (uint16_t)ua * b; printf("unsigned result: %u\n", wrong); // 3012 return 0; }

上面的 wrong 就是我在 2.1 节算过的 3012。它给出的不是 -60,原因就是 ua 按无符号解释后,符号位变成了正的权重。

更隐蔽的是 signed 和 unsigned 混用。C 语言有一条规则:当一个有符号类型和一个无符号类型参与同一运算时,有符号类型会被隐式转换为无符号类型。看这段代码:

#include <stdio.h> #include <stdint.h> int main(void) { int32_t x = -1; uint32_t y = 1; uint32_t r1 = x * y; // x 转为无符号,0xFFFFFFFF * 1 = 0xFFFFFFFF int32_t r2 = x * y; // 结果是无符号,赋值给有符号时按实现定义转换 printf("r1: %u\n", r1); // 4294967295 printf("r2: %d\n", r2); // 常见平台输出 -1 return 0; }

r2 能在主流平台上得到 -1,纯粹是因为无符号 0xFFFFFFFF 按位拷贝到 int32_t 后,按补码解释恰好是 -1。这个“恰好”会让不少人误以为混用没问题。但一旦数值范围超过有符号正数上限,结果就会彻底错乱。更可怕的是,C 标准里有符号整数溢出是未定义行为,编译器在优化时可能把溢出情况当成永远不会发生,从而生成让人摸不着头脑的代码。Debug 版正常、Release 版发疯的情况,很多时候就是这里埋下的雷。

3.2 Verilog 中如何写出正确的有符号乘法器

Verilog 的有符号乘法,核心是把端口和寄存器都声明成 signed。

module signed_mult ( input wire signed [7:0] a, input wire signed [7:0] b, output wire signed [15:0] p ); assign p = a * b; endmodule

两个 8 位有符号数相乘,输出至少要 16 位,这和第 2 章讲的位宽结论一致。如果输出只留 8 位,负负得正、正正得负这几种溢出情况会非常离谱。

最容易踩的坑是混用无符号和有符号。比如:

reg [7:0] a; reg signed [7:0] b; wire signed [15:0] p = a * b;

这里 a 没有声明成 signed,Verilog 会按照“只要有一个操作数是无符号,乘法就按无符号处理”的规则执行。结果相当于把 a 当成 8 位无符号数去和 b 相乘,完全是另一种结果。

修正方法有两种。一种是把 a 也声明为 signed;另一种是用 $signed 转换函数:

wire signed [15:0] p = $signed(a) * b;

$signed 只是把位模式解释方式改变,不改变底层数据,所以不会像类型转换那样引入额外的数值修正。同理,$unsigned 可以把有符号信号按无符号解释,常用于位拼接和寄存器切片的场景。

仿真波形里还有一个特别容易迷惑人的现象。很多 FPGA 开发环境的波形窗口,默认用无符号十进制显示 reg 和 wire。即使信号本身声明成 signed,波形面板上仍然显示一个巨大的正数。比如 -1 显示成 65535,-32768 显示成 32768。这不是仿真结果错,只是显示 radix 不对。需要手动把波形的显示格式改成 signed decimal,或者补一位十六进制观察,心里默念补码规则。

3.3 饱和乘法器的实现

在很多控制类和信号处理场景中,乘法结果不能随意回绕。比如电机控制里的 PID 输出,如果乘法结果超过 DAC 或 PWM 的范围,我们希望它停在最大值,而不是突然变成负的最大值。

一个带饱和的 8 位输出乘法器,思路是先算完整乘积,再判断是否超出输出范围,超了就钳位:

module sat_mult #( parameter WIDTH = 8 )( input wire signed [WIDTH-1:0] a, input wire signed [WIDTH-1:0] b, output reg signed [WIDTH-1:0] p ); localparam MAX_VAL = (1 << (WIDTH-1)) - 1; localparam MIN_VAL = -(1 << (WIDTH-1)); wire signed [2*WIDTH-1:0] full = a * b; always @(*) begin if (full > MAX_VAL) p = MAX_VAL; else if (full < MIN_VAL) p = MIN_VAL; else p = full[WIDTH-1:0]; end endmodule

这种写法在最终截断前先把值拉回合法区间,避免了回绕带来的阶跃。实际项目里如果追求极致时序,可以把饱和判断改成两级流水线;如果乘法器本身放在 DSP 硬核里,还需要结合 DSP 硬核的寄存器级做时序约束。核心思想不变:先宽后窄,加范围保护。

4. MATLAB 中十六进制转有符号数:方法与实测

4.1 为什么总有这个需求

机械臂的关节角度、温度传感器的原始 ADC 值、下位机上报的报文……这些数据在串口和总线里传输时,往往以十六进制字符串形式出现。比如一条报文里出现 0xFF38,它到底是 65336 还是 -200,完全取决于协议里定义的是有符号还是无符号。

很多工程师在 MATLAB 里用 hex2dec 一转换,发现数值不对,第一反应是“是不是大小端反了”,折腾半天才发现其实问题在符号解析。这就是为什么需要一套可靠的 16 进制转有符号数方案。

4.2 三种可靠实现

第一种方案,手写判断函数。最直观,也最可控。

function dec = hex2signed(hexStr, bitWidth) dec = hex2dec(hexStr); if dec >= 2^(bitWidth-1) dec = dec - 2^bitWidth; end end

用法示例:

dec1 = hex2signed('FF', 8); % -1 dec2 = hex2signed('FFF8', 16); % -8 dec3 = hex2signed('8000', 16); % -32768

第二种方案,用 typecast 按位模式重解释。适合批量数据和本来就按整数类型存储的场景。

val = uint16(hex2dec('FFF8')); dec = typecast(val, 'int16'); % -8

注意 typecast 要求输入输出字节数一致。8 位十六进制对应 uint8 加 int8,16 位对应 uint16 加 int16,32 位对应 uint32 加 int32。如果收到的是一个字节数组,先拼接成对应整数,再 typecast,可以省去手动判断符号位的步骤。

第三种方案,用固定点工具箱的 fi 对象。fi 能同时完成位宽声明、有符号解析和格式转换,特别适合做定点算法原型验证。

dec = double(fi(hex2dec('FF'), 1, 8, 0)); % -1

fi 的参数分别是数值、符号位(1 表示有符号)、总位宽、小数位宽。fi 会把大于有符号最大值的输入按补码回绕解释,得到预期的负数。三种方案各有适用场景,我做了一个简单的对比:

方案优点局限
手写 hex2signed无工具箱依赖,逻辑清晰处理超长位宽时需注意精度
typecast直接操作底层字节,性能好需要手动保证字节数匹配
fi 定点对象融合位宽、符号、定标,适合算法验证需要 Fixed-Point Designer 工具箱

4.3 结合有符号乘法的实战

有符号数乘法在 MATLAB 里最常见的应用场景,是把采集到的十六进制数据还原成真实物理量,再做定点或浮点运算,最后再编码回十六进制给下位机。

举个例子。一个温度传感器通过串口上报 16 位十六进制数据,协议规定为 Q15 格式的有符号数,满量程对应 -50 到 +150 摄氏度。读取到 'C000',按 Q15 解释,实际温度是多少?

raw = hex2signed('C000', 16); % -16384 temperature = raw / 32768 * 200 - 50; % -150

计算过程很清晰:Q15 定点数的整数部分从 -1 到接近 1,乘以量程 200 再偏移 -50,就是实际温度。这是“hex 转有符号数 + 定点乘法定标”的典型链路。

如果收到的数据是多个字节,还要注意大小端。比如下位机发 [0xFF, 0x38] 两个字节表示 16 位有符号数,需要先按协议拼成 0xFF38,再做转换:

bytes = [0xFF, 0x38]; word = bytes(1) * 256 + bytes(2); dec = hex2signed(dec2hex(word, 4), 16); % -200

批量处理一串报文时,可以用数组循环或者 cellfun。数据量达到几万条时,建议一次性 vectorize,避免 for 循环拖慢速度。一条简洁的无符号转有符号批量语句是:

raw = hex2dec('FF38'); % 示例 signedVal = raw - (raw >= 2^15) * 2^16;

这个写法本质上是前面手写函数的向量化版本,性能比逐条判断快不少。

5. 有符号乘法调试套路与常见问题速查

5.1 现场排查四步法

遇到乘法结果异常时,我一般按下面四步排查。这四步基本能覆盖九成以上的问题。

第一步,确认操作数类型。代码里两个量到底是不是都声明成了有符号?C 里是不是混进了 unsigned?Verilog 里是不是有个 reg 忘了加 signed?先用类型和位宽把嫌疑范围缩小。

第二步,确认结果位宽。两个 N 位有符号数相乘,至少留 2N 位。如果输出被截断,先看是不是高位被砍掉了。把乘积完整打印或拉线出来看,很多问题一眼就能发现。

第三步,检查是否有隐式转换或提升。C 的整型提升、Verilog 的无符号混用规则,都会改变乘法语义。检查的方法很简单:把乘积结果直接打印成十六进制,和手工算的补码结果比对。

第四步,验证边界值。用 0、1、-1、最大正数、最小负数各算一遍。比如 8 位乘法,至少跑一下 127 × 127、127 × (-128)、(-128) × (-128),看结果是否饱和或被正确截断。边界值过了,大部分中间值就不会有大问题。

5.2 常见问题速查表

现象可能原因处理办法
C 中负数相乘得到巨大正数操作数被隐式转为无符号检查 unsigned 混用,保持类型一致
C 中 release 和 debug 行为不一致有符号溢出触发未定义行为扩大结果位宽或改用无符号运算并自行处理回绕
Verilog 波形显示大正数波形 radix 默认无符号将信号显示格式改为 signed decimal
Verilog 中 signed 与 unsigned 相乘结果错误混用导致按无符号乘法处理全部声明 signed,或用 $signed() 转换
乘法输出被截高位后符号翻转结果位宽不足输出扩展到 2N 位后截断
MATLAB hex2dec('FF') 得到 255hex2dec 只认无符号使用 hex2signed 函数或 typecast/fi
串口收到的字节顺序对不上大小端不符按协议交换字节后再转换

5.3 一个真实的调试故事

有次我在调一块音频处理板,输入是两路 16 位 ADC 数据,算法里有一个 16 位乘 16 位的混音系数相乘。刚开始仿真波形完全正常,但实际板上跑起来,高音量时输出信号总有明显咔哒声。

排查了很久,最后发现问题是:乘法器输出是 32 位,但为了防止信号过大,只截了高 16 位给 DAC。DAC 的数据总线是补码无符号混接的,截位后没有做饱和处理,一旦乘积超过 16 位正数范围,就会回绕成负数,输出直接跳变,产生爆音。

后来在截位前加了一级饱和判断,问题立刻消失。这个教训让我养成了一个习惯:任何乘法器的输出截断点,都要问一句“这里到底应该回绕还是饱和”。回绕只适合地址计算、哈希等场景,涉及物理量、声音、图像,几乎都该用饱和。

有符号数乘法讲到底,就是三个问题的组合:符号对不对、位宽够不够、溢出了怎么处理。把这三个问题拿捏住,不管是写 C、写 Verilog,还是在 MATLAB 里做定点验证,都能少走很多弯路。我个人的习惯是,每次写乘法前先停下来想这三点,写完再用手工算一遍边界值,基本就不会再被这个“简单问题”坑到了。

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

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

立即咨询