DMA 实战避坑:从”数据只搬了一半”到 Cache 一致性,一次高速采集的调优全过程

2026年9月16日 0 By admin

上个项目要做一款振动监测仪:3 轴加速度计 20kHz 采样,每轴 16bit,跑 FFT 算频谱。第一版我用中断方式读 SPI,CPU 占用率直接飙到 70%,主循环里连串口命令都响应不过来。改成 DMA 之后 CPU 占用掉到 8% 以下——但中间踩的坑一点不比写代码少:数据只搬了一半、波形周期性错位、Cache 打开后数值全乱。这篇文章把 DMA 从配置模型到一致性问题的整条链路讲透。

一、先算清账:中断搬运的天花板在哪

很多人对 DMA 的印象停留在”速度快”,但真正该关心的是 CPU 每字节省下多少周期。以 SPI 读一个 16bit 采样点为例:

方式 每点 CPU 开销 20kHz×3 轴时 CPU 占用 说明
轮询 约 40 周期(等标志+读+存) ~50% 纯浪费时间,绝不可取
中断 约 30 周期(入栈+出栈+处理) ~70%(含 SPI 中断嵌套) 中断频率高时开销剧增
DMA 接近 0,仅搬运结束中断 <5% 每 1024 点才中断一次

关键在于:中断开销是”每点一次”,DMA 开销是”每缓冲块一次”。20kHz 采样下中断方式每秒进 6 万次中断,光是入栈出栈就吃掉大半算力;DMA 把 1024 点攒成一包,中断频率降了三个数量级。

但 DMA 不是免费的:它要抢总线带宽。如果你的系统里同时跑着 LCD 刷屏、以太网、SD 卡,就该认真看第七节的带宽预算。

二、配置模型:只需要回答四个问题

不管哪家 MCU,DMA 配置本质就是回答四个问题:

  1. 谁触发(请求源):SPI1_RX、ADC1、TIM2_UP、USART1_TX……
  2. 从哪到哪(方向):外设→内存 / 内存→外设 / 内存→内存
  3. 搬多少(数据宽度 + 数量):8/16/32bit,NDTR 计数值
  4. 搬完怎么办(模式):单次(Normal)/ 循环(Circular)/ 双缓冲(Double Buffer)

看个典型的 SPI 接收配置(STM32F4 HAL 风格,但要点通用):

static uint16_t adc_dma_buf[3][1024];   /* 3 轴各 1024 点 */

void dma_spi_rx_init(void)
{
    /* 关键点 1:外设地址不自增,内存地址自增 */
    hdma_spi_rx.Instance                 = DMA1_Stream0;
    hdma_spi_rx.Init.Channel             = DMA_CHANNEL_3;
    hdma_spi_rx.Init.Direction           = DMA_PERIPH_TO_MEMORY;
    hdma_spi_rx.Init.PeriphInc           = DMA_PINC_DISABLE;   /* 外设寄存器地址固定 */
    hdma_spi_rx.Init.MemInc              = DMA_MINC_ENABLE;    /* 缓冲区逐字递增  */
    hdma_spi_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
    hdma_spi_rx.Init.MemDataAlignment    = DMA_MDATAALIGN_HALFWORD; /* 必须与上面对齐! */
    hdma_spi_rx.Init.Mode                = DMA_CIRCULAR;       /* 循环采集用循环模式 */
    hdma_spi_rx.Init.Priority            = DMA_PRIORITY_HIGH;
    hdma_spi_rx.Init.FIFOMode            = DMA_FIFOMODE_ENABLE;
    hdma_spi_rx.Init.FIFOThreshold       = DMA_FIFO_THRESHOLD_HALFFULL;
    HAL_DMA_Init(&hdma_spi_rx);

    /* 关键点 2:开启半传输 + 传输完成两个中断 */
    __HAL_DMA_ENABLE_IT(&hdma_spi_rx, DMA_IT_TC | DMA_IT_HT);

    /* 关键点 3:绑定外设请求(F4 是独立 DMA,F1 要手动开 SPI 的 DMA 位) */
    __HAL_LINKDMA(&hspi1, hdmarx, hdma_spi_rx);

    HAL_SPI_Receive_DMA(&hspi1, (uint8_t *)adc_dma_buf, sizeof(adc_dma_buf)/2);
}

