移码与补码:表示有符号数的两种方式

用二进制表示有符号数有好几种办法,每一种都有不同的取舍。 最常见的是补码(two’s complement),如今几乎每种语言里的每种整数类型都在用它。 但还有另外两种值得了解的方案:原码(sign-magnitude,也就是「直接加个符号位」这种直觉做法,几十年前就被弃用了,因为它把算术搞坏了)和移码(offset binary,也叫 biased exponent 或 offset-k,其中 k 表示偏移量),IEEE-754 用它来表示每个浮点数的阶码。 本文会把这三种都走一遍,弄清原码为什么不够用、补码如何修好它的问题,以及 IEEE-754 为什么在阶码上仍然选了移码而不是补码。

天真的第一次尝试:原码

把二进制扩展到有符号数,最直觉的办法是把最左边那一位留作符号标志——0 表示正、1 表示负——再用其余的位把数值大小当作无符号数编码进去。 这种方案叫原码(sign-magnitude)。

以 4 位为例,+5 和 -5 长这样:

+510=01012510=11012\begin{aligned} +5_{10} &= 0101_2 \\ -5_{10} &= 1101_2 \end{aligned}

数值部分同为 101,只有最高位不同。读起来简单清楚。但这个方案有两个严重缺陷,使它不适合真实硬件。

两个零

由于符号位与数值部分互不相干,零就有了两种表示:

+010=00002010=10002\begin{aligned} +0_{10} &= 0000_2 \\ -0_{10} &= 1000_2 \end{aligned}

零本该只有一个。为它准备两种位模式,会迫使 CPU 对相等性判断做特殊处理——0000 == 1000 仍然必须为真。 它还浪费了 4 位下 16 种可能模式中的一种:在原码方案里,[-7; 7] 范围内我们只有 15 个不同的值,而不是完整的 16 个。

算术无法直接生效

在无符号二进制里,a + b 很直白——逐位相加、传递进位,完事。原码把这一点破坏了。看看 4 位原码下的 3 + (-3)

100112(+3)+  110112(3)111102(?)\begin{aligned} & \phantom{1}0011_2 \quad (+3) \\ +\; & \phantom{1}1011_2 \quad (-3) \\ \hline & \phantom{1}1110_2 \quad (?) \end{aligned}

1110 当原码读,得到的是 -6——离谱地错。 普通的二进制加法并不知道最高位本该是符号;它把那一位和其他位一并加了进去。 要让原码的算术能用,CPU 必须检查两个操作数的符号位, 判断该把数值部分相加还是相减,可能还要比较数值大小以确定结果的符号, 并且单独处理两个零的情形。这一切都要落实为额外的电路和更慢的操作。

正是这些问题推动了转向另一种方案:让符号不再是一个独立的标志,而是编码本身的算术结果——也就是补码。

补码速览

补码是现代 CPU 上有符号整数的标准编码方式,而它比听起来简单。

在补码中,最左边那一位(最高有效位)担着双重角色:它像其他位一样参与运算,并且——当该值被解释为有符号整数时——它还告诉你符号(0 表示非负,1 表示负)。 它不是一个会被剥离出来的独立标志;符号是从算术中自然得出的,因为最高位的位权是负的。

我们取二进制数 1011,并假设它是补码表示的有符号整数。要解码它,我们用与无符号二进制类似的位置计数法,每一位都以 2 的某个幂作为权重——只是最高位的那个幂取了负号。对 4 位来说是这样:

b3(23)+b222+b121+b020b_3 \cdot (-2^3) + b_2 \cdot 2^2 + b_1 \cdot 2^1 + b_0 \cdot 2^0

注意与普通无符号二进制相比变了什么:低三位 b2,b1,b0b_2, b_1, b_0 保留它们通常的正权重(+4+4+2+2+1+1),而最高位 b3b_3 乘的是 23=8-2^3 = -8,而不是 +23=+8+2^3 = +8。最高那一位上这一次符号翻转,就是整个方案的全部。推广开来:对 n 位补码数,最高位的权重是 2n1-2^{n-1} 而非通常的 +2n1+2^{n-1},其余各位保持正常的正权重。

