3. 模拟信号采集:ESP32 ADC原理、电压测量、光敏电阻实战

从数字世界跨入模拟世界,是嵌入式开发中非常有意思的一步。ESP32 虽然有强大的数字处理能力,但它也得「感知」真实世界的电压、温度、光照强度——这些连续变化的物理量。ADC(模数转换器)就是那座桥梁。

我个人习惯把 ADC 比作「电压翻译官」。它把模拟电压翻译成数字,CPU 才能读懂。今天我们就来彻底搞懂 ESP32 的 ADC,并用光敏电阻做个实战。

本章核心目标: 理解 ADC 工作原理 → 掌握 ESP32 ADC 特性 → 实现电压测量 → 完成光敏电阻光照采集
ESP32 ADC 知识体系 ESP32 ADC ADC 工作原理 采样 → 量化 → 编码 分辨率:12位(0~4095) ESP32 ADC 特性 ADC1:8通道,WiFi可用 ADC2:10通道,WiFi冲突 衰减设置:0~11dB 实战应用 电压测量(分压电路) 光敏电阻(光照采集) 软件滤波(均值/中值) 模拟世界 → 数字世界 → 控制逻辑

3.1 ADC 基本原理:从电压到数字

ADC 全称 Analog-to-Digital Converter。它的工作流程可以概括为三步:采样 → 量化 → 编码。

  • 采样: 每隔一段时间「拍一张快照」,记录当前电压值。
  • 量化: 把连续的电压范围分成若干等级。比如 0~3.3V 分成 4096 级。
  • 编码: 把等级编号转换成二进制数字,CPU 直接读取。

分辨率决定了 ADC 的「精细程度」。ESP32 的 ADC 默认是 12 位,也就是 2¹² = 4096 个等级。如果参考电压是 3.3V,那么每个 LSB(最低有效位)代表:

LSB = 3.3V / 4096 ≈ 0.806 mV

也就是说,ADC 读数每变化 1,对应电压变化约 0.8 毫伏。嗯,这个精度对于大多数传感器来说已经够用了。

我的经验: 实际项目中不要指望达到理论精度。ESP32 的 ADC 存在非线性误差,尤其是在电压接近 0V 或接近 Vref 时。我一般会避开两端各 5% 的范围。

3.2 ESP32 ADC 硬件特性

ESP32 内部有两个 ADC 模块:ADC1 和 ADC2。它们各有各的脾气。

特性 ADC1 ADC2
通道数 8 个(GPIO32~39) 10 个(GPIO0,2,4,12~15,25~27)
WiFi 兼容性 可用(WiFi 不影响) 不可用(WiFi 占用时无法使用)
分辨率 12 位(可配置为 9~12 位) 12 位(可配置为 9~12 位)
衰减设置 0dB / 2.5dB / 6dB / 11dB 0dB / 2.5dB / 6dB / 11dB
推荐用途 传感器采集(首选) 非 WiFi 场景
重要提醒: 如果你同时使用 WiFi 和 ADC,必须使用 ADC1。ADC2 在 WiFi 启动后会被占用,读取会返回错误值。我曾经在这个坑里浪费了半天,后来才查到官方文档的说明。

3.3 衰减设置与电压测量范围

ESP32 的 ADC 参考电压(Vref)内部约为 1.1V。如果不设置衰减,输入电压不能超过 1.1V。这显然不够用——我们常用的传感器输出往往是 0~3.3V 甚至更高。

衰减(Attenuation)就是用来解决这个问题的。它相当于在 ADC 输入前加了一个可调的分压器。

衰减设置 可用输入范围 典型用途
ADC_ATTEN_DB_0 0 ~ 1.1V 内部参考、低电压信号
ADC_ATTEN_DB_2_5 0 ~ 1.5V 小信号传感器
ADC_ATTEN_DB_6 0 ~ 2.2V 中等电压信号
ADC_ATTEN_DB_11 0 ~ 3.3V 通用测量(推荐)

我个人习惯直接使用 ADC_ATTEN_DB_11,覆盖 0~3.3V 全范围。除非你明确知道信号电压很低,否则就用这个,省心。

3.4 电压测量实战:读取并换算

理论讲完了,直接上代码。下面是一个完整的电压测量示例,读取 GPIO34(ADC1_CH6)上的电压。

#include <driver/adc.h>
#include <esp_adc_cal.h>

#define ADC_CHANNEL   ADC1_CHANNEL_6   // GPIO34
#define ADC_WIDTH     ADC_WIDTH_BIT_12
#define ADC_ATTEN     ADC_ATTEN_DB_11

static esp_adc_cal_characteristics_t adc_chars;

void adc_init(void) {
    // 1. 配置 ADC 宽度和衰减
    adc1_config_width(ADC_WIDTH);
    adc1_config_channel_atten(ADC_CHANNEL, ADC_ATTEN);

    // 2. 校准特性(获取 Vref)
    esp_adc_cal_characterize(
        ADC_UNIT_1, ADC_ATTEN, ADC_WIDTH,
        1100, &adc_chars  // 1100 = 默认 Vref (mV)
    );
}

uint32_t read_voltage_mv(void) {
    uint32_t adc_reading = 0;

    // 多次采样取平均,减少噪声
    for (int i = 0; i < 64; i++) {
        adc_reading += adc1_get_raw(ADC_CHANNEL);
    }
    adc_reading /= 64;

    // 将原始值转换为电压(mV)
    uint32_t voltage = esp_adc_cal_raw_to_voltage(adc_reading, &adc_chars);
    return voltage;
}