配置本身不复杂,坑都在”以为配对了”的地方。

三、坑一:数据宽度不匹配,高位凭空消失

第一次调试,采回来的加速度值永远在 0x00xx 范围,分辨率只有 8bit——明明传感器是 16bit 输出。

原因:外设数据宽度与内存数据宽度不一致时会做截断而非拆分。我当时配了 PeriphDataAlignment = BYTEMemDataAlignment = HALFWORD,DMA 把 SPI 的 16bit 数据寄存器当 8bit 外设读,每次取低 8 位,另一半直接丢掉。

记住一条铁律:

外设与内存的数据宽度必须一致。 如果源数据是 16bit 而缓冲区是 uint8_t,那不是”配小一点”,DMA 不会帮你拆字节。

需要按字节处理的场景,正确做法是把缓冲声明成 uint16_t 数组,或者在软件层用联合体解析,而不是让 DMA 去做转换。

四、坑二:地址自增方向搞反,缓冲区被写成”最后一个值”

第二版代码手抖把 PeriphIncMemInc 配反了,现象非常有辨识度:缓冲区的 1024 个点全部是同一个值(而且是最后一个采样值)。

因为内存地址不自增,DMA 每一次传输都往同一个 buf[0] 覆盖。反过来,外设地址自增更危险——DMA 会去写 SPI 数据寄存器旁边的一串寄存器,把整个外设配置打乱,问题表现可能完全无关(比如串口突然不工作了)。

配置时默念两遍:

  • 外设寄存器地址永远是固定的PeriphInc = DISABLE
  • 内存缓冲区要逐点存MemInc = ENABLE

唯一例外是”外设到外设”搬运(某些型号不支持)或内存到内存模式,那时两边都要自增。

五、坑三:单缓冲 + 传输完成中断,CPU 一慢就丢波形

初期我用最简单的单缓冲方案:采满 1024 点触发 TC 中断,中断里把数据拷到处理缓冲,再重启 DMA。跑仿真数据没问题,接上真实传感器就发现波形每隔一段时间出现”台阶”——一段数据是上一轮的旧值。

根因:从 TC 中断进入,到我在中断里重启 DMA,这中间有一段 DMA 停止的窗口。窗口内传感器还在输出,没有接收方,数据就永久丢了。更糟的是我在中断里做了浮点运算,窗口长达几十微秒。

两个正确解法,按复杂度递增:

方案 A:半传输 + 传输完成双中断(双缓冲效果)

把缓冲区当成两半,HT 中断处理前半,TC 中断处理后半。DMA 从不停止,永远在搬:

volatile uint8_t half_ready = 0;   /* 0=前半就绪 1=后半就绪 */

void DMA1_Stream0_IRQHandler(void)
{
    if (__HAL_DMA_GET_FLAG(&hdma_spi_rx, DMA_FLAG_HTIF0)) {
        __HAL_DMA_CLEAR_FLAG(&hdma_spi_rx, DMA_FLAG_HTIF0);
        dma_notify(DMA_HALF);       /* 只做标记/投递队列,绝不在这里算 FFT */
    }
    if (__HAL_DMA_GET_FLAG(&hdma_spi_rx, DMA_FLAG_TCIF0)) {
        __HAL_DMA_CLEAR_FLAG(&hdma_spi_rx, DMA_FLAG_TCIF0);
        dma_notify(DMA_FULL);
    }
}

方案 B:硬件双缓冲(Double Buffer Mode)

F4/F7/H7 的 DMA 流支持 M0/M1 两块内存自动交替,硬件层面无缝切换,连重启 DMA 都不需要:

HAL_DMAEx_MultiBufferStart(&hdma_spi_rx,
                           (uint32_t)&SPI1->DR,   /* 源:外设 */
                           (uint32_t)buf_a,       /* M0 */
                           (uint32_t)buf_b,       /* M1 */
                           1024);
HAL_DMAEx_ConfigMultiBuffer(&hdma_spi_rx, 0);      /* 从 M0 开始 */

每搬完一块,硬件自动切到另一块并触发 TC。处理函数永远有一整块的富余时间,只要保证处理速度跟得上采集速度就不会丢数据。

