【ESP32笔记】ADC获取NTC温度

本节目标:搞懂 ADC 单次采样与校准,能从分压电路的采样值反推 NTC 阻值,并用"查表 + 二分查找 + 线性插值"算出温度

一、本节要解决的问题

DHT11、DS18B20 这类数字传感器内部已经把模拟量处理成数字量,MCU 直接读结果就行;而 NTC 热敏电阻是最便宜的测温器件,它本身只是一个"阻值随温度变化"的电阻,没有数字接口。

要用 ESP32 读出温度,必须自己打通一条转换链:温度 → NTC 阻值 → 分压点电压 → ADC 原始码值 →(校准)电压 → 阻值 → 温度 。前半段靠硬件分压电路,后半段靠软件换算;普通 GPIO 只能读高/低两种状态,所以中间必须使用 ADC(模数转换器)。本节的重点和难点都在后半段。

二、硬件基础

2.1 NTC 与 B 参数方程

NTC 是 Negative Temperature Coefficient 的缩写,即负温度系数热敏电阻:温度越高,阻值越小 。它的阻值-温度关系是非线性的,通常用 B 参数方程描述:

复制代码
Rt  = R0 * exp(Bn * (1/T - 1/T0))                  已知温度求阻值
1/T = 1/T0 + (1/Bn) * ln(Rt/R0)                    已知阻值求温度
T(℃) = 1 / (1/T0 + ln(Rt/R0)/Bn) - 273.15          输出摄氏度

公式里的 T、T0 单位是开尔文(K),算完要减 273.15 才是摄氏度。教程给出的典型参数:T0=25℃(298.15K)时 R0=10KΩ,材料常数 Bn=3950。

注意 :教程讲解了 B 参数公式,但配套工程没有用公式直接算温度,而是使用厂家 R-T 表 + 查表插值(见 3.4 节),取舍在后面说明。

2.2 分压电路

阻值没法被 MCU 直接读取,必须先转成电压:

复制代码
+3.3V ──── 10K 电阻 ────●──── NTC ──── GND
                        │
                      Vout → GPIO36
  • 上臂是 10K 参考电阻(对应代码里的 NTC_RES),下臂是 NTC,分压点 Vout 接 ESP32 的 GPIO36(原理图中的 SENSOR_VP 网络);

  • 由分压公式 Vout = 3.3V × R_ntc / (10K + R_ntc) 反解:

    R_ntc = Vout × 10K / (3.3V − Vout)

也就是代码 res = (voltage*NTC_RES)/(ADC_V_MAX-voltage) 的由来。温度升高 → NTC 阻值下降 → Vout 下降;25℃ 时 NTC 正好是 10K,Vout = 1.65V,工作点落在量程中间。

2.3 ESP32 的 ADC 资源

ESP32 有 ADC1、ADC2 两个转换模块,每个 8 路通道。支持 ADC1 的 GPIO 只有这 8 个:

GPIO ADC1 通道 GPIO ADC1 通道
GPIO36(VP) CH0 GPIO32 CH4
GPIO37 CH1 GPIO33 CH5
GPIO38 CH2 GPIO34 CH6
GPIO39(VN) CH3 GPIO35 CH7

注意 :ADC2 与 WiFi 不能同时使用 ,教程明确不推荐 ADC2,本节只用 ADC1。这些引脚号是 ESP32 特有的,换成 ESP32-S3 后 ADC1_CH0 对应的 GPIO 完全不同;仓库里个别工程(如 ledc)已被切到 esp32s3,但 ntc/sr04/sdcard 三个工程的 sdkconfig 目标仍是 esp32。

2.4 衰减与量程

ESP32 内部 ADC 参考电压只有 1100mV,要测更大电压必须设置衰减:

衰减设置 可测范围
ADC_ATTEN_DB_0 100 ~ 950 mV
ADC_ATTEN_DB_2_5 100 ~ 1250 mV
ADC_ATTEN_DB_6 150 ~ 1750 mV
ADC_ATTEN_DB_12 150 ~ 2450 mV

注意 :公式里的 3300(mV)是分压电路上端的 3.3V 参考电压,不是 ADC 量程。12dB 衰减实际只能正确测到约 2450mV,NTC 在低温时阻值变大、Vout 可能超过 2450mV 而饱和(读数停在 4095),这是模拟测温的硬件边界。

