news 2026/10/1 9:25:27

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
原码、反码、补码:从机器数到模运算,彻底搞懂有符号整数

很多年前我第一次接触计算机组成原理时,被“机器数、真值、原码、反码、补码”这串概念绕晕过很久。后来去面试别人,我几乎每次都会问一个问题: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 0010

1000 0010 按原码解释,是 -2。

你没看错,1 + (-1) 算成了 -2。如果换一种更贴近人脑的做法:先比较符号位,再决定是加法还是减法,然后按绝对值大小做减法,那倒是能算出 0,但问题是,这种“先判断符号、再分类讨论、再决定运算类型”的流程,对电路来说极其复杂。CPU 里加法器是一个非常纯粹的电路,它只认一件事:按位相加,产生和与进位。要让减法器单独存在,成本高、速度慢,而且在计算机历史上,工程师们非常希望用一种方式让“减去一个数”变成“加上另一个数”,从而把减法统一到加法里。

于是,问题就变成了:能不能找到一种负数的编码方式,让“加法器无脑相加”就能得到正确结果?原码显然做不到。那反码呢?

2. 原码与反码:两个有天然缺陷的方案

2.1 原码:直观是真直观,坑也是真坑

原码的定义很简单:符号位表示正负,剩余位表示数值的绝对值。

真值8位原码
+00000 0000
+90000 1001
+1270111 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 1111

1111 1111 是 -0 的反码,而 -0 和 +0 数值上都是 0。诶,这次结果对了。那反码是不是就够了?

不够。反码一个著名的问题是“循环进位”。来看 4 位反码的例子:计算 -1 + 2。

-1 的反码是 1110,2 的反码是 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 = 247,247 ≥ 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 到 127?

8 位二进制一共能产生 2^8 = 256 个机器数,从 0000 0000 到 1111 1111。这些机器数在补码体系下分别代表:

机器数补码解释真值
0000 0000+0
0000 0001+1
......
0111 1111+127
1000 0000-128
1000 0001-127
......
1111 1110-2
1111 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 是 0,bit1 是第一个 1,所以 bit1 和 bit0(也就是“10”这两位)保持不变,bit2 到 bit7 全部取反:

0000 1010 高位取反 低位不变 1111 0110

1111 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 1111(127)+ 0000 0001(1),结果为 1000 0000。最高位确实产生进位(从 bit7 进位到 bit8,进位值为 1),结果被解释为 -128,这确实是溢出。但反过来,1111 1111(-1)+ 0000 0001(1)= 0000 0000,最高位也产生了进位(bit7 进位到 bit8),结果却是正确的 0,没有溢出。

所以看“最高位是否进位”完全不可靠。正确的判断方法是:

最高位进位 与 次高位进位 不同,说明溢出。

次高位指的是符号位右侧那一位(bit6)。看 127 + 1:bit6 最高位的两个加数分别是 1 和 0,加上 bit6 进来的进位 0,bit6 向 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 默认可能是 signed,0xFF 按补码解释就是 -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 1111,signed 解释是 -1,unsigned 解释是 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 的进位是 1,bit31 向 bit32 的进位是 1?等等,仔细看:0111...1111 + 1,最低位产生进位一路传到最高位,bit31 得到进位后变成 1,同时它自己向 bit32 的进位也是 1,但 bit30 向 bit31 的进位也是 1,所以最高位进位和次高位进位相同?这里其实没有溢出?不对,有符号加法的溢出判断不是这个例子该纠结的:127+1 在 8 位下我们用过,bit6向bit7进位1,bit7向bit8进位1?等等,重新推一遍 0111 1111 + 0000 0001:bit7(符号位)加数 0+0,bit6 加数 1+0,bit6 到 bit7 的进位是 0(因为 bit0 的进位传到 bit6 是 0?实际 0111 1111 + 0000 0001,最低位 1+1 产生进位,依次传到 bit6,bit6 = 1+0+1 = 0 产生进位到 bit7,bit7 = 0+0+1 = 1,bit7 向外进位 0。所以最高位进位 0,次高位进位 1,不同,溢出)。对,最高位进位是 0,次高位进位是 1,不同,这就是溢出。所以 int.MaxValue + 1 的结果是 int.MinValue,符号位被进位翻转,正正得负,典型的补码溢出。这个面试题考察的就是对补码全 1 + 1 进位链的理解。

5.2 -1 >> 1 为什么还是 -1,而 -1 >>> 1 会变成 2147483647

-1 的 32 位补码全是 1:1111 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,也就是 2147483647,int.MaxValue。这道题满分回答,就是先写出 -1 的补码全 1,然后分别描述算术右移和逻辑右移对高位填充的规则。没有补码的全 1 认知,这道题就无从下手。

5.3 为什么哈希表容量喜欢用 2 的幂

很多人知道 HashMap 容量是 2 的幂是为了用(n - 1) & hash替代hash % n求余,但没意识到这和补码也有关系。当 n = 2^k 时,n - 1 的二进制是低 k 位全是 1、高位全是 0,hash & (n - 1)等价于取 hash 的低 k 位,数学上等价于 hash % n(对非负 hash 而言)。

但 hash 有可能是负数(比如 Java 的 hashCode 返回 int,完全可能为负),直接用hash % n在 Java 里会得到负数下标,所以必须先处理符号。而hash & (n - 1)天然规避了这个问题:按位与的结果,最高位一定是 0,也就是结果为非负。这本质上是用了补码的位模式来避开负数取模的符号陷阱。所以“容量取 2 的幂”表面上是位运算优化,底层还是对补码和二进制位模式的深刻理解。

如果只让我记一句话,我会记这句:补码的本质就是“模减去绝对值”,其余一切特性,包括取反加一、符号位参与运算、唯一的 0、负数多一个,都是这个公式的推论。我把这句话当成解释所有补码问题的总开关:遇到计算题先想 256 减几,遇到原理题先想钟表拨针,基本不会跑偏。这也是我给所有带过的实习生反复强调的:别背公式,抓本质,这个知识点一旦从数学上通了,十年后你都不会忘。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 9:22:57

STM32嵌入式C++实战:从寄存器点亮LED到模板封装

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 9:21:26

改进YOLOv8的电力设备缺陷分割实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 9:21:10

Unity GPU Instancing:让万级同屏物体性能提升的批处理方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 9:20:23

Win10下STM32开发板CP2102驱动安装与串口排查指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 9:19:59

BL460工业控制器:树莓派生态的工业级演进

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华