DMA 这三个字母,搞过嵌入式的基本都眼熟。早几年我调串口 DMA 接收不定长数据,照着网上的参考例抄下来,能收,但一换帧长就乱,一帧数据中间偶尔夹 0xFF,甚至发送完成后直接进 STOP 把最后一个字节尾巴丢了。后来把手册里的 DMA 工作流程、传输模式、请求映射表逐行啃了一遍,才明白坑全在不理解“DMA 到底在替你干什么,干完没有”这两件事上。
这篇文章把我这些年调 DMA 的实际经验整理出来:从工作流程到 Normal、Circular、双缓冲、描述符链这些传输模式,再到串口不定长接收、ADC 多通道多次采样、PWM 波形发生、FreeModbus 整合这些典型应用场景,最后是一份能直接用来排查问题的疑难杂症速查表。适合刚接触 DMA 的新手,也适合被 UFS DMA、分布式 DMA、DMA 测速这些概念绕晕、想一次性把主线理清的人。
1. DMA 到底是什么:先理解“数据搬家”这个本质
1.1 没有 DMA 的时候 CPU 有多惨
DMA 全称 Direct Memory Access,直译就是“直接内存访问”。它最核心的用途只有一个:替你搬数据。你可以把它想成一个不占厨师双手的传菜员,厨师把菜谱和位置交代清楚后,传菜员自己一趟一趟端菜,厨师继续炒下一道菜。
如果没有 DMA,串口收一个字节,CPU 至少要做三件事:循环等待 RXNE 标志置位、从数据寄存器读数据、把数据写进内存数组。伪代码长这样:
for (i = 0; i < len; i++) { while (!(USART1->SR & USART_SR_RXNE)); // 等一个字节 buf[i] = USART1->DR; // 搬一个字节 }一个字节看似没什么,但波特率一上来,比如 921600bps,一个字节大约 10us 窗口,CPU 如果还同时要跑 Modbus 状态机、读传感器、刷新显示,很快就会卡死在搬运循环里。ADC 连续采样更是如此,如果每个采样值都要 CPU 去搬,采样率稍微高一点,整个系统就别干别的了。
DMA 解决的就是这个“重复搬运”问题。CPU 只需要在传输前配置好源地址、目的地址、数据长度和触发源,然后使能 DMA,之后每来一个“外设请求”,DMA 控制器就自动搬一个或多个数据,直到全部搬完,再通过中断告诉 CPU:活干完了。这一来,CPU 从“按字节搬运工”变成了“包工头”,只管派活和验收。
1.2 一台 DMA 引擎由哪些部件组成
在 STM32、py32 这类 MCU 里,DMA 不是一个抽象概念,而是一个实实在在的硬件外设。配置 DMA 时,你要关注的其实就这么几个东西:
| 组件 | 作用 | 容易忽略的点 |
|---|---|---|
| 源地址/目的地址 | 决定数据从哪来、到哪去 | 外设地址一般固定,内存地址可能需要自增 |
| 传输计数器 | 记录还剩多少数据没搬 | 搬一个减一,减到 0 触发完成事件 |
| 数据传输方向 | 外设到内存、内存到外设、内存到内存 | 方向反了,数据全乱 |
| 数据宽度 | 字节、半字、字 | 必须和外设寄存器宽度匹配 |
| 传输模式 | Normal、Circular、双缓冲等 | 一个 Normal 模式就能卡掉一半新手 |
| 触发/请求源 | 哪个外设请求 DMA 干活 | 通道和外设映射关系必须查表 |
| 优先级 | DMA 内部多个通道谁先走 | 总线被抢,实时性会出问题 |
这里最需要强调的是“通道映射表”。在 STM32F103 上,DMA1 和 DMA2 的每个通道都不是随便绑定外设的,比如 DMA1 的某个通道可以服务 ADC1,另一个通道可以服务 USART1_TX,再换一个能服务 TIM2_UP。你换了一个型号,照抄老工程的 DMA 通道号,大概率翻车。
除了通用 DMA 控制器,很多系统里还有“藏在设备内部”的 DMA。比如 UFS 存储控制器,内部就有一套 DMA/描述符逻辑,把设备返回的数据按物理区域描述符表写到系统内存,CPU 只要维护描述符,不用一个 page 一个 page 手动搬。这也是为什么 UFS 测速软件报出的是整个数据通路吞吐,而不只是闪存本身的速度。
1.3 从 UFS DMA 到分布式 DMA 的理解
提到 UFS DMA,它不是某个孤立 DMA 通道,而是存储控制器集成的一套 DMA 子系统。它负责搬运命令、Inquiry 数据、逻辑块数据,并支持把散落在物理内存不同页的数据通过描述符链一次性组织起来。软件只需要告诉控制器:这批数据在哪个地址、总共多少字节、下一段在哪。对于测速软件来说,它看到的顺序读速度,很大一部分取决于这套 DMA 通路有没有被打满。
“分布式 DMA”这个概念,我在看多核 SoC 和服务器网卡时体会更深。这类系统不会只有一颗 DMA 引擎,而是把 DMA 能力分布到各个总线段:USB 控制器有自己的 DMA,以太网控制器有 Ring Descriptor DMA,显卡有显存拷贝引擎,甚至内存控制器本身也承担一部分搬运工作。从 CPU 视角看,这就是一批“分布式 DMA 资源”,驱动要分别管理和同步。
在实际 MCU 工程里,分布式 DMA 的雏形也很常见。比如我用一个 DMA 通道收串口、另一个 DMA 通道发串口,ADC 再用一个 DMA 通道连续采样,它们共享同一个总线,各走各的请求线。配的时候要想清楚:它们会不会同时抢总线?谁的优先级更高?会不会互相干扰?理解了这一点,后面查疑难杂症就顺了。
2. DMA 工作流程拆解:从初始化到完成中断
2.1 七步流程,按顺序走就不会乱
很多人第一次碰 DMA,喜欢“先抄代码,跑不通再查手册”。我的习惯是倒过来,先把工作流程背下来,再看代码就会觉得每行都是顺理成章的。
- 使能 DMA 时钟和外设时钟。
- 配置 DMA 通道:源地址、目的地址、传输方向、数据宽度、传输数量、传输模式、优先级。
- 配置外设侧:使能外设的 DMA 请求,比如串口要置位 CR3 的 DMAR/DMAT,ADC 要置位 DMAEN。
- 使能 DMA 通道。
- 外设产生一次请求,DMA 控制器参与总线仲裁,获得总线后搬一个或多个数据。
- 每搬一次,内部计数器减一,源地址或目的地址按配置自动增减。
- 计数器减到 0,产生传输完成事件/中断;如果是 Circular 模式,计数器自动重载,继续下一轮。
对于内存到内存模式,没有外设请求线,只要使能 DMA,搬砖工作立刻开始,搬完自动停止。所以你想用 DMA 做大块内存拷贝,千万别开 Circular,它一次搬完就是真的完了。
2.2 串口接收参考例:DMA + 空闲中断判断接收结束
串口接收不定长数据,是我被问得最多的问题之一。核心思路就是两句话:DMA 负责把字节搬进内存,空闲中断负责告诉 CPU“这一帧结束了”。参考写法如下:
#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; void uart_dma_rx_start(void) { __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE); } void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); uint16_t len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); HAL_UART_DMAStop(&huart1); process_frame(rx_buf, len); HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE); } HAL_UART_IRQHandler(&huart1); }很多人不理解len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(...)这行。原理很简单:初始化时 DMA 计数器写的是 RX_BUF_SIZE,每搬一个字节,计数器减一。空闲中断触发时,计数器里存的就是“还剩多少字节没搬”,用总量一减,自然得到“已经收到多少字节”。
比如配置 256 字节缓冲区,收到一帧后计数器剩 240,说明实际收到 16 字节。剩下的 16 字节是没被用完的剩余空间,不需要清空,下次继续覆盖写就行。
这个例子里我用了process_frame()去处理完整帧,处理完再重启 DMA。如果你用 Circular 模式,也可以不用HAL_UART_DMAStop,直接读计数器做处理,但要注意加了拷贝逻辑后,硬件可能又在往缓冲区里写新数据,这时候轻则干扰,重则覆盖没处理完的旧数据。我的建议是:不确定帧速率的情况下,宁可 Stop 再重启,也不要让“处理”和“接收”在同一个缓冲区里赛跑。
2.3 串口发送时,DMA 完成中断能代表发送完成吗
答案是:不能,必须再等串口自己的 TC(Transmission Complete)标志。这是整个 DMA 问题里比较经典的一个坑。
DMA 传输完成中断,只代表 DMA 已经把数据从内存搬到了串口的数据寄存器 DR,但串口还要经过发送移位寄存器才能把最后一个字节真正送到 TX 引脚上。这个过程还需要若干位时间。如果你在HAL_UART_TxCpltCallback里立刻关 UART、关 DMA、进 STOP 模式,或者直接修改发送缓冲区,最后的尾巴就丢了。
参考处理逻辑如下:
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { // 等待串口真正发完最后一个字节 while (__HAL_UART_GET_FLAG(huart, UART_FLAG_TC) == RESET) {} // 到这里再关 UART、切 485 方向、释放 DMA 缓冲区 UART_DIR_OUTPUT_LOW(); }所以回到那个热搜问题“DMA 串口发送需要等待上一轮数据发送完吗”,分两层看:第一层,HAL_UART_Transmit_DMA()如果返回HAL_BUSY,说明上一轮 DMA 还没完成,你肯定不能直接再发,要么排队要么丢弃;第二层,即使上一轮 DMA 完成了,也要等 UART 的 TC 标志置位,才算真正发出去了。这两个条件缺一个都不行。
3. 传输模式详解:Normal、Circular、双缓冲、描述符链
3.1 最常用的四种模式,一次讲清楚
先说 Normal 模式。它是最直白、最简单的一种:搬完指定数量就停,想再用必须重新启动。串口固定长度接收、一次性 ADC 转换、内存到内存拷贝,都适合用 Normal。但很多人就栽在这里:第一次数据收完了,第二次数据没来,查了半天才发现 DMA 没重启。
再说 Circular 模式。搬完指定数量后,计数器自动回滚到初始值,地址回到起始位置,继续搬运。它天生适合“永远不知道什么时候结束”的连续数据流,比如 ADC 连续采样、串口不定长接收。Circular 模式通常还配合半传输中断和全传输中断使用,也就是把缓冲区切成两半,一半在接收新数据时,另一半用来做业务处理,典型 ping-pong 结构。
双缓冲模式和 Circular 有点像,但它是两个独立缓冲区。DMA 在当前缓冲区填满后,自动切换到另一个缓冲区,同时给你一个中断。好处是应用程序在处理一个缓冲区时,DMA 写另一个缓冲区,两边互不干扰,性能比 Circular 更稳。
描述符链/链式 DMA 则是更高级的东西。每个描述符里记录着源地址、目的地址、长度、下一描述符指针,一个结束后自动加载下一个。用它来搬运分散在内存不同位置的数据非常方便,UFS 控制器的物理区域描述符、PCIe DMA 的 scatter-gather 列表,本质都是这套思路。
3.2 DMA continuous requests 到底是什么
“continuous requests” 这个词在不同芯片里含义不完全一样,但思路大同小异:让 DMA 在某类外设请求到来后,不是一次搬一个单元就停,而是持续地按配置的突发长度搬完一整批,或者让描述符链在没有 CPU 介入的情况下连续执行。
在 STM32 某些 HAL 库里,ADC 初始化结构体里能看到DMAContinuousRequests这个选项。它的本意是:ADC 转换完成后持续产生 DMA 请求,而不是每个转换都要 CPU 重新启动 DMA。开启之后,ADC 和 DMA 进入“自动流水线”模式,采样值源源不断进内存。如果不开,可能采一次就断一次。
对于 UFS、SSD 这类控制器,continuous requests 更像是把多个数据块请求合并成一大笔描述符链,减少中断次数、提高总线利用率。所以你在测速软件里看到顺序读性能明显高于随机读,很多时候就是因为连续请求模式下 DMA 的带宽被压满了。
3.3 模式选型速查表
| 场景 | 推荐模式 | 主要原因 |
|---|---|---|
| 串口不定长接收 | Normal/Circular + 空闲中断 | 帧结束时间未知,需要靠空闲标志判断 |
| 串口定长接收 | Normal | 数量固定,一次搬完最省事 |
| 串口发送 | Normal | 每帧独立,发送完就停 |
| ADC 连续采样 | Circular 或双缓冲 | 需要长时间不间断采集,处理时不丢数 |
| ADC 单次多通道采样 | Normal | 采一轮够用,重启不会很频繁 |
| PWM 波形发生 | Circular | 查表循环输出,不中断 |
| 大块内存拷贝 | Normal(MEM2MEM) | 搬完就结束,不开循环 |
| UFS/SSD 大块传输 | 描述符链/PRD | 物理内存分散,需要 scatter-gather |
3.4 什么时候应该上描述符链
很多人觉得描述符链是高不可攀的东西,实际上它解决的痛点非常具体。比如你要从外部存储读一段数据,但这段数据在内存里被分成了 4 个不连续的区域。没有描述符链,你得启动 4 次 DMA,每次都打断 CPU。有了描述符链,只需要一次配置,DMA 搬完一段自动跳到下一段描述符,搬完最后一段再给你一个完成中断。
在嵌入式 MCU 上,描述符链还能减少连续收发数据时的延迟。尤其在做音频、图像采集这类数据量大的项目时,描述符链比手动重启 DMA 稳定得多,因为它消除了“中断到重启之间”的窗口期。代价是描述符本身要占用内存,而且对描述符的对齐、存放位置有要求,这个后面排查部分细说。
4. 串口 DMA 不定长接收与 FreeModbus 整合
4.1 py32/STM32 通用参考例:空闲中断判断接收结束
py32f003 这种 M0+ 内核的国产芯片,设计思路和 STM32 很接近,串口 DMA 接收不定长数据同样可以用“DMA + 空闲中断”这套组合。用寄存器操作写一条通用流程,放在哪款芯片上都能看懂:
// 1. 初始化 UART:8N1、波特率、使能接收 // 2. 初始化 DMA:外设地址 = UART_DR,内存地址 = rx_buf, // 方向 = 外设到内存,数据长度 = N // 3. 使能 UART 的 DMA 接收请求,也就是置位 CR3 的 DMAR // 4. 使能 UART 空闲中断,也就是置位 CR1 的 IDLEIE // 5. 启动 DMA // 中断里这样处理: if (UART_IDLE_FLAG) { // 清空闲标志:读一次 SR,再读一次 DR (void)UART_GetSR(); (void)UART_GetDR(); uint16_t len = N - DMA_GetRemainingCount(DMA_CH); process_frame(rx_buf, len); DMA_Disable(DMA_CH); DMA_SetRemainingCount(DMA_CH, N); DMA_Enable(DMA_CH); }这套流程有几个关键点。第一,清空闲标志不能只清一次,很多芯片需要读 SR 和 DR 配合才能彻底清掉,否则会反复进中断,把 CPU 卡死在 ISR 里。第二,重启 DMA 之前一定要先把 DMA 禁用,再去修改计数器,否则硬件可能在修改过程中又搬了几字节,计数和实际位置对不上。第三,处理完一帧后最好立即重启 DMA,不要把“处理业务”拖在 ISR 里,不然长帧或高波特率下就可能丢下一帧。
“用接收空闲中断判断接收结束”这句话容易把新手带偏,以为只要有空闲中断就能收任意长度数据。其实空闲中断只是告诉你“线路空了一段时间”,波特率越高,这段时间越短。如果帧与帧之间的间隔小于 UART 判定空闲的时间,硬件会把两帧拼成一帧。所以 DMA 缓冲区设多大、超过缓冲区边界怎么处理,在协议层必须有兜底方案。
4.2 FreeModbus 怎么和 DMA 结合
FreeModbus 是很多工业项目里用的 Modbus 协议栈,默认移植方式是用串口收发中断或查询。接 DMA 之后,很多人会乱:DMA 一次收一堆数据,FreeModbus 却是一个字节一个字节地喂,这怎么办?
我的做法是,利用“DMA + 空闲中断”先把一整帧数据收进 DMA 缓冲区,空闲中断触发后用长度判断是不是合法请求,如果长度合适,直接把整个帧交给 FreeModbus 的接收处理回调。具体端口函数名不同版本不一样,你对着自己移植的eMBPortRxCb/pxMBFrameCBByteReceived这一类入口去接就行。
发送侧更要小心。Modbus RTU 是半双工,很多板上用 485 收发器,方向控制由 GPIO 决定。用 DMA 发送时,不能只看 DMA 完成中断就拉低方向控制脚,必须等到 UART TC 置位,确定最后一个字节已经从 TX 引脚发出后再切换方向。否则对端设备会收到一个被截断的响应帧。
另外,Modbus RTU 帧有 3.5 字符时间间隔要求。如果上一帧和下一帧之间的间隔太短,从机会认为还在同一帧里。用 DMA 加空闲中断时,这个间隔天然由 UART 硬件特性决定,但如果实测发现帧边界不对,优先检查的是“空闲判定时间”和“波特率标称值是否一致”,而不是 DMA 配置。
5. ADC + DMA:连续多次采样配置与平均滤波
5.1 HAL 库单通道多次采样的标准写法
STM32 HAL 库做 ADC 单通道 DMA 多次采样,通常有两种玩法。一种是单次转换加 Normal 模式 DMA,采够指定的样本数后一次性进完成回调;另一种是连续转换加 Circular 模式 DMA,ADC 一直采,DMA 一直写,回调里只管取数据。
参考第一种,也就是“采集固定次数再处理”的写法:
#define SAMPLE_NUM 64 __ALIGN_BEGIN __IO uint16_t adc_buf[SAMPLE_NUM] __ALIGN_END; void start_adc_sampling(void) { HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, SAMPLE_NUM); } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { uint32_t sum = 0; for (uint16_t i = 0; i < SAMPLE_NUM; i++) { sum += adc_buf[i]; } g_adc_avg = sum / SAMPLE_NUM; // 单次模式时,需要在这里重新启动,才能继续下一轮采样 if (hadc->Instance == ADC1) { HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, SAMPLE_NUM); } }你可能会问:为什么非要采样 64 次再去平均?因为 ADC 在采集真实信号时,会混入白噪声。对白噪声做平均,64 次理论上可以把噪声幅度压到原来的八分之一左右,非常适合测电池电压、电位器位置这类变化缓慢的模拟量。但这也要求信号本身是基本平稳的,如果你在电机启动瞬间去采样,平均值反而会把真实的变化细节抹掉。
如果使用 HAL 库的连续模式,就要注意DMAContinuousRequests是否使能。使能后,ADC 完成一次转换后立即触发下一次 DMA 请求,不需要 CPU 重复启动。再配合 Circular 模式 DMA,算是把“DMA continuous requests”用到了实处。
5.2 ADC + DMA 最容易翻车的三件事
第一,数据宽度不匹配。STM32 的 ADC 数据寄存器通常是 16 位,DMA 配置成半字比例子最顺手。如果你把内存缓冲区定义成uint32_t[],DMA 又配成字,那数据会把 ADC 的高 16 位和低 16 位都写进去,看起来好像没丢,但后面取数计算全乱。我一直建议 ADC 缓冲区就用uint16_t数组。
第二,缓冲区对齐问题。许多 DMA 控制器要求内存地址按数据宽度对齐,尤其是在 M4/M7 系列或者开了 MPU、Cache 的情况下。__ALIGN_BEGIN这种关键字不是摆设,它可以保证数组起始地址是 4 字节或 8 字节对齐。起步阶段就加上,能省很多排查时间。
第三,Cache 一致性问题。Cortex-M7 这类带 D-Cache 的内核上,DMA 写内存是硬件直接写到物理内存,而 CPU 读的可能还是 Cache 里的旧数据。就算代码上看起来数组内容没变,实际表现就是“DMA 收完了,但读出来还是上一次的值”。解决办法是 DMA 传输完成后,对缓冲区地址执行一次 Cache 失效操作:
SCB_InvalidateDCache_by_Addr((uint32_t *)adc_buf, sizeof(adc_buf));在 M3/M4 这类不带 D-Cache 的内核上一般没有这个问题,换到 H7 才要开始认真对待。
6. PWM + DMA:用定时器更新事件刷 CCR,实现“无 CPU 波形”
6.1 STM32F103 上 PA1/PA3 的思路与参考代码
热搜里有个问题叫“stm32f103 pwm + dma pa1 pa3”,我猜是想在 PA1 和 PA3 上同时输出受控 PWM 波形。PA1 对应 TIM2_CH2,PA3 对应 TIM2_CH4。思路是让定时器每次更新(溢出)时触发一次 DMA 请求,DMA 把内存查找表里的下一个值搬到 CCR 寄存器里,这样占空比就能自动按表变化,CPU 完全不用管。
首先初始化 GPIO 和定时器,把 PA1、PA3 复用成 TIM2 的 PWM 输出:
GPIO_InitTypeDef gpio = {0}; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); gpio.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_3; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); TIM_TimeBaseInitTypeDef tb = {0}; tb.TIM_Period = 999; tb.TIM_Prescaler = 71; tb.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &tb); TIM_OCInitTypeDef oc = {0}; oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_Pulse = 0; TIM_OC2Init(TIM2, &oc); TIM_OC4Init(TIM2, &oc);这个配置下,定时器时钟如果是 72MHz,PSC=71 后变成 1MHz,ARR=999 时 PWM 频率就是 1MHz / 1000 = 1kHz。每次更新,DMA 会把查找表里的一个新值搬到 CCR2。如果查找表有 100 个点,输出波形频率就是 1kHz / 100 = 10Hz,一个比较慢的正弦波,做 LED 呼吸灯刚好合适。
然后配置 DMA。这里有一个很重要的提醒:F103 上 TIM2 的更新事件对应的 DMA 通道,一定要去查 Reference Manual 的 DMA 请求映射表,不同系列不同封装都可能不一样。下面代码里的通道号只是示例,不要无脑照抄:
DMA_InitTypeDef dma = {0}; dma.DMA_PeripheralBaseAddr = (uint32_t)&TIM2->CCR2; dma.DMA_MemoryBaseAddr = (uint32_t)sine_lut; dma.DMA_DIR = DMA_DIR_PeripheralDST; dma.DMA_BufferSize = LUT_LEN; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Circular; dma.DMA_Priority = DMA_Priority_High; DMA_Init(DMA1_Channel5, &dma); DMA_Cmd(DMA1_Channel5, ENABLE); TIM_DMACmd(TIM2, TIM_DMA_Update, ENABLE);严格说,F103 上要同时刷 CCR2 和 CCR4 两个寄存器,能用的方案不止一种。可以配两路 DMA 分别服务两个 CCR,也可以只刷新一路,另一路用定时器的比较预装载功能去配合。但我建议新手先按“一路 DMA + 一个通道”调通,再考虑双通道同步。原因是更新事件同时在多个 DMA 通道上触发,有些芯片的表现和你直觉并不一致,双通道同步更多依赖通道仲裁和时序,不是加一行代码那么简单。
6.2 波形频率到底怎么算
PWM + DMA 的精髓,是把 PWM 的更新频率当成“采样率”。每个更新事件,DMA 搬一个 CCR 值进去,相当于输出一个采样点。所以:
- PWM 频率:由定时器时钟和 ARR 决定。
PWM频率 = 定时器时钟 / (PSC + 1) / (ARR + 1) - 查找表采样率:等于 PWM 频率,因为每次更新搬一个采样点。
- 输出波形频率:
采样率 / 查找表长度
比如定时器时钟 72MHz,PSC=71,ARR=999,那么 PWM 频率 1kHz。查找表 200 个点,波形频率就是 5Hz。如果想把波形频率调高,要么提高 PWM 频率,要么缩小查找表长度。只是查找表别太少,否则波形阶梯感会非常明显,低通滤波也救不回来。
6.3 PWM + DMA 还能干嘛
除了 LED 呼吸灯,这套玩法能做的还有很多。比如用 PWM 输出一个经过低通滤波的正弦波,冒充简易 DAC;比如电机驱动里做电流限幅,实时改变峰值电流;再比如方波信号发生器,用 DMA 按表切换占空比,产生特定频率组合。
我做过一个给 LED 调色的小项目,两路 PWM 分别控制红蓝两色,查找表里放的是不同亮度比例。DMA 在 Circular 模式下自己跑,CPU 全程只在初始化时忙一下,后面完全空闲,效果还比用延时函数刷 GPIO 平滑得多。
7. DMA 疑难杂症排查手册和测速思路
7.1 高频问题速查表
| 现象 | 大概率原因 | 解决办法 |
|---|---|---|
| 只收到第一次数据,后面再没动静 | Normal 模式没有重启 DMA | 完成回调里重新启动,或改成 Circular |
| 串口 DMA 收一帧后全是 0xFF | 发生过载错误 ORE,DMA 停止 | 读一次 DR 清错误标志,重启 DMA |
| 发送完成后进 STOP,最后一个字节丢了 | 没等 UART TC | DMA 完成后继续等待 TC 置位 |
| 数据搬完但 CPU 读到旧值 | D-Cache 未失效 | SCB_InvalidateDCache_by_Addr() |
| DMA 一直返回 HAL_BUSY | 上一轮没完成,或忘了 Stop / 回调没执行 | 检查状态标志,确保完成后重置 |
| 多通道 ADC 数据顺序和预期不符 | 扫描顺序/DMA 顺序配置错 | 按 ADC 注入序列重新核对缓冲区排列 |
| 内存地址写坏导致 HardFault | 地址未对齐或缓冲区太短越界 | 使用对齐宏,估算最大数据长度 |
| 定时器更新 DMA 一启动就跑飞 | 请求映射表看错,通道错配 | 翻开参考手册,逐行核对请求源 |
7.2 我排查 DMA 问题的固定套路
我踩了很多次坑之后,总结了一套固定排查顺序。第一步,先确认时钟。DMA 时钟、外设时钟、总线时钟三者任何一个没开,DMA 表现都可能是“完全没反应”或“偶尔动一下”。第二步,查请求映射表。这是 DMA 和普通外设最大的不同,通道号错了,配置再对也是白搭。第三步,检查数据宽度和对齐。外设寄存器 16 位,你就配半字;内存地址按宽度对齐。第四步,看计数器。用调试器把 DMA 当前计数器读出来,如果发现它频繁归零但中断没触发,多半是模式配置或者中断处理有问题。第五步,考虑 Cache 和内存屏障。带 D-Cache 的内核必须做一致性和失效处理,这一点在 M7 上尤其重要。
这套流程看着笨,但每次都能定位到问题。比起看一堆网上零散的“解决方案”,自己按系统流程来查更快。
7.3 DMA 测速软件和自己写测速逻辑
很多人在网上搜“DMA 测速软件”,其实是想看看 UFS、SSD 这类存储设备的速度。这类测速软件测的是整条 I/O 链路,包括文件系统、驱动、控制器 DMA、闪存介质。得分高低不完全等于 DMA 能力强弱,它更像一个“系统级性能指标”。
在 MCU 上,我更推荐自己写一段测速逻辑。用 Cortex-M 内核的 DWT 周期计数器,可以精确测一次 DMA 搬运要多少时钟周期,从而算出实际吞吐:
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; DWT->CYCCNT = 0; // 启动 DMA,等待完成标志 start_dma_transfer(); while (!dma_done_flag); uint32_t cycles = DWT->CYCCNT; double mbps = (double)total_bytes / ((double)cycles / SystemCoreClock) / 1e6;这种测法能直接告诉你:DMA 到底跑出了几 MB/s,CPU 在这个过程里被总线仲裁拖慢了多少。注意一点,DMA 并非零成本。它搬数据时也要占用系统总线,CPU 同时访问 Flash、SRAM 或外设时会有竞争。所谓“DMA 完全不给 CPU 添麻烦”是理想状态,真实项目里总线和 Cache 的冲突非常常见,所以测速结果比理论峰值低才是常态。
8. 典型应用场景汇总与选型速查
8.1 一张表把常见场景和配置串起来
| 应用场景 | 常用请求源 | 传输方向 | 推荐模式 | 核心注意点 |
|---|---|---|---|---|
| 串口不定长帧接收 | UART RX | 外设到内存 | Normal/Circular + IDLE | 空闲标志清除、DMA 重启 |
| 串口发送 | UART TX | 内存到外设 | Normal | 必须等 UART TC |
| ADC 单通道多次采样 | ADC EOC | 外设到内存 | Normal 或 Circular | 连续请求、缓冲区宽度 |
| ADC 多通道扫描 | ADC EOC | 外设到内存 | Circular | 通道顺序与缓冲区排列对应 |
| PWM 波形输出 | TIM Update | 内存到外设 | Circular | 请求映射表、查找表长度 |
| SPI 刷屏或读传感器 | SPI RX/TX | 双向 | Circular/Double | 数据宽度和 FIFO 阈值 |
| I2S 音频播放 | I2S | 内存到外设 | Double Buffer | 左右声道数据交替顺序 |
| 大块内存拷贝 | 软件触发 | 内存到内存 | Normal | 总线和 Cache 竞争 |
| FreeModbus RTU | UART | 双向 | IDLE + Normal | 485 方向控制、帧间隔 |
| UFS/SSD 数据传输 | Host Controller | 双向 | 描述符链/PRD | 物理内存分散页表 |
这张表基本覆盖了 MCU 和嵌入式 Linux 里最常见的 DMA 应用。拿到一个新项目,先按这个表对号入座,再去看对应型号手册里的请求映射,能少走很多弯路。
8.2 我选 DMA 配置时的固定顺序
每次做新板子,我不会一上来就写代码,先做三件事:查地图、定内存、排中断。查地图,就是把 DMA 通道和请求源的对应表打印出来;定内存,就是把源地址、目的地址、缓冲区长度、对齐要求先写在纸上;排中断,就是想清楚哪些 DMA 中断可以合并到已有的中断服务函数里,哪些必须独立。
最后再分享一个我从翻车里得来的习惯:每次接到新板子,先把 DMA 请求映射表拍照贴在工位上,再开始写代码。DMA 不是拿来即用的,它替你搬数据,你得替它把路认对。