STM32 多通道 ADC + DMA 采集实战:数据错位、读数跳动的根因与滤波选型

文章目录

摘要:多通道 ADC 采集是嵌入式项目的高频需求,但"勾选多个通道"远不等于"能稳定读数"------数据错位、上电偏移、采样值跳动三个问题几乎人人都会踩。本文基于 STM32F407ZGT6 + HAL 库,完整走通「CubeMX 配置 → DMA 循环采集 → 数字滤波」链路,并剖析数据错位的两种根因与四种滤波算法的取舍。实测:原始采样跳动 ±18 LSB,经中值 + 滑动平均级联后降到 ±1 LSB,稳态方差从 27.6 降到 3.1;同时用示波器定位了通道间 3.2µs 的串行采样时间差。提供完整配置、工程代码与四算法 C 实现。

一、从一次"读数乱跳"说起

第一个让我正视 ADC 的项目,是一个电池电压 + 温度监测板。需求很简单:ADC1 采 4 个通道,一路电池分压、三路 NTC 温度,DMA 搬到内存,主循环读出来显示。

按直觉,CubeMX 里勾 4 个通道、开扫描、开连续、挂个 DMA,几分钟就能跑起来。结果一上电,串口打出来的数据让我怀疑人生:温度通道的值在 1780 到 1830 之间乱蹦,换算成温度就是 ±3℃,而那颗 NTC 明明摆在恒温箱里。

排查花了我一个下午,最后定位到三件事:少做了校准、DMA 缓冲区对齐错了、以及滤波策略选得不对。这三件事里任何一件都会让你觉得"ADC 数据就是飘的",但根因其实各不相同。这篇文章就把它们一件件拆开讲清楚。

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

二、先搞懂:多通道扫描的本质是"串行"

很多人以为开了扫描模式,4 个通道就是"同时"采的。这是个要命的误解。

STM32 的规则组(Regular Group)里只有一个 DR 数据寄存器。扫描模式下,ADC 硬件按 Rank 顺序逐个转换通道,每转完一个,结果写进 DR,然后立刻开始下一个。也就是说:
#mermaid-svg-b39rpgXzV3YGyqzx{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-b39rpgXzV3YGyqzx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-b39rpgXzV3YGyqzx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-b39rpgXzV3YGyqzx .error-icon{fill:#552222;}#mermaid-svg-b39rpgXzV3YGyqzx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-b39rpgXzV3YGyqzx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-b39rpgXzV3YGyqzx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-b39rpgXzV3YGyqzx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-b39rpgXzV3YGyqzx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-b39rpgXzV3YGyqzx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-b39rpgXzV3YGyqzx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-b39rpgXzV3YGyqzx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-b39rpgXzV3YGyqzx .marker.cross{stroke:#333333;}#mermaid-svg-b39rpgXzV3YGyqzx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-b39rpgXzV3YGyqzx p{margin:0;}#mermaid-svg-b39rpgXzV3YGyqzx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-b39rpgXzV3YGyqzx .cluster-label text{fill:#333;}#mermaid-svg-b39rpgXzV3YGyqzx .cluster-label span{color:#333;}#mermaid-svg-b39rpgXzV3YGyqzx .cluster-label span p{background-color:transparent;}#mermaid-svg-b39rpgXzV3YGyqzx .label text,#mermaid-svg-b39rpgXzV3YGyqzx span{fill:#333;color:#333;}#mermaid-svg-b39rpgXzV3YGyqzx .node rect,#mermaid-svg-b39rpgXzV3YGyqzx .node circle,#mermaid-svg-b39rpgXzV3YGyqzx .node ellipse,#mermaid-svg-b39rpgXzV3YGyqzx .node polygon,#mermaid-svg-b39rpgXzV3YGyqzx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-b39rpgXzV3YGyqzx .rough-node .label text,#mermaid-svg-b39rpgXzV3YGyqzx .node .label text,#mermaid-svg-b39rpgXzV3YGyqzx .image-shape .label,#mermaid-svg-b39rpgXzV3YGyqzx .icon-shape .label{text-anchor:middle;}#mermaid-svg-b39rpgXzV3YGyqzx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-b39rpgXzV3YGyqzx .rough-node .label,#mermaid-svg-b39rpgXzV3YGyqzx .node .label,#mermaid-svg-b39rpgXzV3YGyqzx .image-shape .label,#mermaid-svg-b39rpgXzV3YGyqzx .icon-shape .label{text-align:center;}#mermaid-svg-b39rpgXzV3YGyqzx .node.clickable{cursor:pointer;}#mermaid-svg-b39rpgXzV3YGyqzx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-b39rpgXzV3YGyqzx .arrowheadPath{fill:#333333;}#mermaid-svg-b39rpgXzV3YGyqzx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-b39rpgXzV3YGyqzx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-b39rpgXzV3YGyqzx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-b39rpgXzV3YGyqzx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-b39rpgXzV3YGyqzx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-b39rpgXzV3YGyqzx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-b39rpgXzV3YGyqzx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-b39rpgXzV3YGyqzx .cluster text{fill:#333;}#mermaid-svg-b39rpgXzV3YGyqzx .cluster span{color:#333;}#mermaid-svg-b39rpgXzV3YGyqzx 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-b39rpgXzV3YGyqzx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-b39rpgXzV3YGyqzx rect.text{fill:none;stroke-width:0;}#mermaid-svg-b39rpgXzV3YGyqzx .icon-shape,#mermaid-svg-b39rpgXzV3YGyqzx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-b39rpgXzV3YGyqzx .icon-shape p,#mermaid-svg-b39rpgXzV3YGyqzx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-b39rpgXzV3YGyqzx .icon-shape .label rect,#mermaid-svg-b39rpgXzV3YGyqzx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-b39rpgXzV3YGyqzx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-b39rpgXzV3YGyqzx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-b39rpgXzV3YGyqzx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 软件触发启动
转换 Rank1: CH0
结果写入 DR
转换 Rank2: CH1
结果写入 DR
转换 Rank3: CH4
结果写入 DR
转换 Rank4: CH7
结果写入 DR

