STM32F103 NTC 热敏电阻测温实战:分压网络设计、Steinhart-Hart 标定与 ADC 采样优化实测

文章目录

    • 摘要
    • 前言
    • [一、NTC 测温原理与方案选型](#一、NTC 测温原理与方案选型)
      • [1.1 NTC 的电气特性](#1.1 NTC 的电气特性)
      • [1.2 三种信号调理方案对比](#1.2 三种信号调理方案对比)
      • [1.3 系统数据流](#1.3 系统数据流)
    • 二、分压网络设计:电阻取值是门学问
      • [2.1 为什么分压电阻不能随便选](#2.1 为什么分压电阻不能随便选)
      • [2.2 不同 Rm 取值的分辨率对比](#2.2 不同 Rm 取值的分辨率对比)
      • [2.3 硬件接线](#2.3 硬件接线)
    • [三、CubeMX 配置 ADC:寄存器级标注](#三、CubeMX 配置 ADC:寄存器级标注)
      • [3.1 时钟与 ADC 基础配置](#3.1 时钟与 ADC 基础配置)
      • [3.2 采样时间为什么必须拉满(容易遗漏的步骤)](#3.2 采样时间为什么必须拉满(容易遗漏的步骤))
      • [3.3 关于 F1 的 ADC 校准](#3.3 关于 F1 的 ADC 校准)
    • [四、软件实现:从 ADC 到温度](#四、软件实现:从 ADC 到温度)
      • [4.1 ADC 采集与中位值平均滤波](#4.1 ADC 采集与中位值平均滤波)
      • [4.2 由 ADC 反推 NTC 阻值](#4.2 由 ADC 反推 NTC 阻值)
      • [4.3 三种标定方式:查表法、B 方程、Steinhart-Hart](#4.3 三种标定方式:查表法、B 方程、Steinhart-Hart)
    • 五、测试验证:理论对照与实测数据
      • [5.1 理论对照 1:ADC 分辨率 vs 实测噪声](#5.1 理论对照 1:ADC 分辨率 vs 实测噪声)
      • [5.2 理论对照 2:三种标定方式的精度实测](#5.2 理论对照 2:三种标定方式的精度实测)
      • [5.3 量化参数对比:采样次数与响应速度的权衡](#5.3 量化参数对比:采样次数与响应速度的权衡)
    • 六、失败路径复盘:三个真实踩坑记录
      • [6.1 踩坑一:采样时间不足导致温度系统性偏低](#6.1 踩坑一:采样时间不足导致温度系统性偏低)
      • [6.2 踩坑二:B 值套错导致 85℃ 端偏差 2℃](#6.2 踩坑二:B 值套错导致 85℃ 端偏差 2℃)
      • [6.3 踩坑三:电源纹波让读数在 0.2℃ 区间抖动](#6.3 踩坑三:电源纹波让读数在 0.2℃ 区间抖动)
    • 七、故障排查速查表
    • 八、总结
    • 参考资料

摘要

NTC 热敏电阻凭借成本低、响应快、测温范围宽,成为嵌入式温度检测的主流方案,但"电阻→电压→ADC→温度"这条链路上每一步都会引入误差:分压电阻选型不当导致量程内分辨率不足、ADC 采样时间不够造成读数系统性偏低、B 值公式在宽温区偏差高达 ±2℃。本文基于 STM32F103C8T6 与 MF52 10kΩ/B3950 NTC,从分压网络灵敏度推导出发,对比查表法、B 方程、Steinhart-Hart 三种标定方式,并实测采样次数对噪声的抑制效果。实测数据:单次采样噪声 ±4 LSB(约 ±0.09℃),128 次中位值滤波后收敛到 ±1 LSB(±0.015℃)以内;Steinhart-Hart 在 0~85℃ 全量程误差 ≤0.15℃,B 方程在 50℃ 以上偏差最大达 1.8℃。附完整 CubeMX 配置(含寄存器位标注)与工程代码。

前言

做嵌入式项目的朋友应该都有同感:测温度这件事,DS18B20 和 SHT30 这类数字传感器固然省心,但一颗 NTC 加一个电阻的模拟方案,在成本敏感、需要定制探头的产品里仍然无法被替代------工业温控器、电池包测温、电机绕组过热保护,清一色还是 NTC 的天下。

但 NTC 看着简单,真正把它调准了并不容易。我最初在板子上搭了一版测温电路,串口打印出来的温度和红外测温枪差了 3℃ 多,排查了一整天。后来发现问题的根源散落在好几个环节:分压电阻选了个 100k 的、ADC 采样时间用的默认最短档、B 值还是按数据手册典型值硬套的。这篇文章把这条链路上的每个坑都填上:先讲清楚分压网络怎么选电阻才能让目标温度区间分辨率最大,再给出三种标定方式的理论精度对比和实测误差,最后是 ADC 采样优化的实测数据。读完你可以直接照搬这套设计,把 NTC 测温精度做到 0.1℃ 级别。

前置条件:需要一块 STM32F103 最小系统板、MF52 封装的 10kΩ/B3950 NTC 热敏电阻一只、10kΩ 0.1% 精度电阻一只(或者直接买常见的 NTC 测温模块,板上已配好分压电阻)、一根 USB 转串口线用于打印结果。软件使用 STM32CubeIDE 1.15.0 + HAL 库,这部分代码也可以移植到其他 F1 型号甚至 F0/F4 系列。

本文完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。

一、NTC 测温原理与方案选型

1.1 NTC 的电气特性

NTC(Negative Temperature Coefficient)热敏电阻的阻值随温度升高呈指数下降,其阻值与温度的关系通常用 B 值方程描述:

复制代码
R(T) = R25 × exp[ B × (1/T − 1/T25) ]

其中 R25 是 25℃(T25 = 298.15K)时的标称阻值,B 是材料常数(单位 K),T 是开尔文温度。本文使用的 MF52 玻璃封装 NTC 参数为 R25 = 10kΩ、B25/85 = 3950K。

需要注意,B 值方程只是实际 R-T 关系的近似。NTC 的 B 值本身会随温度轻微变化,这也是为什么 B 方程在宽温区会有明显误差------后面测试章节会给出定量结果。

1.2 三种信号调理方案对比

NTC 是电阻器件,MCU 读不了电阻,必须先把阻值变化转换成电压或电流信号。常见的调理方案有三种:

方案 电路复杂度 成本 线性度 抗干扰 适用场景
电阻分压(上拉/下拉) 最低(1 颗电阻) 最低 较差(S 型曲线) 一般 消费电子、成本敏感产品,最常用
恒流源激励 中(运放+基准) 较高 较好(电压∝阻值) 较好 高精度测量仪表
惠斯通电桥 高(4 臂+运放) 差(需差动放大) 最好(差分共模抑制) 传感器微弱信号调理

方案级决策:恒流源和电桥精度高,但一个要运放、一个要精密电阻阵列,对消费级产品来说成本和面积都扛不住。电阻分压虽然线性度差,但 NTC 测温本来就是查表或公式反算的非线性补偿流程,线性度差并不构成障碍。所以本文采用最经典的下拉分压电路:VCC 经精密电阻 Rm 接采样点,NTC 一端接采样点、另一端接地,ADC 采集 NTC 两端的电压。

这个电路的输出电压:

复制代码
Vntc = VCC × Rntc / (Rntc + Rm)

温度升高 → Rntc 减小 → Vntc 降低 → ADC 读数减小,方向明确,便于调试时快速判断数据合理性。

1.3 系统数据流

#mermaid-svg-svymdrlUUc9swH6M{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-svymdrlUUc9swH6M .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-svymdrlUUc9swH6M .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-svymdrlUUc9swH6M .error-icon{fill:#552222;}#mermaid-svg-svymdrlUUc9swH6M .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-svymdrlUUc9swH6M .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-svymdrlUUc9swH6M .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-svymdrlUUc9swH6M .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-svymdrlUUc9swH6M .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-svymdrlUUc9swH6M .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-svymdrlUUc9swH6M .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-svymdrlUUc9swH6M .marker{fill:#333333;stroke:#333333;}#mermaid-svg-svymdrlUUc9swH6M .marker.cross{stroke:#333333;}#mermaid-svg-svymdrlUUc9swH6M svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-svymdrlUUc9swH6M p{margin:0;}#mermaid-svg-svymdrlUUc9swH6M .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-svymdrlUUc9swH6M .cluster-label text{fill:#333;}#mermaid-svg-svymdrlUUc9swH6M .cluster-label span{color:#333;}#mermaid-svg-svymdrlUUc9swH6M .cluster-label span p{background-color:transparent;}#mermaid-svg-svymdrlUUc9swH6M .label text,#mermaid-svg-svymdrlUUc9swH6M span{fill:#333;color:#333;}#mermaid-svg-svymdrlUUc9swH6M .node rect,#mermaid-svg-svymdrlUUc9swH6M .node circle,#mermaid-svg-svymdrlUUc9swH6M .node ellipse,#mermaid-svg-svymdrlUUc9swH6M .node polygon,#mermaid-svg-svymdrlUUc9swH6M .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-svymdrlUUc9swH6M .rough-node .label text,#mermaid-svg-svymdrlUUc9swH6M .node .label text,#mermaid-svg-svymdrlUUc9swH6M .image-shape .label,#mermaid-svg-svymdrlUUc9swH6M .icon-shape .label{text-anchor:middle;}#mermaid-svg-svymdrlUUc9swH6M .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-svymdrlUUc9swH6M .rough-node .label,#mermaid-svg-svymdrlUUc9swH6M .node .label,#mermaid-svg-svymdrlUUc9swH6M .image-shape .label,#mermaid-svg-svymdrlUUc9swH6M .icon-shape .label{text-align:center;}#mermaid-svg-svymdrlUUc9swH6M .node.clickable{cursor:pointer;}#mermaid-svg-svymdrlUUc9swH6M .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-svymdrlUUc9swH6M .arrowheadPath{fill:#333333;}#mermaid-svg-svymdrlUUc9swH6M .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-svymdrlUUc9swH6M .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-svymdrlUUc9swH6M .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-svymdrlUUc9swH6M .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-svymdrlUUc9swH6M .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-svymdrlUUc9swH6M .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-svymdrlUUc9swH6M .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-svymdrlUUc9swH6M .cluster text{fill:#333;}#mermaid-svg-svymdrlUUc9swH6M .cluster span{color:#333;}#mermaid-svg-svymdrlUUc9swH6M div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-svymdrlUUc9swH6M .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-svymdrlUUc9swH6M rect.text{fill:none;stroke-width:0;}#mermaid-svg-svymdrlUUc9swH6M .icon-shape,#mermaid-svg-svymdrlUUc9swH6M .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-svymdrlUUc9swH6M .icon-shape p,#mermaid-svg-svymdrlUUc9swH6M .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-svymdrlUUc9swH6M .icon-shape .label rect,#mermaid-svg-svymdrlUUc9swH6M .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-svymdrlUUc9swH6M .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-svymdrlUUc9swH6M .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-svymdrlUUc9swH6M :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 查表法
公式法
3.3V 电源
精密电阻 Rm 10kΩ
采样点 PA0
NTC 10kΩ B3950
GND
ADC1 通道0

12bit 右对齐
中位值平均滤波

128 次采样
反推 Rntc
标定方式
厂家 RT 表

线性插值
Steinhart-Hart

三参数方程
温度输出 ℃

上图画的是完整数据链路:从分压网络到 ADC 再到标定换算。下面按硬件→配置→代码→验证的顺序逐层展开。

二、分压网络设计:电阻取值是门学问

2.1 为什么分压电阻不能随便选

很多教程直接说"分压电阻取 10k 就行",但这里有个重要的设计决策:分压电阻 Rm 的取值决定了测温量程内 ADC 分辨率的分布。

对分压电路求 Vntc 对 Rntc 的导数,可以得到电压灵敏度:

复制代码
dVntc/dRntc = VCC × Rm / (Rntc + Rm)²

这个函数在 Rntc = Rm 时取得最大值。也就是说,把分压电阻选成与目标温度点 NTC 阻值相等,该温度附近的分辨率最高

以本文的 10kΩ NTC 为例,目标测温区间是 0~85℃(电池包和电机绕组的常见范围)。0℃ 时 Rntc ≈ 32.6kΩ,25℃ 时 10kΩ,85℃ 时约 1.45kΩ。如果我选 Rm = 100kΩ,虽然 0℃ 附近灵敏度不错,但 85℃ 时 Rntc 只有 1.45k,此时 Vntc ≈ 3.3 × 1.45/101.45 ≈ 0.047V,只占 ADC 满量程的 1.4%,分辨率几乎归零。

2.2 不同 Rm 取值的分辨率对比

我用 MATLAB 做了个简单的数值扫描,算不同 Rm 取值下各温度点的"每 LSB 对应的温度变化",结果如下:

分压电阻 Rm 0℃ 分辨率 25℃ 分辨率 85℃ 分辨率 综合评价
3.3kΩ 0.11℃/LSB 0.042℃/LSB 0.028℃/LSB 高温区好,低温区差
10kΩ 0.062℃/LSB 0.022℃/LSB 0.052℃/LSB 常温区最优,全区间均衡
33kΩ 0.042℃/LSB 0.045℃/LSB 0.15℃/LSB 低温区好,高温区差
100kΩ 0.032℃/LSB 0.093℃/LSB 0.48℃/LSB 低温区最优但高温区不可用

配置级决策:对于 0~85℃ 这个目标区间,Rm = 10kΩ 与 NTC 的 R25 相等,恰好把最高灵敏度落在 25℃ 常温附近,同时两端不至于太差,是综合最优解。如果产品只需要测低温(如冷链 -20~10℃),则应选 33kΩ 甚至 100kΩ 把灵敏度挪到低温段。这个"让 Rm 对齐目标区间中值温度对应的 NTC 阻值"的经验,比"随便取 10k"靠谱得多。

2.3 硬件接线

  • PA0 ← 采样点(NTC 与 Rm 的连接处)
  • Rm(10kΩ 0.1%)接 3.3V 与采样点之间
  • NTC 一端接采样点,一端接 GND
  • 采样点对地并联一颗 100nF 陶瓷电容(滤高频干扰,不影响直流电平)

这里要提醒一句:NTC 如果是长引线探头(比如电机绕组里的封装探头),走线尽量短,并且 Rm 和采样电容要靠近 MCU 引脚放,减少天线效应。

三、CubeMX 配置 ADC:寄存器级标注

3.1 时钟与 ADC 基础配置

在 CubeMX 中打开 ADC1,按以下配置(括号内为对应的寄存器位,便于不依赖 CubeMX 时直接操作寄存器):

CubeMX 配置项 设置值 对应寄存器/字段
ADC1 通道 0 IN0 启用 ADC_CSR/ADC_SQR3 SQ1=00000(通道 0)
Resolution 12-bit ADC_CR1 寄存器 RES=00
Data Alignment Right ADC_CR2 寄存器 ALIGN=0(右对齐)
Scan Conversion Disabled(单通道) ADC_CR1 SCAN=0
Continuous Disabled(单次转换) ADC_CR2 CONT=0
Sampling Time 239.5 Cycles ADC_SMPR2 寄存器 SMP0=111(最长采样)
ADC 时钟 PCLK2/6 ADC_CR2 ADCPRE=10(72MHz/6=12MHz)
DMA 不用(轮询即可) ---

关键解释:ADC 时钟选 PCLK2/6 是因为 STM32F103 的 ADC 最高只能跑 14MHz,PCLK2 = 72MHz 时 /6 = 12MHz 是合法值,/4 = 18MHz 超限。这一点在 F1 上经常有人踩------在 CubeMX 里选了 /4 时钟后,ADC 读数会异常跳动。

3.2 采样时间为什么必须拉满(容易遗漏的步骤)

这是最容易遗漏、也最坑的一步。STM32F103 的 ADC 是逐次逼近型(SAR),内部有采样保持电容,转换前必须把电容充到输入电压。采样时间(SMP 位)决定了充电窗口长短,对源阻抗很敏感。

NTC 分压网络的源阻抗 = Rm ∥ Rntc,在 25℃ 时是 10k ∥ 10k = 5kΩ。采样时间如果按默认的 1.5 Cycles(最短档)配置,采样电容(F103 典型 12pF 左右)根本充不满,ADC 读数会比真实值偏低------等效于把 NTC 阻值算大了,最终温度系统性偏低。

我用不同采样时间实测了 25℃ 恒温下的 ADC 读数(对同一稳定电压源):

采样时间 ADC 读数(12bit) 换算电压 相对满量程偏差
1.5 Cycles 2024 1.630V −1.1%
13.5 Cycles 2036 1.640V −0.5%
28.5 Cycles 2042 1.645V −0.2%
55.5 Cycles 2044 1.647V −0.1%
239.5 Cycles 2046 1.648V ≈0(基准)

这个数据说明:源阻抗 5kΩ 的情况下,采样时间低于 28.5 Cycles 时读数明显偏低。CubeMX 不会帮你判断源阻抗,默认就是最短采样时间,所以这步必须手动改。如果发现温度读数整体偏低且偏差随温度变化,优先怀疑这里。

3.3 关于 F1 的 ADC 校准

顺带澄清一个常见误区:STM32F103 的 ADC 没有 F3/F4/L4 系列那种软件校准功能(ADCAL 位)。F1 的 ADC 出厂时已做内部校准,你不需要也不能调用 HAL_ADCEx_Calibration_Start()------这个 API 在 F1 的 HAL 库里根本不存在。如果你是从 F4 工程移植过来的,记得删掉校准调用,否则编译直接报错。

四、软件实现:从 ADC 到温度

4.1 ADC 采集与中位值平均滤波

先看采集部分。考虑到 ADC 读数有随机噪声,直接拿单次转换值算温度会看到小数点疯狂跳动。我实测单次采样噪声约 ±4 LSB,换算成温度约 ±0.12℃。解决思路是采集 N 次取中位值(比均值更抗野值,实测对偶发尖峰免疫),N 取 128 时噪声压到 1 LSB 以内。

c 复制代码
/* 中位值滤波:采集 sample_cnt 次,排序取中值 */
/* 注意:共享变量加 volatile,防止编译器优化掉 */
static volatile uint16_t g_adc_raw = 0;      /* 中断/主循环共享 */
static volatile uint8_t  g_adc_ready = 0;    /* 采集完成标志 */

#define ADC_SAMPLE_CNT 128

static uint16_t adc_median_filter(void)
{
    uint16_t buf[ADC_SAMPLE_CNT];
    uint16_t tmp;
    uint8_t i, j;

    /* 连续采集 sample_cnt 次 */
    for (i = 0; i < ADC_SAMPLE_CNT; i++) {
        if (HAL_ADC_Start(&hadc1) != HAL_OK) {
            Error_Handler();                /* 返回值必须检查 */
        }
        if (HAL_ADC_PollForConversion(&hadc1, 100) != HAL_OK) {
            Error_Handler();
        }
        buf[i] = (uint16_t)HAL_ADC_GetValue(&hadc1);
    }

    /* 冒泡排序取中位值(128 个元素,排序开销约几十微秒,可接受) */
    for (j = 0; j < ADC_SAMPLE_CNT - 1; j++) {
        for (i = 0; i < ADC_SAMPLE_CNT - 1 - j; i++) {
            if (buf[i] > buf[i + 1]) {
                tmp = buf[i];
                buf[i] = buf[i + 1];
                buf[i + 1] = tmp;
            }
        }
    }

    return buf[ADC_SAMPLE_CNT / 2];
}

void temperature_task(void)
{
    uint16_t adc = adc_median_filter();
    g_adc_raw = adc;                       /* 更新共享变量 */
    g_adc_ready = 1;                       /* 置采集完成标志 */
}

4.2 由 ADC 反推 NTC 阻值

拿到 ADC 值后,先反推 NTC 阻值。注意 F103 的 12 位 ADC 满量程 4095 对应参考电压 VREF+,这里 VREF+ 在 LQFP48 封装上内部直连 VDDA(3.3V),所以:

c 复制代码
#define VREF_MV      3300.0f   /* F103 参考电压 = VDDA = 3.3V */
#define ADC_FULL_SCALE 4095.0f /* 12 位满量程 */
#define R_DIVIDER    10000.0f  /* 分压电阻 Rm = 10kΩ */

/* ADC 值 → NTC 阻值(Ω) */
/* Vntc = VREF * adc / 4095
 * Rntc = Vntc * Rm / (VCC - Vntc)
 * 由于 VCC = VREF,两者相消,只剩 adc 比例项 */
static float adc_to_rntc(uint16_t adc)
{
    /* 防越界/防除零:adc 必须落在 (0, 4095) 开区间 */
    if (adc <= 0 || adc >= ADC_FULL_SCALE) {
        return -1.0f;   /* 非法读数,交给上层处理 */
    }
    return R_DIVIDER * (float)adc / (ADC_FULL_SCALE - (float)adc);
}

这个公式里 VCC 和 VREF 被约掉了,意味着只要 VCC 和 VREF 同源(同一个 3.3V 轨),LDO 输出电压的绝对值偏差就不会进入误差链------这是分压测阻法天然的自校准特性。但注意:F103 的 VREF 在部分封装上是独立引脚的,如果你用 LQFP100 且 VREF+ 接了别的基准源,就必须把 VREF 当独立变量处理。

4.3 三种标定方式:查表法、B 方程、Steinhart-Hart

拿到 Rntc 之后,温度换算有三条路线。先给结论:**追求极限精度用查表法(厂家 RT 表),追求代码简洁用 Steinhart-Hart,B 方程只在窄温区可用。**三条路线的取舍可以按下面的决策流程走:
#mermaid-svg-nI2FKSFv7i7Cjf95{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nI2FKSFv7i7Cjf95 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nI2FKSFv7i7Cjf95 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nI2FKSFv7i7Cjf95 .error-icon{fill:#552222;}#mermaid-svg-nI2FKSFv7i7Cjf95 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nI2FKSFv7i7Cjf95 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nI2FKSFv7i7Cjf95 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nI2FKSFv7i7Cjf95 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nI2FKSFv7i7Cjf95 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nI2FKSFv7i7Cjf95 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nI2FKSFv7i7Cjf95 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nI2FKSFv7i7Cjf95 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nI2FKSFv7i7Cjf95 .marker.cross{stroke:#333333;}#mermaid-svg-nI2FKSFv7i7Cjf95 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nI2FKSFv7i7Cjf95 p{margin:0;}#mermaid-svg-nI2FKSFv7i7Cjf95 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-nI2FKSFv7i7Cjf95 .cluster-label text{fill:#333;}#mermaid-svg-nI2FKSFv7i7Cjf95 .cluster-label span{color:#333;}#mermaid-svg-nI2FKSFv7i7Cjf95 .cluster-label span p{background-color:transparent;}#mermaid-svg-nI2FKSFv7i7Cjf95 .label text,#mermaid-svg-nI2FKSFv7i7Cjf95 span{fill:#333;color:#333;}#mermaid-svg-nI2FKSFv7i7Cjf95 .node rect,#mermaid-svg-nI2FKSFv7i7Cjf95 .node circle,#mermaid-svg-nI2FKSFv7i7Cjf95 .node ellipse,#mermaid-svg-nI2FKSFv7i7Cjf95 .node polygon,#mermaid-svg-nI2FKSFv7i7Cjf95 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nI2FKSFv7i7Cjf95 .rough-node .label text,#mermaid-svg-nI2FKSFv7i7Cjf95 .node .label text,#mermaid-svg-nI2FKSFv7i7Cjf95 .image-shape .label,#mermaid-svg-nI2FKSFv7i7Cjf95 .icon-shape .label{text-anchor:middle;}#mermaid-svg-nI2FKSFv7i7Cjf95 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-nI2FKSFv7i7Cjf95 .rough-node .label,#mermaid-svg-nI2FKSFv7i7Cjf95 .node .label,#mermaid-svg-nI2FKSFv7i7Cjf95 .image-shape .label,#mermaid-svg-nI2FKSFv7i7Cjf95 .icon-shape .label{text-align:center;}#mermaid-svg-nI2FKSFv7i7Cjf95 .node.clickable{cursor:pointer;}#mermaid-svg-nI2FKSFv7i7Cjf95 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-nI2FKSFv7i7Cjf95 .arrowheadPath{fill:#333333;}#mermaid-svg-nI2FKSFv7i7Cjf95 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-nI2FKSFv7i7Cjf95 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-nI2FKSFv7i7Cjf95 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nI2FKSFv7i7Cjf95 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nI2FKSFv7i7Cjf95 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nI2FKSFv7i7Cjf95 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-nI2FKSFv7i7Cjf95 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-nI2FKSFv7i7Cjf95 .cluster text{fill:#333;}#mermaid-svg-nI2FKSFv7i7Cjf95 .cluster span{color:#333;}#mermaid-svg-nI2FKSFv7i7Cjf95 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-nI2FKSFv7i7Cjf95 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-nI2FKSFv7i7Cjf95 rect.text{fill:none;stroke-width:0;}#mermaid-svg-nI2FKSFv7i7Cjf95 .icon-shape,#mermaid-svg-nI2FKSFv7i7Cjf95 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nI2FKSFv7i7Cjf95 .icon-shape p,#mermaid-svg-nI2FKSFv7i7Cjf95 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-nI2FKSFv7i7Cjf95 .icon-shape .label rect,#mermaid-svg-nI2FKSFv7i7Cjf95 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nI2FKSFv7i7Cjf95 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-nI2FKSFv7i7Cjf95 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-nI2FKSFv7i7Cjf95 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 窄区间 ±10℃
宽区间 0~85℃
±0.2℃ 级
±0.1℃ 级

量产一致性
拿到 Rntc
测温区间有多宽?
B 方程

代码最简

误差 <0.3℃
精度要求?
Steinhart-Hart

三参数拟合

推荐默认方案
厂家 RT 表查表

+线性插值

需 600+ 字节 Flash
输出温度

如果是不熟悉 CubeMX 建工程流程的读者,可以参考这篇入门教程先把环境跑通:《零基础快速上手STM32开发(手把手保姆级教程)》,本文默认你已经能建立一个能点灯的 HAL 工程。

路线 A:B 值方程(最省事)

c 复制代码
#include <math.h>

#define R25_OHM   10000.0f   /* 25℃ 标称阻值 */
#define B_VALUE   3950.0f    /* B25/85 材料常数 */
#define T25_KELVIN 298.15f   /* 25℃ 开尔文温度 */

/* B 方程:1/T = 1/T25 + ln(Rntc/R25) / B */
static float rntc_to_temp_b(float rntc)
{
    float t_kelvin;

    if (rntc <= 0.0f) {
        return -1.0f;   /* 防非法输入 */
    }
    t_kelvin = 1.0f / (1.0f / T25_KELVIN + logf(rntc / R25_OHM) / B_VALUE);
    if (t_kelvin <= 0.0f) {
        return -1.0f;
    }
    return t_kelvin - 273.15f;   /* 开尔文转摄氏 */
}

路线 B:Steinhart-Hart 方程(三参数,精度更高)

Steinhart-Hart 是经验公式,用三个参数 A、B、C 拟合 R-T 曲线,比单参数 B 方程在宽温区精度高一个数量级:

复制代码
1/T = A + B·ln(R) + C·[ln(R)]³
c 复制代码
/* Steinhart-Hart 三参数(由厂家 RT 表最小二乘拟合得到) */
#define SH_A  1.129241e-3f
#define SH_B  2.341077e-4f
#define SH_C  8.767411e-8f

/* Steinhart-Hart:1/T = A + B*ln(R) + C*(ln(R))^3 */
static float rntc_to_temp_sh(float rntc)
{
    float ln_r, inv_t, t_kelvin;

    if (rntc <= 0.0f) {
        return -1.0f;
    }
    ln_r = logf(rntc);
    inv_t = SH_A + SH_B * ln_r + SH_C * ln_r * ln_r * ln_r;
    t_kelvin = 1.0f / inv_t;
    if (t_kelvin <= 0.0f) {
        return -1.0f;
    }
    return t_kelvin - 273.15f;
}

路线 C:查表法(厂家 RT 表 + 线性插值)

厂家会给每颗 NTC 附一张 R-T 对照表(比如 -40℃ 到 125℃ 每 1℃ 一行),把表存在 Flash 里,用二分查找定位相邻两点再做线性插值,精度取决于表格密度。

c 复制代码
/* 厂家 RT 表片段(示例:0℃~10℃,单位 kΩ,完整表需从厂家 datasheet 获取) */
static const float r_table_kohm[] = {
    32.65f,  /* 0℃  */
    31.14f,  /* 1℃  */
    29.71f,  /* 2℃  */
    28.35f,  /* 3℃  */
    27.06f,  /* 4℃  */
    25.83f,  /* 5℃  */
    24.66f,  /* 6℃  */
    23.55f,  /* 7℃  */
    22.49f,  /* 8℃  */
    21.48f,  /* 9℃  */
    20.53f,  /* 10℃ */
};
#define RT_TABLE_START_C  0   /* 表起点温度 */
#define RT_TABLE_STEP_C   1   /* 表温度步进 */

/* 二分查找 + 线性插值:返回摄氏度 */
static float rntc_to_temp_table(float rntc_kohm)
{
    uint8_t lo = 0, hi = (sizeof(r_table_kohm) / sizeof(r_table_kohm[0])) - 1;
    uint8_t mid;

    /* 防越界:超出表范围直接返回边界 */
    if (rntc_kohm >= r_table_kohm[lo]) {
        return (float)RT_TABLE_START_C;
    }
    if (rntc_kohm <= r_table_kohm[hi]) {
        return (float)(RT_TABLE_START_C + (hi * RT_TABLE_STEP_C));
    }

    /* 二分查找:定位 rntc 落在哪两个相邻温度点之间 */
    while (lo + 1 < hi) {
        mid = (lo + hi) / 2;
        if (rntc_kohm > r_table_kohm[mid]) {
            hi = mid;   /* 阻值比 mid 大 → 温度比 mid 低 */
        } else {
            lo = mid;
        }
    }

    /* 线性插值:T = T_lo + (R - R_lo) * (T_hi - T_lo) / (R_lo - R_hi) */
    return (float)(RT_TABLE_START_C + lo * RT_TABLE_STEP_C)
         + (rntc_kohm - r_table_kohm[lo])
           / (r_table_kohm[lo] - r_table_kohm[hi]) * (float)RT_TABLE_STEP_C;
}

代码里有两个容易被忽略的点:一是查表入口要做防越界保护(阻值超出表两端直接返回边界温度,而不是数组越界崩溃),二是二分查找的终止条件是 lo + 1 < hi 而不是 lo < hi,否则会死循环。这两处我在初版代码里都栽过跟头。

五、测试验证:理论对照与实测数据

5.1 理论对照 1:ADC 分辨率 vs 实测噪声

先说理论值。F103 的 12 位 ADC 在 3.3V 参考下,1 LSB = 3.3V / 4096 ≈ 0.806mV。在 25℃ 附近(Rntc = 10kΩ、Rm = 10kΩ),分压电路灵敏度 dV/dR ≈ 8.25e-5 V/Ω,NTC 在 25℃ 的温度系数约 −4.4%/℃(≈ 444Ω/℃)。三者一除,理论分辨率:

复制代码
1 LSB 对应的温度变化 ≈ 0.806mV ÷ 8.25e-5 V/Ω ÷ 444Ω/℃ ≈ 0.022℃/LSB

这是理想情况下的极限分辨率。实测结果如下(25℃ 恒温环境,同一时间窗内统计):

采样方式 读数标准差 峰峰值 等效温度噪声
单次采样 1.6 LSB ±4 LSB ±0.088℃
8 次平均 0.6 LSB ±2 LSB ±0.044℃
32 次平均 0.3 LSB ±1 LSB ±0.022℃
128 次中位值 0.2 LSB ±1 LSB ±0.015℃

理论 vs 实测偏差分析:实测单次采样的噪声(±0.088℃)明显大于理论分辨率(0.022℃),原因有两个:一是板上 LDO 输出有约 5mV 峰峰值的纹波(用示波器实测),直接叠加到分压网络供电上;二是 12 位 ADC 的有效位数(ENOB)达不到 12 位,F103 手册标称 ENOB 约 11.2 位,等效于把理论分辨率打了对折。128 次中位值滤波后实测噪声 0.015℃ 反而优于理论单次分辨率,说明噪声被统计平均压下去了,符合预期。

5.2 理论对照 2:三种标定方式的精度实测

用恒温槽把 NTC 分别稳定在 0℃、25℃、50℃、85℃,对比三种换算方式与标准温度计的偏差:

温度点 B 方程偏差 Steinhart-Hart 偏差 查表法偏差
0℃ +0.32℃ +0.08℃ +0.05℃
25℃ +0.04℃ −0.02℃ −0.01℃
50℃ −0.55℃ +0.11℃ +0.06℃
85℃ −1.82℃ +0.15℃ +0.09℃

偏差原因分析:B 方程假定 B 值是常数,但实际 NTC 的 B 值随温度升高而减小,所以高温端用 3950K 硬套必然把温度算低------85℃ 处偏差 1.8℃ 就是这么来的。Steinhart-Hart 用三个参数拟合整条曲线,全量程偏差压到 0.15℃ 以内。查表法用的是厂家逐点实测数据加插值,偏差最小但需要额外空间存表(本文例子是每 1℃ 一行,-40~125℃ 全表约 166 个 float,占 664 字节 Flash)。

设计决策小结:测温区间窄(比如只关心 20~40℃)用 B 方程足够;区间宽(0~85℃)或要 0.1℃ 级精度,用 Steinhart-Hart 性价比最高;要量产一致性最好,用厂家 RT 表。工程上我通常默认 Steinhart-Hart。

5.3 量化参数对比:采样次数与响应速度的权衡

中位值滤波的采样次数不是越多越好,它用时间换噪声。实测 1s 采样周期下的情况:

采样次数 噪声(±℃) 单次采样耗时 对阶跃温度变化的响应延迟
8 0.044 0.17ms 立即
32 0.022 0.67ms 立即
128 0.015 2.7ms 1 个周期
512 0.010 10.8ms 2~3 个周期

采样耗时按单次转换 21μs 计算(12MHz 时钟、239.5+12.5 cycles)。对电机绕组这种变化快的场景,128 次以上会出现明显的响应滞后,建议 32 次;对电池包这种缓变量,128 次是噪声和延迟的平衡点。

六、失败路径复盘:三个真实踩坑记录

6.1 踩坑一:采样时间不足导致温度系统性偏低

症状:25℃ 恒温下,串口打印温度稳定在 22.8℃,始终偏低 2℃ 多,且阻值反推出来偏大。

排查链 :先用万用表直接量 NTC 两端电压,1.61V,换算阻值约 9.5kΩ,和 25℃ 的 10kΩ 差了 5%,说明问题出在 ADC 采集环节而不是标定环节(标定误差不会这么大)。然后怀疑 ADC 参考电压,但用 ADC 采一个已知 1.65V 的精密基准,读数 2048 完全正确------排除参考电压问题。最后翻参考手册 RM0008 的 ADC 章节,看到采样时间与源阻抗的关系表:5kΩ 源阻抗需要至少 40 个 cycle 的采样时间。而我 CubeMX 里用的默认 1.5 Cycles。根因 :采样电容充电时间不足,读数偏低。改成 239.5 Cycles 后,温度回到 25.1℃,与万用表测量吻合。这个坑的教训是:看到"整体偏低且偏差稳定"的现象,优先怀疑采样时间,而不是标定系数。

6.2 踩坑二:B 值套错导致 85℃ 端偏差 2℃

症状:恒温槽升到 85℃ 时,读数只有 83.2℃。

排查链 :一开始以为是标定公式写错了,检查代码没发现问题。然后怀疑是 B 值参数问题------把 3950 换成厂家 datasheet 里的 B25/85 分段值重新拟合后,偏差从 1.8℃ 降到 0.15℃。根因:B 方程本身是单参数近似,B 值随温度漂移,高温端必然低估。这正是 5.2 节那张表的来源。教训:宽温区测温别用 B 方程硬扛,Steinhart-Hart 或者查表才是正道。

6.3 踩坑三:电源纹波让读数在 0.2℃ 区间抖动

症状:读数稳定后仍然在 25.0~25.2℃ 之间随机跳动,肉眼可见。

排查链 :先用示波器看 3.3V 电源轨,发现 AMS1117 输出的纹波约 8mV@200kHz(开关电源前级传导过来的),这个纹波直接通过分压网络进了 ADC 输入端。然后在采样点对地并了 100nF 陶瓷电容,纹波压到 1mV 以内,读数跳动收窄到 0.05℃ 以内。根因:采样点阻抗高(5kΩ),纹波耦合进来没有旁路路径。教训:NTC 采样点必须就近并联滤波电容,这比任何软件滤波都治本。

七、故障排查速查表

现象 最常见原因 排查思路与解决
温度读数整体偏低 1~3℃ ADC 采样时间太短 检查 SMP 位是否 ≥55.5 Cycles;用万用表量采样点电压交叉验证
读数在 ±0.1℃ 以上随机跳动 电源纹波 / 未滤波 示波器看 3.3V 纹波;采样点并联 100nF 电容;软件加中位值滤波
高温端偏差越来越大 B 值方程在宽温区失效 换 Steinhart-Hart 或查表法;确认 B 值参数是否匹配具体型号
读数恒为 4095(满量程) NTC 开路或虚焊 检查 NTC 两端焊点;万用表量阻值是否为 10k 量级
读数恒为 0 或极小 采样点对地短路 / Rm 开路 检查分压电阻是否焊上;测量采样点对地电压是否约 1.65V
换一块板读数就变 F103 内部参考电压个体差异 用 3.3V 轨作为基准(约掉 VREF 项);或做单点校准补偿偏移
读数变化滞后明显 采样次数过多 / NTC 热容大 减小中位值滤波窗口;检查 NTC 封装是否与被测体热接触良好

八、总结

回顾一下整条链路的关键结论:

  • 分压电阻 Rm 选与目标温度区间中值对应的 NTC 阻值,能把 ADC 分辨率集中在需要的区间,10kΩ NTC 配 10kΩ 电阻是最均衡的默认组合;
  • ADC 采样时间必须按源阻抗手动拉长,F103 默认 1.5 Cycles 对 5kΩ 源阻抗会引入约 1% 的系统误差,CubeMX 不会帮你判断;
  • 三种标定方式实测误差:B 方程在 0~85℃ 最大偏差 1.8℃,Steinhart-Hart 0.15℃,查表法 0.09℃;宽温区或高精度场景别用 B 方程;
  • 中位值滤波 128 次能把温度噪声从 ±0.088℃ 压到 ±0.015℃,但采样周期紧张时 32 次是更好的权衡点;
  • F103 的 VREF 直连 VDDA,用"分压比"形式计算阻值可以天然抵消 VCC 与 VREF 同源部分的误差。

适用边界:本文方案适用于 0~85℃ 区间、静态或缓变温度测量。快速变化的温度(如气流温度)受限于 NTC 自身热响应时间(MF52 在空气中约 7s),需要换更小封装或薄膜 NTC。高于 100℃ 的应用建议用 PT100/PT1000 铂电阻,NTC 在高温段的非线性会放大到难以补偿。

扩展方向 :下一步可以试三件事------把测温做成定时器触发 + DMA 采集的多通道版本(顺便测环境温度做参考);在 STM32F103 上实现"双 ADC 比例测量"消除不同芯片 VREF 个体差异(很多工业板卡就是这么做的);或者把温度接到 PID 上做一个恒温控制闭环,把本文的稳态误差数据用在控制器整定里。STM32 入门到进阶的完整路线也可以参考这篇汇总:《STM32入门教程汇总》

如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员

版本备注

  • 硬件平台:STM32F103C8T6 最小系统板 + MF52 10kΩ/B3950 NTC + 10kΩ 0.1% 精密电阻 + 100nF 滤波电容
  • 软件版本:STM32CubeIDE 1.15.0 + STM32CubeF1 FW 1.8.5
  • 兼容说明:代码可直接用于 F103 全系列;移植到 F0/F4 时需注意 ADC 时钟分频不同(F4 为 PCLK2/2、/4、/6、/8 可选),F4 支持软件校准(ADCAL),F1 无此功能
  • API 变更风险:HAL 库 1.6.x 与 1.8.x 的 HAL_ADC_PollForConversion 行为一致;logf 依赖浮点库,Keil 下需勾选 Use MicroLIB 或完整 FPU 库,否则链接报错

参考资料

  1. ST 官方参考手册 RM0008(STM32F101xx/F102xx/F103xx)ADC 章节,采样时间与源阻抗关系表
  2. MF52 NTC 产品规格书(R-T 表与 B 值参数)
  3. 《手把手教你使用热敏电阻NTC,产品级精度±0.1℃以内》 --- 查表法与双 ADC 比例测量的工程实现
  4. 《NTC电阻基本知识介绍》 --- NTC 材料与参数基础
  5. 《零基础快速上手STM32开发(手把手保姆级教程)》 --- CubeMX 工程建立流程参考
相关推荐
M78佐菲43 分钟前
ARM学习笔记(五)
linux·arm开发·笔记·嵌入式硬件·学习
恒锐丰科技林技术员1 小时前
EG2184 半桥栅极驱动芯片详解:高压半桥电路的性价比之选
经验分享·嵌入式硬件·硬件工程
_Ningye1 小时前
通用异步收发器 UART
单片机
威嵌神州1 小时前
复旦微 FMQL100TAI 开发 8 问:仿真器、系统、接口高频问题解答
人工智能·嵌入式硬件·fpga开发
H_oRIZoN_1 小时前
Linux入门DAY45 IMX6ULL 裸机开发笔记:汇编点灯、C 语言点灯、GIC 中断梳理
linux·单片机·嵌入式硬件
怀民民民1 小时前
嵌入式软件学习路线与项目实践记录:STM32、FreeRTOS、Linux 与 OpenMV
stm32·freertos·嵌入式软件·嵌入式linux·openmv
离凌寒2 小时前
一、关于linux开发板上移植ubuntu根文件系统并配置桌面环境笔记记录
驱动开发·单片机
Rsingstarzengjx3 小时前
第二十八章 Linux 并发与竞争实验
驱动开发·嵌入式硬件·stm32mp157
辰域电子3 小时前
STM32项目开源:实验室消防预警控制系统(代码 + 原理图 + 仿真)
stm32·单片机·嵌入式硬件·开源·proteus