计算机怎么用 0 和 1 存下整个世界:从进制到补码再到浮点数
读者已经会手算原码、反码、补码,知道 4 位补码的范围是 −8 到 +7,也知道把 0.1 存进 float 会得到一个近似值。本专题不重述这些「怎么做」,而是回答两个「为什么」:补码为什么要这样设计、为什么 1000 必须是 −8 而不是 −0;以及 0.1 为什么存进去的那一刻就已经是近似值。再往后,前 5 章建立的「数表示」会和内存层次、冯·诺依曼结构、RISC-V 指令字段一起,把「位」接到「整机跑起来」的硬件证据上。
一、位、字节、字:N bits = 2^N 能装下多少东西
先把最底层的事实压稳:bit 是物理的,要么是高电平要么是低电平,软件把它读成 1 或 0。这种二选一的属性之所以被选定,不是因为人天生爱二进制,而是因为它最容易区分、最不容易出错。十进制的「10 个不同符号」能表达一切,是十指手数来的;Mayans 用 base 20,Babylonians 用 base 60,时钟用 base 12 和 base 60。今天计算机的世界里只剩 0 和 1 这两个符号。

图注:两幅掌心朝上的手部简笔画,左手拇指标 1、其余四指 2-5,右手拇指标 10、其余四指 6-9,底部手写「Base-10」。这是 base-10 之所以是十进制的最朴素来源:十根手指 = 十个不同符号。把它换成 base-2 就只剩 0 和 1 两根「手指」。
每个 bit 两种可能,两个 bit 是 $2 \times 2 = 4$ 种,三个 bit 是 $2^3 = 8$ 种,N 个 bit 就是 $2^N$ 种。这个公式是后文所有数字编码的基础:N 位无符号整数能表示 $[0, 2^N - 1]$,3 位补码能表示 $[-4, +3]$,32 位 IEEE 754 float 能表示大约 $2^{32} = 4.29 \times 10^9$ 种不同的 bit pattern(虽然其中很多映射到同一个数值)。
实际工程上不会按「1 bit」去寻址,约定是 1 Byte = 8 Bits,再往上 1 Word = 2 / 4 / 8 Bytes(取决于 CPU 架构)。Patterson & Hennessy 的《Computer Organization and Design》给出最常见的对照:8 bits = 1 byte = 256 个值;32 bits = 4 bytes = 1 word(在 RV32 下);64 bits = 8 bytes = 1 double word(在 RV64 下)。CS61C 课程 notes 还引入一个常用中间单位:nibble = 4 bits = 1 hex digit,这是十六进制和二进制之间转换的桥梁。
「bits 能表示一切」这件事,需要一个具体例子才看得见。下面这张图把 24 位的颜色编码拆成 3 个字节,让「24 bits = 3 bytes」的字节边界变得可数CS61C Bytes.

图注:金黄色色块配文「California Gold 0xFDB515」,下方三个色块分别标 Red (FD) / Green (B5) / Blue (15)。一个 RGB 颜色用 3 个字节存:第一个字节是红色分量,第二个是绿色,第三个是蓝色。每个字节再拆成两段十六进制数字,每段 4 位对应一个 nibble。
到这里,位的物理来源、N 位能表示多少种东西、字节和字的标准切分就讲清楚了。下一节会把这套「按权展开」直接推广到十六进制,并解释为什么 1 hex digit 正好 = 4 bits = 1 nibble,让进制转换这件事变成一个一望可知的对应关系。
二、进制与转换:按权展开 = 余数法 = 同一种换算的两种视角
读者已经会进制转换,本节只做两件事:把「按权展开」和「余数法」明确成同一种换算的两个视角;并为后面补码章的 mod $2^N$ 算术打下一个伏笔。
先看按权展开。十进制 9472 拆开来是 $9 \times 10^3 + 4 \times 10^2 + 7 \times 10^1 + 2 \times 10^0$。每一项都是「数字 × 这一位的权」,权是 base 的若干次方。换成十六进制也一样:$(4 \times 16^3) + (10 \times 16^2) + (12 \times 16^1) + (2 \times 16^0)$ 就是 4AC2₁₆ 的展开式(其中 A=10、C=12 用字母代表 10-15 的数码)。这套公式对任意 base $b$ 都成立:$d_n b^n + d_{n-1} b^{n-1} + \cdots + d_0 b^0$。Patterson & Hennessy 教材把这套公式写成「Positional Number System」,把 $b^n$ 那一项叫「weight」,并据此给出 hex↔binary 4-bit 分组的规则P&H。

