Skip to content

二进制补码原理

前言

在计算机的世界里,整数都是用二进制存储的。但计算机的硬件设计很「偷懒」,它只擅长做加法,不想专门为减法再搞一套电路。所以,要做减法 ab,就要把它变成加法 a+(b)。这样一来,关键问题就成了:怎么在二进制里表示这个 b

为了解决这个问题,计算机科学家们想出了几种方案——原码、反码、补码。最终,补码胜出,成了计算机存储和运算整数的标准方式。

补码(Two's complement)很巧妙地解决了原码和反码的一些遗留问题——比如 0 有两种写法(+0 和 -0),以及运算规则太麻烦。而补码的核心操作,就是那句口诀:取反加一

这个规则看似简单,但很多人在使用的时候会感到疑惑:为什么取反之后还需要加 1?少加这个 1 行不行?本文就是从原理上把这个「为什么」彻底讲清楚,并从多个角度验证它的正确性。

核心术语

在讨论细节之前,有必要把几个核心术语讲清楚。懂了这些,后面的推演就会顺畅很多。

基础概念

  • 字长:计算机表示整数的固定二进制位数,常见 8 位、16 位、32 位、64 位。

    字长决定了整数的表示范围。比如 8 位二进制,能表示 256(即 28)个数字。

  • 机器数:计算机实际存储的二进制整数,用于表示正数的具体大小。

    机器数的位数取决于具体计算机系统的字长,没有统一的「多少位」说法。现代计算机普遍采用 32 位或 64 位字长。

  • 真值:机器数对应的实际十进制数值,即二进制编码所代表的真实大小。

    例如 8 位机器数 00001000,其真值为 5。

有/无符号整数

有符号整数和无符号整数,是计算机中表示整数的两种基本方式。它们的区别在于是否能表示负数以及二进制位的解释规则。

有符号整数可以表示正数、零和负数。最高位(最左边一位)作为符号位,0 表示正数或 0,1 表示负数(采用补码形式存储)。其余位表示数值。有符号整数的范围为 2n12n11,例如 8 位二进制有符号位整数的范围就是 -128 到 127。

无符号整数只能表示正数和零。所有位都用于表示数值大小,无符号位。无符号整数的范围为 0 到 2n1,例如 8 位二进制无符号位整数的范围就是 0 到 255。

有无符号位整数‌决定了机器数如何解释这些二进制位。两者的核心关系在于:‌是否将最高位视为符号位‌,从而影响数值范围和运算方式。

换句话说,你可以把有无符号整数,看作是程序员视角对机器数的解读规则,同一串机器数,按无符号、有符号解读,值完全不一样。

原码、反码和补码

原码、反码和补码都是计算机表示有符号数的方法,它们都有符号位(即最高位,0 表示正,1 表示负)和数值位(下面的例子都以 8 位二进制有符号整数为例)。

  • 原码:最直观的表示法。正数就是它自己的二进制形式;负数的符号位为 1,数值位不变。

    例如,+5 → 00000101,-5 → 10000101。

    它的缺点是:0 有两种表示(+0 和 -0),这样就浪费了编码空间,而且加减法很麻烦,需要先比较符号再决定是加还是减。

  • 反码:正数的反码与原码相同;负数的反码是符号位不变,数值位按位取反。

    例如:-5 的原码是 10000101,其反码就是 11111010。

    反码比原码进步了,减法可以用加法来实现,但仍有缺陷——0 的表示不唯一,而且运算时有「循环进位」的问题,硬件实现起来很别扭。

  • 补码:计算机真正采用的编码。正数的补码与原码相同;负数的补码是反码加 1。

    例如:-5 的补码是 11111010 + 1 = 11111011。

    补码解决了 0 的双重表示问题。+0 的原码是 00000000,补码与原码相同也是 00000000;而 -0 的原码是 10000000,其反码就是 11111111,补码为反码加 1,就变成了 100000000,多出的最高位因为超出了 8 位,所以被丢弃,结果还是 00000000。

    补码的符号位可以和数值位一起参与运算,不需要额外处理,这是它最大的优势。

    提示

    现代计算机不用原码、反码做运算,全是基于补码。

    另外,所有主流编程语言,比如 C / C++ / Java / Go / Rust / Python / JavaScript / C# 中的整数都属于有符号整数,底层全是补码。

    比如 JavaScript 中做任何位运算时,就会将真值临时转为 32 位有符号整数,并按照补码规则来做运算,运算完成之后再转换成十进制真值返回。

什么叫「模」

「模」是理解补码的关键概念,它指的是一个计数系统的容量。

举个最简单的例子:钟表。钟表上有 12 个刻度,走到 12 点之后,下一格是 1 点,而不是 13 点。钟表的「模」就是 12。

计算机也一样,假设我们用 8 位二进制无符号整数来表示数字,它能表示的范围是 0 到 255(即 00000000 到 11111111),一共 256 个数。当计数到 256 时,就会发生溢出,回到 0。所以,8 位二进制系统的模就是 256

模的作用就是:在模的范围内,减法可以转化为加法

还是用钟表举例,比如现在是 10 点,你想把它调到 5 点。有两种方法:

  • 往回拨 5 小时:105=5
  • 往前拨 7 小时:10+7=17,因为钟表以 12 为模,1712=5,所以 17 点就是 5 点。

由此可见,在模 12 的系统中,减 5 等价于加 7。7 就是 -5 在模 12 下的「补数」,也叫「补码」。

计算机里也是同样的道理。n 位二进制数能表示的范围是 0 到 2n1,一共有 2n 个数。当计数达到 2n 时,就会溢出,回到 0。所以,n 位二进制系统的模就是 2n

在这个系统中,-x 的真实含义是:从 0 出发,逆时针走 x 步。但计算机只会顺时针走(只会加法),所以需要用「顺时针走 2nx 步」来代替「逆时针走 x 步」。这就是所谓的化减为加。

换成公式就是:[x]=2nx

「取反加一」的原理

假设我们有一个 8 位的正数 x,比如 3(即 00000011),把它按位取反,得到 ~x(即 11111100)。

此时很容易发现,x~x 的每一位加起来,都等于 1,所以 x + ~x = 11111111。而 11111111 就是 8 位二进制能表示的最大数,它的真值是 255(即 281=255)。因此可以得出 ~x = 255 - x,换成公式就是:x=2n1x

这个恒等式告诉我们:「取反」的本质,就是用 n 位二进制能表示的最大数减去原数

现在我们把按位取反和负数补码这个公式结合在一起来看:

  • 按位取反x=2n1x
  • 负数补码[x]=2nx

可以看出,两者之间差了 1。最终可以得到:

[x]=∼x+1

所以,「取反加一」就是计算补码(即计算 2nx)的一种简便方法。

换个更直观的说法:取反之后的结果,比真正的补码少了 1,所以必须加 1 「校准」。

「取反加一」规则是双向的

「取反加一」不仅能从正数求出负数的补码,也能从负数的补码还原回正数。

结合按位取反的公式,如果此时对 [-x]补 再做一次「取反加一」的操作,就会变成:

[x]+1=2n1[x]+1=2n[x]

再结合负数补码的公式,可以得出:

2n[x]=2n(2nx)=x

所以可以得到:[x]+1=x

由此可见,对补码再来一次「取反加一」,就回到了原数。

事实上,「先减一再取反」与「取反加一」是等价的,我们来验证一下:

([x]1)=2n1([x]1)=2n[x]=2n(2nx)=x

这意味着,硬件只需要一套电路,就能实现「正 → 负」和 「负 → 正」两种转换,省事又高效。

实例验证

从二进制层面看,x 的每一位与 ~x 的对应为相加,结果都是 1。

因此 x+x=111...1=2n1,移项得到 x=(2n1)x

这个验证从最底层的位运算出发,说明「取反」不是随意定义的操作,而是二进制逻辑的自然结果

现在,我们用 8 位二进制(模 256)的实例来验证这个结果。

  • 实例 1:5 - 3 = 2

    5 的二进制是 00000101;3 是 00000011,取反加 1 得 11111101。

    做加法:00000101 + 11111101 = 1 00000010

    丢弃溢出的最高位 1,剩下 00000010,转为十进制就是 2。

  • 实例 2:-5 + 8 = 3

    -5 的二进制是 11111011(补码);8 是 00001000。

    做加法:11111011 + 00001000 = 1 00000011

    丢弃溢出的最高位 1,剩下 00000011,转为十进制就是 3。

补码还原原数

补码还原为原数(也就是原码)的过程,取决于这个补码所表示的是正数还是负数。判断的依据就是它的符号位

  1. 判断符号位

    对于一个 n 位的二进制补码,最高位(即最左边的那一位)就是符号位:

    • 符号位为 0:表示这个数是正数
    • 符号位为 1:表示这个数是负数
  2. 正数的情况(符号位为 0)

    如果补码的符号位是 0,说明它是一个正数。正数的补码和原码是完全相同的,所以不需要做任何转换,直接把这个补码当做原码使用就行了。

    例如,补码 00000101 的最高位是 0,说明它是正数,它的原码也就是 00000101,对应的十进制是 5。

  3. 负数的情况(符号位为 1)

    如果补码的符号位是 1,说明它是一个负数。这时候就需要进行转换了。转换方法有两种:「取反加一」或 「先减一再取反」,结果是一样的。

    例如,补码 11111011 的最高位是 1,说明它是负数。先取反得到 00000100,再加 1 得到 00000101,对应的十进制是 -5。

    还是补码 11111011,先减 1 得到 11111010,再取反得到 00000101,对应的十进制还是 -5。

常见问题

  1. 为什么不能只取反,不加 1?

    只取反得到 (2n1)x,而补码需要的是 2nx,两者之间相差 1。

  2. 「取反加一」的规则在所有字长下都使用吗?

    是的。无论 8 位、16 位、32 位还是 64 位,规则背后的数学逻辑是一样的,只是模 2n 的具体值不同而已,规则本身不变。

  3. 补码为什么能解决 0 的双重编码问题?

    在原码和反码中,+0 和 -0 是两种不同的编码。但在补码中,-0 的补码是 2n0=2n,发生溢出后丢弃最高位的 1,剩下 0000...0,正好是 +0 的补码。所以补码中 0 只有一种表示。

  4. 补码表示的范围为什么不对称?

    以 8 位为例,补码能表示的范围是 -128 到 127,负数比正数多一个。这是因为 0 只占用了一个编码,节省出来的那个编码(10000000)被用来表示 -128 了。

总结

「取反加一」这个操作,本质上就是用数学公式 2nx 求负数补码的一种硬件友好的实现方式。

它之所以成为计算机存储整数的标准方案,原因有以下几点:

  1. 化减为加:减法转加法,CPU 只需一个加法器,硬件设计大大简化;
  2. 符号位统一处理:补码的符号位可以跟数值位一起参与运算,不需要额外判断;
  3. 0 只有一种表示:解决了原码和反码中「0 有二义」的问题;
  4. 双向规则:从正到负、从负到正,都是用「取反加一」,硬件实现非常统一;
  5. 溢出判断简单:通过符号位的变化就能判断是否溢出。

理解这些原理,不仅能让你把「取反加一」记得更牢,更能深入理解计算机底层是如何工作的。当你接触到计算机组成原理、汇编语言或嵌入式开发时,这种理解会帮上大忙。