ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

原码、反码、补码深度解析:从底层原理到工程实践中的溢出与边界

原码、反码、补码深度解析:从底层原理到工程实践中的溢出与边界 很多人觉得原码、补码是大学《计算机组成原理》里考完就忘的抽象概念我一直到工作三五年后才发现它们直接影响着我每天写的代码。前阵子帮一个做嵌入式开发的哥们排查问题他用uint8_t当循环计数器条件写的是while(count 0)结果程序跑进死循环调了一下午。原因就是八位无符号数减到0以后再减一次变成了255。这背后的机制往深了挖就是补码在起作用。这篇文章我想换个角度不讲教科书上那套枯燥定义而是从“为什么计算机最终选择了补码而不是听起来更直观的原码和反码”出发把原码、补码的来龙去脉、数学原理、边界特例彻底讲透。看完以后你会发现面试问烂了的-128、127 1 -128、INT_MIN取绝对值还是负数乃至二分查找里的left right溢出全都能串成一条线。1. 从工程视角看负数在计算机里凭什么能存1.1 一个看起来诡异的死循环先把开头那个bug的完整场景还原一下。我朋友写了一段类似这样的代码uint8_t count 10; while (count 0) { do_something(count); count--; }他本意是从10倒数到0然后结束循环。然而uint8_t是一个无符号八位整数取值范围是0到255。当count从1减到0时条件0 0成立循环继续执行接着执行count--此时0 - 1会变成多少在无符号数体系里结果不是-1而是255。为什么是255因为0的二进制是00000000减1需要向高位借位借完之后的结果在八位空间里就是11111111也就是255。这行代码在底层做的事情跟补码计算负数的方式完全一致——只是这个结果被解读成了无符号数255而不是有符号数-1。同样的二进制11111111如果你把它当成int8_t来解读它代表的是-1如果当成uint8_t来解读它是255。同一个状态两种含义差异巨大这恰好说明了“计算机里没有负数”这句话的真实含义。1.2 计算机里没有负号符号位是怎么来的数字电路的世界里只有高电平和低电平对应到抽象层就是1和0。硬件里不存在一个物理意义上的“负号”寄存器你也没办法让ALU算术逻辑单元理解“这个数前面有个减号”。所以要表示负数只能靠编码约定。最早期的自然想法是我不如拿最高位当符号位吧。最高位是0表示正数最高位是1表示负数剩下的位照常表示数值大小。这就是原码的雏形。拿八位二进制举例00000101是5那么10000101就是-5。看起来挺合理人脑很容易接受最高位那个1就相当于一个负号标记其他位和正数一模一样。但如果你真正动手去设计一套运算电路马上就会发现问题正数和负数做加法的时候符号位和数值位该怎么一起参与运算如果符号位不参与运算只是最后加个判断那相当于硬件里要同时存在两套计算逻辑——一套处理数值一套处理符号判断电路复杂度直线上升。1.3 设计目标让加减法变成同一件事上世纪四十年代计算机先驱们在设计早期机器时想明白了一个关键原则最好能让减法也变成加法来做。即x - y可以统一转换成x (-y)这样硬件里只需要一个加法器不需要单独设计一套减法器。减法器的电路不仅浪费晶体管还会拉慢时钟频率因为借位borrow的传播路径比进位carry更复杂。所以评价一种负数表示法好不好不该只看“人好不好理解”而要看它能不能满足下面三个硬性条件符号位能否直接参与运算不需要额外判断。同一个数字只有一种表示不能出现一个正零一个负零。加减法的结果在模运算下自动正确溢出行为可预测。带着这三把尺子我们回头去看原码和反码就能明白它们为什么不合格也就知道补码为什么会登场。2. 原码和反码为什么这两个“直观方案”都失败了2.1 原码的致命伤1 (-1) 等于 -2先亲手算一遍原码的加法。八位原码里1 00000001-1 10000001如果让ALU把这两个数按普通二进制直接相加会发生什么00000001 10000001 ----------- 10000010结果10000010用原码解读是-2。也就是说1 (-1) 在原码体系下算出的是-2这显然是错的。问题出在哪里出在半数的二进制位包括符号位被同时塞进了加法器但符号位并没有被设计成能参与算术运算的状态。想让原码做加减法就必须先把符号位单独拆出来判断两个数的符号异同再决定到底是做加法还是做减法最后还要单独处理结果的符号。这等于绕了一大圈最后还是没能绕开减法器。此外原码还有一个鸡肋零有两种表示。00000000是010000000是-0。在数学里0就是0哪有什么正负之分。两个不同的二进制状态对应同一个数学值不仅浪费了一个可用的编码空间还会让判断“两个数是否相等”变得啰嗦——你得先排除0和-0。2.2 反码一次看似进步、实则半途而废的妥协反码的想法很简单正数保持原码不变负数则把原码的每一位取反符号位也一起取反。还是拿8位举例5 00000101-5就是在00000101的基础上按位取反得11111010这个方案比原码好的一点是它让正数和负数之间有了对称关系。任何数的反码再取反一次就能变回原数这在某些运算上非常顺手。但是反码的本质缺陷依然存在。第一零仍然有两种表示00000000是011111111是-0。第二虽然负数的反码能让“加法器做减法”成为可能但运算过程中会产生一个叫“循环进位”的额外步骤。简单来说最高位产生的进位如果落到结果上还需要再加回到最低位指令周期里多一次额外加法对硬件设计来说非常别扭。反码真正的价值在于它是补码体系里不可或缺的一块跳板。我们后面会看到“取反”这个动作在补码里有精确的数学意义它不是为了反着好看而是为了凑出那个关键的“补数”。2.3 原码和反码的共同病根盯着人脑而不是盯着电路把原码和反码放在一起看它们犯的是同一个方向性错误设计者一开始想的是“人怎么读这个数”而不是“电路怎么算这个数”。原码把符号位当作一个不该参与运算的标签结果运算时要不断做分支判断。反码意识到了符号位可以参与取反但没有更进一步解决“0的重复表示”和“进位补偿”问题。两个方案都试图让表示方式去适应人的直觉可是硬件的核心诉求是所有位都应该一视同仁地进入加法器没有任何一位需要特殊对待。什么时候能满足这个诉求当加法器本身不需要知道符号位在哪里只需要按照统一的规则把每位相加、逐位进位的时候。这意味着我们要找一种编码方式让负数的二进制形态直接成为“某个正数在模意义下的补数”。这个答案就是补码。3. 补码的诞生取反加一背后的模数原理3.1 先从时钟和模运算说起在进入补码定义之前先花一分钟理解一个小学概念模运算。想象一个12小时的时钟。现在时针指向3点我问你**再往前走11个小时时针指向几点**答案是2点因为3 11 14而14对12取余是2。换个角度看如果我说“把时针从3点往回拨1个小时”结果也是2点。也就是说在12小时制的世界里3 - 1 2 3 11 2 14 mod 12 2减1和加11在模12的世界里是等价操作。这个例子太关键了。它说明只要定义好一个“模”的范围减法可以变成加一个很大的数。计算机的整数运算本质上就是模2^n运算——n位二进制能表示的状态总数就是2^n个八位就是256个状态十六位就是65536个状态。所以想用加法器做减法我们的任务就变成了找到一个负数在模2^n体系下的等价正数。这个“等价正数”就叫补数。用数学写出来就是负数x的补码表示 2^n x 其中x为负数结果对2^n取模举个例子要求-5在八位下的补码表示就是256 (-5) 251而251的二进制是11111011。现在明白了吧-5的补码不是“人眼一眼能看出的-5”而是“和-5在模256意义下完全等价的251”。3.2 补码的定义以及“取反加一”为什么成立如果你去查教材看到的补码定义通常是这样的正数的补码 原码本身负数的补码 符号位不变其余各位取反末位加1。前半句很好理解后半句就是无数人背过又忘掉的规则。我要强调一下这条规则不是约定出来的是推导出来的。对于任意一个二进制数x如果我们把它逐位取反包括符号位得到的结果其实是(2^n - 1) - x。为什么因为全1的n位二进制数就是2^n - 1比如八位的11111111等于255。任意一个数加上它自己的按位取反结果每一位都是1所以有x (~x) 2^n - 1移项得到~x (2^n - 1) - x再在这个取反结果上加1~x 1 (2^n - 1) - x 1 2^n - x看这就跟上一节推出的补数公式2^n x严丝合缝地对上了。**“取反加一”等于“补数”这不是技巧是同余数学推导的自然结果。**在C语言里“取反加一”可以一字不差地写成int neg ~x 1;这行代码拿任何数试都能得到它的相反数包括负数取正int original 5; int neg ~original 1; // -5 int back ~neg 1; // 53.3 “负数补码末位进1”是误解重点在“取反加一”很多技术帖里流传一句话“负数补码就是原码末位进1。”严格来说这种说法不准确。补码的定义是“按位取反后整体加1”而不是“末位加1”。一字之差容易让人误以为只有最右边那一比特需要变化。举两个例子-2的八位补码原码考虑的话先看2的二进制00000010取反得11111101加1得11111110。注意加1这个过程并没有影响末位“进位链”末位自己就变成0了真正加进去的“1”被吸收进了低位变化里。最终的-2补码是11111110如果只盯着“末位进1”去理解根本解释不通。-64的八位补码64的二进制01000000取反得10111111加1得11000000。这里末位确实从1变成了0并产生进位但进位一路上传最终变化的是第6位。所以“末位进1”只是加1过程的表面现象真正要记住的是取反之后整体加1。为什么会有这个误解因为很多人记“原码取反加一”时把“反码加1”直接简化成了“末位加1”但补码和反码的差距恰恰在于这个“加1”发生在整个数上而不是某一位上。实际写代码时不需要关心这些过程但理解到位后你在看汇编指令或者调试二进制数据时能少走很多弯路。3.4 补码让加法器一统天下补码最大的工程价值在于它把“符号”这个概念彻底融合进了运算过程。在补码体系下-1的八位表示是11111111。如果你把这个数和00000001也就是1相加会发生什么11111111 00000001 ----------- 100000000结果是一个九位二进制100000000。但我们只有八位寄存器最高位的进位会被丢弃保留低八位00000000也就是0。于是硬件上不需要任何特殊判断直接加扔掉溢出进位就得到了-1 1 0。再看-5 3-5的补码111110113的补码0000001111111011 00000011 ----------- 1111111011111110按补码解读就是-2结果完全正确。整个过程里符号位没有受到任何特殊对待它和数值位一起被塞进加法器一起进位一起溢出。这才是补码真正的厉害之处把复杂的人为判断变成了简单的规则运算。从这一刻开始硬件设计者可以理直气壮地在CPU里只做一个加法器。减法把减数取反加一然后直接加就完事了。4. 补码运算实测加减法、溢出与-128的边界之谜4.1 手算验证负数加正数、正数减正数都不再是问题用补码做手算是我个人觉得最扎实的复习方式。不要只背概念一定要自己拿笔写几组数。第一组5 - 8。按补码逻辑这个式子应当转化为5 (-8)5 00000101-8 11111000先看8的二进制00001000取反11110111加1得11111000相加00000101 11111000 ----------- 1111110111111101是什么它的符号位是1后面按位取反得00000010加1得00000011也就是3。所以这是-3。答案正确。第二组-7 - 6也就是(-7) (-6)-7 11111001-6 1111101011111001 11111010 ----------- 111110011丢弃最高位的进位1剩下11110011。这个数取反加一00001100 1 00001101也就是-13。正确。注意看第二组的细节两个负数相加最高位必然会产生进位但这并不意味着结果出错。丢弃进位这个动作在模256的语境下本来就理所当然。对硬件来说它压根不需要知道“丢弃”了多少它只是忠实地保留了寄存器宽度内的位。4.2 溢出是算错吗127加1为什么等于-128补码的边界问题最典型的就是127 1。八位补码里127是01111111。加1二进制运算01111111 00000001 ----------- 10000000结果10000000按补码解读是-128。从“数学正确性”来看127加1当然应该是128但在八位补码的取值范围内根本没有128这个数的位置。八位补码能表示的取值范围是-128到127一共256个状态。127加1突破了最大值落到最负端这就是“正溢出”。反过来-128减1会得到127这是“负溢出”。所以溢出严格来说不是“算错了”而是结果超出了当前位宽的表示范围被模运算卷回了有效区间。类比时钟11点加1小时是12点但12小时制的世界里没有12点它又变成了0点。补码的溢出就是整数时钟上的12点效应。4.3 -128的诡异之处它没有原码和反码八位补码里有个非常特殊的数-128它的补码是10000000。有意思的是在原码和反码体系里10000000都代表-0而不是-128。补码体系把“-0”这个冗余状态回收利用了从而多出一个额外的负数取值。这带来两个后果。第一-128没有对应的原码和反码。如果你对10000000按原码规则解读符号位是1后面是0数值是0即-0不是-128。如果你按“对负数补码求原码”的方式操作也就是取反加一会发现10000000 取反01111111 加110000000转了一圈还是10000000。这表示-128在八位补码里是自反的对它求绝对值结果还是它自己。第二这也解释了为什么INT_MIN取绝对值在一些语言里是个坑。用C语言做示例#include stdio.h #include limits.h int main() { int x INT_MIN; int y -x; printf(%d %d\n, x, y); // 输出 -2147483648 -2147483648 return 0; }-x并没有变成正数因为在32位int里2147483648这个值根本不存在它溢出回绕成了INT_MIN。写业务代码时遇到这种边界如果没意识到补码回绕排查难度会非常高。4.4 符号扩展与截断不同位宽之间切换时会发生什么补码还有一个在工程里天天遇到、但很多人没深想过的性质符号扩展sign extension。把一个八位的有符号数扩展到十六位时规则很简单正数在高位补0负数在高位补1。比如八位的11111011-5扩展到十六位得到1111111111111011。高八位全是1但数值没变依然是-5。为什么因为负数补码的符号位本来就是1扩展时把符号位复制到所有新增的高位才能保持同一个负数的模表示。如果你在代码里做了“有符号数和无符号数混合运算”编译器也常常会做这种隐式扩展。两个不同宽度的数比较或运算时窄的那个先被扩展成宽的再进行运算。扩展规则由符号性决定有符号数做符号扩展无符号数做零扩展。截断则是另一码事。把一个十六位数截断成八位直接砍掉高八位保留低八位。如果原来的数大小在八位范围内截断后不变如果超出了结果就会变成和原数模256同余的另一个数。这在做二进制协议解析、网络数据包处理时尤其要注意——比如从流里读回来的是0xFF你把它存成int8_t得到-1存成uint8_t得到255完全看代码怎么解释这8个比特。提示理解符号扩展和截断是后面看懂“有符号和无符号比较”这类坑的基础。它们的本质都是补码在“位宽变化”下的保持规则。5. 这些坑我踩过补码在日常编码中的真实影响5.1 无符号数和有符号数比较-1居然大于0这是我见过的线上bug里出现频率最高的一类。在C/C中如果拿一个int和一个unsigned int做比较编译器会把int隐式转换为unsigned int然后再比较。看这段代码int a -1; unsigned int b 0; if (a b) { printf(a小于b\n); } else { printf(a大于等于b\n); }如果你期望输出“a小于b”那就错了。-1在32位补码里是0xFFFFFFFF转换成unsigned int之后变成4294967295跟0比较当然不成立。所以程序会打印“a大于等于b”。类似的问题还出现在循环条件里for (int i 10; i 0; i--) { printf(%d\n, i); }这段代码没问题因为int是有符号数。可如果哪天你图方便把i改成unsigned int循环条件i 0永远不会为假因为无符号数永远不小于0。这就是开头那个嵌入式死循环的根源。调试的时候注意一眼变量类型能省掉大量无谓的排查。5.2 INT_MIN取绝对值最经典的边界脑筋急转弯前面已经在第4章提到过INT_MIN自反的特性。在实际开发中这个特性还会引出一个更隐蔽的问题——如果你在计算绝对值时直接调用abs()结果可能不是正数#include stdio.h #include stdlib.h #include limits.h int main() { int x INT_MIN; int y abs(x); printf(%d\n, y); return 0; }在32位int下输出结果是-2147483648。因为INT_MIN的绝对值2147483648超出了int能表示的最大值2147483647补码回绕让它又变回了自己。很多算法题里“对数组元素取绝对值然后排序”之类的操作遇到元素是INT_MIN时就会悄悄翻车。这个边界问题在面试里出现频率极高一旦出现通常考察的不是数学而是你有没有真正理解补码的表示范围。5.3 二分查找里的left right溢出老生常谈但永远有人踩二分查找是最常见的算法之一但经典版本里藏着一个补码相关的溢出bugint binary_search(int nums[], int size, int target) { int left 0, right size - 1; while (left right) { int mid (left right) / 2; // 当left和right都很大时leftright可能溢出 ... } }当left和right都是很大的正数比如分别接近INT_MAX时left right会超过int的表示范围结果变成负数。于是mid变成负数数组下标直接越界。为什么left right会变成负数因为在32位补码下两个正数相加结果超过了INT_MAX01111111111111111111111111111111进位翻到符号位结果就被解读成了负数。这是补码的“正溢出”现象在算法里的经典体现。标准修法所有人都知道int mid left (right - left) / 2;写成这样之后right - left不会超过两个数的差距加法不会溢出。但如果不是真的理解补码回绕面试时很容易只记住“这么写安全”却说不清why。5.4 二进制协议与序列化同一个字节两个世界做网络通信、嵌入式开发、文件解析时int8_t和uint8_t混用是另一个高频坑。假设一个二进制协议规定某字段是有符号8位整数取值范围-128到127。你用uint8_t读到0x80打印出来是128。但协议设计者本意是-128。如果后续计算没做转换整个逻辑链会偏掉。反过来代码里定义了一个int8_t x -1把它原封不动地写进一个二进制缓冲区。读取方如果用uint8_t去解释这8个比特拿到的就是255。从字节的角度看双方都没错大家都忠实地传输了11111111。真正出问题的是解释层——你是把这8个比特当成-1还是255。在实际项目中我通常建议**协议里如果定义的是有符号字段读取侧一定要显式转换成有符号类型再参与运算如果协议字段本身是无符号的千万别在业务层误当作负数处理。**所有跨语言、跨平台的二进制解析最终都要回到“同一串补码比特在不同类型下有不同的解读”这一点上。5.5 我的建议把“取反加一”内化成手部肌肉记忆说了这么多最后分享一点个人的实操习惯。我很推荐在写代码工具时比如写一个数字转换小脚本或者调试打印函数多接几个“奇怪的输入”去验证补码边界。最常用的几组测试值值八位二进制按int8解读按uint8解读0000000000012701111111127127-12810000000-128128-111111111-1255这四行表我几乎每次讲解补码都会列一遍因为它的信息密度极高同一个二进制串在不同符号性下的差异、-128的特殊位置、无符号255和有符号-1的重叠全在一张表里。数组越界、死循环、协议解析错位90%的整数坑都能在这张表里找到根源。学补码别死记“正数不变负数取反加一”那样换个符号性、换个位宽就蒙了。把“模2^n、补数、同余”这三个关键词理解透原码反码补码的来龙去脉自然就通了。而理解这些不是为了应付考试是为了在你真正调试到那一行诡异代码时能一眼看出问题出在符号性和位宽上。
返回列表