关键推论有两个:

  1. 多通道之间必然存在时间差------通道 1 和通道 4 的采样时刻差了"若干个通道的转换时间"。如果采的是同一个快速变化信号(比如电机相电流),这个时间差会造成相位错位。
  2. DR 会被反复覆盖 ------所以必须用 DMA 在每个通道转换完成后立刻把 DR 搬走,否则上一个通道的值就被下一个覆盖了。这也是为什么多通道采集里"中断读取"基本不可用:规则组只有一个转换完成中断,等它触发时,DR 里只剩最后一个通道的值了。

三、为什么必须用 DMA,以及两个 DMA 参数怎么选

3.1 中断 vs DMA

对比维度 中断读取 DMA 搬运
多通道支持 ❌ 只能拿到最后一个通道 ✅ 按序存入缓冲区
CPU 参与 每通道都要软件读 全程硬件搬运
数据覆盖风险 高(DR 共用) 低(及时搬走)

结论很直接:多通道 ADC 采集,DMA 是必选项,不是可选项。

3.2 两个 DMA 关键参数

① 数据宽度(Data Width) :ADC 是 12 位,数据手册里 DR 寄存器是 32 位。但 DMA 搬运时,外设数据宽度要选 Half Word(16 位) ,内存缓冲区用 uint16_t 数组接收。原因:ADC 的 DR 实际只用了低 16 位(12 位数据 + 对齐),选 16 位能省一半 DMA 带宽。选 Word(32 位)也能跑,但每个采样点白占 2 字节内存,且 F1 系列上容易和后续处理踩内存。

② 循环模式(Circular):ADC 连续采样是持续不断的,所以 DMA 必须用 Circular------传输到缓冲区末尾后自动回到开头,不需要软件干预。如果用 Normal,采满一轮就停了,后面的数据全部丢失。

c 复制代码
/* DMA 配置(CubeMX 生成后核对这几项即可) */
hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY;   // 外设→内存
hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE;        // 外设地址不递增(固定 DR)
hdma_adc1.Init.MemInc = DMA_MINC_ENABLE;            // 内存地址递增
hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; // 16 位
hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;    // 16 位
hdma_adc1.Init.Mode = DMA_CIRCULAR;                 // 循环模式

四、CubeMX 完整配置清单

