笔者在数年前其实就在计算机组成原理的课堂中学习了这部分内容,但是今日在重新拾起的时候发现自己没有能够真正充分理解这部分内容。故写下这样一篇文章。

我们先问一个问题:计算机中的数究竟是如何被表示的?

进制与位权:同一个数的不同写法#

我们都熟悉十进制,因为人类习惯用十个数字计数。但计算机底层处理的是二进制位。于是,我们需要一种办法,让同一个数能够在不同的表示方式之间自由转换。

先看一个例子:

同一个整数的四种写法
十进制
13
基数 10
二进制
1101
基数 2
八进制
15
基数 8
十六进制
D
基数 16

这四个数完全相等。进制只是书写同一个数量的不同规则。

为什么十六进制会出现字母?因为单个数字符号需要表示 0~15,总共 16 种可能,于是用 A~F 表示 10~15。

那么转换的本质是什么?其实就是每一个位置代表的权重不同。

例如十进制的 123:

$$ 123 = 1 \times 100 + 2 \times 10 + 3 \times 1 $$

二进制也一样,只不过相邻位置的权重不再相差 10 倍,而是 2 倍。

以十进制 13 为例:

$$ 13 = 8 + 4 + 1 $$

二进制各位置的权重从右向左是 1、2、4、8、16……需要哪个权重,就在对应位置写 1;不需要则写 0。

自己构造一个二进制数

点击下面的 0 或 1,切换该位,观察数值怎样变化。

上排:位置权重 下排:该位的值

十进制
13
二进制
00001101
八进制
15
十六进制
D
当前取值 = 8 + 4 + 1 = 13

由此得到最通用的进制转换原则:任何进制的数,都可以通过位权展开还原成十进制。反过来,十进制整数可以通过不断除以目标进制的基数、倒序读取余数来转换。

二进制与八进制、十六进制之间,还有一个更直接的办法。因为:

$$ 8 = 2^3, \qquad 16 = 2^4 $$

因此:

  • 每 3 个二进制位,恰好对应 1 个八进制数字。
  • 每 4 个二进制位,恰好对应 1 个十六进制数字。

例如,把 $11010110_2$ 转换成十六进制与八进制:

转换成十六进制:从右往左,每 4 位一组
1101D
01106
D6

转换成八进制:从右往左,每 3 位一组

0113
0102
1106
326

最左边位数不足时,可以补 0;前导 0 不改变整数的值。

所以:

$$ 11010110_2 = 326_8 = \mathrm{D6}_{16} = 214_{10} $$

这也是十六进制在计算机工程中如此常见的原因:它能把每 4 个二进制位写成 1 个十六进制数字,而且转换不需要复杂计算。

在 Python 中,0b、0o、0x 分别用于表示二、八、十六进制整数字面量。

bit、Byte 与容量单位#

理解了二进制,容量单位就很自然了。

一个 bit(位)能够表示 0 或 1 两种状态。一个 Byte(字节,B)通常由 8 个 bit 组成:

$$ 1\,\mathrm{B} = 8\,\mathrm{bit} $$

于是,一个字节共有 $2^8 = 256$ 种不同的位组合。

容量单位这里有一个历史遗留问题:计算机传统上常用 1024 作为换算倍数,但规范写法把十进制前缀和二进制前缀严格区分开。12

十进制标准二进制标准
1 kB = 1000 B1 KiB = 1024 B
1 MB = 1000 kB1 MiB = 1024 KiB
1 GB = 1000 MB1 GiB = 1024 MiB
1 TB = 1000 GB1 TiB = 1024 GiB

遇到实际存储设备、网络带宽时,要留意使用的是哪套标准。

例如,网络标称 100 Mbps,按照十进制单位换算,理论上相当于 12.5 MB/s;实际传输还会受到协议开销等因素影响。

这里最容易错的地方是:带宽标记里的小写 b 表示 bit,大写 B 表示 Byte。

负数怎样交给加法器#

既然我们已经能够用学会二进制表示数字了,那么接下来就到了运算的部分。

二进制做加法很简单,只要一个加法器处理好进位就好了,但是做减法却不是那么容易。

这正是冯·诺依曼在 1945 年的 《First Draft of a Report on the EDVAC》 第 8.1~8.2 节中讨论的问题。他提出了一个至今仍然重要的思路:让负数采用某种特殊的二进制表示,使加减法能够沿用相同的加法机制。

原码与反码#

最直观的想法是给数字加上正负号,

假设我们只有 4 个二进制位,希望表示 $+3$ 和 $-3$。

