CSAPP - 02.Floating Point Numbers

一、小数的二进制表示 (Fractional Binary Numbers)

1. 表示方法

计算机使用二进制来表示一切信息。对于小数,其表示方法与整数类似,只是在小数点右侧的每一位代表 2 的负数次幂。

一个二进制小数 bibi1...b1b0.b1b2...bjb_{i}b_{i-1}...b_{1}b_{0}.b_{-1}b_{-2}...b_{-j} 的值可以表示为:

Value=k=jibk×2k\text{Value} = \sum_{k=-j}^{i}b_{k}\times2^{k}

  • 小数点左边的位权是 20,21,22,...2^0, 2^1, 2^2, ...
  • 小数点右边的位权是 21(1/2),22(1/4),23(1/8),...2^{-1} (1/2), 2^{-2} (1/4), 2^{-3} (1/8), ...

示例

  • 101.112=1×22+0×21+1×20+1×21+1×22=4+0+1+0.5+0.25=5.75101.11_2 = 1 \times 2^2 + 0 \times 2^1 + 1 \times 2^0 + 1 \times 2^{-1} + 1 \times 2^{-2} = 4 + 0 + 1 + 0.5 + 0.25 = 5.75
  • 0.111111...20.111111..._2 的值趋近于 1.0 (1/2+1/4+1/8+...1.01/2 + 1/4 + 1/8 + ... \rightarrow 1.0)。

2. 表示的局限性

  • 精确表示:只有形如 x/2kx/2^k 的有理数才能被精确地表示。
  • 循环表示:其他许多有理数在二进制下是无限循环小数。
    • 1/3=0.010101...21/3 = 0.010101..._2
    • 1/5=0.00110011...21/5 = 0.00110011..._2
    • 1/10=0.000110011...21/10 = 0.000110011..._2

二、IEEE 754 浮点数标准

为了统一浮点数的算术标准,IEEE 在 1985 年推出了 IEEE 754 标准。它被所有主流 CPU 支持,并为舍入、溢出等问题提供了良好的规范。该标准的主要设计者是 William Kahan,他因此获得了 1989 年的图灵奖。

1. 数值形式 (Numerical Form)

所有浮点数都被表示为科学记数法的形式:

(1)s×M×2E(-1)^s \times M \times 2^E

  • ss (Sign): 符号位,决定正负 (0 为正, 1 为负)。
  • MM (Significand): 尾数,通常是一个范围在 [1.0, 2.0) 之间的小数。
  • EE (Exponent): 阶码,用于对数值进行加权。

2. 编码 (Encoding)

在计算机中,一个浮点数被编码为三个部分:

s (符号位) exp (阶码字段) frac (小数部分)
  • s: 直接编码符号 ss
  • exp: 编码阶码 EE(但 exp 不等于 EE)。
  • frac: 编码尾数 MM(但 frac 不等于 MM)。

3. 精度 (Precisions)

  • 单精度 (Single Precision / float): 32 bits 4 bytes。
    • s: 1 位, exp: 8 位, frac: 23 位。
  • 双精度 (Double Precision / double): 64 bits 8 bytes。
    • s: 1 位, exp: 11 位, frac: 52 位。

4. 三种编码类型

a. 规格化值 (Normalized Values)

这是最常见的情况。

  • 条件: exp 字段既不全为 0,也不全为 1。
  • 阶码 (Exponent): 采用 偏置表示法 (Biased Value)
    • E=ExpBiasE = \text{Exp} - \text{Bias}
    • Expexp 字段的无符号整数值。
    • Bias 是一个偏置常数(用于表示大于和小于1的数),值为 2k112^{k-1}-1kkexp 的位数)。
      • 单精度 k=8 Bias= 127,能表达212621272^{-126} \sim 2^{127} (阶码不全为0也不全为1)
      • 双精度 Bias = 1023
  • 尾数 (Significand): 采用 隐含的头一位 (Implied Leading 1)
    • M=1.xxx...x2M = 1.xxx...x_2,其中 xxx...xfrac 字段的值。
    • 因为规格化数的尾数第一位总是 1,所以可以省略不存,从而 “免费” 获得一位额外的精度。

