浮点数:计算机如何表示实数

举报
yd_232225224 发表于 2026/10/09 15:26:26 2026/10/09
【摘要】 在几乎所有编程语言中,下面这个表达式的结果都不是 true:0.1 + 0.2 == 0.3计算出来的 0.1 + 0.2 实际上是 0.30000000000000004。初次遇到这个现象的人往往会感到困惑:计算机连最简单的小数加法都算不对吗?答案是:计算机并没有算错,它只是用一种有限精度的方式表示实数,而这种方式无法精确表示 0.1、0.2 和 0.3。理解这一点,需要了解浮点数的表示方...

在几乎所有编程语言中,下面这个表达式的结果都不是 true:

0.1 + 0.2 == 0.3

计算出来的 0.1 + 0.2 实际上是 0.30000000000000004。初次遇到这个现象的人往往会感到困惑:计算机连最简单的小数加法都算不对吗?

答案是:计算机并没有算错,它只是用一种有限精度的方式表示实数,而这种方式无法精确表示 0.1、0.2 和 0.3。理解这一点,需要了解浮点数的表示方法。

本文介绍浮点数的设计思想、IEEE 754 标准的格式与特殊值、舍入与误差的来源、常见的精度陷阱,以及在实际编程中如何正确地处理浮点数,最后简要介绍近年来在机器学习领域兴起的低精度浮点格式。

一、为什么需要浮点数

定点数的局限

计算机最自然的数值表示是整数。如果要表示小数,最直接的方法是定点数:约定小数点固定在某个位置。例如,用一个 32 位整数表示金额,约定最后两位是"分",那么整数 12345 就表示 123.45 元。

定点数简单高效,运算规则与整数相同,在金融计算、嵌入式系统、数字信号处理中仍被广泛使用。但它有一个根本局限:表示范围与精度之间存在固定的取舍。小数点后保留的位数越多,能表示的整数部分就越小。

而科学计算中需要处理的数值,跨度往往极大:从原子的尺度到天体的距离,从电子的质量到星系的质量,相差数十个数量级。用定点数同时覆盖这样的范围并保持合理的精度,需要极长的位数。

科学计数法的启示

人们在书写极大或极小的数时,通常使用科学计数法:

  • 光速约为 2.998 × 10⁸ 米每秒;
  • 电子质量约为 9.109 × 10⁻³¹ 千克。

科学计数法将一个数分为两部分:有效数字和指数。有效数字决定了精度,指数决定了数量级。无论数值多大或多小,都只用几位有效数字加一个指数来表示,相对精度保持一致。

浮点数正是科学计数法在二进制中的实现。“浮点"这个名字,指的就是小数点的位置随指数"浮动”,而不是固定在某处。

二、IEEE 754 标准

在 20 世纪 80 年代之前,不同厂商的计算机各自使用不同的浮点格式,舍入方式、特殊情况的处理千差万别。同一段数值计算程序在不同机器上可能得到不同的结果,甚至在某些机器上崩溃。这给科学计算程序的移植和结果的可重复性带来了极大困难。

1985 年,IEEE 发布了 754 号标准,统一了浮点数的格式和运算规则,并于 2008 年和 2019 年进行了修订。如今,几乎所有的处理器和编程语言都遵循这一标准。

基本格式

一个 IEEE 754 浮点数由三部分组成:

  • 符号位(Sign):1 位,0 表示正数,1 表示负数;
  • 指数位(Exponent):若干位,表示 2 的幂次;
  • 尾数位(Fraction,也称有效数位):若干位,表示有效数字的小数部分。

最常用的两种格式是:

格式 总位数 符号位 指数位 尾数位 约有效十进制位数 常见名称
单精度 32 1 8 23 约 7 位 float
双精度 64 1 11 52 约 15 至 16 位 double

规格化数

对于大多数正常的数值,浮点数表示的值为:

值 = (−1)^符号 × 1.尾数 × 2^(指数 − 偏移量)