很自然地,我们会把最高位拿来记录符号,其余三位记录绝对值。

+3
0011
−3
1011
着色位置表示符号:0 为正,1 为负。这就是原码的思路。

这种表示称为原码(Sign-Magnitude)。

它非常符合直觉。然而,你尝试把 $+3$ 和 $-3$ 直接送进普通二进制加法器,就会发现:

$$ \mathtt{0011}_2 + \mathtt{1011}_2 = \mathtt{1110}_2 $$

按照原码解释,1110 是 $-6$,而我们期待的结果显然是 0。

也就是说,普通加法器根本不认识我们人为规定的正负号。

原码还有一个小麻烦:0000 表示 $+0$,1000 表示 $-0$。我们浪费了一个位组合,表达了两个数值相同的零。

于是,接下来的设计目标就清楚了:能不能重新安排负数的编码,让电路按照普通二进制加法计算,结果依然正确?

假设仍然使用 4 位。我们希望表示 $-3$,可以从 $+3$ 的 0011 出发,将每个二进制位翻转:

$$ \mathtt{0011} \xrightarrow{\text{逐位取反}} \mathtt{1100} $$

这就是反码(One’s Complement)。

它看起来像一个奇怪的小技巧,但有一个好处:正数与负数之间只需要翻转所有位。加法也比原码方便了许多,不过需要处理最高位产生的循环进位(End-around Carry),而且仍然存在 0000 和 1111 两种零的表示。

位模式与数值解释#

那么什么是循环进位呢?为什么1100不能同时代表12或者-3呢?

在开始讲反码的循环进位之前,我们先补充一个前提:同一串二进制位,是可以按照不同规则解释成不同的数。计算机并无法只凭这串数字,判断你想表达什么。

以 1100 为例,我们假设它占据 4 个 bit:

解释规则1100 代表的数
无符号整数12
原码−4
反码−3
补码−4

四种答案都对。因为 1100 本身只是一个位模式(Bit Pattern),它没有自带“我是 12”或者“我是 −3”的标签。

这很像内存里的 01000001:你可以把它解释为整数 65,也可以按照 ASCII 解释为字符 A。这两者并不矛盾。

那么,计算机究竟怎么知道该用哪一种解释?这通常是由程序的数据类型、运算指令和约定决定的。例如在 C 语言中,uint8_t 表示 8 位无符号整数,而 int8_t 表示 8 位有符号整数。即使它们存储了相同的 8 个 bit,程序对它们的解释也可以不同。

有了这个前提,再来看反码的循环进位,就容易理解多了。

我们用反码来计算 $5 - 3$。计算前先规定:下面的数都采用 4 位反码表示。

正数 5 写成 0101,负数 $-3$ 写成 1100。

直接相加
进位回加
进位 1 回加到最低位左侧进位 1 沿下方箭头绕回右侧四位结果 0001 的最低位。10001
0001 + 0001 = 0010
超出 4 位的进位 1,回加到最低位;0010 表示十进制的 2。

这就是循环进位(End-around Carry):不丢弃最高位产生的进位,而是绕回最低位再加一次。这是反码加法的规则。

从数轴到环#

你可能会追问:为什么要做这么奇怪的事情?

我觉得我们需要回到一个更加基本的问题了:

如果一台计算机只能保存有限个数字,那么无限延伸的整数数轴,该怎样装进这个有限的空间?

我们仍然只使用 4 个二进制位。从 0000 到 1111,一共只有 $2^4 = 16$ 种位模式。如果让每一种编码各占一个位置,应该有 16 个位置。先记住这个数量。

当然,我们没办法让有限个编码与无限多个整数一一对应。但可以换一个想法:允许一些不同的整数,共用同一个位置。

先看普通数轴上的 -3、12 和 27。它们是三个不同的数,但相邻两个数恰好都相差 15。

为什么这里挑中了 15?回头看看刚才的反码:0011 逐位取反得到 1100。如果暂时把它们按无符号整数读取,前者是 3,后者是 12,相加刚好是 15。

这不是巧合。任何一个 4 位编码,与它逐位取反后的编码相加,都恰好凑成 1111,也就是 15。因此,负数 $-3$ 的反码按无符号读取就是 $15 - 3 = 12$;负数 $-5$ 则是 $15 - 5 = 10$。

现在,我们试着规定:数轴上相差 15,或者相差 15 的整数倍的位置,都归为同一类。

这样,-3、12、27 就会归到一起。因为:

$$ -3 = -1 \times 15 + 12, \qquad 12 = 0 \times 15 + 12, \qquad 27 = 1 \times 15 + 12 $$

