IEEE 754 如何存储实数?
理解 binary32、binary64、舍入、NaN 与累加误差
IEEE 754 如何存储实数?
本文以教学为目的。硬件、编译选项和语言实现可能改变细节,重要行为请在自己的环境中确认。
初学者常对 0.1 + 0.2 不完全等于 0.3 感到意外。计算机并不是不会加法,而是必须把无限多个实数放进有限的位模式,因此大多数值只能用邻近值表示。
IEEE 754 不只规定位排列,也定义运算、舍入、无穷大、NaN、渐进式下溢、转换与异常状态。本文会拆解 binary32 和 binary64,再整理比较、累加、金额与序列化时的实用原则。
定点数与浮点数
定点数预先固定小数点位置。若整数 12345 永远代表两位小数,实际值就是 123.45。它适合固定尺度,但同一格式难以同时覆盖极小与极大的数。
浮点数类似科学记数法。
6.25 = 1.5625 × 2²
0.15625 = 1.25 × 2⁻³
有效数字和指数分开存储,范围因此很宽,但相邻值之间的距离并不固定。
IEEE 754 涵盖的内容
IEEE 754-2019 规范二进制与十进制格式、运算、转换、舍入、异常条件和默认处理。实现可以使用硬件、软件或两者结合。
| 名称 | 常见语言类型 | 总位数 | 符号 | 指数 | 存储 fraction | 精度 |
|---|---|---|---|---|---|---|
binary32 | 通常是 float | 32 | 1 | 8 | 23 | 24 位 |
binary64 | 通常是 double | 64 | 1 | 11 | 52 | 53 位 |
“通常”很重要。C 和 C++ 的类型名称本身不保证所有平台都使用这些格式。C++ 可通过 std::numeric_limits<T>::is_iec559 等属性检查实现。
binary32 的三个字段
1 位符号 | 8 位指数 | 23 位 fraction
普通有限值的公式是:
(-1)^s × (1.fraction)₂ × 2^(E - 127)
指数使用 127 的 bias。正规化二进制有效数字的开头必定是 1,因此不必实际存储。23 位 fraction 因而提供 24 位精度。binary64 的 bias 是 1023,52 位 fraction 提供 53 位精度。
手工编码 6.25
6.25₁₀ = 110.01₂ = 1.1001₂ × 2²
符号为 0。存储指数是 2 + 127 = 129,即 10000001₂。省略开头的 1 后,fraction 是 1001000...0。
0 10000001 10010000000000000000000
十六进制为 0x40C80000。
float value = 6.25f;
std::uint32_t bits = std::bit_cast<std::uint32_t>(value);
特殊指数模式
| 指数 | fraction | 含义 |
|---|---|---|
| 全为 0 | 全为 0 | 带符号的 0 |
| 全为 0 | 非 0 | subnormal 数 |
| 1 到 254 | 任意 | 正规化有限数 |
| 全为 1 | 全为 0 | 正或负无穷大 |
| 全为 1 | 非 0 | NaN |
因此 binary32 的正规化实际指数范围是 -126 到 127。
正零与负零
指数和 fraction 都为 0 时仍保留符号,所以同时存在 +0 与 -0。普通比较认为两者相等,但某些运算会保留方向。
double p = 0.0;
double n = -0.0;
std::cout << (p == n) << '\n';
std::cout << std::signbit(n) << '\n';
std::cout << 1.0 / p << '\n';
std::cout << 1.0 / n << '\n';
一般 IEEE 行为下,最后两个结果分别为正无穷大和负无穷大。
subnormal 与渐进式下溢
如果靠近 0 时仍要求开头为 1,最小正规化数与 0 之间会留下空隙。存储指数为 0 且 fraction 非 0 时改用:
(-1)^s × (0.fraction)₂ × 2^-126
subnormal 数以逐步失去精度的方式接近 0。binary32 最小正规化正数约为 1.17549435 × 10^-38,最小 subnormal 正数约为 1.40129846 × 10^-45。
部分 SIMD 或 GPU 环境为了性能会把 subnormal 直接视为 0,数值程序必须确认此模式。
无穷大与 NaN
指数全为 1 且 fraction 为 0 时表示无穷大。溢出或非零有限值除以 0 可能在默认处理下产生无穷大。
fraction 非 0 时表示 NaN,Not a Number。0.0 / 0.0 或负数的实数平方根都可能产生 NaN。
NaN 没有普通顺序,甚至与自己比较也不相等。
double nan = std::numeric_limits<double>::quiet_NaN();
std::cout << (nan == nan) << '\n';
std::cout << std::isnan(nan) << '\n';
应使用 std::isnan 判断。quiet NaN、signaling NaN 和 payload 的支持取决于语言与硬件。
为什么 0.1 不能精确表示
0.1 等于 1 / 10。最简分数要成为有限二进制小数,分母的质因数只能有 2。10 还含有 5,因此二进制表示会循环。
0.1₁₀ = 0.000110011001100110011...₂
binary64 只保留 53 位有效数字,其余部分必须舍入。
std::cout << std::setprecision(20) << 0.1 << '\n';
常见结果为 0.10000000000000000555。0.1 + 0.2 的差异来自输入和加法结果各自的舍入。
数值间距随大小增加
浮点数并不均匀分布。binary32 有 24 位精度,因此到 16,777,216 为止的整数都能精确表示。在更大的区域中,相邻值开始相差 2 或更多。
float a = 16'777'216.0f;
float b = a + 1.0f;
std::cout << (a == b) << '\n';
普通 binary32 会输出真,因为该大小附近没有独立的 a + 1。
epsilon 不是万能容差
std::numeric_limits<double>::epsilon() 是 1 与下一个可表示 double 的距离,不适合直接套用到所有大小。实际程序常结合接近 0 时的绝对误差与其他范围的相对误差。
bool almostEqual(double a, double b,
double absoluteTolerance = 1e-12,
double relativeTolerance = 1e-9)
{
double difference = std::abs(a - b);
if (difference <= absoluteTolerance)
{
return true;
}
double scale = std::max(std::abs(a), std::abs(b));
return difference <= scale * relativeTolerance;
}
容差必须根据坐标、时间、物理量等问题领域决定。
舍入模式与五种异常状态
精确结果落在两个可表示值之间时,舍入方向决定存储结果。常见默认是取最近值,完全居中时选择最后一位为偶数的一侧。
C 与 C++ 通过 <fenv.h> 和 <cfenv> 访问实现支持的舍入方向和状态标志。
五种 IEEE 异常状态是 invalid operation、division by zero、overflow、underflow、inexact。这通常不是 C++ throw,而是产生默认结果并记录标志。
std::feclearexcept(FE_ALL_EXCEPT);
volatile double one = 1.0;
volatile double zero = 0.0;
volatile double result = one / zero;
if (std::fetestexcept(FE_DIVBYZERO))
{
std::cout << "division by zero flag\n";
}
运算顺序与累加误差
每个中间结果都会舍入,所以有限精度加法不一定满足结合律。
double large = 1e16;
double small = 1.0;
double first = (large + small) - large;
double second = small + (large - large);
第一个表达式可能丢失 small。大量累加时可以考虑 Kahan summation、相近大小配对相加或使用更宽的累加类型。
金额与精确数据
二进制浮点数很适合图形、坐标和许多科学计算,但通常不适合需要精确十进制舍入的金额。
- 用整数存储元、分等最小单位。
- 使用 decimal 或定点类型。
- 明确规定舍入的时机与方向。
标识符和必须精确的计数器也应使用整数。
序列化与实现检查
跨越文件或网络边界时,应明确 binary32 或 binary64、协议字节序,以及是否保留带符号 0 与 NaN payload。文本往返可用 C++ max_digits10 决定足够位数。
std::numeric_limits 可检查 is_iec559、radix、digits、min()、denorm_min() 和 max()。浮点类型的 min() 是最小正规化正值;最负有限值应查询 lowest()。
参考资料
- IEEE 754-2019 概要
- Oracle Numerical Computation Guide
- What Every Computer Scientist Should Know About Floating-Point Arithmetic
- C++ 0
- C++ floating-point environment
总结
IEEE 754 使用符号、有效数字和指数,在有限位数内覆盖极大范围。代价是近似、随数值大小改变的间距、特殊值,以及每一步可能发生的舍入。理解同一套模型后,0.1 + 0.2、NaN 比较、大坐标抖动和累加误差就不再是互不相关的怪现象。