图注:十进制数 9472 配角标 ₁₀,四支箭头分别从 thousand's place / hundred's place / 10's place / one's place 指向 9、4、7、2 这四位。这是「按权展开」最朴素的形式:每一位置上的数字乘以这一位的权(10 的若干次方),所有项加起来就是原数。
反过来,从十进制整数换到二进制,就用「余数法」(Patterson & Hennessy 教材里叫 repeated division,课件里叫「leftover algorithm」——课件还专门标注「这不是正式名字」)。核心动作只有一条:反复除以 2,记下余数,余数就是二进制的最低位(LSB),倒过来写就是答案。这套流程在 P&H 教材 Ch.2 §2.3 有完整推导P&H。

图注:三列八行的表,第一列是被除数(155、77、38、19、9、4、2、1),第二列是 ÷2 后的商,第三列是余数。最低位余数 1 用红色高亮,最高位余数 1 用蓝色高亮。右侧反向箭头标「Arrange remainders in reverse order」,底部写出最终结果「155₁₀ = 10011011₂」。这就是余数法的全部流程:8 次除法,8 个余数,倒序拼出 8 位二进制。
按权展开和余数法表面上方向相反,骨子里是同一件事——它们都在做「把一个整数在 $b^k$ 这套基底上展开」的工作。每一次「除以 $b$ 取余数」,数学上等价于「剥掉最低位」;把余数从后往前排,就是把每一位按权展开的数字收集起来。所以无论用哪种视角,结果都唯一确定,并且和「在 base $b$ 下做 mod $b^k$ 运算」是同一回事。这个等价关系会在第 3 章补码的 mod $2^N$ 算术处被回收:补码的 1111 + 1 = 0000,本质上就是 unsigned 整数在 mod $2^4$ 下做加法。
至于十六进制为什么在计算机里好用,根因是 $16 = 2^4$。所以 1 hex digit 恰好对应 4 bits,一个 byte 恰好对应 2 hex digit(比如 0xF3)。下面是教材里最常见的 hex-binary 对照表,把这个对应关系落到 16 行:
| Hex | 0000 | 0001 | 0010 | 0011 | 0100 | 0101 | 0110 | 0111 | 1000 | 1001 | 1010 | 1011 | 1100 | 1101 | 1110 | 1111 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 缩写 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F |
二进制和十六进制互转因此只是「每 4 位二进制换 1 位十六进制」的查表工作,不需要再算一遍余数。
到这里,进制转换就讲完了。读者已经会手算的部分一带而过,重点是把「按权展开 = 余数法 = mod 算术」这个统一视角立起来。下一节进入本专题第一个「为什么」:从 unsigned 到有符号,演进过程中每一种方案到底败在哪里。
三、从无符号到有符号:四种编码方案各自败在哪里
N 位 unsigned 整数干净利落,能表示 $[0, 2^N - 1]$ 的全部整数,硬件做加法、比较、判断大小都直接。但日常的数有负数,怎么把 $[0, 2^N - 1]$ 这条数轴改成左右都能延伸,是这一节的核心。
第一种尝试叫 sign-magnitude(原码):把最高位(MSB)当符号位,0 表示正、1 表示负,剩下的 N-1 位表示绝对值。直觉上很对称,但数轴方向立刻出问题:在 sign-magnitude 下,从 0111(+7)继续加一会变成 1000,按 sign-magnitude 的解释这是 −0;从 1000 再加一会变成 1001,这是 −1。也就是「MSB 翻成 1 之后,数轴方向反转」——按二进制从 0000 数到 1111,数值先升到 +7,再掉到 −0,再降到 −7。这就是 Stanford CS107 课件 slide 6-10 总结的 sign-magnitude 的三大 Con:第一,双零(+0 和 −0 都是合法表示);第二,加法麻烦(符号位不同要做减法、相同要做加法,硬件要分支判断);第三,MSB 被符号位占用,只有 N-1 位在表数值,「牺牲一位」。CS61C notes 把这件事总结成一句话:TANSTAAFL — There Ain't No Such Thing As A Free Lunch,「要表示负数就必须放弃一些正数」CS61C.
为了修数轴方向,第二种尝试叫 one's complement(反码):正数表示和原码一致,负数则是「对应正数按位取反」。这样 −1 表示成 1110、−2 表示成 1101,从 −1 加一会变成 1111……按反码解释这是 −0;继续加一会变成 0000,按反码解释这是 +0。所以双零问题还在,只是数轴方向不再反转了。这个修补看似成功了一半,硬件仍然要做分支判断(或者用「循环进位」的笨办法),浪费了一位编码。
第三种尝试叫 two's complement(补码):正数表示和原码、反码完全一致,负数则是「对应正数按位取反,再加 1」。把这种编码下 4 位能表示的范围画出来,是从 −8 到 +7 的 16 个整数,零只有 0000 一种表示,1000 单独映射到 −8。
| 方案 | 4 位能表示的范围 | 零的种数 | 加法是否需要分支 | MSB 牺牲 |
|---|---|---|---|---|
| unsigned | 0 ~ 15 | 1 | 不需要 | 否 |
| sign-magnitude | −7 ~ +7 | 2 (+0 和 −0) | 需要 | 是 |
| one's complement | −7 ~ +7 | 2 (+0 和 −0) | 需要(循环进位) | 是 |
| two's complement | −8 ~ +7 | 1 | 不需要 | 否 |
这一节故意停在「双零不再、范围不对称」这两个事实层面,不展开补码的几何和代数。下一节才是本专题第一个真正的「为什么」:为什么这个方案偏偏是 −8 到 +7,而不是 −7 到 +8、不是别的区间?为什么 1000 必须是 −8 而不是 −0?
四、补码为什么这样设计:1000 为什么是 −8 而不是 −0
上一节把补码放进了一个优劣对比表,结论是它「不双零、加法直接、范围不对称」。这一节要回答两件更具体的事:为什么 4 位补码的范围偏偏是 [−8, +7] 这个不对称区间、为什么 1000 必须映射到 −8 而不是 −0。回答这两件事需要两个独立的视角,几何的(odometer 里程表)和代数的(mod $2^N$ 算术),下面分别展开。
几何视角:odometer 数轮上的「首尾相接」
先建立一个 wraparound(绕回)的直觉。考虑一个 4 位的 unsigned 计数器,从 0000 一路加 1 加到 1111(十进制 15)。再下一步加 1 会发生什么?1111 + 1 = 10000,但因为只能存 4 位,最高位 1 被丢弃,结果变成 0000。这跟汽车里程表从 99999 跳到 00000 是同一个动作——Stanford CS107 课件第 4 页就把它画成一个 16 格的圆盘,外圈 015、内圈 00001111,盘子转一圈就 wraparoundStanford CS107.