1011 代入公式得到 8+0+2+1=5-8 + 0 + 2 + 1 = -5——不是 11(把 1011 当普通无符号二进制读会得到的结果:8+0+2+18 + 0 + 2 + 1),也不是 -3(原码会给出的结果:最高位 1 表示为负,低位 011 编码数值 3)。

怎样编码一个负数

上一节我们解码的是内存里已经躺着的位模式。但反过来怎么做——从一个像 -5 这样的十进制数出发,产出用于存放它的 4 位模式? 位置计数公式告诉你某个模式是什么意思,但没有直接告诉你如何为给定的负值构造出模式。

好在有一个简单的配方,每次都能给出正确的模式:取这个数的正值版本,把所有位取反(0 → 1,1 → 0),再加 1。结果就是该负值的补码编码。(这个配方为何成立,我们稍后在讲模算术的那一节会看到——现在先把它当成一套机械步骤。)

我们用它来求 -5 在 4 位下的表示:

  1. 从 +5 的二进制开始:
+510=01012+5_{10} = 0101_2
  1. 把所有位取反:
01012101020101_2 \rightarrow 1010_2
  1. 加 1:
10102+00012=101121010_2 + 0001_2 = 1011_2

所以 -5 在 4 位补码中存作 1011。用位权公式复核一下:8+0+2+1=5-8 + 0 + 2 + 1 = -5

为什么采用这个方案

补码让 CPU 能用同一套加法电路(由逻辑门构成的物理硬件模块,执行带进位传播的逐位加法)同时处理有符号和无符号算术。

我们来算 5 + (-5)

101012(+5)+  110112(5)100002\begin{aligned} & \phantom{1}0101_2 \quad (+5) \\ +\; & \phantom{1}1011_2 \quad (-5) \\ \hline & 10000_2 \end{aligned}

最高位产生的进位被丢弃,剩下 0000——正好是零。硬件不需要检查操作数是否有符号,也不需要一条单独的减法通路。普通的二进制加法就是能用。

这种编码还顺带带来另外两个性质。第一,n 位的取值范围是 [2n1; 2n11][-2^{n-1};\ 2^{n-1}-1]——4 位是 [-8; 7],8 位是 [-128; 127]。 注意它是不对称的:负值比正值多一个,因为在原码方案下本该表示 -0 的那个槽位,在这里被改用来表示最负的那个数。 第二,零的位模式只有一个 0000,而不是 00001000 两个。

统一的原理:模算术

现在来看看补码为什么会是这个样子。有一个念头几乎能解释这套格式的一切:n 位算术就是一个 2n2^n 个位置的圆环上的模算术。这套格式几乎所有其他性质——取反加一的配方、共用一套电路的论证、溢出时的绕回——都直接由这一个视角推出。

「模算术」是什么意思

当我们说「n 位算术是模的」,意思是所有的值和所有的运算都发生在一个 2n2^n 个位置的环上,而不是在无限延伸的数轴上。想象一个 4 位的钟面,有 16 个位置,每个位置带两个标签:无符号值(外圈)和补码有符号值(内圈):

外圈:无符号 (0–15)内圈:有符号 (−8…+7)同一组位,两种读法001+12+23+34+45+56+67+78−89−710−611−512−413−314−215−1

注意有符号的标签如何把圆环分成两半:右侧是非负值 0+7, 左侧是负值 −1−8(其中位置 8 本身——位模式 1000——存放着最负的值 −8)。

关于这个钟面的运作方式,有几点值得指出:

  • 每个位置只存放一个位模式——无符号标签和有符号标签只是同样 4 个位的两种不同解读。
  • 1 让你顺时针移动一个位置。
  • 过了 15 之后,你会绕回 0。在这个钟面上,15 + 1 = 015 + 2 = 115 + 3 = 2,以此类推——任何超过 15 的值都会继续绕着圆环走,落到位置 0,然后 1,然后 2