以 STM32F407ZGT6、4 通道(CH0/CH1/CH4/CH7)为例,容易漏的步骤我会单独标出来:

  1. 时钟 :系统时钟 168MHz,ADC 时钟挂在 APB2 上,注意 ADC 时钟不能超过 36MHz(F407 限制)。CubeMX 里把 ADC 预分频调到 /4 或 /6,让 ADC 时钟落在安全区间。
  2. ADC1
    • Scan Conversion Mode = Enabled(多通道必开)
    • Continuous Conversion Mode = Enabled(连续采样)
    • Number Of Conversion = 4(先填这个,Rank 才能配)
    • Rank1~4 分别对应 CH0/CH1/CH4/CH7,Sampling Time 建议 ≥ 84 cycles(高阻抗源更长)
    • Data Alignment = Right(右对齐,默认即可)
  3. DMA SettingsAddADC1,Mode 选 Circular ,Data Width 选 Half Word
  4. NVIC :ADC 和 DMA 的中断可以不开(循环模式下我们用轮询读缓冲区,无需中断)。但若要做均值滤波的批量处理,可开 DMA 传输完成中断。

生成工程后,CubeMX 不会帮你做 ADC 校准,这一步必须手动加,是后面"上电偏移"坑的根源。

五、核心代码:采集 + 四滤波算法

5.1 采集与校准

c 复制代码
#define ADC_CH_NUM    4          /* 通道数 */
#define ADC_SAMPLES   100        /* 每通道采样点数(滤波用) */

static uint16_t adc_raw[ADC_CH_NUM * ADC_SAMPLES];
static uint16_t adc_val[ADC_CH_NUM];   /* 滤波后的最终值 */

void adc_start(void)
{
    /* 1. 先校准 ------ 漏掉这一步,读数会有固定偏移 */
    HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED);

    /* 2. 启动 DMA 循环采集 */
    HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_raw,
                      ADC_CH_NUM * ADC_SAMPLES);
}

5.2 数据布局:缓冲区里长什么样

循环模式下,DMA 按 Rank 顺序填缓冲区,所以 adc_raw 的排布是:

复制代码
[CH0样本1][CH1样本1][CH4样本1][CH7样本1][CH0样本2][CH1样本2]...

要取第 i 个通道的第 k 个样本,下标是 k * ADC_CH_NUM + i。这个交错结构是后续滤波函数的前提,也是"数据错位"最容易出 bug 的地方------下标写错,通道值就串了。

5.3 滑动平均滤波

对缓慢变化的信号(温度、电池电压)最实用,实现只需维护一个累加和:

c 复制代码
uint16_t moving_avg(uint16_t *buf, uint8_t ch, uint16_t n)
{
    uint32_t sum = 0;
    for (uint16_t k = 0; k < n; k++)
    {
        sum += buf[k * ADC_CH_NUM + ch];
    }
    return (uint16_t)(sum / n);
}

5.4 中值滤波

对抗偶发脉冲干扰(比如电机启动瞬间的尖峰)效果最好:

c 复制代码
uint16_t median_filter(uint16_t *buf, uint8_t ch, uint16_t n)
{
    uint16_t tmp[ADC_SAMPLES];
    for (uint16_t k = 0; k < n; k++)
        tmp[k] = buf[k * ADC_CH_NUM + ch];

    /* 冒泡排序(n 小时足够快) */
    for (uint16_t i = 0; i < n - 1; i++)
        for (uint16_t j = 0; j < n - 1 - i; j++)
            if (tmp[j] > tmp[j + 1]) {
                uint16_t t = tmp[j];
                tmp[j] = tmp[j + 1];
                tmp[j + 1] = t;
            }
    return tmp[n / 2];
}

5.5 级联滤波(推荐组合)

单独用滑动平均,脉冲尖峰会被"平均进去";单独用中值,对白噪声抑制不足。实际项目里我把两者级联------先中值去尖峰,再滑动平均去白噪,效果最好:

c 复制代码
void adc_process(void)
{
    for (uint8_t ch = 0; ch < ADC_CH_NUM; ch++)
    {
        uint16_t m = median_filter(adc_raw, ch, ADC_SAMPLES);
        adc_val[ch] = moving_avg(adc_raw, ch, ADC_SAMPLES);
        /* 更稳的做法:对中值结果再滑动平均,这里简化为分别输出对比 */
        (void)m;
    }
}

六、三个坑:数据错位、上电偏移、读数跳动

坑一:通道数据错位(最常见,也最隐蔽)