它们除以 15 后,都可以留下同一个余数 12。数学上把这种关系称为同余,写成:

$$ -3 \equiv 12 \equiv 27 \pmod{15} $$

这句话不是说三个整数本来就相等,而是说:如果只关心它们除以 15 的余数,就不再区分它们。加减法也保留这种关系。例如,-3 和 12 同余,那么分别加上 5,得到的 2 和 17 仍然同余。

接下来,再把数轴卷起来:0 与 15 重合,1 与 16 重合,2 与 17 重合……所有相差整整 15 的位置,都叠在一起。无限延伸的数轴,就可以用 0~14 这 15 个位置组成的环来表示。

从数轴到模十五的环四位二进制有十六种位模式。按模十五归类后,环上只有十五个位置:负三、十二和二十七共用位置十二,编码0000和1111共用位置零。015相差 15相差 15−31227
4 位:16 种位模式
数轴上的 −3、12、27,相邻两点相差 15。

在这个环上,从 0 往回走 3 步,和向前走 12 步,会到达同一个位置。这时,让 1100 承担 $-3$ 的角色,就有了一个清楚的解释:它的无符号值 12,与 $-3$ 在模 15 下本来就属于同一类。

等一下:刚才明明说,4 位二进制有 16 种位模式,为什么图里的环只有 15 个位置?少的那一个到哪里去了?

我们把这 16 种编码,从 0000 到 1111,逐个放到环上。前 15 种编码按无符号读取是 0~14,恰好各占一个位置。最后还剩 1111,它按无符号读取是 15。但按照刚才选定的模 15 规则:

$$ 0 \equiv 15 \pmod{15} $$

所以,1111 又落回了 0000 已经占据的零的位置。没有少一种编码,而是两种编码共用了同一个位置。在反码中,0000 是正零,1111 是负零。正零和负零,就这样在环上重合了。

因此,这个环的 15 个位置,来自我们选择的模 15 规则。4 位的 16 种编码依然都在,只是 0000 与 1111 在这套规则下不再占据两个不同的位置。

循环进位为什么需要#

现在回到 $5 - 3$。我们从 $-3$ 的位置出发,每次加 1,一共走 5 步。下面的环标的是余数位置,可以对照每一步的实际数值和反码编码:

0 / 5
从负三出发,向前走五步依次经过余数位置十二、十三、十四、零、一、二。零处保留负零编码1111;跨过零后,将高位进位加回最低位。
−3 + 0 = −3;反码 1100

走到第三步时,我们到达零,编码是 1111,也就是负零。再向前走一步,数值应该从零来到 $+1$,编码应该是 0001。

但普通二进制加法器会算出:

$$ \mathtt{1111} + \mathtt{0001} = \mathtt{1\ 0000} $$

如果直接丢掉最左边的进位,只留下 0000,结果还是零。我们从负零走到了正零,却没有到达本该到达的 $+1$。

为什么会差这一步?因为我们现在按照 15 来分组,而超出四位的那个进位,代表的数值是 16:

$$ 16 = 15 + 1 \equiv 1 \pmod{15} $$

所以,那个进位仍然相当于最低位的 1,不能直接舍弃掉。把它加回去,才得到 0001。这就是循环进位的由来。3

同样,前面的 $5 - 3$,在位串相加时得到的是:

$$ 5 + 12 = 17 = 16 + 1 \equiv 1 + 1 = 2 \pmod{15} $$

对应到电路上,就是先算出 1 0001,再把高位的进位 1 加回低四位,得到 0010。循环进位是在让普通二进制加法器,按照模 15 的规则完成这次反码运算。

补码与取反加一#

既然如此,我们能不能让负数的编码也按照模 16 运算,省掉循环进位这一步?

仍然只用 4 位,仍然看 $-3$。我们希望它与 $+3$ 相加后,能够回到零。

在刚才的模 15 规则下,3 加上 12,得到 15,回到零。所以反码用 1100 表示 $-3$。

如果改成模 16,那么 3 应该加上多少,才能回到零?

答案是 13:

$$ 3 + 13 = 16 \equiv 0 \pmod{16} $$

因此,我们把 $-3$ 的编码改成 1101。在 16 个位置的环上,它正好位于从零向后退三步的地方。

再计算一次 $5 - 3$:

$$ \mathtt{0101} + \mathtt{1101} = \mathtt{1\ 0010} $$

这一次,直接丢掉超出四位的进位,剩下的 0010 就是我们期待的 2。不需要再回加一次。

这种表示,就是补码(Two’s Complement)。

现在回头看,两种编码恰好相差 1:

$$ \begin{aligned} \text{反码:}&\quad 15 - 3 = 12 = \mathtt{1100}_2 \\ \text{补码:}&\quad 16 - 3 = 13 = \mathtt{1101}_2 \end{aligned} $$

所以,教材中那句“取反加一”,就有了原因:对负数 $-x$,其中 $x$ 是它的绝对值,逐位取反先得到 $15-x$;再加 1,才得到与 $x$ 凑成 16 的编码。推广到 $n$ 位,就是用 $2^n-x$ 表示范围内的非零负数 $-x$,并只保留这 $n$ 位。

切换下面的反码与补码,仍然只计算同一个 $5 - 3$:

同一个五减三,两种编码规则反码从余数十二走五步到二,需要回加进位;补码从余数十三走五步到二,只保留低四位即可。
−3:1101 → 结果:0010(2)
舍弃进位:1 0010 → 0010

补码让编码与保留低四位的加法遵循同一个周期。在这个周期里,16 本来就与 0 同余,因此高位进位可以直接丢弃。

那原来的两个零,现在变成什么了?

正零还是 0000。把旧反码的负零编码 1111 也加上 1:

$$ \mathtt{1111} + \mathtt{0001} = \mathtt{1\ 0000} \quad\longrightarrow\quad \mathtt{0000} $$

只保留四位之后,它也回到了 0000。两个零合并了。在模 16 的环上,16 种位模式可以各占一个位置,不再需要让两个编码共用零的位置。

−8 与有符号溢出#

现在又有一个问题:原来反码只能表示 15 个不同的整数,补码能表示 16 个。多出来的那个数是谁?

我们先看看原来的负数编码去了哪里:

数值反码补码
−111101111
−211011110
−311001101
………
−710001001

非零负数的编码都比原来多了 1。原来表示 $-7$ 的 1000 变成了 1001,于是,1000 空出来了。

如果我们仍然让最高位为 0 的编码表示非负数,那么 0000~0111 已经表示了 0~7。最高位为 1 的编码则表示负数;其中 1001~1111 已经表示了 $-7$~$-1$。剩下的 1000,正好可以接在 $-7$ 前面,表示 $-8$。

在环上,它还有一个特别的性质:从零出发,朝任一方向走半圈,都能到达它。

顺时针走 8 步,和逆时针走 8 步,抵达同一个余数位置,因此:

$$ +8 \equiv -8 \pmod{16} $$

按照补码最高位为 1 表示负数的解释规则,1000 被解释为 $-8$。

于是,4 位补码的范围就完整了:0000~0111 表示 0~7,1000~1111 表示 $-8$~$-1$。一共 8 个非负数、8 个负数。因为零占据了非负数中的一个位置,正负两端的绝对值就不对称了。

推广到 $n$ 位,补码整数的范围是:

$$ -2^{n-1} \le x \le 2^{n-1}-1 $$

那如果我们偏要用 4 位表示 $+8$,会发生什么?

+8
四位补码的正对面是负八十六格环按补码标为零到七、负八到负一。正八与负八走相反方向的半圈后共用编码1000;该编码按补码读取为负八。
保留低四位 1000 → 按补码读取为 −8
+8 超出了 −8~7 的范围,不能用 4 位补码表示。

当输入 $+8$ 时,保留低四位确实会留下 1000,但按补码读取仍然是 $-8$。位模式没有办法再替我们区分“想表示的 $+8$”和“能够表示的 $-8$”。前者已经超出了范围。

这个正对面的位置,还解释了一个容易让人困惑的现象:对 $-8$ 的编码 1000 取反加一,会得到什么?

$$ \mathtt{1000} \xrightarrow{\text{取反}} \mathtt{0111} \xrightarrow{\text{加一}} \mathtt{1000} $$

结果居然还是它自己。因为在模 16 下:

$$ 8 + 8 = 16 \equiv 0 \pmod{16} $$

从环上的位置看,8 的加法逆元就是它自己。但在普通整数中,$-8$ 的相反数应该是 $+8$,而 $+8$ 无法用 4 位补码表示。所以,这里发生了有符号溢出。

这里 0111 + 0001 = 1000 并没有产生第五位的进位。高位进位与有符号溢出不是一回事:前者看二进制加法是否超出位宽,后者看数学结果是否超出所选编码能够表示的范围。

到这里,再把三种编码放在一起:

4 位表示原码反码补码
−3 的编码101111001101
零的编码0000、10000000、11110000
整数范围−7~7−7~7−8~7
加减法处理需区分符号与绝对值需回加高位进位保留固定位宽即可得到模运算结果

