原码、反码、补码

本篇是总篇 00-信息的表示与处理 第三章的展开:从"二进制里负数怎么表示"这个问题出发,看原码、反码、补码三代方案如何一代一代填上一代的坑,最后补码一统天下。

在 C 和 C++ 中,整数有两种方式编码:一种只能表示非负数,另一种能表示负数、0 和正数。而 Java 只支持有符号数。

C 语言中有着很多表示有限范围的整数的数据类型:char、short、long……它们默认表示可能为负数,还可以声明为 unsigned 表示非负数。为这些不同大小分配的字节数,根据程序编译为 32 位还是 64 位而有所不同——long 是与机器相关的大小指示符,64 位机器用 8 字节表示,32 位机器用 4 字节表示。

image-fed93318

32 位程序上 long 表示为 -2147483648 ~ 2147483647,unsigned long 表示为 0 ~ 4294967295。

可以发现取值范围其实是不对称的——负数的范围比正数范围大 1。这个"不对称"的伏笔,读到补码一节就解开了。

一、无符号数的编码

假设一个整数的数据类型有 w 位,我们可以将位向量写成 [x(w-1), x(w-2), ……, x0]。

把向量 x 看做一个二进制的数,每个 xi 都取 0 或 1,就获得了它的无符号表示。可以使用函数表示为:

image-def50d2e

一个特别好用的理解方式——把每一个位当做一个向量

  • 该位为 1 时,就加上该向量的长度
  • 为 0 则不加
  • 最终的总长度即为无符号表示的值

