ESP32 ADC 实战:衰减档位、校准与锂电池电压监测
发布时间:2026/10/24 4:35:44 锦皓数字建站

写这篇笔记的时候我正拿一块带标准模组的开发板测锂电池电压。原本以为 ADC 就是照着手册配一个寄存器然后读一个数结果发现 ESP32 的 ADC 和以前在普通单片机上用的完全不是一回事衰减档位会影响量程、WiFi 会和部分 ADC 通道打架、不校准的话批量板子读数可能差出一大截。这篇笔记是“ESP32 学习笔记”系列的第八篇专门把 ADC 从硬件电路到软件实现完整过一遍顺便把我踩过的坑都写清楚。如果你正准备用 ESP32 做电池电压监测、传感器采集或者电位器输入这篇文章应该能帮你少走不少弯路。1. 整体设计与思路拆解1.1 为什么 ESP32 的 ADC 不能照抄普通单片机的写法ADC全称 Analog-to-Digital Converter作用就是把连续的模拟电压转成离散的数字量。ESP32 内部用的是逐次逼近型 ADC也就是 SAR ADC核心思路和大多数单片机一样通过比较器逐位逼近输入电压最终得到一个二进制码。ESP32 的 ADC 默认分辨率是 12 位理论上能分辨 4096 个等级比常见的 8 位和 10 位 ADC 精确不少。但问题在于ESP32 的 ADC 并不是一个“读起来很干净”的外设。它和其它单片机 ADC 最大的区别有三点一是它有多个衰减档位档位不同测量范围不同对应精度也不同二是它的模拟前端输入阻抗并不算高直接用一个高阻分压网络去接很容易导致读数漂移三是它内置了可选的校准机制如果不用校准即使同一个型号芯片不同板卡之间的电压读数差异也会很明显。这些特性叠加起来就注定了不能只把 ADC 当做一个“电压表”去用。从设计思路上讲我会把 ADC 的使用分成四步选定通道和档位、设计外部电路、完成软件初始化、做滤波和校准。每一步都有对应的坑后面会一一拆开讲。1.2 通道分布和 ADC2 的“限时使用”问题ESP32 内部有两个 ADC 单元分别叫 ADC1 和 ADC2。ADC1 有 8 个通道接入的引脚是 GPIO32 到 GPIO39ADC2 有 10 个通道分散在 GPIO0、GPIO2、GPIO4、GPIO12 到 GPIO15、GPIO25、GPIO26、GPIO27。这个分布在数据手册里有但真正容易忽略的是 ADC2 的使用限制。ADC2 和 WiFi 共用射频模块相关的模拟前端。也就是说一旦 WiFi 处于打开状态ADC2 的通道就会被硬件层强制让位读出来的数据要么不稳定要么直接读不到。这不是软件层面的调度问题而是硬件资源冲突。很多网友第一次在开发板上开着一个 web 服务器同时想读 ADC2 对应的引脚结果数据乱跳排查半天才发现是这个原因。所以我的建议很直接如果项目里要开 WiFi又必须读模拟量那就老老实实选 ADC1 的通道。ADC2 可以在不启用 WiFi 的低功耗原子上碰一碰但尽量不要在联网项目里用。ADC1 和 ADC2 的通道对应关系我整理成了一张表方便后面看。ADC 单元可用 GPIO备注ADC1GPIO32、GPIO33、GPIO34、GPIO35、GPIO36、GPIO37、GPIO38、GPIO39推荐优先使用不受 WiFi 影响ADC2GPIO0、GPIO2、GPIO4、GPIO12、GPIO13、GPIO14、GPIO15、GPIO25、GPIO26、GPIO27WiFi 开启时不可靠尽量避免1.3 衰减档位先选量程再谈精度衰减attenuation是 ESP32 的 ADC 一个特色。它本质上是对输入信号做一定的电压缩放让原本超出参考电压范围的信号能安全采样。衰减档位选择直接决定量程档位越高能测的电压范围越大但相对的小信号部分的灵敏度会变差。我看过很多别人的文章把四个衰减档位和量程写得很绝对实际上芯片手册给的是“典型值”和“推荐工作区”。我用实测和官方文档核对下来大致是这样衰减设置典型可测范围官方推荐工作区适合场景0dB0 ~ 950mV100mV ~ 950mV低压小信号比如某些温度传感器输出2.5dB0 ~ 1250mV100mV ~ 1250mV单片光敏传感器6dB0 ~ 1750mV150mV ~ 1750mV中等电压采集11dB0 ~ 3100mV150mV ~ 2500mV电池分压、开关电源电压监测如果你只是测一个 3.7V 锂电池经过两个等值电阻分压后ADC 引脚上的电压在 1.5V 到 2.1V 之间那选 11dB 就很合适。如果选的挡位过低信号超过量程读出来的数字会直接顶到 4095完全看不出真实电压如果反而选了过高的挡位比如用 11dB 去测只有 300mV 的信号精度也会受影响因为量程太大小信号的变化会被压缩。2. 硬件引脚与电路准备的实操细节2.1 引脚复用能直接读的通道和容易踩坑的引脚程序能读出数来先决条件是这个引脚没有被复用也没有被板子上的其它电路占住。ADC1 通道里GPIO34 到 GPIO39 这几个引脚是输入专用引脚不能当普通输出用。这在一些扩展板上不是问题但在自己画 PCB 时就要特别注意别把驱动 LED 这种需要输出方波的功能分配到这几个脚上。ADC2 通道里的 GPIO0、GPIO2、GPIO12、GPIO15 都参与芯片启动模式的判断。GPIO0 低电平进入下载模式GPIO2 和 GPIO12 在启动时如果被拉低或拉高可能导致模组无法正常启动。GPIO15 也会影响启动日志输出。所以哪怕你不开 WiFi也不建议把这些启动相关的引脚用在 ADC 上。剩下相对安全的是 GPIO4、GPIO25、GPIO26、GPIO27但其中一些引脚可能被板载模组或者音频电路占用具体要看手上的开发板原理图。链接到我的实际经历最开始我图省事把电位器接到 GPIO12 上程序烧录后运行正常但每次按复位键有时候能进系统有时候卡在引导阶段。查了好几个小时才发现就是 GPIO12 启动电平的事。后面只要涉及 ADC我都先去看一遍完整引脚说明绝不图省事。2.2 分压电路怎么算别让高阻网络坑了读数直接在 ADC 引脚上接一个 3.7V 电池是很危险的事。ESP32 的 ADC 引脚耐压有限超过 3.3V 后极大概率损坏芯片。所以电池场景基本都要用电阻分压。两个电阻串联输出点接 ADC 引脚这是最常见也最可靠的方案。分压公式非常简单Vout Vin × R2 / (R1R2)。比如锂电池满电 4.2V用两个 10kΩ 电阻平分R110kΩ、R210kΩVout 就是 2.1V已经落在 11dB 档位的安全范围内。但很多人忽略了一个问题ADC 引脚的内阻不是无穷大。ESP32 的 ADC 在采样时内部采样电容会从外部电路抽取电流外部电路的等效内阻越高采样误差越大。分压网络对外呈现的是 R1 和 R2 的并联值。两个 10kΩ 电阻并联等效输出阻抗是 5kΩ如果用两个 100kΩ输出阻抗变成 50kΩ这时候读数就可能因为采样瞬间的充放电不足而出现几十毫伏甚至几百毫伏的偏差。我的经验是电池分压场景用两个 10kΩ 或者 15kΩ 比较合适。静态功耗约 0.2mA 到 0.3mA对大部分嵌入式项目来说可以接受。如果做低功耗电池设备想换成 100kΩ 或 470kΩ就要在软件里适当延长采样等待时间或者多次采样取平均后面会讲。2.3 校准不是可选项同型号芯片也有个体差异一块芯片和另一块芯片内部的带隙基准电压会有细微差别所以同一个 ADC 输入电压在不同板子上读原始值可能不同。ESP32 出厂时会把校准数据写进 eFuse 存储区包括单点校准和两点校准。软件上使用官方 ADC 校准组件就可以自动调用这些出厂数据把原始值换算成更准确的电压值。如果不校准最明显的问题是批量产品里每块板子的电压读数都不一致。有些偏大有些偏小差值往大了说可能达到 50mV 甚至更多。对电池监测来说50mV 就意味着约 2% 左右的误差反映到电量百分比上很可能就是“半格电”的差别。所以用 ADC 的场合别把校准当成一个可有可无的步骤。3. 实操从零写一段可用的 ADC 采集代码3.1 环境准备用 ESP-IDF 官方驱动还是其它框架如果你用的是官方 ESP-IDF 环境ADC 的驱动已经拆分成了新的组件接口名以 adc_oneshot 开头。老版本的 API 比如 adc1_get_raw 在部分过期文档里还能看到新代码不建议再用。我这里全部按目前常见的新驱动来写。其它开源框架虽然 API 不同但配置思路完全一致初始化单元、设置通道衰减、创建校准句柄、开始读取。在命令行里先创建一个项目目录正常执行编译烧录流程即可。这里不展开环境安装直接进入代码逻辑。3.2 初始化与单次读取代码我选择 GPIO32 作为测试引脚对应的 ADC 单元是 ADC1通道号是 ADC_CHANNEL_4衰减用 11dB。先定义一个全局句柄然后初始化 ADC 单元和通道。#include esp_adc/adc_oneshot.h #include esp_adc/adc_cali.h #include esp_adc/adc_cali_scheme.h static adc_oneshot_unit_handle_t adc_handle; static adc_cali_handle_t cali_handle; void adc_init(void) { // 1. 创建 ADC1 单元 adc_oneshot_unit_init_cfg_t unit_cfg { .unit_id ADC_UNIT_1, }; ESP_ERROR_CHECK(adc_oneshot_new_unit(unit_cfg, adc_handle)); // 2. 配置 GPIO32也就是 ADC1 通道 4 adc_oneshot_chan_cfg_t chan_cfg { .atten ADC_ATTEN_DB_11, .bitwidth ADC_BITWIDTH_12, }; ESP_ERROR_CHECK(adc_oneshot_config_channel(adc_handle, ADC_CHANNEL_4, chan_cfg)); }这里有两个点需要解释。bitwidth 设为 12 位得到原始值范围是 0 到 4095。如果项目对分辨率要求不高设成 10 位也可以但读取电压时要统一换算不能和 12 位混在一起。atten 设成 11dB意味着我允许输入电压最高接近 3.1V适合分压后的电池电压。读取原始值比较简单一行就可以完成int raw; esp_err_t ret adc_oneshot_read(adc_handle, ADC_CHANNEL_4, raw); if (ret ESP_OK) { ESP_LOGI(ADC, raw%d, raw); }每次调用会启动一次采样适用于低频采集场景。连续高频采样要另说后面会提到。3.3 用校准接口把原始值换成毫伏如果你只想看原始值那前面那段就够了。但真正有用的项目一定需要电压值。ESP-IDF 提供了 adc_cali_raw_to_voltage 接口可以直接完成从原始值到毫伏的换算。使用前要先创建校准句柄。不同芯片支持的校准曲线方案不完全一样有的是曲线拟合有的是线性拟合建议做一个兼容初始化。bool enable_calibration(void) { // 优先使用 curve fitting 方案 adc_cali_curve_fitting_config_t curve_cfg { .unit_id ADC_UNIT_1, .chan ADC_CHANNEL_4, .atten ADC_ATTEN_DB_11, .bitwidth ADC_BITWIDTH_12, }; if (adc_cali_create_scheme_curve_fitting(curve_cfg, cali_handle) ESP_OK) { return true; } // 部分老型号使用 line fitting 方案 adc_cali_line_fitting_config_t line_cfg { .unit_id ADC_UNIT_1, .atten ADC_ATTEN_DB_11, .bitwidth ADC_BITWIDTH_12, }; if (adc_cali_create_scheme_line_fitting(line_cfg, cali_handle) ESP_OK) { return true; } return false; }校准句柄创建成功后读取流程就变成两步uint32_t voltage_mv 0; adc_oneshot_read(adc_handle, ADC_CHANNEL_4, raw); adc_cali_raw_to_voltage(cali_handle, raw, voltage_mv); ESP_LOGI(ADC, voltage%lu mV, (unsigned long)voltage_mv);这段代码看起来不多但它是前面所有理论落地的地方。从读原始值到换算成电压中间隔着一个校准机制的差别。量产时如果省略校准你看到的是每个芯片各说各话加上校准后电压读数才有一个统一尺度。3.4 给 ADC 数据加一个实用的滤波直接用 adc_oneshot_read 读出来的数值通常会跳。这不是 ESP32 的问题而是所有 ADC 都存在的噪声现象。我会做一个滑动平均滤波把最近 16 次采样累加平均。代码能直接抄#define FILTER_LEN 16 static uint32_t filter_buf[FILTER_LEN]; static uint8_t filter_pos 0; uint32_t adc_read_filtered(void) { uint32_t sum 0; int raw; filter_buf[filter_pos] 0; if (adc_oneshot_read(adc_handle, ADC_CHANNEL_4, raw) ESP_OK) { filter_buf[filter_pos] raw; } filter_pos (filter_pos 1) % FILTER_LEN; for (int i 0; i FILTER_LEN; i) { sum filter_buf[i]; } return sum / FILTER_LEN; }要注意一点滤波之后的数据是原始值的平均所以后面依然要拿去走换算接口不能直接把平均值当成“电压”。如果你有突发性尖峰干扰优先考虑中值滤波把一组数据排序后取中间那个值。它的抗尖峰能力比平均滤波强很多。我经常把“中值 滑动平均”组合起来用先取 5 次中值再做 8 次平均效果非常稳。3.5 完整案例做 3.7V 锂电池电压监测现在把前面所有步骤组合成一个锂电池电压监测的例子。外部电路很简单两个 10kΩ 电阻串联在电池正极和 GND 之间中间点接 GPIO32。电池电压范围按 3.0V 到 4.2V 计算分压点约在 1.5V 到 2.1V用 11dB 衰减完全没问题。主循环逻辑周期性读取滤波后的原始值用校准接口转成电压再把 ADC 引脚电压乘以 2还原出真实电池电压。最后给出电池电量百分比这里我简化成查表式映射避免直接用线性比例因为锂电池放电曲线在中段非常平缓。真实电压大致剩余电量4.2V100%3.8V60%3.6V30%3.3V5%≤ 3.0V0%建议关机主循环代码结构uint32_t adc_voltage_mv; uint32_t battery_mv; int raw (int)adc_read_filtered(); adc_cali_raw_to_voltage(cali_handle, raw, adc_voltage_mv); battery_mv adc_voltage_mv * 2; ESP_LOGI(BAT, battery%lu mV, (unsigned long)battery_mv);这段代码跑起来后拿万用表量电池两端再对比日志里的 battery 值通常误差能控制在 30mV 以内。如果误差明显偏大优先检查分压电阻精度然后是校准是否生效。4. 提高 ADC 采样质量的处理细节4.1 硬件布局线短一点、地干净一点软件能解决一部分噪声问题但硬件布局不好软件滤波也救不回来。我做过一个传感器采集板把电位器用 40cm 长的杜邦线引到 ADC 引脚上读数浮动特别厉害。杜邦线在一定频率下就是一根天线空间里的电磁干扰会直接叠加在采样值上尤其是周围有开关电源或者 WiFi 模块的时候。所以做 ADC 相关硬件首先要做的事情是让信号线尽量短最好在采样引脚旁边放一个 0.1uF 的电容到地。这个电容能滤掉一部分高频噪声但电容不宜过大否则会造成信号建立时间变长。有些项目会放 1uF 甚至 10uF那就需要配合很长的采样等待时间否则读数反而滞后。另外模拟地和数字地尽量单点连接避免 ADC 信号回路和 WiFi 射频回路重叠。对绝大多数开发板来说板上地已经处理好外部接线时不要再在同一个采样回路里串入电机或继电器等大电流负载。4.2 软件算法中值滤波、滑动平均还是限幅滤波软件滤波方案没有绝对的好坏关键看噪声类型。电位器或分压电路上的随机热噪声适合滑动平均偶然出现的大型尖峰干扰适合中值滤波如果信号本来变化就慢比如电池电压、环境温度还可以用简单的限幅滤波当前值和上一次值差超过一定阈值就丢弃本次采样。我常用的组合是每次读 5 个原始值排序取中位数再做 8 次中位数的滑动平均。这样的好处是代码简单不需要浮点运算也不会因为一两个坏点把结果拉飞。如果你要做得更平滑可以用加权平均例如当前值占 40%历史值占 60%但响应会变慢。还有一点很容易漏掉ADC 采样时 CPU 会被短暂占用。如果项目里同时跑蓝牙或 WiFi 任务建议把 ADC 读取放在独立任务里不要放在定时器中断里做耗时操作。中断里只能触发一个标志真正读取计算放到循环中完成否则会阻塞其它高优先级中断造成系统卡顿。4.3 连续采样和定时采样按需求来ADC 的读取节奏取决于最终需求。电池监测这种慢变信号1 秒采一次就够了电机电流检测可能需要 1kHz 以上采样率还要做波形采集就得靠连续转换加 DMA。ADC 驱动里最常用的是单次读取模式适合低速场景。如果要做连续采样就不能再用上面 adc_oneshot_read 一个数一个数地去读需要配置连续模式开启 DMA然后从 DMA 缓冲里取数据。不过连续模式需要额外配置 ring buffer 和 DMA 通道资源占用明显高。实际做项目时我先问自己一个问题这个信号的带宽到底是多少如果信号本身只有几 Hz那频率跑到 100Hz 已经非常富余完全没有必要上 DMA。我见过有人为了读一个电位器上了 4kHz 连续采样然后把大量 CPU 时间浪费在算滑动平均上这明显是需求没搞清楚。5. 常见问题与排查技巧实录5.1 电压读数一直跳怎么办这是新手遇到最多的问题。如果读数在一个固定的中心值附近小幅跳动大概率是输入阻抗太高或空间噪声干扰如果读数像随机数一样乱飞检查是否错用了 ADC2 通道且 WiFi 已打开还有一种容易忽略的情况是把 ADC 引脚复用成数字输出比如引脚在初始化时被拉低或拉高采样就会异常。我的排查顺序是先接一个干净的电池经过低阻分压网络确认读数稳定然后换回原电路看是否因为阻抗变高引起。如果还是跳再加软件滤波。切忌上来就改算法硬件问题没解决算法再花哨都白搭。现象常见原因解决方向读数轻微上下抖动输入阻抗高、参考电压噪声降低分压电阻阻值、加 0.1uF 电容、滑动平均滤波读数陡然出现尖峰附近有开关电源或继电器中值滤波、屏蔽线、加大采样间隔读数整体偏移大未校准或衰减档位设置不对启用校准接口核对衰减档位读数归零或 4095引脚悬空、信号超量程、通道冲突检查电路连接、降低输入电压、换 ADC1 通道5.2 读出来的电压和万用表差很多多半是校准没启用或者校准句柄初始化失败后你没有发现。前面提到过批量芯片之间的差异这种差异不经过校准根本无法消除。还有一种情况是衰减档位设错了某个引脚其实是 11dB代码里却按 0dB 的档位做了内部换算读出来的电压自然不对。不同芯片的 ADC 校准库可能选择不同但原理都是依据 eFuse 里的出厂数据做补正如果你的芯片压根没有校准数据这里会直接创建失败这种情况只能换一颗有校准数据的芯片。5.3 同一个引脚时而能读时而不能读先看启动阶段的电气约束。GPIO0、GPIO2、GPIO12、GPIO15 等引脚在复位、下载程序的过程中都有指定电平要求如果外部电路在这些引脚上强拉电平轻则启动不稳定重则无法烧录。另外引脚很可能和其它外设共用比如某些开发板上的 LED、按钮、Flash 芯片已经占用了相关引脚你在软件里配置 ADC 不会报错但读取到的值反映的是其它电路的状态。建议拿到一块新板子先找出完整的引脚复用表再决定把哪些 ADC 通道留给模拟信号。5.4 WiFi 一开ADC 数据就开始乱看到这个现象第一时间去查是不是用了 ADC2。ADC2 和 WiFi 共享射频前端WiFi 初始化后ADC2 的读取经常得不到有效结果。在基于 ESP-IDF 的环境里运行 WiFi 后仍然去读 ADC2驱动往往返回错误或脏数据。这个限制不是 bug而是硬件设计如此。解决方案非常明确把模拟量全部放到 ADC1 通道上属于 GPIO32、GPIO33、GPIO34、GPIO35、GPIO36、GPIO37、GPIO38、GPIO39 这几个脚。如果引脚确实不够用可以考虑用外部模拟开关或 I2C 型 ADC 芯片做扩展而不是硬碰 ADC2。5.5 校准接口创建失败如果你发现在编译时找不到 adc_cali_create_scheme_curve_fitting先检查 SDK 版本是否够新如果运行时报错考虑芯片是否支持该方案。我遇到过一个旧模组芯片没有两点校准参数曲线拟合接口直接失败最后回退到 line fitting 才成功。所以前面代码里我特意写了先曲线后直线的回退逻辑这个顺序尽量保留。校准句柄也要在项目初始化早期创建并且一旦创建不要反复销毁重建会影响稳定。最后一个经验也是我自己走了好几次弯路才确定的组合ADC1 11dB 衰减 出厂校准 32 次中值平均滤波。这套方案做电池采集和传感器采样都很稳不管是原型板还是批量盒子读数一致性都让我满意。ADC 这个外设表面上只是“把电压变成数字”真正用好它要管住衰减、阻抗、校准、噪声四件事。希望这篇笔记能让你少折腾几天。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。