尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

原码、反码、补码:从机器数到模运算,彻底搞懂有符号整数

原码、反码、补码:从机器数到模运算,彻底搞懂有符号整数 很多年前我第一次接触计算机组成原理时被“机器数、真值、原码、反码、补码”这串概念绕晕过很久。后来去面试别人我几乎每次都会问一个问题Java/C# 的 int 范围为什么是 -2147483648 到 2147483647负数那边为什么比正数多一个这个问题能讲清楚的候选人真的不多。大多数人能背出“补码取反加一”但你要问他为什么非要用补码用原码行不行他就开始含糊了。今天这篇就把这一串概念从头到尾掰开揉碎讲一遍讲完你会发现原码、反码、补码根本不是三个需要死记的公式而是一段“为什么”层层递进的故事。1. 一个让新手懵圈的运算结果把符号位直接带进加法会发生什么1.1 机器数与真值计算机里根本没有负号先厘清两个最基础的概念真值和机器数。所谓真值就是我们平时写在纸上的带符号二进制数比如 -9、5、-127它有数学上的正负号。而机器数是这些数值在计算机里实际的二进制存储形态。计算机硬件只有高低电平也就是 0 和 1它不认识“-”这个符号怎么办只能用某个二进制位来代表符号。惯例是把最高位当作符号位0 表示正1 表示负。这个符号位本身也是 0/1也能参与逻辑运算但关键是它代表的是正负性而不是数值大小。例如真值 9在 8 位字长下机器数就是 0000 1001真值 -9机器数则是 1000 1001。注意这里的 1000 1001 中最高位的“1”并不代表数值“128”而是代表“这是负数”的标记。这里有个容易踩的认知坑很多初学者以为机器数就是某个固定规则下的二进制但其实“机器数”是一个总称。原码、反码、补码都是机器数的具体编码方案同一个真值 -9在原码、反码、补码三种方案下对应的机器数完全不一样。后面马上会看到不同方案对同一个 0/1 串的“解释”也不同。1.2 用原码直接算“1 - 1”你猜会得到什么既然最直观的方案是“符号位 绝对值”那这个方案到底行不行我们来做一个最简单的实验用 8 位原码计算 1 (-1)。1 的原码是 0000 0001-1 的原码是 1000 0001。如果硬件只是机械地把这两个 8 位二进制数逐位相加结果是0000 0001 1000 0001 ----------- 1000 00101000 0010 按原码解释是 -2。你没看错1 (-1) 算成了 -2。如果换一种更贴近人脑的做法先比较符号位再决定是加法还是减法然后按绝对值大小做减法那倒是能算出 0但问题是这种“先判断符号、再分类讨论、再决定运算类型”的流程对电路来说极其复杂。CPU 里加法器是一个非常纯粹的电路它只认一件事按位相加产生和与进位。要让减法器单独存在成本高、速度慢而且在计算机历史上工程师们非常希望用一种方式让“减去一个数”变成“加上另一个数”从而把减法统一到加法里。于是问题就变成了能不能找到一种负数的编码方式让“加法器无脑相加”就能得到正确结果原码显然做不到。那反码呢2. 原码与反码两个有天然缺陷的方案2.1 原码直观是真直观坑也是真坑原码的定义很简单符号位表示正负剩余位表示数值的绝对值。真值8位原码00000 000090000 10011270111 1111-01000 0000-91000 1001-1271111 1111原码最大的优点是好理解人眼一看就能对应到真值。但它的缺陷非常致命一是 0 的表示不唯一。0 是 0000 0000-0 是 1000 0000。你可能会觉得这没什么但在计算机里“两个不同的机器数代表同一个数值”意味着比较两个数是否相等时必须先做一次特殊判断。一个 0 都要占两个编码浪费了整整一个码点。二是原码做加减法真的很痛苦。上面 1 (-1) 算出 -2 的例子已经说明问题如果硬件不搞符号判断直接相加就是错的。哪怕硬要处理也需要在运算前判断符号、比较绝对值大小、决定谁减谁、再给结果定符号。每一步都是额外电路、额外延迟。所以现代计算机的有符号整数基本不会用原码存储原码更多出现在浮点数的存储中IEEE 754 浮点数的尾数部分就使用了类似符号位 绝对值的方式以及作为理解补码的跳板。2.2 反码折腾一圈还是没绕开“两个 0”反码的规则是正数的反码等于原码负数的反码是保持符号位不变其余位全部取反。-9 的原码是 1000 1001反码就是 1111 0110。注意这里符号位仍然是 1只是把后面 7 位数值位从 000 1001 取反成了 111 0110。反码的意义在于它确实让某些加法变得合理了。试试用反码计算 1 (-1)0000 0001 1 的反码 1111 1110 -1 的反码 ----------- 1111 11111111 1111 是 -0 的反码而 -0 和 0 数值上都是 0。诶这次结果对了。那反码是不是就够了不够。反码一个著名的问题是“循环进位”。来看 4 位反码的例子计算 -1 2。-1 的反码是 11102 的反码是 0010相加1110 0010 ------ 10000结果变成了 5 位最高位的进位 1 怎么办如果直接丢弃结果是 0000也就是 0但 -1 2 明显等于 1。反码的解决办法是把这个进位“绕回去”加到最低位上0000 1 ------ 0001这样才得到 1。这种“将最高位进位循环回最低位再相加一次”的机制叫 end-around carry。电路上听起来简单实际意味着加法器需要多一级判断有没有进位有进位就再执行一次低位的加法。这不但让关键路径变长还让所有运算都慢一拍。更麻烦的是反码的 0 依然有两种表示0000 0000 和 1111 1111。也就是说反码在 0 的问题上没有任何改善。一个又慢、又存在双零歧义的编码显然不是终点。但它提供了一个非常关键的过渡思路取反运算在电路上是极其便宜的几乎不耗时间而“在取反的基础上再想办法把循环进位问题解决掉”就自然引出了补码。3. 补码借一个钟表和模数把减法彻底变成加法3.1 从“模”说起钟表上减 3 小时就是加 9 小时补码背后的数学原理说穿了根本不神秘就是一个词模。我们天天都在用模运算。一个 12 小时的钟表现在是 6 点我想把它拨到 3 点可以逆时针拨 3 小时也可以顺时针拨 9 小时因为在 12 小时这个“模”下加 9 和减 3 的效果完全一样。用数学语言说就是-3 ≡ 9 (mod 12)。把这个思想搬到二进制8 位二进制能表示的范围是 0 到 255一共 256 个数那么模就是 2^8 256。在这个模下想表示 -5就可以用 “256 - 5 251” 来代表它。251 的二进制是 1111 1011。你再看一眼 -5 的补码恰好就是 1111 1011。这不是巧合补码的定义就是负数的补码 模 该负数对应的真值比如 8 位字长下-9 的补码 256 (-9) 247 1111 0111。这就是为什么负数的补码最高位一定是 1因为 256 - 9 247247 ≥ 128二进制下最高位必然为 1。一下子所有公式都通了为什么补码能在加法器里“无脑相加”因为 9 (-5) 在模 256 下等价于 9 251 260 4丢弃超过 8 位的进位“256”剩余就是 4。计算机丢弃最高位进位的操作本质上就是自动完成了对 256 取模。3.2 为什么“取反加一”就是补码推导给你看大多数教材直接给结论负数补码 原码除符号位外取反再加 1。这个结论常让人背得云里雾里其实从模的角度可以一步推出来。假设一个 n 位正数 x它对应的二进制原码数值部分记为 |x|。对 |x| 做按位取反等价于计算 (2^n - 1) - |x|也就是 255 - |x|8 位下。再加 1就得到(255 - |x|) 1 256 - |x|而 256 - |x| 正好是 x 在模 256 下的相反数的补码。所以在“正数原码 正数补码”的前提下对负数的数值部分“取反加一”得到的是 256 减去绝对值的差值也就是它的补码。一句话总结取反加一不是魔法它只是在快速计算“模减去绝对值”而已。这个方法为什么快因为硬件的“取反”几乎不需要额外成本加一也可以顺手在加法器最低位进位里完成。顺带说一个很多初学者会纠结的问题负数补码“取反加一”时符号位到底参不参与如果按定义来符号位本身就是数据的一部分参与取反和计数没有矛盾。看 -9原码 1000 1001数值位 000 1001 取反得到 111 0110再加 1 得 111 0111。如果你把整个 8 位 1000 1001 按位取反得到 0111 0110再加 1 得 0111 0111符号位被冲掉了这是错的。所以常规做法是“符号位不变数值位取反加一”本质是只对绝对值做模运算符号位留给编码约定。3.3 验算 9 - 5补码让加法器一路绿灯我们完整走一遍 9 - 5 的补码计算。9 的补码0000 1001。-5 的补码5 的二进制 0000 0101取反 1111 1010加 1 得 1111 1011。0000 1001 1111 1011 ----------- 10000 0100结果产生了 9 位最高位的进位 1 直接丢弃这就是模 256 下的取模剩下 0000 0100也就是 4。再看 1 (-1)0000 0001 1111 1111 ----------- 10000 0000丢弃进位结果是 0000 0000完美归零。这里有一个很妙的点-1 的补码是 1111 1111和 8 位无符号数 255 的二进制完全一样。所以同一个机器数 1111 1111你把它当无符号整数是 255当有符号补码是 -1。这就是“机器数相同、真值取决于解释方式”的最佳例证。补码能把减法和加法完全统一也就不再需要单独的减法器了。CPU 里只需要一套加法器遇到 a - b 时先给 b 取反加一变成 -b 的补码然后继续走加法流程。这个“先取反加一”的操作在现代 CPU 里甚至是被完全流水线化地隐藏起来的速度接近纯加法。3.4 为什么是 -128 而不是 -127补码多出的那个负数现在来解决开头那个面试问题8 位有符号数的范围为什么是 -128 到 127而不是 -127 到 1278 位二进制一共能产生 2^8 256 个机器数从 0000 0000 到 1111 1111。这些机器数在补码体系下分别代表机器数补码解释真值0000 000000000 00011......0111 11111271000 0000-1281000 0001-127......1111 1110-21111 1111-1注意看-127 到 -1 加上 0 到 127一共是 255 个不同的数值还剩一个 1000 0000 没有被“对号入座”。它被定义为 -128。为什么这样定义因为 -128 256 - 128 128 1000 0000也就是 -128 的补码就是 1000 0000。而原码和反码里的“-0”1000 0000在补码体系中不再需要了因为补码的 0 只有一种表示0000 0000。于是这个曾经被浪费的码点被让给了最小的负数 -128。这就是负数那边比正数多一个数的根本原因原码和反码用两个码点表示同一个 0白白浪费一个编码补码把 0 唯一化省出的位置就给了最左侧那个多出来的负数。3.5 一个超好用的手算技巧从最低位找第一个 1最后分享一个很多教材不讲但调试和面试中非常实用的技巧。求一个正数的相反数的补码不需要“先取反再加一”可以按以下规则从二进制数的最低位最右边开始从低往高找到第一个“1”这个 1 以及它右边更低位保持不变它左边的所有位全部取反。以 10 为例10 的 8 位二进制是 0000 1010。从右往左看bit0 是 0bit1 是第一个 1所以 bit1 和 bit0也就是“10”这两位保持不变bit2 到 bit7 全部取反0000 1010 高位取反 低位不变 1111 01101111 0110 就是 -10 的补码。反过来由负数的补码求绝对值也可以用完全相同的规则从右往左找到第一个 1保留它和它右边的内容左边全部取反。比如 1111 0110找到 bit1 的 1保留“10”高位取反得到 0000 1010绝对值就是 10。这个技巧在脑内就能算省去了按公式逐位来的时间。4. 实战里最常见的三个翻车点4.1 “补码求原码”到底怎么求别再搞混了不少人面试前背了一句口诀“补码的补码等于原码”但真做题就翻车。我解释一下这句话的适用边界。已知一个负数的补码 1111 0111让你求它的原码。正确做法是最高位符号位 1 不动把后面 7 位取反加一。即 111 0111 取反得到 000 1000再加 1 得 000 1001补上符号位原码就是 1000 1001所以它对应的真值是 -9。那“补码的补码等于原码”是什么意思其实它说的是对整个补码表示含符号位做取反加一得到的是这个数的相反数的补码。比如 1111 0111 整体取反加一0000 1000 1 0000 1001这是 9 的补码也就是 9 的绝对值。所以你要么记住“负数补码求原码符号位不动数值位取反加一再补符号位”要么记住“整体取反加一得到的是绝对值想表达为原码还需要把符号位置 1”。两个口径都可以但别混着用。我在帮新人 review 代码时看到过太多次把“绝对值”直接当成“原码”写回去的失误。4.2 溢出和那个“最高位进位”的误会很多人以为 8 位补码加法溢出就是看最高位有没有进位这是个经典误区。举例0111 1111127 0000 00011结果为 1000 0000。最高位确实产生进位从 bit7 进位到 bit8进位值为 1结果被解释为 -128这确实是溢出。但反过来1111 1111-1 0000 00011 0000 0000最高位也产生了进位bit7 进位到 bit8结果却是正确的 0没有溢出。所以看“最高位是否进位”完全不可靠。正确的判断方法是最高位进位 与 次高位进位 不同说明溢出。次高位指的是符号位右侧那一位bit6。看 127 1bit6 最高位的两个加数分别是 1 和 0加上 bit6 进来的进位 0bit6 向 bit7 的进位是 0而 bit7 向 bit8 的进位是 1。次高位进位 0 ≠ 最高位进位 1溢出。实际写代码时你不必手动判断进位的有一种更朴素的观察法看操作数和结果的符号。补码运算里正数加正数得到负数或者负数加负数得到正数一定溢出正数加负数则无论如何都不会溢出。比如 100 的补码加上 100 的补码变成 -56一眼就知道错了。这个原则在调试中比数进位快得多。4.3 类型转换里的 -1 和 255同一个机器数的两种人生有符号 char 的 -1机器数是 1111 1111。无符号 char 的 255机器数也是 1111 1111。两者位模式一模一样只是解释方式不同。这就是“机器数相同、真值不同”在实战中最常见的体现。我踩过一个很典型的坑解析某二进制文件时按字节读取数据读到一个 0xFF。我想把它当 255 用直接赋给 int结果变成 -1。原因是 C/C 里 char 默认可能是 signed0xFF 按补码解释就是 -1赋值给 int 时会做符号扩展变成 0xFFFFFFFF仍然是个负数。这个 -1 参与后面的累加运算整个校验和就全错了。解决办法很简单要么把读入类型声明为 unsigned char要么在参与运算前先与 0xFF 做一次按位与0xFF byteValue把高位的符号扩展位清掉。unsigned char ch 0xFF; int value ch; // 255正确 signed char sc 0xFF; // sc -1 int bad sc; // -1踩坑 int good sc 0xFF; // 255正确解法这种问题调试起来很隐蔽明明读出来的字节打印 hex 是对的但十进制的数值就是不对。根源从来不是数据错了而是你拿错了“解码表”同一个 1111 1111signed 解释是 -1unsigned 解释是 255。5. 用补码思维重看几道高频笔试题5.1 int.MaxValue 1 为什么变成了负数Java/C# 里 int.MaxValue 是 0111 1111 1111 1111 1111 1111 1111 1111加 1 之后变成 1000 0000 0000 0000 0000 0000 0000 0000也就是 int.MinValue等于 -2147483648。这一步不是 Bug而是补码溢出的必然结果。最高位的进位被丢弃而 bit30 向 bit31 的进位是 1bit31 向 bit32 的进位是 1等等仔细看0111...1111 1最低位产生进位一路传到最高位bit31 得到进位后变成 1同时它自己向 bit32 的进位也是 1但 bit30 向 bit31 的进位也是 1所以最高位进位和次高位进位相同这里其实没有溢出不对有符号加法的溢出判断不是这个例子该纠结的1271 在 8 位下我们用过bit6向bit7进位1bit7向bit8进位1等等重新推一遍 0111 1111 0000 0001bit7(符号位)加数 00bit6 加数 10bit6 到 bit7 的进位是 0因为 bit0 的进位传到 bit6 是 0实际 0111 1111 0000 0001最低位 11 产生进位依次传到 bit6bit6 101 0 产生进位到 bit7bit7 001 1bit7 向外进位 0。所以最高位进位 0次高位进位 1不同溢出。对最高位进位是 0次高位进位是 1不同这就是溢出。所以 int.MaxValue 1 的结果是 int.MinValue符号位被进位翻转正正得负典型的补码溢出。这个面试题考察的就是对补码全 1 1 进位链的理解。5.2 -1 1 为什么还是 -1而 -1 1 会变成 2147483647-1 的 32 位补码全是 11111 1111 1111 1111 1111 1111 1111 1111。有符号右移 是算术右移高位补的是符号位。所以 -1 右移一位后高位移入的仍然是 1结果是 1111 1111 1111 1111 1111 1111 1111 1111还是 -1。右移多少位它都不会变这是很多面试者容易懵的点。而无符号右移 是逻辑右移高位补 0。于是 -1 变成了 0111 1111 1111 1111 1111 1111 1111 1111也就是 2147483647int.MaxValue。这道题满分回答就是先写出 -1 的补码全 1然后分别描述算术右移和逻辑右移对高位填充的规则。没有补码的全 1 认知这道题就无从下手。5.3 为什么哈希表容量喜欢用 2 的幂很多人知道 HashMap 容量是 2 的幂是为了用(n - 1) hash替代hash % n求余但没意识到这和补码也有关系。当 n 2^k 时n - 1 的二进制是低 k 位全是 1、高位全是 0hash (n - 1)等价于取 hash 的低 k 位数学上等价于 hash % n对非负 hash 而言。但 hash 有可能是负数比如 Java 的 hashCode 返回 int完全可能为负直接用hash % n在 Java 里会得到负数下标所以必须先处理符号。而hash (n - 1)天然规避了这个问题按位与的结果最高位一定是 0也就是结果为非负。这本质上是用了补码的位模式来避开负数取模的符号陷阱。所以“容量取 2 的幂”表面上是位运算优化底层还是对补码和二进制位模式的深刻理解。如果只让我记一句话我会记这句补码的本质就是“模减去绝对值”其余一切特性包括取反加一、符号位参与运算、唯一的 0、负数多一个都是这个公式的推论。我把这句话当成解释所有补码问题的总开关遇到计算题先想 256 减几遇到原理题先想钟表拨针基本不会跑偏。这也是我给所有带过的实习生反复强调的别背公式抓本质这个知识点一旦从数学上通了十年后你都不会忘。
返回列表