三者改变的是编码与整数之间的对应关系,补码让这种对应关系与加法器保留低位的规则一致。

从定点到浮点#

到这里,我们已经能够表示并计算正负整数了。但还有一种常见的数没有位置:小数。比如,2.5 应该放在哪里?

定点数:固定刻度的取舍#

先暂时不考虑符号,只看 4 位无符号整数。我们有 16 种编码,原来把它们解释为 0~15。现在约定:每个编码代表的整数都除以 4。

于是,0000 表示 0,0001 表示 0.25,0010 表示 0.5……1010 就能表示 2.5。

小数出现了,但代价也出现了:原来最大能表示 15,现在只能表示到 3.75。编码数量没有增加,只是每个单位变小了。

0.25
十六种编码,对应十六个等距位置改变单位时,数轴上十六个编码的数值一起缩放。标出的编码1010始终是第十个单位。1010 → 2.5
间距 0.25 · 上限 3.75

这就是定点数的思路:预先约定每一位的权重,小数点的位置固定。把单位调大,能覆盖的范围就变大,但相邻位置也会变远;把单位调小,刻度变细,范围又跟着缩小。

有效数字与指数#

那么,我们能不能让不同数量级上的数,使用不同大小的刻度?

为了先看清刻度的布局,我们只约定每个数保留三位二进制有效数字,指数怎样存储稍后再说。这里的三位包括小数点前的一位,所以它们可以是 1.00、1.01、1.10、1.11。

二进制小数点右边的权重依次是 $1/2$、$1/4$……例如:

$$ 1.10_2 = 1 + \frac{1}{2} + 0\times\frac{1}{4} = 1.5 $$

把这四个有效数字分别乘以 $2^2$ 和 $2^3$,就得到:

数量级可以表示的数相邻间距
$1.xx_2 \times 2^2$4、5、6、71
$1.xx_2 \times 2^3$8、10、12、142

有效数字的位数没有增加,但整组刻度放大了一倍。于是范围扩大了,间距也扩大了。

绝对间距与相对间距#

先别急着把它叫作“精度”,我们实际量一下两个相邻位置之间的距离。

选中 6,它右侧的下一个可表示数是 7,中间的距离是 $7-6=1$。这个距离,就是这里说的绝对间距,记作 $\Delta$。选中 12,下一个可表示数是 14,所以绝对间距变成了 $14-12=2$。

距离确实变大了,但 1 对于 6 有多大,和 2 对于 12 有多大,是另一个问题。

如果把从 0 到 6 的长度当作 1 个单位,那么原来长度为 1 的空隙,就只占这个单位的 $1/6$。对 12 做同样的事,长度为 2 的空隙也只占 $2/12=1/6$。

这就是相对间距:用间距除以当前数值的大小。这里先只看正数;同时讨论负数时,参考长度应写作 $|x|$,因此比例是 $\Delta/|x|$。

下面两图观察的是同一对相邻数。左图保留原数值,右图把所有距离除以当前的 $x$,于是当前位置固定为 1,下一个位置变成了 $\text{next}/x$。蓝色括号量出的,就分别是 $\Delta$ 和 $\Delta/x$。比较 6 与 12:在左图看距离怎样改变,在右图看它占当前数值的比例有没有改变。

6
原数值
在原数轴上量两个相邻数的距离灰色括号从零到选中数,表示参考长度x。蓝色括号连接选中数与右侧下一个可表示数,表示绝对间距。
Δ = 7 − 6 = 1
把 x 当作 1 个单位
全部除以x后,量同一段间距的比例当前位置变成一,下一个位置变成next除以x,两者之间蓝色括号的长度就是相对间距。左右图使用不同单位。
1 / 6 ≈ 16.7%

再往右拖一拖:在 4、5、6、7 这一组里,绝对间距一直是 1,而相对间距依次是 $1/4$、$1/5$、$1/6$、$1/7$。选中 8 时,下一个位置变成 10,绝对间距跳到 2,相对间距也回到 $2/8=1/4$。

因此,这套刻度的相对间距不是处处相同。它在一个数量级内部逐渐减小,跨到下一个数量级时跳回去,然后重复同样的变化。这才是我们说“不同数量级保留差不多的相对分辨率”的意思。

这就是浮点数的核心想法:用有效数字确定尺度内部的位置,再用指数决定这一组刻度的大小。

规格化与末位的权重#

这很像十进制的科学记数法。例如,12300 可以写成 $1.23\times10^4$。在二进制里,6 则可以写成:

$$ 110_2 = 1.10_2 \times 2^2 $$

