什么是二进制补码?计算机如何表示负数
计算机内部只存储 0 和 1。
如果只处理正整数,这很简单。比如 8 位二进制一共有 256 种组合,可以表示:
|
1 2 3 4 5 6 |
00000000 = 0 00000001 = 1 00000010 = 2 ... 11111111 = 255 |
但是计算机还需要处理负数。
例如:
|
1 2 3 4 |
-1 -5 -100 |
问题来了:
硬件内部只有 0 和 1,负号应该怎样表示?
现代计算机通常使用一种叫做 二进制补码,Two’s Complement 的方法来表示有符号整数。
补码最重要的优势是:
正数和负数可以使用同一套二进制加法硬件进行运算。
这也是为什么二进制补码成为现代 CPU 的标准整数表示方式。
1. 为什么需要特殊的负数表示方法
假设我们只有 4 个比特。
一共可以产生:
|
1 2 |
2^4 = 16 |
种不同组合。
如果全部按无符号整数解释,那么范围就是:
|
1 2 |
0 到 15 |
例如:
|
1 2 3 4 5 6 |
0000 = 0 0001 = 1 0010 = 2 ... 1111 = 15 |
但是这里完全没有负数。
如果 CPU 需要计算:
|
1 2 |
5 - 8 |
结果应该是:
|
1 2 |
-3 |
因此,我们必须拿出一部分二进制模式,用来表示负数。
2. 为什么不能直接加一个符号位
一个很自然的想法,是把最左边的一位作为符号位。
例如:
|
1 2 3 |
0 = 正数 1 = 负数 |
那么 8 位整数就可以写成:
|
1 2 |
符号位 + 数值 |
例如:
|
1 2 3 |
00000101 = +5 10000101 = -5 |
这种表示方法叫做:
Sign-and-Magnitude,符号加绝对值。
看起来很直观,但是它有明显的问题。
首先,它会产生两个零:
|
1 2 3 |
00000000 = +0 10000000 = -0 |
计算机实际上并不需要两个零。
另外,加减法硬件也会变得复杂,因为 CPU 必须先判断符号,再决定怎样计算。
二进制补码解决了这些问题。
3. 二进制补码的基本思想
在补码中,正数和普通二进制完全一样。
例如 8 位整数:
|
1 2 3 4 5 |
00000001 = +1 00000010 = +2 00000101 = +5 01111111 = +127 |
负数通常以 1 开头:
|
1 2 3 4 5 |
11111111 = -1 11111110 = -2 11111011 = -5 10000000 = -128 |
这里要特别注意:
虽然最高位可以帮助我们判断正负,但补码并不是简单的“一个符号位加一个数值”。
整个二进制模式共同决定这个数的值。
4. 怎样把一个正数变成负数
补码最常见的规则非常简单:
|
1 2 3 4 |
所有比特取反 + 加 1 |
也就是:
|
1 2 |
Invert + 1 |
例如,我们把:
|
1 2 |
+5 |
转换成:
|
1 2 |
-5 |
先写出 8 位的正 5:
|
1 2 |
00000101 |
第一步,所有比特取反:
|
1 2 |
11111010 |
第二步,加 1:
|
1 2 3 4 5 |
11111010 + 1 ---------- 11111011 |
所以:
|
1 2 |
11111011 = -5 |
这就是 8 位二进制补码中的负 5。
5. 为什么 11111111 表示 -1
再看一个非常重要的例子。
正 1 是:
|
1 2 |
00000001 |
全部取反:
|
1 2 |
11111110 |
再加 1:
|
1 2 3 4 5 |
11111110 + 1 ---------- 11111111 |
所以:
|
1 2 |
11111111 = -1 |
这也是低级编程中经常看到的情况。
十六进制:
|
1 2 |
0xFF |
对应二进制:
|
1 2 |
11111111 |
如果按 8 位无符号整数解释:
|
1 2 |
11111111 = 255 |
如果按 8 位有符号补码解释:
|
1 2 |
11111111 = -1 |
比特完全没有变化。
变化的是解释方式。
6. 同样的比特,为什么既可以是 255,也可以是 -1
这是理解计算机数据表示非常重要的一点。
内存只保存比特。
例如:
|
1 2 |
11111111 |
内存并不知道它是 255,还是 -1。
如果程序把它解释成 unsigned,也就是无符号整数:
|
1 2 |
11111111 = 255 |
如果程序把它解释成 signed two’s complement,也就是有符号补码:
|
1 2 |
11111111 = -1 |
所以:
比特本身没有 signed 或 unsigned 属性。
真正决定意义的是:
- 数据类型
- CPU 指令
- 程序上下文
同样一组二进制数据,可以表示完全不同的值。
7. 为什么二进制补码能够工作
8 个比特一共有:
|
1 2 |
2^8 = 256 |
种状态。
8 位二进制运算天然具有一种“环绕”特性。
例如:
|
1 2 3 4 5 |
11111111 + 1 ---------- 1 00000000 |
如果 CPU 只保留最低 8 位,那么最高的进位会被丢掉。
结果就变成:
|
1 2 |
00000000 |
也就是说:
|
1 2 |
255 + 1 → 0 |
这就是固定宽度二进制运算的 wraparound,也就是环绕。
补码正是利用了这个性质。
可以把:
|
1 2 |
11111111 |
理解成比 0 小一步,也就是:
|
1 2 |
-1 |
然后:
|
1 2 3 |
11111110 = -2 11111101 = -3 |
依次向下。
8. 另一种理解负数的方法
对于一个 n 位补码,如果最高位是 1,可以先把它按无符号数读取,然后计算:
|
1 2 |
无符号值 - 2^n |
例如 8 位的:
|
1 2 |
11111111 |
作为无符号数是:
|
1 2 |
255 |
因为:
|
1 2 |
2^8 = 256 |
所以:
|
1 2 |
255 - 256 = -1 |
再看:
|
1 2 |
11111011 |
作为无符号数是:
|
1 2 |
251 |
于是:
|
1 2 |
251 - 256 = -5 |
所以:
|
1 2 |
11111011 = -5 |
这种方法在分析机器码和底层数据时非常方便。
9. 为什么 8 位有符号整数范围是 -128 到 127
8 位一共有 256 种二进制组合。
如果按无符号整数解释:
|
1 2 |
0 到 255 |
如果按补码有符号整数解释,则分成两部分。
非负数:
|
1 2 3 4 |
00000000 = 0 ... 01111111 = 127 |
负数:
|
1 2 3 4 |
10000000 = -128 ... 11111111 = -1 |
因此 8 位有符号整数的范围是:
|
1 2 |
-128 到 127 |
一般来说,n 位有符号补码的范围是:
|
1 2 3 4 |
-2^(n-1) 到 2^(n-1) - 1 |
例如 8 位:
|
1 2 |
-2^7 到 2^7 - 1 |
也就是:
|
1 2 |
-128 到 127 |
10. 为什么负数比正数多一个
你可能已经注意到:
8 位补码有:
|
1 2 |
128 个负数 |
但是只有:
|
1 2 |
127 个正数 |
另外还有一个 0。
原因很简单:
补码只有一个零。
|
1 2 |
00000000 = 0 |
不像 sign-and-magnitude 那样存在:
|
1 2 3 |
+0 -0 |
因此,多出来的一个二进制模式可以用来表示:
|
1 2 |
-128 |
所以 8 位补码有:
|
1 2 |
10000000 = -128 |
但是没有:
|
1 2 |
+128 |
最大的正数只能是:
|
1 2 |
01111111 = 127 |
11. 怎样读取一个负补码
假设我们看到:
|
1 2 |
11110110 |
并且知道它是一个 8 位有符号整数。
最高位是 1,因此它是负数。
可以用“取反加一”的方式找到它的绝对值。
先取反:
|
1 2 3 4 |
11110110 ↓ 00001001 |
再加 1:
|
1 2 3 4 5 |
00001001 + 1 ---------- 00001010 |
得到:
|
1 2 |
10 |
所以原来的数是:
|
1 2 |
11110110 = -10 |
也可以使用前面介绍的快速方法。
11110110 作为无符号数是:
|
1 2 |
246 |
所以:
|
1 2 |
246 - 256 = -10 |
结果完全一样。
12. 补码为什么让加法变得简单
补码最大的硬件优势,是同一个二进制加法器可以同时处理正数和负数。
例如:
|
1 2 |
5 + (-3) |
8 位的 5 是:
|
1 2 |
00000101 |
负 3 的补码是:
|
1 2 |
11111101 |
直接做普通二进制加法:
|
1 2 3 4 5 |
00000101 + 11111101 ---------- 1 00000010 |
丢掉最高位进位后:
|
1 2 |
00000010 |
也就是:
|
1 2 |
2 |
所以:
|
1 2 |
5 + (-3) = 2 |
加法器并不需要知道哪个数是负数。
它只需要正常做二进制加法。
13. 减法为什么可以变成加法
补码还可以把减法转换成加法。
例如:
|
1 2 |
A - B |
可以变成:
|
1 2 |
A + (-B) |
例如:
|
1 2 |
7 - 3 |
可以写成:
|
1 2 |
7 + (-3) |
8 位二进制:
|
1 2 3 |
7 = 00000111 -3 = 11111101 |
相加:
|
1 2 3 4 5 |
00000111 + 11111101 ---------- 1 00000100 |
丢掉最高位进位:
|
1 2 |
00000100 |
也就是:
|
1 2 |
4 |
因此:
|
1 2 |
7 - 3 = 4 |
这意味着 CPU 不需要完全独立的加法和减法硬件。
减法可以建立在加法器基础上实现。
14. 最高位为什么可以看成负权重
还有一种非常直观的数学理解方式。
普通 8 位无符号整数的权重是:
|
1 2 |
128 64 32 16 8 4 2 1 |
而 8 位补码的权重可以看成:
|
1 2 |
-128 64 32 16 8 4 2 1 |
注意最高位从:
|
1 2 |
+128 |
变成了:
|
1 2 |
-128 |
例如:
|
1 2 |
11111011 |
按照补码权重计算:
|
1 2 |
-128 + 64 + 32 + 16 + 8 + 2 + 1 |
得到:
|
1 2 |
-5 |
所以也可以直接通过权重计算补码数值,而不必每次都取反加一。
15. 什么是 Signed Overflow
固定宽度整数的表示范围有限。
8 位有符号整数只能表示:
|
1 2 |
-128 到 127 |
如果计算:
|
1 2 |
127 + 1 |
127 是:
|
1 2 |
01111111 |
加 1:
|
1 2 3 4 5 |
01111111 +00000001 --------- 10000000 |
但是:
|
1 2 |
10000000 |
在 8 位补码中代表:
|
1 2 |
-128 |
而不是 128。
原因是:
|
1 2 |
128 |
已经超出了 8 位有符号整数能够表示的范围。
这种情况叫:
Signed Overflow,有符号溢出。
很多 CPU 都提供 overflow flag,用来帮助程序判断这种情况。
16. Carry 和 Overflow 不是一回事
Carry,也就是进位,和 overflow 都和算术有关,但两者不是同一个概念。
Carry 更常用于判断无符号运算是否超出范围。
Overflow 则关注:
有符号运算的真实结果是否超出了当前位宽能够表示的范围。
例如:
|
1 2 |
127 + 1 |
会产生 signed overflow。
所以在学习 CPU 状态寄存器和算术指令时,一定要区分:
|
1 2 |
Carry |
和:
|
1 2 |
Overflow |
17. 什么是 Sign Extension
假设 8 位的负 5 是:
|
1 2 |
11111011 |
现在需要把它扩展成 16 位。
如果简单在左边补 0:
|
1 2 |
00000000 11111011 |
它就会变成一个正数。
正确的方法叫:
Sign Extension,符号扩展。
做法是复制原来的最高位。
因为负数最高位是 1,所以:
|
1 2 |
11111011 |
扩展成 16 位后是:
|
1 2 |
11111111 11111011 |
它仍然表示:
|
1 2 |
-5 |
正数最高位是 0,所以扩展时高位补 0。
18. Zero Extension 和 Sign Extension
再看:
|
1 2 |
11111111 |
如果它是 8 位无符号整数:
|
1 2 |
255 |
扩展成 16 位时使用 zero extension:
|
1 2 |
00000000 11111111 |
结果仍然是:
|
1 2 |
255 |
但是如果原来的八个比特表示:
|
1 2 |
-1 |
就必须使用 sign extension:
|
1 2 |
11111111 11111111 |
这样它仍然表示:
|
1 2 |
-1 |
又一次可以看到:
同样的原始比特,因为 signed 和 unsigned 不同,处理方式也会不同。
19. C 语言中的 signed 和 unsigned
编程语言直接暴露了这种区别。
例如 C 语言中:
|
1 2 3 |
int8_t uint8_t |
int8_t 是 8 位有符号整数。
范围通常是:
|
1 2 |
-128 到 127 |
uint8_t 是 8 位无符号整数。
范围是:
|
1 2 |
0 到 255 |
假设存储的二进制模式是:
|
1 2 |
11111111 |
作为:
|
1 2 |
uint8_t |
它代表:
|
1 2 |
255 |
作为:
|
1 2 |
int8_t |
它代表:
|
1 2 |
-1 |
这就是为什么在 C 语言、嵌入式系统和底层编程中,理解 signed 和 unsigned 非常重要。
20. 为什么现代 CPU 使用二进制补码
二进制补码能够成为现代计算机的主流表示方式,主要有几个原因。
只有一个零
|
1 2 |
00000000 = 0 |
不需要单独的负零。
正数和负数可以共用加法器
CPU 可以用相同的二进制加法硬件处理正数和负数。
减法可以转换成加法
|
1 2 |
A - B |
可以转化为:
|
1 2 |
A + (-B) |
符号扩展非常简单
只需要复制最高位。
硬件实现更加简单
很多算术操作都可以共享底层电路。
21. 32 位和 64 位整数也是同样的原理
补码并不只适用于 8 位整数。
无论是:
|
1 2 3 4 5 |
8-bit 16-bit 32-bit 64-bit |
基本原理完全一样。
32 位有符号整数的范围是:
|
1 2 3 4 |
-2,147,483,648 到 2,147,483,647 |
而同样的 32 个比特,如果解释成无符号整数,则范围是:
|
1 2 3 4 |
0 到 4,294,967,295 |
比特没有变化。
变化的仍然只是解释方式。
22. 二进制补码最重要的规则
最后,可以记住下面几条规则。
对于 n 位有符号补码:
表示范围
|
1 2 3 4 |
-2^(n-1) 到 2^(n-1) - 1 |
把正数变成负数
|
1 2 3 |
所有比特取反 然后加 1 |
把负补码还原成正数绝对值
|
1 2 3 |
所有比特取反 然后加 1 |
快速计算负补码
|
1 2 |
无符号值 - 2^n |
扩展有符号数
使用:
|
1 2 |
Sign Extension |
复制最高位。
总结
二进制补码,Two’s Complement,是现代计算机表示有符号整数的标准方法。
正数和普通二进制没有区别。
负数可以通过:
|
1 2 3 4 |
取反 + 加 1 |
得到。
例如:
|
1 2 |
+5 = 00000101 |
取反:
|
1 2 |
11111010 |
加 1:
|
1 2 |
11111011 |
所以:
|
1 2 |
11111011 = -5 |
补码只有一个零,并且允许同一个二进制加法器同时处理正数和负数。
8 位有符号整数的范围是:
|
1 2 |
-128 到 127 |
而同样的 8 个比特,如果按无符号整数解释,则范围是:
|
1 2 |
0 到 255 |
这说明了计算机体系结构中一个非常重要的原则:
比特本身并不知道自己是 signed 还是 unsigned。
同一组比特,可以因为数据类型、CPU 指令或者程序上下文不同,而代表完全不同的数值。
理解二进制补码以后,再学习 CPU 算术、整数类型、溢出、符号扩展、汇编语言和 ALU,就会容易很多。
下一步,我们可以继续研究:
二进制加法、半加器、全加器,以及逻辑门如何一步步组成 CPU 内部真正的算术电路。
