STM32 DMA 双缓冲采样

在 STM32 项目中,ADC + DMA 是非常常见的数据采集方案。简单场景下,开启 DMA 循环模式就能拿到数据;但一旦采样率提高、通道数增多、算法处理变重,单缓冲方案很容易出现数据被覆盖、处理不及时、偶发毛刺等问题。双缓冲采样的价值就在这里:DMA 负责持续搬运,CPU 处理已经完成的一半缓冲区,两边通过半传输和全传输中断交替协作。

本文以 STM32 ADC 采样为例,讲清楚 DMA 双缓冲的工程结构、常见代码写法、缓存一致性问题、调试方法和上线检查点。虽然不同 STM32 系列的寄存器和 HAL 接口略有差异,但设计思路是通用的。

一、为什么单缓冲容易出问题

最常见的单缓冲写法是 ADC 连续转换,DMA 循环搬运到一个数组里,任务定时读取数组:

c 复制代码
uint16_t adc_buf[ADC_CH_NUM];

HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, ADC_CH_NUM);

这种方式的问题是 CPU 不知道"哪一组数据是完整的一帧"。如果 DMA 正在写数组,任务同时读取,就可能读到一半旧数据、一半新数据。采样率低时问题不明显,采样率上去后就会变成偶发异常。

典型现象如下:

现象 可能原因
波形偶尔跳点 CPU 读取时 DMA 正在覆盖缓冲区
多通道数据错位 没有按完整扫描序列处理
压力测试时丢帧 算法处理时间超过半缓冲填充时间
Debug 正常 Release 异常 缓存、优化、变量可见性问题

双缓冲的本质是把缓冲区拆成两个半区:

text 复制代码
DMA 写前半区 -> 半传输中断 -> CPU 处理前半区
DMA 写后半区 -> 全传输中断 -> CPU 处理后半区

二、数据流设计

一个比较稳的采样链路如下:

text 复制代码
ADC Scan
   |
   v
DMA circular buffer
   |
   +-- HT interrupt -> sample block A ready
   |
   +-- TC interrupt -> sample block B ready
                       |
                       v
                 采样处理任务
                       |
                       v
             滤波 / 标定 / 协议上报

建议不要在 DMA 中断里做滤波、换算和协议打包。中断里只标记哪个半区准备好了,然后通过队列或任务通知交给处理任务。

三、缓冲区大小怎么选

缓冲区大小需要同时考虑通道数、采样率、处理耗时和系统延迟。

假设:

  • ADC 通道数:8
  • 每通道采样率:2 kHz
  • 一帧包含 8 个通道各 1 次采样
  • 半缓冲包含 32 帧

那么半缓冲处理周期为:

text 复制代码
32 / 2000 = 16 ms

这意味着 CPU 必须在 16 ms 内处理完一个半缓冲,否则下一轮 DMA 会覆盖还没处理完的数据。工程上最好保留至少 50% 余量,也就是处理耗时控制在 8 ms 以内。

缓冲区定义示例:

c 复制代码
#define ADC_CH_NUM          8
#define ADC_FRAME_PER_HALF  32
#define ADC_HALF_WORDS      (ADC_CH_NUM * ADC_FRAME_PER_HALF)
#define ADC_DMA_WORDS       (ADC_HALF_WORDS * 2)

static uint16_t adc_dma_buf[ADC_DMA_WORDS];

如果芯片带 DCache,例如 Cortex-M7,需要按 cache line 对齐:

c 复制代码
#define CACHE_LINE_SIZE 32

__attribute__((aligned(CACHE_LINE_SIZE)))
static uint16_t adc_dma_buf[ADC_DMA_WORDS];

四、HAL 方式实现双缓冲处理

HAL 的循环 DMA 模式可以通过半传输和全传输回调处理双缓冲:

c 复制代码
typedef enum {
    ADC_BLOCK_FIRST = 0,
    ADC_BLOCK_SECOND = 1,
} adc_block_id_t;

typedef struct {
    adc_block_id_t id;
    uint16_t *data;
    uint32_t words;
} adc_block_msg_t;

static QueueHandle_t adc_block_q;

