浮点数是《深入理解计算机系统》(CSAPP)中公认的难点。看数学公式和抽象概念很容易绕晕,真正理解它的唯一捷径就是直接去算比特位。
本教程抛弃模糊的比喻,直接切入 32 位物理内存,带你手推二进制位模式。我们将通过实际的运算例子,一步步拆解
第一部分:前置知识 —— 二进制小数的局限与浮点数的诞生
要理解复杂的浮点数标准(IEEE 754),我们必须先搞清楚两件事:二进制如何表示小数,以及为什么我们不能直接固定小数部分的位置。
1.1 二进制小数的表示
在日常使用的十进制中,小数点左边的数字权重是等,而小数点右边的数字权重是
等(即
)。
二进制小数的逻辑完全一样,只是基数从变成了
。
小数点左边的权重是
小数点右边的权重是
具体对应的值如下:
实例计算:将二进制101.11转换为十进制
我们将每一位乘以它的权重,然后相加:
整数部分 (
101):5
小数部分 (
.11):0.75
组合结果:
5.75
反向实例:将十进制 5.75 转换为二进制
你可以将其拆解为能用的幂次表示的数之和:
对应的二进制位就是:101.11。
核心认知:二进制小数只能精确表示那些能够被写成
形式的数。例如十进制的0.2(即
)无法被精确拆解为 1/2, 1/4, 1/8 的有限组合,它在二进制中是一个无限循环小数。这就注定了浮点数天生带有“精度丢失”的基因。
1.2 定点数的局限性(为什么需要浮点数?)
既然我们可以用101.11这样的方式表示小数,为什么计算机不直接把 32 位内存分成两半:前 16 位存整数,后 16 位存小数?
这种表示方法被称为定点数(Fixed-Point)。它的“小数点”位置是固定死不动的。定点数非常简单直接,但它在计算机科学中存在致命的局限性:
1. 无法表示极大的数
如果前 16 位用来表示整数,哪怕是无符号数,它的最大值也只能到65535。在处理天文学数据(如光速、星系距离)或现代游戏引擎的宏大坐标时,连都装不下,这显然是不行的。
2. 无法表示极小的数
后 16 位表示小数,它能表示的最小正数是(大约是
)。如果你要计算电子的质量(约
千克),定点数直接会将其视作0.0。
3. 极端的空间浪费
假设我们要表示。如果你用定点数,你需要先写出二进制的
101,然后在后面跟着写 100 个0。这就意味着,为了表示一个有效信息只有两三位的数字,你浪费了整整 100 位的存储空间去记录“小数点的位置”。
破局之道:科学记数法
为了解决这些问题,计算机科学家引入了十进制中“科学记数法”的二进制版本。
在十进制中,我们不写 150000000,而是写成。
在二进制中,我们也可以将任何数表示为:。
小数点不再是固定的,而是随着指数(E)的变化而“浮动”。这就是浮点数(Floating-Point)名称的由来。IEEE 754 标准正是基于这个数学公式,用极其巧妙的方式将符号(s)、尾数(M)和指数(E)压缩进 32 位或 64 位的内存中。
第二部分:核心基石 —— IEEE 754 标准与通用结构
2.1 核心数学公式
在 IEEE 754 标准中,无论是单精度还是双精度,任何一个浮点数都可以用下面这个极其优美的数学公式来表示:
这个公式包含三个核心维度:
s(Sign,符号):决定这个数是正数还是负数。s=0 时为正,s=1时为负。
M(Significand/Mantissa,尾数):一个二进制小数,它包含了数字的有效位信息。
E (Exponent,阶码):2 的幂次,它的作用是对浮点数进行加权,决定了二进制小数点的位置(向左还是向右“浮动”)。
极其重要的认知:
很多初学者会在这里栽跟头。请务必记住:内存里存的并不是 s, M, E 的直接数值。内存中存储的二进制串只是用来推导出 s, M, E 的线索。
2.2 内存布局(Bit 级别的解剖)
为了在内存中存储上述的三个元素,IEEE 754 将一段连续的比特(Bits)划分成了三个独立的字段:
符号位
s:占 1 位。直接对应公式中的 s。阶码字段
exp:占 k 位。它用来推导公式中的 E(注意:exp不等于 E)。小数字段
frac:占 n 位。它用来推导公式中的 M(注意:frac不等于 M)。
我们在 C 语言中最常用的两种浮点类型,其位分配如下表:
| C 语言类型 | 精度 | 总位数 | 符号位 (s) | 阶码位 (exp) 宽度 k | 小数位 (frac) 宽度 n |
float | 单精度 | 32 | 1 | 8 | 23 |
double | 双精度 | 64 | 1 | 11 | 52 |
对于 32 位的float,它的位模式看起来像这样:[s] [exp: 8位] [frac: 23位]。
2.3 偏置常数(Bias):为什么不直接用补码?
接下来我们要解决一个极其硬核的工程问题:阶码 E必须能表示负数。
因为要表示像 0.0001 这样极小的数,我们需要负的指数(比如)。
既然需要负数,按照《深入理解计算机系统》整数部分的常识,我们应该用补码(Two's Complement)来存储exp对吧?
但是,IEEE 754 的设计者极其巧妙地拒绝了补码,而是采用了“偏置(Biased)”编码。
为什么这么干?为了性能。计算机 CPU 在比较两个浮点数大小(比如a > b)时,如果用补码,硬件电路会非常复杂,因为浮点数的符号位在最前面,中间还夹着阶码。 为了让浮点数的比较能够直接复用整数比较的硬件电路,设计者决定:让exp字段永远是一个无符号整数(Unsigned Int)。无符号数越大,表示的真实阶码 E就越大。
如何做到?引入偏置常数(Bias):
我们给exp的无符号值减去一个固定的常数(Bias),就能得到真实的、有正有负的 E。
Bias 的计算公式是:
(其中 k 是exp的位数)
单精度(k=8)的 Bias:
127
双精度(k=11)的 Bias:
1023
实例推演(以单精度为例):
真实阶码的公式为:
如果内存中的
exp位全是0,它的无符号值是 0。真实阶码 E = 0 - 127 = -127。(实际情况略有不同,这是第三部分非规格化值的特殊规则,先留个悬念)。如果内存中的
exp是1000 0000,则无符号值是 128。真实阶码 E = 128 - 127 = 1。如果内存中的
exp是0111 1110,则无符号值是 126。真实阶码 E = 126 - 127 = -1。
通过这种“减去一个常数”的做法,原本只能表示的 8 位无符号数,被完美地平移成了表示负数和正数的阶码范围。
第三部分:深水区 —— 浮点数的三大形态(重点)
3.1 第一形态:规格化值(Normalized Values)
这是最常见的情况。
触发条件:当exp的位模式既不全为0,也不全为1时(即无符号值在之间)。
在这种形态下,有两个极其重要的硬核规则:
1. 真实的阶码
这在我们上一部分的结尾已经推导过了。
2. “隐藏的 1”:真实尾数
这是计算机科学中极为经典的一个优化设计。想想我们第一部分提到的科学记数法,在二进制中,任何非零数字的科学记数法表示,小数点左边必然是 1(例如),因为二进制只有 0 和 1,既然是非零数,首位肯定不能是 0。
既然这个1永远存在,设计者想:为什么我们要在极其宝贵的内存里去存一个永远是 1 的数字呢?
所以,IEEE 754 规定:这个1是免费送的,不需要存储!内存里的frac字段(也就是数学公式里的 f),只存储小数点右边的部分。
硬核推演:将 5.75 存入 32 位 float 内存
在第一部分我们算过,
的二进制是
101.11。步骤 1:转为二进制科学记数法
步骤 2:确定符号位
s正数,所以
s = 0。步骤 3:计算
exp真实阶码
。根据公式
,得出
。
的 8 位无符号二进制是
1000 0001。步骤 4:确定
frac尾数 M 是
。去掉隐藏的 1,剩下的小数部分
是
0111。我们将它填入 23 位的
frac字段中,尾部补 0:011 1000 0000 0000 0000 0000。最终内存中的 32 位真实布局:
0 10000001 01110000000000000000000
3.2 第二形态:非规格化值(Denormalized Values)
触发条件:当exp的所有位全为0时。
为什么要有这种形态?假设没有它,规格化数能表示的最小正数是。比这个数更小的数值(极靠近 0 的数)就只能直接变成 0,这会导致计算精度的突然断崖式下降。
非规格化值就是为了表示0以及极度接近 0 的数字而存在的。
它的规则发生了变化:
1. 没有隐藏的 1:真实尾数
既然是为了表示极小的数,首位就不能再是 1 了,而是。
2. 特殊的阶码计算:真实阶码
注意,这里不是。这是为了实现一种叫做“逐渐下溢(Gradual Underflow)”的平滑过渡特性,使得最大非规格化数和最小规格化数之间能够完美衔接(这里只需记住公式,这是 IEEE 754 为了数学平滑性做的特殊硬编码)。
特殊现象:+0.0 和 -0.0
当exp全为 0,且frac也全为 0 时,根据公式算出来的值就是 0。
但由于符号位s的存在,浮点数在底层实际上有两种 0:符号位为 0 的+0.0和符号位为 1 的-0.0。在 C 语言中,它们在数值比较(==)时是相等的,但在某些极其特殊的数学库运算中会有不同的行为。
3.3 第三形态:特殊值(Special Values)
触发条件:当exp的所有位全为1时(即无符号值为 255)。
这是一种错误处理机制,用来表示数学上无法处理的结果:
无穷大(Infinity):如果
frac全为0,则表示无穷大。当s=0时是正无穷,s=1时是负无穷。这通常发生在两个极大的数相乘导致溢出,或者进行除以 0 的操作时(例如1.0 / 0.0在 C 语言里不会崩溃,而是得到正无穷)。不是一个数(NaN - Not a Number):如果
frac不全为0,则表示 NaN。这通常发生在非法数学运算时,比如计算,或者
。
第四部分:规则与陷阱 —— 舍入机制(Rounding)
4.1 为什么必须舍入?
在十进制中,有些分数是无法用有限小数表示的,比如
在二进制中,情况更加严峻。比如我们最常用的十进制(即 1/10),它的分母 10 并不是 2 的幂次。如果你尝试把它转成二进制,会得到一个无限循环小数:
但是,在单精度float中,我们的frac字段只有 23 位!这就意味着,计算机必须在某一位强行“一刀切”,把后面的比特全部丢掉。这种为了适应有限内存而丢弃精度的操作,就是舍入。
4.2 IEEE 754 的默认规则:向偶数舍入(Round-to-Even)
从小我们学到的舍入规则叫“四舍五入”。比如,
。
但“四舍五入”在计算机科学里存在一个致命缺陷:统计偏差。
想一想,如果一个数字正好处在两个整数的绝对中间(比如 $1.5$、$2.5$、$3.5$),按照四舍五入的规则,它们全都会被向上进位。如果在一个包含数百万次计算的金融系统中,所有处在正中间的数都往上进位,最终的计算结果会不可避免地偏大。
为了解决这个问题,IEEE 754 默认采用向偶数舍入。
规则:当一个数字不在正中间时,它离谁近就舍入给谁(这跟普通的舍入一样)。
核心差异:当一个数字恰好在正中间时,我们不固定向上或向下,而是看哪个结果是偶数,就舍入给谁。
比如在十进制中模拟这个规则:
在正中间,舍入后是偶数,所以变成2。
在正中间,舍入后是偶数,所以变成2(注意,不是3!)。
由于数字末尾是奇数和偶数的概率各占 50%,这种“有时向上、有时向下”的机制,完美抵消了统计偏差。
4.3 二进制的“向偶数舍入”实战
在二进制中,什么叫“偶数”?二进制的最低有效位是0就是偶数,是1就是奇数。
什么叫“恰好在正中间”?在二进制里,被舍弃的部分如果恰好是1000...00(即首位是 1,后面全 0),就是恰好一半。
实例演练:我们要将以下数字舍入到小数点后两位(即保留XX.YY的格式)
不到一半(向下截断):
10.00 | 011竖线后面的011小于一半(100),直接丢弃。结果是10.00。超过一半(向上进位):
10.00 | 110竖线后面的110大于一半(100),向上进位。结果是10.01。恰好一半(向偶数舍入 - 末位为 0):
10.10 | 100竖线后面是100,恰好是一半!此时看保留部分的最后一位,是0(已经是偶数了)。所以直接丢弃。结果是10.10。恰好一半(向偶数舍入 - 末位为 1):
10.11 | 100竖线后面是100,恰好一半!此时保留部分的最后一位是1(奇数)。为了变成偶数,必须向上进 1。10.11 + 0.01 = 11.00。结果是11.00。
第五部分:实战与反直觉现象 —— 浮点运算与 C 语言特性
当你掌握了底层的位模式和舍入规则,C 语言里那些曾经让你百思不得其解的 Bug 就会豁然开朗。
5.1 浮点数加法丧失结合律(大数吃小数)
在纯数学中,(A + B) + C = A + (B + C) 永远成立。
但在 C 语言的浮点数中,这不成立。
代码现象:
float a = (3.14 + 1e20) - 1e20; float b = 3.14 + (1e20 - 1e20);如果你打印这两个变量,你会发现a的值是0.0,而b的值是3.14。
底层原理解密(灾难性抵消):
当我们计算3.14 + 1e20时,由于两个数字相差极大,CPU 在做加法前必须先“对齐阶码”。为了和对齐,3.14的阶码必须疯狂增大,而它的尾数则必须疯狂右移。
在单精度的 23 位尾数限制下,右移的过程中,3.14的有效位早就被全部舍入丢弃了。所以3.14 + 1e20在计算机眼里的结果依然是1e20。接着再减去1e20,结果自然就变成了 0.0。
5.2 C 语言中的类型转换强制规则
面试经常问:在int、float、double之间互相转换会发生什么?
int转换成float:不会溢出,但可能丢失精度。原因:C 语言的
int是 32 位的,它的有效位可以占满 31 位。但是float的尾数frac只有 23 位(加上隐藏的 1 共 24 位)。如果你的int数字非常大,需要超过 24 个二进制位来精确表示时,多出来的低位就会被“向偶数舍入”无情切掉。
int转换成double:绝对安全。原因:
double的尾数有高达 52 位,把一个 32 位的int完整塞进去绰绰有余。
float或double转换成int:向零舍入(直接截断)。原因:不看偶数还是奇数,直接把小数部分粗暴砍掉。如果浮点数太大,超出了
int的最大范围,C 语言标准并未定义具体行为,通常在现代系统上会变成一个极大的负数(比如-2147483648)。
结语
至此,CSAPP 浮点数章节已经全部拆解完毕。从为什么会有浮点数,到偏置常数与三大形态的解析,再到向偶数舍入的逻辑,最后回归到C语言的代码现象。这套标准虽然看似反人类,但在“性能、存储范围、数学平滑性”这个不可能三角之间,IEEE 754 已经做到了极其优雅的平衡。