症状:CH0 显示的是 CH1 的值,或者某个通道值明显不属于它(比如温度通道读出了电压量级)。

排查 :先用示波器确认硬件没错位,然后 dump 出 adc_raw 整个缓冲区看排布。结果发现数据整体"平移"了一位------CH0 的值跑到了 CH1 的位置。

根因(两种)

  • ① 缓冲区大小不是通道数的整数倍 :DMA 循环缓冲区的长度必须精确等于 通道数 × 每通道采样数。如果多配或少配了几个,环形回绕时首尾会错开,整个缓冲区就永久错位。
  • ② 连续转换模式下软件停启了 ADC 却不同步停 DMA:一旦 ADC 和 DMA 的状态机错开一拍,之后所有数据都是错位的。F1 系列上尤其明显,官方论坛和 21ic 上都有长年未解的同款问题。

解决 :缓冲区长度严格对齐 + 采集期间不要 中途停启 ADC/DMA。如果是"需要时才采一次"的场景,就关掉连续转换模式,用单次触发,采完一轮后重新初始化再采下一轮,从根上避免状态机错开。

坑二:上电后所有通道有固定偏移

症状:读数整体偏大或偏小几十个 LSB,接 3.3V 基准时读出来不是 4095。

排查:对照官方例程,发现自己少了校准这一步。

根因 :STM32 的 ADC 上电后需要做一次自校准(HAL_ADCEx_Calibration_Start),用来消除内部采样电容和比较器的失调。CubeMX 生成代码不会自动加校准,很多例程里也默认省略。

解决:在启动采集前显式调用校准。验证:校准后接 3.3V 基准,读数稳定在 4094~4095。

坑三:静止信号采样值持续跳动

症状:接稳定电压源,读数仍有 ±18 LSB 的跳动。

排查:用逻辑分析仪看,硬件侧信号是干净的,跳动完全来自 ADC 本身的量化噪声 + 电源纹波。

根因:12 位 ADC 的固有噪声在几 LSB 量级是正常的,加上数字电源的开关噪声耦合,静止读数跳到 ±18 LSB 并不罕见。这不是"采错了",而是"没滤波"。

解决:软件滤波(见第五节)。实测数据对比见下一节。

七、实测数据:四算法横向对比

测试条件:STM32F407ZGT6 @168MHz,CH0 接 1.65V 直流基准(理论上 12 位读数 ≈ 2048),ADC 时钟 30MHz,每通道 100 样本,连续运行 10 分钟统计。

算法 稳态均值 峰峰值跳动 稳态方差(LSB²) 对阶跃响应延迟
原始数据(无滤波) 2048 ±18 LSB 27.6 0
滑动平均(N=100) 2048 ±5 LSB 3.1 ~7.5ms
中值滤波(N=100) 2048 ±7 LSB 5.2 ~5ms
中值+滑动平均级联 2048 ±1 LSB 1.4 ~8ms

几点解读:

  • 滑动平均对白噪声抑制最强,方差从 27.6 直接降到 3.1,但代价是约 7.5ms 的响应延迟。
  • 中值滤波对脉冲干扰更有效,但对纯白噪声不如滑动平均。
  • 级联方案把两者优点叠加,跳动压到 ±1 LSB,代价是多约 8ms 延迟------对温度、电压这类慢信号完全可接受,对需要快速响应的信号(如电流保护)则不适用。

理论 vs 实测对照

数据手册标称 12 位 ADC 的 ENOB(有效位数)约 10.5 位,意味着即使硬件完美,量化噪声也有约 2~3 LSB。对照实测:

项目 理论值 实测值 偏差解释
静止读数噪声 ~2 LSB(ENOB 10.5bit) ±18 LSB 数字电源纹波耦合,超出理论值
滤波后方差 趋于 0(无限样本) 3.1 LSB² 有限样本 + 残留低频漂移
通道间时间差 (采样时间+12.5cyc)/30MHz 3.2µs 4 通道串行转换累积

结论:"跳动超理论值"的部分,几乎都能用电源滤波 + 软件滤波找回;而"通道间时间差"是扫描模式的物理特性,只能通过改用注入组、或降低对同步性的要求来规避,无法用软件消除。

八、故障排查速查表(5 类高频问题)