我们以单精度(Single Precision) 为例来详细解释:

  1. exp 字段的位数: 对于单精度浮点数,阶码字段 exp 共有 k=8 位 1。
  2. exp 的原始范围: 作为一个8位的无符号整数,exp 字段可以表示的十进制值范围是从 00000000 (0) 到 11111111 (255)。
  3. 保留的特殊值: IEEE 754 标准规定,exp 字段的两个极端值是保留作特殊用途的:
  • exp 全为 0 (00000000, 即十进制的 0) 被保留用于表示非规格化数 (Denormalized Values) 和 02222。
  • exp 全为 1 (11111111, 即十进制的 255) 被保留用于表示无穷大 (Infinity) 和 NaN (Not-a-Number) 3。
  1. 规格化数的 exp 范围: 因为最大和最小的值被保留了,所以用于表示常规规格化数(Normalized Values)的 exp 字段的有效范围就变成了从 1 到 254
  2. 计算实际的阶码 E:
  • 偏置值 (Bias): 单精度的 Bias 是2811=1272^{8-1} -1 = 127
  • 最小阶码 Emin​: 使用 exp 的有效最小值 1 来计算。Emin=1Bias=1127=126E_{min} = 1 - Bias = 1 - 127 = -126
  • 最大阶码 Emax​: 使用 exp 的有效最大值 254 来计算。Emax=254Bias=254127=127E_{max} = 254 - Bias = 254 - 127 = 127

简单来说,引入偏置值(Bias)是为了在 exp 字段中只使用无符号整数,就能方便地表示正负范围的阶码,这简化了硬件在比较浮点数大小时的设计。而去掉头尾两个特殊值,则是为了能够表示像0和无穷大这样的特殊情况。

[!tip] 规格化浮点数换算
数值 = (1)s×M×2E(-1)^s × M × 2^E

  1. 符号 (s): 直接取二进制的最高位。0代表正数,1代表负数。
  2. 阶码 (E):
  • 读取exp字段的二进制,将其转换为无符号整数 Exp
  • Exp减去一个固定的偏置值 (Bias) 得到 E
  • 公式E = Exp - Bias (例如,单精度Bias为127)。
  1. 尾数 (M):
  • frac字段的二进制小数前,加上一个隐含的 “1.”。
  • 公式M = 1.frac (二进制形式)。

将计算出的 sE, 和 M 代入核心公式,即可得到最终的十进制数。

b. 非规格化值 (Denormalized Values)

用于表示非常接近 0 的数。

  • 条件: exp 字段全为 0。
  • 阶码 (Exponent): E=1BiasE = 1 - \text{Bias}。这是是为了实现平滑过渡(Seamless Transition)
    • 最小的规格化数(4字节) exp=1, frac=00...0 : Value=1.0×21127=1.0×2126Value = 1.0 \times 2^{1-127} = \mathbf{1.0 \times 2^{-126}}
    • 最大的非规格化数 (exp=0, frac=11...1): 如果 EE127-127Value=0.11...1×2127Value = 0.11...1 \times 2^{-127}。这与上面的 1.0×21261.0 \times 2^{-126} 之间会出现断层。 如果 EE126-126Value=0.11...1×2126Value = \mathbf{0.11...1 \times 2^{-126}}
  • 尾数 (Significand): 隐含的头一位是 0。
    • M=0.xxx...x2M = 0.xxx...x_2,其中 xxx...xfrac 字段的值。
  • 特殊情况
    • 如果 frac 也全为 0,则表示 0 值(区分 +0-0)。
    • 如果 frac 不全为 0,则表示非常接近 0 的数,实现了向 0 的 渐进下溢 (Gradual Underflow)

