ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

DMA工作流程与典型应用:串口、ADC、PWM一次讲清

DMA工作流程与典型应用:串口、ADC、PWM一次讲清 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/DMATADC 要置位 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 完成中断能代表发送完成吗答案是不能必须再等串口自己的 TCTransmission 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查表循环输出不中断大块内存拷贝NormalMEM2MEM搬完就结束不开循环UFS/SSD 大块传输描述符链/PRD物理内存分散需要 scatter-gather3.4 什么时候应该上描述符链很多人觉得描述符链是高不可攀的东西实际上它解决的痛点非常具体。比如你要从外部存储读一段数据但这段数据在内存里被分成了 4 个不连续的区域。没有描述符链你得启动 4 次 DMA每次都打断 CPU。有了描述符链只需要一次配置DMA 搬完一段自动跳到下一段描述符搬完最后一段再给你一个完成中断。在嵌入式 MCU 上描述符链还能减少连续收发数据时的延迟。尤其在做音频、图像采集这类数据量大的项目时描述符链比手动重启 DMA 稳定得多因为它消除了“中断到重启之间”的窗口期。代价是描述符本身要占用内存而且对描述符的对齐、存放位置有要求这个后面排查部分细说。4. 串口 DMA 不定长接收与 FreeModbus 整合4.1 py32/STM32 通用参考例空闲中断判断接收结束py32f003 这种 M0 内核的国产芯片设计思路和 STM32 很接近串口 DMA 接收不定长数据同样可以用“DMA 空闲中断”这套组合。用寄存器操作写一条通用流程放在哪款芯片上都能看懂// 1. 初始化 UART8N1、波特率、使能接收 // 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 模式 DMAADC 一直采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_CH2PA3 对应 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);这个配置下定时器时钟如果是 72MHzPSC71 后变成 1MHzARR999 时 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 频率因为每次更新搬一个采样点。输出波形频率采样率 / 查找表长度比如定时器时钟 72MHzPSC71ARR999那么 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发生过载错误 OREDMA 停止读一次 DR 清错误标志重启 DMA发送完成后进 STOP最后一个字节丢了没等 UART TCDMA 完成后继续等待 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/sCPU 在这个过程里被总线仲裁拖慢了多少。注意一点DMA 并非零成本。它搬数据时也要占用系统总线CPU 同时访问 Flash、SRAM 或外设时会有竞争。所谓“DMA 完全不给 CPU 添麻烦”是理想状态真实项目里总线和 Cache 的冲突非常常见所以测速结果比理论峰值低才是常态。8. 典型应用场景汇总与选型速查8.1 一张表把常见场景和配置串起来应用场景常用请求源传输方向推荐模式核心注意点串口不定长帧接收UART RX外设到内存Normal/Circular IDLE空闲标志清除、DMA 重启串口发送UART TX内存到外设Normal必须等 UART TCADC 单通道多次采样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 RTUUART双向IDLE Normal485 方向控制、帧间隔UFS/SSD 数据传输Host Controller双向描述符链/PRD物理内存分散页表这张表基本覆盖了 MCU 和嵌入式 Linux 里最常见的 DMA 应用。拿到一个新项目先按这个表对号入座再去看对应型号手册里的请求映射能少走很多弯路。8.2 我选 DMA 配置时的固定顺序每次做新板子我不会一上来就写代码先做三件事查地图、定内存、排中断。查地图就是把 DMA 通道和请求源的对应表打印出来定内存就是把源地址、目的地址、缓冲区长度、对齐要求先写在纸上排中断就是想清楚哪些 DMA 中断可以合并到已有的中断服务函数里哪些必须独立。最后再分享一个我从翻车里得来的习惯每次接到新板子先把 DMA 请求映射表拍照贴在工位上再开始写代码。DMA 不是拿来即用的它替你搬数据你得替它把路认对。
返回列表