深入理解DMA:从STM32到Linux内核的完整实践指南
2026/9/24 4:46:36 网站建设 项目流程

我做了十几年嵌入式,最开始接触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 为什么需要DMA:CPU搬运数据的代价

先说个最简单的场景。你用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连续采样、高速串口接收里非常常见。

具体映射到程序里,就是把缓冲区长度设成你要处理的数据块大小的两倍。在中断回调里判断是HT(Half Transfer)还是TC(Transfer 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还是G474,CubeMX里的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库里这个流程有对应的API:HAL_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有多级Cache,CPU读到的是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_ONCE?

DMA传输一启动就报错

  • 地址是否对齐:字节模式不用管,半字模式需要2字节对齐,字模式需要4字节对齐。
  • 缓冲区是否分配在可访问的内存区域:MCU里是否用错数组段?Linux里是否用了未经DMA API分配的地址?
  • 外设时钟是否开启:DMA控制器本身也是外设,有时钟门控,忘了开时钟所有寄存器写进去都没反应。

DMA偶尔丢数据

  • 是否使用了循环模式却没有用半传输中断,导致处理期间数据被覆盖?
  • 缓冲区处理耗时是否太长,超过了DMA传输周期?
  • 多通道够用,是否因为优先级太低被其他通道抢占?

Cache一致性问题(仅Linux)

  • 流式映射是否在合适的时机调用了dma_map_single/unmap?
  • 传输方向错位:DMA写内存后CPU读之前是否invalidate?DMA读之前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并不神秘,它只是一个听话到有点死板的执行者,你对它越好——配置越完整、越明确,它就越不会给你添乱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询