c. 特殊值 (Special Values)

  • 条件: exp 字段全为 1。
  • 情况一:无穷大 (Infinity)
    • frac 字段全为 0 时。
    • 表示运算溢出(如 1.0 / 0.0)的结果,有 +∞-∞
  • 情况二:非数值 (Not-a-Number / NaN)
    • frac 字段不全为 0 时。
    • 表示一个无法确定数值结果的操作(如 sqrt(-1), ∞ - ∞)。
  • 位置: 非规格化数填充了最小的正规格化数与零之间的空隙(同样适用于负数)。
  • 密度: 非规格化与规格化数在数轴上越靠近零的区域分布越密集
  • 间距: 与数值越大间距越大的规格化数不同,所有的非规格化数之间是等距 (Equispaced) 的,即它们均匀地分布在零附近。相同阶数的规格化数的间距是相等的,并且阶数越大间距越大。
    • 在二进制里,尾数 MM 每次变化的最小单位是最后一位加 1。对于 32 位浮点数(23 位尾数),这个最小变化量是 2232^{-23}。所以,相邻两个数的间距(Gap)是:Gap=(1.Mnext1.Mcurr)×2E=223×2E\text{Gap} = (1.M_{next} - 1.M_{curr}) \times 2^E = 2^{-23} \times 2^E
    • 结论:只要 EE 不变,Gap\text{Gap} 就是一个定值。所以在 2E2^E2E+12^{E+1} 这一段里,刻度是完全均匀的。
    • 每当跨越一个 2 的幂次边界(比如从 2 到 4,或者从 1024 到 2048),浮点数的间距就会精确地翻倍
  • 作用: 这种分布方式实现了“渐进下溢” (Gradual Underflow),避免了从一个很小的数直接跳变为零,从而保留了对极小数值的处理能力,但代价是牺牲了精度。

三、浮点数运算 (Floating Point Operations)

1. 基本思想

精确结果所含的有效位数,超过了目标浮点格式的尾数(frac)字段所能容纳的位数时,就需要进行舍入 。以下是讲义中提到的几种情况:

  • 浮点数乘法 (Floating Point Multiplication): 两个浮点数的尾数相乘后,其结果的有效位数可能会翻倍,超出了 frac 字段的长度,因此需要舍入 。
  • 浮点数加法 (Floating Point Addition): 在加法运算中,为了对齐小数点,阶码较小的数的尾数需要右移,这可能产生额外的低位。两个尾数相加后的结果也可能需要舍入才能存入 frac 字段 。
  • 类型转换 (Casting): 当一个位数更多的整数(例如,一个超过24位的 int)转换为 float 时,由于 float 的尾数只有23位(加上隐含的1位),无法精确表示该整数,因此需要进行舍入 。同样,从double(52位尾数)转换到 float 也常常需要舍入

公式表示:

  • x+fy=Round(x+y)x +_f y = \text{Round}(x+y)
  • x×fy=Round(x×y)x \times_f y = \text{Round}(x \times y)

2. 舍入模式 (Rounding Modes)

核心原则:默认模式总是优先选择精度最高(最接近)的值,仅在无法区分远近时使用仲裁规则。

1. 默认模式:向偶数舍入 (Round-to-nearest, ties to even)

这是几乎所有现代计算机系统的默认行为。判断逻辑分为两步:

  • 第一步:找最近 (Nearest)
    • 绝对优先原则。选择距离原数值最近的可表示值,不需要看奇偶。
    • 1.1 \to 1.01.9 \to 2.0
  • 第二步:中间值仲裁 (Ties to Even)
    • 触发条件:仅当原数值恰好位于两个可表示值的正中间(即 .5000\dots .5000\dots)时。
    • 规则:选择最低有效位 (LSB) 为 0(偶数)的那个值。
    • 例 (十进制)1.5 \to 2 (偶); 2.5 \to 2 (偶)。
    • 例 (二进制)
      • 10.10 100 (中间值) \to 10.10 (末尾0, 偶)。
      • 10.11 100 (中间值) \to 11.00 (末尾0, 偶)。
  • 优势统计无偏 (Statistically Unbiased)。避免了传统“四舍五入”在大量运算中造成结果持续偏大的系统误差(50%几率向上,50%几率向下)。

2. 其他非默认模式 (Directed Rounding)

这些模式不考虑“谁更近”,直接按方向强制取整,常用于区间算术或特定边界计算。

  • 向零舍入 (Round toward zero): 直接截断 (Truncate)。C 语言 (int) 强制转换时的行为。
  • 向正无穷舍入 (Round toward +Inf): 向上取整 (Ceiling)。始终向数轴右侧靠近(1.1 \to 2.0, -1.5 \to -1.0)。
  • 向负无穷舍入 (Round toward -Inf): 向下取整 (Floor)。始终向数轴左侧靠近(1.1 \to 1.0, -1.5 \to -2.0)。

3. 浮点数加法和乘法详解

浮点数运算比整数复杂,本质上是“科学计数法”的运算。

1. 浮点数乘法 (Floating-point Multiplication)