不过,同一个数也可以写成 $11.0_2\times2^1$,或者 $0.110_2\times2^3$。如果都允许,就会出现多种编码表达同一个数的情况。

所以,我们先规定一种统一写法:让有效数字落在 $[1,2)$,写成 $1.xxx\ldots_2\times2^e$。这叫规格化。在一个格式允许的指数范围内,按这种形式表示的数称为正规数。数值没有改变,我们只是统一了小数点的位置,让有限的有效数字位得到充分利用。4

刚才量出的间距,也可以从最后一位的权重直接读出来。三位有效数字中,1.00 变成 1.01,只增加了 $1/4$;整组乘以 $2^e$ 后,最小增量就是 $2^e/4$。所以指数为 2 时,间距是 1;指数为 3 时,间距是 2。

如果一共有 $p$ 位有效数字,小数点后就有 $p-1$ 位,最后一位的权重是 $2^{-(p-1)}$。乘上尺度 $2^e$ 后,在 $[2^e,2^{e+1})$ 内得到:

$$ \Delta = 2^e \times 2^{-(p-1)} = 2^{e-(p-1)} $$

常说的一个 ULP,可以从这一数量级中最后一位的位权来理解。它衡量的是可表示位置之间的步长,后面讨论实际计算误差时,还需要区分两者。

舍入:落在刻度之间#

接下来,问题出现了。仍然只保留三位有效数字,暂时把可用指数定为 $-2$~3。前面看到了,在 $[8,16)$ 这个区间,我们只能表示 8、10、12、14。

那 9 放在哪里?

如果计算 $8+1$,两个操作数都能精确表示,数学结果 9 也没有超出这套格式的数值范围,但它偏偏不在刻度上。存回去时,只能选一个附近的可表示数。

这就是舍入。

IEEE 754 默认使用最近值舍入;如果恰好位于正中间,就选择保留后最低二进制有效位为 0 的一侧,也叫“取偶数”。5

对于 9,两个候选是:

$$ 8 = 1.00_2\times2^3, \qquad 10 = 1.01_2\times2^3 $$

9 恰好居中,而 8 的有效数字末位为 0,所以这套三位精度下的 $8+1$ 会舍入到 8。换成 $10+1$,11 同样居中,但会选择 $1.10_2\times2^3=12$。

八到十四之间只有四个可表示数刻度为八、十、十二、十四。细虚线是数学结果,蓝点是舍入结果;居中时按最低二进制位取偶。9
9 → 8
正中间 · 最低有效位取偶

这与前面的整数加减法有一个重要区别:整数在结果没有越界时可以保持精确;浮点数即使结果仍在范围内,也可能落在两个刻度之间。

对于 IEEE 754 规定的基本运算,结果应当等价于先算出精确的数学结果,再按照目标格式和指定规则舍入。这叫正确舍入。它规定的是最后应该得到什么结果,并不要求硬件真的先保存一个无限精度的中间数;电路怎样高效地得到这个结果,是另一层实现问题。5

现在可以把“间距”和“误差”分开了。8 与 10 之间的绝对间距是 2;把 9 舍入到 8,这次的绝对误差却是 $|8-9|=1$。以精确数学结果 9 为参考,相对误差是 $1/9$。如果输入恰好就是可表示的 8,舍入误差为零,但它右侧的刻度间距仍然是 2。

间距描述这把刻度尺能分多细,误差描述某一次舍入偏离了多少。

次正规数:零附近怎么办#

现在再看看零附近。规格化要求有效数字以 1 开头;如果最小指数已经是 $-2$,最小的正规数就是:

$$ 1.00_2\times2^{-2}=0.25 $$

那么,0 与 0.25 之间怎么办?如果继续要求以 1 开头,这一段就没有非零的数可以放进去。

我们可以把指数停在 $-2$,允许有效数字改成以 0 开头:

表示数值
$0.00_2\times2^{-2}$0
$0.01_2\times2^{-2}$0.0625
$0.10_2\times2^{-2}$0.125
$0.11_2\times2^{-2}$0.1875
$1.00_2\times2^{-2}$0.25

这样,零与最小正规数之间就补上了几个位置,相邻间距始终是 0.0625。其中补在 0 与 0.25 之间的三个非零数——0.0625、0.125、0.1875——称为次正规数(Subnormal);0.25 本身仍是最小正规数。

我们已经知道怎样量两种间距了。再选中最小正规数 0.25,它右边是 0.3125,绝对间距是 0.0625,占当前数值的 $1/4$。如果改选最小非零数 0.0625,它右边是 0.125,绝对间距还是 0.0625,却已经和当前数值一样大。

