知识门户

Back

嵌入式浮点陷阱: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

这里存在两个隐患:

  1. FPGA 在采样异常时(例如 ADC 时序错乱、输入信号悬空)会把数据线拉到全 1,也就是 0xFFFF
  2. 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.0finf - 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 也会被"污染"成 NaN
c

因为 NaN 满足 IEEE 754 的”无效操作”传播规则,这种污染会不断扩散,最终整个控制链路里到处都是 NaN。我在调试器中看到的场景是:从温度变量开始,到补偿值、PID 输出、PWM 占空比,几乎所有浮点变量都变成了 NaN,整个系统已经处于”逻辑死亡”状态,但 CPU 仍在照常运行指令。


4. 为什么数值比较完全失效?#

这是整个故障最致命的一环。IEEE 754 规定:任何与 NaN 的比较,结果均为假(false)

包括但不限于:

比较表达式结果
NaN > 85.0ffalse
NaN < -20.0ffalse
NaN == NaNfalse
NaN != NaNtrue(注意这个反直觉的结果)
NaN >= 0.0ffalse
NaN <= 0.0ffalse

我们回头再看保护函数:

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_MAXTEMP_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 → +Inf
  • 0xFF800000 → -Inf
  • 0x7F7FFFFF → 最大的有限正数(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 关键特性速查#

特性NaNInfinity (±∞)
典型产生0/0, ∞-∞, ∞×0, √(-1), log(负数)x/0 (x≠0), 溢出 (exp(大数), FLT_MAX×2)
算术运算任何含 NaN 的运算结果仍为 NaN有限数 ± ∞ = ±∞; ∞×0 或 ∞-∞ 会变成 NaN
与自身比较NaN == NaNfalse+∞ == +∞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 == xif (x > LIMIT) 来过滤特殊值,在快速数学优化下可能行为不确定。

在嵌入式系统里,NaNinf 带来的危害远不止数值错误这么简单:

  • 硬件输出危险值:如果 NaN 被写入 PWM 占空比或 DAC,硬件通常会输出不确定电平,可能损坏功率器件。我们这次就差点烧掉一个功率管。
  • 死循环或跑飞NaN 参与循环条件判断时,如果条件永假,循环不会按预期退出。
  • 通信协议破坏:若将 NaN 通过串口、CAN 等发送出去,会导致接收端解析出错(例如 JSON 序列化失败、Modbus 返回异常码)。
  • 存储扩散:写入 EEPROM 或 Flash 的参数可能永久异常,即使重启后依然错误。

7. 解决和防御方案#

7.1 在源头拦下异常数据(SPI 读取校验)#

首先要对 SPI 读取的原始数据进行合理性检查。ADC 有效码值往往在某个区间内(根据实际硬件调整):

7.2 运算前使用 isfinite() 保护#

C99 提供了 isfinite() 宏,能同时检查 infNaN。在关键计算路径入口,增加一层防护:

有更细粒度的需求时,可以分别用 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 中的等效快速数学优化后,编译器会假定代码中不存在 NaNInf,并可能将 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 语言的严格别名违规:

注意:使用 union 进行类型双关是 C99/C11 标准允许的做法(C++ 中则需谨慎)。避免使用 *(uint32_t*)&x 的指针强制转换方式,因为它违反严格别名规则,在 O2 及以上优化下可能产生未定义行为。

如果项目已链接 libm,直接使用 <math.h> 中的 isfinite() 最省心——前提是确认没有开启 -ffast-math


8. 小结#

这次故障的根因极其简单:

输入数据未校验 → logf(负数) 产生 NaN → NaN 利用”比较永假”特性绕过保护 → 系统失控

但排查过程却十分痛苦,因为表象是保护逻辑没生效,很容易怀疑到硬件、RTOS 调度或 FPGA 逻辑上,而忽略浮点特殊值这个隐蔽杀手。

核心教训#

  1. 永远不要信任跨边界的数据:在嵌入式系统中,凡跨越软硬件边界(SPI、I2C、UART、ADC)的数据,一律需要做合法性检查。

  2. 浮点数不等于”实数”:IEEE 754 定义了 NaNInf 这些特殊值,它们的行为与普通实数完全不同。任何浮点计算链路都必须显式防御这些特殊值。

  3. 比较操作可能失效:千万不要依赖 if (x > LIMIT) 来过滤异常值。NaN 会悄无声息地绕过所有比较。

  4. 防御要分层:源头拦截 + 计算前检测 + 保护函数兜底,三层防御才能确保万无一失。

  5. 警惕编译器优化-ffast-math 会让你的浮点防御代码形同虚设。

希望这篇记录能帮助大家少踩一个坑。

嵌入式浮点陷阱:NaN传播导致温度保护失效的排查与防御
https://glinfei.space/blog/c%E8%AF%AD%E8%A8%80%E6%8A%80%E5%B7%A7/nan
Author 甘霖飞
Published at 2026年8月10日
Comment seems to stuck. Try to refresh?✨