其中有两个关键设计。

隐含的前导 1。 在二进制科学计数法中,任何非零数都可以调整为 1.xxxx × 2^n 的形式,即小数点前恰好是一个 1。既然这一位总是 1,就没有必要存储它。因此尾数位只存储小数点之后的部分,前导的 1 是隐含的。这为尾数免费增加了一位精度:单精度虽然只存储 23 位尾数,实际有效位数是 24 位;双精度实际有效位数是 53 位。

偏移指数。 指数需要能够表示正数和负数。IEEE 754 没有使用补码,而是采用偏移量表示:存储的指数值减去一个固定偏移量,才是实际的指数。单精度的偏移量为 127,双精度为 1023。例如,单精度中存储的指数为 130,实际指数为 130 − 127 = 3。

使用偏移表示的一个好处是:对于同号的浮点数,如果将它们的二进制位当作无符号整数来比较,大小顺序与浮点数本身的大小顺序一致。这使得浮点数的比较可以借助整数比较电路高效实现。

一个例子

以单精度表示十进制数 −6.25 为例:

  1. 符号为负,符号位为 1;
  2. 6.25 转换为二进制为 110.01;
  3. 规格化为 1.1001 × 2²;
  4. 实际指数为 2,存储的指数为 2 + 127 = 129,二进制为 10000001;
  5. 尾数为前导 1 之后的部分:1001,后面补零到 23 位。

最终的 32 位表示为:

1 10000001 10010000000000000000000

三、特殊值

指数位的全 0 和全 1 两种取值被保留,用于表示特殊情况。

零

当指数位和尾数位全部为 0 时,表示零。由于符号位可以是 0 或 1,浮点数中存在正零和负零两个零。

在大多数比较中,正零和负零被视为相等。但它们在某些运算中表现不同,例如 1 除以正零得到正无穷,1 除以负零得到负无穷。负零的存在使得一些数学函数能够在边界处保持正确的符号信息,例如一个从负方向趋近于零的极小数下溢后,仍然保留其负号。

无穷大

当指数位全为 1、尾数位全为 0 时,表示无穷大,符号位决定正负。

无穷大通常由溢出产生,即运算结果超出了可表示的最大值,或者由非零数除以零产生。无穷大参与运算时遵循直观的数学规则:任何有限数加上正无穷仍是正无穷,正无穷乘以正数仍是正无穷。

非数

当指数位全为 1、尾数位不全为 0 时,表示非数(Not a Number,NaN)。

NaN 用于表示无意义或无法定义的运算结果,例如:

  • 0 除以 0;
  • 无穷大减无穷大;
  • 0 乘以无穷大;
  • 负数的平方根(在实数范围内)。

NaN 有一些独特的性质:

  • 传播性:任何涉及 NaN 的算术运算,结果都是 NaN。这使得一个错误能够沿着计算链传递下去,而不会被悄悄掩盖成一个看似合理的数值;
  • 不等于任何值,包括自身:NaN == NaN 的结果为 false。这是检测 NaN 的一种方式,但更推荐使用语言提供的专门函数;
  • 比较总是失败:NaN 与任何值进行大于、小于、等于比较,结果都是 false。

NaN 的这些性质常常引发隐蔽的程序错误。例如,对包含 NaN 的数组进行排序,由于比较关系不再满足传递性,排序结果可能是错乱的;在以浮点数为键的哈希表中,NaN 无法被查找到。

非规格化数

当指数位全为 0、尾数位不全为 0 时,表示非规格化数(Subnormal Number,也称次正规数)。

规格化数中隐含的前导 1,使得能够表示的最小正数存在一个下限。对于单精度,最小的规格化正数约为 1.18 × 10⁻³⁸。如果没有其他机制,比这更小的数只能直接变为零,这在 0 和最小规格化数之间留下了一个"空洞"。

