我做了十几年嵌入式最开始接触DMA时也觉得这玩意儿没啥技术含量外设要收发数据CPU懒得管DMA帮忙搬一下完事。但随着在STM32、Linux内核、Zynq这些平台上不断踩坑我才意识到DMA远不止“搬运数据”这么简单。它是一套独立的硬件执行引擎是一整套需要CPU提前把“什么时候搬、搬多宽、往哪搬、搬完干什么”全部交代清楚的协作机制。很多时候项目出问题不是外设配置错了而是你对DMA的理解还停留在“能搬就行”的层面。这篇文章我不打算贴一堆寄存器手册也不做那种“照抄手册翻译一遍”的教程。我想从一个实践者的角度把DMA的工作原理、关键参数、缓冲区策略、中断回调以及从裸机到Linux内核的DMA模型差异全部串起来讲一遍。如果你正被SPI DMA读数据、串口DMA接收、ADC DMA搬运、或者Linux驱动里的dma_alloc_coherent搞得头疼这篇文章应该能给你一个比较完整的坐标系。1. 先搞清楚DMA到底在干什么——从CPU搬运工的痛点说起1.1 为什么需要DMACPU搬运数据的代价先说个最简单的场景。你用STM32F103的SPI去读外部Flash或者ADC芯片的数据如果不用DMA每收到一个字节SPI外设就会触发一次中断CPU进中断服务函数把DR寄存器里的数据读走存到内存数组里然后退出中断。单字节数据量小的时候没问题但当你用10MHz的SPI时钟连续读几百个字节等于CPU每几个时钟周期就被打断一次大部分时间都耗在“进中断、读寄存器、存内存、出中断”这条流水线上。这时候用DMA就完全不同了。你事先告诉DMA把SPI接收寄存器里的数据按字节宽度搬到内存地址0x20000010开始的地方一共搬1024个字节。配置完成之后启动传输DMA硬件会自动监听SPI外设的请求信号来一个字节搬一个字节搬完了触发一次DMA传输完成中断CPU全程几乎不用管。用一个生活化的类比来说CPU就像一个项目经理DMA就是一个专职快递员。没有快递员的时候经理得亲自一趟一趟跑腿送文件文件多的时候一天啥也干不了。有了快递员经理只需要写一张交接单告诉他去哪取、送到哪、送多少快递员就自己干完了最后打一个电话说“搞定了”。1.2 DMA是“无脑执行者”它需要什么才能干活DMA本质上是个没有“大脑”的硬件状态机它自己不会思考不会判断数据对不对不会决定优先级它只会严格按预设的配置机械执行。所以你要让DMA干活必须把下面这些东西全部交代清楚传输方向是外设到内存内存到外设还是内存到内存。源地址和目的地址外设的数据寄存器地址、内存缓冲区地址。数据宽度字节8位、半字16位、字32位。传输长度一共传输多少个单位。触发源什么事件触发它搬一次数据比如SPI RX缓冲区非空、定时器更新事件、UART接收到数据。地址是否自增搬完一个单位后源地址或目的地址是否要加一个偏移。这就像你给快递员写了一张详细的“交接单”每一项都不能漏。漏了地址数据搬错地方漏了长度数据可能搬一半就停宽度配错了两个字节的数据被当成一个半字搬到内存后面全错位。很多初学者配置DMA失败问题就出在没意识到“DMA是一个独立执行单元”这个本质上。你以为配置完它就会自己“理解”你的意图但硬件不理解意图它只理解规则。所有规则都得你提前安排好它才按照规则机械化执行。2. DMA驱动模型与关键参数从寄存器到行为的完整映射2.1 核心参数解剖方向、地址、宽度、长度我们拿STM32来举例其实大部分MCU的DMA控制器模型大同小异。STM32的DMA配置里有几个关键参数我个人把它们称为“DMA四要素”。第一个是传输方向。常见的就三种外设到内存Peripheral to Memory、内存到外设Memory to Peripheral、内存到内存Memory to Memory。前两种最常见分别对应接收和发送第三种在某些场景下也挺有用比如把一段数据从Flash搬到RAM或者把一块内存数据搬到另一块但要注意内存到内存模式下必须显式触发一次启动它不会像外设触发那样自动持续。第二个是源地址和目的地址。很多人搞不清为什么有的配置里外设地址在前有的在后。其实方向确定了源和目的就确定了。比如SPI接收用“外设到内存”那源就是SPI-DR的地址目的就是你定义的接收缓冲区地址。这里有个特别容易踩坑的地方外设地址一般不开启自增因为SPI或者ADC的数据寄存器就那么一个地址不变而内存地址必须开自增否则100个字节全挤到同一个地址里每来一个覆盖一个最后只剩最后一个字节。第三个是数据宽度。字节、半字、字必须和外设寄存器、内存变量类型匹配。如果你用uint8_t的数组去接收SPI发过来的16位数据数据会按字节拆开看起来就是乱码。反过来如果你声明一个uint32_t数组但DMA配置成字节模式那每个元素的高24位永远是0也容易出问题。第四个是传输长度。在STM32的标准DMA里这是指“总共要传多少个数据单元”单位是前面说的数据宽度而不是字节数。每次传输完成后长度寄存器会递减到0所以如果你在循环模式Circular下想查询还剩多少个数据没传读NDTR寄存器就能拿到。这个寄存器是递减的很多人没注意它读出来是“剩余值”而不是“已传值”调试时容易让人困惑。2.2 传输模式单次、循环、突发怎么选除了四要素传输模式也很关键。STM32 DMA的传输模式主要有Normal单次和Circular循环。Normal模式下传完设定的长度就停除非你重新关闭DMA再开启否则它不会自己再次启动。Circular模式则会在传输完成后自动把地址和长度寄存器重载到初始值继续下一次传输非常适合ADC连续采样、串口连续接收这类持续不断的数据流。还有一个经常被忽略的是突发模式Burst。突发模式允许DMA在每次拿到总线控制权时连续传输多个数据单位而不是一个单位就释放总线。这种方式能显著减少总线切换开销提高吞吐量。但代价是占用总线的时间更长如果系统里还有实时性要求很高的中断可能导致中断响应延迟变大。所以到底用不用Burst要看你系统里总线的忙闲程度不能一味追求高性能。选择模式的核心逻辑很简单如果数据流是一次性的用Normal如果数据流是持续不断的用Circular。Circular配上半传输中断和传输完成中断就能实现“双缓冲”的效果这个后面第三节细说。2.3 优先级与流量控制多通道竞争时的“红绿灯”MCU里的DMA控制器往往有多个通道比如STM32F1有7个通道F4有16个流它们共享同一个DMA控制器和系统总线。当多个通道同时被触发时硬件上有一个优先级仲裁机制。优先级从高到低一般依次是硬件优先级通道编号越小越高、软件优先级配置、FIFO中的排队顺序。这里有个特别有意思的点软件配置的优先级只在多个请求同时到达时起作用。如果你的系统里ADC DMA和串口DMA都在跑ADC采样的实时性要求高就把ADC所在通道的优先级配成Very High串口配成Medium。但如果你某个通道的数据量特别大一直占着总线低优先级的通道可能长时间得不到服务数据就丢了。这种情况需要你重新审视传输模式或者调整数据搬移的粒度。Linux内核里其实也有类似的概念dmaengine框架里可以通过struct dma_slave_config设置slave_id、src_addr、dst_addr等本质上和MCU上的配置逻辑是一致的。理解了MCU这套模型再看内核代码里的DMA驱动很多概念能直接迁移过去。3. DMA中断与缓冲区策略真正考验工程能力的地方3.1 半传输中断与传输完成中断循环模式下实现“双缓冲”很多人配置DMA接收时只用传输完成中断Transfer Complete然后在整个缓冲区收满之后才去处理数据。这在高速持续接收场景下有个致命问题数据接收期间缓冲区不能被访问但你根本不知道下一次数据什么时候来等满了再处理处理耗时可能又导致下一轮数据覆盖。经典的解法是半传输中断Half Transfer加传输完成中断配合Circular模式。DMA每次传输完成一半时触发一次半传输中断全部传输完成时触发一次完成中断。这样缓冲区就被硬件天然切成了两半前半段在接收数据时你可以在后半段做处理后半段在接收数据时你在前半段做处理。两个区域交替使用不会互相覆盖这种策略在音频采集、ADC连续采样、高速串口接收里非常常见。具体映射到程序里就是把缓冲区长度设成你要处理的数据块大小的两倍。在中断回调里判断是HTHalf Transfer还是TCTransfer Complete对应处理缓冲区的前半段和后半段。这样每段数据在DMA持续搬运的同时CPU能并行处理上一段数据吞吐量提升非常明显。3.2 环形缓冲与乒乓缓冲缓冲区管理的“战术选择”提到“半传输完成中断”本质上就是乒乓缓冲Ping-Pong Buffer的一种硬件实现。乒乓缓冲的核心思想是两个缓冲区交替工作一个在接收时另一个在处理处理完就交换角色。这种方式逻辑简单但缺点是缓冲区利用率低——同一时间只有一半缓冲区在干接收的活。环形缓冲区Ring Buffer则更灵活。它把一整块内存当成一个首尾相接的环写指针和读指针不断向前推进遇到末尾就回绕到开头。配合DMA的Circular模式硬件自动回绕写指针永远跟着DMA走读指针则由处理代码维护。这样缓冲区利用率从50%提升到接近100%而且理论上可以适应任意大小的数据流。但环形缓冲区也带来一个新的问题数据可能跨越缓冲区末尾与开头形成“分片”。比如你环形缓冲区大小是256字节DMA写指针走到250时你要读的是一段20字节的数据其中10字节在末尾、10字节在开头你处理时就得处理两次。解决方法是要么预留足够空间避免分片要么在处理函数里对分片情况做拼接。实话说如果没有很强的时间紧迫性我更推荐直接用乒乓缓冲逻辑简单不容易出错只有数据量大、连续流持续时间长的场景我才会上环形缓冲区。3.3 中断回调里到底能干什么别把DMA中断当成“万能钥匙”无论是HAL库里的HAL_UART_RxCpltCallback还是Linux内核里的DMA completion callback都有一个共同的禁忌处理时间必须短不能在里面做费时的操作比如打印日志、动态分配内存、加锁等待。原因是中断上下文有优先级你在中断里待得越久其他中断和实时任务就被推迟得越久极端情况下直接造成系统卡死。在裸机开发里我一般只在回调里做两件事置一个标志位或者从一个环形缓冲区把数据拷出来存到自己的内存池。真正的数据处理逻辑放到主循环或者RTOS的任务里去。在Linux内核里这个思路对应的是“顶半部/底半部”机制。DMA完成中断处理函数顶半部只负责做必要的硬件操作然后调用tasklet或者workqueue把耗时的处理放到软中断或进程上下文里执行。如果驱动里没有遵循这个分层在中断处理函数里加了mutex或者大量内存操作长时间运行下来系统迟早会出问题。4. 实操环节从CubeMX到串口DMA接收的完整配置思路4.1 CubeMX/CubeIDE配置DMA的要点以SPI和ADC为例这一节我们来点实在的从工程配置的角度把DMA的设置过一遍。不管用STM32F103、F407还是G474CubeMX里的DMA配置界面会把知识点都浓缩成一排下拉框但很多人只是看着教程选了一串选项并不知道每个选项背后的含义。先以“STM32 SPI通过DMA方式读取外部芯片数据”为例。这个场景最常见的做法是SPI主机发送一个读命令然后连续读取若干个字节的数据。CubeMX里你要做的是在SPI的DMA Settings页面添加两个DMA通道一个RX、一个TX。注意RX的传输方向一定是Peripheral to Memory地址自增选内存地址自增数据宽度要看外部芯片的协议——如果是16位ADC数据比如ADS127L11这种数据宽度要选Half Word接收缓冲区就要声明成uint16_t数组。然后看ADC DMA。以STM32G474的ADC为例你开启ADC的连续转换模式再用DMA把转换结果周期性地搬到内存。这里有个关键点ADC的数据寄存器地址是不变的DMA必须配置成外设地址不自增、内存地址自增数据宽度要和ADC分辨率匹配。很多人在这一步配错导致所有ADC通道的数据都是同一个值其实不是ADC坏了是DMA搬到同一个内存地址去了。CubeMX生成的代码只是个骨架你还要在应用层手动启动DMA传输。比如SPI DMA读取你要在需要读取时调用HAL_SPI_Receive_DMA()然后等待HAL_SPI_RxCpltCallback回调。一定要记得每次DMA传输完成后如果需要再次启动得先调用HAL_SPI_DMAStop再重新调用接收函数否则第二次传输可能不工作这是因为DMA状态机还停留在完成状态没有复位。4.2 串口DMA加空闲中断一套可以“抄作业”的标准套路串口DMA接收是另一个高频需求。只用DMA接收有个痛点DMA是按固定长度搬运的但串口数据是变长的你根本不知道下一条数据有多长。如果把DMA长度配成200字节实际只来了10个字节硬件不会告诉你“只有10个字节到了”它只会傻等第200个字节。解决方案就是DMA加空闲中断IDLE Interrupt。串口在接收完一个字节后如果总线上出现一个字节周期的空闲就会触发IDLE中断。这个中断就像“话音落下”的信号告诉你这一帧数据收完了。配合DMA你可以把接收缓冲区的长度配成最大值比如256字节正常接收时DMA往缓冲区搬一旦检测到总线空闲IDLE中断触发你在中断里读出DMA剩余的未传输数据量通过NDTR寄存器或者HAL库的HAL_DMA_GetCounter用最大长度减去剩余量就是这一帧实际收到的字节数。HAL库里这个流程有对应的APIHAL_UARTEx_ReceiveToIdle_DMA()它的回调函数是HAL_UARTEx_RxEventCallback()参数里直接带接收长度。如果是用标准库或者寄存器开发自己实现也不复杂开DMA接收中断同时开USART的IDLE中断在IDLE中断里关闭DMA、计算长度、处理数据、再重启DMA。这套思路在STM32F103到H7上都通用原理完全一致。4.3 容易被忽略的细节传输宽度与内存对齐再分享一个实战中特别容易翻车的细节内存对齐。DMA在搬运数据时很多MCU的DMA控制器对源地址、目的地址、传输长度都有对齐要求。比如DMA配置成32位宽度传输那么缓冲区地址必须是4字节对齐的长度也最好是4的倍数。如果你的接收缓冲区是用uint8_t数组定义的编译器可能把它分配到任意地址一旦不是4字节对齐在某些型号上DMA传输会直接产生错误。怎么解决最简单的办法是定义缓冲区时用__attribute__((aligned(4)))或放入特定的对齐内存段。在Linux内核驱动里对应的是用DMA API分配缓冲区比如dma_alloc_coherent它天然保证了对齐和一致性。另外要注意数据宽度切换时的“字节序”问题。比如外部SPI设备发来的是两个字节组成一个16位数据如果你的DMA配成字节模式在内存里先收低字节还是高字节取决于外设的发送序和DMA的搬运顺序。这个不在调试日志里仔细比对很容易当成数据错误处理实际上把变量类型从uint8_t改成uint16_t然后做一次大小端转换就解决了。5. Linux内核视角DMA从“寄存器配置”变成了“资源管理”5.1 裸机DMA与内核DMA的本质区别如果你只做过裸机开发理解了上面的内容基本够用。但一旦你把同样的逻辑搬到Linux内核驱动里会发现思路要有一个大转弯。裸机开发里DMA是一个外设你直接操作它的寄存器配置通道、源地址、目的地址启动传输。整个系统里只有你一个“管理员”所有资源都是你的你可以随意配置。但在Linux内核里DMA不再只是“配置寄存器”它变成了一套需要管理的资源。内核里运行着多个进程、多个驱动谁都不能独占某一个DMA通道驱动之间也不能随便访问物理内存。DMA控制器对内核来说是一个共享设备所以内核抽象出了dmaengine子系统统管DMA通道的分配、配置、传输和释放。更重要的区别在于内存管理。裸机开发中你把数组地址直接填到DMA寄存器里就行CPU和DMA访问的都是同一个物理内存没有区别。但在Linux里内核使用的是虚拟内存地址而DMA外设访问的是物理地址。而且CPU有多级CacheCPU读到的是Cache里的数据DMA搬运的却是物理内存里的数据。如果两者不一致就出现了经典的“Cache一致性”问题。5.2 Cache一致性的两种解法一致性映射与流式映射脏数据问题怎么解决Linux内核DMA API给出了两条路。第一条路是一致性DMA映射Consistent DMA Mapping对应API是dma_alloc_coherent()。这个函数会分配一片内存并保证CPU和DMA设备访问这片内存时看到的始终是一致的不需要手动刷新Cache。因为内核在驱动中禁止了这片内存的Cache功能或者通过硬件IOMMU/SMMU做了处理。这个内存区域适合那些CPU和DMA设备会同时访问的数据比如DMA描述符、环缓冲区、控制结构体。好处是简单坏处是分配开销大而且禁止Cache后CPU访问速度会变慢不适合大量数据吞吐。第二条路是流式DMA映射Streaming DMA Mapping对应API是dma_map_single()。它不会禁止Cache只是在传输之前用dma_map_single()做一次地址映射底层会根据需要做Cache clean操作把CPU里Cache的数据刷回内存或Cache invalidate操作让CPU下次读取时从内存重新拿。传输完成后调用dma_unmap_single()做反向操作。什么时候用哪种持久使用、长期存在的缓冲区用一致映射一次性传输、数据流密集的缓冲用流式映射。比如网卡驱动的环形缓冲区就常用dma_alloc_coherent分配而收到的网络数据包大多数用dma_map_single映射传完立刻unmap。这里我想特别说一句很多内核驱动开发者调试DMA传输失败经常忽略一个方向性问题。不同传输方向需要不同的Cache操作如果DMA写内存比如网卡收包CPU在读之前需要invalidate否则读到的可能是Cache里旧的脏数据如果DMA读内存比如网卡发包CPU在写之后、DMA读之前需要clean否则DMA读到的可能是没有刷到物理内存的Cache数据。搞反了数据就会莫名其妙地错乱而且时好时坏极其难排查。5.3 dmaengine框架与中断回调的下半部机制在Linux内核里写DMA驱动你不太会直接操作DMA控制器的寄存器而是使用dmaengine API。申请DMA通道用dma_request_channel()或dma_request_slave_channel()配置传输用dmaengine_prep_slave_single()或dmaengine_prep_dma_cyclic()然后通过dmaengine_submit()提交描述符最后dma_async_issue_pending()启动传输。dmaengine_prep_dma_cyclic()对应前面说的Circular模式非常适合周期性数据流。它的回调机制和裸机中断大同小异每次周期传输完成会调用回调函数。但这个回调是运行在中断上下文还是线程上下文取决于驱动的实现。很多框架代码里会把它绑定到tasklet或workqueue里执行目的就是缩短中断关闭时间避免阻塞系统其他关键任务。对应用层来说DMA完成事件一般会进一步封装成等待队列或completion让read()、mmap()等系统调用能够阻塞等待数据到了再唤醒。这一整套分层下来DMA就从“手动操作的寄存器”变成了一个“可以异步等待的数据源”。6. 常见问题与排查技巧实录6.1 一张实用的DMA排查清单做DMA调试这么久我总结了一个自己的排查清单。遇到问题先从这几个方向查大部分疑难杂症都能定位到。DMA数据错位或乱码数据宽度是否匹配外设寄存器宽度、DMA宽度、内存变量类型三者是否一致地址自增配置内存地址是否开了自增外设地址是否错误地开了自增缓冲区指针是否被编译器优化在Linux里是否缺了volatile或READ_ONCE/WRITE_ONCEDMA传输一启动就报错地址是否对齐字节模式不用管半字模式需要2字节对齐字模式需要4字节对齐。缓冲区是否分配在可访问的内存区域MCU里是否用错数组段Linux里是否用了未经DMA API分配的地址外设时钟是否开启DMA控制器本身也是外设有时钟门控忘了开时钟所有寄存器写进去都没反应。DMA偶尔丢数据是否使用了循环模式却没有用半传输中断导致处理期间数据被覆盖缓冲区处理耗时是否太长超过了DMA传输周期多通道够用是否因为优先级太低被其他通道抢占Cache一致性问题仅Linux流式映射是否在合适的时机调用了dma_map_single/unmap传输方向错位DMA写内存后CPU读之前是否invalidateDMA读之前CPU写之后是否clean是否用了dma_alloc_coherent分配的缓冲区还是自己随便kmalloc了一片内存6.2 几个典型的“事故”复盘第一个是串口DMA接收出现“第一帧正常后面的全乱”。查了很久发现是接收缓冲区长度和DMA配置长度不一致第一次配置了256实际触发空闲中断拿到了20字节然后处理完重新启动接收时漏了重新设置DMA接收长度DMA还停留在上一轮的完成状态再次启动后地址和长度都不对。解决方法是每次重启DMA接收前必须把DMA的Counter归位在HAL库里就是先HAL_UART_DMAStop再HAL_UART_Receive_DMA。第二个是SPI DMA读取外部ADC数据读出来的数值整体偏移了一个字节。后来对比发现SPI时序里主机发送读命令和数据读取是同时进行的主机每发一个字节就会收到一个字节。如果主机先发命令再开启DMA接收就会漏掉命令发送期间芯片返回的第一个字节。正确的做法是MOSI发送命令的同时就启动RX DMA用同一个SPI时钟把返回数据一并收下来。第三个是Linux内核里用dma_map_single映射缓冲区后DMA传输完发现数据还是旧的。这个就属于典型的Cache一致性问题CPU写过数据后没有做clean操作DMA读走了Cache里还没刷到内存的内容。加上dma_map_single(DMA_TO_DEVICE)时的强制刷新就解决了。调试这类问题时别急着质疑DMA控制器先去想想CPU和Cache是不是在“捣乱”。6.3 调试工具和技巧MCU平台上我用得最多的调试手段其实很朴素在DMA中断回调里加一个GPIO翻转用示波器或逻辑分析仪看DMA中断的频率和持续时间。比如你配置1ms触发一次中断但示波器测出来实际间隔是1.5ms那可能说明系统里还有其他高优先级中断在抢占也可能说明你的中断回调处理太慢。串口DMA调试时打印DMA的NDTR寄存器值也很有用。它表示剩余待传输的数据量和“期望值”一对比就能判断DMA是否真正搬运了这么多数据。如果NDTR值和预期不符优先检查触发源和请求信号而不是怀疑DMA本身。Linux平台上用tracepoint或者perf来跟踪DMA事件会更方便。比如perf trace -e dma:*可以查看DMA相关事件。有时候我也会在内核驱动里临时加dump_stack()来确认回调执行的上下文这比盲猜代码快得多。我个人在实际操作中的体会是DMA调试最大的敌人不是硬件而是你“以为”它应该这样工作。一旦你放下这个“以为”老老实实理解DMA的每个参数、每个时序、每次Cache操作问题往往很快就会自己浮出水面。DMA并不神秘它只是一个听话到有点死板的执行者你对它越好——配置越完整、越明确它就越不会给你添乱。
阅读完成 · 觉得有帮助?