嵌入式浮点陷阱:NaN传播导致温度保护失效的排查与防御#
你是否遇到过这些问题?
- ADC 采集回来的温度值,明明校准系数是对的,结果算出来却是
NaN,整个控制链路瞬间瘫痪- 温度明明已经超限,
if (temp > MAX)却像没看见一样,保护逻辑完全失效- 固件升级后,掉电保存的浮点参数读出来全是
inf或乱码- 调试器里看到的变量值”不是数字”,但代码却照常运行,没有任何报错
如果你遇到过以上任何一个场景,这篇文章或许能帮你省下几天的排查时间。
背景#
最近在调试一块基于 STM32H743 的主控板。板上 FPGA 负责高速 ADC 采样,MCU 通过 SPI 定期读取采样结果。采样值被用于 NTC 热敏电阻的温度换算,温度数据进一步参与插值补偿和 PID 控制。一切看起来都很常规——直到某天测试时,系统突然出现完全离谱的行为:温度明明已经超限,保护逻辑却像”睡着”了一样毫无反应,最终导致后级设备过热。
排查过程持续了整整两天。我怀疑过硬件 SPI 时序、怀疑过 RTOS 任务调度、甚至怀疑过 FPGA 逻辑有 Bug。最终,问题根源指向了一个极其隐蔽的元凶:浮点运算中悄悄出现了一个 NaN,然后它像病毒一样在计算链路中传播,并利用 IEEE 754 标准的”特殊通行证”轻松绕过了所有数值上下限比较。
这篇文章把完整的故障过程、根本原因和防御方案记录下来,希望能帮到遇到类似问题的嵌入式同行。
1. 症状:温度保护形同虚设#
系统里有一个简单的温度上下限保护函数,长这样:
#define TEMP_MAX 85.0f
#define TEMP_MIN -20.0f
float limit_temperature(float temp)
{
if (temp > TEMP_MAX) {
temp = TEMP_MAX;
} else if (temp < TEMP_MIN) {
temp = TEMP_MIN;
}
return temp;
}c逻辑很直白:超上限就钳位到上限,超下限就钳位到下限,范围内的值保持原样。
但实际运行时,我发现监控日志里偶尔会蹦出完全离谱的温度值,比如 -273.15°C,甚至有时候直接显示为 nan。更诡异的是,当温度变量变成 NaN 后,它就像穿了”隐身衣”一样,直接穿过了 if 判断,根本没有进入任何一个分支。于是,一个无意义的 NaN 继续流向后面的温度补偿表和 PID 控制器,引起一系列混乱:
- 温度补偿表索引变成了随机值,导致输出剧烈抖动
- PID 积分项累积了一个
NaN,整个控制输出卡死在某个电平 - 后级功率管因为占空比异常而过热
调试过程的关键转折点:我一开始一直在日志里搜索”温度是否超出 ±85°C”,却没想到有些时候温度根本就已经不是一个有效的数值了。直到我在调试器中给 limit_temperature() 函数打断点,观察 temp 参数的实际值时,才第一次看到那个刺眼的红字——NaN。
2. 回溯:NaN 从哪里来?#
发现 NaN 后,我沿着调用链往上追溯,最终锁定了温度值的来源——一段 NTC 热敏电阻的换算代码。
2.1 SPI 读取 ADC 值#
MCU 通过 SPI 从 FPGA 读取一个 16 位的 ADC 原始值:
uint16_t adc_raw;
if (HAL_SPI_Receive(&hspi2, (uint8_t*)&adc_raw, 2, HAL_MAX_DELAY) != HAL_OK) {
// 通信失败,需要处理,不能直接使用 adc_raw!
// HAL 在超时时可能不会刷新 adc_raw,若直接使用将导致随机错误值
}c这里存在两个隐患:
- FPGA 在采样异常时(例如 ADC 时序错乱、输入信号悬空)会把数据线拉到全 1,也就是
0xFFFF - SPI 通信受到干扰时,可能读到完全随机的错误码,甚至帧错位导致半字节颠倒
而我们没有对 SPI 接收的数据做任何合法性检查,直接就拿去计算温度了。
2.2 NTC 温度换算中的 NaN 诞生路径#
电路采用常见的 NTC 上拉电阻分压方式,NTC 接在 GND 和 ADC 输入之间,上拉电阻 R_pullup 接 VREF。ADC 码值换算为 NTC 电阻的公式为:
#define R_PULLUP 10000.0f // 10kΩ
#define VREF 3.3f
#define ADC_MAX 65535.0f // 16位
float adc_to_resistance(uint16_t adc)
{
float v_ratio = (float)adc / ADC_MAX;
float r_ntc = (R_PULLUP * v_ratio) / (1.0f - v_ratio);
return r_ntc;
}c温度换算采用 B 参数公式:
#define T0 298.15f // 25°C 开尔文
#define R0 10000.0f // 25°C 电阻
#define B 3950.0f
float resistance_to_temp(float r_ntc)
{
float ln_r = logf(r_ntc / R0);
float temp_k = 1.0f / (1.0f / T0 + ln_r / B);
return temp_k - 273.15f; // 摄氏度
}c那么,NaN 到底是怎么诞生的?我梳理了三条主要路径:
路径一:有符号数误读(最隐蔽,也是本文故障的根源)
当 SPI 读到 0xFFFF,如果代码中某处误将其强制转换为 int16_t(例如 (int16_t)adc_raw),则 0xFFFF 被解析为 -1。此时:
v_ratio = -1.0f / 65535.0f(负值,约 -1.5e-5)r_ntc = (R_PULLUP * v_ratio) / (1.0f - v_ratio)→ 负电阻- 紧接着
logf(负电阻)在 C 标准库中直接返回NaN(并置errno = EDOM)
这个 NaN 就是绕过保护的真凶。
路径二:电气噪声导致零值附近抖动
FPGA 的 ADC 输入端受到干扰时,adc 值可能在零附近剧烈抖动,偶尔读取到 0x0001 甚至 0x0000 附近的随机值。如果 adc 被错误地处理为有符号数,负值同样会导致 logf 返回 NaN。
路径三:SPI 帧错位导致无效码值
FPGA 和 SPI 之间的帧同步如果发生错位,可能得到一个半字节颠倒的无效数(例如 0x8000 这类含有高位的值)。这些值经过浮点运算后,在极端情况下可能产生 0.0f / 0.0f 或 inf - inf 等操作,结果也是 NaN。
一个常见的误区:很多人认为
adc = 0xFFFF(全 1)会导致r_ntc = Inf,然后logf(Inf) = Inf,最终算出-273.15°C。这确实会发生,但-273.15°C是有限数值,它会正常触发else if (temp < TEMP_MIN)分支,保护并不会失效。真正绕过保护的是logf(负数)产生的NaN,它拥有”比较永假”的特性。这个细节差之毫厘,谬以千里。
3. NaN 的恐怖传播性#
一旦某个变量变成了 NaN,所有基于它的运算结果也都会是 NaN。IEEE 754 标准规定:任何包含 NaN 作为操作数的算术运算,结果都为 NaN。
例如后续的插值补偿:
float compensate_temp(float temp, float voltage)
{
float factor = (voltage - 12.0f) * 0.05f;
return temp + factor; // 如果 temp 是 NaN,结果仍是 NaN
}c再比如多阶平滑滤波:
static float temp_filtered = 25.0f;
temp_filtered = temp_filtered * 0.9f + new_temp * 0.1f;
// 一旦 new_temp 是 NaN,temp_filtered 也会被"污染"成 NaNc因为 NaN 满足 IEEE 754 的”无效操作”传播规则,这种污染会不断扩散,最终整个控制链路里到处都是 NaN。我在调试器中看到的场景是:从温度变量开始,到补偿值、PID 输出、PWM 占空比,几乎所有浮点变量都变成了 NaN,整个系统已经处于”逻辑死亡”状态,但 CPU 仍在照常运行指令。
4. 为什么数值比较完全失效?#
这是整个故障最致命的一环。IEEE 754 规定:任何与 NaN 的比较,结果均为假(false)。
包括但不限于:
| 比较表达式 | 结果 |
|---|---|
NaN > 85.0f | false |
NaN < -20.0f | false |
NaN == NaN | false |
NaN != NaN | true(注意这个反直觉的结果) |
NaN >= 0.0f | false |
NaN <= 0.0f | false |
我们回头再看保护函数:
if (temp > TEMP_MAX) { // NaN > 85.0f → false
temp = TEMP_MAX;
} else if (temp < TEMP_MIN) { // NaN < -20.0f → false
temp = TEMP_MIN;
}
// NaN 原样返回,保护完全被绕过!c这就是温度保护失效的根本原因:代码假定输入一定是一个普通浮点数,完全没有考虑特殊值的存在。当 NaN 来了,所有的比较判断都返回 false,保护逻辑如同虚设。
在排查过程中,我一度难以置信——我反复检查了 TEMP_MAX 和 TEMP_MIN 的宏定义,确认没有写反;检查了函数是否被正确调用;甚至怀疑过编译器优化把比较语句优化掉了。最终在 IEEE 754 规范里找到了答案:这不是 Bug,这是 Feature。
一个让很多工程师困惑的问题:为什么
if (temp != temp)能检测NaN?因为
NaN != NaN的结果是true,而任何有限数x != x的结果都是false。所以if (temp != temp)确实可以检测NaN,但这种方式可读性差,且在某些快速数学优化下可能被误优化,不推荐使用。正确的做法是用isnan()或isfinite()。
5. 在 ARM Cortex-M 上调试 NaN 的实用技巧#
在排查过程中,我发现调试 NaN 相关的故障有一些实用技巧,分享给大家:
技巧一:在调试器中直接查看浮点值的十六进制表示
在 IAR 或 Keil 的 Watch 窗口中,将变量显示格式从 float 切换为 hex(或 uint32_t),可以直接看到浮点数的内存位模式:
0x7FC00000→ 经典的 quiet NaN(qNaN)0x7F800000→ +Inf0xFF800000→ -Inf0x7F7FFFFF→ 最大的有限正数(FLT_MAX)
这样就可以快速确认变量到底是有限数、Inf 还是 NaN。
技巧二:在代码中植入 NaN 探测器
在调试阶段,可以在 RTOS 的空闲钩子或低优先级任务中周期性扫描关键全局变量:
void check_float_health(void)
{
if (!isfinite(g_temperature)) {
// 触发调试断点,或通过串口打印调用栈
printf("ERROR: temperature is not finite! value = %f\n", g_temperature);
}
}c这样可以在 NaN 扩散之前尽早捕获。
技巧三:关注 errno
当 logf()、sqrtf() 等数学函数遇到无效参数时,会设置 errno = EDOM。在关键计算后检查 errno,可以快速定位 NaN 的诞生点。
6. NaN 与 Infinity 关键特性速查#
| 特性 | NaN | Infinity (±∞) |
|---|---|---|
| 典型产生 | 0/0, ∞-∞, ∞×0, √(-1), log(负数) | x/0 (x≠0), 溢出 (exp(大数), FLT_MAX×2) |
| 算术运算 | 任何含 NaN 的运算结果仍为 NaN | 有限数 ± ∞ = ±∞; ∞×0 或 ∞-∞ 会变成 NaN |
| 与自身比较 | NaN == NaN → false | +∞ == +∞ → true |
| 与普通数比较 | 永远返回 false | 正常比较(∞ > 任何有限数 为 true) |
| 保护判断 | 完全绕过所有上下限检查 | 能触发上限/下限,但后续运算可能转为 NaN |
| 转换为整数 | 未定义行为,可能产生任意值 | 未定义行为,不可预测 |
| C 标准检测宏 | isnan(x) | isinf(x) |
| 统一检测非有限值 | !isfinite(x) 可以同时检出 NaN 和 Inf |
嵌入式开发关键提醒:
- NaN 的”比较全部为假”特性是保护逻辑失效的根源,必须用
isnan()或isfinite()显式拦截。 - Infinity 虽然能通过大小比较,但极易在后续计算(如求差值、乘零)中变为 NaN,导致二次污染。例如
inf * 0.0f的结果是NaN。 - 任何从外部(SPI、ADC、传感器)进入浮点链路的数据,都必须在计算前用
isfinite()做一次”健康检查”,将异常值替换为安全默认值。 - 切勿依赖
x == x或if (x > LIMIT)来过滤特殊值,在快速数学优化下可能行为不确定。
在嵌入式系统里,NaN 和 inf 带来的危害远不止数值错误这么简单:
- 硬件输出危险值:如果
NaN被写入 PWM 占空比或 DAC,硬件通常会输出不确定电平,可能损坏功率器件。我们这次就差点烧掉一个功率管。 - 死循环或跑飞:
NaN参与循环条件判断时,如果条件永假,循环不会按预期退出。 - 通信协议破坏:若将
NaN通过串口、CAN 等发送出去,会导致接收端解析出错(例如 JSON 序列化失败、Modbus 返回异常码)。 - 存储扩散:写入 EEPROM 或 Flash 的参数可能永久异常,即使重启后依然错误。
7. 解决和防御方案#
7.1 在源头拦下异常数据(SPI 读取校验)#
首先要对 SPI 读取的原始数据进行合理性检查。ADC 有效码值往往在某个区间内(根据实际硬件调整):
#define ADC_MIN_VALID 50
#define ADC_MAX_VALID 65400
bool adc_valid(uint16_t adc)
{
return (adc > ADC_MIN_VALID && adc < ADC_MAX_VALID);
}
// 在 SPI 接收后立即检查
uint16_t adc_raw;
if (HAL_SPI_Receive(&hspi2, (uint8_t*)&adc_raw, 2, HAL_MAX_DELAY) == HAL_OK) {
if (adc_valid(adc_raw)) {
// 进入正常计算流程
float temp = calculate_temperature(adc_raw);
} else {
// 无效码值:重试、上报错误、或使用上次有效值
// 绝对不要不加校验就直接计算!
}
}c7.2 运算前使用 isfinite() 保护#
C99 提供了 isfinite() 宏,能同时检查 inf 和 NaN。在关键计算路径入口,增加一层防护:
#define TEMP_DEFAULT 25.0f
float safe_calculate_temperature(uint16_t adc)
{
float r_ntc = adc_to_resistance(adc);
// 第一层防御:检查电阻值是否有效
if (!isfinite(r_ntc) || r_ntc <= 0.0f) {
return TEMP_DEFAULT;
}
float temp = resistance_to_temp(r_ntc);
// 第二层防御:检查温度计算结果是否有效
if (!isfinite(temp)) {
return TEMP_DEFAULT;
}
return temp;
}c有更细粒度的需求时,可以分别用 isnan() 和 isinf() 进行区分处理。
7.3 修正保护函数,使其能感知特殊值#
即使在保护函数内部,也可以增加对非有限值的处理:
float limit_temperature(float temp)
{
// 防御 NaN 和 Inf
if (!isfinite(temp)) {
return TEMP_DEFAULT; // 或触发紧急停机
}
if (temp > TEMP_MAX) return TEMP_MAX;
if (temp < TEMP_MIN) return TEMP_MIN;
return temp;
}c这样即使上游漏了一个 NaN,保护逻辑依然生效。
7.4 ⚠️ 致命编译选项:警惕 -ffast-math#
这是嵌入式工程中最常见的大坑。开启 -ffast-math 或 Keil/IAR 中的等效快速数学优化后,编译器会假定代码中不存在 NaN 和 Inf,并可能将 isnan()、isfinite() 优化为恒假(或直接删除相关判断语句)。
如果在 Makefile 或 IDE 工程中默认开了此优化,你在 7.2 和 7.3 节写的防御代码将完全失效。
解决方案:
- 安全关键代码中,务必确认未启用
-ffast-math - 若必须开启,需使用
#pragma局部恢复标准数学行为 - 或者使用下文 7.6 节的整数位运算自定义宏(该宏不受
-ffast-math影响)
7.5 借助编译器选项和静态检查#
- 开启
-Wfloat-equal等警告,注意浮点比较 - 使用 MISRA C 规则指导(如 Rule 21.17 要求不能直接使用
==或!=检验浮点异常值) - 在调试阶段,周期扫描全局状态变量,检测是否出现
NaN/inf,发现问题立即记录
7.6 为嵌入式优化检测开销(标准兼容版)#
isfinite 在 ARM Cortex-M 上可以用整数位运算高效实现。但需要注意避免 C 语言的严格别名违规:
#include <stdint.h>
// 标准兼容的 isfinite 实现(不受 -ffast-math 影响)
static inline bool is_finite(float x)
{
union {
float f;
uint32_t u;
} fu = { .f = x };
// 指数位全为 1(0x7F800000)表示 Inf 或 NaN
return (fu.u & 0x7F800000u) != 0x7F800000u;
}
// 可选:分别检测 NaN 和 Inf
static inline bool is_nan(float x)
{
union { float f; uint32_t u; } fu = { .f = x };
return ((fu.u & 0x7F800000u) == 0x7F800000u) &&
((fu.u & 0x007FFFFFu) != 0u);
}
static inline bool is_inf(float x)
{
union { float f; uint32_t u; } fu = { .f = x };
return ((fu.u & 0x7F800000u) == 0x7F800000u) &&
((fu.u & 0x007FFFFFu) == 0u);
}c注意:使用
union进行类型双关是 C99/C11 标准允许的做法(C++ 中则需谨慎)。避免使用*(uint32_t*)&x的指针强制转换方式,因为它违反严格别名规则,在 O2 及以上优化下可能产生未定义行为。
如果项目已链接 libm,直接使用 <math.h> 中的 isfinite() 最省心——前提是确认没有开启 -ffast-math。
8. 小结#
这次故障的根因极其简单:
输入数据未校验 → logf(负数) 产生 NaN → NaN 利用”比较永假”特性绕过保护 → 系统失控
但排查过程却十分痛苦,因为表象是保护逻辑没生效,很容易怀疑到硬件、RTOS 调度或 FPGA 逻辑上,而忽略浮点特殊值这个隐蔽杀手。
核心教训#
-
永远不要信任跨边界的数据:在嵌入式系统中,凡跨越软硬件边界(SPI、I2C、UART、ADC)的数据,一律需要做合法性检查。
-
浮点数不等于”实数”:IEEE 754 定义了
NaN和Inf这些特殊值,它们的行为与普通实数完全不同。任何浮点计算链路都必须显式防御这些特殊值。 -
比较操作可能失效:千万不要依赖
if (x > LIMIT)来过滤异常值。NaN会悄无声息地绕过所有比较。 -
防御要分层:源头拦截 + 计算前检测 + 保护函数兜底,三层防御才能确保万无一失。
-
警惕编译器优化:
-ffast-math会让你的浮点防御代码形同虚设。
希望这篇记录能帮助大家少踩一个坑。