14、浮点异常调试:NaN与Inf的追踪

浮点数这东西,看着简单,用起来坑不少。

我刚开始做嵌入式那几年,最怕的就是程序跑着跑着突然出个NaN或者Inf。NaN就是Not a Number,Inf是Infinity。说白了,就是计算结果不是个正经数了。你想想看,一个传感器采集回来的数据,经过几轮算法处理,突然变成NaN了,后面的控制逻辑全乱套。嗯,这种问题我遇到过不止一次。

14.1 浮点异常到底长什么样?

先看个最简单的例子。你写个除法,分母不小心变成0了:

#include <stdio.h>
#include <math.h>

int main() {
    float a = 1.0f;
    float b = 0.0f;
    float c = a / b;
    
    printf("c = %f\n", c);  // 输出 inf
    
    float d = 0.0f / 0.0f;
    printf("d = %f\n", d);  // 输出 nan
    
    return 0;
}

运行结果:

c = inf
d = nan

你看,1.0除以0.0得到Inf,0.0除以0.0得到NaN。这两个东西一旦出现在你的计算链里,后面的所有运算都会跟着遭殃。NaN加任何数还是NaN,Inf乘任何非零数还是Inf。这就是所谓的“污染传播”。

⚠️ 注意: 浮点除零不会触发硬件异常!它不像整数除零那样直接崩溃。程序会继续运行,但结果已经不对了。这是最坑的地方——你根本不知道什么时候出的问题。

14.2 NaN和Inf是怎么产生的?

我个人习惯把浮点异常的来源分成几类。这样排查起来思路清晰:

异常类型 常见原因 典型场景
Inf 除零、溢出 1.0/0.0、exp(1000)
-Inf 除零、负溢出 -1.0/0.0、log(0)
NaN 0/0、∞-∞、0*∞ sqrt(-1)、asin(2)
信号NaN 未初始化内存 malloc后未赋值直接使用

我在项目中遇到过最离谱的一次,是ADC采集的数据偶尔出现NaN。查了两天才发现,是DMA传输过程中有个字节被覆盖了,导致浮点数的位模式变成了NaN。嗯,硬件问题也能搞出NaN来。

14.3 如何检测NaN和Inf?

C标准库提供了几个宏,专门用来判断浮点数的状态:

#include <math.h>
#include <stdio.h>

void check_float(float x) {
    if (isnan(x)) {
        printf("检测到NaN!\n");
    }
    if (isinf(x)) {
        printf("检测到Inf!\n");
    }
    if (isfinite(x)) {
        printf("正常有限数\n");
    }
}

int main() {
    float a = 0.0f / 0.0f;
    float b = 1.0f / 0.0f;
    float c = 3.14f;
    
    check_float(a);  // NaN
    check_float(b);  // Inf
    check_float(c);  // 正常
    
    return 0;
}
💡 小技巧: 调试时可以在关键计算点插入断言。比如:assert(!isnan(result));。这样一旦出现NaN,程序立刻停下来,你就能看到调用栈。

14.4 实战:追踪NaN的源头

说个真实案例。我之前做电机控制算法,有个PI控制器输出偶尔变成NaN。代码大概长这样:

float pi_controller(float error, float kp, float ki) {
    static float integral = 0.0f;
    
    integral += error * 0.001f;  // 积分项
    float output = kp * error + ki * integral;
    
    return output;
}

表面上看没问题。但实际跑起来,有时候error是NaN,那integral也变成NaN,output自然也是NaN。问题是——error为什么是NaN?

我当时的排查步骤:

  1. 加断言:在pi_controller入口处检查error是否为NaN
  2. 加日志:记录每次error的值,看什么时候开始变NaN
  3. 反向追踪:发现error来自传感器数据融合模块,那里有个卡尔曼滤波
  4. 定位根因:卡尔曼滤波的协方差矩阵更新时,出现了数值不稳定,导致矩阵元素变成NaN

最终解决方案是给协方差矩阵加了个下限保护:

// 防止协方差矩阵出现负值或NaN
for (int i = 0; i < N; i++) {
    if (P[i][i] < 1e-6f) {
        P[i][i] = 1e-6f;
    }
}

你看,问题不在PI控制器本身,而在上游的数据质量。所以排查NaN的时候,一定要顺着调用链往上找。

14.5 浮点异常处理的进阶技巧

C99标准提供了fenv.h,可以捕获浮点异常。不过嵌入式编译器支持程度不一,我一般这样用:

#include <fenv.h>
#include <stdio.h>

#pragma STDC FENV_ACCESS ON

void enable_fpe_exceptions() {
    feclearexcept(FE_ALL_EXCEPT);
    feenableexcept(FE_DIVBYZERO | FE_INVALID | FE_OVERFLOW);
}

int main() {
    enable_fpe_exceptions();
    
    float a = 1.0f;
    float b = 0.0f;
    float c = a / b;  // 这里会触发SIGFPE信号
    
    return 0;
}
⚠️ 注意: 不是所有编译器都支持feenableexcept。GCC需要加-lm链接数学库。有些MCU的浮点单元根本不支持异常捕获。用之前一定要查手册。

14.6 预防胜于治疗

与其等NaN出现了再追,不如一开始就防住。我总结了几个习惯:

  • 检查除数:除法之前判断分母是否接近0
  • 限制输入范围:sqrt、log、asin等函数的输入必须在有效范围内
  • 使用饱和运算:积分项加限幅,防止溢出
  • 初始化浮点变量:不要依赖默认值,显式赋0
  • 定期检查:在关键计算节点插入isnan/isinf检查

我曾经因为一个浮点变量没初始化,在Release模式下跑了一周才复现问题。从那以后,我所有浮点变量都强制初始化。哪怕知道后面会赋值,也先给个0。

14.7 知识体系总览

下面这张图是我自己整理的浮点异常调试思路,你可以参考:

浮点异常调试知识体系 浮点异常 NaN (Not a Number) Inf (Infinity) 0/0, ∞-∞, 0*∞ sqrt(-1), asin(2) 除零 (1.0/0.0) 溢出 (exp(1000)) 调试方法 isnan() / isinf() fenv.h 异常捕获 断言 + 日志追踪

14.8 避坑指南

最后分享几个我踩过的坑:

我曾经... 在STM32上做浮点运算,发现Release版和Debug版行为不一样。查了半天,发现是编译器优化把浮点比较给优化掉了。比如 if (x != x) 判断NaN,Release下直接被跳过了。后来改用 isnan() 才解决。

我曾经... 在ARM Cortex-M4上做FFT运算,结果数组里出现NaN。排查发现是浮点单元FPU的配置问题——没使能FPU,所有浮点运算都用软件模拟,精度不够导致溢出。嗯,这种硬件配置问题最容易被忽略。

我曾经... 在通信协议里传输浮点数,接收端解析出来全是NaN。原因是发送端和接收端的字节序不一样。大端小端的问题,在浮点数上表现得特别诡异。从那以后,我传输浮点数一律转成字符串或者用IEEE 754的十六进制表示。

浮点异常调试,说白了就是三个字:早发现。等NaN污染了整个计算链再回头找,那工作量就大了。我的习惯是在每个模块的出口都加一个检查点,一旦发现异常立刻打日志。这样问题出现时,你能第一时间知道是哪个模块出的问题。


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