下面把零附近单独展开。往左拖,观察左边的蓝色间距保持不变,右边把当前数值当作 1 个单位后,同一段间距却越来越长。

0.25
原数值
零附近,相邻间距始终为十六分之一选择0.0625、0.125、0.1875或最小正规数0.25。灰色参考长度变化,蓝色绝对间距保持不变。
Δ = 0.3125 − 0.25 = 0.0625
把 x 当作 1 个单位
数值越小,同一间距所占的比例越大当前位置化为一。相对间距从最小正规数处的四分之一,增大到最小非零数处的一。
0.0625 / 0.25 = 25%

指数已经不能再小了,绝对间距就停在 0.0625,不再随着数值一起缩小。从最小正规数向零移动,选中数依次变成 $4/16$、$3/16$、$2/16$、$1/16$,相对间距则依次变成 $1/4$、$1/3$、$1/2$、1。

这才是这里“相对精度逐渐减少”的具体含义:刻度没有变粗,但它相对于当前数值已经越来越大。这种用次正规数继续靠近零的安排,称为逐渐下溢。到零点时,零与最小正数的间距仍然可以量出,但相对间距的分母为零,不再定义。6

另一端也有边界。当前这套规则最大的正规数是 $1.11_2\times2^3=14$。在默认舍入模式下,足够大的结果会溢出为无穷大;而 $0/0$ 这类无效运算,则需要 NaN 来标记。

IEEE 754:把规则写进 bit#

到这里,我们已经决定了数值刻度怎样安排、落在刻度之间时怎样舍入,以及边界怎样处理。现在才把这些规则写进 bit。

我们造一个 6 位的玩具浮点格式:1 位符号 S、3 位阶码 E、2 位尾数字段 F。

规格化后的有效数字始终以 1 开头,所以对正规数,这个 1 可以不存,读取时再补回来。实际保存的小数部分,就是尾数字段。例如,完整有效数字是 1.10,字段 F 只需要保存 10。因此,两位尾数字段加上隐含的前导 1,得到三位有效精度。

指数可能为负,保存时我们给它加一个固定的偏置。玩具格式取偏置 3,即普通阶码的实际指数是 $e=E-3$。这里的 E、F 都先按无符号整数读取。

再把阶码的两个端点留出来:E=000 用于零和次正规数,E=111 用于无穷大和 NaN。剩下的 E=1~6,正好对应刚才使用的指数 $-2$~3。

对于次正规数,前导位改为 0,指数固定为最小正规指数 $-2$。

例如,$6=1.10_2\times2^2$,所以 S=0,E=$2+3=5$,即 101,F=10。最终编码就是 0 | 101 | 10。

符号 S
阶码 E
尾数字段 F
6正规数
1.10₂ × 2²

这里的符号 S 单独决定正负。

把同样的思路放进真正的 binary32,字段分配就是:

字段位数作用
S1符号
E8阶码,偏置为 127
F23尾数字段,正规数另有隐含前导 1

于是,正规数有 24 位二进制有效精度,其数值为:

$$ x=(-1)^S\left(1+\frac{F}{2^{23}}\right)2^{E-127},\qquad 1\le E\le254 $$

E=0 时,前导位改为 0,实际指数固定为 $-126$;E=255 则留给无穷大和 NaN。7

阶码 E尾数字段 F含义
00+0 或 −0,由 S 决定
0非零次正规数
1~254任意正规数
2550+∞ 或 −∞,由 S 决定
255非零NaN

比如,binary32 的最小正规数是 $2^{-126}$,最小正次正规数是 $2^{-149}$。零附近的最小间距也因此是 $2^{-149}$。

不同格式改变的是这份位数预算:指数位多,通常能覆盖更多数量级;有效数字位多,同一数量级内的刻度就更细。常见的 binary16、binary32、binary64,正规数分别有 11、24、53 位二进制有效精度。

浮点精度与误差#

刚才我们已经在局部量过两种距离:相邻刻度之间的绝对间距 $\Delta$,以及它占当前数值大小的比例 $\Delta/|x|$。现在把同样的测量铺到整个有限数值空间,才得到下面两条曲线。

从局部刻度到全景曲线#

全景图用对数坐标压缩很大的跨度,适合看整体趋势。先看平滑趋势,再切换真实间距,回看刚才量出的平台、台阶和比例变化。左右两侧按绝对值所在区间的步长对称展示。

浮点数的绝对间距与相对间距全景预览