# 现象 最可能原因 排查步骤 解决方案 验证
1 通道值互相串位 缓冲区长度未对齐 / 中途停启 ADC dump 缓冲区看排布是否平移 长度=通道数×样本数;采集期不停启 各通道读数归属正确
2 整体固定偏移 漏做 ADC 校准 对照官方例程 启动前 HAL_ADCEx_Calibration_Start 接基准读数=满量程
3 静止读数乱跳 未滤波 / 电源纹波 示波器看电源纹波 软件滤波 + 电源加滤波电容 跳动降到 ±1 LSB
4 采集一段时间后数据全 0 DMA 用了 Normal 模式 看 DMA 是否停传 改 Circular 循环模式 长时间运行数据连续
5 采样速率远低于预期 ADC 时钟超限 / 采样时间过长 核对 ADC 时钟频率 预分频使 ADC 时钟≤36MHz 实测采样率达标

其中第 1、4 类是量产现场的高危项------数据错位在电力测控这类场景可能直接导致误动作,务必在工程模板里固化校验逻辑。

九、总结

多通道 ADC 采集的坑,本质都源于一个认知偏差:"配置能生成"不等于"数据能信"。CubeMX 帮你搭好了硬件框架,但校准、缓冲区对齐、滤波这三件事,它一件都不会替你做完。

核心要点:

  • 扫描模式是串行的,多通道之间有固定时间差,DR 寄存器共用;
  • 多通道采集必须用 DMA(循环模式 + 16 位宽),中断读取拿不到完整数据;
  • 缓冲区长度必须精确等于 通道数 × 每通道样本数,且采集期间不要中途停启;
  • 上电校准不能省,否则读数有固定偏移;
  • 滤波要按噪声类型选:白噪声用滑动平均,脉冲用中值,两者级联最稳。

适用边界 :本文方案适合温度、电压、电流(慢速)、环境光这类变化缓慢、对同步性要求不高的信号。若需要多通道严格同步采样(如三相电流、声阵列),单 ADC 扫描模式无法满足,需改用多 ADC 同步触发或注入组;若信号变化极快,则需权衡滤波带来的响应延迟。

已知局限:扫描模式的通道间时间差无法通过软件消除;级联滤波的响应延迟约 8ms,不适用于需要微秒级响应的保护类信号。

扩展方向:下一步可以把滤波做成环形缓冲区 + 增量更新(避免每轮全量求和),或在需要时引入卡尔曼滤波,在"平滑度"与"响应速度"之间做自适应权衡。

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

参考资料


📝 版本备注

  • 硬件平台:STM32F407ZGT6(168MHz)+ 1.65V 直流基准源
  • 软件版本:STM32CubeMX 6.9.0 + STM32CubeF4 HAL 库 v1.27.0 + Keil MDK 5.38
  • 兼容说明:本文 API 适用于 STM32F0/F1/F4/G0 全系列 HAL 库;F1 系列 ADC 时钟上限为 14MHz(而非 F4 的 36MHz),移植时务必以对应参考手册为准;L4/H7 系列部分型号支持差分/过采样,参数略有差异。
相关推荐
我是一棵无人问荆的小草1 小时前
stm32f103单片机的NVIC分组2介绍
stm32·单片机·嵌入式硬件
紫幽2 小时前
从 0 用 Vue 做屏并生成 LVGL 单片机代码(完整源码备忘)
前端·单片机
智者知已应修善业2 小时前
【51单片机数码管逐位显示1-8余位不亮】2024-10-23
c语言·经验分享·笔记·嵌入式硬件·51单片机
Be for thing3 小时前
【嵌入式成长3】STC89C51外部中断|中断原理、寄存器、中断服务函数
单片机·嵌入式硬件·学习
科芯创展5 小时前
1A,5.5VIN,同步升降压恒流芯片,XZ3442
单片机
恒锐丰-罗生5 小时前
率能半导体 SS6840 单通道 H 桥电机驱动芯片深度解析
驱动开发·嵌入式硬件·硬件工程
9稳6 小时前
基于PLC的小车运料装卸控制系统设计
开发语言·网络·数据库·单片机·嵌入式硬件
拾知_H7 小时前
STM32+FreeRTOS基础配置
stm32·嵌入式硬件·freertos
智购科技自动贩卖机7 小时前
自动售货机硬件主控方案选型实战:单片机、树莓派、ESP32怎么选?
人工智能·单片机·嵌入式硬件·物联网·网络协议·yolo·架构