乘法的逻辑相对直观,符号、阶码和尾数可以分开处理。

  • Step 1: 生成符号位 (Compute Sign)

    • 操作s=s1s2s = s1 \oplus s2
    • 原理:同号相乘为正,异号相乘为负。
  • Step 2: 阶码运算 (Compute Exponent)

    • 操作E=E1+E2BiasE = E1 + E2 - Bias
    • 原理:阶码存储为移码 (Estore=Ereal+BiasE_{store} = E_{real} + Bias)。若直接相加,Bias 会被加两次,因此必须减去一次偏置值
  • Step 3: 尾数相乘 (Multiply Significands)

    • 操作M=M1×M2M = M1 \times M2
    • 范围:两个规格化数 [1,2)[1, 2) 相乘,结果 MM 的范围是 [1,4)[1, 4)
  • Step 4: 规格化与舍入 (Normalization & Rounding)

    • 情况 A (M<2M < 2):结果已规格化,无需调整。
    • 情况 B (M2M \ge 2):即结果在 [2,4)[2, 4) 之间。
      • 动作:尾数 右移 1 位,阶码 加 1
    • 舍入:截断多余位并按规则(如向偶数舍入)处理。
    • 检查:检查阶码是否发生上溢 (Overflow) 或下溢 (Underflow)。

2. 浮点数加法 (Floating-point Addition)

加法的核心难点在于必须先对齐小数点(对阶)。

  • Step 1: 对阶 (Alignment)

    • 原则小阶向大阶看齐
    • 理由:小阶变大需要右移尾数,丢失的是低位精度(可控);大阶变小需要左移尾数,会丢失高位有效数字(灾难性错误)。
    • 操作:保持大阶码不变,将小阶码对应的尾数 右移,直到阶码相同。
  • Step 2: 尾数加减 (Significand Addition)

    • 操作M=M1±M2M = M1 \pm M2' (其中 M2M2' 是对阶后的尾数)。
    • 根据符号位决定实际执行加法还是减法。
  • Step 3: 规格化 (Normalization)

    • 情况 A (进位, M2M \ge 2):例如 1.5+1.5=3.01.5 + 1.5 = 3.0
      • 动作:尾数 右移 1 位,阶码 加 1
    • 情况 B (抵消, M<1M < 1):例如 1.51.4=0.11.5 - 1.4 = 0.1
      • 动作:尾数 左移若干位,直到最高位恢复为 1。
      • 补偿:阶码相应 减小
    • 特殊情况:结果为 0,阶码和尾数全置 0。
  • Step 4: 舍入 (Rounding)

    • 处理对阶或计算中移出的位。
    • 注意:舍入可能导致再次进位(如 1.11...1.11... 进位成 10.00...10.00...),此时需再次执行“右移1位、阶码加1”。

1. 可行性 (Feasibility)

  • 可以运算: 非规格化数之间、以及与规格化数之间完全可以进行加减乘除。
  • 机制: 硬件 (FPU) 或软件库会自动处理阶码对齐和尾数计算,实现从规格化数到 0 的平滑过渡。

2. 潜在风险 (Risks)

  • 性能陷阱 (Performance Penalty): 许多硬件处理非规格化数效率极低(可能触发微代码或软件模拟),导致计算速度下降几十倍甚至上百倍
  • 精度损失 (Precision Loss): 数值越小,尾数的前导零越多,有效位数递减,导致计算精度严重下降。

3. 工程对策 (Handling)

  • 归零模式 (Flush-to-Zero, FTZ): 在游戏开发或高性能计算中,为了保住性能,通常配置 CPU 将非规格化数直接视为 0 处理,但这会牺牲 IEEE 754 的标准兼容性。

4. 数学属性 (Mathematical Properties)

浮点数运算不完全满足实数算术的数学定律。

  • 结合律不成立 (Associativity is violated):
    • (3.14 + 1e10) - 1e10 的结果可能是 0.0
    • 3.14 + (1e10 - 1e10) 的结果是 3.14
    • 原因是舍入误差。
  • 分配律不成立 (Distributivity is violated):
    • 1e20 * (1e20 - 1e20) 结果是 0.0
    • 1e20 * 1e20 - 1e20 * 1e20 结果可能是 NaN(因为中间结果溢出为 )。

这对编译器优化和严肃的数值计算程序员来说是一个巨大的挑战。


