← 返回教程库

模拟输入:用电位器读懂连续量与 ADC 采样

最后更新 2026-06-22
L2 · 传感与交互 ⏱ 约 22 分钟 🟢 软件/低风险
你将学到
  • 理解 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_readadc_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);

attenbitwidth 这几个值在建校准句柄时必须和你配通道时填的一模一样,否则换算出来的电压是错的。要量电压,优先走这条校准路,比手算映射准得多。

滤波:让读数别那么抖

裸读的 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_unitconfig_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_unitadc_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——本篇调光只是顺手用了一下,下一篇会把它讲透:频率、占空比、分辨率到底怎么选,怎么做丝滑的呼吸灯和舵机控制。读完那篇,你的动手挑战也就水到渠成了。

想看完整的学习路线,回 /guide//roadmap/ 找下一站。

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明