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?
我当时的排查步骤:
- 加断言:在pi_controller入口处检查error是否为NaN
- 加日志:记录每次error的值,看什么时候开始变NaN
- 反向追踪:发现error来自传感器数据融合模块,那里有个卡尔曼滤波
- 定位根因:卡尔曼滤波的协方差矩阵更新时,出现了数值不稳定,导致矩阵元素变成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 知识体系总览
下面这张图是我自己整理的浮点异常调试思路,你可以参考:
14.8 避坑指南
最后分享几个我踩过的坑:
我曾经... 在STM32上做浮点运算,发现Release版和Debug版行为不一样。查了半天,发现是编译器优化把浮点比较给优化掉了。比如 if (x != x) 判断NaN,Release下直接被跳过了。后来改用 isnan() 才解决。
我曾经... 在ARM Cortex-M4上做FFT运算,结果数组里出现NaN。排查发现是浮点单元FPU的配置问题——没使能FPU,所有浮点运算都用软件模拟,精度不够导致溢出。嗯,这种硬件配置问题最容易被忽略。
我曾经... 在通信协议里传输浮点数,接收端解析出来全是NaN。原因是发送端和接收端的字节序不一样。大端小端的问题,在浮点数上表现得特别诡异。从那以后,我传输浮点数一律转成字符串或者用IEEE 754的十六进制表示。
浮点异常调试,说白了就是三个字:早发现。等NaN污染了整个计算链再回头找,那工作量就大了。我的习惯是在每个模块的出口都加一个检查点,一旦发现异常立刻打日志。这样问题出现时,你能第一时间知道是哪个模块出的问题。
公众号:蓝海资料掘金营,微信deep3321