非规格化数取消了隐含的前导 1,改为隐含前导 0,并将指数固定为最小值。这样,在最小规格化数和零之间,可以继续以均匀的间隔表示一系列更小的数,代价是精度逐渐降低。这一机制称为渐进下溢。

渐进下溢保证了一个重要的性质:对于两个不相等的浮点数 x 和 y,x − y 永远不会等于 0。如果没有非规格化数,两个非常接近的极小数相减,结果可能因下溢而变为零,导致后续的除法出现除以零的错误。

非规格化数也有代价:在许多处理器上,涉及非规格化数的运算比正常运算慢得多,可能相差数十倍。一些对性能要求极高、对极小值精度不敏感的应用,例如音频处理和图形渲染,会开启刷新为零模式,将非规格化数直接当作零处理。

四、为什么 0.1 无法精确表示

回到开头的问题。十进制的 0.1 转换为二进制,是一个无限循环小数:

0.1 (十进制) = 0.0001100110011001100110011... (二进制)

这与十进制中 1/3 = 0.3333… 无法精确表示是同样的道理。一个分数能否用有限位小数表示,取决于其分母(化为最简形式后)的质因数是否都是进制基数的因数。十进制的基数是 10 = 2 × 5,因此分母只含 2 和 5 的分数可以精确表示;二进制的基数是 2,因此只有分母为 2 的幂的分数才能精确表示。

0.1 = 1/10,分母含有因数 5,在二进制中无法精确表示。同理,0.2 和 0.3 也都无法精确表示。

浮点数只有有限的尾数位,必须在某一位截断并舍入。因此,代码中写下的 0.1,实际存储的是与 0.1 最接近的那个可表示的双精度数,约为:

0.1000000000000000055511151231257827...

类似地,0.2 和 0.3 存储的也都是近似值。0.1 和 0.2 的近似值相加,结果再经过一次舍入,得到的数与 0.3 的近似值并不是同一个双精度数,因此两者不相等。

而像 0.5、0.25、0.125、0.375 这样分母为 2 的幂的小数,在二进制中可以精确表示,它们之间的加减运算往往能得到精确的结果。

五、舍入与误差

舍入模式

由于浮点数的位数有限,大多数运算结果都无法精确表示,需要舍入到某个可表示的值。IEEE 754 定义了几种舍入模式:

  • 就近舍入,平分时取偶:舍入到最接近的可表示值;如果恰好处于两个可表示值的正中间,则舍入到最低位为 0 的那个。这是默认模式;
  • 向零舍入:直接截断;
  • 向正无穷舍入:总是向上取;
  • 向负无穷舍入:总是向下取。

默认的"平分时取偶"规则,也称为银行家舍入,它的目的是避免系统性偏差。如果总是将中间值向上舍入,大量运算累积后,结果会整体偏大;而取偶规则使得向上和向下的舍入在统计上大致平衡。

后三种模式主要用于区间运算:分别以向上和向下舍入计算同一个表达式,可以得到真实结果的上下界。

精确舍入的保证

IEEE 754 对基本运算做出了一个重要承诺:加、减、乘、除和平方根的结果,必须等于将精确的数学结果按照当前舍入模式舍入后的值。

也就是说,每一次基本运算本身的误差,都不超过结果最后一位的半个单位。单次运算是"尽可能精确"的。问题在于,多次运算的误差会累积,并且某些运算会放大已有的误差。

机器精度

衡量浮点数精度的一个常用指标是机器精度(Machine Epsilon),通常定义为 1 与大于 1 的最小可表示浮点数之间的差。

  • 单精度的机器精度约为 1.19 × 10⁻⁷;
  • 双精度的机器精度约为 2.22 × 10⁻¹⁶。

机器精度反映了浮点数的相对精度:任何一个规格化浮点数,与其相邻的可表示数之间的间隔,大约是该数本身乘以机器精度。