(就是我们二进制转十进制的步骤,参见 01-进制与进制转换

image-faf9509b image-266a700f

所能表示的范围为:

image-0a0d395f

二、有符号数的编码

有符号就引出了"负号"的概念。

平时的十进制计算中,看到 2 + (-18) 会把它变成 2 - 18;2 - (-18) = 2 + 18;2 × (-18) = -(2×18)——把负号换掉或者拿掉,在结果上再加上负号。

那么二进制怎么做?历史提出了三种解决方式——原码、反码、补码

2.1 原码

image-812168e8

最高位是符号位,0 代表正数,1 代表负数,非符号位为该数字绝对值的二进制。

image-eee777d6

通俗的理解:仿照十进制,用一个特殊标志表示负数,该标志在这个数以外——该位为 1 则整个数为负,反之为正。

通俗的理解,原码就是一个整数本来的二进制形式,原码最左边的一个数字就是符号位,0 为正,1 为负。

例如:18(D) = 0000 0000 0001 0010,-18 = 1000 0000 0001 0010。左边第一位为符号位,其他位为数据位。

一个 byte 有 8 bit,最大值是 0111 1111(+127),最小值是 1111 1111(-127)。

这样一来问题就来了:符号位不参与数本身,就不能直接进行二进制运算,还需要一个新硬件去判断结果到底是正还是负,对于乘除来说则更加麻烦。

正数计算——使用原码对正数进行计算不会有任何问题。例如 5 + 2:

  0 0 0 0 0 1 0 1
+ 0 0 0 0 0 0 1 0
-----------------
  0 0 0 0 0 1 1 1

把这个结果转成十进制刚好就等于 7,完全正确无误。

负数计算——但如果是负数的话,那计算的结果就会大相径庭了。计算 -56 - 1:

  1 0 1 1 1 0 0 0     ← -56 的原码
- 0 0 0 0 0 0 0 1
-----------------
  1 0 1 1 0 1 1 1

-56 的原码是 1011 1000,减 1 之后变成 1011 0111,这个数转成十进制就是 -55

计算前是 -56,减一之后正确的结果应该是 -57(原码 1011 1001)才对,居然还越减越大了。

📌 原码的坑:① 负数不能用这套位模式直接算;② 0 有两种表示(+0 = 0000 0000,-0 = 1000 0000)。

为了解决原码不能用于计算负数的这种问题,这时候,反码出现了,作为负数的"计算的救星"。

2.2 反码

image-077d01f4

正数的反码不变和原码一致,负数的反码会在原码的基础上,高位符号位不变,其他位取反(1 变成 0,0 变为 1)。

反码的存在是为了正确计算负数,因为原码不能用于计算负数。

image-00b272cb image-33fbf4e0

再来使用反码计算一下 -56 - 1:

-56 的原码是 1011 1000,转成反码(符号位不变,其他位取反)就是 1100 0111:

  1 1 0 0 0 1 1 1
- 0 0 0 0 0 0 0 1
-----------------
  1 1 0 0 0 1 1 0

-56 - 1 = -57,-57 的原码是 1011 1001,转成反码刚好是 1100 0110,刚好等于刚才算出的值 ✓

不过反码也有它的"软肋":如果是负数跨零进行计算的话,计算得出的结果不对。

拿 -3 + 5 来举例:

-3 的原码是 1000 0011,转成反码就是 1111 1100:

  1 1 1 1 1 1 0 0
+ 0 0 0 0 0 1 0 1
-----------------
  0 0 0 0 0 0 0 1

把计算结果转成十进制是 1。但 -3 + 5 显然等于 2——差了 1

这正是跨零的代价:只要运算跨过了零,反码的结果就会偏 1(根源还是反码里有两个零,0 和 -0 各占了一个编码,挤掉了真实值的位置)。

📌 反码的坑:跨零计算差 1,仍然有两个零。

那么我们该怎么计算呢?这时候,作为反码的补充编码——补码就出现了。

2.3 补码

image-1fca7bca image-99c0dbd2 image-3459e3b0

正数的补码是其本身;负数的补码等于其反码 + 1,符号位不变。

因为反码不能解决负数跨零(类似于 -3 + 5)的问题,所以补码出现了。它的思路很妙:利用越界会丢失的特性

考虑 -1,希望 -1 + 1 = 0,该怎么做到?

0000 0001  1
0000 0000  0

如果可以让 1111 1111 表示 -1 的话,就能实现这个操作:

  1111 1111
+ 0000 0001
-----------
1 0000 0000    最高位溢出丢弃,剩下 0000 0000,正是 0 
image-dddc65a5 image-6fa10603 image-e5716360

用首位判断符号、并且首位的 1 也是这个数的一部分——补码的权重理解:

把每一个位当做一个向量:首位为 1 时方向向左(负权重),为 0 则向右,依次加上每位长度,最终的总长度即为有符号补码表示的值。

image-aaf36f75

再来使用补码计算一下 -3 + 5 的结果:

-3 的原码是 1000 0011,转成反码是 1111 1100,再转成补码就是 1111 1101:

  1 1 1 1 1 1 0 1
+ 0 0 0 0 0 1 0 1
-----------------
  0 0 0 0 0 0 1 0

把这个数转成十进制刚好等于 2,结果正确 ✓

同一个 1111 1111,被当成纯二进制来看待是 255,被当成补码来看时是 -1——解释方式不同而已,这句话是整篇的钥匙,后面转换一节还会回到它。

对于 -a,它的补码就是 0 - a,实际上是 2ⁿ - a(n 为位数):

image-493e5311

如果 1 没被舍去,它就是 256(2⁸),2⁸ 减 1 就得到了 -1 的补码。

补码的意义就是:拿补码和原码可以加出一个溢出的零。

1111 1111 补码是 -1,按无符号看是 255
-1 + 1  255 + 1 = 256  1 0000 0000 溢出,得到全 0

有了补码最大的好处就是:做计算的时候不需要做符号判断,可以直接做简单的二进制加法——硬件终于不用再准备一套"符号判断电路"了。

三、三者关系与取值范围

3.1 三者转换关系

数型 原码 反码 补码
正数 本身 本身(不变) 本身(不变)
负数 符号位 1 + 绝对值 符号位不变,其余取反 反码 + 1

举例验证:

  • 1 的原码是 [0000 0001],它的反码是其本身 [0000 0001],补码也是其本身
  • -1 的原码是 [1000 0001],其反码是 [1111 1110],补码是 [1111 1111]

3.2 范围为什么不对称

现在的计算机当中都是使用补码来进行计算和存储的。补码很好地解决了反码负数不能跨零计算的弊端,并且补码还可以记录一个特殊的值:-128——这个数据在 1 个字节下是没有原码和反码的(原码/反码的 8 位表示不出来它)。

补码的范围是不对称的,Tmin 没有与之对应的正数。是因为有 0 的存在:一半的位模式表示负数,另一半表示非负数,因为 0 占用了非负数这边的一个名额,所以能表示的正数比负数少一个。

Java 中要求用补码表示,单字节为 byte 而非 char,为保证无论在何机器运行,程序都表现一样:

image-1c7c3f7d

3.3 时钟:理解溢出的最好比喻

image-bf07d68f

其实可以理解成一个时钟:顺时针 0 - 1 = -1 很正常,逆时针 -1 + 1 = 0 也很正常。顺时针为减、逆时针为加:-128 - 1 = 127,127 + 1 = -128。

比最小的小就变成最大的,比最大的大就变成最小的(24 点过后不就是新的一天变成 1 点了)。

对于无符号数也是同理:255 + 1 = 0,0 - 1 = 255。

image-92c2165e

1111 1111 + 1 = 1 0000 0000,最高位溢出了,留下的只有 0000 0000;0000 0000 - 1:

image-ecdca16a

留下的是 1111 1111。

顺便回答开头的伏笔:如果单看二进制还可以理解,突然告诉我 127 + 1 = -128 就大吃一惊了——这里是作为有符号数看待的:char 无符号数范围为 0~255,有符号数的范围为 -128~127。时钟转过头去,就从最大值绕回了最小值。

四、有无符号数的转换

4.1 核心原则

C 语言允许在各种不同数字数据类型间强制类型转换:

int x;
unsigned u;
(unsigned)x;   // 把 x 的值换成一个无符号数值
(int)u;        // 把 u 转换成有符号整数

两种表示的范围不一样,就可能出现几种情况:

  1. 对于两种形式下都存在的值——保持不变
  2. 对于负数换成无符号数——可能得到大正数
  3. 转换的无符号数太大,超过了补码能表示的范围——可能得到负数

在类型转换下,位值是不会变的,改变的只是解释这些位的方式(就像存入一个数,这个数在内存里不会变,变的只是你用二进制还是十进制去解释它、取出它)。

-12345 的 16 位补码表示和 54191 的 16 位无符号表示的位模式是一样的。也就是说 0xCFC7 的 16 位位模式既是 -12345 的补码表示又是 54191 的无符号表示。

12345 + 54191 = 65536 = 2¹⁶。

???????

这并非巧合——这个属性可以推广到给定位模式的两个数值(补码和无符号数)之间的关系。

4.2 补码 → 无符号数

对于 4 位的转换:

补码 无符号数
-8 -8 + 16 = 8
-2 -2 + 16 = 14
0 0
5 5

补码能表示的正数范围小于无符号数,所以无需考虑正数越界的问题:若为正则保持一致;若为负,加上 2^w 就能得到转换结果。

为什么?用 4 位来解释:

  • 对于补码来说,最高位是符号位,用向左的条表示(权重 -8)
  • 对于无符号数来说,最高位是正权重,用向右的条表示(权重 +8)

从补码变到无符号数,最高有效位的权重从 -8 变成了 +8,因此补码表示的负数如果看成无符号数,值会增加 2⁴ = 16。

image-9b5a715e

相差了两个权重的绝对值之和。所做的事情也可以用图像表示——非负数保存不变,负数转换成了大的正数:

image-5bfe407f

4.3 无符号数 → 补码

对于 4 位的转换:

无符号数 补码
14 14 - 16 = -2
7 7
image-f0225929 image-75285f90

大于有符号表示范围的数变成负的,小于等于有符号表示范围的数不变。

4.4 总结

对于 0 ≤ x ≤ Tmax 范围内的值而言,T2U(x) = x 且 U2T(x) = x——在这个范围内数字有着同样的无符号和补码表示。

对于这个范围以外的数值,转换需要加上或减去 2^w。这样一来:

  • 最靠近 0 的负数会被映射成最大的无符号数
  • 最小的负数映射为一个刚好在补码正数范围以外的无符号数

五、C 语言中的有无符号数

C 语言支持所有整型数据类型的有符号和无符号运算。尽管 C 语言标准没有指定有符号数要采用某种表示,但几乎所有机器都使用补码。

通常大多数数字都被默认为是有符号的,如果要创建一个无符号常量,就必须加上后缀 U 或者 u。C 语言允许有符号和无符号数之间的转换,虽然 C 标准没有精确定义如何进行这种转换,但大多数系统遵循的原则是底层的位表示保持不变,因此在一台采用补码的机器上,转换进行的就是上面两个函数的操作。

这种转换分为显式的和隐式的:

// 显式
int tx, ty;
unsigned ux, uy;
tx = (int)ux;
uy = (unsigned)ty;

// 隐式:赋值时自动转换
tx = ux;
uy = ty;

// 隐式:printf 中
int x = -1;
printf("x=%u=%d\n", x, x);   // 两种解释同时打印

// 隐式:运算中

最要命的是运算中的隐式转换:执行一个运算时,一个运算数是有符号的而另一个为无符号的,那么 C 语言会隐式地将参数强制类型转换成无符号数,并假设这两个数都是非负的再执行运算。

这对于算术运算可能没多大差异,但是在关系运算中会导致非直观的结果:

-1 < 0U

先转换成无符号的,就成了:

4294967295U < 0U     ← 最靠近 0 的负数被映射成最大的无符号数

显然是错的(本应为真)。

六、盲点自测

  1. 为什么负数的表示要迭代三代? ——原码不能算负数(还俩零),反码跨零差 1(还是俩零),补码全解决(零唯一)——每一代都是给上一代填坑。
  2. -1 的补码为什么是 1111 1111? ——利用溢出丢失:255 + 1 = 2⁸ 溢出归零,所以 1111 1111 和 1 相加正好得 0,它就是 -1。
  3. 补码范围为什么是 -128 ~ 127 而不是 -127 ~ 127? ——0 占了非负数一个名额,正数比负数少一个;多出来的 1000 0000 直接定义为 -128。
  4. 为什么 -128 没有原码和反码? ——原码/反码的 8 位里,1000 0000 被规定为 -0,表示不到 -128。
  5. 有无符号转换时内存变了吗? ——没变,变的只是解释方式;负数补码看成无符号 = 值 + 2^w,本质是最高位权重从 -2^(w-1) 翻成 +2^(w-1)。
  6. -1 < 0U 为什么是假的? ——C 隐式把 -1 转成无符号,得 4294967295,自然大于 0。关系运算混用有无符号是经典事故现场。
  7. 127 + 1 为什么等于 -128? ——把 8 位补码想成时钟,转到头就绕回另一端;无符号的 255 + 1 = 0 同理。

补码把"负数"焊死在了硬件里。接下来看看程序员能直接摸到的位运算兵器谱 → 03-位运算应用

⬅️ 进制 🏠 00-信息的表示与处理 ➡️ 03-位运算应用