ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

有符号数乘法:补码原理、代码陷阱与MATLAB转换全解析

有符号数乘法:补码原理、代码陷阱与MATLAB转换全解析 有符号数乘法这六个字看着简单实际是数字电路、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_{i0}^{N-2} b[i] \times 2^{i} ]最高位被赋予了负权重其余位仍然是正权重。这就是补码和“取反加一”能够对上的根本原因。拿 4 位二进制来举例1110 -8 4 2 -21111 -8 4 2 1 -10111 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 ~ 1278 位0x80 ~ 0xFF-128 ~ -116 位0x0000 ~ 0x7FFF0 ~ 3276716 位0x8000 ~ 0xFFFF-32768 ~ -132 位0x00000000 ~ 0x7FFFFFFF0 ~ 214748364732 位0x80000000 ~ 0xFFFFFFFF-2147483648 ~ -1MATLAB 里默认的 hex2dec 函数只认无符号输入 FF 会返回 255 而不是 -1。这就是为什么“16进制转有符号数”能成为高频搜索词。后面第 4 章我会专门给几种可靠的转换写法。2. 补码乘法为什么不能“直接乘”数学与硬件2.1 一个反直觉的算例-5 × 12先看一个真实场景。假设我要计算 8 位有符号数 -5 乘以 12。数学结果显然是 -60。-5 的 8 位补码是 1111101112 的补码是 00001100。如果我把两个补码当成无符号数做乘法11111011 按无符号读是 251乘以 12得到 3012。十六进制是 0x0BC4二进制是 0000101111000100。3012 和 -60 差了十万八千里。问题出在哪关键的数学推导是这样的。设一个负数 A 的补码按无符号读出的值为 A那么[ A A 2^8 ]因为补码把最高位的负权重 -128 换成了正权重 128正好相差 256。对于 -5A -5 256 251完全吻合。类似的如果乘数 B 也为负B B 2^8。那么无符号乘法实际算的是[ AB (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 没有声明成 signedVerilog 会按照“只要有一个操作数是无符号乘法就按无符号处理”的规则执行。结果相当于把 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 加 int816 位对应 uint16 加 int1632 位对应 uint32 加 int32。如果收到的是一个字节数组先拼接成对应整数再 typecast可以省去手动判断符号位的步骤。第三种方案用固定点工具箱的 fi 对象。fi 能同时完成位宽声明、有符号解析和格式转换特别适合做定点算法原型验证。dec double(fi(hex2dec(FF), 1, 8, 0)); % -1fi 的参数分别是数值、符号位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 里是不是混进了 unsignedVerilog 里是不是有个 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 decimalVerilog 中 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 里做定点验证都能少走很多弯路。我个人的习惯是每次写乘法前先停下来想这三点写完再用手工算一遍边界值基本就不会再被这个“简单问题”坑到了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进