再谈二进制补码

写在前面:此文章不是介绍补码的基础知识,在您向下阅读之前,确保已经对数据的二进制表示有一定的了解,否则请勿浪费时间。

在初学计算机组成原理 (ICS) 的时候,入门的第一课便是数据的编码表示,其中一个重要的部分就是补码 (Complement) 。

在学习补码时,有个结论是经常提到的,而且要求我们记住:

正数的补码是其二进制表示本身,负数的补码是对应正数补码的按位取反再加一。

这个结论等价于:对于某个整型 \(k\)\(k\) 的相反数:-k = ~k + 1

在大二学习 ICS 这门课的时候,我经常使用这个结论去写出负数的补码(先写正数的补码,然后按位取反再加一)。

但同时我有一个「坏习惯」,当我想把某个负数的补码转换为 10 进制表示时,我总是先把补码按位取反再加一,然后求出正数,即可求得负数。

在学习 CMU 的 15213-CSAPP 的 Course 时,从另外一种角度来理解补码似乎更符合我的思维习惯。

\(X=x_{w-1} ... {x_0}\) 是一个比特串,如果将 \(X\) 视作是一个无符号数 (unsigned) ,那么有:

\[B2U(X) = \sum_{i=0}^{w-1}x_i \cdot 2^i \]

如果将 \(X\) 视作是一个有符号数 (signed) ,那么有:

\[B2T(X)= -x_{w-1} \cdot 2^{w-1} + \sum_{i=0}^{w-2} x_i \cdot 2^i \]

下面是这个公式给我带来的几点启示。

特殊值

对于 32 位的 int ,我们知道 int x = 0x80000000 表示的是十进制中的 -2147483648

大一上 C 语言的时候,老师当时的解释:最高位表示的是符号位,因此按「道理」来说, 0x00000000 表示 +00x80000000 表示 -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\)

\[B2T(X_1) = -2^{n_1-1} + \sum_{i=0}^{n_1-2}x_i \cdot 2^i \]

对于 \(X_2\)

\[\begin{align} B2T(X_2) &= -2^{n_2-1} + \sum_{i=0}^{n_2-2}x_i \cdot 2^i \\ &= -2^{n_2-1} + \sum_{i=n_1-1}^{n_2-2}2^i + \sum_{i=0}^{n_1-2}x_i \cdot 2^i \\ &= -2^{n_2-1} + \frac{2^{n_1-1}(1-2^{n_2-n_1})}{1-2} + \sum_{i=0}^{n_1-2}x_i \cdot 2^i \\ &= -2^{n_1-1} + \sum_{i=0}^{n_1-2}x_i \cdot 2^i \\ &= B2T(X_1) \end{align} \]

证毕。

对于上述 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 向数轴左边移动,这正好就是我们需要的「丢弃」小数位的除法。

posted @ 2020-02-28 18:43  sinkinben  阅读(745)  评论(0编辑  收藏  举报