首页 / 资讯中心 / 文章详情

原码反码补码从原理到实战:手算-105的16位补码与常见陷阱

原码反码补码从原理到实战:手算-105的16位补码与常见陷阱 ★ FEATURED ARTICLE
三年前我第一次在汇编课上被原码反码补码按在地上摩擦时怎么也想不通明明叫原码是给人看的计算机却偏偏要用补码来算。后来自己写了一个模拟ALU的小项目用门电路搭加减法器才彻底明白这三兄弟的关系根本不是什么教学顺序而是一段硬件设计师被现实逼出来的进化史。这篇我就把当时的理解过程完整捋一遍从为什么原码不行到补码怎么算最后把 -105 的 16 位补码手算全程和常见的翻车点都摊开来说。1. 一条 1 (-1) 的歪路原码为什么第一个被淘汰1.1 原码的第一印象好看但只是好看原码的定义几乎不需要解释最高位当符号位0 代表正数1 代表负数剩下的位直接放绝对值的二进制。8 位原码里5 就是 0000 0101-5 就是 1000 0101。你要是把它写在纸上给人看完全符合直觉这就是我们平时写正负号 数值的习惯被翻译成二进制的样子。我第一次学到这里是舒服的毕竟没有任何弯弯绕绕。但舒服只是暂时的因为只要真的把两个原码数字丢进加法器问题立刻就炸了。拿 1 (-1) 举例按小学加法直接逐位加原码是这样的0000 0001 (1) 1000 0001 (-1) ----------- 1000 0010最高位是 1后面是 000 0010翻译回来是 -2。这结果错得离谱关键是加法器压根不知道自己错了它只是老老实实把每一位加起来。原因也清楚原码里的符号位虽然叫符号位但在硬件眼里它就是普通的一位比特它和数值位一样参与逐位相加于是产生了这种莫名其妙的进位污染。1.2 原码做减法时人类和机器都崩溃如果以为加法出错是符号位惹的祸那干脆把符号位单独拎出来判断行不行行但代价是你得写一套先判断符号 比较绝对值大小 决定谁减谁 再定结果符号的流程。把这条流程翻译成逻辑电路就是一大串比较器和选择器运算速度慢得离谱。举例5 - 3在十进制里人类心算直接得 2。可如果只用原码且不引入补码概念那得先发现 5 的绝对值大于 3 的绝对值确定结果为正然后再做 5 的绝对值减 3 的绝对值。要是遇到 3 - 5流程还要反过来先借位再做借位修正。这套逻辑用软件模拟能跑但硬件工程师看到这种设计会直接摔键盘为了一个减法追加的电路规模比加法器本身还大好几倍实在不划算。1.3 一个数两个零比较器都犯迷糊还有一个很少有人注意但特别致命的问题0 的原码有两种写法。0 是 0000 0000-0 是 1000 0000。某次我调一个写得很烂的排序算法数据里有正零和负零两种皮的数字程序里写if (x 0)想过滤零值结果负零没被过滤掉导出来一看负数排序全乱套。原因就是从原码角度-0 和 0 确实是两个不同的二进制串。两条完全一样的数值却对应了两套编码这在数学上就是灾难。你没法用简单的按位比较判断两个整数是否相等因为 1000 0000 和 0000 0000 得先翻译成人能读的规则再做一次逻辑推断。正是因为加法错误、减法繁琐、双零混乱原码在实际做运算时根本没法直接用。但它的价值在于它是理解反码和补码的起点因为后面两个定义都建立在符号位加绝对值这个框架上。2. 反码的巧思按位取反背后的数学直觉2.1 反码到底是什么反码的规则我记得很清楚正数的反码等于原码负数的反码是符号位不变、其余每一位取反。比如 8 位里 -5 的原码是 1000 0101按位取反得到 1111 1010这就是 -5 的反码。当时我的第一反应是这不就是把 1 换成 0、0 换成 1 吗看起来毫无道理直到我把它当成一个数学函数去理解对一个 n 位数 x 做按位取反实际上算的是(2^n - 1) - x。举 8 位的例子x 0000 01015按位取反得到 1111 1010而2^8 - 1 255255 - 5 250正是 1111 1010 的十进制值。所以按位取反不是一拍脑袋想出来的花活它对应的是用一个全 1 的数去减原数。2.2 反码能救减法吗试一下就知道一半带着这个数学理解去看反码做减法思路就通了既然取反等价于用全 1 去减那 5 - 3 或许可以改写成 5 (255 - 3) 的形式而 255 - 3 恰好就是 3 的反码不需要真正的减法器只需要按位取反一根根导线接出来就行。8 位里手动验证 5 - 30000 0101 (5) 1111 1100 (-3 的反码因为 0000 0011 取反得 1111 1100) ----------- 0000 0001 最高位进位溢出8 位结果看 0000 0001直接看 8 位结果是 1但正确结果应该是 2。问题出在溢出的最高位进位丢了。反码解决这个问题的方法是端回进位把最高位的进位再加到最低位上去。上面的结果 0000 0001 加回进位 1得到 0000 0010这才是正确的 2。看到这里我觉得反码比原码聪明多了至少把真减法变成了取反加修正但修正还是要额外判断有没有进位、要不要再加一次。硬件上为了这个端回进位还得加一条回送线路和一次额外的加法效率损失不小。2.3 反码的残留问题零还是两个进位还是难缠反码和原码有个共同的毛病0 依然有两种表示。8 位反码里 0 是 0000 0000-0 是 1111 1111。而且端回进位这个东西在并行加法器里非常讨厌。它意味着你没法一次把加法做完必须先得到一个中间结果看最高位有没有进位有进位再回加一次。如果回加之后又产生了进位呢理论上不会因为两个 n 位数相加最多产生一次最高位进位但这一次额外回加已经从纯组合逻辑电路变成带反馈的时序逻辑了控制时序一下子复杂起来。所以反码在历史上不是完全没用很多老式机器确实用过反码但它是个典型的过渡方案——比原码强却没强到让人省心。3. 补码的翻身仗模运算视角下的唯一解3.1 钟表类比为什么减法可以变成加法补码之所以是最终答案核心思想来自模运算。一个 8 位二进制数能表示 0 到 255如果只在 8 位范围内看256 就等于 0因为再多一位就溢出了这部分必须丢掉。更准确地说8 位系统里的所有加减法其实都是在模 256 的意义下进行的。这个概念我后来用一个钟表才彻底想通钟面上只有 12 个数字从 3 点开始拨回 8 个小时到 7 点和从 3 点开始往前拨 4 个小时到 7 点效果完全一样。为什么因为 13 和 1 在模 12 的意义下是同一个数。减 8 就等于加 4差一个模数 12。放到 8 位二进制里模数是 256减一个数 b等价于加256 - b。这个256 - b就成了 b 的补数而负数在补码里的编码本质上就是它对应的那个补数。3.2 取反加一不是魔法是公式教材上总说负数补码 原码取反加一但对刚入门的人而言这句话像天降神谕。从模运算角度推导一下就清楚了。对于 n 位系统模 M 2^n。对一个数 x 按位取反前面说了得到的是(M - 1) - x。再在这个结果上加 1就得到M - x。而M - x恰恰是 x 关于模 M 的补数。所以取反加一不是一个约定俗成的小技巧它是在算M - x。例如 8 位下 -5 的补码先算 5 的二进制 0000 0101取反得 1111 1010加 1 得 1111 1011。验证一下256 - 5 251而 251 的二进制正好是 1111 1011对上了。3.3 补码带来的三大红利第一个红利是零唯一。模 256 下256 - 0还是 0所以 -0 不存在了。空出来的 1000 0000 也不能浪费它被定义为 -128。这就是为什么 8 位有符号整数的范围是 -128 到 127而不是对称的 -127 到 127。第二个红利是减法彻底统一成加法。减法X - Y变成X (256 - Y)而256 - Y正好是 Y 的相反数补码于是硬件只需要一个加法器减号在电路层面直接消失了。第三个红利是符号位可以光明正大地参与运算不需要额外判断。因为补码的符号位本质上也参与模运算进位自然丢弃结果天然正确。1 (-1) 用补码算一遍0000 0001 1111 1111 ----------- 0000 0000溢出进位丢掉剩 0干净利落。后来我自己在逻辑仿真软件里搭了一个四位加法器接上补码电路之后才发现整套做加减法的核心器件除了加法器就是取反器和增量器没有复杂的比较器原来硬件工程师舍得用补码就是这么个朴素原因。4. 公式解密为什么 X - Y 的补码等于 X补 Y补取反 14.1 拆开这条经常让人眼花的式子网上搜补码减法经常看到这么一条写法X - Y X补 Y补 1但第一次看的人特别容易懵因为按公式直接代Y 的补码加 1 到底是什么这里其实有个简写造成的误会。准确讲要计算 X - Y 的补码最直接的办法是先求 Y 的相反数的补码也就是对 Y 的补码按位取反再加 1记为(~Y补) 1再把 X 的补码加到这个结果上所以完整的公式是(X - Y)补 X补 (~Y补) 1网上那句X补 Y补 1里的 Y补通常指的是Y 的补码按位取反之后的缩写也就是~Y补并不是 Y 本身那个补码。你要是直接拿 Y 的补码原值去加 1举 5 - 3 的例子就会算出莫名其妙的结果。把 3 的补码取反得到 1111 1100再加 1 得到 1111 1101这才是 -3 的补码。最后让 5 的补码和它相加0000 0101 1111 1101 ----------- 0000 0010结果 2正确。4.2 这个公式为什么能同时处理正 Y 和负 Y我当时有个疑问如果 Y 本来就是负数这个公式还成立吗答案是成立但思路要反过来看。假设算 5 - (-3)也就是 5 3。用上面的流程3 的补码是 0000 0011取反得 1111 1100加 1 得 1111 1101这是 -3 的补码但这里 Y -3要算的是 5 - (-3)所以应该取 (-Y) 的补码也就是 3 的补码 0000 0011结果 0000 0101 0000 0011 0000 1000 8正确细看会发现对 Y 的补码按位取反再加 1这个操作求的永远都是Y 的相反数。如果 Y 是 3取反加一得到 -3如果 Y 是 -3取反加一又回到 3。所以无论 Y 的正负把公式里的 Y 当作已知正数去套也没问题因为取反加一这个操作天然具备求相反数的对称性。4.3 实战用公式算 100 - 6716 位环境里100 的补码是 0000 0000 0110 010067 的补码是 0000 0000 0100 0011。先对 67 的补码取反1111 1111 1011 1100再加 11111 1111 1011 1101。这是 -67 的补码十六进制是 FFB D。然后直接加0000 0000 0110 0100 1111 1111 1011 1101 ---------------------- 0000 0000 0010 0001最高位进位溢出被丢掉结果为 0x0021即 33。100 - 67 33完全一致。这类公式用熟了之后你会发现根本不需要记一堆分支规则不管被减数是正是负统一执行取反、加一、相加、丢进位四步答案一定对。位数要是不一致记得先把两个数符号扩展成同样宽度。5. 给十六位较真一次-105 的补码全程手算5.1 先把 105 写成 16 位二进制要求 -105 的 16 位补码第一步永远是先求 105 的二进制。我习惯用短除法简单且不容易出错105 ÷ 2 52 余 152 ÷ 2 26 余 026 ÷ 2 13 余 013 ÷ 2 6 余 16 ÷ 2 3 余 03 ÷ 2 1 余 11 ÷ 2 0 余 1从下往上读余数得到 110 1001。用四位一组切分是 110 1001补成 16 位前面补 9 个 0得0000 0000 0110 1001分四位看就是 0x0069。5.2 取反加一得到 FF97负数补码是对绝对值二进制整体取反再加 1注意是全部 16 位取反不是只处理数值位0000 0000 0110 1001 取反 1111 1111 1001 0110 加一 1111 1111 1001 0111换成十六进制就是 FF97。所以你以后看到 16 位有符号整数 FF97第一个想法就应该是它等于 -105。验证方法也很简单把 FF97 当成无符号数它是 0xFF97 65431用模 65536 减去它65536 - 65431 105负号一加回来就是 -105。这其实是另一个计算补码的捷径负数补码的无符号值等于2^n - 绝对值。5.3 手写的原码反码对照表为了看得更清楚我把 -105 在 16 位下的三种编码放一起编码类型二进制表示十六进制原码1000 0000 0110 10018069反码1111 1111 1001 0110FF96补码1111 1111 1001 0111FF97原码是符号位为 1后面跟 105 的 15 位绝对值反码是符号位保持不变、数值位取反补码是在反码基础上加 1。这张表建议贴在显示器旁边因为很多位运算题考的就是在三种编码之间互相转换最容易出错的反而是原码的绝对值位数。5.4 为什么 16 位够用8 位也够用但别混着用很多人会顺手把 -105 也换算成 8 位补码看看这是允许的因为 8 位有符号范围是 -128 到 127-105 落在里面。8 位计算如下105 的 8 位二进制是 0110 1001取反得 1001 0110加 1 得 1001 0111十六进制 0x97。但同样的十六进制 0x97在 16 位视角下是无符号 151在 8 位有符号视角下是 -105。所以一旦跨宽度推理必须明确当前数据的位宽否则 0x97 到底是正数还是负数根本说不清楚。我调程序的时候遇到过很多次类似问题最后都归因于把 8 位负数直接塞进 16 位变量符号位被错误地当成了普通数值补零。正确的做法叫符号扩展把 8 位的 1001 0111-105转成 16 位要在前面补 8 个 1得到 1111 1111 1001 0111也就是 FF97而不是 0000 0000 1001 0111 这种正数值。6. 补码实战里最容易翻车的四个细节6.1 陷阱一取反时把符号位也翻了个底朝天计算负数补码时第一步是对绝对值二进制整体取反再加 1这个整体包括最高位。但做原码转反码时符号位是保持不变的。这两个规则经常被混在一起。举个例子-105 的原码是 1000 0000 0110 1001求反码时符号位仍然为 1数值位取反得到 1111 1111 1001 0110。可如果你带着取反就是把每一位都反过来的惯性从原码直接逐位取反得到 0111 1111 1001 0110这就等于把一个负数变成了正数是 32662完全不是我们要的东西。记一个死规则原码转反码符号位不动反码转补码才是整体加 1包括符号位也参与进位。但补码的符号位在加法运算中是自然参与运算的不需要人干预。6.2 陷阱二补码求相反数也是取反加一别再绕回原码求一个负数补码的相反数有一个特别高效的办法对它整体取反加一。比如 -5 的 8 位补码是 1111 1011取反得 0000 0100加 1 得 0000 0101正好是 5。这是因为补码是一个对称结构取反加一操作就是数学上的取负函数。很多初学者算 -( -5 ) 时会走弯路先翻译成原码再判断符号再转换折腾半天。实际上补码体系里根本不需要人脑判断正负直接取反加一就完事。这也是补码适合硬件的原因之一它连求相反数都只有一种固定操作。6.3 陷阱三溢出判断不能只看最高位补码虽然好用但溢出问题总是冒出来。以 8 位为例-128 的补码是 1000 0000再减去 1 就超过了下边界。计算过程如下-128 的补码 1000 0000加上 -1 的补码 1111 1111得到 1 0111 11118 位截断后是 0111 1111即 127。你看结果竟然是正数 127符号位完全变了。但真正可怕的不是符号位变没变而是两个负数相加居然产生了正结果或者两个正数相加产生了负结果这种情况铁定溢出。正数加负数则永远不可能溢出因为结果被限制在两数之间的那个范围内。硬件上的判断思路是看最高位进位和符号位进位是否一致。如果最高位向更高位产生了进位而符号位没有进位说明结果的符号被数据位顶穿了反过来如果符号位进位了而最高位没进位同样说明溢出。这两个标志在大多数 CPU 里对应进位标志 C 和溢出标志 V编程时可以用它们配合检查。6.4 陷阱四零的唯一性与它带来的范围不对称补码解决了双零问题代价是表示范围变得不对称。8 位范围是 -128 到 127正数最多只能表示 127负数却能多一个 -128。这不是错误是模运算的自然结果。这个不对称带来的一个实用影响是当你用一个无符号变量读一个补码负数时读出来的无符号数会非常大。比如 16 位下 -1 的补码是 0xFFFF如果强制转成无符号 short就变成 65535。某次我调试数据协议时收到 0xFFFF 以为是合法的大数据后来才发现是对方发来的 -1这种坑在嵌入式串口通信和网络字节序处理里非常常见。建议在处理任何二进制协议时先明确规定字段是有符号还是无符号。各领域常犯的错误里十有八九都能追溯到把有符号数当无符号数解析或者反过来。7. 我自己验证补码正确性时用的两种土办法7.1 模数回推法算完再减回去每次手算补码结果不确定时我会用模数回推验证。对 n 位补码把结果的十六进制当作无符号数 U如果 U 大于等于 2^(n-1)那它就是一个负数真实值等于U - 2^n。比如刚才的 16 位 -105补码 0xFF97无符号值 65431减去 65536 得到 -105立刻校验通过。这种方法不依赖原码反码转换只需要一次减法非常快。7.2 运算法交叉验证用加法验证减法另一种土办法是拿补码做一道减法题再原路返回。比如算出 -105 的补码是 0xFF97 之后心里立刻算一下 0xFF97 0x00691111 1111 1001 0111 0000 0000 0110 1001 ---------------------- 0000 0000 0000 0000进位溢出丢弃结果是 0说明 -105 105 0互为相反数补码关系没算错。这个方法能顺便检验取反加一的流程有没有漏掉最高位进位实际操作中非常好用。从我个人的教训来看学原码反码补码最忌讳的就是死背规则。只要抓住模数、相反数、取反加一这条主线所有计算都能在一个框架里完成也不容易出错。最后提醒一句以后写代码处理有符号数时看到 0xFFFF 这种满屏 F 的数值先想想它是不是 -1补码这个设计虽然伟大但坑起人来也是一点都不含糊。
阅读完成 · 觉得有帮助?
咨询建站