数学家为这种绕回行为准备了一套紧凑的记号:a ≡ b (mod n) 读作「在一个有 n 个位置的钟面上,ab 落在同一个位置」。于是上面那条要点里的绕回例子可以写成 16 ≡ 0 (mod 16)17 ≡ 1 (mod 16)18 ≡ 2 (mod 16)——每一条都只是在说:当你绕着一个 16 位置的圆环走时,左边那个数与右边那个数落在同一位置。 所以 18 ≡ 2 (mod 16) 只是说,在 16 位置的钟面上 182 落在同一位置——整整一圈(16 步)再多走 2 步。

请注意,a ≡ b (mod n) 是一个关系(比较两个数的真假陈述),而不是一个运算——所以它并不直接对应 %。它在代码层面的等价物是判断 a % n == b % n——这两个表达式为真,恰好就是 ab 落在同一钟面位置的时候。% 运算符本身对应的是另一套记号 a mod n(写的时候不带 ),那是一个运算:例如 18 mod 16 = 2 给出的是相除后的余数,也就是你最终落到的那个钟面位置。对任意 n,n 位无符号算术的行为恰如一个 2n2^n 个位置的钟面。

取反加一的配方为何成立

现在我们的工具足够多了,可以看清前面那个配方为什么确实能给出负数的正确编码。

核心概念是加法逆元(additive inverse)。在模算术的钟面上,「负 x」根本不是一个减号——它是那个与 x 相加后能把你带回位置 0 的钟面位置。这个位置就叫做 x 的加法逆元,而补码存进位里的恰恰就是它。而我们先前看到的配方——取正值版本、把所有位取反、再加 1——做的正是这件事:它是计算加法逆元的位级步骤。

我们在这个 16 位置的钟面上求 5 的加法逆元。我们想找一个位置 y,使 5 + y 落在 0。从 5 出发顺时针走 11 步会落到 16,而它绕回成 0。所以 y = 11

5+11=160(mod16)5 + 11 = 16 \equiv 0 \pmod{16}

于是,在 16 位置钟面上 5 的加法逆元是 11。而 11 的二进制是 1011——正是前面取反加一的配方为 −5 给出的那个位模式。

现在可以把我们用 511 做的事推广到任意位数。在 2n2^n 个位置的钟面上,任意值 x 的加法逆元是 2nx2^n - x,因为

x+(2nx)=2n0(mod2n)x + (2^n - x) = 2^n \equiv 0 \pmod{2^n}

也就是说,补码把 −x 存成 2nx2^n - x 这个数按无符号读取时的位模式。这正是我们在钟面上观察到的:有符号的负值 −1, −2, …, −8 与无符号数 15, 14, …, 8 坐在相同的钟面位置上——每一个都是其正值对应者的加法逆元。

最后,「先把位取反、再加 1」只是一种不做真正减法就能算出 2nx2^n - x 的快捷位级办法:

  • xx 的每一位取反得到 2n1x2^n - 1 - x(这个中间值叫做反码,one’s complement)。以 4 位、x=5=x = 5 = 0101 为例:取反得到 1010,按无符号读是 10,而确实 1615=1016 - 1 - 5 = 10
  • 再加 1 就得到 2nx2^n - x。接着上面的例子:10+1=1110 + 1 = 11,正是我们上面算出的加法逆元。

所以这个配方不是设计者想出来的什么巧妙花招——它就是计算模加法逆元的位级捷径。

一句话概括整套格式

补码就是给钟面上半部分换了一套标签的无符号模算术。整个方案就这么多。它的每一个性质——那个配方、共用一套电路、绕回、只有一个零——都是这一个念头的推论。

这也解释了为什么单独一个加法器电路就能同时应付有符号和无符号算术。硬件既不知道也不在乎你把钟面上的位置标成「有符号」还是「无符号」。它只是在做模 2n2^n 的加法。无论你把位模式 1101 解读成无符号的 13 还是有符号的 -3,CPU 都把两个操作数当作钟面位置,按第二个操作数的步数往前走,落到某个位置上。两种解读来自你如何读取结果,而不是 CPU 如何计算它

移码:IEEE-754 采用的方案

