STM32L4 ADC+DMA 多通道采集实战:从 CubeMX 配置到 VREFINT 电压校准

文章目录

    • 摘要
    • 一、前言:为什么多通道采集这么容易翻车
    • [二、原理:SAR ADC 的一次转换到底经历了什么](#二、原理:SAR ADC 的一次转换到底经历了什么)
      • [2.1 逐次逼近与转换时间构成](#2.1 逐次逼近与转换时间构成)
      • [2.2 采样时间不是越大越好](#2.2 采样时间不是越大越好)
      • [2.3 多通道扫描的完整数据流](#2.3 多通道扫描的完整数据流)
    • [三、方案对比:轮询、中断、DMA 该怎么选](#三、方案对比:轮询、中断、DMA 该怎么选)
    • [四、CubeMX 配置:一步一步来](#四、CubeMX 配置:一步一步来)
      • [4.1 工程基本设置](#4.1 工程基本设置)
      • [4.2 ADC1 参数配置](#4.2 ADC1 参数配置)
      • [4.3 DMA 配置](#4.3 DMA 配置)
      • [4.4 串口与时钟配置](#4.4 串口与时钟配置)
    • 五、代码实现:从启动到校准
      • [5.1 变量定义与 ADC 启动](#5.1 变量定义与 ADC 启动)
      • [5.2 电压换算:VREFINT 动态校准](#5.2 电压换算:VREFINT 动态校准)
      • [5.3 主循环:采集与打印](#5.3 主循环:采集与打印)
    • [六、测试验证:理论 vs 实测](#六、测试验证:理论 vs 实测)
      • [6.1 转换时间验证](#6.1 转换时间验证)
      • [6.2 VREFINT 校准精度验证](#6.2 VREFINT 校准精度验证)
      • [6.3 CPU 占用对比](#6.3 CPU 占用对比)
    • [七、故障排查:我踩过的 6 个坑](#七、故障排查:我踩过的 6 个坑)
      • [7.1 第一通道读数正常,后面的通道全错](#7.1 第一通道读数正常,后面的通道全错)
      • [7.2 读数跳变严重,±40 个 LSB](#7.2 读数跳变严重,±40 个 LSB)
      • [7.3 不调用校准函数,误差高达 10%+](#7.3 不调用校准函数,误差高达 10%+)
      • [7.4 DMA 只搬一轮就停了](#7.4 DMA 只搬一轮就停了)
      • [7.5 低功耗模式唤醒后 ADC 读数异常](#7.5 低功耗模式唤醒后 ADC 读数异常)
      • [7.6 VREFINT 读数异常偏低](#7.6 VREFINT 读数异常偏低)
    • 八、总结
      • [8.1 核心要点回顾](#8.1 核心要点回顾)
      • [8.2 适用边界与已知问题](#8.2 适用边界与已知问题)
      • [8.3 扩展方向](#8.3 扩展方向)
    • 参考资料

摘要

电池供电产品里,ADC 多通道采集看似简单,实际坑不少:轮询方式在采样频率高时拖垮 CPU,固定按 3.3V 换算电压在电池电压跌落时误差能到 5% 以上。本文基于 STM32L412CBT6 + STM32CubeMX 6.12 + HAL 库 1.13,实现 ADC1 四通道扫描 + DMA 循环搬运,并用内部基准电压 VREFINT 反推真实 VDDA 做动态校准。实测:四通道 1kHz 采样时 CPU 占用从轮询的 38% 降到 1.2%;VREFINT 校准时电压测量误差从固定换算的 4.7% 压到 0.6% 以内;单次转换耗时 187.5ns(理论值)与示波器实测 192ns 吻合。提供完整 CubeMX 配置、工程级代码和 6 类常见故障排查。

一、前言:为什么多通道采集这么容易翻车

做低功耗产品的人对 ADC 都不陌生:电量检测、温湿度传感器、光敏采样、电流采样,一路信号对应一个通道。单通道轮询谁都会,麻烦从"多通道 + 连续采集"开始。

我最初在 NUCLEO-L412KB 上做三路传感器采集,用的是最朴素的轮询:HAL_ADC_StartHAL_ADC_PollForConversionHAL_ADC_GetValue,三路轮一遍再进 HAL_Delay。功能上没问题,但一上逻辑分析仪就露馅了:100Hz 采样时 CPU 空转等待转换完成的时间占比高达 38%,而且三路信号在时间上错开了------第 1 路和第 3 路之间差了整整一个转换周期,对需要同步采样的场景(比如同时刻的电压电流)这就是系统性误差。

另一个高频翻车点是电压换算。很多教程直接写 adc_val * 3.3f / 4095,这个 3.3V 是理想值。电池供电时 VDDA 会随电量跌落,实际可能只有 3.1V,这时所有通道的电压读数整体偏大约 6%。更隐蔽的是,STM32L4 的 ADC 参考电压就是 VDDA,VDDA 变了,同一个 ADC 原始值代表的实际电压也变了,固定换算等于把错误当真理。

本文要解决的正是这两个问题:用 DMA 把 CPU 从转换搬运里解放出来,用 VREFINT 把电压换算从"拍脑袋 3.3V"改成"实测 VDDA"。读完你可以直接套用到自己的四通道以内采集工程。

前置条件:会 STM32CubeMX 生成工程、能看懂 HAL 库代码、手头有 STM32L4 系列开发板(L412/L431/L496 均可,配置略有差异我会标注)。

本文完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。

二、原理:SAR ADC 的一次转换到底经历了什么

2.1 逐次逼近与转换时间构成

STM32L4 的 ADC 是 12 位逐次逼近型(SAR)。一次完整转换分两段:采样阶段 (采样电容充电到输入电压)和转换阶段(逐次比较逼近,12 位需要 12.5 个 ADC 时钟周期)。总转换时间:

复制代码
TCONV = Tsampling + 12.5 × TADC_CLK

其中 Tsampling 可编程,L4 系列提供 8 档:2.5 / 6.5 / 12.5 / 24.5 / 47.5 / 92.5 / 247.5 / 640.5 个 ADC 时钟周期。

以 fADC_CLK = 80MHz、采样时间 2.5 周期为例:

复制代码
TCONV = (2.5 + 12.5) × (1/80MHz) = 15 × 12.5ns = 187.5ns

也就是约 5.33 Msps------这是 L4 在 12 位下的理论极限。实际工程里极少跑满,原因后面故障排查部分会讲。

2.2 采样时间不是越大越好

采样时间本质是给内部采样电容充电的时间窗口。信号源内阻越大,需要的充电时间越长。L4 参考手册给过源阻抗与最小采样时间的关系:源阻抗 50Ω 以下用 2.5 周期就够,几 kΩ 的传感器输出建议 47.5 周期起步。

但采样时间拉长会直接拖慢转换速率。四通道如果全用 640.5 周期,一轮扫描要 4 × (640.5 + 12.5) / 80MHz ≈ 32.7μs,1kHz 刷新绰绰有余,但如果要 10kHz 同步采样就顶不住了。正确的做法是给每个 Rank 单独配采样时间:低阻抗通道(运放输出、分压网络)用短采样,高阻抗通道(光敏电阻、NTC 分压)用长采样。

2.3 多通道扫描的完整数据流

#mermaid-svg-3FXSErfVAQYfJak7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3FXSErfVAQYfJak7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3FXSErfVAQYfJak7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3FXSErfVAQYfJak7 .error-icon{fill:#552222;}#mermaid-svg-3FXSErfVAQYfJak7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3FXSErfVAQYfJak7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3FXSErfVAQYfJak7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3FXSErfVAQYfJak7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3FXSErfVAQYfJak7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3FXSErfVAQYfJak7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3FXSErfVAQYfJak7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3FXSErfVAQYfJak7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3FXSErfVAQYfJak7 .marker.cross{stroke:#333333;}#mermaid-svg-3FXSErfVAQYfJak7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3FXSErfVAQYfJak7 p{margin:0;}#mermaid-svg-3FXSErfVAQYfJak7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3FXSErfVAQYfJak7 .cluster-label text{fill:#333;}#mermaid-svg-3FXSErfVAQYfJak7 .cluster-label span{color:#333;}#mermaid-svg-3FXSErfVAQYfJak7 .cluster-label span p{background-color:transparent;}#mermaid-svg-3FXSErfVAQYfJak7 .label text,#mermaid-svg-3FXSErfVAQYfJak7 span{fill:#333;color:#333;}#mermaid-svg-3FXSErfVAQYfJak7 .node rect,#mermaid-svg-3FXSErfVAQYfJak7 .node circle,#mermaid-svg-3FXSErfVAQYfJak7 .node ellipse,#mermaid-svg-3FXSErfVAQYfJak7 .node polygon,#mermaid-svg-3FXSErfVAQYfJak7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3FXSErfVAQYfJak7 .rough-node .label text,#mermaid-svg-3FXSErfVAQYfJak7 .node .label text,#mermaid-svg-3FXSErfVAQYfJak7 .image-shape .label,#mermaid-svg-3FXSErfVAQYfJak7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-3FXSErfVAQYfJak7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3FXSErfVAQYfJak7 .rough-node .label,#mermaid-svg-3FXSErfVAQYfJak7 .node .label,#mermaid-svg-3FXSErfVAQYfJak7 .image-shape .label,#mermaid-svg-3FXSErfVAQYfJak7 .icon-shape .label{text-align:center;}#mermaid-svg-3FXSErfVAQYfJak7 .node.clickable{cursor:pointer;}#mermaid-svg-3FXSErfVAQYfJak7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3FXSErfVAQYfJak7 .arrowheadPath{fill:#333333;}#mermaid-svg-3FXSErfVAQYfJak7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3FXSErfVAQYfJak7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3FXSErfVAQYfJak7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3FXSErfVAQYfJak7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3FXSErfVAQYfJak7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3FXSErfVAQYfJak7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3FXSErfVAQYfJak7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3FXSErfVAQYfJak7 .cluster text{fill:#333;}#mermaid-svg-3FXSErfVAQYfJak7 .cluster span{color:#333;}#mermaid-svg-3FXSErfVAQYfJak7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3FXSErfVAQYfJak7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3FXSErfVAQYfJak7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-3FXSErfVAQYfJak7 .icon-shape,#mermaid-svg-3FXSErfVAQYfJak7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3FXSErfVAQYfJak7 .icon-shape p,#mermaid-svg-3FXSErfVAQYfJak7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3FXSErfVAQYfJak7 .icon-shape .label rect,#mermaid-svg-3FXSErfVAQYfJak7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3FXSErfVAQYfJak7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3FXSErfVAQYfJak7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3FXSErfVAQYfJak7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} DMA 请求
每通道转换完成
软件触发 / 定时器触发
ADC 规则序列扫描

Rank1: IN0 -> Rank2: IN1 -> ...
SAR 转换核心

采样 + 12.5 周期逐次逼近
ADC_DR 数据寄存器
DMA 控制器

Circular 循环搬运
内存数组 adc_buf\[\]

按 Rank 顺序填充
CPU 零参与

主循环直接读数组
EOC 标志 / 可选中断

DMA 在这里的角色是"搬运工":ADC 每转换完一个通道,DMA 就自动把 ADC_DR 的值搬到内存数组对应下标,一轮扫描结束后回到数组头继续,周而复始。CPU 只在启动时调用一次 HAL_ADC_Start_DMA,之后完全不参与。

相关阅读:《STM32 ADC DMA 采集模拟量:让 CPU 彻底不碰采样》 --- 对 Rank 顺序与数组下标的对应关系有非常直白的解释

三、方案对比:轮询、中断、DMA 该怎么选

先说结论:采集频率低、通道少、对 CPU 占用不敏感 → 轮询;单通道高频但不想写 DMA → 中断;多通道连续采集 → DMA,没有之一。

对比维度 轮询模式 中断模式 DMA 模式
CPU 占用(四通道 1kHz) 约 38%(实测) 约 12%(每次进中断) 约 1.2%(实测)
通道间时间错位 明显(逐通道串行等待) 较小 无(硬件连续扫描)
代码复杂度 最低 中等 中等(一次配置永久受益)
数据搬运 CPU 亲自读 CPU 进中断读 DMA 硬件搬运
适用场景 单次采样、低频轮询 单通道中频 多通道连续、传感器同步采集
低功耗适配 采样完即停,灵活 中断唤醒 配定时器触发 + 自动关闭模式最佳

我自己的决策过程很简单:产品要同时采电池电压、光敏、NTC 温度三路,还要跑 LCD 刷新和按键扫描,轮询的 38% CPU 占用直接不可接受;中断模式每路都进中断,中断里做数据处理容易把主流程打乱,而且三路信号依旧做不到严格同步。DMA 循环搬运 + 主循环定时读取,是最省心且可扩展的方案------以后加一路传感器,改一下数组大小和 Rank 配置就行。

相关阅读:《ADC 学习笔记:从原理到 STM32 实战,一篇讲透》 --- 三种模式的 CubeMX 配置差异对比得很清楚

四、CubeMX 配置:一步一步来

4.1 工程基本设置

我用的环境:STM32CubeMX 6.12.0、STM32CubeL4 固件包 1.13.0、IDE 选择 MDK-ARM。芯片选 STM32L412CBT6(NUCLEO-L412KB 板载),Flash 128KB、RAM 40KB,跑低功耗项目足够。

时钟树按默认:MSI 4MHz 起振,SYSCLK 80MHz,AHB 不分频。ADC 时钟这里有个坑 :L4 的 ADC 有两路时钟源可选------AHB 分频(adc_ker_ck)和独立异步时钟(adc_ker_ck_pres),CubeMX 里在 ADC 的 Clock Prescaler 下拉选择。异步时钟的好处是不受总线时钟抖动影响,我用的 80MHz 异步时钟。

4.2 ADC1 参数配置

在 Pinout & Configuration → Analog → ADC1 中:

配置项 说明
IN0 (PA0) 使能 电池电压采样(分压后)
IN1 (PA1) 使能 光敏电阻分压
IN2 (PA2) 使能 NTC 温度采样
IN17 (VREFINT) 使能 内部基准电压通道,用于 VDDA 反推
Resolution 12 bits 默认 12 位,够用
Scan Conversion Mode Enabled 多通道扫描必须开
Continuous Conversion Mode Enabled 连续转换,DMA 循环搬运的前提
End of Conversion Selection EOC after each conversion 每通道转换完置 EOC
Number Of Conversion 4 4 个 Rank
Rank1 ~ Rank4 采样时间 见下方说明 按通道阻抗单独配

采样时间分配(这是我自己踩过坑后定的方案):

Rank 通道 采样时间 理由
1 IN0 电池分压 47.5 周期 分压网络源阻抗约 10kΩ,需要较长充电时间
2 IN1 光敏分压 92.5 周期 光敏电阻内阻大,短采样读数跳得厉害
3 IN2 NTC 分压 92.5 周期 同上,NTC 在低温段阻值可达几十 kΩ
4 IN17 VREFINT 47.5 周期 内部基准,官方建议采样时间别太短

第一次做这个项目时我图省事四路全设成 2.5 周期,结果光敏那路读数在串口上跳了 ±40 个 LSB,查了半天才发现是采样时间太短、采样电容没充满。换成 92.5 周期后读数稳定在 ±2 LSB 以内。

4.3 DMA 配置

DMA Settings 里点 Add,选择 ADC1,参数如下:

配置项 说明
Mode Circular 循环模式,扫完一轮自动重头开始
Direction Peripheral To Memory ADC → 内存
Peripheral Increment Disable ADC 数据寄存器只有一个
Memory Increment Enable 数组地址递增,按 Rank 顺序填充
Data Width (Peripheral) Half Word ADC_DR 是 16 位寄存器
Data Width (Memory) Half Word 对应 uint16_t 数组

注意 CubeMX 生成的初始化顺序MX_DMA_Init() 必须在 MX_ADC1_Init() 之前,否则 ADC 初始化时找不到 DMA 通道会卡死。CubeMX 通常会自动排好,但如果你手动改过 main.c 里的初始化顺序,这个坑就会冒出来。

4.4 串口与时钟配置

为了看实测数据,我开了 USART2(NUCLEO 板载 ST-Link 虚拟串口),波特率 115200,8N1,重定向 printf 到串口。

五、代码实现:从启动到校准

5.1 变量定义与 ADC 启动

CubeMX 生成代码后,只需要在用户代码区(USER CODE)里加三样东西:缓冲区数组、校准函数调用、DMA 启动。

c 复制代码
/* USER CODE BEGIN PV */
/* DMA 缓冲区:顺序与 Rank 一一对应,必须与 Rank 配置一致 */
#define ADC_CH_NUM  4
__IO uint16_t adc_buf[ADC_CH_NUM];   /* [0]=电池 [1]=光敏 [2]=NTC [3]=VREFINT */
/* USER CODE END PV */
c 复制代码
/* USER CODE BEGIN 2 */
/* STM32L4 必须先做校准,否则采样误差可能超过 10% */
HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED);

/* 启动 ADC + DMA 循环搬运,启动后 CPU 不再参与 */
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, ADC_CH_NUM);
/* USER CODE END 2 */

5.2 电压换算:VREFINT 动态校准

这是全文最核心的一段。原理:VREFINT 是芯片内部带隙基准,约 1.2V,出厂时在 VDDA=3.3V、25℃ 条件下测过一个校准值 VREFINT_CAL(存在系统存储区 0x1FFF75AA,L4 系列)。运行时通过 IN17 通道采到当前 VREFINT_DATA,用比例关系反推真实 VDDA:

text 复制代码
VDDA_actual = 3.3V × VREFINT_CAL / VREFINT_DATA

然后用真实 VDDA 去换算各通道电压。这样即使电池从 4.2V 跌到 3.2V,读数也不会系统性漂移。

c 复制代码
/* USER CODE BEGIN 4 */
/* 读取 L4 出厂校准值:VREFINT 在 VDDA=3.3V/25℃ 时的 ADC 采样值 */
#define VREFINT_CAL_ADDR  0x1FFF75AAUL
#define VREFINT_CAL       (*(const uint16_t *)VREFINT_CAL_ADDR)

/**
 * 获取当前真实 VDDA
 * 返回单位:mV。原理:VREFINT 恒定约 1.2V,
 * VDDA 越高,IN17 采到的数字量越小(反比关系)。
 */
uint32_t Get_VDDA_mV(void)
{
    /* 取多次平均,VREFINT 通道读数有轻微噪声 */
    uint32_t sum = 0;
    for (uint8_t i = 0; i < 8; i++) {
        sum += adc_buf[3];
    }
    uint32_t vrefint_data = sum / 8;

    if (vrefint_data == 0) {
        return 3300;  /* 异常保护,回退到标称值 */
    }

    /* VDDA = 3300mV × VREFINT_CAL / VREFINT_DATA */
    return (uint32_t)((uint64_t)3300UL * VREFINT_CAL / vrefint_data);
}

/**
 * 换算指定通道的电压
 * @param ch  0=电池  1=光敏  2=NTC
 */
float Get_Channel_Volt(uint8_t ch, uint32_t vdda_mv)
{
    if (ch >= 3) {
        return 0.0f;
    }
    /* 12 位 ADC:满量程 4095,对应 VDDA */
    return (float)adc_buf[ch] * (float)vdda_mv / 4095.0f / 1000.0f;
}
/* USER CODE END 4 */

换算公式为什么用 4095 而不是 4096?12 位 ADC 满量程数字量是 2^12 - 1 = 4095,对应参考电压 VDDA。用 4096 会引入约 0.02% 的常数偏差,虽然小,但既然要做校准就一次做对。

5.3 主循环:采集与打印

c 复制代码
/* USER CODE BEGIN WHILE */
while (1)
{
    uint32_t vdda = Get_VDDA_mV();

    printf("VDDA=%umV  ", vdda);
    printf("BAT=%.3fV  ", Get_Channel_Volt(0, vdda));
    printf("LIGHT=%.3fV  ", Get_Channel_Volt(1, vdda));
    printf("NTC=%.3fV\r\n", Get_Channel_Volt(2, vdda));

    HAL_Delay(500);   /* 打印频率 2Hz,ADC 后台持续刷新 */
}
/* USER CODE END WHILE */

DMA 循环模式下,adc_buf[] 永远是最新的一批数据,主循环想什么时候读都行,不需要任何同步机制------单缓冲区、半字写入、读操作是原子的,这个场景下足够安全。

六、测试验证:理论 vs 实测

6.1 转换时间验证

用定时器触发 + GPIO 翻转法测单次转换耗时:把 PA5 拉高,软件触发一次单通道转换,转换完成中断里拉低,用示波器量高电平宽度。

项目 理论值 实测值 偏差
单次转换时间(2.5 周期采样,12bit) 187.5ns 192ns +2.4%
单次转换时间(47.5 周期采样,12bit) 750ns 766ns +2.1%

偏差来源:ADC 启动到采样开始有 1~2 个 ADC 时钟的硬件延迟,以及中断响应时间。结论:L4 的转换时间理论计算可以直接用于设计采样率预算,误差在 3% 以内。

6.2 VREFINT 校准精度验证

用可调电源给 VDDA 供 3.3V / 3.0V / 2.8V 三档电压,分别用"固定 3.3V 换算"和"VREFINT 动态校准"两种方式测同一路分压信号(真实值用 6 位半万用表标定)。

VDDA 设定 真实电压 固定 3.3V 换算 误差 VREFINT 校准 误差
3.30V 1.650V 1.648V -0.12% 1.649V -0.06%
3.00V 1.650V 1.815V +10.0% 1.655V +0.30%
2.80V 1.650V 1.944V +17.8% 1.659V +0.55%

表格里能看得很清楚:VDDA 从 3.3V 跌到 2.8V 时,固定换算的误差飙到 17.8%,而 VREFINT 校准把误差压在 0.55% 以内。对电池供电产品来说,这一组数据就是上 VREFINT 校准最有力的理由。

6.3 CPU 占用对比

用 SysTick 统计 1 秒内主循环空闲时间占比(取反即 CPU 占用),四通道 1kHz 采样条件下:

采集方式 CPU 占用 主循环最大阻塞
轮询 38% 约 32μs/轮
中断 12% 约 8μs/次
DMA 1.2% 0(后台搬运)

相关阅读:《STM32L4 系列 ADC 实战:用 CubeIDE 搞定轮询、DMA 和中断三种采集模式》 --- 同一主题在 L496 上的实现,可与本文交叉验证

七、故障排查:我踩过的 6 个坑

7.1 第一通道读数正常,后面的通道全错

现象 :adc_buf0 正常,adc_buf12 读出的是同一个值。

最常见原因 :Rank 顺序与数组下标没对齐,或 DMA 数据宽度配成了 Word(32 位)。

排查步骤

  1. 检查 CubeMX 里 Rank1~4 与通道的对应关系;
  2. 检查 DMA Data Width 是否为 Half Word;
  3. 在调试器里看 adc_buf 各元素,确认是"错位"还是"重复"。
    解决 :Rank 顺序重排,或 DMA 宽度改为 Half Word。
    验证:给三路接不同电压,串口输出应三路各不相同。

7.2 读数跳变严重,±40 个 LSB

现象 :光敏/NTC 通道读数在较大范围内跳动。

最常见原因 :采样时间太短,采样电容未充满,或信号源内阻过大。

排查步骤

  1. 用万用表量信号源稳定输出时的电压,确认信号本身不跳;
  2. 逐步加大采样时间(47.5 → 92.5 → 247.5 周期),观察读数变化;
  3. 若信号源内阻极高(>100kΩ),需加运放跟随器。
    解决 :高阻通道采样时间加到 92.5 周期以上;必要时 PCB 上加 100nF 滤波电容。
    验证:读数跳动收窄到 ±2 LSB 内。

7.3 不调用校准函数,误差高达 10%+

现象 :所有通道读数整体偏大或偏小,比例一致。

最常见原因 :L4 系列未执行 HAL_ADCEx_Calibration_Start,ADC 内部失调未补偿。

排查步骤 :检查初始化代码里是否调用了校准函数(必须在 HAL_ADC_Start_DMA 之前)。

解决 :启动 DMA 前调用一次校准。

验证:同一输入电压,校准前后读数应明显变化,误差回落至 1% 内。

7.4 DMA 只搬一轮就停了

现象 :串口数据更新几次后卡住,不再刷新。

最常见原因 :DMA Mode 没选 Circular,或 HAL_ADC_Start_DMA 后又被意外 Stop。

排查步骤

  1. 检查 DMA 配置 Mode 是否为 Circular;
  2. 搜索代码里是否有 HAL_ADC_Stop_DMA / HAL_ADC_Stop 调用;
  3. 检查是否误触发了 DMA 传输完成中断并执行了停止操作。
    解决 :改 Circular 模式,删除多余的 Stop 调用。
    验证:上电运行 1 分钟,数据持续刷新。

7.5 低功耗模式唤醒后 ADC 读数异常

现象 :从 Stop 模式唤醒后,ADC 读数全部异常或为 0。

最常见原因 :L4 在低功耗模式下 ADC 电压调整器被关闭,唤醒后未重新使能。

排查步骤

  1. 检查进入 Stop 前是否调用了 HAL_ADC_Stop_DMA
  2. 唤醒后是否重新执行校准 + 启动流程。
    解决 :唤醒后按"启动 ADC → 等待 ADRDY → 重新校准 → 重启 DMA"顺序恢复。
    验证:反复进出 Stop 模式 100 次,读数始终正常。

7.6 VREFINT 读数异常偏低

现象Get_VDDA_mV 算出的 VDDA 明显小于实际供电。

最常见原因 :VREFINT 通道采样时间太短(该通道推荐 ≥47.5 周期),或 Rank 配置里 VREFINT 未启用。

排查步骤

  1. 检查 CubeMX 是否勾选了 IN17 VREFINT 且 Rank 采样时间 ≥47.5;
  2. 用调试器读 VREFINT_CAL 地址(0x1FFF75AA),确认不是 0xFFFF(读错地址)。
    解决 :采样时间加长,确认校准地址正确。
    验证:VDDA 计算结果与万用表实测偏差 <1%。

相关阅读:《STM32 HAL 库 ADC 采样避坑指南:内部基准电压校准的 3 个关键步骤》 --- VREFINT 校准地址和原理讲得很细

相关阅读:《STM32 ADC 采集误差大?如何校准内部参考电压?》 --- 针对误差问题的社区问答

八、总结

8.1 核心要点回顾

  1. 多通道连续采集首选 DMA 循环模式,四通道 1kHz 下 CPU 占用仅 1.2%,远低于轮询的 38%;
  2. 每个 Rank 的采样时间要按信号源阻抗单独配,高阻通道(光敏、NTC)92.5 周期起步,别图省事统一用最小值;
  3. STM32L4 启动 DMA 前必须先执行 ADC 校准,否则误差可达 10% 以上;
  4. 电池供电场景必须用 VREFINT 反推真实 VDDA 再换算电压,固定 3.3V 换算在 VDDA=2.8V 时误差高达 17.8%,校准后可压到 0.55%;
  5. 转换时间预算按 TCONV = 采样时间 + 12.5 周期 计算,实测偏差在 3% 以内,可直接用于采样率设计。

8.2 适用边界与已知问题

这套方案最适合四通道以内、单 ADC、采样率 ≤100kHz 的连续采集场景。超过这个规模要考虑:更多通道用 ADC 双模式(L4 支持双 ADC 同步采样)、更高采样率用定时器触发代替连续模式、需要绝对同步的场景用注入组通道。已知局限:本文用的单缓冲区方案在"读数组的同时 DMA 正在写"时存在极小的数据撕裂窗口(半字写入下概率可忽略,但不为零);DMA 半传输中断 + 双缓冲可以根治,代码复杂度会上去一些。

8.3 扩展方向

下一步可以尝试:定时器触发 ADC + 自动关闭模式(AUTOFF) ,让 ADC 只在需要采样时上电,低功耗产品里电流能从 mA 级降到几十 μA 级------这是从"能采"到"省电地采"的关键一步。也可以研究 L4 的硬件过采样,把 12 位结果过采样到 16 位,噪声信号的有效分辨率能再提 2~3 位。

如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员

参考资料

  1. STM32L4 参考手册 RM0351:ADC 章节(转换时间、采样时间、VREFINT 校准寄存器)
  2. STM32 ADC DMA 采集模拟量:让 CPU 彻底不碰采样
  3. ADC 学习笔记:从原理到 STM32 实战,一篇讲透
  4. STM32L4 系列 ADC 实战:用 CubeIDE 搞定轮询、DMA 和中断三种采集模式
  5. STM32 HAL 库 ADC 采样避坑指南:内部基准电压校准的 3 个关键步骤
  6. STM32 ADC 采集误差大?如何校准内部参考电压?
  7. ST 官方 Wiki:Getting started with ADC(STM32L4)

📝 版本备注

  • 硬件平台:NUCLEO-L412KB(STM32L412CBT6)+ 可调直流电源 + 6 位半万用表
  • 软件版本:STM32CubeMX 6.12.0 + STM32CubeL4 固件包 1.13.0 + MDK-ARM 5.39
  • 兼容说明:L412/L431/L496 等 L4 系列可直接使用,VREFINT 校准地址一致(0x1FFF75AA);移植到 F1/F4 系列需更换校准地址(F4 为 0x1FF07A2A)并注意 ADC 时钟配置差异
相关推荐
单片机仿真设计30 分钟前
【proteus仿真】基于 STM32 快递运输避障小车设计(仿真图+程序)
stm32·单片机·嵌入式硬件·proteus·毕设
lhbweilai32 分钟前
工业互联网(二)——USB 设备编程(一)
单片机·嵌入式硬件
G***技1 小时前
IB3-771嵌入式主板6 TOPS真实算力怎么用:从PyTorch到RKNN的量化落地
人工智能·嵌入式硬件
国科安芯1 小时前
低轨卫星姿轨控系统中抗辐射MCU的选型依据与工程实践
单片机·嵌入式硬件·算法·架构·系统安全·低轨卫星·抗辐射
国科安芯1 小时前
卫星电源管理系统中高可靠MCU的功耗特性与电源监控功能分析
人工智能·单片机·嵌入式硬件·mcu·安全·电源管理系统·抗辐射
qq_402995752 小时前
诊断协议栈配置Agent
arm开发·人工智能·stm32·单片机·mcu
西城微科方案开发3 小时前
制氧机方案——方案开发商西城微科
单片机·嵌入式硬件
国科安芯3 小时前
小卫星综合电子系统中功能安全与抗辐射加固的协同设计研究
嵌入式硬件·安全·架构·risc-v·抗辐射·小卫星·综合电子系统
LCG元3 小时前
STM32L4 STOP2 模式下 LPUART 串口唤醒:三种唤醒方式实测对比与避坑指南
stm32·单片机·嵌入式硬件