void adc_start(void)
{
    adc_block_q = xQueueCreate(4, sizeof(adc_block_msg_t));
    HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_dma_buf, ADC_DMA_WORDS);
}

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    BaseType_t wake = pdFALSE;
    adc_block_msg_t msg = {
        .id = ADC_BLOCK_FIRST,
        .data = &adc_dma_buf[0],
        .words = ADC_HALF_WORDS,
    };

    xQueueSendFromISR(adc_block_q, &msg, &wake);
    portYIELD_FROM_ISR(wake);
}

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    BaseType_t wake = pdFALSE;
    adc_block_msg_t msg = {
        .id = ADC_BLOCK_SECOND,
        .data = &adc_dma_buf[ADC_HALF_WORDS],
        .words = ADC_HALF_WORDS,
    };

    xQueueSendFromISR(adc_block_q, &msg, &wake);
    portYIELD_FROM_ISR(wake);
}

处理任务:

c 复制代码
static void adc_process_task(void *arg)
{
    adc_block_msg_t msg;

    for (;;) {
        if (xQueueReceive(adc_block_q, &msg, portMAX_DELAY) != pdTRUE) {
            continue;
        }

        adc_process_block(msg.data, msg.words);
    }
}

注意:如果 adc_process_block() 耗时可能接近半缓冲周期,就不要直接处理 DMA 原始区。可以把半区数据快速复制到处理池,或者设计多块 ping-pong buffer。

五、带 DCache 芯片的缓存一致性

在 STM32H7、部分 Cortex-M7 平台上,DMA 写内存时不会自动更新 CPU DCache。CPU 读缓冲区前必须 invalidate 对应 cache 区域,否则读到的可能是旧数据。

示例:

c 复制代码
static void adc_invalidate_cache(uint16_t *ptr, uint32_t words)
{
    uintptr_t addr = (uintptr_t)ptr;
    uint32_t size = words * sizeof(uint16_t);

    addr &= ~(CACHE_LINE_SIZE - 1);
    size = (size + CACHE_LINE_SIZE - 1) & ~(CACHE_LINE_SIZE - 1);

    SCB_InvalidateDCache_by_Addr((uint32_t *)addr, (int32_t)size);
}

static void adc_process_block(uint16_t *data, uint32_t words)
{
    adc_invalidate_cache(data, words);

    for (uint32_t i = 0; i < words; i += ADC_CH_NUM) {
        process_one_frame(&data[i]);
    }
}

如果缓冲区放在不可缓存 SRAM 区域,也可以避免这个问题,但要确认 linker script 和 MPU 配置正确。不要只靠"测试看起来没问题"判断 cache 一致性。

六、过载检测和丢帧统计

双缓冲不是无限吞吐。如果处理任务来不及消费,队列会堆积,最终还是会丢数据。建议加入统计:

c 复制代码
typedef struct {
    uint32_t ht_count;
    uint32_t tc_count;
    uint32_t queue_full;
    uint32_t max_process_tick;
} adc_diag_t;

static volatile adc_diag_t adc_diag;

static void adc_send_from_isr(adc_block_msg_t *msg, BaseType_t *wake)
{
    if (xQueueSendFromISR(adc_block_q, msg, wake) != pdTRUE) {
        adc_diag.queue_full++;
    }
}

处理耗时统计:

c 复制代码
static void adc_process_task(void *arg)
{
    adc_block_msg_t msg;

    for (;;) {
        xQueueReceive(adc_block_q, &msg, portMAX_DELAY);

        TickType_t start = xTaskGetTickCount();
        adc_process_block(msg.data, msg.words);
        TickType_t cost = xTaskGetTickCount() - start;

        if (cost > adc_diag.max_process_tick) {
            adc_diag.max_process_tick = cost;
        }
    }
}

这些统计可以通过 shell、串口命令、debugfs 或诊断协议导出。现场问题发生时,有统计比只看波形更有效。

七、调试方法

1. 用 GPIO 测中断和处理耗时

在半传输/全传输回调和处理任务入口翻转 GPIO,用逻辑分析仪观察:

c 复制代码
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    DBG_GPIO_SET();
    /* send message */
    DBG_GPIO_CLR();
}

如果中断间隔稳定,但处理任务脉宽越来越长,说明 CPU 算法或同步链路有瓶颈。如果中断本身不稳定,要回头查 ADC 触发源、DMA 配置和总线负载。

2. 用固定输入源验证数据顺序