浮点数的刻度
绝对间距 Δ2⁻¹⁴⁹
绝对间距随数值绝对值增大而增大,零附近为恒定平台。
相对间距 Δ / |x|零点未定义
相对间距在正规数区域近似稳定,在次正规数区域增大,零点留空。
0
分段对数横轴 · 零附近已展开冷色:间距小 暖色:间距大
读图方式

纵轴按 2 的幂显示。平滑模式展示连续上包络;真实模式保留数量级边界的跳变。游标读数始终取实际可表示数的区间步长。左右两侧按绝对值所在区间对称计算,最大有限值取内侧间距。

在绝对间距这张图里,数值的绝对值越大,刻度通常越稀;到次正规数区域,间距不再缩小,而是形成一块平台。

相对间距则不同。正规数区域中,数值与间距随着尺度一起伸缩,比例维持在一个较小的范围内。binary32 的区间步长与数值之比,大致介于 $2^{-24}$ 与 $2^{-23}$ 之间。真实曲线会在每个数量级内部缓慢下降,再在边界处跳回去,而不是完全水平。

进入次正规数区域后,绝对间距固定了,数值却还在变小,所以相对间距越来越大。到最小正次正规数,数值本身就等于一个步长,两者的比值是 1。零点的这个比值没有定义,因此图中单独留空。

这也解释了为什么只说“浮点数精度不够”往往不够准确:我们需要分清,是绝对间距太大,还是相对于当前数值,能分辨的差别已经太粗。

输入误差与运算误差#

有了这套刻度,再看实际程序中常见的误差,就容易区分它们的来源了。

第一种,是输入本身就落不到刻度上。例如十进制的 0.1:有限位二进制小数的分母只能是 2 的幂,而 $0.1=1/10$ 的分母里还有因子 5,所以它无法用有限位二进制精确表示,读入时就需要舍入。

第二种,是输入都在刻度上,运算结果却不在。刚才的 $8+1$ 就是这样。在真正的 binary32 中,也有一个同类例子:$2^{24}=16777216$ 可以精确表示,但从它向更大的数走,相邻间距已经是 2。

所以,把加法结果按默认规则舍入回 binary32,会得到:

$$ \operatorname{fl}_{32}(16777216+1)=16777216 $$

如果每一步都写回这种精度,计算顺序也可能影响结果。例如先把 1 加到 16777216 上,再减去 16777216,会得到 0;先计算 $1-16777216=-16777215$,这个中间结果可以精确表示,再加上 16777216,就会得到 1。

同样的三个操作数,只调整了括号,结果就不同了。舍入可能丢掉一部分信息,后面的运算只能从已经存下来的值继续进行。5

回到最初的问题#

回到文章最初的问题:计算机究竟怎样存储数字?

它保存的是有限的位模式。我们为这些状态分配数值,再规定它们怎样参与运算。补码让有限位宽的加减法与模运算对应起来;浮点数则在不同数量级上安排不同密度的刻度,并用舍入处理落不到刻度上的结果。

很多看似奇怪的现象,也因此有了来处:最小负数求相反数后仍是自己,大数加一后没有变化,调整括号却得到不同结果。

对笔者而言,这次重新拾起这些内容,最有价值的收获,是那些曾经分别记忆的公式终于连成了一条线:我们怎样用有限的状态,尽可能保留需要的数学信息。


  1. NIST,Definitions of the SI units: The binary prefixes。 ↩︎

  2. NIST,NIST Guide to the SI, Chapter 4: The Two Classes of SI Units and the SI Prefixes。十进制前缀 kilo 的符号为小写 k,因此表中使用 kB。 ↩︎

  3. RFC 1071:Computing the Internet Checksum,附录收录的 William W. Plummer《TCP Checksum Function Design》(1978)用 $R \equiv 1 \pmod{R-1}$ 解释回卷进位。本文用四位例子中的 $R=16$ 展示同一关系。 ↩︎

  4. William Kahan,Lecture Notes on the Status of IEEE Standard 754 for Binary Floating-Point Arithmetic(1997),其中 Representable Numbers 与 Encodings, Span and Precision 两节从可表示数集合讨论精度、范围和编码。 ↩︎

  5. David Goldberg,What Every Computer Scientist Should Know About Floating-Point Arithmetic(1991),参见 Relative Error and Ulps、Exactly Rounded Operations;链接为 Oracle 获授权的重印版。 ↩︎ ↩︎ ↩︎

  6. Oracle,Underflow,讨论逐渐下溢及次正规数区域的固定间距。 ↩︎

  7. Oracle,IEEE Formats,参见 Single Format、Double Format 中的字段分配与数值解释。 ↩︎