这意味着浮点数的绝对间隔随数值大小而变化。在 1 附近,双精度相邻数之间的间隔约为 10⁻¹⁶;而在 10¹⁶ 附近,间隔已经大于 1,此时连相邻的整数都无法区分了。

双精度浮点数能够精确表示的最大连续整数是 2⁵³,约为 9 × 10¹⁵。超过这个值后,就会出现无法表示的整数。这在一些将所有数值都以双精度浮点数存储的语言中尤其需要注意,例如处理较大的数据库编号或时间戳时,可能出现精度丢失。

六、常见的精度陷阱

1. 直接比较相等

由于舍入误差,经过不同计算路径得到的两个数学上相等的值,在浮点数中可能并不相等。直接使用等号比较浮点数,往往会得到意外的结果。

常见的做法是检查两个数的差是否足够小。但"足够小"的标准需要谨慎选择:

  • 使用固定的绝对误差阈值,例如差值小于 10⁻⁹,对于很大的数会过于严格,对于很小的数又会过于宽松;
  • 使用相对误差,即差值相对于两数大小的比例,在大多数情况下更合理,但在比较接近零的数时会失效;
  • 实践中常将两者结合:只要绝对误差或相对误差满足其一,即认为相等。

更根本的做法是,在算法设计上尽量避免依赖浮点数的精确相等。

2. 灾难性抵消

当两个非常接近的数相减时,它们高位相同的有效数字会相互抵消,结果只剩下低位那些原本就含有误差的数字,相对误差被急剧放大。这种现象称为灾难性抵消(Catastrophic Cancellation)。

一个经典例子是求解一元二次方程。求根公式为:

x = (−b ± √(b² − 4ac)) / 2a

当 b² 远大于 4ac 时,√(b² − 4ac) 非常接近 |b|。如果 b 为正数,计算 −b + √(b² − 4ac) 时,两个几乎相等的数相减,结果的有效数字大量丢失,得到的根可能完全不准确。

改进的方法是利用韦达定理:先用不会发生抵消的那个公式计算出一个根,再用两根之积等于 c/a 求出另一个根。这种通过数学变换避免相近数相减的技巧,在数值计算中非常常见。

3. 大数吃小数

当一个很大的数与一个很小的数相加时,如果小数的量级低于大数的精度,小数会在对齐指数时被完全舍去,加法的结果等于大数本身。

例如,在双精度下,10¹⁶ 加上 1,结果仍然是 10¹⁶。

这在累加大量数值时会造成严重问题。如果按顺序将一百万个很小的数加到一个逐渐增大的累加和上,随着累加和增大,后面加上的小数可能部分甚至完全被舍去,最终结果可能显著偏小。

常见的改进方法有:

  • 排序后累加:先将数值按绝对值从小到大排序,让小数先相互累加,再与大数相加;
  • 成对累加:将数组递归地分成两半分别求和,再将结果相加。这样误差的增长速度从与数据量成正比降低为与其对数成正比;
  • 补偿求和:最常用的是 Kahan 求和算法。它在累加过程中维护一个额外的补偿变量,记录每次加法因舍入而丢失的低位部分,并在下一次加法中将其加回。这使得累加的误差几乎与数据量无关,代价是每次加法需要额外进行几次运算。

4. 运算顺序影响结果

在实数中,加法满足结合律:(a + b) + c = a + (b + c)。但在浮点数中,由于每一步都有舍入,结合律不再成立。

以双精度为例:

(1e16 + 1) + (−1e16) = 0
1e16 + (1 + (−1e16)) = 0  或其他值,取决于中间结果的舍入
(1e16 + (−1e16)) + 1 = 1

这意味着,对同一组数据,改变计算的顺序可能得到不同的结果。这在并行计算中带来了一个实际问题:并行求和时,数据被划分给多个线程分别计算再合并,划分方式和合并顺序取决于线程数量和调度情况,因此同一个程序多次运行可能得到略有差异的结果。对于需要严格可重复结果的场景,必须固定计算顺序,或使用专门的可重复求和算法。