多通道扫描时建议先接固定电压或函数发生器,确认通道顺序和采样间隔。不要一开始就接复杂传感器,否则很难判断是硬件输入问题还是 DMA 数据错位。

3. 压测组合

双缓冲采样要和其它高负载场景一起压测:

  • 高速串口收发。
  • Flash 写入。
  • 网络发送。
  • 屏幕刷新。
  • 日志大量输出。

因为 DMA、CPU、总线、cache、Flash 都可能互相影响。

八、真实案例:采样波形每隔几秒跳一次

某项目使用 STM32H743 做 12 路 ADC 采样,实验室低负载时波形正常,一打开以太网上报后,每隔几秒出现一次尖峰。最初怀疑 ADC 前端模拟电路,示波器测输入却很稳定。

排查过程:

  1. 给 DMA 半传输和全传输中断打 GPIO,发现中断周期稳定。
  2. 给采样处理任务打 GPIO,发现网络发送期间处理耗时变长。
  3. 打印 queue_full,发现尖峰前队列已满过几次。
  4. 检查代码发现处理任务里直接做浮点滤波和 JSON 打包。
  5. H743 开了 DCache,但 DMA 缓冲区没有 invalidate。

最终修复:

  • ADC 处理任务只做标定和轻量滤波。
  • JSON 打包放到低优先级通信任务。
  • DMA buffer 对齐到 32 字节,并在读取前 invalidate。
  • 队列满时记录丢帧并使用上一帧有效值,不输出半帧数据。
  • 逻辑分析仪验证处理耗时小于半缓冲周期的 40%。

九、常见坑点表

坑点 后果 修复建议
半传输中断里直接做算法 ISR 时间过长,影响系统响应 ISR 只发通知
没有 cache invalidate 读到旧数据或偶发跳点 对齐缓冲区并维护 DCache
缓冲区太小 处理稍慢就覆盖 根据采样率和 WCET 计算
多通道顺序没校验 通道值错位 用固定输入源逐通道验证
队列满不统计 丢帧无证据 增加 queue_full 和最大耗时统计
ADC 触发源不稳定 采样间隔抖动 使用定时器触发 ADC

十、上线检查清单

  • ADC 触发源明确,采样率可测。
  • DMA 使用循环模式,半传输和全传输回调都已覆盖。
  • 半缓冲时间至少大于处理最坏耗时的 2 倍。
  • ISR 中不做滤波、打印、协议打包。
  • Cortex-M7 平台已处理 DCache 一致性。
  • 统计了丢帧、队列满、最大处理耗时。
  • 使用逻辑分析仪或示波器验证中断周期和任务耗时。
  • 进行通信、存储、显示同时开启的压力测试。

十一、总结

STM32 DMA 双缓冲采样的关键不是把 DMA 打开,而是让"DMA 写入"和"CPU 处理"形成清晰边界。只要缓冲区大小、cache 一致性、ISR 责任、处理耗时和过载统计都设计到位,ADC 连续采样就能从"看起来能跑"变成"现场可诊断、可压测、可上线"。

相关推荐
ScilogyHunter1 小时前
Buildroot完全指南:从入门到实战
linux·嵌入式·buildroot
毕竟是shy哥1 小时前
Claude Code 接入 DeepSeek 保姆级教程,WSL/Linux 通用
linux·安装教程·codex·deepseek·claude code·openclaw
无限进步_1 小时前
从零实现一个迷你Shell——深入理解Linux命令行解释器
linux·运维·服务器·开发语言·c++·chrome
西城微科方案开发2 小时前
SIC8P370D2L-PLP16 8位OTP单片机 低功耗多功能MCU详解
单片机·嵌入式硬件
happymaker06262 小时前
Linux常见命令总结
linux·运维·服务器
lbb 小魔仙2 小时前
【Linux】DevOps 工程师必备:Linux 自动化脚本与高效工具链整合
linux·自动化·devops
小短腿的代码世界2 小时前
Qt D-Bus深度解析:跨进程通信高级架构与源码实现
qt·架构·系统架构
开源量化GO2 小时前
期货 K 线算信号 tick 级止损:天勤双序列 wait_update 触发规则
linux·运维·服务器·python
m0_738120722 小时前
HVV应急溯源基础——Linux 系统安全加固配置指南(一)
linux·运维·服务器·安全·网络安全·系统安全