DMA 实战避坑:从”数据只搬了一半”到 Cache 一致性,一次高速采集的调优全过程
上个项目要做一款振动监测仪: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 配置本质就是回答四个问题:
- 谁触发(请求源):SPI1_RX、ADC1、TIM2_UP、USART1_TX……
- 从哪到哪(方向):外设→内存 / 内存→外设 / 内存→内存
- 搬多少(数据宽度 + 数量):8/16/32bit,NDTR 计数值
- 搬完怎么办(模式):单次(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 = BYTE、MemDataAlignment = HALFWORD,DMA 把 SPI 的 16bit 数据寄存器当 8bit 外设读,每次取低 8 位,另一半直接丢掉。
记住一条铁律:
外设与内存的数据宽度必须一致。 如果源数据是 16bit 而缓冲区是 uint8_t,那不是”配小一点”,DMA 不会帮你拆字节。
需要按字节处理的场景,正确做法是把缓冲声明成 uint16_t 数组,或者在软件层用联合体解析,而不是让 DMA 去做转换。
四、坑二:地址自增方向搞反,缓冲区被写成”最后一个值”
第二版代码手抖把 PeriphInc 和 MemInc 配反了,现象非常有辨识度:缓冲区的 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);
三个必须注意的细节,我当时全踩了:
- 地址和长度都要按 Cache Line 对齐(Cortex-M7 是 32 字节)。长度不对齐会误伤相邻变量,导致”改了 A 变量,B 变量莫名变化”这种玄学问题。用
__attribute__((aligned(32)))修饰缓冲区:
__attribute__((aligned(32))) static uint16_t adc_buf[1024];
- 失效操作必须放在 DMA 完成之后、CPU 读取之前,且这一小段代码期间不能有其他事务碰这块内存。
- 连续的 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)被置位。
处理办法:
- 提高关键通道优先级:采集通道设
DMA_PRIORITY_VERY_HIGH,LCD 刷屏设 LOW。注意优先级只在仲裁冲突时生效,不是”独占”。 - 用 FIFO + Burst 减少总线占用次数:开启 DMA FIFO,配 4 拍 Burst,把 16 次单字传输合并成 4 次突发,总线占用时间大幅下降。
- 降低非关键通道的请求频率:LCD 用大块传输而不是逐点刷新,SD 卡记录攒够 4KB 再写。
- 兜底:错误中断必须处理。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 的三个有效手段
- 看 NDTR 寄存器。它是剩余传输计数,实时反映进度。停在这里不动 = DMA 没被触发或通道没使能;突然归零后又立刻恢复初值 = 循环模式正常;跑到一半卡住 = 总线错误或目标不可达。
- 在缓冲区首尾放哨兵值。填 0xDEADBEEF,跑一轮后检查是否被覆盖、覆盖到哪里,能立刻判断”搬了多少”和”有没有越界”。
- 用逻辑分析仪抓触发信号。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)。