先讲个我踩过好几次坑的场景你以为两个负数相乘照小学数学竖式一路乘下去就完了结果一上板子波形出来一个完全离谱的数查了半天发现问题出在“补码中间结果忘了符号扩展”。很多讲二进制补码的资料加法讲得头头是道一讲到有符号数乘法就含糊带过仿佛“乘法和加法一样符号位自然处理就行”。但实际上补码乘法才是真正让人挠头的地方。这篇文章我想把有符号数乘法这件事彻底讲透从为什么补码要这么设计到手算怎么算再到硬件乘法器是怎么用 Booth 算法偷懒的最后落到实际工程里最容易翻车的溢出和位宽问题。内容兼顾纯软件、嵌入式、数字 IC 三个方向只要你写过一句a * b或者看过一眼 RTL 里的*都值得花几分钟过一遍。1. 补码的来历负数不是“记个符号”而是要绕一圈回来1.1 原码的麻烦加法和减法互相看不顺眼早期计算机要想表示负数最直觉的想法就是“符号位 数值位”也就是原码最高位表示正负剩下几位表示绝对值。4 位字长下-3就是10113就是0011。听起来没毛病但你一旦要做加减法就头疼了。加法和减法得完全分开做减法还得先比较绝对值大小谁大谁小然后大数减小数最后再补一个符号位。这不叫算法这叫伺候人。更致命的是原码里0有两种表示0000和1000一个正零一个负零。每次判断if (x 0)都得多做一层处理硬件逻辑被这种无聊的特例白白拖累。1.2 补码的“钟表”直觉负数就是往回拨一格补码的思路完全不一样。别把最高位当“符号开关”把它当成“负权重”来看。一个 4 位二进制数权重从高位到低位分别是-8, 4, 2, 1。所以1101 -8 4 0 1 -3。这就是补码符号位天然参与数值计算不需要额外判断。它的几何感觉就像一个 24 小时的钟表。钟面上没有“-3 点”只有 21 点但你心里清楚21 点就是昨天剩下的 3 小时等价于往前拨 3 小时。二进制里也一样4 位字长下-3写出来是1101但1101按无符号读是 13。在模 16 的世界里13 和 -3 根本就是同一个数13 -3 16。所以补码的本质是“同余”负数就是比模数小一点的那个无符号数。这也解释了为什么负数取补码的方法是“按位取反再加一”3 0011取反得到1100加一得到1101。从模 16 的角度看取反其实是在算(15 - x)再加一就是16 - x正好回到“往回拨一圈”的位置。理解这一点后再看乘法你会明白一个关键结论补码本身为加减法而生的它没有义务让乘法也“自然成立”。乘法的深水区比自己补码的取反加一复杂得多。2. 有符号数乘法翻车的高发区符号扩展与位宽倍增2.1 为什么乘法天然会把位数撑大加法是两个同宽度的数相加结果最多比原宽度多 1 位所以溢出很好判断。乘法不是这样一个 n 位二进制数和一个 n 位二进制数相乘结果最多需要 2n 位才能放得下。这并非补码特有的问题无符号数也一样。比如两位无符号数最大是 33×39二进制是 4 位的1001。所以任何乘法器设计第一件事就问你你要的是“截断后的 n 位结果”还是“完整宽度的 2n 位结果”。这个选择直接决定了后面所有逻辑的写法。2.2 一个一眼看出毛病的案例我们用一个 4 位补码的例子。-3的补码是11015的补码是0101。如果你把1101当成无符号数 13 去做普通二进制乘法得到 65二进制是01000001。这个 65 和正确答案-15差了十万八千里。用代码演示更直接def sign_extend(x, bits): 把 bits 位补码扩展成 Python 带符号整数 sign 1 (bits - 1) return x - (1 bits) if x sign else x a 0b1101 # 4 位补码代表 -3 b 0b0101 # 4 位补码代表 5 # 错误做法直接当无符号数硬乘 raw a * b print(raw) # 65完全错误 # 错误做法把结果的低 4 位当补码 truncated raw 0b1111 print(sign_extend(truncated, 4)) # 1还是错误 # 正确做法先还原成带符号数再乘 correct sign_extend(a, 4) * sign_extend(b, 4) print(correct) # -15正确从这里能看出一个深层原因当中间过程的某个部分积没有做符号扩展后面的求和就等于是把“模 16 世界里的 13”和“正常世界里的 5”相乘得到的是另一个世界的数字。所以无论是软件还是硬件有符号乘法最核心的操作步骤就是符号扩展。2.3 符号扩展的常见写法符号扩展规则就一句话这个数是负数就把新增的高位全补 1是正数就全补 0。看起来简单实际写代码时容易手滑。比如某段 Verilog 里如果写成了wire [7:0] ext {4b0, a};那负数直接就变正数了。正确写法是用重复操作符wire signed [3:0] a; wire [7:0] ext {{4{a[3]}}, a}; // a[3] 是符号位如果你用的是 Verilog 里的signed关键字编译器会自动处理符号扩展但前提是两个操作数都声明成signed。最坑的是那种“一个 signed 一个 unsigned”混搭工具会按无符号处理结果全在不知不觉间就错了。我的习惯是任何跟有符号数乘法相关的信号必须显式加signed修饰并且代码评审时重点看这一行不要信任隐式规则。Python 里做符号扩展有一个常用小技巧x | ~0xF把高位全部置 1。但我更喜欢写成函数形式逻辑清楚也方便做十六进制转有符号数时的调试。3. 手算补码乘法最稳的方法反而是“先取绝对值”3.1 不推荐课堂竖式的原因网上很多资料教你直接列竖式把每个部分积都做符号扩展然后加起来。这个方法理论可行但实操时很容易算错。我前面踩坑时试过-3 × -3用竖式算出来的中间结果经常对不上就是因为“负数权重的部分积”和“正数权重的部分积”混在一起时你得在某一列做减法而普通竖式只有加法。为了手算不烧脑我强烈建议换一条路符号和数值分开处理。做法如下把两个数都取绝对值得到一个正数。用普通无符号乘法把两个绝对值乘起来。根据两个原始输入的同号还是异号决定最终结果是正还是负。如果是负数把步骤 2 的结果再取补码。这个过程看着多了几步但每一步都是最基础的运算很难出错。3.2 三种符号组合完整演示4 位补码字长取 8 位输出。正乘负5 × (-3)绝对值5 × 3 15异号结果应为负数15 00001111取补码11110001验证11110001按 8 位补码读权重从高位到低位是-128, 64, 32, 16, 8, 4, 2, 1算出来是-128 64 32 16 1 -15正确。负乘正(-3) × 5这个和上面本质相同结果同样是-15二进制还是11110001。所以要验证自己竖式算得对不对直接看符号扩展后的数值能不能对回去。负乘负(-3) × (-3)绝对值3 × 3 9同号结果应为正数9 00001001验证两个负数相乘得到正 900001001高位是 0读出来就是 9。这样一套流程下来你既不用处理符号扩展的部分积也不用纠结中间进位适合所有手算和快速确认结果。我平时在纸上验证电路时也一直是这么算的。3.3 为什么“先绝对值乘法”对现代乘法器不重要你可能会问既然取绝对值这么爽那硬件为什么不这么做因为取绝对值要付出代价。判断符号、条件取反、再加一这些操作都会增加组合逻辑级数和时延。乘法器本身已经不是单周期能随便搞定的事了再在前面串一堆取绝对值电路关键路径直接爆炸。所以硬件专门设计了一种不需要预判符号直接按补码位模式运算的乘法算法这就是下一节的主角Booth 算法。4. Booth 算法硬件乘法器怎么“偷懒”4.1 为什么需要 Booth先看一个无符号乘法器的基础结构一个 n 位乘 n 位的乘法器内部可以展开为 n 个“部分积”的加法每个部分积等于“被乘数 乘数某一位”然后移位对齐。位数一多绵延的全加器阵列非常壮观。但实际上很多部分积是 0纯属白算。Booth 算法的高明之处在于它扫描乘数二进制位里的“连续 1 块”。比如乘数011110正常做法要加四次被乘数。但如果把它看成100000 - 000010那么只需要做一次加、一次减。硬件上就是扫描相邻两个位根据相邻位的组合决定是加、减还是不操作。这样既能处理补码的负权重又能压缩部分积数量。4.2 规则表与例子Booth 算法一位布斯也叫 Radix-2 Booth在被乘数最低位右侧补一个 0 作为附加位然后每次看“当前最低位”和“它右边那一位”的配对。规则如下当前位右边位操作00无操作01加上被乘数10减去被乘数11无操作为什么 0→1 是加1→0 是减因为遇到连续 1 的块前半段进入 1 时要加一次后半段离开 1 时要减一次中间全部跳过。这正好等价于把连续 1 段替换成高位加一、低位减一。用-3 × 5走一遍完整迭代。设A -3 1101B 5 0101部分积寄存器 P 初始化为0000 0101 0其中前面 4 位是累加器中间 4 位是乘数最后 1 位是附加位。每轮按最低两位决定操作然后整体算术右移。轮次P 低两位操作操作后 P右移后 P110累加器减 A0000-110100110011 0101 00001 1010 1201累加器加 A0001110111101110 1010 11111 0101 0310累加器减 A1111-110100100010 0101 00001 0010 1401累加器加 A0001110111101110 0010 11111 0001 0最后一行的 P 高 4 位是1111低 4 位是0001拼起来是11110001正好是-15。这里最需要注意的一点是右移必须是算术右移。右移时新补进来的高位和被乘数符号保持一致而不是无脑补 0。很多自己实现 Booth 算法的人在这里翻车一位移位补错了后面全错。4.3 从一位 Booth 到多位 Booth实际工程里一位 Booth 已经很少单独使用了。因为一位 Booth 每轮只处理一位乘数部分积数量没有实质减少硬件省下的主要是加法器的动态功耗。真正常用的是 Radix-4 Booth也叫“修正 Booth 编码”每轮扫描 3 位乘数把部分积数量压缩一半。它除了加被乘数、减被乘数以外还要支持左移一位后的被乘数即 2 倍所以硬件上要提前准备A、2A、-A、-2A这些候选值。如果你写 RTL 时直接用了综合工具的*运算符大家通常会把它映射到某种 Booth 编码器 Wallace 树压缩不需要你自己手写。但学习 Booth 的意义在于当综合结果面积不符合预期、或者你需要做流水线拆分时只有理解了乘法器是怎么被展开的才知道瓶颈在哪里。5. 溢出与位宽规划工程里最先坑人的不是算法是截断5.1 乘法不会“溢出”截断才会严格讲只要给足 2n 位输出乘法永远放得下。工程项目里发生的“溢出”几乎都是因为输出寄存器或总线位宽不够悄无声息地截断了。最经典的翻车例子127 × 1278 位有符号数。真实结果是16129 0x3F01。如果你只用 8 位保存结果低 8 位就是0x01 1。127 乘 127 等于 1这还不是最恶心的。更麻烦的是在 C 语言里两个int8_t相乘会被自动提升到int计算结果本身没错一旦你把它赋给int8_t截断后的结果可能看起来非常符合“硬件行为”于是程序就带着这个错误结果一路狂奔直到最后数据全乱了你才回头查。5.2 如何判断截断是否发生判断方法很简单如果完整结果的高 n 位不是全 0 也不是全 1那说明低 n 位截断后一定失真。因为补码的符号扩展本质上就是把符号位不断复制到高位所以一个正确的、能放进 n 位的结果它的高 n 位必然全是相同的符号位。例如 8 位字长127 × 127 0x3F01高 8 位是0x3F既不是0x00也不是0xFF所以截断必然出错。写代码时你可以这样兜底int16_t a, b; int32_t r32 (int32_t)a * b; // 先提升到 32 位 // 判断结果是否超出 int16 范围 if (r32 INT16_MAX || r32 INT16_MIN) { // 饱和或者报错取决于你的业务逻辑 r32 (r32 0) ? INT16_MAX : INT16_MIN; } int16_t r16 (int16_t)r32;这段代码看起来简单但实际项目里很多人图省事直接写int16_t r16 a * b;那就等着数据玄学吧。5.3 硬件位宽规划的一条经验法则数字 IC 里做乘法之前一定先倒推一遍极端值。不用复杂的数学需要记住两个极端值INT_MIN和INT_MAX。比如设计一个 16 位 x 16 位乘法器完整结果必须是 32 位如果你只取 16 位输出那么-32768 × -32768的真实结果是1073741824而截断后的 16 位补码是0。没错两个绝对值的最大负数相乘得到的是 0听起来不可思议但这就是模运算的结果。所以我在做信号处理链路时习惯给中间结果留宽16bit × 16bit - 32bit然后再统一做饱和/舍入到 16bit。这样每个子系统之间的接口证明确确才不会被某个偶然的尖峰数据击穿。另一个容易被忽略的坑是除法。乘法的坑是“结果变宽”除法的坑是“结果变窄”二者凑一起时一定要用足够宽的中间寄存器否则一条链路算下来误差会一层层叠上去。6. 从补码乘法到浮点、矩阵和日常调试工具6.1 MATLAB 十六进制转有符号数的正确姿势排查数据时经常看到一串十六进制比如F123你心里得能快速反应它是不是负数、是多少。手算当然可以但脚本时代不用脚本就是浪费。MATLAB 里最常犯的错误是直接用hex2dec转完当成有符号数用。hex2dec(F123)返回的是 double 类型的 61731完全不是-3805。要保留位模式并解释为有符号数得用typecast% 16 位十六进制转 int16 v16 typecast(uint16(hex2dec(F123)), int16); % 32 位十六进制转 int32 v32 typecast(uint32(hex2dec(FFFFF123)), int32); % 如果是小端序收到的数据别忘了先处理字节序 v16_le typecast(swapbytes(uint16(hex2dec(F123))), int16);核心逻辑是先hex2dec得到纯数值再用uint16/uint32把这串数“固化”成无符号位模式最后typecast重解释成有符号类型。这跟补码的“模”思想完全一致数据本身只是一串位解释权在你手上。如果你是写 Python 的对应的做法是import struct value struct.unpack(h, bytes.fromhex(F123))[0] # 小端 value struct.unpack(h, bytes.fromhex(F123))[0] # 大端6.2 浮点数乘法另一条完全不同的路线有人问浮点数乘法是不是也得做补码乘法答案是模运算上彻底分离了。IEEE 754 浮点数把符号位单独放在最高位数值部分用“符号-绝对值”的方式表示尾数永远是正数或者负数由符号位决定。所以浮点乘法器内部做尾数乘法时根本不需要 Booth 处理负数权重直接拿无符号/绝对值部分做乘法最后再把符号位用异或门拼出来两个符号位相同得正不同得负。这也解释了为什么当你觉得“整数乘法这么难浮点乘法是不是更复杂”时实际反而更简单浮点乘法的难度主要在于指数对阶、尾数规格化和舍入而不是负数权重。如果你去读浮点乘法器的 RTL会看到一堆移位器、加法器、归一化逻辑但很少看到复杂的补码乘法修正。6.3 矩阵乘法中的“行观点”和“列观点”矩阵乘法听起来和补码没什么关系但当我们真正做高性能计算时一个矩阵乘法里的每一个标量相乘归根到底还是那套补码或浮点乘法。所谓“行观点”和“列观点”是指看待C A * B的两种方式行观点把 A 的每一行和 B 的每一列做点积得到一个元素。列观点把 A 的一列和 B 的一行做“外积”一次贡献一个秩 1 矩阵累加到 C 上。这两种观点数学上等价实际性能天差地别。行观点偏随机访问 B 矩阵的一列如果 B 按列存储还好按行存储就有大量缓存未命中列观点则更擅长利用 B 的连续存储优势。所以现在很多矩阵库都在讨论要不要把矩阵重新布局本质上就是给 CPU/GPU 减少 cache miss跟补码乘法本身关系不大但会影响你在哪个资源上放大规模点积时会不会溢出。工程上我的建议是无论哪种观点一定要留意中间累加器的位宽。标量乘法你可能给足 32 位但矩阵乘法的累加器会一加几千次只用 32 位整数照样溢出。这也是为什么现代 BLAS 库做整数矩阵乘法时动不动就给你累加一个 64 位甚至更长宽度的中间和。结尾留个实际经验我做过的不少信号处理项目里真正花时间调试的从来不是 Booth 算法本身而是数据一路传递过程中的位宽丢失。补码乘法这个知识点表面上是数字电路教材里的老古董但落到实际代码里就是你到底有没有把int16提升成int32、有没有在截断之前做饱和、有没有把十六进制数正确解释成有符号数。如果你今天只能带走一句话我希望是所有有符号数乘法的坑最后都能归因到“符号扩展”和“位宽截断”这八个字。写代码时多问自己一句这个结果还能不能安全放回原来的变量里先做到这一点再用 Booth 那套去优化硬件顺序别搞反了。