三、知识点

3.1 单次转换与连续转换

ESP-IDF 提供两类 ADC 驱动:单次转换 (adc_oneshot_*)按需触发一次,读完就停;连续转换 (adc_continuous_*)用 DMA 不停搬运,适合高速采样。教程作者实测连续转换精度差、易受其他通道影响,因此本工程只用单次转换。

3.2 分辨率决定原始值范围

12 位分辨率意味着原始码值范围是 0 ~ 2¹²−1 = 0 ~ 4095:输入 0mV 读数接近 0,输入约 2450mV 读数到 4095,超过量程则饱和为 4095、不会再变大。

3.3 校准:把原始值换成毫伏

每颗芯片出厂时在 eFuse 里预烧了 ADC 校准参数,adc_cali_raw_to_voltage() 利用它们把原始码值换算成比较准确的毫伏值。ESP32 上优先使用曲线拟合 (Curve Fitting)方案;example_adc_calibration_init() 在曲线拟合不可用时会退回线性拟合(Line Fitting),两者都不可用则返回 false、跳过校准。

3.4 从阻值到温度:查表 + 二分查找 + 线性插值(难点)

不用 B 参数公式直接算温度的原因:B 值本身有误差,不同批次、不同厂家的 NTC 并不相同;求温度要算对数,在 MCU 上不划算;而厂家会直接提供标定好的"温度-阻值"表。于是问题变成"给我一个阻值,怎么快速查到温度",工程分三步:

  1. 建表 :结构体数组保存"温度 + 该温度下的阻值",从 -10℃ 到 50℃。温度越高阻值越小,所以表的阻值是降序排列的;

  2. 二分查找:表有 61 项,遍历最坏要看 61 次,二分查找最多 6 次比较就能定位;

  3. 线性插值:算出的阻值几乎不可能正好等于表中某一项。把相邻两点 (X1,Y1)、(X2,Y2) 当作一条直线(两点距离近时足够近似曲线),用两点式求 k、b,再代入 R 得到带小数的温度:

    k = (y2 - y1) / (x2 - x1)
    b = (x2 * y1 - x1 * y2) / (x2 - x1)
    T = k * R + b

注意(教程与仓库代码的差异) :教程伪代码写的是 R >= s_ntc_table[index].res 且 R < s_ntc_table[index+1].res,把表当成"阻值升序"来夹逼。但仓库的 s_ntc_table[] 是阻值降序 排列(index 越大阻值越小),find_ntc_index() 实际返回的是满足 s_ntc_table[index].res <= R 的下标,因此 s_ntc_table[index+1].res 比 R 更小。代码实际是在 index 和 index+1 两点上做直线近似;因为相邻两点很近、曲线平缓,误差很小,但阅读时应按仓库的实际行为理解,不要照抄教程的不等式。

四、代码分析

4.1 工程结构

复制代码
ntc/
├── CMakeLists.txt
├── main/
│   ├── CMakeLists.txt      # idf_component_register(SRCS "ntc.c" "main.c" INCLUDE_DIRS ".")
│   ├── main.c              # app_main():每秒打印一次温度
│   ├── ntc.c               # ADC 初始化、采样任务、NTC 表、二分查找、线性插值
│   └── ntc.h               # 对外提供 temp_ntc_init() 和 get_temp()
└── sdkconfig               # 目标芯片 esp32

4.2 关键宏定义

c 复制代码
#define TEMP_ADC_CHANNEL     ADC_CHANNEL_0      //ADC转换通道(ADC1有8个通道,对应gpio32 - gpio39)

#define NTC_RES             10000               //NTC电阻标称值(在电路中和NTC一起串进电路的那个电阻,一般是10K,100K)
#define ADC_V_MAX           3300                //最大接入电压值

#define ADC_VALUE_NUM       10                  //每次采样的电压

ADC_CHANNEL_0 在 ESP32 上对应 GPIO36,与教程说的"Vout 接到 GPIO36"一致;ADC_V_MAX 的单位是 mV,所以能和 adc_cali_raw_to_voltage() 输出的毫伏值直接做运算。

4.3 初始化:建单元、配通道、校准、建任务