四、C 语言中的浮点数

  • 数据类型:C 语言保证了两种精度的浮点数:float (单精度) 和 double (双精度)。
  • 类型转换 (Casting):
    • double/floatint: 截断 (Truncate) 小数部分,类似于向零舍入。
    • intdouble: 只要 int 的位数不多于 53 位(double 的尾数位数),转换是精确的(满足double的步长小于1)。对于一般的int(4 bytes)转换是精确的,对于long long可能发生舍入。
    • intfloat: 可能会发生舍入。尾数 (Mantissa):只剩下 23 位(加上隐含的 1,共 24 位有效数字),不一定能存下int的最多32位有效数字。
  • 比较问题
    • 表达式 (f+d)-f == d 可能为 false,因为 f+d 的计算可能导致 d 的低位精度丢失。大数+小数导致精度丢失。

浮点数谜题(Floating Point Puzzles)

预设条件: int x (32位), float f (32位), double d (64位)。假设无 NaN。

1. 类型转换与精度

  • x == (int)(float) x \rightarrow False
    • 原因: int (31位有效值) > float (24位有效精度)。大整数转 float 会发生舍入,丢失精度。
  • x == (int)(double) x \rightarrow True
    • 原因: double (53位有效精度) > int (31位)。double 能像大箱子一样无损装下 int。
  • f == (float)(double) f \rightarrow True
    • 原因: float 转 double 是无损的(尾数补零),转回来数值不变。
  • d == (float) d \rightarrow False
    • 原因: double 转 float 会发生舍入(丢弃低位)或溢出(变无穷大)。

2. 运算规则

  • 2/3 == 2/3.0 \rightarrow False
    • 原因: 左边是整数除法 (结果为 0),右边是浮点除法 (结果为 0.66…)。
  • (d+f)-d == f \rightarrow False
    • 原因: 大数吃小数。当 d 极大 f 极小时,d+f 可能因精度限制仍等于 d,导致最终结果为 0 (不等于 f)。

3. 符号与不等式

  • f == -(-f) \rightarrow True
    • 原因: 两次翻转符号位,还原回原值。
  • d < 0.0 => ((d*2) < 0.0) \rightarrow True
    • 原因: 浮点数乘 2 仅增加指数,不改变符号位。即使溢出为 -\infty 也依然小于 0。
  • d > f => -f > -d \rightarrow True
    • 原因: 数学不等式性质,同乘 -1 变号。
  • d * d >= 0.0 \rightarrow True
    • 原因: 实数平方非负。自乘时符号位异或 (00=00 \oplus 0 = 0),结果永远为正。

核心记忆原则

  1. 大转小必有失double \to float \to int 容易丢失精度或溢出。
  2. 小转大很安全int \to double 是绝对精确的。
  3. 大数吃小数:浮点加法运算中,极大数加极小数,小数的精度会被忽略。

五、前沿热点:FP8

近年来,随着 AI 的发展,低精度浮点数格式成为研究热点。

  • 背景:为了在 AI 芯片中实现更高的计算吞吐量和更低的功耗,业界提出了多种比 FP32/FP16 更低精度的格式,如 FP8
  • FP8 格式:FP8 有多种变体,主要通过调整 阶码 (Exponent)尾数 (Mantissa/Fraction) 的位数来权衡动态范围 (Range)精度 (Precision)
    • E5M2: 5 位阶码,2 位尾数。提供更大的动态范围,适合梯度等数值范围大的场景。
    • E4M3: 4 位阶码,3 位尾数。提供更高的精度,适合权重等场景。
  • 优势:在专用硬件(如 NVIDIA H100 GPU)上,使用 FP8 进行张量运算可以比 FP16 带来数倍的性能提升。

总结

  • IEEE 754 标准为浮点数提供了一套清晰的数学属性和运算规则。
  • 浮点数的表示形式为 (1)s×M×2E(-1)^s \times M \times 2^E
  • 我们可以独立于具体硬件实现来推理浮点运算,因为其行为就像是先用无限精度计算再进行舍入。
  • 核心要点:浮点数算术 不等于 实数算术,它不满足结合律和分配律。理解这一点对于编写健壮、可靠的数值计算代码至关重要。

CSAPP - 02.Floating Point Numbers
https://yima-gu.github.io/2026/01/14/CSAPP/02-Folat/
作者
Yima Gu
发布于
2026年1月15日
许可协议