计算机中的整数表示
继续实现更多类型之前,需要先弄清整数在机器里到底是什么。尤其是负数,它并不是用一个单独的“负号”存起来的。本章会说明无符号整数,以及用二进制补码(two's complement)表示有符号整数的方法。
下面用 0b 前缀表示二进制位模式,并按 4 位一组加下划线分隔,例如 0b0001_1010。不少 C 编译器也支持 0b 这种扩展写法,但通常不允许在数字中写下划线。
无符号整数
无符号整数(unsigned integer)的表示方法和普通二进制数相同。十进制数从低位开始依次表示个位、十位、百位、千位,换成幂就是 10^0、10^1、10^2、10^3……;二进制数也一样,从低位开始依次表示 1、2、4、8……,也就是 2^0、2^1、2^2、2^3……。
例如 0b1110 这个位模式表示的无符号整数,可以通过查看哪些位为 1 来计算。这里第 2 位、第 3 位、第 4 位为 1,也就是 2 位、4 位和 8 位为 1,所以 0b1110 表示 2 + 4 + 8 = 14。下面的图给出几个例子。
如果不断给无符号整数加 1,它的值会像下面的图一样循环。这里展示的是 4 位整数的例子。
运算结果超出有限位宽时,与无限位整数的结果不同,这称为“溢出”。例如 8 位整数中,1+3 不会溢出,但 200+100 和 20-30 会溢出,结果分别变成 44 和 246。数学上,这等价于对 2^8 = 256 取余。
专栏:溢出引发的有趣缺陷
数值溢出有时会引发意想不到的缺陷。这里介绍游戏《Civilization》初代版本中的一个缺陷。
《Civilization》是一款文明之间展开战争与战略竞争的游戏,玩家可以选择成吉思汗、伊丽莎白女王等历史人物,目标是通过征服世界或太空开发竞赛取得胜利。
初代《Civilization》中有一个著名缺陷:本来非常非暴力的甘地会突然频繁发动核攻击。原因是游戏中民主主义会使攻击性降低 2,而甘地的攻击性初始值是所有玩家中最小的 1。游戏推进后印度文明采用民主主义时,攻击性从 1 减 2 发生无符号溢出,变成 255,甘地就突然成为游戏中极端好战的玩家。通常到了这个阶段,各文明也已经拥有核武器,于是甘地的回合会突然发动核战争。这个“核甘地”很有趣,因此后来成为《Civilization》系列中的固定梗;不过初代中它原本是一个非预期缺陷。
有符号整数
有符号整数(signed integer)通常使用“二进制补码表示”(two's complement)。在 n 位二进制补码整数中,除了最高位之外,各位的含义和无符号整数相同;最高位不表示 2^(n-1),而表示 -2^(n-1)。
以 4 位二进制数为例,各位表示的值如下表。
| 第 4 位 | 第 3 位 | 第 2 位 | 第 1 位 | |
| 无符号情况 | 8 | 4 | 2 | 1 |
| 有符号情况 | -8 | 4 | 2 | 1 |
和无符号整数一样,要看一个位模式表示的有符号值,也要查看哪些位为 1。例如把 0b1110 看作 4 位有符号整数时,第 2、第 3、第 4 位为 1,也就是 2、4、-8 这三位为 1,所以它表示 2 + 4 + (-8) = -2。下面给出图示。
根据这个规则,只要最高位没有置 1,有符号整数表示的数值就和把同一位模式解释为无符号整数时相同。
在 4 位整数中,0~7 的位模式在有符号和无符号解释下完全一致。另一方面,最高位为 1 时,该位模式表示 -8~-1(0b1000~0b1111)之间的数。最高位为 1 时表示负数,因此最高位也称为“符号位”(sign bit)。
如果不断给有符号整数加 1,它的值会像下面的图一样循环。这里展示的是 4 位整数的例子。
理解了上面的规则,就能解释编程中经常见到的、有符号整数看似奇怪的行为。
有符号整数不断加 1,发生溢出时会从最大正数跳到最小负数。读者应该也见过这种现象。
用二进制补码可以清楚理解发生了什么。例如 8 位有符号整数中,最大值是 0b0111_1111,也就是 127。再加 1 会得到 0b1000_0000,在二进制补码中它表示 -128,也就是绝对值最大的负数。
在一元 - 的测试中,如果 main 返回 -3,程序整体的退出码会是 253。这是因为 main 把 RAX 设置为 -3,也就是 0b1111_..._1101;接收退出码的一侧只关心 RAX 低 8 位,并把它解释为无符号整数,于是 0b1111_1101 就成为 253。
也就是说,同一个位模式表示什么数,取决于读取方如何解释。纸上墨迹是否构成文字,是由读者把它当作文章来解释才产生意义;计算机内存中的内容本质上也只是由开关状态组成的位列,它本身没有固定意义。要传递数值,写入方和读取方必须在解释方式上保持一致。
另外,二进制补码能表示的负数比正数多一个。例如 8 位整数能表示 -128,但 +128 超出范围。正负范围不对称是机制上不可避免的。n 位位模式共有 2^n 种,总是偶数;其中一个位模式要分配给 0,剩下的就是奇数个,不可能平均分给正数和负数。
符号扩展
计算机经常需要把数值的位宽变宽。例如从内存读取一个 8 位数值并放入 64 位寄存器时,就需要把 8 位值扩展为 64 位值。
如果把值当作无符号整数使用,扩展很简单:高位全部补 0 即可。例如 4 位值 0b1110 = 14 扩展到 8 位后是 0b0000_1110 = 14。
如果把值当作有符号整数使用,只补 0 就会改变数值。例如 4 位值 0b1110 = -2 扩展到 8 位时,如果变成 0b0000_1110,它就表示 14,不再是负数。
扩展有符号整数时,如果符号位为 1,新增的高位都要补 1;如果符号位为 0,新增的高位都要补 0。这个操作称为“符号扩展”(sign extension)。例如 4 位值 0b1110 = -2 符号扩展到 8 位后是 0b1111_1110 = -2,位宽变大但数值不变。
无符号整数可以理解为:数值左侧无限延伸着 0,扩展时只是取出更多高位。
同样,有符号整数可以理解为:数值左侧无限延伸着与符号位相同的值,扩展时也是取出更多高位。
因此,当需要把数值放入比原来更宽的位置时,必须事先知道自己要把该值当作有符号数还是无符号数处理。
专栏:不需要符号扩展的负数表示
二进制补码是计算机中广泛使用的有符号整数表示方法,但如果只考虑怎样把正负整数映射到位模式,它并不是唯一方案。例如可以考虑“负二进制”表示:从低位开始,各位分别表示 (-2)^0、(-2)^1、(-2)^2……。以 4 位为例,各位权重可以如下比较。
| 第 4 位 | 第 3 位 | 第 2 位 | 第 1 位 | |
| 无符号 | 8 | 4 | 2 | 1 |
| 二进制补码 | -8 | 4 | 2 | 1 |
| 负二进制 | -8 | 4 | -2 | 1 |
4 位负二进制可以表示 -10~5 共 16 个整数。负二进制的缺点是按位观察时较难处理,而且表示范围中心附近不是 0;优点是没有单独的符号位。因此,把负二进制扩展到更多位时,高位总是补 0 即可。
由此可见,计算机上的整数表示并不只有二进制补码一种。二进制补码是在各种方案中对硬件最方便的一种,因此现存计算机几乎都采用它。
符号取反
编写编译器并不一定需要深入掌握二进制补码的所有细节,但记住一些与二进制补码有关的技巧会很有用。这里说明一种简单地反转数值正负号的方法。
在二进制补码中,“把所有位取反,然后加 1”这个操作会把数值变成相反数。例如要求 8 位有符号整数中 3 对应的 -3 位模式,可以按下面步骤做。
- 先把数值写成二进制。3 是
0b0000_0011。 - 把所有位取反,得到
0b1111_1100。 - 再加 1,得到
0b1111_1101。这就是 -3 的位模式。
记住这个方法,就能很容易地求出负数的位模式。
反过来,如果看到一个符号位为 1 的位模式,也可以用同样操作求出它表示的数值。例如 0b1111_1101 表示什么,用普通加法直接算会有些麻烦;但把它取反再加 1 得到 0b0000_0011,也就是 3,因此原来的位模式表示 -3。
这个技巧为什么成立并不难理解。到目前为止,我们没有严格定义二进制补码中的运算,说明也比较直观;但思路如下。
把所有位取反,等价于从 -1(也就是所有位都是 1 的位模式)中减去该数。例如 0b0011_0011 这个位模式,可以如下反转。
1111 1111 - 0011 0011 = 1100 1100
也就是说,表示数值 n 的位模式取反,等价于计算 -1 - n。然后再加 1,就得到 (-1 - n) + 1 = -n,因此结果正好是 n 的相反数。
专栏:数字字面量的基数
C 标准允许用八进制、十进制和十六进制书写整数。普通的 123 是十进制;以 0x 开头的 0x8040 是十六进制;以 0 开头的 0737 是八进制。
很多读者可能以为自己从不用 C 的八进制写法,但由于单独的 0 也属于八进制表示,实际上每个 C 程序员都频繁写八进制字面量。这听起来像冷知识,但细想有其原因。
0 作为数的记法本身就有些特殊。普通数值中,前导 0 不会写出来;如果把这个规则应用到 0,结果就是空字符串。但什么都不写在实用上不行,所以 0 必须作为特殊规则单独写出。从这个角度看,C 语法中把 0 放入某种特殊类别也并不奇怪。