
1. 先把DMA看成一个“专职快递员”核心原理与驱动价值1.1 DMA到底搬了什么又省了什么DMA全称Direct Memory Access直接内存访问。说白了它就是一条从外设到内存、从内存到外设、甚至内存到内存的高速搬运通路。CPU只需要告诉DMA控制器“从哪里搬、搬多少、搬到哪”然后就可以去处理别的任务搬完数据后DMA发一个中断通知CPU整套流程闭环。我在这个系列里把DMA单独拿出来聊是因为它几乎贯穿了所有外设驱动串口收发可以用它SPI读写可以用它ADC连续采样可以用它并行总线、显示刷新、音视频数据流也离不开它。很多项目的CPU占用率一直压不下来并不是业务逻辑太复杂而是大量时间被耗在“把寄存器里的数据搬到缓冲区”这种没有创造性的活上。用DMA把这部分接走之后CPU才能真正去处理协议、状态机和业务数据所以它解决的其实不只是“快”而是“把CPU空出来”。用生活里的例子来理解DMA就是一个专职快递员。以前每来一个包裹老板都得亲自跑去接、亲手搬到仓库一个两个还行一天几千个就受不了了。DMA方案就是老板给快递员定好规则包裹来了你去搬搬完放指定货架然后按一下铃告诉我就行。老板可以一边谈生意一边等铃声整个仓库吞吐量立刻上去了。驱动开发里也是一样的逻辑。串口每来一个字节就让CPU中断一次和攒了一堆数据让DMA一次搬完性能完全是两个量级。尤其在高波特率串口、多路ADC同步采样、高速传感器数据采集这类场景下没有DMACPU基本就被绑架在数据搬移上别的事都干不了。1.2 驱动里引入DMA的收益与隐藏成本收益很容易罗列。第一CPU占用率显著下降这是最直接的收益。第二吞吐量更稳定因为搬运节奏由硬件控制不像中断一样每次进去还有压栈出栈、代码执行的开销。第三低功耗表现更好CPU可以在DMA搬运期间进入睡眠或者低功耗模式等DMA完成中断再唤醒来处理。第四批量数据处理时例如持续性的传感器采样DMA能保证采样点不丢失这对信号分析类应用非常重要。但DMA不是“外设加个功能”那么简单它是有隐藏成本的。首先是复杂度驱动里必须额外管理缓冲区生命周期、Cache一致性、描述符列表还要小心多通道之间的优先级竞争。其次是调试难度DMA出问题时症状往往是数据错位、数据丢失、缓冲被覆盖这类问题不像普通逻辑BUG一样有明确的调用栈很多时候要拿着寄存器手册一行一行核对。我见过不少项目一开始图省事不用DMA等性能测不过去再硬加结果被一致性和缓冲区问题折磨一两个月。所以是否引入DMA要先算清楚收益和复杂度而不是盲从“标配”。2. DMA驱动设计的第一张图纸数据通路与资源规划2.1 先回答三个问题谁发起、谁搬运、谁通知写DMA驱动之前别急着查寄存器手册。我的习惯是先拿张白纸把数据通路画出来然后回答三个问题谁发起、谁搬运、谁通知。谁发起是指DMA传输由什么触发。一种情况是外设拉出DMA请求比如串口接收到数据后自动申请DMA搬运SPI控制器在TX FIFO空余达到阈值时请求DMA搬下一批数据另一种情况是软件主动触发一次内存到内存的搬运这在大块数据拷贝、图像处理场景里很常见。设备驱动里最常打交道的是前一种理解外设触发条件极其重要。例如STM32的UART会在RXNE置位时产生DMA请求而有些外设是等FIFO里攒够一定数量才请求DMA。这些触发条件直接决定了你要不要开启FIFO、要不要设置阈值。谁搬运就是DMA控制器本身。你需要知道自己平台上有几组DMA每组有多少通道每个通道能连接哪些外设请求通道优先级如何支不支持链式描述符支不支持循环模式。芯片不同这些差异非常大。比如STM32有DMA1和DMA2两套控制器每套若干通道每个通道绑定的外设请求是固定的GD32大体结构类似但请求映射完全不一样HC32F460和AT32也各有各的DMA模块寄存器风格接近但细节不同。最后确认的依据永远是具体芯片的数据手册和参考手册不能靠“我从STM32项目移植过来”的经验惯性。谁通知就是传输完成之后如何让驱动知道。绝大多数DMA都支持完成中断也有一部分支持错误中断、半传输中断。这里要决定使用中断还是轮询以及回调函数里要做什么。比如串口DMA接收用循环模式时我通常会开半传输中断和完成中断这样驱动程序可以分成两段消费缓冲区一段正在被DMA写另一段由CPU处理互不干扰。还有一个容易被忽略的点是通道优先级。多个DMA通道同时请求时谁的优先级高谁先被服务。如果高优先级通道不停搬运低优先级通道可能长期被饿死这在多外设并发场景下尤其危险。所以设计驱动时我会先列出系统中所有DMA使用者的优先级明确哪些外设对实时性要求高哪些可以稍微等一下再在驱动里分配通道优先级而不是全部配成默认值。2.2 缓冲区和描述符的约定方式DMA驱动本质上是在三方之间做约定CPU、DMA控制器、外设。这三方必须对缓冲区地址、长度、数据宽度、突发长度完全一致的理解否则就会出各种诡异问题。缓冲区这一层要决定用静态数组还是动态分配用普通内存还是一致性内存用一块连续大缓冲区还是让多个块通过描述符串起来。在很多芯片上DMA控制器支持表或者链表模式把一组传输任务放进描述符表DMA搬完一段后自动从内存里取下一个描述符继续搬CPU只需要一次性提交一串任务。这个能力在网络驱动、图像采集驱动里几乎是标配特别是要搬运非连续内存块时链式描述符能节省大量重配寄存器的时间。然后要约定数据宽度。串口是8位数据SPI可能是8位、16位或32位外部ADC可能是24位打包在某个格式里。DMA搬运单位如果配错整个数据流都会错位。这里尤其值得注意的是外设地址和内存地址的数据宽度可以不同许多DMA支持把外设的窄数据打包成内存里的宽数据或者反过来拆包。这个能力很强大但对配置要求极严格FIFO阈值、源地址宽度、目的地址宽度、突发长度四者必须匹配。突发长度也值得展开。burst决定DMA一次连续从外设或内存拿多少个数据再释放总线。burst太小总线事务频繁效率不高burst太大要求外设能连续提供那么多数据否则会等待甚至出错。以SPI读ADC为例如果ADC通过SPI返回的数据并没有准备好连续突发却把burst设得很大DMA就会在SCLK上读出无效数据。所以我配置burst时一定会回头确认外设的FIFO深度以及它实际的响应速度。2.3 不同芯片的DMA控制器差异这个坑值得单独写一段因为太多工程师换平台时在这里摔过。STM32体系里DMA配置通常围绕几个核心寄存器控制寄存器CCR、传输数量寄存器CNDTR、外设地址寄存器CPAR、内存地址寄存器CMAR。流程大致是禁止通道、按需配置方向/地址递增/位宽/循环模式/优先级、设置传输数量、使能通道。GD32的结构类似但寄存器位域和名字不完全一样例如GD32用CTL寄存器而不是CCR。AT32虽然很多外设是兼容ST的但DMA请求映射表经常有自己的编号方式。HC32F460更接近瑞萨风格它的DMA配置需要格外注意标志位清除顺序和请求源使能。一个我亲自踩过的例子某次移植GD32的串口DMA代码直接套STM32的寄存器定义结果发现DMA根本不触发。查了一圈才意识到GD32在启动DMA之前需要在相应外设的配置里单独使能“DMA请求输出”而STM32有些外设是默认就把请求信号接过去的。这种平台差异只有在认真读参考手册时才能发现靠经验移值是移不出来的。还有中断标志清除问题。有些DMA控制器要求软件写1清除完成标志有些是硬件自动清除有些甚至要求必须按“先读状态、再清标志”的顺序操作。如果漏掉了清除操作轻则中断风暴重则后续传输再也进不了中断。所以我换平台后的第一件事就是在初始化里把状态寄存器所有位读一遍再把所有标志位写一遍清除确保DMA控制器处于一个已知的干净状态然后才去配置具体传输。3. 实操一个DMA驱动串口接收的完整实现3.1 最简单的驱动舞台从uart_rx开始为什么拿串口接收来说事因为它足够简单、人人都熟悉又具备DMA驱动几乎全部的必要元素外设产生数据、DMA搬运到内存、传输完成触发中断、驱动在回调里把数据交给上层。把串口DMA搞明白SPI DMA、ADC DMA都是同一个套路。在Linux下使用DMA engine子系统的串口接收驱动大致分四步第一步请求DMA通道第二步分配缓冲区第三步配置外设和DMA的地址、宽度、方向第四步提交传输描述符并启动。下面是一段根据常见内核API整理的最小骨架不同内核版本的API略有差异这里采用比较新的写法。struct uart_dma_rx { struct dma_chan *chan; void *buf; dma_addr_t dma_phys; size_t buf_size; struct dma_async_tx_descriptor *desc; dma_cookie_t cookie; }; static int uart_dma_rx_setup(struct uart_dma_rx *rx, struct device *dev, dma_addr_t periph_addr, size_t buf_size) { struct dma_slave_config cfg; int ret; /* 1. 请求DMA通道和设备树里的dmas属性对应 */ rx-chan dma_request_chan(dev, rx); if (IS_ERR(rx-chan)) return PTR_ERR(rx-chan); /* 2. 分配一致性内存CPU访问地址和DMA物理地址同时拿到 */ rx-buf_size buf_size; rx-buf dma_alloc_coherent(dev, buf_size, rx-dma_phys, GFP_KERNEL); if (!rx-buf) { dma_release_channel(rx-chan); return -ENOMEM; } /* 3. 配置外设侧参数 */ memset(cfg, 0, sizeof(cfg)); cfg.direction DMA_DEV_TO_MEM; cfg.src_addr periph_addr; cfg.src_addr_width DMA_SLAVE_BUSWIDTH_1_BYTE; cfg.src_maxburst 16; ret dmaengine_slave_config(rx-chan, cfg); if (ret) { dma_free_coherent(dev, buf_size, rx-buf, rx-dma_phys); dma_release_channel(rx-chan); return ret; } /* 4. 用循环模式提交只要不停止就会自动翻转到下一半 */ rx-desc dmaengine_prep_dma_cyclic(rx-chan, rx-dma_phys, buf_size, buf_size / 2, DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT); if (!rx-desc) { dma_free_coherent(dev, buf_size, rx-buf, rx-dma_phys); dma_release_channel(rx-chan); return -ENOMEM; } rx-desc-callback uart_dma_rx_callback; rx-desc-callback_param rx; dmaengine_submit(rx-desc); dma_async_issue_pending(rx-chan); return 0; }这里有几个关键点一定要展开。第一dma_request_chan通常和设备树配合使用。设备树里声明了dmas和dma-names驱动按名字拿通道。比如serial40010000 { dmas dma1 4 2; dma-names rx; };这样同一个驱动在不同板卡上可以复用不需要为每一块板子改代码。如果你使用更老的SDK可能会看到dma_request_slave_channel这个接口它和dma_request_chan的功能类似但新代码推荐使用dma_request_chan。第二dma_alloc_coherent返回的rx-buf是CPU的虚拟地址rx-dma_phys才是DMA控制器应该看到的设备地址或者说是总线地址。新手最常见的错误就是手动调用virt_to_phys(rx-buf)来计算物理地址这在简单平台上可能碰巧是对的但一旦有SMMU/IOMMU或者更复杂的地址映射就等于把数据写到了错误的物理地址上。凡是DMA引擎提供的dma_alloc_*接口返回的设备地址一定以接口返回为准不要自己去算。第三循环模式dmaengine_prep_dma_cyclic很适合串口接收这种持续到达的数据流。缓冲区被切成两半或者更多段DMA搬满一段就触发一次回调驱动处理这一段数据时DMA可以继续往另一半写。这就是典型的生产者-消费者模型而且不需要为每个字节都产生CPU中断。缓冲区大小通常设计成2的整数倍我习惯给串口设成1KB或者4KB根据波特率计算填满半段的时间保证上层处理的时候没有优先级反转就行。3.2 裸机/RTOS下的实现思路与注意点如果你不在Linux下开发而是在裸机或者RTOS上写串口DMA逻辑是一样的只是把API操作换成直接写寄存器。以常见MCU为例步骤包括使能外设的DMA请求、配置DMA方向、设置外设地址和内存地址、写入传输数量、配置数据宽度和优先级、使能DMA通道。难点不在第一步配置而在“传输完成后如何处理下一次传输”。很多MCU的DMA在单次模式下传输完成之后长度寄存器会归零通道自动关闭。你必须在完成中断里重新填入长度并再次使能。如果从“完成中断产生”到“重新使能DMA”之间有空窗这个空窗里到达的数据就丢了。在高速串口下这可能只有几个微秒。所以我强烈建议只要硬件支持循环模式就优先用循环模式不要再退回到单次模式里折腾重新装载逻辑。另一个容易翻车的地方是错误中断。很多DMA控制器有传输错误、FIFO溢出、总线错误这些状态位一旦置位而没被处理DMA控制器可能进入异常状态后续你不管怎么重新配置它都不干活。我写DMA中断服务函数时有个固定习惯进入ISR第一步就把状态寄存器的全部标志读出来逐个确认然后全部写清除最后再判断到底是什么中断触发的。这样能避免残留标志导致的反复进入也能第一时间发现错误位。还有一个细节是外设数据寄存器地址。配置DMA时填写的periph_addr必须是外设的数据寄存器地址而不是控制寄存器或状态寄存器地址。这个看似不会错但遇到同一外设有多组FIFO时很多人会填错偏移。我在项目里会让硬件工程师把原理图里对应外设的寄存器地址表打印一份配置时对着填宁可慢一点也不要上来写个偏移量就完事。4. Cache一致性DMA驱动九成的坑都在这4.1 为什么DMA会读到“旧数据”Cache一致性这个话题很多刚接触Linux驱动或者Cortex-A平台的人会撞得头破血流。明明CPU刚把一个字节写进缓冲区DMA这边读到的却是旧值又或者DMA明明已经把外设数据写进内存了CPU一读发现还是空数据。原因在于CPU和DMA看到内存的方式不一样。CPU写内存时通常先写进Cache之后才把缓存行回写到物理内存而DMA控制器通过总线直接读写物理内存完全绕过了CPU的Cache。于是两边看到的物理地址虽然相同内容却可能不一样。这个问题在带D-Cache的Cortex-A系列上几乎一定会出现在某些MCU上因为内部有总线缓冲或写缓冲也会表现出类似症状。我调试过一次SPI读温度传感器的小概率错值问题从同一个0x2000A000地址读出来的数据十次里有九次正确一次是上一次的旧值。抓寄存器又看不出异常最终定位到CPU在启动DMA之前没有把Cache里的脏数据刷回内存DMA读到的就是物理内存里的旧内容。这种偶发问题最折磨人跑一整天可能就错一两回而且只在特定时序下复现。从那以后凡是涉及CPU和DMA共享缓冲区的代码我都会先过一遍一致性处理而不是等出了问题再去测试台上碰运气。4.2 Linux内核的处理API与典型用法Linux内核提供了一套DMA映射API来保证一致性核心思路其实就是在不同使用阶段明确维护Cache。我在3.1里用的dma_alloc_coherent就是最省心的一种它分配的内存本身处于一致映射区域CPU访问和DMA访问看到的是同一份内容不需要每次手动同步。这类内存适合驱动长期持有、频繁被DMA读写的数据缓冲区。但很多实际场景中缓冲区不是驱动自己分配的而是上层传下来的例如网络协议栈的SKB缓冲区、文件系统的页缓存。这些内存不是一致性内存使用DMA之前就要做动态映射。dma_addr_t dma_handle; struct device *dev ...; dma_handle dma_map_single(dev, cpu_addr, len, DMA_TO_DEVICE); if (dma_mapping_error(dev, dma_handle)) { /* 映射失败处理 */ } /* 交给DMA搬运 */ dma_unmap_single(dev, dma_handle, len, DMA_TO_DEVICE);这里DMA_TO_DEVICE表示CPU写缓冲区、DMA去读映射后驱动需要确保已经写入的数据刷出CacheDMA才能读到最新内容。DMA_FROM_DEVICE表示DMA写缓冲区、CPU读DMA传输完成后CPU必须使相应的Cache行失效才能看到DMA写入的新数据。DMA_BIDIRECTIONAL则两者都做最简单也最保守但开销最大。如果你像我一样做外设驱动还需要经常和dma_sync_single_for_cpu、dma_sync_single_for_device打交道。比如这种场景DMA循环模式一直在往一块映射过的缓冲区写数据上层需要周期性地拿一小段。你在读之前调用dma_sync_single_for_cpu使Cache失效读完后再调用dma_sync_single_for_device把缓冲区重新交给DMA。这类代码要特别注意同步范围每次只同步你真正要读的那一小段不要动不动把整块缓冲区刷一遍。4.3 MCU带D-Cache时的处理方式很多工程师以为Cache问题只存在Linux和高级应用处理器上其实带D-Cache的MCU同样躲不开。Cortex-M7内核以及部分带缓存功能的RISC-V MCU一旦在启动文件里打开了D-Cache再用DMA搬运数据立刻就能看到“DMA数据不更新”的经典现象。处理方法其实不复杂。以外设往内存搬运数据为例DMA完成中断触发后CPU读缓冲区前需要先把对应地址范围的Cache行失效。在Cortex-M7上通常是这样SCB_InvalidateDCache_by_Addr((uint32_t *)buffer, len);反过来如果是CPU往缓冲区写数据再让DMA往外设搬运CPU写完以后就要马上清理对应地址的CacheSCB_CleanDCache_by_Addr((uint32_t *)buffer, len);这里有一个顺序特别容易踩雷Invalidate必须放在“DMA写完之后、CPU读之前”。失效操作会丢弃当前Cache里缓存的内容然后从物理内存重新取值。如果你顺序搞反先把Cache失效了DMA再把新数据写进内存那没问题可如果你是CPU先读了一遍再去失效那就把内存里的新数据干掉了缓冲区里永远是旧东西。我在项目里见过有人把Invalidate和Clean来回颠倒配了好几版最后才发现是顺序问题。如果缓冲区没有和Cache Line大小对齐这里还会衍生出另一个坑。一个Cache Line通常是32字节或者64字节如果缓冲区横跨两个Cache行操作的时候可能会把相邻区域的数据也一起失效或者清理掉造成相邻变量的值“莫名其妙变空”。所以我有一个硬性习惯所有DMA缓冲区的起始地址都按平台最大Cache Line对齐长度也尽量补成整数个Cache Line宁肯多浪费几十个字节也不去冒“跨行误伤”的险。5. 问题排查实录与经验速查5.1 常见症状与排查路线DMA驱动一旦出问题症状通常比普通逻辑BUG更“硬”数据完全不动、数据成段丢失、数据错位、完成中断不来或者来个不停。我整理了一张自己常用的排查表按优先级排列症状优先检查方向补充说明DMA完全不工作时钟、通道映射、寄存器使能位用调试器看DMA控制寄存器是否真的配置成功数据总是旧值Cache一致性检查是否在正确时刻做了clean/invalidate数据少了几个字节长度寄存器、外设FIFO标志时序确认是否在使能DMA前清掉残留数据数据错位数据宽度、突发长度、FIFO阈值高位低位配置不一致经常导致错位完成中断不来中断使能、标志清除方式有的芯片要求先清标志再重新装载中断风暴标志没清除、循环模式没有停止在ISR里先读再清杜绝遗留位缓冲区被覆盖回调并发、锁缺失、半满处理太慢中断和正常上下文同时操作buffer一定出问题排查顺序上我的经验是先确认DMA功能通不通再优化效率。先看DMA通道有没有被触发、长度寄存器有没有在递减、完成标志有没有置位这些都有确定性答案。如果基础流程都没问题再怀疑Cache一致性和外设时序。很多人一上来就怀疑是Cache问题结果折腾半天发现是串口FIFO里残留了一个垃圾字节那个冤枉劲儿就别提了。5.2 两个实战排查案例先说一个串口DMA接收整体错位的案例。某MCU平台上串口接了一组传感器数据预期输出是“55 01 02 03 04”实际收到的是“AA 55 01 02 03”也就是说整体往后错了一个字节。当时第一反应是DMA地址配置错了一位后来把串口外设的FIFO寄存器全部读了一遍发现上电瞬间数据寄存器里已经有残留字节。DMA开始搬运时把这个残留当成了第一个有效数据也搬进缓冲区于是所有数据往后错一格。解决方法是在使能DMA前先做一次清空操作把接收状态和FIFO里的残留数据全部清掉。这种坑在复位和热插拔时最容易复现初始化序列里固定加一次清除就不会再出现。另一个案例是Linux下通过SPI DMA读取高精度ADC的数据8次采样里总有一次高几位突然跳变。从DMA传输数量、中断频率来看完全正常后来拿逻辑分析仪对比SCLK、片选和数据引脚的时序才发现片选拉低之后到SCLK开始拉高之间几乎没有延时ADC还没来得及把转换结果稳定到输出寄存器DMA已经按照SPI协议开始读数据了。解决方法是调整SPI的时序参数在片选建立后增加一小段延时再让SCLK开始工作。这类问题的核心是DMA本身没有错错在“数据提供方还没准备好DMA就去搬了”。所以排查DMA问题时不要只盯着DMA控制器还要回头看外设的数据有效时间。5.3 从布局上减少DMA bug的几个习惯这一节算是我做了多年DMA驱动后沉淀下来的经验供各位参考。第一先做内存到内存验证。无论什么平台拿到DMA驱动之后先做一个memcpy式测试把一块已知内容的内存搬运到另一块确认长度、内容完全一致再接入实际外设。这么做的好处是把“DMA控制器的配置问题”和“外设时序问题”彻底分隔开。如果内存到内存都不通那不用怀疑外设问题一定在DMA配置上。如果内存到内存通、接外设就不通那重点看外设请求信号、FIFO阈值、时序参数。第二缓冲区对齐到Cache Line。在Linux里关键缓冲区尽量用dma_alloc_coherent或者让上层分配的缓冲区天然满足对齐。在MCU上缓冲区地址至少要保证32字节对齐最好64字节对齐。这个习惯在项目后期会救你很多次因为Cache行对齐的缓冲区做clean/invalidate时不会误伤邻居。第三回调函数越短越好。DMA完成回调里只做三件事确认哪个半段完成、把半段数据提交给上层、准备下一次提交。协议解析、数据处理、日志打印都不要放在回调里否则回调执行时间过长DMA可能已经开始写同一个缓冲区的下一段了。等竞态问题出现时你查起来会比DMA配置错误难十倍。第四善用调试手段。SPI/I2C这类有同步时钟的总线逻辑分析仪几乎必备裸机上用JTAG直接看寄存器和内存最有效Linux下可以用/proc/interrupts确认中断频率用devmem观察寄存器还可以在驱动里临时加tracepoint。DMA是硬件行为光靠日志猜是很慢的眼见为实才是正道。最后分享一个看起来笨但非常实用的习惯每次写完DMA初始化代码先把所有相关寄存器读出来按位对照手册确认一遍再往下写业务逻辑。这个动作只要花几分钟但能堵住几十种低级错误入口。等DMA跑通之后再回头优化代码结构和性能你会感觉整个过程顺畅很多。DMA这个东西重要的不是把它配置出来而是从一开始就留足规矩让所有数据通路上的协作方都按同一个约定工作。