
刚接到一个调测任务的时候现场反馈串口收数据会莫名其妙丢一帧我第一反应是中断写得太慢结果排查到最后问题出在没用 DMACPU 被高频串口中断拖得抬不起头。从那以后我养成了习惯只要涉及连续数据搬运第一选择就是先把 DMA 挂上。DMA 不是一个需要精通的高深模块但工作流程、传输模式、应用场景里藏着不少容易忽略的细节这篇就把这些年实际使用中总结的东西完整梳理一遍覆盖从 MCU 片上 DMA 到存储类 UFS DMA、分布式 DMA 的延伸给准备上手或者已经被坑到的朋友一个参考。1. DMA不是加速器而是给CPU腾出双手的内存搬运工很多人第一次接触 DMA以为它像 GPU 那样加速计算其实不是。DMA 的全称是 Direct Memory Access直译过来就是直接内存访问核心价值是让外设和内存之间、内存和内存之间的数据搬运可以绕过 CPU 的寄存器中转由 DMA 控制器独立完成。你可以把它理解成公司里的专职快递员CPU 是老板负责审批和处理核心业务而 DMA 就是那个把文件从仓库搬到打印室、从门口搬到老板桌上的跑腿老板不用亲自抱着文件来回走。1.1 为什么没有DMA时数据搬运会拖垮CPU以串口接收为例传统方式下每收到一个字节硬件就触发一次接收中断CPU 要在中断服务函数里把数据从数据寄存器读出来再写到内存缓冲区。115200 波特率下一个字节大约 86.8 微秒如果每字节一次中断CPU 要频繁地保存现场、进出中断、执行读改写逻辑。波特率再往上到 921600留给每个字节的时间只有 10.8 微秒左右这时候可能一个高优先级中断插进来串口数据就丢了。DMA 参与后串口外设的数据寄存器一旦收到数据DMA 控制器会根据事先配置好的源地址外设数据寄存器、目的地址内存缓冲区、传输长度比如 100 字节自动完成搬运。传输期间 CPU 不需要介入只有整批数据传输完毕后才会触发一次完成中断。这样一来CPU 的中断压力从每字节一次降到了每 N 字节一次大量 CPU 时间被释放出来给主逻辑处理。1.2 DMA控制器的本质和它在系统总线上扮演的角色DMA 控制器不是一个抽象概念它本身就是一个总线主设备能够主动发起总线读写请求。MCU 内部常见结构是CPU、DMA 控制器、内存、各类外设全部挂在系统总线上DMA 控制器通过总线仲裁机制和 CPU 竞争总线使用权。大部分 DMA 通道支持内存到外设、外设到内存、内存到内存三种方向关键的区别在于谁去触发这次传输外设到内存需要外设产生请求信号内存到内存则由软件启动。说起搬运有人会误以为有了 DMA 之后数据拷贝就完全免费了。实际上DMA 搬运依然会占用总线带宽当 DMA 在搬运时如果 CPU 也需要频繁访问内存两者会产生总线冲突只是 DMA 通过突发传输和优先级仲裁把这种影响降到了最低。所以我更愿意把 DMA 的作用概括为给 CPU 腾出双手而不是让系统更快。在一些大数据量场景下DMA 配置得不好反而会因为总线抢占导致 CPU 访问内存变慢这点后面会详细展开。2. 一次DMA传输背后的完整链路请求、仲裁、搬运、中断一个都不能少想用好 DMA先要搞清楚一次传输是怎么从头走到尾的。以 STM32 上最典型的外设到内存搬运为例完整链路可以拆成四个阶段。2.1 阶段一配置好源地址、目的地址、传输长度和触发源DMA 传输开始之前需要告诉 DMA 控制器四件事数据从哪来外设地址、数据送到哪去内存缓冲区地址、搬多少数据数据长度、什么时候开始搬触发源。比如用 STM32 HAL 库配置串口接收 DMA核心代码大致是这样uint8_t rx_buf[128]; DMA_HandleTypeDef hdma_usart1_rx; hdma_usart1_rx.Instance DMA1_Channel5; hdma_usart1_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_usart1_rx.Init.MemInc DMA_MINC_ENABLE; hdma_usart1_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode DMA_NORMAL; hdma_usart1_rx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_usart1_rx); __HAL_LINKDMA(huart1, hdmarx, hdma_usart1_rx); HAL_UART_Receive_DMA(huart1, rx_buf, 128);这段配置里PeriphInc 和 MemInc 很重要。外设地址寄存器一般不需要自增数据从同一个寄存器连续读出内存地址必须自增否则每一字节都会被写到同一个位置后到的数据覆盖前面的数据。数据对齐方式则要和外设寄存器宽度匹配串口是字节、ADC 可能是半字配置错位会导致搬运的数据全是乱的。2.2 阶段二外设请求到达DMA控制器经过仲裁获得总线配置完成后外设一旦产生数据比如串口收到一个字节会向 DMA 控制器发出请求信号。DMA 控制器看到请求后先检查对应通道是否使能再根据优先级仲裁。多个 DMA 通道的请求同时发生时优先级高的通道先获得 DMA 控制权同优先级则按通道号排顺序。这里有个容易被忽略的点DMA 获得的是一个传输单位的总线控制权不是整批数据的控制权。每次搬运一个字节或一个字后总线释放下一个请求再来时再仲裁。正因为如此高优先级的 DMA 通道可能打断低优先级通道的连续传输如果实时性要求高的数据比如 ADC 连续采样和大量普通拷贝共用 DMA 控制器需要把优先级分配好。2.3 阶段三DMA控制器直接读写源地址和目的地址仲裁通过后DMA 控制器作为总线主设备执行一次读操作和一次写操作。读源地址后数据暂存在 DMA 控制器的内部数据暂存寄存器中然后写入目的地址。如果方向是内存到外设则先从内存读取再写入外设。外设到内存模式中外设的请求信号可以被设计成每次传输后重新触发所以可以等到外设数据寄存器再次有数据后继续搬运。普通模式下搬完配置的长度后 DMA 通道自动关闭传输完成标志位置位循环模式下计数器回卷到初始值DMA 通道保持使能数据会持续不断地往缓冲区里写。这两种模式的选择直接影响后面怎么判断一帧数据完整接收。2.4 阶段四完成中断触发CPU介入做善后处理当传输计数器减到 0DMA 控制器会产生传输完成事件。这里面有一个很多新手会踩的坑完成中断表明指定长度的数据已经搬运完毕不等于外设数据都处理完了。尤其是在串口不定长接收中如果只配普通模式接收 100 字节才产生一次中断实际收到的数据往往只有几十字节那么要等凑满 100 字节才会触发回调数据响应非常及时性很差。所以实际工程里串口 DMA 接收很少只用简单的完成中断多半会配合空闲中断IDLE来判断一帧数据结束。这个后面用单独一章讲。3. 普通、循环、连续请求到底有什么不一样传输模式的取舍DMA 传输模式是使用中区别最大、也最需要结合场景来选择的部分。ST 的 DMA 有普通模式和循环模式NXP 的 eDMA 还有 continuous requests 这类更细的配置很多人在选型时看得眼花缭乱。我把常用模式和真正的连续请求机制分开讲。3.1 普通模式与循环模式的核心区别普通模式Normal下DMA 搬运完配置的传输长度后通道自动禁用。好处是逻辑清晰搬了多少数据去哪拿这些数据边界明确。坏处是下次传输要手动重新使能 DMA 通道否则外设再有请求也没人管。循环模式Circular下DMA 传输长度归零后自动重载通道始终使能数据像水一样不断流进内存缓冲区。循环模式特别适合连续数据流比如 ADC 连续采样、麦克风音频采集、串口接收长数据流。但副作用是缓冲区会被反复覆盖CPU 必须及时把数据取走否则旧数据会被新数据冲掉。选择普通还是循环的本质是你是否能明确界定一批数据的终点。Modbus 这类协议一帧数据有明确数量用普通模式空闲中断很舒服音频采集没有明确终点用循环模式加双缓冲一个缓冲区被 DMA 填另一个被 CPU 处理更合适。3.2 DMA continuous requests不是简单地把传输模式改成循环continuous requests这个说法在部分 DMA 控制器上会看到它和 ST 的循环模式有关联但并不完全等同。以 NXP eDMA 为例外设请求通常是沿触发式的外设产生一次请求DMA 搬一次数据。如果外设通过软件触发方式持续请求配合连续请求位可以让 DMA 在没有新硬件请求的情况下持续进行传输。实际使用时continuous requests 最适合的场景是内存到内存的批量拷贝或者外设需要源源不断的数据流。比如你想用 DMA 持续输出一段波形数据到 DAC如果不开启连续请求模式DMA 每次搬运一个点都要等一个触发源数据输出就会出现缝隙开启连续请求后DMA 会以控制器允许的最大速率持续搬运输出波形就会连贯很多。这里我踩过一个误区早年看到continuous以为是字面意义的永远搬下去实际它在很多实现里是允许 DMA 在当前通道请求结束后自动启动下一次传输和循环模式配合才能真正做到无缝。所以要仔细查芯片参考手册里的描述光看泛化的概念很容易被坑。3.3 突发传输、FIFO和多通道仲裁如何影响实际吞吐除了普通和循环DMA 控制器往往还有突发burst模式。普通模式下每次传输是单个数据单元传输突发模式下 DMA 会一次锁定总线连续搬运 4、8、16 个数据单元然后再释放总线。突发传输能显著减少总线交接次数适合内存到内存大块拷贝但突发传输会长时间占用总线如果系统中还有高实时性中断需要访问内存反而可能引入延迟。多通道 DMA 控制器还有一个内部 FIFO。FIFO 的作用是缓冲当外设数据宽度和内存数据宽度不一致时比如 ADC 输出 16 位但内存按 32 位存取FIFO 可以做数据拼接。想追求极致吞吐时可以采用突发模式 FIFO 阈值配合拉满想追求低延迟时反而要避免过长突发。3.4 分布式DMA当系统不再只有一颗DMA控制器时聊到分布式 DMA这已经跳出单片机的范畴了。在复杂 SoC 和处理器平台上单一 DMA 控制器会成为瓶颈于是芯片设计把多个 DMA 引擎分布到不同总线段或者外设集群旁边形成分布式 DMA 架构。CPU 只需要把传输描述符交给 DMA 引擎DMA 引擎之间可以接力完成数据搬运。分布式 DMA 最典型的应用在存储领域比如 UFS 控制器内部就集成了一套 DMA 引擎负责把接口收到的数据搬运到主内存。在这种场景里CPU 看到的是高性能存储设备但背后是多个 DMA 通道并行工作。理解单片机的 DMA 原理再去看分布式 DMA 架构发现套路是一样的请求、仲裁、搬运、中断只是数量级从几个通道变成了几十上百个调度算法更复杂而已。4. 串口DMA接收不定长数据的正确姿势空闲中断打配合串口是最常见的外设串口 DMA 也是很多人入门 DMA 的第一站。但接收不定长数据这个需求用 DMA 比用传统中断更讲究因为 DMA 本身只负责搬运不知道什么时候一帧数据结束了。这里最常用的方案是串口 DMA 空闲中断。4.1 为什么不定长接收必须搭配IDLE中断串口空闲中断IDLE在 UART 接收线上检测到一段空闲时间后触发。所谓空闲时间是接收线在一段时间内没有检测到起始位通常意味着前面一帧数据已经收完了。这个中断不用手动清除多余标志处理起来很高效。搭配 DMA 后流程变成串口 DMA 一直开着循环模式或普通模式接收数据写入缓冲区接收线上出现空闲触发 IDLE 中断在 IDLE 中断里读取当前 DMA 剩余传输量DMA_GetDataCounter算出已接收数据长度立即处理缓冲区里的有效数据然后重启 DMA 或调整剩余接收位置。在 STM32 HAL 库里可以更方便地使用HAL_UARTEx_ReceiveToIdle_DMA()它会把空闲中断和 DMA 接收包装在一起做出类似收到一帧数据就进回调的效果。uint8_t rx_buf[256]; // 启动不定长DMA接收帧结束或长度达到256都会触发回调 HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buf, sizeof(rx_buf)); void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { // Size 是本次实际接收到的数据长度 process_frame(rx_buf, Size); // 重新启动接收准备下一帧 HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buf, sizeof(rx_buf)); } }4.2 国产替代芯片的差异以PY32F003为例很多项目现在用国产芯片替代 STM32比如 PY32F003。这颗片子同样支持串口 DMA但要注意它的 DMA 通道配置和外设请求映射跟 ST 不是完全一致的尤其是判断收发完成的标志位细节。我调试 PY32F003 串口 DMA 接收时发现如果把参考手册上的寄存器名称想当然对应 ST很容易翻车。比如有些国产芯片的空闲中断标志清除方法不同需要先读串口状态寄存器再读数据寄存器顺序反了中断标志清不掉会导致一帧数据连续触发多次空闲中断。所以用国产芯片做 DMA 时不要直接抄 ST 的代码必须对照对应芯片的参考手册重新确认以下几点DMA 通道与串口的请求映射关系、空闲中断标志清除序列、DMA 剩余数据长度的读取方式和单位是字节还是半字。这几点确认好后代码移植其实是很快的。4.3 FreeModbus与DMA的结合定时器超时判断代替空闲中断FreeModbus 是工业控制里常用的 Modbus 协议栈它的帧结束判定和普通串口不一样Modbus 规定帧内字符间间隔不能超过 3.5 个字符时间超过就认为一帧结束了。这在 DMA 接收场景下怎么配合常见做法是串口 DMA 循环接收数据写入缓冲区同时启动一个定时器超时时间设为 3.5 字符时间每次收到新字节时刷新定时器定时器超时后认为一帧结束。这里关键是不用字节中断去刷定时器否则 DMA 的优势就没了。实际工程中可以利用空闲中断替代部分功能但 Modbus 对帧间隔有严格要求空线的判断和 DMA 完成中断的组合需要仔细验证。我自己的方案是 DMA 循环接收 定时器超时查询定时器中断里读 DMA 剩余计数如果在一段时间内发现剩余计数值没变化就判定帧结束然后取走缓冲区数据。这个方法规避了芯片间空闲中断异步差异兼容性更好配合 FreeModbus 的移植也更顺畅。5. PWM和ADC用DMA的高级玩法多路输出与多通道采样串口只是 DMA 的入门应用真正体现 DMA 价值的还有 PWM 和 ADC。它们一个利用 DMA 不断填充控制寄存器一个利用 DMA 连续接收转换结果都能把 CPU 从高频操作中解放出来。5.1 STM32F103的PWMDMA用内存表驱动PA1、PA3多路输出STM32F103 的高级定时器和通用定时器可以产生 PWM 输出但占空比需要实时更新才能形成动态效果。假设你需要同时在 PA1 和 PA3 上输出两路频率相同、占空比按正弦变化的波形传统做法是每隔一小段时间进中断按角度查表更新 CCR 寄存器。如果速率要求高CPU 基本就用来搬正弦表了。用 PWM DMA 的思路是把一段时间内要输出的占空比数值预先放进内存表然后配置定时器更新事件触发 DMA把内存表里的值依次搬到定时器的 CCR 寄存器。搬运完成后DMA 完成中断指示一轮波形结束。我做过一个实用例子主频 72MHz 的 STM32F103输出两路 10kHz 的正弦波查表点数 100 点DMA 循环传输模式下完全不需要 CPU 参与占空比更新输出非常稳定。PA1 是定时器 2 的通道 2PA3 是定时器 2 的通道 3。配置 DMA 时要把内存地址指向正弦表数组把外设地址指向 CCR2 或 CCR3 寄存器DMA 每次更新一个通道的值。如果两路同步输出可以配置两个 DMA 通道分别对应 CCR2 和 CCR3或者用一个 DMA 通道搬运到 CCR2另一个搬运到 CCR3注意优先级错开避免同时请求总线时互相等待。5.2 HAL库ADC单通道DMA多次采样连续采样和平均滤波一次搞定ADC 采样里最麻烦的是连续采样多组数据取平均。如果用中断采样每次转换完成进中断读值高频下 CPU 占用率极高。DMA 能直接把 ADC 数据寄存器里的转换结果连续搬运到内存数组配合 ADC 扫描模式和 DMA 循环模式可以实现不间断的多通道采样。用 STM32 HAL 库配置 ADC 单通道多次采样时大致步骤是ADC_HandleTypeDef hadc1; DMA_HandleTypeDef hdma_adc1; // 假设采样32次求平均 #define ADC_SAMPLE_TIMES 32 uint16_t adc_values[ADC_SAMPLE_TIMES]; HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_values, ADC_SAMPLE_TIMES);这条函数会启动 ADC并让 DMA 把转换结果自动搬运到adc_values数组。当 DMA 搬运完 32 个数据后产生传输完成中断此时对这 32 个值求平均即可得到滤波后的采样值。过程中 CPU 没有参与每一次的数据读取。需要注意的一个细节ADC 触发方式和 DMA 循环模式配合时如果用定时器触发 ADC 转换触发频率不能太高否则 DMA 来不及搬运上一次的结果数据会被覆盖。通常把 DMA 优先级调整为高于其他通道并且确保采样频率和 DMA 带宽匹配。5.3 双缓冲乒乓缓冲解决数据连续性问题ADC 连续采样或者音频采集时往往不希望 DMA 搬运完一批数据后停下来等待 CPU 处理这样会产生数据空洞。双缓冲是标准解法DMA 目标地址在缓冲 A 和缓冲 B 之间自动切换。DMA 正在填 A 的时候CPU 处理 B当 A 填满时 DMA 自动切换去填 B同时产生中断通知 CPU 处理 A。两个缓冲区交替使用数据流可以不间断。在单片机没有硬件双缓冲的情况下也可以用 DMA 传输完成中断回调里手动修改内存地址来实现但切换瞬间可能会有短暂的请求冲突。现在不少芯片的 DMA 控制器直接支持 double-buffer 模式配置一下就能用这是我在实际项目中非常推荐的一种工作方式。6. DMA踩坑记录连续请求、缓存一致性、发送等待这些坎怎么过DMA 用顺手以后真正麻烦的不是原理而是各种边缘情况。这些坑不遇到一次看参考手册十遍也记不住。6.1 DMA串口发送要不要等上一轮发送完再开启下一轮答案是肯定的。串口 DMA 发送如果不等上一轮发送完成标志就重新配置缓冲区并启动发送轻则发出的数据错乱重则 DMA 把未传输完的旧数据和刚更新的新数据混在一起。正确做法是启动发送前检查上一次HAL_UART_GetState()是否为 ready或者在 DMA 传输完成中断回调里设置标志位下一帧发送前等这个标志位。这里有一个隐藏性能问题一帧数据长度很短时DMA 发送完成中断产生后硬件 FIFO 可能还没把最后几个字节完全移位发送出去如果立刻切换 GPIO 或者关闭串口尾字节可能丢失。严格做法是等TC传输完成标志而不是只等 DMA 完成标志。6.2 缓存一致性当DMA和CPU同时访问同一块内存带 Cache 的高性能 MCU比如 Cortex-M7上DMA 搬运的数据对 CPU 来说可能是过期的。原因在于 CPU 通过 Cache 读数据而 DMA 直接把数据写进了主内存两边的数据不一致。处理办法是用 CMSIS 提供的函数SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, len); // 接收后在读之前使Cache失效 SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len); // 发送前把Cache数据回写到主内存如果不处理 Cache 一致性会看到串口接收的数据偶尔是旧值、发送数据偶尔缺一段这类问题很难排查因为不是稳定复现。排查时如果确认 DMA 配置没问题就要考虑是不是 Cache 惹的祸。6.3 数字逻辑分析仪和DMA测速软件在调试中的角色调试 DMA 时很多人喜欢用各种 DMA 测速软件去测搬运速率但其实单片机上并没有现成的DMA 测速软件可以直接跑。更实用的方法是用逻辑分析仪看外设引脚时序或者听中断回调里对 GPIO 翻转再用示波器测量带宽。PC 平台上确实有 DMA 测速工具但用来测磁盘或内存和嵌入式 DMA 完全是两码事。我常用的测量思路是开启 DMA 循环传输在 DMA 完成中断里翻转一个 GPIO然后用逻辑分析仪测量 GPIO 翻转频率就能反推 DMA 实际搬运速率。这个速率和理论带宽的差异往往能暴露总线冲突或 DMA 优先级问题。6.4 常见DMA配置错误汇总列一个我踩过的高频坑清单优先级错误现象根因1外设到内存传输后第一个数据丢失外设请求尚未准备好时 DMA 已启动建议使能外设后再启动 DMA2循环模式缓冲区数据全是重复的同一个值MemInc 未使能内存地址没有递增3DMA搬运完串口回调迟迟不来串口全局中断和 DMA 中断未同时使能或 NVIC 优先级配置不当4修改缓冲区后发送数据还是旧的DMA 启动了但缓存未 Clean这个坑在 Cortex-M7 上最常见5高优先级 DMA 通道频繁抢占低优先级数据丢失应该把实时性要求高的通道配为高优先级大块拷贝配低优先级6空闲中断一帧触发多次没有正确读取状态寄存器和数据寄存器清除标志6.5 一个真实的排查过程PWMDMA输出波形有毛刺去年做一款驱动板STM32F103 用 PWMDMA 产生多路模拟信号上电后发现波形上偶尔有毛刺。一开始怀疑是逻辑分析仪触发问题后来用示波器确认毛刺确实存在且位置不固定。排查路径是检查 PWM 频率和 DMA 搬运速率匹配度确认定时器更新事件触发 DMA 的频率没有超过 DMA 吞吐上限用 GPIO 翻转法测出 DMA 实际搬运周期和预计周期对比发现不稳定推测有总线竞争查看工程里 ADC DMA 也在工作且优先级配置和 PWM DMA 一样把 ADC DMA 优先级降低PWM DMA 保持高优先级毛刺消失。这个案例说明DMA 不是独立运行的多 DMA 通道之间、DMA 与 CPU 之间的总线调度会直接影响外设时序。配置 DMA 时不能只看单个通道要从系统角度统一规划优先级。最后说一个个人体会DMA 用得好不好往往是看一个人能不能从每一个中断都处理的惯性思维里走出来。先想清楚数据流向再想清楚谁来触发搬迁最后再关心中断怎么处理这个顺序下来大多数 DMA 问题都能迎刃而解。遇到疑难杂症时不要急着怀疑硬件按请求-仲裁-搬运-完成中断整条链路逐个环节排查比盲目调整参数高效得多。