补码在它被设计来做的事情上很出色:它让有符号整数算术与无符号算术共用同一套电路。但当你不再只问「我想加减有符号整数」,而是对一种有符号表示提出别的问题时,它就开始显得别扭了。比如这些问题:

  • 我怎样像 CPU 比较无符号值那样,逐位比较两个有符号值?
  • 那些特殊的位模式(最小、最大、零)会自然落在位模式空间的什么位置?
  • 这种编码能否干净地把「小」和「大」分开,而不必围着符号标志做体操?

补码中「最高位即符号」的约定意味着 1000...0000(最小的负数)和 0111...1111(最大的正数)位于位模式空间的两个相对端点,散落各处,而不是齐整地排在边界上。对通用整数来说这没问题,但它妨碍了那些需要可预测、单调的有符号值的格式——比如浮点数的阶码字段,按阶码比较两个浮点数是一条热路径,硬件必须把它做得廉价。

因此,当这些性质要紧时,计算机算术会改用另一种有符号表示:移码(offset binary,也称 biased binaryexcess-K)。这正是 IEEE-754 用于每个浮点数阶码的方案,至于究竟为什么,我们稍后就会看到。现在先看看它实际是怎么运作的。

移码的编码步骤很直白:算出一个偏移量(bias),把它加到你想存的数上,得到的值就是真正被存进去的东西(必要时再转成二进制)。 为了演示这些步骤,我们看看数 3 如何存进 4 位。

首先,用 IEEE-754 标准中提到的公式求偏移量:

K=2n11K = 2^{n-1} - 1

其中 n 是位数。所以 4 位的偏移量是 7。然后把偏移量加到原数上:3 + 7 = 10

得到的数 10 就是数 3 在移码方案下的存储形式。由于我们是用十进制算出结果 10 的,还需要把它转成二进制:

10:2=5+05:2=2+12:2=1+01:2=0+11010=10102\begin{aligned} 10 : 2 &= 5 + 0 \\ 5 : 2 &= 2 + 1 \\ 2 : 2 &= 1 + 0 \\ 1 : 2 &= 0 + 1 \\ \\ 10_{10} &= 1010_2 \end{aligned}

如果你不了解这个转换算法,或者想知道它为什么成立,可以看我那篇关于十进制与二进制转换算法的文章。

确定偏移量

假设我们只有 4 位来存数。可重复排列的公式告诉我们,一共有 24=162^4 = 16 种不同的位模式。问题在于这 16 个数代表什么。假设我们只关心存放非负整数,那么范围是:

[0;15]10[0000;1111]2[0; 15]_{10} \qquad [0000; 1111]_2

但如果把负整数也包含进来,范围就可以有多种:

[1;14]10[0000;1111]2[7;8]10[0000;1111]2[8;7]10[0000;1111]2\begin{aligned} [-1; 14]_{10} &\qquad [0000; 1111]_2 \\ [-7; 8]_{10} &\qquad [0000; 1111]_2 \\ [-8; 7]_{10} &\qquad [0000; 1111]_2 \end{aligned}

有意思的是,虽然十进制下的范围变了,二进制下却始终不变——只不过现在最小的那个数 0000 代表的是一个负数。这就好像最小的那个数从零往下偏移了:第一种情形偏移 1,第二种偏移 7,第三种偏移 8

换句话说,偏移量 K 只是在选择在哪里把这一组固定的位模式在负数与非负数之间划分开——它决定了零的两侧各分到多少个槽位。至于该怎么选,并没有唯一的数学标准,只有惯例。常见的有两种取法:

K=2n1(均分,范围 [2n1; 2n11])K=2n11(IEEE-754,范围 [(2n11); 2n1])\begin{aligned} K &= 2^{n-1} \quad &\text{(均分,范围 } [-2^{n-1};\ 2^{n-1}-1]) \\ K &= 2^{n-1} - 1 \quad &\text{(IEEE-754,范围 } [-(2^{n-1}-1);\ 2^{n-1}]) \end{aligned}

对 4 位来说,K = 8 给出均分的范围 [-8; 7],而 K = 7(IEEE-754 的选择)给出范围 [-7; 8],多出一个正值。

