浮点数的迷雾:比较、异常值与精度陷阱

浮点数这东西,看着简单,用起来全是坑。我入行第三年就吃过一次大亏——一个电机控制算法,在实验室跑得好好的,到了客户现场就间歇性抽风。查了三天,最后发现是浮点数比较出了问题。从那以后,我对浮点数就多了一份敬畏。

今天咱们就把浮点数的几个核心陷阱掰开揉碎讲清楚。说白了就三件事:怎么比、怎么处理特殊值、怎么控制精度。

一、浮点数比较:别直接用 ==

很多新手会这么写:

float a = 0.1f;
float b = 0.1f;
if (a == b) {
    // 你以为会进来?
}

嗯,这个例子可能能过。但换个场景试试:

float a = 0.1f;
float b = 0.1f + 0.2f - 0.2f;  // 你觉得结果是多少?
if (a == b) {
    printf("相等\n");
} else {
    printf("不相等\n");
}

结果大概率是「不相等」。为什么?因为浮点数的二进制表示天生就不精确。0.1 在二进制里是个无限循环小数,存进去的时候就已经被截断了。

核心原则:永远不要直接用 == 比较浮点数。永远。

那怎么比?用「绝对误差」或「相对误差」:

#include <math.h>
#include <float.h>

// 绝对误差比较
int float_eq_abs(float a, float b, float epsilon) {
    return fabsf(a - b) < epsilon;
}

// 相对误差比较(推荐)
int float_eq_rel(float a, float b, float epsilon) {
    float diff = fabsf(a - b);
    float max_val = fmaxf(fabsf(a), fabsf(b));
    return diff <= max_val * epsilon;
}

// 使用示例
if (float_eq_rel(a, b, 1e-6f)) {
    // 认为相等
}

我个人习惯:在嵌入式场景下,如果数值范围已知,我更喜欢用绝对误差。比如电压值在 0~3.3V 之间,epsilon 取 0.001 就够用了。相对误差在数值接近零时会出问题,要注意。

二、NaN 和 Infinity:你代码里的隐形炸弹

NaN(Not a Number)和 Infinity 是浮点数的两个特殊值。它们不是错误,是标准规定的合法值。但如果你不处理,它们会像病毒一样传播。

先看怎么产生的:

float inf = 1.0f / 0.0f;      // +Infinity
float nan = 0.0f / 0.0f;      // NaN
float neg_inf = -1.0f / 0.0f; // -Infinity

NaN 有个特别坑的特性——它不等于任何值,包括它自己:

float x = nan;
if (x == x) {
    // 这行永远不会执行!
    printf("这不可能\n");
}

我在项目中遇到过一个问题:传感器偶尔返回 NaN,然后整个控制链路全乱了。因为 NaN 参与任何运算结果都是 NaN,一路传下去,最后执行器直接飞车。

正确的做法是:在关键计算节点做检查。

#include <math.h>

int is_safe_float(float x) {
    // 检查是否有限数(不是 NaN 也不是 Infinity)
    return isfinite(x);
}

// 使用示例
float sensor_val = read_sensor();
if (!is_safe_float(sensor_val)) {
    // 用上一次有效值代替,或者报错
    sensor_val = last_valid_value;
    log_error("传感器返回异常值");
}

避坑指南:我曾经在一个 PID 控制器里忘了检查积分项的 NaN,结果积分项变成 NaN 后,整个输出也变成 NaN,电机直接满速运转。嗯,那次差点把机械臂干报废。从那以后,我所有浮点运算的输入输出都会加 isfinite 检查。

三、精度问题:你以为的精度不是你以为的

float 只有 23 位尾数,大约 7 位十进制有效数字。double 有 52 位尾数,大约 15 位有效数字。很多人知道这个数字,但没意识到这意味着什么。

看个例子:

float a = 16777216.0f;  // 2^24
float b = a + 1.0f;
printf("a = %f\n", a);  // 16777216.000000
printf("b = %f\n", b);  // 16777216.000000  —— 没变!
printf("a == b: %d\n", a == b);  // 1(相等!)

为什么?因为 float 的精度不够,16777217 无法精确表示,被舍入回了 16777216。这不是 bug,是浮点数的物理限制。

精度损失的常见场景:

场景问题建议
大数加小数小数被吃掉先排序,从小加到大
相近数相减有效数字丢失用数学变换避免直接相减
累加大量小值误差累积用 Kahan 求和算法
循环迭代误差逐级放大定期重置或使用更高精度

Kahan 求和算法是个好东西,我经常在需要累加大量浮点数的场景用:

float kahan_sum(float *data, int n) {
    float sum = 0.0f;
    float c = 0.0f;  // 补偿项
    for (int i = 0; i < n; i++) {
        float y = data[i] - c;
        float t = sum + y;
        c = (t - sum) - y;  // 计算丢失的低位
        sum = t;
    }
    return sum;
}

我的经验:在嵌入式系统里,能用 int 就别用 float。比如电压采集,ADC 出来是 12 位整数,直接做整数运算,最后再转浮点显示。这样既快又准。你想想看,为了省那点开发时间,后面花三天查浮点 bug,不值当。

四、知识体系总览

下面这张图把浮点数的核心陷阱串起来了。我建议你把它存下来,写代码前瞄一眼:

浮点数陷阱知识体系 比较陷阱 特殊值处理 精度损失 不要用 == 直接比较 使用绝对/相对误差 注意 epsilon 的选择 NaN ≠ NaN(不自等) Infinity 参与运算 用 isfinite() 检查 大数吃小数 相近数相减丢精度 Kahan 求和算法 核心原则:能不用浮点就不用,用了就要处处设防

浮点数不是数学里的实数,它是有限精度的近似表示。接受这个事实,你的代码就会少很多莫名其妙的 bug。记住三句话:比较用误差、运算前检查、精度要心里有数。


公众号:蓝海资料掘金营,微信deep3321