
如果让我在计算机基础概念里挑一个最容易让人“卡壳”的知识点我大概率会选原码、反码和补码。不少科班出身的人都有过这种经历上课时规则背得滚瓜烂熟可一到做题、调程序、看内存数据负数在机器里到底长什么样还是容易懵。尤其是刷到“-105的补码用16位表示”这种经典问题时第一反应往往是掏出计算器然后发现自己不知道怎么验证答案对不对。今天这篇不拽公式、不摆高大上的架子我就按自己这些年写代码、调协议、读内存的实际经验把原码、反码、补码这件事从头到尾串一遍。它到底解决了什么问题、三种码之间怎么转、补码为什么最后“赢了”、手算时有哪些坑、编程里那些诡异的负数又是怎么回事一次讲清楚。不管你是自学、准备考试还是面试突击这二十多分钟读下来应该能省下不少自己摸索的时间。1. 三种码到底在解决什么问题有符号数的表示困境1.1 计算机只认01正负号也得“编码”先退一步想计算机内存里存的只有0和1没有任何东西自带“正号”“负号”。你定义int x -3;本质上是把一个二进制序列存进了内存比如在32位环境下通常是11111111111111111111111111111101靠编译器约定来把它解释成“-3”。那问题就来了数字部分转二进制容易正负号这个东西怎么塞进二进制里最朴素的想法是拿最高位当符号位最高位是0表示正数最高位是1表示负数。比如8位环境下00000011是310000011就是-3。这种表示方式就叫“原码”。原码最大的优点是直观人一眼就能看懂。缺点也极其致命计算机做加减法的时候不能直接拿这两个二进制数按位相加。你试试1 (-1)00000001 10000001 10000010等于-2显然不对。这说明符号位不能参与普通二进制加法必须额外判断符号、区分正负再决定是加还是减硬件上就要多设计一套复杂的处理逻辑。1.2 原码的直观与致命伤原码真正让人头疼的地方还不止运算复杂它连“0”都没安排好。以8位原码为例00000000表示010000000表示-0。表面上这没什么但仔细一想0的绝对值是0哪来正0和负0之分同一个数占用了两个不同的编码这种浪费看似不大却让后续所有运算都要多判断一步。我上学那会儿老师讲到这里常说一句话硬件工程师最怕的就是“情况分支”。如果一套数字表示方案在运算时要分好几种情况处理就意味着电路要多绕好几个弯延迟和成本都会上去。原码就是典型的“看起来人畜无害用起来处处掣肘”。为了改善这个问题反码出现了正数的反码就是原码本身负数的反码是原码除符号位外其余全部按位取反。比如8位原码10000011-3反码就是11111100。反码让部分负数运算变得规矩了一些但依然没解决0的问题00000000是0的反码11111111是-0的反码还是有正0负0。更要命的是反码做加法时如果最高位产生进位这个进位不能直接扔掉得绕一圈加回结果的最低位这就是传说中的“循环进位”。你可以想象一下连续做多次减法进位来回倒腾硬件性能很难看而且逻辑设计起来也烦。所以原码、反码更像进化过程中的过渡形态最后真正被硬件大规模采用、沿用至今的是补码。2. 原码、反码、补码的定义与转换规则2.1 先记住三句话定义很多人学这块总是记混其实只要抓住核心的三句话正数的原码、反码、补码三个完全相同。负数的反码 原码除符号位外逐位取反。负数的补码 反码末位加1。拿8位的-3来举例整个过程一眼就能看明白类型二进制表示说明原码10000011最高位1表示负数数值位是3反码11111100符号位保持1数值位取反补码11111101反码加1即11111100 1补码还有一种等价快捷求法从右往左数找到第一个“1”这个1本身保持不变它左边所有数值位全部取反符号位保持不变。以-3为例原码10000011最低位就是1保留它左边的所有位取反得到11111101结果和反码加1完全一致。这个快捷求法在草稿纸上手算时特别省时间强烈建议练熟。我实习带过好几个刚入门的朋友他们一开始都是老老实实先把反码写出来再加1其实熟练之后用“找第一个1”的方式口算都能出结果后面我会专门讲口算技巧。2.2 转换流程以-105的16位原码为例理论讲完上点实际数据。我们以“-105的补码用16位表示”为例一步步把它算出来。第一步写出105的16位二进制。105拆开就是64 32 8 1所以105 0b1101001补足16位之后0000000001101001注意这里前面的高位都是0这个习惯很多新手容易漏。原码转补码时位宽必须一开始就定好后面所有取反加1操作都要在16位框架内做不能先算出7位再临时拼凑。第二步因为105是正数它的原码就是0000000001101001。要表示-105就把最高位变成11000000001101001 // -105的原码16位我把符号位和数值位分得清楚一点方便你看。实际手写时也可以直接写。第三步求反码。保持最高位符号位不变其余15位全部取反1111111110010110 // -105的反码16位这里最容易犯的错是连符号位一起取反。符号位1变成0整个数就变成一个“看起来像正数”的东西后面全乱套。记住符号位只是“负号标记”它不参与数值位的取反操作。第四步补码 反码 11111111110010110 0000000000000001 ------------------- 1111111110010111所以-105的16位补码是1111111110010111。十六进制写法就是0xFF97。这个结果你可以反着验算把1111111110010111和0000000001101001相加得到1111111110010111 0000000001101001 1 000000000000000016位环境下最高位进位直接丢弃剩下0000000000000000正好是0。负数加它的绝对值等于0这是一条非常靠谱的验证法则。2.3 0的唯一性与补码的不对称范围补码解决了原码和反码都头疼的“正0负0”问题。以8位为例原码的-0是10000000反码的-0是11111111。补码中0只有00000000这一种表示因为-0的补码计算过程是10000000取反加1后进位溢出只剩00000000。正因为0的编码被唯一化原本“浪费”掉的那个负零编码10000000就被解放出来用来表示-128。于是8位补码的范围变成了-128 ~ 127比原码反码多了一个负数。这个不对称范围是很多新手记混的根源。16位补码的范围是-32768 ~ 3276732位补码的范围是-2147483648 ~ 2147483647。你去看Java的int、C语言的int范围全都是这个结构。以后面试被问到“为什么int的最小值比最大值绝对值大1”背后的根源就在这儿补码把0只表示一次省出来的编码留给了最左端那个负数。3. 为什么补码是最终赢家运算逻辑和模数原理3.1 减法变加法一套加法器通吃补码之所以能从原码反码里胜出核心原因是它让符号位也参与运算并且减法可以被改造成加法。用8位例子看5减3也就是5加(-3)。-3的8位补码是11111101直接做加法00000101 11111101 ----------- 00000010进位丢弃结果是2完全正确。硬件不需要设计独立的减法器把减数取补码再加过去就行。CPU里的ALU算术逻辑单元只需要一套加法电路加上取反和加1的控制逻辑就能同时完成加减法。反码之所以被淘汰问题就出在它的“循环进位”上。比如反码做(-1) (-1)11111110 11111110最高位产生了进位1 11111100如果直接丢弃进位得到11111100这在反码里是-3不是-2。必须把进位加回最低位得到11111101才是-2。这种“进位转圈”让电路设计和时序控制都非常难受性能和逻辑复杂度都输得很彻底。3.2 模运算视角负数其实是个“大正数”补码背后最本质的数学思想就是“模运算”。什么叫模如果在一个8位容器里存数它能表示的状态总共是2^8 256种那么256就是这个容器的模。在模256的世界里一个负数的补码可以理解为用“模减去绝对值”得到的那个正数。比如-3的补码256 - 3 253而253的二进制正好就是11111101也就是-3的补码。所以你可以把-3的补码理解成“一个很大的正数253”只是在有符号数的解读视角下它才被翻译成-3。生活里有特别好的类比钟表是12小时制模就是12。现在8点我想回到5点可以逆时针拨3小时也就是-3也可以顺时针拨9小时也就是9。在12这个模下-3和9是等价操作。补码干的事就是把所有“逆时针”的减法操作全部等价成“顺时针”的加法操作而且这个等价关系在二进制世界里不需要任何额外判断天然成立。所以补码这个名字里的“补”本质上是“补足到模数”的意思。负数-x的补码就是模x减去只不过在二进制里这个减法转成了“取反加1”这样的位操作执行起来特别便宜。3.3 溢出判断别让结果悄悄出错补码这么好用但也不是没有陷阱最大的坑就是溢出。一个典型的例子8位补码下01111111是127再加101111111 00000001 ----------- 10000000按8位无符号视角结果是128但按有符号补码视角10000000是-128。正数加正数得负数这显然是错的原因就是结果超出了8位补码能表示的最大值127。怎么判断溢出教科书里最常用的方法是看最高位的进位和符号位进位是否一致。简单说两个正数相加得到负数或者两个负数相加得到正数说明结果爆了。单个数的话可以看运算时最高位有没有进位以及符号位有没有进位。还有一个很实用的双符号位判断法计算时临时用两位符号位比如正数用00开头负数用11开头。运算结束后如果结果的“两位符号位”是01表示正溢出是10表示负溢出都没问题的是00或11。这个方法在纸上手算和电路里都很直观。溢出和进位是两个完全不同的概念这个一定要分清。进位是运算结果超出了当前位宽能承载的“无符号长度”比如8位加法结果超过255最高位产生进位溢出是有符号运算结果超出了补码能表示的范围。一个unsigned char做2551结果是0那是正确的回绕不算溢出有符号char做1271得到-128那才是真正的溢出错误。4. 实操演练手算-105的16位补码全流程4.1 分步计算细节拆解前面已经从正数变成负数推了一遍这一步我把手算时的每一个细节再拆开对照着写方便你照着练习。假设题目是“求-105的16位补码”确定位宽是16位。写出105的16位二进制0000000001101001验算从右往左第1、第4、第6、第7位分别是1加起来就是183264105。在最高位补充符号位1得到原码1000000001101001。符号位保持1其余15位数值取反得到反码1111111110010110。反码加1得到补码1111111110010111。如果题目要求十六进制从右往左每4位一组1111 1111 1001 0111对应0xFF97。整个过程如果写成一串式子就是105的16位二进制0000000001101001 取反含符号位外1111111110010110 再加11111111110010111这里再强调一次正数的补码就是它本身不需要任何转换。只有负数才需要走取反加1的流程。4.2 验证办法怎么确定你没算错手算完补码最慌的就是不知道对不对。这里分享我常用的三种验证方法任选一种都比干瞪眼强。方法一加回绝对值验证。把求出的补码和该数的绝对值加一下结果应为0。比如1111111110010111 0000000001101001 1 0000000000000000丢弃进位后得到0说明算对了。这个验证在纸上只需要做一次二进制加法很方便。方法二反过来看补码代表什么。已知1111111110010111是补码我想知道它是什么数可以先减1得到反码1111111110010110再除符号位取反得到原码1000000001101001最后读出数值位1101001即105加上负号就是-105。方法三用十六进制对应关系。16位补码0xFF97可以直接查到最高位是1说明是负数那么它对应的绝对值就是2^16 - 0xFF97也就是65536 - 65431 105。你看拆开来看每一步都没那么玄乎。4.3 位宽陷阱8位扩展成16位时为什么不能补0“-105的补码用16位表示”这个题还有一个隐藏考点很多人会先求8位补码10010111然后想扩展成16位结果直接往前面补0写成0000000010010111这在有符号数里是151显然不对。正确的扩展方式是“符号扩展”从8位扩展到16位要把原来的符号位复制填充到所有新增的高位。比如10010111的符号位是1扩展到16位就是1111111110010111。如果用32位来存就是11111111111111111111111110010111。这个坑在真实开发里相当常见。比如你从串口或者网络协议里读到一个8位有符号字段想把它赋给16位或32位的变量如果直接做无符号的位拼接负数会凭空变成正数排查半天还以为是硬件问题。看到负数在内存里表现为“一堆F”千万不要觉得奇怪那其实就是符号扩展的痕迹。反过来也有一个坑把一个16位的有符号数截断成8位如果数值超出8位范围结果也会出错。所以处理跨位宽的数据时心里要始终绷着一根弦扩位补符号位截位要考虑是否溢出。5. 常见问题与排查技巧实录5.1 新手最容易犯的五个错误每次带新人或者辅导朋友我基本都会把这几个错误提前打个预防针。做个表格放在这儿方便直接对照自查。错误类型错误表现正确做法符号位一起取反求-105反码时把符号位1变成0符号位保持1只对数值位取反忘记定死位宽8位和16位混着算结果对不上一上来先明确是8位、16位还是32位0的表示记不清以为补码的-0是10000000补码只有00000000一个0正数也按负数转换给105也取反加1得到错误结果记住正数原码反码补码完全一致符号扩展补08位负数扩成16位时高位补0把原符号位复制到新增高位即补1最后再补一句取反加1这个操作只对负数有意义。正数和0都不能取反加1。5.2 口算补码的几个实用技巧考试和面试现场往往不让你用计算器这时候口算技巧就很重要。我最常用的有三招。第一招“找第一个1”快速求补码。写出原码之后从右往左找到第一个1这个1和它右边所有位保持不变左边所有数值位取反符号位保持1。以-105为例原码1000000001101001从右往左第一个1就在最低位所以最低位保留其余所有数值位取反得出1111111110010111和反码加1的结果完全相同而且不用算加法。第二招记住8位补码的常用基准值。-1是11111111-2是11111110-127是10000001-128是10000000。看到这些值你就能直接联想出它在有符号和无符号视角下的含义。很多负数补码的题目本质上都是在这几个基准值基础上做加减。第三招做减法验证。负数补码加它的绝对值稳定得到2的n次方。这个性质前面已经说过它不仅是验证手段也能帮你从结果反推原值。看到一个十六进制补码0xFF97先算出0xFF97 0x0069 0x10000所以它对应的绝对值就是0x69 105数是-105。5.3 编程里的补码陷阱调试器和打印输出最后聊点实战层面的东西这部分最接近日常开发。在C语言里同样一个二进制序列你用有符号类型解读和无符号类型解读出来的数完全不同。看一段最朴素的代码#include stdio.h int main(void) { int x -105; unsigned int u (unsigned int)x; printf(%d\n, x); // -105 printf(%u\n, u); // 4294967191 return 0; }为什么u那么大因为-105的32位补码是11111111111111111111111110010111它按无符号32位解读就是2^32 - 105 4294967191。同一个内存片段变个类型就天差地别这是所有底层程序员迟早要撞上的事。调试器里看内存如果你用看字节的方式观察一个16位的-105会看到两个字节低位是0x97高位是0xFF。这与人类习惯写0xFF97相反因为x86这类小端架构在内存里是把低位字节放前面的。很多新手在内存窗口里看到97 FF还以为数据错了其实完全正常。这也是为什么要学补码你得能从一串十六进制里马上认出负数。还有一个隐藏更深的坑C和C里有符号整型的溢出是“未定义行为”。比如int a 2147483647; int b a 1;在多数机器上运行b会变成-2147483648看起来就像补码加法在正常工作但编译器有权假设有符号溢出不会发生从而做一些激进优化导致程序行为难以预测。所以在真正写业务代码时别依赖这种“看似自然的回绕”该用无符号就无符号该做溢出检查就做检查。补码帮我们在硬件层面实现了高效运算但语言标准层面的约束又是另一回事这两层得分开理解。最后再讲一个我常用的练习方法拿一组8位补码比如10000000、10000001、11111111、01111111自己先写出它们对应的真值再用调试器验证。熟练之后你看到调试器里的负数十六进制一眼就能换算成原值串口调试、抓包解析、二进制文件分析这类工作会顺手很多。这个基础概念就像地基看着简单但所有上层运算都踩在它上面。多花点时间把它揉碎了吃透后面学移位运算、大小端、浮点数表示、溢出攻击原理都会轻松不少。