假设我们需要把数 3 存进 4 位。我们用 IEEE-754 的偏移量公式 K=2n11K = 2^{n-1} - 1,在 n=4n = 4 时给出 K=7K = 7。这个偏移量把 16 种位模式划分为范围 [-7; 8],于是位模式 0000 代表 -71111 代表 8。那么,既然 0000-7,我们需要加上多少才能到 3?是 10

看看这给了我们什么:

00002+10102=10102710+1010=310\begin{aligned} 0000_2 + 1010_2 &= 1010_2 \\ -7_{10} + 10_{10} &= 3_{10} \end{aligned}

这表明在偏移量为 7 时,数 3 的二进制存储形式是 1010。同时也应该很容易看出,「加上偏移量以得到该数在移码中的表示」这个操作是从哪来的:

7+10=33+7=10-7 + 10 = 3 \rightarrow 3 + 7 = 10

由此还可推出:既然我们是加上偏移量来得到移码表示,那么要把数转换回原值,就应当把它减掉。

相对于补码的优势

移码相对补码最大的优势在于,它允许按字典序直接比较数值,无需额外操作。举例来说,我们比较用 4 位表示的两个数 3-3。在移码下它们的表示如下:

310=10102310=01002\begin{aligned} 3_{10} &= 1010_2 \\ -3_{10} &= 0100_2 \end{aligned}

逐位比较,计算机从第一位就立刻能看出第一个数更大。而字典序无法套用到以补码存储的数上:

310=00112310=11012\begin{aligned} 3_{10} &= 0011_2 \\ -3_{10} &= 1101_2 \end{aligned}

要比较它们,计算机必须执行额外的操作。

单调有序是这一优势的核心,由它还引出几个重要的推论——合在一起,就解释了 IEEE-754 为何偏偏为浮点阶码选择了移码。

最大的收益在于:一个完整的正浮点数——符号、阶码和尾数一起被当作一个大的无符号整数来读——对它所表示的值而言是单调的。这意味着硬件的浮点比较电路可以直接复用普通的整数比较器。若换成补码,这个性质就会被打破,从而迫使浮点数需要专门的比较逻辑。

这个方案还把保留的位模式整齐地摆在了边界上。全零和全一的阶码字段落在范围的两端,IEEE-754 正是把它们当作特殊值的槽位——底端是 ±0 和次正规数,顶端是 ±∞NaN。偏移量(例如 fp64 的 1023)被挑选成让可用的阶码落在这两个保留端点之间,而次正规数与渐进下溢之所以能平滑工作,正因为它们就住在低端、紧挨着精确的零。若采用补码,最小和最大的位模式反而会落到位模式空间的中间,使得特殊值的检测变得别扭。

所以这个选择并非随意——移码正是能让上述所有性质一次性各就各位的那种表示。

范围总是不对称的

三种方案都有一个共同性质:任何包含零的二进制表示,其范围都是不对称的,而且正好差一个值。这是一个计数上的约束——有 n 位就有 2n2^n 个编码(偶数个),而围绕零对称的范围需要 2k+12k + 1 个编码(奇数个)。所以总有一侧要多带一个值。

三种方案对此的处理各不相同。补码把多出的那个值放在负数一侧——8 位时范围是 -128+127,其中 -12810000000)是唯一没有正值对应者的负数。移码则让偏移量自己决定哪一侧多得一个槽位——IEEE-754 的 K = 2^{n-1} - 1 多给一个正值,而 K = 2^{n-1} 则与补码一致。原码要避开不对称范围,只能靠拥有两个零(+0-0),而那会把算术搞坏——所以实践中,任何能把算术做对的方案都必须接受这份不对称。

这一点在 IEEE-754 的常数里能直接看到:对 fp64 而言,(扣除保留之后)可用的阶码范围是 -1022+1023,依然差着一个。这也正是为什么最大的有限双精度数约为 1.8×103081.8 \times 10^{308},而最小的正规正双精度数约为 2.2×103082.2 \times 10^{-308}——两者的量级接近,但并不相等。

想看四种基本运算(加、减、乘、除)在补码整数上究竟如何进行——以及它们溢出或以其他方式出错时位层面发生了什么——请参阅二进制算术是怎么工作的:补码整数与 IEEE-754 浮点数