void app_main(void) {
    adc_init();

    while (1) {
        uint32_t mv = read_voltage_mv();
        printf("ADC Raw: %d, Voltage: %d mV (%.3f V)\n",
               adc_reading, mv, mv / 1000.0);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}
关键点:
  • 使用 esp_adc_cal_raw_to_voltage() 自动完成换算,不需要手动计算。
  • 64 次采样取平均,能有效抑制随机噪声。
  • 校准函数 esp_adc_cal_characterize() 会读取芯片内部的 eFuse Vref 值,提高精度。

3.5 光敏电阻实战:光照强度采集

光敏电阻(LDR)的阻值随光照强度变化——光照越强,阻值越低。我们需要把它和固定电阻组成分压电路,让 ESP32 读取变化的电压。

电路连接

3.3V ──┬── R_fixed (10kΩ) ──┬── GND
       │                    │
       └── LDR ─────────────┘
                │
              GPIO34 (ADC输入)

这个电路是 上拉分压 结构。LDR 和固定电阻串联,中间点接 ADC。光照变化 → LDR 阻值变化 → 分压点电压变化 → ADC 读数变化。

选值建议: 固定电阻选 10kΩ 比较通用。如果环境偏暗,可以换成 100kΩ 提高灵敏度。我在做智能灯控项目时,根据安装位置的光照范围,现场换过好几次电阻值。

代码实现

#include <driver/adc.h>
#include <esp_adc_cal.h>

#define LDR_ADC_CH     ADC1_CHANNEL_6   // GPIO34
#define SAMPLE_COUNT   32

static esp_adc_cal_characteristics_t adc_chars;

void ldr_init(void) {
    adc1_config_width(ADC_WIDTH_BIT_12);
    adc1_config_channel_atten(LDR_ADC_CH, ADC_ATTEN_DB_11);
    esp_adc_cal_characterize(
        ADC_UNIT_1, ADC_ATTEN_DB_11,
        ADC_WIDTH_BIT_12, 1100, &adc_chars);
}

// 返回光照等级:0(最暗)~ 100(最亮)
int ldr_read_light_level(void) {
    uint32_t sum = 0;
    for (int i = 0; i < SAMPLE_COUNT; i++) {
        sum += adc1_get_raw(LDR_ADC_CH);
    }
    uint32_t raw = sum / SAMPLE_COUNT;

    // 将 ADC 原始值映射到 0~100
    // 经验值:暗处约 500,亮处约 3500
    int level = (raw - 500) * 100 / (3500 - 500);
    if (level < 0)   level = 0;
    if (level > 100) level = 100;

    return level;
}

void app_main(void) {
    ldr_init();

    while (1) {
        int light = ldr_read_light_level();
        printf("Light Level: %d/100\n", light);

        if (light < 20) {
            printf("→ 环境很暗,开灯!\n");
        } else if (light > 80) {
            printf("→ 环境很亮,关灯!\n");
        } else {
            printf("→ 环境适中\n");
        }

        vTaskDelay(pdMS_TO_TICKS(2000));
    }
}

3.6 软件滤波:让数据更稳定

ADC 原始数据往往带有噪声。你想想看,电源纹波、电磁干扰、甚至手指靠近都会让读数跳动。不加滤波的话,你的设备会「神经质」一样频繁触发。

我常用的三种滤波方法:

方法 原理 优点 缺点
均值滤波 多次采样取平均 简单,抑制随机噪声 对突发干扰敏感
中值滤波 排序后取中间值 抗脉冲干扰强 计算量大,有延迟
一阶低通滤波 y[n] = α·x[n] + (1-α)·y[n-1] 平滑,响应可调 有相位滞后
避坑指南: 我曾经在一个光照控制项目中只用了一次采样就做判断,结果 LED 灯在黄昏时疯狂闪烁——因为每次 ADC 读数的差异导致开关频繁切换。后来加了 32 次均值滤波 + 20% 的迟滞区间,问题彻底解决。

3.7 迟滞比较:防止临界抖动

光敏电阻在临界光照下,ADC 读数会在阈值附近来回跳动。如果不做处理,你的设备会频繁开关,既耗电又影响寿命。

迟滞比较的思路很简单:开启和关闭使用不同的阈值。

#define THRESHOLD_ON   30   // 低于此值开灯
#define THRESHOLD_OFF  50   // 高于此值关灯

static bool light_on = false;

void control_light(int light_level) {
    if (!light_on && light_level < THRESHOLD_ON) {
        light_on = true;
        printf("→ 开灯\n");
    }
    else if (light_on && light_level > THRESHOLD_OFF) {
        light_on = false;
        printf("→ 关灯\n");
    }
    // 在 30~50 之间保持当前状态,不动作
}

这样就有了 20 个单位的「死区」。只要噪声不超过这个范围,输出就不会抖动。

3.8 本章小结

  • ADC 把模拟电压转换成数字量,ESP32 是 12 位分辨率。
  • ADC1 和 WiFi 兼容,ADC2 不兼容——记住选 ADC1。
  • 衰减设置 ADC_ATTEN_DB_11 覆盖 0~3.3V,日常够用。
  • 光敏电阻 + 固定电阻组成分压电路,ADC 读取电压变化。
  • 软件滤波(均值/中值/低通)和迟滞比较是实战必备技巧。

掌握了这些,你已经可以采集大多数模拟传感器了。温度、湿度、气压、声音——原理都一样,只是前端电路和换算公式不同。动手试试看,把光敏电阻换成电位器,你就能做一个简易的电压表了。