出于同样的原因,编译器在默认情况下不会随意重排浮点运算的顺序。一些编译器提供了"快速数学"选项,允许为了性能而重排运算、忽略 NaN 和无穷大的特殊处理等,这可能显著提升速度,但也可能改变计算结果,甚至破坏依赖于严格 IEEE 语义的代码,例如通过 x != x 检测 NaN 的逻辑。

5. 循环中的累积误差

以浮点数作为循环计数器时,误差会随迭代次数累积:

for (x = 0.0; x != 1.0; x += 0.1) { ... }

由于 0.1 无法精确表示,x 可能永远不会精确等于 1.0,导致无限循环。即使使用小于号代替不等号,循环的实际次数也可能比预期多一次或少一次。

正确的做法是使用整数作为循环计数器,在循环体内通过乘法计算浮点值,例如 x = i * 0.1。这样每次计算只有一次舍入,误差不会累积。

6. 十进制与二进制转换

将浮点数打印为十进制字符串,或者从十进制字符串解析浮点数时,也涉及舍入。

一个看似简单的问题是:打印一个双精度数时,应该输出多少位?输出位数太少,可能丢失信息,再解析回来得到的是另一个数;输出位数太多,会暴露出 0.1000000000000000055511151231257827 这样令人困惑的结果。

现代编程语言通常采用"最短往返表示":输出能够唯一确定该浮点数的最短十进制字符串,即重新解析后能够精确得到原来的值。对于双精度数,最多需要 17 位有效数字即可保证往返。这也是为什么大多数语言打印 0.1 时显示 0.1,而打印 0.1 + 0.2 时显示 0.30000000000000004:后者的最短往返表示确实需要这么多位。

在数据序列化和交换时,如果以文本格式保存浮点数,必须保证输出足够的有效位数,否则数据在保存和读取的过程中就会悄悄改变。

七、在实践中正确使用浮点数

金额计算不要使用二进制浮点数

金融计算对精度有严格要求:账目必须分毫不差,舍入方式也往往由法规或业务规则明确规定。二进制浮点数无法精确表示 0.01 这样的十进制小数,累积计算后可能出现一分钱甚至更大的偏差。

金额计算的常见做法有:

  • 使用整数表示最小货币单位:例如以"分"为单位存储金额,所有计算都在整数上进行,只在显示时转换为元;
  • 使用十进制浮点数或高精度十进制类型:许多语言和数据库提供了以十进制为基数的数值类型,能够精确表示十进制小数,并支持指定精度和舍入模式。IEEE 754 自 2008 年起也定义了十进制浮点格式。

选择合适的精度

双精度应当作为科学计算和工程计算的默认选择。它的精度足以应对大多数场景,在现代处理器上的运算速度与单精度相差不大。

单精度的优势在于存储空间减半、内存带宽需求减半,并且在支持向量化指令的处理器和图形处理器上,单精度的吞吐量通常是双精度的两倍或更高。在图形渲染、信号处理、机器学习等对精度要求不高但对吞吐量要求很高的场景中,单精度甚至更低的精度被广泛使用。

一种常见的策略是混合精度:数据以较低精度存储和传输,而关键的累加或求和步骤在较高精度下进行,兼顾性能和精度。

使用专门的数学函数

标准数学库为许多容易出现精度问题的计算提供了专门的函数。例如:

  • 计算 log(1 + x) 时,如果 x 很小,先计算 1 + x 会因大数吃小数而丢失 x 的精度。专门的 log1p 函数可以直接精确计算;
  • 类似地,expm1 用于精确计算 exp(x) − 1;
  • hypot 用于计算 √(x² + y²),它能够避免 x² 或 y² 在中间步骤中溢出或下溢;
  • 融合乘加(FMA)运算 a × b + c 在计算过程中只进行一次舍入,而不是乘法和加法各舍入一次,既提高了精度,在支持它的硬件上通常还能提高速度。

