原码、反码、补码
本篇是总篇 00-信息的表示与处理 第三章的展开:从"二进制里负数怎么表示"这个问题出发,看原码、反码、补码三代方案如何一代一代填上一代的坑,最后补码一统天下。
在 C 和 C++ 中,整数有两种方式编码:一种只能表示非负数,另一种能表示负数、0 和正数。而 Java 只支持有符号数。
C 语言中有着很多表示有限范围的整数的数据类型:char、short、long……它们默认表示可能为负数,还可以声明为 unsigned 表示非负数。为这些不同大小分配的字节数,根据程序编译为 32 位还是 64 位而有所不同——long 是与机器相关的大小指示符,64 位机器用 8 字节表示,32 位机器用 4 字节表示。
32 位程序上 long 表示为 -2147483648 ~ 2147483647,unsigned long 表示为 0 ~ 4294967295。
可以发现取值范围其实是不对称的——负数的范围比正数范围大 1。这个"不对称"的伏笔,读到补码一节就解开了。
一、无符号数的编码
假设一个整数的数据类型有 w 位,我们可以将位向量写成 [x(w-1), x(w-2), ……, x0]。
把向量 x 看做一个二进制的数,每个 xi 都取 0 或 1,就获得了它的无符号表示。可以使用函数表示为:
一个特别好用的理解方式——把每一个位当做一个向量:
- 该位为 1 时,就加上该向量的长度
- 为 0 则不加
- 最终的总长度即为无符号表示的值
(就是我们二进制转十进制的步骤,参见 01-进制与进制转换)
所能表示的范围为:
二、有符号数的编码
有符号就引出了"负号"的概念。
平时的十进制计算中,看到 2 + (-18) 会把它变成 2 - 18;2 - (-18) = 2 + 18;2 × (-18) = -(2×18)——把负号换掉或者拿掉,在结果上再加上负号。
那么二进制怎么做?历史提出了三种解决方式——原码、反码、补码。
2.1 原码
最高位是符号位,0 代表正数,1 代表负数,非符号位为该数字绝对值的二进制。
通俗的理解:仿照十进制,用一个特殊标志表示负数,该标志在这个数以外——该位为 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 反码
正数的反码不变和原码一致,负数的反码会在原码的基础上,高位符号位不变,其他位取反(1 变成 0,0 变为 1)。
反码的存在是为了正确计算负数,因为原码不能用于计算负数。
再来使用反码计算一下 -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 补码
正数的补码是其本身;负数的补码等于其反码 + 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 ✓
用首位判断符号、并且首位的 1 也是这个数的一部分——补码的权重理解:
把每一个位当做一个向量:首位为 1 时方向向左(负权重),为 0 则向右,依次加上每位长度,最终的总长度即为有符号补码表示的值。
再来使用补码计算一下 -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 为位数):
如果 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,为保证无论在何机器运行,程序都表现一样:
3.3 时钟:理解溢出的最好比喻
其实可以理解成一个时钟:顺时针 0 - 1 = -1 很正常,逆时针 -1 + 1 = 0 也很正常。顺时针为减、逆时针为加:-128 - 1 = 127,127 + 1 = -128。
比最小的小就变成最大的,比最大的大就变成最小的(24 点过后不就是新的一天变成 1 点了)。
对于无符号数也是同理:255 + 1 = 0,0 - 1 = 255。
1111 1111 + 1 = 1 0000 0000,最高位溢出了,留下的只有 0000 0000;0000 0000 - 1:
留下的是 1111 1111。
顺便回答开头的伏笔:如果单看二进制还可以理解,突然告诉我 127 + 1 = -128 就大吃一惊了——这里是作为有符号数看待的:char 无符号数范围为 0~255,有符号数的范围为 -128~127。时钟转过头去,就从最大值绕回了最小值。
四、有无符号数的转换
4.1 核心原则
C 语言允许在各种不同数字数据类型间强制类型转换:
int x;
unsigned u;
(unsigned)x; // 把 x 的值换成一个无符号数值
(int)u; // 把 u 转换成有符号整数
两种表示的范围不一样,就可能出现几种情况:
- 对于两种形式下都存在的值——保持不变
- 对于负数换成无符号数——可能得到大正数
- 转换的无符号数太大,超过了补码能表示的范围——可能得到负数
在类型转换下,位值是不会变的,改变的只是解释这些位的方式(就像存入一个数,这个数在内存里不会变,变的只是你用二进制还是十进制去解释它、取出它)。
-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。
相差了两个权重的绝对值之和。所做的事情也可以用图像表示——非负数保存不变,负数转换成了大的正数:
4.3 无符号数 → 补码
对于 4 位的转换:
| 无符号数 | 补码 |
|---|---|
| 14 | 14 - 16 = -2 |
| 7 | 7 |
大于有符号表示范围的数变成负的,小于等于有符号表示范围的数不变。
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 的补码为什么是 1111 1111? ——利用溢出丢失:255 + 1 = 2⁸ 溢出归零,所以 1111 1111 和 1 相加正好得 0,它就是 -1。
- 补码范围为什么是 -128 ~ 127 而不是 -127 ~ 127? ——0 占了非负数一个名额,正数比负数少一个;多出来的 1000 0000 直接定义为 -128。
- 为什么 -128 没有原码和反码? ——原码/反码的 8 位里,1000 0000 被规定为 -0,表示不到 -128。
- 有无符号转换时内存变了吗? ——没变,变的只是解释方式;负数补码看成无符号 = 值 + 2^w,本质是最高位权重从 -2^(w-1) 翻成 +2^(w-1)。
- -1 < 0U 为什么是假的? ——C 隐式把 -1 转成无符号,得 4294967295,自然大于 0。关系运算混用有无符号是经典事故现场。
- 127 + 1 为什么等于 -128? ——把 8 位补码想成时钟,转到头就绕回另一端;无符号的 255 + 1 = 0 同理。
补码把"负数"焊死在了硬件里。接下来看看程序员能直接摸到的位运算兵器谱 → 03-位运算应用
⬅️ 进制 🏠 00-信息的表示与处理 ➡️ 03-位运算应用
💬 评论