本节目标:搞懂 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 上不划算;而厂家会直接提供标定好的"温度-阻值"表。于是问题变成"给我一个阻值,怎么快速查到温度",工程分三步:
-
建表 :结构体数组保存"温度 + 该温度下的阻值",从 -10℃ 到 50℃。温度越高阻值越小,所以表的阻值是降序排列的;
-
二分查找:表有 61 项,遍历最坏要看 61 次,二分查找最多 6 次比较就能定位;
-
线性插值:算出的阻值几乎不可能正好等于表中某一项。把相邻两点 (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,读数会缓慢上升;松手后慢慢回落。