检查特殊值

在数值计算的关键位置,应当检查结果是否为 NaN 或无穷大。NaN 一旦产生就会在后续计算中不断传播,如果没有及时检测,最终可能导致错误的输出,并且很难追溯问题的源头。

一些开发环境和语言运行时允许开启浮点异常捕获,在产生 NaN、溢出、除以零等情况时立即中断程序,有助于在开发和调试阶段尽早发现问题。

八、低精度浮点格式

近年来,深度学习的快速发展催生了一系列新的低精度浮点格式。神经网络的训练和推理涉及海量的矩阵运算,对单个数值的精度要求并不高,但对计算吞吐量和内存带宽的要求极高。降低数值精度,可以在相同的硬件资源下处理更多的数据。

半精度

IEEE 754 定义的 半精度(FP16)格式为 16 位:1 位符号、5 位指数、10 位尾数。它的存储空间和带宽只有单精度的一半,在支持它的硬件上运算速度也更快。

但 FP16 的指数位只有 5 位,能表示的最大值约为 65504,最小规格化正数约为 6 × 10⁻⁵。在神经网络训练中,梯度值常常非常小,容易下溢为零;某些中间结果又可能很大,容易溢出。为此,使用 FP16 训练时通常需要配合损失缩放技术:在反向传播前将损失值乘以一个较大的系数,使梯度处于 FP16 能够表示的范围内,在更新参数前再除以该系数。

BF16

BF16(Brain Floating Point)格式同样为 16 位,但分配方式不同:1 位符号、8 位指数、7 位尾数。

BF16 的指数位与单精度完全相同,因此能够表示的数值范围与单精度一致,几乎不会出现溢出和下溢问题,通常不需要损失缩放。代价是尾数只有 7 位,精度比 FP16 低。实践表明,神经网络对数值范围比对精度更敏感,BF16 在训练中通常表现得比 FP16 更稳定。

BF16 的另一个优点是与单精度之间的转换非常简单:只需截取单精度数的高 16 位即可,反向转换只需在低位补零。

更低的精度

在推理阶段,以及越来越多的训练场景中,还使用了更低精度的格式,例如 8 位浮点数(FP8),以及 6 位、4 位的浮点格式。FP8 通常有两种变体:一种分配更多指数位以获得更大的范围,适合表示梯度;另一种分配更多尾数位以获得更高的精度,适合表示权重和激活值。

在如此低的精度下,单个数值能够表示的范围非常有限,通常需要配合缩放因子使用:将一组数值共享一个较高精度的缩放系数,每个数值只存储相对于该系数的低精度值。缩放的粒度可以是整个张量、每一行或每一列,也可以是固定大小的小块,粒度越细,精度损失越小,但缩放因子本身的存储和计算开销越大。

这些低精度格式的设计,体现了与 IEEE 754 不同的思路:不再追求通用的数值计算精度,而是针对特定的工作负载,在范围、精度、存储和计算效率之间寻找最合适的平衡。

结语

浮点数是计算机表示实数的一种工程折中。它用有限的位数,以科学计数法的方式覆盖了极大的数值范围,并在整个范围内保持近似一致的相对精度。IEEE 754 标准则为这种表示方式提供了严格统一的规则,使得数值计算的结果在不同平台上具有可预测性和可重复性。

但有限的位数必然带来近似。0.1 无法精确表示,加法不满足结合律,相近的数相减会放大误差,大数会吞没小数。这些都不是计算机的错误,而是浮点数这种表示方式的固有性质。

理解这些性质,就能在编程中避免常见的陷阱:不直接比较浮点数是否相等,不用浮点数表示金额,不用浮点数作为循环计数器,在累加大量数据时使用补偿算法,在关键计算中选用专门的数学函数。在大多数情况下,浮点数是可靠的工具;只有当使用者对它的行为有错误的期待时,它才会给出"错误"的结果。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。