c 复制代码
void temp_ntc_init(void)
{
    adc_oneshot_unit_init_cfg_t init_config1 = {
        .unit_id = ADC_UNIT_1,  //WIFI和ADC2无法同时启用,这里选择ADC1
    };

    //启用单次转换模式
    ESP_ERROR_CHECK(adc_oneshot_new_unit(&init_config1, &s_adc_handle));

    //-------------ADC1 Config---------------//
    adc_oneshot_chan_cfg_t config = {
        .bitwidth = ADC_BITWIDTH_12,       //分辨率
        .atten = ADC_ATTEN_DB_12,
        //衰减倍数,ESP32设计的ADC参考电压为1100mV,只能测量0-1100mV之间的电压,如果要测量更大范围的电压
        //需要设置衰减倍数(下面在源码里还有一段可测量范围注释,内容与 2.4 节表格相同,此处从略)
    };
    ESP_ERROR_CHECK(adc_oneshot_config_channel(s_adc_handle, TEMP_ADC_CHANNEL, &config));
    //-------------ADC1 Calibration Init---------------//
    do_calibration1 = example_adc_calibration_init(ADC_UNIT_1, ADC_ATTEN_DB_12, &adc1_cali_handle);

    //新建一个任务,不断地进行ADC和温度计算
    xTaskCreatePinnedToCore(temp_adc_task, "adc_task", 2048, NULL,2, NULL, 1);
}

流程是四步:创建 ADC1 单次转换单元 → 配置通道(12 位、12dB 衰减)→ 初始化校准 → 创建采样任务(优先级 2、绑定内核 1、栈 2048 字节)。校准函数内部按"曲线拟合 → 线性拟合"的顺序尝试,成功返回 true,失败打印警告并返回 false。

4.4 采样任务:10 次平均滤波 + 换算

