2.2 浮点表示与数值误差
地心引擎的温控读数出现了肉眼看不出的偏差,阿花决定从数字的机器表示开始追查。
整数表示用固定宽度覆盖一段连续整数。浮点格式把有限位分给有效数字和指数,用离散的可表示值覆盖更大的数量级。
浮点数不是“带小数点的实数”
控制器恢复供电后,传感器显示 0.1。这个十进制文本先被解析成附近的二进制浮点数,参与运算后再格式化为文本。屏幕仍可能只显示 0.1,但内部值并不一定等于数学上的十分之一。
任何固定宽度格式都只有有限个位模式,不可能精确表示所有实数。浮点设计接受这个事实,在表示范围与相对精度之间做权衡。它更像二进制科学记数法,而不是无限精确的小数容器。
binary32 与 binary64 怎样分配位
IEEE 754 的两种常用二进制格式是:
| 格式 | 常见语言类型 | 符号位 | 指数位 | fraction 位 | 十进制有效数字约数 |
|---|---|---|---|---|---|
| binary32 | C/Java float | 1 | 8 | 23 | 6–9 |
| binary64 | C/Java double | 1 | 11 | 52 | 15–17 |
“6–9”不是每个 binary32 都有九位十进制精度。它表达两个不同保证:约 6 位十进制数可可靠地往返到 binary32,而唯一标识任意 binary32 值通常最多需要 9 位十进制数字。
对规格化有限值,若符号位为 s,指数字段为 E,fraction 整数为 F,fraction 位数为 p,则值为:
(-1)^s × (1 + F / 2^p) × 2^(E - bias)binary32 的 p 为 23、bias 为 127;binary64 的 p 为 52、bias 为 1023。开头的 1 不必存储,常称为隐含位或 hidden bit。
例如 5.25 的二进制是 101.01,规格化为 1.0101 × 2^2。binary32 中:
符号 s = 0
指数字段 E = 2 + 127 = 129 = 10000001₂
fraction = 01010000000000000000000
位模式 = 0 10000001 01010000000000000000000
十六进制 = 0x40A80000保留指数字段承载特殊值
指数字段全 0 或全 1 时,不使用普通规格化公式:
| 指数字段 | fraction | 含义 |
|---|---|---|
| 全 0 | 全 0 | +0 或 -0 |
| 全 0 | 非 0 | subnormal(次正规数) |
| 全 1 | 全 0 | +∞ 或 -∞ |
| 全 1 | 非 0 | NaN |
次正规数没有隐含的前导 1,其值为:
(-1)^s × (F / 2^p) × 2^(1 - bias)它让数值在接近零时逐渐损失精度,而不是从最小规格化数突然跳到零。NaN 用于表示无效结果,如 0.0 / 0.0;NaN 与任何值的有序比较都为假,甚至 NaN == NaN 也是假,应使用 isnan 判断。
正负零比较相等,却能在某些运算中保留方向信息,例如 1.0 / +0.0 与 1.0 / -0.0 分别产生正负无穷。是否允许这些结果,还取决于语言、运行时和浮点异常配置。
用 memcpy 查看对象表示
下面的 C17 程序检查 binary32 常见位模式。它先用 sizeof 和 FLT_RADIX 验证前提,再通过 memcpy 复制对象表示,避开不兼容指针类型带来的严格别名问题。
#include <float.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
static void print_binary32(float value) {
uint32_t bits = 0;
memcpy(&bits, &value, sizeof bits);
uint32_t sign = bits >> 31;
uint32_t exponent = (bits >> 23) & UINT32_C(0xFF);
uint32_t fraction = bits & UINT32_C(0x7FFFFF);
printf("value=% .9g bits=0x%08" PRIX32
" sign=%" PRIu32 " exponent=%" PRIu32
" fraction=0x%06" PRIX32 "\n",
value, bits, sign, exponent, fraction);
}
int main(void) {
if (sizeof(float) != sizeof(uint32_t) || FLT_RADIX != 2
|| FLT_MANT_DIG != 24 || FLT_MAX_EXP != 128) {
fputs("this example requires IEEE 754 binary32 float\n", stderr);
return 1;
}
print_binary32(5.25f);
print_binary32(0.1f);
print_binary32(-0.0f);
return 0;
}在满足前提的机器上,第一行的 bits 是 0x40A80000。0.1f 的 fraction 末尾经过舍入,不会是无限循环展开的完整结果。
为什么传感器上的十进制 0.1 不能精确保存
控制器屏幕仍写着 0.1,那只是格式化后的短文本。把内部 binary64 值展开,它落在数学上的十分之一附近;误差来自表示网格,不是某次加法“粗心算错”。
有限二进制小数只能精确表示约分后分母为 2 的幂的有理数。十进制 0.1 = 1/10 的分母含因子 5,因此它的二进制展开无限循环:
0.0001100110011001100110011...₂解析器只能把它舍入到相邻的可表示值。binary64 中的 0.1 和 0.2 各自已经是近似值,它们相加再舍入后,通常得到:
>>> format(0.1 + 0.2, ".17g")
'0.30000000000000004'
>>> 0.1 + 0.2 == 0.3
False这不是所有浮点加法都“不可信”,而是每一步都遵守格式和舍入规则。0.5、0.25 等二进制有限小数可以精确表示;许多对同一个已存值的比较也有明确用途。
还要注意精度取决于数量级。binary32 在 1 附近相邻值间隔约为 2^-23,到了 2^24 附近,间隔已经大到不能表示每一个整数。范围很大不等于精度也很高。
比较时先定义“足够接近”
“浮点数永远不能用 ==”过于绝对。检查同一常量、精确的零结果、缓存键或协议规定的特殊值时,精确比较可能正是需求;但近似计算的结果通常需要误差模型。
只用固定绝对误差会在大数处过严,在接近零时又可能需要单独下限。一个常见策略同时使用相对和绝对容差:
#include <math.h>
#include <stdbool.h>
static bool nearly_equal(double left, double right,
double relative_tolerance,
double absolute_tolerance) {
if (relative_tolerance < 0.0 || absolute_tolerance < 0.0) {
return false;
}
if (left == right) {
return true; /* 也覆盖同号无穷与正负零 */
}
if (!isfinite(left) || !isfinite(right)) {
return false;
}
double difference = fabs(left - right);
double scale = fmax(fabs(left), fabs(right));
return difference <= fmax(absolute_tolerance,
relative_tolerance * scale);
}容差不是随手写一个 1e-9。它应来自输入误差、算法稳定性和业务单位。例如温度传感器可能容忍 0.01°C,几何计算常结合相对尺度,金钱则通常采用最小货币单位的整数或明确舍入规则的十进制类型。
运算顺序会改变舍入
浮点加法满足交换律的常见有限情形,并不满足实数加法的结合律:
(a + b) + c 可能不等于 a + (b + c)当大数与很小的数相加,小数部分可能落在当前间隔以下而消失。长序列求和时,顺序、分组与补偿算法都会影响误差。
values = [1e16, 1.0, -1e16]
print(sum(values))
print(sum(reversed(values)))这两种顺序在典型 binary64 计算中都可能丢掉 1.0;换成 math.fsum(values) 可用更精确的累加策略得到 1.0。这并不意味着一种函数适合所有场景:吞吐量、可复现性和误差上界仍要结合任务选择。
乘加融合(FMA)只舍入一次,可能比拆成乘法与加法更精确,也可能让结果与未使用 FMA 的平台末位不同。并行归约改变加法树,同样可能改变末位。要求逐 bit 可复现时,必须控制算法、编译选项、硬件路径和舍入环境。
四种基本舍入方向
IEEE 754 定义的基本舍入方向包括:
- 舍入到最近值,正好居中时取偶数末位;这是常见默认模式;
- 向零舍入;
- 向正无穷舍入;
- 向负无穷舍入。
舍入模式会影响边界结果,但切换运行时环境并不保证所有编译期常量折叠和优化都自动服从它。C 程序若依赖动态舍入环境,需要使用 <fenv.h>、正确的实现支持与相应编译设置,并针对目标工具链验证。
选数值类型之前先问用途
| 场景 | 常见选择 | 原因 |
|---|---|---|
| 图形、机器学习张量 | binary16/32/64 | 硬件支持强,可接受受控近似 |
| 科学计算 | binary64 起步,配合误差分析 | 范围与精度平衡,算法成熟 |
| 货币记账 | 整数最小单位或十进制定点/小数 | 十进制舍入规则可明确审计 |
| 概率、测量值 | 浮点并携带误差语义 | 输入本来就是近似量 |
| 哈希键、标识符 | 整数或规范化文本 | 不应让近似相等参与身份判断 |
十进制类型也有有限精度和舍入,只是能精确表示常见十进制分数。分数类型能精确保存有理数,但分子分母可能迅速增长。不存在脱离约束的“万能精确类型”。
可执行的分类与比较测试
import math
assert 0.5 + 0.25 == 0.75
assert 0.1 + 0.2 != 0.3
assert math.isclose(0.1 + 0.2, 0.3,
rel_tol=1e-12, abs_tol=0.0)
nan = float("nan")
assert nan != nan
assert math.isnan(nan)
assert math.isinf(float("inf"))
assert 0.0 == -0.0
assert math.copysign(1.0, -0.0) == -1.0
assert math.fsum([1e16, 1.0, -1e16]) == 1.0这些断言覆盖精确二进制分数、十进制近似、NaN、无穷、负零和稳健求和。业务测试还应加入问题尺度下的误差上界,而不只是几个教科书常量。
动手拆解误差
- 手算
-6.75f的 binary32 符号、指数和 fraction,并写出十六进制位模式。 - 用
nextafter找到 1.0 与1e20各自相邻的 binary64 值,比较间隔。 - 构造一组让普通
sum与math.fsum明显不同的数据,并解释丢失发生在哪一步。 - 为一个经纬度比较接口选择相对/绝对容差,写清单位和选择依据。
- 解释为什么 NaN 不能作为“缺失值等于缺失值”的普通哨兵参与比较。
表示规则将进入逻辑电路
整数和浮点都已还原为可执行的位级规则。下一章进入数字逻辑:门电路怎样组合出加法器、寄存器与状态机,软件中的一次运算又怎样落到时钟驱动的硬件上。