我最终用了方案 B,实测 20kHz×3 轴下 CPU 占用 6.8%,抖动为零。

这里有个通用原则值得抄下来:中断处理函数里只做”交接”,绝不做”加工”。 中断里应该只置标志、投队列、换指针;FFT、滤波、协议解析统统丢到主循环或独立任务。

六、坑四:Cortex-M7 的 Cache 一致性——最阴的一个坑

换到 STM32H743 做高配版本时,波形出现了更诡异的现象:数据大部分正确,但每隔几十个点跳出一个明显错误的值,而且开优化、关优化现象还不一样。

这是 Cortex-M7 引入 D-Cache 后的经典问题,机理如下:

DMA 直接写物理 SRAM,不经过 D-Cache。而你读数组时,CPU 可能从 D-Cache 里拿到旧数据——因为它不知道 SRAM 已经被 DMA 改过了。反过来内存→外设方向,CPU 写的数据还躺在 Cache 里没落到 SRAM,DMA 搬走的就是旧内容。

解决方式两条,按场景选:

方式一:手动维护 Cache(简单直接)

/* 外设 -> 内存:DMA 搬完后,让 Cache 失效,强制下次从 SRAM 重读 */
SCB_InvalidateDCache_by_Addr((uint32_t *)buf, len);

/* 内存 -> 外设:DMA 启动前,把 Cache 里的数据写回 SRAM */
SCB_CleanDCache_by_Addr((uint32_t *)buf, len);

/* 双向都要:先 Clean 再 Invalidate */
SCB_CleanInvalidateDCache_by_Addr((uint32_t *)buf, len);

三个必须注意的细节,我当时全踩了:

  1. 地址和长度都要按 Cache Line 对齐(Cortex-M7 是 32 字节)。长度不对齐会误伤相邻变量,导致”改了 A 变量,B 变量莫名变化”这种玄学问题。用 __attribute__((aligned(32))) 修饰缓冲区:
   __attribute__((aligned(32))) static uint16_t adc_buf[1024];
  1. 失效操作必须放在 DMA 完成之后、CPU 读取之前,且这一小段代码期间不能有其他事务碰这块内存。
  2. 连续的 Clean 之间不要夹 Invalidate,否则可能把还没落盘的数据丢掉。

方式二:把 DMA 缓冲区设为 Non-Cacheable(工程上更推荐)

用 MPU 把 DMA 缓冲区所在区域配成 Non-Cacheable,从此不再需要任何手工维护:

void mpu_config_dma_region(void)
{
    MPU_Region_InitTypeDef r = {0};
    HAL_MPU_Disable();
    r.Enable           = MPU_REGION_ENABLE;
    r.Number           = MPU_REGION_NUMBER0;
    r.BaseAddress      = 0x30000000;              /* D2 SRAM,专门放 DMA 缓冲 */
    r.Size             = MPU_REGION_SIZE_64KB;
    r.SubRegionDisable = 0x00;
    r.TypeExtField     = MPU_TEX_LEVEL1;
    r.AccessPermission = MPU_REGION_FULL_ACCESS;
    r.DisableExec      = MPU_INSTRUCTION_ACCESS_DISABLE;
    r.IsShareable      = MPU_ACCESS_SHAREABLE;    /* 关键:共享属性 */
    r.IsCacheable      = MPU_ACCESS_NOT_CACHEABLE;
    r.IsBufferable     = MPU_ACCESS_BUFFERABLE;
    HAL_MPU_ConfigRegion(&r);
    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

代价是这块区域读写变慢(每次都要走 SRAM),但 DMA 缓冲区本来就是”过路”数据,CPU 只在处理时才读一遍,影响可以忽略。H7 这类高主频 MCU 上,我强烈建议直接走 MPU 方案,比在每个 DMA 收发点手写 Cache 维护可靠得多——少写十行代码,少十个 bug。

七、坑五:F4 的 CCM RAM 不能被 DMA 访问

回来说 F4 版本的另一个坑。为了实现零等待执行,我把一些缓冲区放到了 CCM RAM(0x10000000 起始,64KB)。结果 DMA 传输全部超时,NDTR 一直是初值不动。

查参考手册才发现:STM32F4 的 CCM RAM 只有 CPU 内核能访问,DMA 控制器挂在外设总线上,根本够不着它。DMA 访问 CCM 地址不会有任何报错,就是默默不工作——这类”静默失败”最难查。

结论:DMA 缓冲区必须放在 DMA 可达的 SRAM 区域。F4 上就是普通的 0x20000000 SRAM,H7 上要注意 D3 SRAM 只能被部分 DMA 访问,跨域访问需要 BDMA 而不是 MDMA/DMA1/2。分配缓冲区时用链接脚本显式指定段:

/* 放进普通 SRAM,避开 CCM */
__attribute__((section(".dma_buffer"), aligned(32)))
static uint16_t adc_buf[1024];

八、坑六:总线带宽预算,多路 DMA 会互相饿死

系统加上 LCD 刷屏(SPI DMA)、SD 卡记录(SDMMC DMA)之后,出现了新的抖动:加速度波形偶尔丢一个点。三个 DMA 同时在跑,谁也没”出错”,但总线仲裁让 SPI 请求偶尔延迟了几个周期,SPI 的 RX 溢出标志(OVR)被置位。

处理办法:

  1. 提高关键通道优先级:采集通道设 DMA_PRIORITY_VERY_HIGH,LCD 刷屏设 LOW。注意优先级只在仲裁冲突时生效,不是”独占”。
  2. 用 FIFO + Burst 减少总线占用次数:开启 DMA FIFO,配 4 拍 Burst,把 16 次单字传输合并成 4 次突发,总线占用时间大幅下降。
  3. 降低非关键通道的请求频率:LCD 用大块传输而不是逐点刷新,SD 卡记录攒够 4KB 再写。
  4. 兜底:错误中断必须处理。DMA 传输错误(TE)、FIFO 错误(FE)、直接模式错误(DME)都要有中断处理,至少记录并重启,否则一次错误就永久停摆:
   void DMA1_Stream0_IRQHandler(void)
   {
       if (__HAL_DMA_GET_FLAG(&hdma, DMA_FLAG_TEIF0)) {
           __HAL_DMA_CLEAR_FLAG(&hdma, DMA_FLAG_TEIF0);
           err_count++;
           HAL_DMA_Abort(&hdma);        /* 清状态 */
           restart_dma_transfer();      /* 重挂,别让它死在那 */
       }
   }

九、调试 DMA 的三个有效手段

  1. 看 NDTR 寄存器。它是剩余传输计数,实时反映进度。停在这里不动 = DMA 没被触发或通道没使能;突然归零后又立刻恢复初值 = 循环模式正常;跑到一半卡住 = 总线错误或目标不可达。
  2. 在缓冲区首尾放哨兵值。填 0xDEADBEEF,跑一轮后检查是否被覆盖、覆盖到哪里,能立刻判断”搬了多少”和”有没有越界”。
  3. 用逻辑分析仪抓触发信号。SPI 的 SCK/CS 和 DMA 传输中断引脚对照看,能区分”是外设没给请求”还是”DMA 没响应请求”。

十、小结:一份可以贴墙上的 DMA Checklist

  • 外设与内存的数据宽度必须一致,DMA 不做格式转换;
  • 外设地址不自增,内存地址自增
  • 中断里只做交接,不做加工;高频采集用半传输 + 传输完成硬件双缓冲
  • Cortex-M7 上有 D-Cache 就必配 MPU Non-Cacheable 区域,Alignment 至少 32 字节;
  • DMA 缓冲区放在 DMA 可达的 SRAM,F4 的 CCM RAM 是禁区;
  • 多路 DMA 并行时做优先级 + Burst + 限频三重控制,并处理错误中断;
  • 调试优先看 NDTR,用哨兵值判断覆盖范围。

DMA 的价值不在于”快”,而在于把 CPU 从重复劳动里解放出来。想清楚这一点,配置里的每个参数都有了明确的取舍理由,遇到怪现象也能顺着”谁在搬、搬到哪、什么时候告诉我”这条线索一路查下去。

下一篇打算聊聊 ADC 采样精度的那些事——从参考电压噪声、采样时间与源阻抗匹配,到硬件平均与过采样如何真正提升有效位数(ENOB)。