图注:酒红色实线圆环,16 个刻度均匀分布,外圈十进制 0
15、内圈 4 位二进制 00001111,圆心写「4-bit unsigned integer representation」。这是 unsigned 整数的环形几何:1111 再走一格就是 0000,不是 10000。这是「wraparound = mod $2^N$ 算术」的几何锚点。
这同一张圆盘,如果换个角度看(不再把 00001111 解释为 015,而是把「1111 之后接 0000」当成「−1 之后接 0」),就得到了 4 位补码的数轴。把圆环从 0000 和 1111 之间剪开拉直,按补码解释从 1000 到 0111 依次写出来,就是下面这条数轴:

图注:水平蓝轴配 8 个刻度。轴上方二进制标签:1000 / 1001 / … / 1111 / 0000 / 0001 / … / 0111。轴下方橙色十进制标签:−8 / −7 / … / −1 / 0 / 1 / … / 7。底部右侧橙色右箭头,从 −8 指向 +7 一路扫过。标题「Binary odometer」。同一张圆环剪开,按补码解释就得到这条 [−8, +7] 的直线。
课件 slide 39 给的是同一条数轴的「教学版」,配色更醒目,附两条手写标注:

图注:4 位补码数轴(−8 到 +7)。负数部分 1000..1111 配 −8..−1 用红色,正数部分 0000..0111 配 0..7 用绿色。负数区域顶部蓝色文字「...isn't wacky anymore!」(箭头覆盖整段负数),正数区域顶部「The odometer...」。数轴终于「不再奇怪」,因为方向自然、单调递增。
这两张图告诉读者同一件事:4 位补码的 −1、−2、…、−8 这 8 个负数,是 unsigned 0~15 这 8 个大数「循环回 0 时被借走了一圈」的产物。具体来说,1111 在 unsigned 视角是 15,在补码视角是 −1;1110 是 14 / −2;依此类推;1000 是 8 / −8。
把 3 位的情形画成一个环,可以更直接地看到 100 在 −4 的位置是怎么来的:

图注:上半行 000 / 001 / 010 / 011,对应 +0 / +1 / +2 / +3,被红色矩形框圈住;分隔线之下半行 100 / 101 / 110 / 111,对应 −4 / −3 / −2 / −1。右侧虚线箭头从 111 绕回 000,构成一个闭环。3 位补码的范围是 [−4, +3],100 是环上「最大正向步进之后的下一步」,必然落在 −4 而不是 −0。
代数视角:取反加一就是 mod $2^N$ 算术
几何直觉给出「数轴首尾相接、−1 = 全 1、1000 落在 −8」的图景,但「为什么补码就是取反加一」「为什么 1000 必须是 −8」还需要代数。UMD CMSC311 笔记把这套代数写得很干净,下面用 4 位情形走一遍UMD CMSC311.
第一步,定义「取反」:把每一位 0↔1。−1 在补码下要等于「全 1」(1111),因为 1111 + 1 = 10000,丢掉最高位 = 0000,正好绕一圈回到 0。这给出「−1 = 全 1」是唯一合理选择。
第二步,定义「加一」:为什么「取反」之后还要「加一」?因为「取反」给出的是 2^N − 1 − |x|,再 +1 就等于 2^N − |x|。在 mod $2^N$ 算术下,2^N ≡ 0,所以 2^N − |x| ≡ −|x|。也就是说「取反加一」得到的就是 −|x| mod $2^N$。
第三步,证明 1000 必须是 −8:1000 取反是 0111,再加 1 是 1000——回到了自己。这是 mod $2^N$ 算术下「除零以外唯一的固定点」(除了 0000 取反加一得到 0000、1000 取反加一得到 1000 之外,没有别的 N 位串有这个性质)。所以 1000 必须映射到一个非零的数,并且必须满足「自己加自己等于 0 mod $2^N$」,即 $2 \times x \equiv 0 \pmod{2^N}$。在 N 位补码的非零整数里,只有 $-2^{N-1}$ 满足这个条件——它的二进制就是 1000(N-1 个 0 加 1 个 1)。换成 4 位就是 1000 = −8;换成 8 位就是 10000000 = −128;换成 32 位就是 0x80000000 = −2147483648。
这就是 4 位补码范围 [−8, +7] 这个不对称区间的来历:负数端多一个名额(−$2^{N-1}$),正数端少一个名额(+$2^{N-1} - 1$),整个区间凑齐 2^N 个数。
减法变加法:ALU 只需要加法器
最后再讲一件补码的「副产品」:有了 mod $2^N$ 算术,硬件做减法就不需要单独的减法器了。
$$x - y \equiv x + (\sim y + 1) \pmod{2^N}$$
也就是说,$x - y$ 在 mod $2^N$ 下等于 $x + (\sim y + 1)$。所以 ALU 只需要一个加法器,减法就是把减数按位取反再 +1 然后做加法。Stanford CS107 课件 slide 12-19 用一连串 0101 + ???? = 0000 这样的硬件探索题让读者自己凑出取反加一的模式,slide 21-22 给出更显式的 111111₂ + 000001₂ = 000000₂,证明 wraparound = mod $2^N$ 的 carry 行为直接给出正确答案Stanford CS107.
到这里本专题的第一个「为什么」就讲透了:补码不是人为选定的、不是历史偶然的、不是某种拍脑袋的简化。mod $2^N$ 算术是 wraparound 这件事的代数刻画;「取反加一」是 mod $2^N$ 算术下「求相反数」的最简算法;1000 必须映射到 −8 是 mod $2^N$ 算术下「唯一非零固定点」的直接推论。下一节进入 IEEE 754,会看到另一套 mod 算术——偏置编码(bias encoding),和补码是同源的设计思路。
五、IEEE 754 浮点:1+8+23 三段位字段把无限实数装进有限 32 位
前 4 章建立的全是「整数」怎么存。现实里还有 0.1、0.2998×10⁹、$\pi$ 这种带小数点的数。32 位只能存 $2^{32} = 4.29 \times 10^9$ 个不同的 bit pattern,但实数有无穷多个。所以 IEEE 754 浮点的设计目标不是「精确表示实数」,而是「用一种科学的近似策略,让实数轴上足够多的位置都被一个合理的近似值覆盖」。这一节先把「位字段结构」讲清楚;下一节再讲 0.1 为什么存进去的那一刻就已经是近似值。
从十进制科学计数法到二进制科学计数法
读者熟悉的十进制科学计数法长这样:$1640.625 = 1.640625 \times 10^3$。换到二进制,思路完全一样,只是 base 从 10 换成 2,小数点从「十进制点」换成「二进制点」:
$$5.625_{10} = 101.101_2 = 1.01101_2 \times 2^2$$
CS61C 课程 notes 给了一个更具体的例子,把 1.0101₂ × 2⁻³ 的每一部分都标出来:

图注:二进制串 1.0101 在二进制点处被切成两半:左边的 1(mantissa 的 leading digit)和右边的 0101(小数部分)。下方箭头标出 binary point 和 base-2 radix,上方箭头标出 mantissa 和 exponent(−3)。这是从「十进制科学计数法」到「IEEE 754 二进制归一化形式」的过渡视觉。
把这条规则定死:一个 normalized(归一化)的二进制浮点数,长成 $(-1)^S \times (1.M) \times 2^{E'}$,其中 $S$ 是符号位(0 或 1)、$1.M$ 是 mantissa(尾数,1 后面带小数)、$E'$ 是真实的指数CS61C FP.
IEEE 754 三段位字段:1 + 8 + 23
IEEE 754 把这 32 位切成三段来存上面的三个变量。最权威的位字段图是 Wikipedia 给出的IEEE 754:

图注:32 个二进制方块横排,最左边 1 个蓝色方块(符号位 S),中间 8 个绿色方块(指数位 E,值 01111100 = 124),右边 23 个红色方块(尾数位 F,首位 1、余下 0)。这张图对应的真值是 +0.15625:因为 $E = 124$ 意味着 $2^{E - 127} = 2^{-3}$,而 $1.F = 1.01000\ldots_2 = 1.25$,所以值 = $1.25 \times 2^{-3} = 0.15625$。
CS61C 风格的注释版用「颜色 + 字段名」标得更清楚:

图注:水平 32 位矩形被竖线切成三段,最左 1 位标 S(sign,符号位),中间 8 位标 E(biased exponent,偏置指数),最右 23 位标 F(significand,尾数)。左端 bit 31、右端 bit 0 各标一个位索引。这张图是 IEEE 754 三段结构的「官方定义」视觉版。
三段的分工:
- S(1 位,符号位):0 表示正、1 表示负。IEEE 754 把符号位单独拿出来放最左边,硬件做绝对值比较的时候直接丢掉它。
- E(8 位,偏置指数):存的不是「真实指数 $E'$」,而是 $E' + 127$。比如 $E' = 0$ 存成 127(01111111),$E' = -3$ 存成 124(01111100),$E' = 28$ 存成 155(10011011)。这个「加 127」就是偏置(bias)。
- F(23 位,尾数):存的是 mantissa 的小数部分($1.M$ 中的 $M$)。注意 $M$ 里的整数 1 不存——因为归一化数的小数点前永远是 1,没理由浪费一位去存。这叫 implicit leading 1,等于「用 23 位存出 24 位精度」。
为什么指数用「偏置」而不是「补码」?CS61C notes 给了一个直白的理由:偏置编码下,所有 IEEE 754 浮点数的二进制表示可以按 unsigned 整数直接比较大小——指数越大、数值越大;如果指数相同,再按尾数比。两个同样符号的浮点数比较,就退化成两个 unsigned 整数比较。如果指数用补码,那 −1(也就是非常小的负数)的指数位会全 1,看起来反而比 +100 大,比较硬件就要看符号位,复杂得多。IEEE 754 在所有字段里都不用补码,是 deliberate design choice。CS61C notes 把它总结为「biased exponent encoding 是 IEEE 754 的 deliberate design choice,不在任何字段使用 two's complement」CS61C FP.
把一个真值放进三段:c = 0.2998×10⁹ 走一遍
课件 slide 61 把 c = 0.2998×10⁹ 作为例题走完整流程:

图注:水平 32 位矩形被竖线切成三段。红色段 S = 1 位(值为 0,正数),蓝色段 E = 8 位(值 10011011 = 155,对应偏置指数 +127 = 真实指数 28),绿色段 F = 23 位(存尾数的小数部分,最高位 1 被隐去)。三段用不同颜色和箭头对应到下方「Sign bit / Biased exponent (+127) / Magnitude with MSB dropped」的标签。
具体过程:c = 0.2998 × 10⁹ = 2.998 × 10⁸,转成二进制科学计数法大致是 $1.001\ldots_2 \times 2^{28}$。S 存 0;E 存 $28 + 127 = 155$(二进制 10011011);F 存 mantissa 小数部分 0.001...。这就是 IEEE 754 用 32 位表示一个实数的完整动作。
到这里位字段结构就讲清楚了。但有一个奇怪的问题还没解决:为什么 0.1 存进去会变成一串尾巴?这个问题不在三段结构上,而在「二进制有限小数到底能表示哪些数」这件事上。下一节专门讲这件事。
六、0.1 为什么存不进去:二进制有限小数 = 分母是 2 的幂
读者已经知道 0.1 存进 float 会得到一个近似值,但「近似是怎么来的、卡在哪一步」,这一节讲透。根因只有一句话:能用有限二进制小数表示的数 = 分母是 2 的幂的分数。
先看一个能精确表示的:0.625
0.625 = 5/8,分母是 $2^3$,所以在二进制下有限:$0.625 = 1 \times 2^{-1} + 0 \times 2^{-2} + 1 \times 2^{-3} = 0.101_2$。
下面这张图把这件事摆得很清楚——同一组位的两种展开,base 10 和 base 2 都列出来,权一一对应:

图注:两行两列的展开表。左列「Decimal (base 10)」:0.625 = 6 × 10⁻¹ + 2 × 10⁻² + 5 × 10⁻³ = 6 × (1/10) + 2 × (1/100) + 5 × (1/1000)。右列「Binary (base 2)」:0.625 = 0.101₂ = 1 × 2⁻¹ + 0 × 2⁻² + 1 × 2⁻³ = 1 × (1/2) + 0 × (1/4) + 1 × (1/8)。两边的「权」恰好是各自 base 的负幂,分子都是 0、1 之类的小整数。把 0.625 = 5/8 这个分数的分母(8 = 2³)拆成 2 的幂,就直接得到二进制有限展开。
判定规则可以一句话归纳:一个十进制小数能精确存进 float 当且仅当它写成最简分数后,分母是 2 的幂。0.625 = 5/8 ✓(分母 8 = 2³),0.5 = 1/2 ✓(分母 2),0.25 = 1/4 ✓(分母 4),0.1 = 1/10 ✗(分母 10 含因子 5),0.2 = 1/5 ✗(分母 5 不是 2 的幂)。
反例:1/10 在二进制下无限循环
下面这张表是 floating-point-gui.de 给出的核心证据,列出同一组分数在 base 10 和 base 2 下的展开对比:

图注:5 行 6 列的对比表(Fraction / Base / Positional Notation / Rounded to 4 digits / Rounded value as fraction / Rounding error)。第 4 行 1/10 base 2 的展开是 0.00011̄(小数点后第 4 位开始循环),4-digit rounding 得到 0.0001₂ = 1/16,rounding error 是 3/80。第 2 行 1/2 在 base 2 下是 0.1 精确(无 rounding error),作为对照证明「分母是 2 的幂就能精确表示」。
这就是 0.1 的命运:$0.1_{10} = 1/10$,分母 10 = 2 × 5,含因子 5,所以二进制展开是无限循环 $0.0001100110011\ldots_2$(专业记法 0.0001$\overline{1001}$,下划线表示循环节)。IEEE 754 没法存无限位,必须截断到一个有限近似值。具体截到 4 位得到 $0.0001_2 = 1/16$,和真值 1/10 的差是 $3/80$;在 IEEE 754 double 下,截到 53 位有效位,得到的近似值是 $0.1000000000000000055511\ldots_{10}$,和真值仍然差一点点Float Guide.
所以 0.1 为什么存不进去,根因不在 IEEE 754 的「三段位字段」设计,不在「机器精度不够」,而在二进制小数这种表示法先天就只能精确表示分母是 2 的幂的分数。0.1 = 1/10,分母含 5,从写入的那一刻起就已经是近似值。
根因讲完,三个常见坑顺手交代
根因明白了,下面这些常见的浮点坑就有了清晰的解释。
第一个坑:0.1 + 0.2 ≠ 0.3。在 IEEE 754 double 下,0.1 + 0.2 算出来是 0.30000000000000004。这不是加法逻辑出问题,而是 parse time 表示就已经出问题了——0.1 这个字面量被编译/解释器读入时,已经被舍入成 0.10000000000000000555...;0.2 同理被舍入成 0.20000000000000001110...;两个近似值相加,得到的就是 0.30000000000000004。floating-point-gui.de 把这件事明确归类为「parse-time 表示误差,不是计算误差」。
第二个坑:== 不可靠。两个浮点数的「精确相等」要求它们从 parse time 到所有运算中间步骤都没有任何舍入——这在真实代码里几乎不可能。所以代码里比较浮点数应该用「nearly equals」:|a - b| < ε,其中 ε 是你接受的最大误差,按场景自己定。
第三个坑:NaN 和 ±∞。IEEE 754 规定 0 / 0 = NaN(Not a Number)、x / 0 = ±∞(x ≠ 0)。NaN 有个特别奇怪的性质——NaN 和任何数(包括自己)比较都返回 false,包括 NaN == NaN 永远 false。所以 == 不可靠这件事在 NaN 这里尤其严重。NaN 会沿着运算链传播(NaN + 1 = NaN、NaN * x = NaN),所以一旦结果里冒出一个 NaN,后续所有运算结果都是 NaN——这是浮点 bug 排错的一条线索。
到这里本专题第二个「为什么」就讲透了。0.1 不是被算错的,是被表示错的。这个误解一旦拆掉,前 5 章的「数表示」就和工程实践里的实际 bug 链上了。下面三章把视角从「位怎么编码」切到「位住在哪、怎么走」,把这条数据表示的故事接到整机跑起来这件事上。
七、从数据到整机:内存金字塔、冯·诺依曼和一条 add 指令是怎么跑的
前 6 章把位、字节、整数编码、浮点三段结构都讲完了。但这些位住在哪、怎么被读出来、怎么变成一条具体指令在 CPU 上跑起来,本专题前 6 章没有交代。这一章把这条线接上:内存金字塔(位住在哪里)→ 冯·诺依曼存储程序(位和指令怎么共存于同一块内存)→ RISC-V 指令字段(处理器怎么读取这些位)。
内存金字塔:速度、容量、价格的三难权衡
CPU 跑得很快(按 GHz 算,每秒几十亿次操作),但主存(DRAM)慢得多(按 ns 算,比 CPU 慢几十到几百倍)。如果 CPU 每条指令都等主存,整体速度会被主存拖垮。所以现代计算机在 CPU 和主存之间塞了多级缓存(L1、L2、L3),全是 SRAM——比 DRAM 快但贵很多。再往下,主存之外还有本地磁盘、远程存储,越往下越慢、越便宜、容量越大。
课件 slide 70 把这层「位住在哪里」画成一张具体的六层金字塔:

图注:六层金字塔,顶端 Register、向下依次 L1 cache (SRAM, in core) / L2 cache (SRAM, outside core) / DRAM / Local Disk / Remote Storage。左侧双向箭头标「Increase in cost per bit」(向上指)和「Increase in Capacity & Access Time」(向下指)。越往上单位 bit 越贵、越快、容量越小;越往下反之。这是内存层次结构的具体形态。
教材版给一个抽象的 generic 金字塔,把「CPU 顶端、Level 1..n 依次往下、随距离变远访问时间变长」这件事抽象出来P&H Ch.5:

图注:倒三角金字塔图,顶端 CPU,依次 Level 1 / Level 2 / … / Level n。右侧向下箭头标「Increasing distance from the CPU in access time」,底部横向双向箭头标「Size of the memory at each level」。这是教材里对内存层次结构的通用画法,名字不锁定到具体哪一层。
为什么一定要做成金字塔?因为速度、容量、价格三个目标互相打架:要快就要小(SRAM 的每个 cell 要 6 个晶体管,DRAM 只要 1 个 + 1 个电容);要便宜就要大(大容量可以靠磁盘、磁带这类机械或准机械介质堆出来);要容量大就要慢(机械寻道 + 旋转延迟就是几十毫秒)。所以现代计算机不是用「一种又快又大又便宜的存储」解决问题,而是把多种存储叠成金字塔,把 CPU 眼下最需要的少量数据放在金字塔顶端(register、L1),常用但没那么急的放在 L2/L3,主数据放在 DRAM,冷数据下放磁盘——程序靠 locality(局部性)让大多数访问都落在金字塔顶端。
冯·诺依曼:程序和数据共用同一块内存
1945 年之前的计算机(比如 ENIAC)改程序要靠物理拨线或插拔开关,ENIAC 重新配置一次要两三天。冯·诺依曼 1945 年 EDVAC 报告里第一次明确提出「stored-program concept」——程序(orders)和数据共用同一块内存,靠「解释方式」区分哪些 bit pattern 是数字、哪些是指令。原文 §14.1 写得很直白:「CC 接收到的 orders 来自 M,也就是来自存放数值材料的同一处地方」von Neumann 1945.下面是课件对现代计算机的总体结构图:

图注:外层圆角矩形标「Computer」,内部分三栏:左栏 Processor 内含 Control 和 Datapath;中栏 Memory(红色标题突出);右栏 Devices 内含 Input 和 Output。三栏之间没有箭头——这是「现代计算机三大部件」的并列布局,强调它们是协作关系而不是层级关系。
「程序和数据共用同一块内存」具体长什么样?看下面这张内存分块图:

图注:垂直堆叠的内存块示意图。蓝色段(在上)依次 0xde / Instrs / 0xbf;绿色段(在下)依次 0xef / 0xed / Data / ……(省略号占位)。Instrs(指令段)和 Data(数据段)占据同一段地址空间,靠地址范围区分。这是「stored-program」的最简视觉。
这意味着同一段 32 位字(32 bits),可以读出来当数字相加,也可以读出来当 add 指令执行——区别仅在于 CPU 把它送到 ALU 还是送到解码器。同一种 bit pattern、解释方式不同,就是不同的东西。这就把前 6 章讲的「位可以表示数字」和「位也可以表示指令」接起来了。冯·诺依曼 1945 年报告原文 §14.1 写得很直白:「CC 接收到的 orders 来自 M,也就是来自存放数值材料的同一处地方。」
一条 add 指令是怎么编码的:add x5, x6, x7 = 0x007302B3
最后一步:把上面的「位可以是数字」和「位可以是指令」具体落到一条 RISC-V 汇编指令上。RV32I 的 R-type 指令长 32 位,按下面这套字段切分:

图注:顶部蓝色框内是汇编指令
add x5, x6, x7的字段(add / x5 / x6 / x7),用彩色箭头指向下方 32 位机器码的六个字段:funct7 = 0x0(7 位)、rs2 = 0x7(5 位)、rs1 = 0x6(5 位)、funct3 = 0x0(3 位)、rd = 0x5(5 位)、opcode = 0x33(7 位)。右上角写x5 = x6 + x7,点明这条指令的语义。黄色箭头从「Register number 7」指向 rs2(0x7)。
这条指令要求:把寄存器 x6 和 x7 的内容相加,结果写到寄存器 x5。编码字段怎么切:
| 字段 | 位宽 | 值 | 含义 |
|---|---|---|---|
| opcode | 7 | 0110011 (0x33) | 操作大类(R-type,整数寄存器-寄存器运算) |
| rd | 5 | 00101 (0x5) | 目标寄存器 x5 |
| funct3 | 3 | 000 (0x0) | 决定是 add / sub / sll / … |
| rs1 | 5 | 00110 (0x6) | 源寄存器 1:x6 |
| rs2 | 5 | 00111 (0x7) | 源寄存器 2:x7 |
| funct7 | 7 | 0000000 (0x0) | 进一步区分 add (0x0) 还是 sub (0x20) |
把这 6 段拼起来(按 bit 31 到 bit 0 的顺序):
$$0000000\ 00111\ 00110\ 000\ 00101\ 0110011 = 0x007302B3 = 7{,}537{,}331$$
同一个 32 位数字 7,537,331,在 unsigned 视角是 0x007302B3,在指令视角就是 add x5, x6, x7。前面章节讲的所有数表示知识(位、字节、进制转换、整数编码)在这里全部同时生效——这 32 位既是「一段 32 位无符号整数」,也是「一段 RISC-V 指令字段」,只是被 CPU 的不同子系统读到不同地方而已。
下面这张二进制视图把这段 32 位切成 7 段(funct7 / rs2 / rs1 / funct3 / rd / opcode)给你看一遍:

图注:水平 32 位条按 7 段从左到右切成二进制:
0000000/00111/00110/000/00101/0110011,上方覆盖蓝色虚线框。两端标位索引 31 和 0。这是同一条add x5, x6, x7的纯二进制视图,对照上面的字段映射图看就更直观了。
RISC-V 规范把 RV32I 的基础指令格式分成 R / I / S / U 四种。下面是 RVI 规范文档第 2.2 节给出的「四格式总览」,把 R-type 放进整个家族看RVI Spec:

图注:四条水平位字段框堆叠,从上到下分别是 R-Type(funct7 + rs2 + rs1 + funct3 + rd + opcode)、I-Type(imm[11:0] + rs1 + funct3 + rd + opcode)、S-Type(imm[11:5] + rs2 + rs1 + funct3 + imm[4:0] + opcode)、U-Type(imm[31:12] + rd + opcode)。顶部标位位置 31/25/24/20/19/15/14/12/11/7/6/0。R-type 就是上面 add 指令用的格式;I-type 用于立即数指令(比如 addi);S-type 用于 store;U-type 用于长立即数(lui / auipc)。
到这里,「位」就不再是孤立的抽象概念了。位住在金字塔顶端的 register 里、被 RISC-V 指令编码成 32 位字段、经过 ALU 算出来、再写回另一个 register——而这条流水线之所以能跑起来,靠的就是前 6 章建立的「数表示」知识:位表示数字(unsigned、补码、IEEE 754)、位表示指令(R/I/S/U 字段)、位表示字符(ASCII / Unicode)、位表示颜色(0xFDB515)。同一种 bit pattern,解释方式不同,就是不同的东西。
参考文献
本专题引用的核心资料如下,建议深入学习时阅读原文:
- UFUG1601 Intro. CS Computer Organization — Chapter 2 — Prof. WANG Hao (course slides, 2024),用户上传课程材料
- Computer Organization and Design RISC-V Edition: The Hardware/Software Interface (2nd Edition) — Patterson & Hennessy, Elsevier, Dec 2020 — Elsevier
- Computer Organization and Design RISC-V Edition — Ch.2 hexadecimal section (Peter Ashenden adaptation) — P&H 5th-ed slides, 2015 — University of Adelaide (Peter Ashenden mirror)
- Signed Int: Two's Complement — UMD CMSC311 course notes, Charles Lin, 2003 (Wayback snapshot 2017)
- Single-precision floating-point format — Wikipedia, last edit 2026-08 — https://en.wikipedia.org/wiki/Single-precision_floating-point_format
- Stanford CS107 Lecture 03 — Bits and Bytes, Integer Representations (Two's Complement, Odometer, Subtraction by Addition) — Jerry Cain, 2026
- CS 61C "Great Ideas in Computer Architecture" Course Notes — L02 Number Representation — UC Berkeley — https://notes.cs61c.org/content/number-rep/
- CS 61C Course Notes — Floating Point (IEEE 754 Single-Precision): Normalized Numbers — UC Berkeley — https://notes.cs61c.org/content/floating-point/fp-floating-point/
- What Every Programmer Should Know About Floating-Point Arithmetic — floating-point-gui.de — https://floating-point-gui.de/
- First Draft of a Report on the EDVAC — John von Neumann, June 30, 1945 (verbatim typescript, Michael D. Godfrey, Stanford 1992; IEEE Annals 1993)
- The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture (Document Version 20240411) — RISC-V International — https://riscv.org/technical/specifications/