c 复制代码
static void temp_adc_task(void* param)
{
    uint16_t adc_cnt = 0;
    while(1)
    {
        adc_oneshot_read(s_adc_handle, TEMP_ADC_CHANNEL, &s_adc_raw[adc_cnt]);
        if (do_calibration1) {
            adc_cali_raw_to_voltage(adc1_cali_handle, s_adc_raw[adc_cnt], &s_voltage_raw[adc_cnt]);
        }
        adc_cnt++;
        if(adc_cnt >= 10)
        {
            int i = 0;
            //用平均值进行滤波
            uint32_t voltage = 0;
            uint32_t res = 0;
            for(i = 0;i < ADC_VALUE_NUM;i++)
            {
                voltage += s_voltage_raw[i];
            }
            voltage = voltage/ADC_VALUE_NUM;

            if(voltage < ADC_V_MAX)
            {
                //电压转换为相应的电阻值
                res = (voltage*NTC_RES)/(ADC_V_MAX-voltage);
                //根据电阻值查表找出对应的温度
                s_temp_value = get_ntc_temp(res);
            }

            adc_cnt = 0;
        }

        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

每轮采一次原始值存入 s_adc_raw[],校准成功才把毫伏值存入 s_voltage_raw[];采满 10 次求平均抑制噪声(教程说的"用平均值进行滤波"),再用分压公式算阻值、查表得温度。voltage < ADC_V_MAX 既是防除零也是防异常数据;每轮延时 100ms,所以温度大约每秒更新一次。app_main()(main.c)只做两件事:调用 temp_ntc_init(),然后每 1000ms 打印一次 get_temp(),温度的计算全部发生在采样任务里,两者通过 volatile 变量 s_temp_value 通信。

4.5 NTC 表、二分查找与线性插值

表用结构体数组保存:每一项是 temp_res_t,成员 temp(int8_t,温度)和 res(uint32_t,阻值)。表共 61 项,从 -10℃ 到 50℃,下面只摘录几项,完整表见 ntc.c:

c 复制代码
static const temp_res_t s_ntc_table[] =
{
    {-10,51815}, //......
    {25,10000},  //......
    {50,3592},
};

二分查找:因为表按阻值降序排列,可以直接折半收缩区间。

c 复制代码
/** 二分查找,通过电阻值查找出温度值对应的下标
 * @param res 电阻值,left/right 为 ntc 表的左右边界
 * @return 温度值数组下标
*/
static int find_ntc_index(uint32_t  res,uint16_t left, uint16_t right)
{
    uint16_t middle = (left + right) / 2;
    if (right <= left || left == middle)
    {
        if (res >= s_ntc_table[left].res)
            return left;
        else
            return right;
    }
    if (res > s_ntc_table[middle].res)
    {
        right = middle;
        return find_ntc_index(res,left, right);
    }
    else if (res < s_ntc_table[middle].res)
    {
        left = middle;
        return find_ntc_index(res,left, right);
    }
    else
        return middle;
}

线性插值由 linera_interpolation() 实现,算法就是 3.4 节的两点式(函数名少了一个字母 n,仓库原样如此),返回值是带小数的温度。

c 复制代码
//根据电阻值、NTC表查出对应温度
static float get_ntc_temp(uint32_t res)
{
    uint16_t left = 0;
    uint16_t right = s_us_ntc_table_num - 1;
    int index = find_ntc_index(res,left,right);
    if (res == s_ntc_table[index].res)
        return s_ntc_table[index].temp;
    if (index == 0 || index == s_us_ntc_table_num - 1)
    {
        return s_ntc_table[index].temp;
    }
    else
    {
        int next_index = index + 1;
        return linera_interpolation(res, s_ntc_table[index].res, s_ntc_table[next_index].res,
        s_ntc_table[index].temp, s_ntc_table[next_index].temp);
    }
}

get_ntc_temp() 处理三种情况:阻值正好命中表中某一项 → 直接返回整数温度;落到了表的边界(index 为 0 或最后一项)→ 返回端点温度;其余情况 → 在 index 与 index+1 两点之间做线性插值。

五、关键 API 速查

API 作用
adc_oneshot_new_unit() 创建 ADC 单次转换单元(选 ADC1/ADC2),拿到句柄
adc_oneshot_config_channel() 配置指定通道的分辨率与衰减
adc_oneshot_read() 触发一次转换,读出原始码值(0~4095)
adc_cali_create_scheme_curve_fitting() 用芯片 eFuse 参数建立曲线拟合校准方案
adc_cali_raw_to_voltage() 原始码值 → 毫伏值
xTaskCreatePinnedToCore() 创建任务并绑定到指定内核
vTaskDelay(pdMS_TO_TICKS(ms)) 毫秒级延时

六、编译与现象验证

bash 复制代码
cd esp32-board/ntc        # 工程当前配置的目标就是 esp32
idf.py build
idf.py -p COM<端口> flash monitor

启动日志先打印校准信息(如 adc: calibration scheme version is Curve Fitting、adc: Calibration Success),之后每秒打印一行 I (xxxx) main: current temp:25.32。用手指捏住 NTC,读数会缓慢上升;松手后慢慢回落。

相关推荐
志尊宝1 小时前
Vue3 零基础每日笔记(088·完结):从零到一——一个完整 Vue3 项目的成长复盘
前端·javascript·vue.js·笔记·ecmascript·#vue.js
Capricorn19881 小时前
一段内容查不出对错时:知芽 Notebook Skill 与 SynthID 检测器的边界
大数据·论文阅读·人工智能·笔记
ClinicTech1 小时前
蠕动泵的隔离式传输原理与关键控制功能解析
单片机·嵌入式硬件
奋斗的阿狸_19861 小时前
ESP32-S3 DevKit 模拟量采集调试报告
单片机·嵌入式硬件
新晨单片机设计1 小时前
S028A-基于STM32单片机智能电表【Proteus仿真+Keil程序+原理图】
stm32·单片机·proteus·智能电表
霖霖7141 小时前
【ESP32笔记】SDIO读写TF卡
笔记·单片机·嵌入式硬件·mcu·物联网
幼儿园蛋小黄1 小时前
嵌入式学习总结:从 I2C 中断驱动到 DHT11 温湿度采集实战
单片机·嵌入式硬件·学习
星恒随风2 小时前
Linux进程间通信(二):深入理解System V共享内存、消息队列、信号量与内核IPC资源管理
linux·运维·服务器·笔记·学习·状态模式
商业白皮书2 小时前
企业通过API调用大模型,推荐哪些具备数据隐私保护能力的平台?
大数据·人工智能·笔记