再谈二进制补码
写在前面:此文章不是介绍补码的基础知识,在您向下阅读之前,确保已经对数据的二进制表示有一定的了解,否则请勿浪费时间。
在初学计算机组成原理 (ICS) 的时候,入门的第一课便是数据的编码表示,其中一个重要的部分就是补码 (Complement) 。
在学习补码时,有个结论是经常提到的,而且要求我们记住:
正数的补码是其二进制表示本身,负数的补码是对应正数补码的按位取反再加一。
这个结论等价于:对于某个整型 \(k\) ,\(k\) 的相反数:-k = ~k + 1
。
在大二学习 ICS 这门课的时候,我经常使用这个结论去写出负数的补码(先写正数的补码,然后按位取反再加一)。
但同时我有一个「坏习惯」,当我想把某个负数的补码转换为 10 进制表示时,我总是先把补码按位取反再加一,然后求出正数,即可求得负数。
在学习 CMU 的 15213-CSAPP
的 Course 时,从另外一种角度来理解补码似乎更符合我的思维习惯。
设 \(X=x_{w-1} ... {x_0}\) 是一个比特串,如果将 \(X\) 视作是一个无符号数 (unsigned) ,那么有:
如果将 \(X\) 视作是一个有符号数 (signed) ,那么有:
下面是这个公式给我带来的几点启示。
特殊值
对于 32 位的 int
,我们知道 int x = 0x80000000
表示的是十进制中的 -2147483648
。
大一上 C 语言的时候,老师当时的解释:最高位表示的是符号位,因此按「道理」来说, 0x00000000
表示 +0
而 0x80000000
表示 -0
,但是 0 只需要一种编码表示就够了,所以把 0x80000000
这个负数的编码「分配」给 -2147483648
。同时也是为了满足计算机的「模运算」系统(大溢出变小,小溢出变大),即:
0x80000000 - 1 = 0x7fffffff
0x7fffffff + 1 = 0x80000000
这种解释对我来说总感觉难以接受,很别扭的感觉。
但是采用上述公式 \(B2T(X)\) 就很好地把所有的二进制编码 统一 映射到我们的十进制数了,整型范围内无一例外。
补码转十进制
假设有 \((10011)_2\) 这个比特串,将其视作有符号数解释。原来没有这个公式,不容易看出对应的十进制数值。原来我是这么转换的:
k = 10011
~k = 01100
~k+1 = 01101 = 13
可得 \(k=-13\) 。
现在有了上述公式,就可以口算:-16 + 3 = -13
。至少对我说,这个效率的提升是显然的(当 \(k\) 有 32 位的时候更加明显)。
补码比较大小
设 \(a=(10011)_{2}\) , \(b=(10101)_{2}\) ,将其均视作有符号数解释。原来我的做法是先转换为十进制,然后再比较大小。但根据上述公式 \(B2T(X)\) ,可以看出,公式的 \(x_{w-1} \cdot 2^{w-1} = -16\) 部分是相等的,所以只需比较后半部分,而 0011 < 0101
,因此显然可以得出 \(a < b\) 。
这就省略了转换十进制的繁琐过程。
符号扩展和位截断
符号扩展,在之前我有一点难以理解。比如:
int8_t x0 = -8; // 1111 1000
int16_t x1 = x0; // 1111 1111 1111 1000
显然,x1
的值也是 -8
,但是我一直很迷惑这其中的原理,怎么证明符号扩展(整数扩展补 0 ,负数扩展补 1)后的值不变?对正数来说是显然的;对于负数,我们可以从其绝对值考虑 abs(k) = ~k + 1
,所以只需要证明 ~k
的值不变即可,这也显然成立。
下面尝试用上述公式证明。设比特串 \(X_1 = x_{n_{1}-1} ... x_0\),现在将 \(X_1\) 扩展为 \(n_2\) 位长的有符号数 \(X_2\) 。
Index | \(n_2 - 1\) | ... | \(n_1 - 1\) | ... | 0 |
---|---|---|---|---|---|
\(X_1\) | \(null\) | \(...null...\) | \(1\) | \(x_{n_1-2} ... x_1\) | \(x_0\) |
\(X_2\) | \(1\) | \(...111...\) | \(1\) | \(x_{n_1-2} ... x_1\) | \(x_0\) |
对于 \(X_1\) :
对于 \(X_2\) :
证毕。
对于上述 2 种证明方法,第一种显然更简单,但个人更喜欢第二种,更直观,更暴力(●=◡=●)。
有点可惜,自己本科没有上过关于编码的理论课程,但我觉得最初搞计算机,设计出「补码」这种编码方式的那些人实在太强大了。「补码」不仅统一了计算机中加减法的运算,而且对于 C 语言中符号扩展,位截断这些处理都变得十分简单。
对于「位截断」来说:
int16_t a = -99;
int8_t b = a;
证明 b
的值仍然是 -99
的过程就是上述证明的逆过程。
补码的四则运算
加减法
在计算机的硬件层面,是没有实现减法的,在 ALU
中有一个叫加法器 Adder 的东西,加法和减法运算都是由它来完成。对于硬件而言,这些家伙没有十进制的概念,也没有「符号位」的概念,给它 2 个的比特串,他就老老实实的算加法:
1111 1111
+ 0000 0001
------------
1 0000 0000
对于加法器而言,0xff + 0x01
的结果就是 0x00
,它可不管你给的有符号数还是无符号数(这些事情都应该是程序员自己来管的,所有 CSAPP
全称叫 Computer System, A Programmer's Perspective )。溢出的 1 bit 在汇编层面我们可以通过 EFLAGS
来获取,但是这些在 C 的层面是「不可见的」。
那么加法器如何计算减法呢?答案是:x - y = x + ~y + 1
。
乘除法
乘法指令 mul
的指令周期大约是 3 ,除法指令 div
的周期大约是 30 (这一点 CSAPP-2015 的 Course 有提到)。因此对于整数的乘除,值得研讨的是通过移位来优化它们(这是编译器层面的工作)。
对于乘法 a * 10
而言,10 的二进制是 1010
,在第 1 位和第 3 位分别有 1 ,因此 a * 10 = a << 1 + a << 3
。这一点通过文章开头的 \(BTU(X)\) 公式是很容易证明的。无论 a 是正数还是负数均适用。
对于除法 a / k
,只有 k
是 2 的幂次形式时才能进行移位优化,我们设 \(k = 2^n\):
- 如果
a
是正数,a / k = a >> n
。 - 如果
a
是负数,\(a / k = (a+2^n-1)>>n\) 。
为什么负数这么特殊?举个 8 位 int
的例子:
int8_t a = -3; // a = 1111 1101
a / 2 should be -1.
a >> 1 = (1111 1101) >> 1 = (1111 1110) = -2
(a + 2^n - 1) >> 1 = (-3 + 1) >> 1 = (-2) >> 1 = (1111 11110) >> 1 = (1111 1111) = -1
因为对于负数, a >> n
丢弃了某些 1 ,使得 a / k
真实数值向数轴的负方向移动。因此移动 n 位,需要加上 \(n\) 个 1 进行修正,该数值就是 \(2^n - 1\) 。
对于正数来说,a >> n
丢弃的某些 1 ,恰好使得 a / k
向数轴左边移动,这正好就是我们需要的「丢弃」小数位的除法。