模拟输入:用电位器读懂连续量与 ADC 采样
- 理解 ADC 把模拟电压转成数字的基本原理,知道 ESP32-S3 是 12 位、读数范围 0-4095
- 会用 adc_oneshot 驱动读电位器的值,看懂 atten 衰减与量程的对应关系
- 能避开 ADC2 与 WiFi 冲突的坑,知道优先用 ADC1 的 GPIO1-GPIO10
- 会用多次采样取平均做简单滤波,并用 LEDC 把读数映射成 LED 亮度
你已经会让一个按键告诉单片机「我按下了」或「我松开了」——这是数字输入,只有高、低两种状态。可现实里很多东西不是非黑即白:一个旋钮可以转到任意角度,光线可以从昏暗渐变到刺眼,电池电压会在放电过程里慢慢往下掉。这些连续变化的量,没法用一个 0 或 1 说清楚。
要读它们,需要一座桥——把连续的电压翻译成单片机能处理的整数。这座桥就是 ADC(Analog-to-Digital Converter,模数转换器)。本篇用最便宜、最直观的元件——电位器(可调电阻,俗称旋钮),带你把这座桥走一遍。开始前请确认你已经跑通过 /guide/l1-blink/,知道怎么 idf.py build flash monitor、怎么看串口日志。
ADC 到底在做什么
把它想象成一把尺子。你给 ADC 一个电压,它告诉你这个电压在「0 到量程上限」这段里占了多少格。格子越多,读得越细。
ESP32-S3 的 ADC 是 12 位的,意味着它把整个量程切成 2 的 12 次方等于 4096 格,编号 0 到 4095。假设量程是 0-3.3V:
- 输入 0V,读数约等于 0
- 输入 1.65V(一半),读数约等于 2048
- 输入 3.3V(满量程),读数约等于 4095
所以读数 ÷ 4095 × 量程,就能反推出电压。转换的内部原理(逐次逼近、采样保持这些)我们不在这里展开,想深挖去看 /principle/adc/;电位器为什么能输出可调电压,背后是分压,看 /principle/divider/ 会更透彻。本篇只管把它用起来。
ESP32-S3 的 ADC 有几个必须知道的脾气
它不是一块完美的尺子,用之前先认清四件事:
一、量程不是 0-3.3V,要靠 atten(衰减)撑开。 ADC 核心其实只认得很窄的电压(约 0-0.95V)。要测到 3V 上下,得在配置通道时给它设一级「衰减」,把信号按比例缩小再喂给核心。ESP-IDF 5.x 里用 ADC_ATTEN_DB_12(满量程约 0-3.1V,足够覆盖 3.3V 供电的电位器中脚),这就是后面代码里 .atten = ADC_ATTEN_DB_12 的来历。衰减选小了(比如 ADC_ATTEN_DB_0)会出现「电压才到 1V 多读数就顶满 4095」的现象。
老 Arduino 框架下默认就帮你把衰减设好了,所以那边你不用管。换到 ESP-IDF 后这件事交回到你手里——adc_oneshot_config_channel 时显式写明 atten,量程到底多大你心里有数,做产品时不会被「框架默默替你设了个值」坑到。
二、它有点不准,而且不线性。 ESP32-S3 的 ADC 在量程两端(接近 0 和接近 4095)会塌边,中间段也有几十个数的噪声跳动。给你一个明确观点:拿它做大致判断(旋钮转了多少、天黑了没有)完全够用;但凡涉及精密测量(比如要 1% 精度的电压表),要么用下面讲的 adc_cali 校准句柄把原始值换算成校准过的毫伏,要么直接外挂一颗专用 ADC 芯片(比如 ADS1115)。别指望裸用片内 ADC 做仪表级活儿。
三、ADC2 和 WiFi 抢资源。 ESP32-S3 有两组 ADC:ADC1 和 ADC2。WiFi 工作时射频会占用 ADC2,导致 ADC2 上的引脚读数失效或返回乱值。结论很简单:只要你的项目用了 WiFi,就优先用 ADC1 的引脚。 ESP32-S3 上 ADC1 对应 GPIO1 到 GPIO10,引脚和通道一一对应:
| GPIO | ADC1 通道宏 |
|---|---|
| GPIO1 | ADC_CHANNEL_0 |
| GPIO2 | ADC_CHANNEL_1 |
| GPIO3 | ADC_CHANNEL_2 |
| GPIO4 | ADC_CHANNEL_3 |
| … | … |
| GPIO10 | ADC_CHANNEL_9 |
本篇用 GPIO3(也就是 ADC_CHANNEL_2)接电位器中脚,正是 ADC1。注意 ESP32-S3 的脚位和老 ESP32 不一样——老 ESP32 的 ADC1 在 GPIO32-39,那套脚位号搬到 S3 上是错的,别照抄旧教程。
四、避开 strapping / USB 脚。 ESP32-S3 的 GPIO0、GPIO45、GPIO46 是 strapping 脚(上电时影响启动模式),GPIO19/20 默认走 USB,能不碰就别拿来做 ADC 输入。从 GPIO1-GPIO10 里随便挑一个干净的就行,本篇的 GPIO3 没这些麻烦。
接线与第一段代码:读电位器
电位器有三只脚。把两端的两只脚分别接到 3.3V 和 GND,中间那只脚(叫滑动端 / wiper)接到 GPIO3。这样转动旋钮,中间脚的电压就在 0 到 3.3V 之间连续变化——这正是分压的把戏。
接线清单:
- 电位器左脚 → 3.3V
- 电位器右脚 → GND
- 电位器中脚 → GPIO3(ADC1_CHANNEL_2)
- 顺便:一颗 LED(串 220Ω 限流电阻)接 GPIO2 → GND,待会儿用来调光
代码先只读、只打印。ESP-IDF 5.x 用的是 oneshot(单次采样)驱动,比老接口干净得多:
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_adc/adc_oneshot.h"
static const char *TAG = "adc";
#define POT_CHANNEL ADC_CHANNEL_2 // GPIO3,ADC1
void app_main(void)
{
// 1. 初始化 ADC1 单元
adc_oneshot_unit_handle_t adc1;
adc_oneshot_unit_init_cfg_t init = {
.unit_id = ADC_UNIT_1,
};
adc_oneshot_new_unit(&init, &adc1);
// 2. 配置我们用的那个通道:12dB 衰减(量程约 0-3.1V),默认位宽(12 位)
adc_oneshot_chan_cfg_t ch = {
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
adc_oneshot_config_channel(adc1, POT_CHANNEL, &ch);
while (1) {
int raw = 0;
adc_oneshot_read(adc1, POT_CHANNEL, &raw); // 读出 0-4095
ESP_LOGI(TAG, "ADC 原始值: %d", raw);
vTaskDelay(pdMS_TO_TICKS(200));
}
}
三步走,记住这个套路:建单元(adc_oneshot_new_unit)→ 配通道(adc_oneshot_config_channel,定 atten 和位宽)→ 循环读(adc_oneshot_read)。adc_oneshot_read 把读到的值塞进你给的 &raw 里,返回 0-4095 的整数。
你应该看到什么
idf.py build flash monitor 上传后看串口日志(波特率 115200)。把电位器从一头慢慢转到另一头,你应该看到 ADC 原始值: 后面的数字在 0 附近和 4095 附近之间平滑爬升或下降。如果转到底是 4095、转到另一头是 0(或者方向相反),说明接线正确、ADC 在工作。
如果数值一直跳、根本不动,或者卡在某个固定值——别急,往下看故障排查表。
加上控制:旋钮调 LED 亮度
光读数不过瘾,让它干点活。把 0-4095 的读数映射到 LEDC 的占空比,去控制 LED 亮度,转动旋钮就能无级调光。LEDC 你在 /guide/l1-blink/ 的呼吸灯变体里见过——同一套 API,这里只是把「自动渐变」换成「跟着旋钮走」。
LED 用 8 位分辨率(占空比 0-255),所以要把 ADC 的 0-4095 线性缩到 0-255。注意这里不用 Arduino 的 map() 函数,ESP-IDF 没有它,自己写一行整数运算更透明:duty = raw * 255 / 4095。
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_adc/adc_oneshot.h"
#include "driver/ledc.h"
static const char *TAG = "adc";
#define POT_CHANNEL ADC_CHANNEL_2 // GPIO3,ADC1
#define LED_GPIO GPIO_NUM_2
void app_main(void)
{
// ---- ADC 初始化(同上)----
adc_oneshot_unit_handle_t adc1;
adc_oneshot_unit_init_cfg_t init = { .unit_id = ADC_UNIT_1 };
adc_oneshot_new_unit(&init, &adc1);
adc_oneshot_chan_cfg_t ch = {
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
adc_oneshot_config_channel(adc1, POT_CHANNEL, &ch);
// ---- LEDC 初始化:8 位分辨率(0-255)、5kHz ----
ledc_timer_config_t timer = {
.speed_mode = LEDC_LOW_SPEED_MODE,
.duty_resolution = LEDC_TIMER_8_BIT,
.timer_num = LEDC_TIMER_0,
.freq_hz = 5000,
.clk_cfg = LEDC_AUTO_CLK,
};
ledc_timer_config(&timer);
ledc_channel_config_t led_ch = {
.gpio_num = LED_GPIO,
.speed_mode = LEDC_LOW_SPEED_MODE,
.channel = LEDC_CHANNEL_0,
.timer_sel = LEDC_TIMER_0,
.duty = 0,
.hpoint = 0,
};
ledc_channel_config(&led_ch);
while (1) {
int raw = 0;
adc_oneshot_read(adc1, POT_CHANNEL, &raw); // 0-4095
int duty = raw * 255 / 4095; // 线性映射到 0-255
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty);
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
ESP_LOGI(TAG, "ADC: %d -> 亮度: %d", raw, duty);
vTaskDelay(pdMS_TO_TICKS(50));
}
}
raw * 255 / 4095 这一行就是线性映射:先乘后除,整数运算注意先乘 255 再除 4095(顺序反了会因整数截断丢精度)。ledc_set_duty + ledc_update_duty 是 LEDC 改占空比的固定两步——设值、生效,缺一不可。LEDC 的频率、占空比、分辨率怎么选,下一篇 /guide/l2-pwm-led/ 会讲透,这里先照用。
转动旋钮,LED 应该跟着由暗到亮、由亮到暗,串口同步打印两个数。这就是「读模拟量 → 做决策 → 输出控制」的最小闭环。
读电压,比读原始值更直观
很多时候你关心的不是 0-4095 这个抽象数字,而是「中脚现在是几伏」。最糙的算法是 raw / 4095.0 * 3.1(量程按 12dB 衰减约 0-3.1V 算),但这没有考虑 ADC 的非线性和个体偏差。ESP-IDF 提供了校准方案:用 adc_cali 句柄把原始值换算成校准过的毫伏,结果靠谱不少。
ESP32-S3 用曲线拟合(curve fitting)校准方案。先建校准句柄:
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
// ...在 app_main 里,配好通道之后:
adc_cali_handle_t cali = NULL;
adc_cali_curve_fitting_config_t cali_cfg = {
.unit_id = ADC_UNIT_1,
.chan = POT_CHANNEL,
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
adc_cali_create_scheme_curve_fitting(&cali_cfg, &cali);
然后在循环里把原始值转成毫伏:
int raw = 0, mv = 0;
adc_oneshot_read(adc1, POT_CHANNEL, &raw);
adc_cali_raw_to_voltage(cali, raw, &mv); // mv 是校准后的毫伏,例如 1652
ESP_LOGI(TAG, "%d mV", mv);
atten、bitwidth 这几个值在建校准句柄时必须和你配通道时填的一模一样,否则换算出来的电压是错的。要量电压,优先走这条校准路,比手算映射准得多。
滤波:让读数别那么抖
裸读的 ADC 值会在小范围里上下跳几十个数,做调光看不出来,但要是拿它判断阈值(比如「电压低于 1.0V 就报警」),抖动会让它在临界点反复横跳。最简单有效的办法是多次采样取平均:
int read_smoothed(adc_oneshot_unit_handle_t adc, adc_channel_t channel)
{
long sum = 0;
for (int i = 0; i < 16; i++) { // 连读 16 次
int raw = 0;
adc_oneshot_read(adc, channel, &raw);
sum += raw;
}
return (int)(sum / 16); // 取平均
}
把 adc_oneshot_read(adc1, POT_CHANNEL, &raw) 那行换成 int raw = read_smoothed(adc1, POT_CHANNEL);,读数会明显稳下来。采样次数越多越平滑,但也越慢,16 次是个常用的折中。要再讲究,可以上滑动平均或一阶低通,但对入门项目,平均法足够了。
故障排查表
| 现象 | 可能原因 | 怎么办 |
|---|---|---|
| 读数疯狂乱跳、不受旋钮控制 | 中脚没接好 / 接成了悬空脚 | 确认接的是中间的滑动端,左右两端接 3.3V 和 GND |
| 读数始终是 0 或始终 4095 | 两端的 3.3V 和 GND 接反或漏接一端 | 检查电位器两端是否分别接到电源和地 |
| 转到一半就顶满 4095 | atten 设小了,量程太窄 | 配通道时用 .atten = ADC_ATTEN_DB_12 撑开量程 |
| 编译报「找不到 adc_oneshot.h」 | 头文件路径写错 | ESP-IDF 5.x 是 esp_adc/adc_oneshot.h,别写成老的 driver/adc.h |
| 接了 WiFi 后读数变垃圾 | 用了 ADC2 的引脚,被射频占用 | 换到 ADC1(GPIO1-GPIO10),本篇的 GPIO3 就是 |
adc_oneshot_read 返回错误码 |
通道没配 / 单元没建 | 确认 new_unit → config_channel 这两步都跑了,再读 |
| 校准后电压总不对 | 建 cali 句柄的 atten/bitwidth 和配通道时不一致 | 两处的 .atten、.bitwidth 必须填成一样 |
| 数值有几十个数的小抖动 | ADC 固有噪声 | 用上面的多次采样取平均 |
变体:换个传感器,换个玩法
电位器只是「会变的电压」最听话的样板。把它换成别的元件,同一套 adc_oneshot 代码就能读别的连续量:
- 光敏电阻(LDR):跟一个固定电阻串成分压,光强变化电压就变化,能测亮暗。接法和读法见 /sensor/ldr/。
- 土壤湿度传感器:输出也是一路模拟电压,越湿电压越低(或越高,看模块),照样用
adc_oneshot_read读,详见 /sensor/soil-moisture/。 - 旋钮调速:把读数映射成电机转速、舵机角度或蜂鸣器音调,就是个手动调速器。
模拟量和数字量的本质区别,以及什么时候该用哪种,可以回看 /principle/digital-analog/ 补一补底层概念。
动手挑战
把 /guide/l1-blink/ 里的呼吸灯(LED 由暗到亮再到暗,循环渐变)改造一下:用电位器来控制呼吸的快慢。转旋钮到一头,灯慢悠悠地呼吸;转到另一头,急促地闪。
提示:呼吸灯的「快慢」由每一步亮度变化之间的 vTaskDelay 决定。在呼吸循环里读电位器,把 0-4095 映射成这个延时(比如 1-30 毫秒,delay_ms = 1 + raw * 29 / 4095),喂给 vTaskDelay(pdMS_TO_TICKS(delay_ms)) 就行。你会同时用到本篇的 ADC 读值和下一篇要讲的 LEDC——提前热个身。
小结与下一步
你现在能读连续量了:用 adc_oneshot_new_unit → adc_oneshot_config_channel(定好 atten)→ adc_oneshot_read 把模拟电压变成 0-4095 的整数,记住 12 位分辨率、用 adc_cali_raw_to_voltage 读电压更准、做精密活儿要校准或外置、用 WiFi 就认准 ADC1 的 GPIO1-GPIO10,再用多次采样取平均压住抖动。这把「读」的能力,配上一个传感器就是一台仪器的雏形。
下一篇 /guide/l2-pwm-led/ 专门讲 LEDC/PWM——本篇调光只是顺手用了一下,下一篇会把它讲透:频率、占空比、分辨率到底怎么选,怎么做丝滑的呼吸灯和舵机控制。读完那篇,你的动手挑战也就水到渠成了。
本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。