做嵌入式驱动开发这些年,DMA(Direct Memory Access,直接内存访问)是我觉得最值得在项目早期就啃透的外设之一。串口、SPI、SDIO、以太网、ADC采样,凡是和“批量数据传输”沾边的驱动,最后几乎都要和DMA打交道。第6期就把DMA从硬件行为到Linux驱动框架、从Cache一致性到带宽测速的完整链路梳理一遍,把我踩过的坑和验证过的方法一并写出来。这篇内容适合正在写Linux字符驱动、搞STM32外设驱动,或者准备嵌入式驱动岗位面试的朋友,无论你用的是什么内核版本,这套分析思路基本通用。
1. 为什么DMA是驱动开发的“硬骨头”
1.1 先搞清楚DMA到底解决了什么问题
很多新手看DMA的教材,上来就是“DMA可以在不占用CPU的情况下搬运数据”,这句话没错,但容易让人忽略背后真正要命的东西:DMA不是简单的“数据搬运工”,它是一套独立的存储访问子系统,拥有自己的总线仲裁逻辑、传输描述符、中断和错误状态。你在驱动里配置DMA,本质上是把这个外设的数据通路整体托管给一个硬件代理,CPU只在传输开始和结束时介入。
嵌入式场景里DMA最常见的三种用途大家应该都熟:外设到内存(比如UART收数据、ADC采集结果)、内存到外设(比如SPI发送、DAC输出)、内存到内存(比如图像数据搬移、加密引擎前的数据重排)。但这三种用途对驱动代码的要求完全不同。外设到内存最常见也最容易出问题,因为你不知道硬件什么时候会丢数据、丢多少,缓冲区管理稍有不慎就是踩内存,查起来极其痛苦。我做过一个采集项目,ADC在1MHz采样率下持续出数,DMA每收满一帧就中断一次,驱动里要保证在下一帧到来之前把当前帧的拷贝完成,否则要么丢数据,要么覆盖未处理的缓冲区。
1.2 DMA让驱动架构复杂了一个维度
没有DMA的时候,驱动的数据通路是线性的:CPU读寄存器或FIFO、判断状态、搬数据、再循环。有了DMA之后,数据通路变成了并行的:CPU在配置好DMA描述符之后就可以去干别的,传输的完成由中断或者轮询标志位来通知。这个并行意味着驱动里天然需要处理异步、并发和时序竞争。
举个最简单的例子:你在UART接收驱动的中断里调用readl读FIFO,数据还在硬件层。而DMA接收是硬件在后台往你指定的缓冲区写,CPU完全不知道数据什么时候写入,只有DMA的完成中断能告诉你“已经收了N个字节”。这中间就引出了DMA驱动开发的核心矛盾:CPU视角的内存内容和DMA正在写入的内容之间,存在时间差和一致性风险。这个矛盾延伸到Linux下就是Cache一致性、DMA屏障、dma_map和dma_unmap的语义问题;在裸机环境下就是DMA缓冲区首地址对齐、总线上是否有其他主设备干扰、内存屏障指令是否要加。
所以,如果你只想“让数据能通”,不一定会碰见这些坑;但一旦你要做量产级的驱动,要处理高吞吐、长时间运行、多通道并发,DMA的每一个细节都会被放大成事故。这篇就以Linux内核的DMA engine框架为主线,穿插裸机场景(STM32那类)作对比,把驱动开发的完整逻辑串起来。
2. DMA驱动框架的核心结构设计
2.1 Linux内核DMA engine的层次拆解
Linux的DMA子系统不是一上来就能request_irq这么简单,它分成三层:DMA控制器驱动层、DMA engine核心层、client使用层。控制器驱动层负责和具体硬件打交道,比如PL330、DW_APB_DMAC、STM32的DMA1/DMA2,这一层把硬件的通道、描述符、中断、寄存器操作抽象成struct dma_device。核心层提供通用接口和状态机管理,维护通道的分配、释放、传输请求的排队。client层就是我们普通驱动开发者主要接触的,通过dma_request_channel和dmaengine_prep_*来发起一次传输。
理解这个层次特别重要,因为它在设计上决定了驱动代码的职责边界。你在自己的驱动里不应该去手动修改DMA控制器的寄存器,而是要调dmaengine_*的API,把“怎么操作硬件”的细节交给控制器驱动。这样做带来的直接好处是代码可移植性:换一颗SoC,只要DMA控制器驱动适配好了,你的业务驱动改动量很小。我在项目里换过主控,业务驱动里只改了设备树里的DMA通道编号和请求号,其余逻辑原封不动。
设备树里通常这样描述一个DMA client:
&uart3 { dmas = <&dma0 22 0x4000 0x0f>; dma-names = "rx", "tx"; };这里dma0引用了DMA控制器节点,22是DMA请求线编号,0x4000是传输配置标志,0x0f是突发长度。这一行的含义比看起来复杂得多,后面我会专门展开突发长度和传输配置怎么选。
2.2 从client视角看一次DMA传输的生命周期
一次典型的DMA读(外设到内存)请求,驱动里会按这个顺序调用接口:
struct dma_chan *chan = dma_request_chan(dev, "rx"); struct dma_slave_config cfg = { .direction = DMA_DEV_TO_MEM, .src_addr = (phys_addr_t)uart_phys_base + UART_RX_FIFO, .src_addr_width = DMA_SLAVE_BUSWIDTH_1_BYTE, .src_maxburst = 4, }; dmaengine_slave_config(chan, &cfg); struct dma_async_tx_descriptor *desc = dmaengine_prep_slave_single(chan, buf_phys, len, DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT); desc->callback = rx_done_cb; desc->callback_param = priv; dmaengine_submit(desc); dma_async_issue_pending(chan);这一步一步写下来逻辑很清楚,但如果你把它和裸机场景对应着看就更好理解了:dma_request_chan相当于选通道,dmaengine_slave_config相当于设置源地址、目的地址、数据宽度、突发模式,dmaengine_prep_slave_single相当于构建一个描述符(对应硬件的DMA描述符表项),dmaengine_submit是把描述符挂到通道的pending队列,dma_async_issue_pending是真正触发硬件开始搬运。
很多人在写的时候容易忽略desc->callback的执行上下文。DMA完成中断跑在硬中断上下文里,回调函数也是在驱动层注册的那个tasklet或者线程化中断里执行。如果你直接用desc->callback,它是在DMA控制器驱动的中断处理流程里被调用的,不能在里面睡眠,不能调用可能睡眠的函数,比如mutex_lock、kmalloc带GFP_KERNEL、copy_to_user。我见过同事在回调里用wait_event_interruptible等用户进程,结果一收数据整个中断就卡死,系统看起来像是DMA吞了中断,其实就是回调阻塞了。
注意:回调里只做轻量级操作,比如把缓冲区挂到工作队列、置一个标志位、唤醒
wait_queue。重量级的拷贝、解析、协议处理放到workqueue或者tasklet里做。
2.3 裸机场景(STM32)的DMA配置思路
Linux下的框架很成熟,但裸机开发的思路其实是一样的,只是没有现成的API给你调。以STM32F1系列为例,DMA1和DMA2各有多个通道,每个通道可以绑定不同的外设请求。比如USART1_TX对应DMA1通道4,USART1_RX对应DMA1通道5,ADC1对应DMA1通道1。配置时核心要决定这几组寄存器:通道配置寄存器(方向、外设/内存地址递增、数据宽度、中断使能)、传输数量寄存器(要搬运多少个数据单元)、外设地址和内存地址寄存器。
一个典型的USART1接收DMA配置,代码骨架如下:
RCC->AHBENR |= RCC_AHBENR_DMA1EN; DMA1_Channel5->CPAR = (uint32_t)&USART1->DR; DMA1_Channel5->CMAR = (uint32_t)rx_buf; DMA1_Channel5->CNDTR = RX_BUF_LEN; DMA1_Channel5->CCR = DMA_CCR_MEMINC | DMA_CCR_PSIZE_0 | DMA_CCR_MSIZE_0 | DMA_CCR_PL_1 | DMA_CCR_TEIE | DMA_CCR_TCIE | DMA_CCR_EN; USART1->CR3 |= USART_CR3_DMAR;这段配置里最容易忽略的是MEMINC这个位。外设地址(CPAR)固定,内存地址(CMAR)每搬运一个数据单元自动递增,这就是DMA接收能连续往缓冲区写数据的原理。如果你忘了置MEMINC,DMA会一直往缓冲区的同一个地址写,收到的数据全部互相覆盖。
裸机开发里DMA的中断服务函数要自己做状态机判断:
void DMA1_Channel5_IRQHandler(void) { if (DMA1->ISR & DMA_ISR_TCIF5) { DMA1->IFCR |= DMA_IFCR_CTCIF5; received_len = RX_BUF_LEN - DMA1_Channel5->CNDTR; // 处理接收数据 } }这里CNDTR寄存器是实时递减的,传输完成后用缓冲区总长度减去剩余计数,得到实际接收的字节数。这个寄存器在传输过程中可以随时读,拿来判断当前收了多少字节是调试利器,但在传输还没完成时读到的值仅代表“剩余多少个单元”,不能直接当字节数用,要乘上数据宽度。
3. 理解DMA的硬件行为与边界条件
3.1 数据宽度、突发长度和FIFO的匹配关系
DMA控制器不是逐字节搬运的,它可以从源端读取一批数据攒到自己的FIFO里,再一次性写入目的端,这批数据的单位叫“突发长度”(burst)。突发长度直接影响总线占用效率和传输带宽,也和外设FIFO深度息息相关。外设的FIFO深度决定了DMA的burst不能大于FIFO能容纳的数据量,否则数据会溢出丢失。
举个实际参数计算的例子:某SPI外设的RX FIFO深度是16字节,DMA接收方向配置的数据宽度是1字节,突发长度最多可以配16,也就是每次从外设FIFO拿出来16个字节再写内存。但如果你突发长度配了32,硬件行为就变成DMA一次性从外设搬32字节,外设FIFO只有16,必然导致FIFO溢出,收到的数据错乱。
在Linux下,src_maxburst和dst_maxburst就是给控制器驱动提示这个限制的,但最终还是要看DMA控制器驱动怎么解释这两个字段。有的驱动会把burst转换成分散/聚集描述符里的DMA_SLAVE_BUSWIDTH_*和MAX_BURST项,有的直接就忽略。所以我的经验是:配置burst的时候,先查datasheet里外设FIFO深度和AHB/APB总线位宽,再对着驱动源码看一眼硬件寄存器到底怎么映射burst字段,不要凭感觉填。
bus宽度和数据宽度也要匹配。比如外设寄存器是16位的,你把src_addr_width配成4字节,DMA会做非对齐的32位访问,很多外设FIFO是16位映射到AHB总线上的,直接按32位访问会把高16位读到错误的值。反过来,内存端的宽度建议不小于外设端,否则DMA一次只能挪半个字,带宽上不去。
3.2 缓冲区对齐和Cache一致性:驱动开发最隐蔽的坑
DMA是直接访问物理内存的,这带来了两个基本约束:缓冲区物理地址必须连续,缓冲区在CPU视角的Cache内容必须和设备视角一致。第一个约束好理解,驱动里要么用dma_alloc_coherent分配一致性内存(保证物理连续且Cache相干),要么用dma_map_single映射一块已有的内核缓冲区。第二个约束就没那么直观了。
现代CPU都有多级Cache,CPU写数据后会留在Cache里,而DMA绕过Cache直接读内存,那DMA看到的就是旧数据。反过来,DMA往内存写了新数据,Cache里还是旧副本,CPU一读就读到脏数据。这就是DMA和Cache的一致性问题。解决思路有两条:一是直接把缓冲区所在的地址空间配置为不可Cache(IO内存或强序内存),dma_alloc_coherent就是这么干的;二是在合适的时间点手动做Cache的clean和invalidate,dma_map_single配合dma_sync_single_for_device和dma_sync_single_for_cpu就是做这个事情。
我见过最典型的错误是这样的:内核驱动里用kmalloc分配了一块缓冲区,然后直接把虚拟地址丢给dmaengine_prep_slave_single,没做任何map操作。小数据量的时候跑得好好的,一旦数据量大了、CPU把缓冲区一部分留在L2 Cache里,DMA收到的数据就是花屏、乱码、偶尔丢字节的随机故障。这种bug的排查难度极高,因为复现条件不稳定,和数据量、CPU负载、Cache替换策略都相关。
实操心得:凡是DMA要读写的缓冲区,统一用
dma_alloc_coherent或者dma_pool分配,少用kmalloc+手动sync,省心。只有对性能极度敏感、想靠Cache命中提速的场景,才考虑手动同步,但要做好每一处dma_sync_*的配对。
3.3 描述符机制:内存到内存传输的隐藏依赖
现在的DMA控制器基本都是描述符驱动的,裸机时代的寄存器式配置已经被替代了。描述符里包含源地址、目的地址、传输长度、突发配置、中断使能、下一个描述符指针。DMA控制器自动从内存里加载描述符,执行完一个就跳下一个,直到链尾。
描述符放在普通内存里,和普通数据缓冲区一样要保证一致性。Linux的dmaengine_prep_dma_memcpy会返回一个描述符,提交之后硬件可能立即开始读这个描述符,所以不要再修改描述符的内容,除非你重新初始化一个。另一个坑是描述符池耗尽:dmaengine_submit之后描述符是在队列里排队,如果提交速度大于DMA执行速度,描述符迟早用完,再提交就会返回错误或者空指针。我在一个网络驱动里遇到过,性能压测一跑,dmaengine_prep_*返回NULL,直接就oops了,后来在提交前加了描述符数量和队列深度的检查,才彻底稳定下来。
裸机项目里如果DMA控制器不支持描述符链表,就只有单次传输模式,高频率外设中断场景下性能瓶颈明显;所以选型时如果明确知道要做高吞吐,优先选带多描述符FIFO的控制器,比如STM32F4以上系列或带PL330的SoC。当然这是硬件选型问题,驱动开发层面要做的就是先吃透控制器的描述符管理方式。
4. 串口DMA与SPI DMA的驱动实操与验证
4.1 串口DMA接收的环形缓冲区设计
串口本身是低速外设,但DMA接收能极大降低CPU中断频率,尤其是在115200bps、每秒钟约1万字节的连续数据流场景下。常规思路是:DMA接收方向配一个比较大的缓冲区,比如4096字节,DMA每收满一次触发中断,驱动里把数据拷贝到协议解析缓冲区,然后重新提交下一次DMA传输。
这个方案有个明显问题:DMA在缓冲区收满之前不会触发完成中断,如果串口数据不是刚好凑够4096字节才停,那最后一小段数据会一直滞留在DMA缓冲区里,要等下一次数据到来把它顶出来。这个问题叫“DMA残留数据”。解决办法通常有两种:一是开DMA的半传输中断和传输完成中断配合,半中断一到来就把前一半拿走,完成中断把后一半拿走,但这样对缓冲区大小和数据包长度有匹配要求;二是用定时器兜底,每次收到DMA中断后启动一个短定时器,如果在一段时间内没有新的DMA中断但缓冲区里还有数据,就手动flush。第二种更通用。
环形缓冲区的经典实现里,读指针和写指针分别由CPU和DMA维护。CPU在DMA完成中断里更新读指针,DMA硬件自己维护写指针(通过CNDTR倒推)。关键点在于:CPU侧要时刻记得DMA还在往缓冲区写,不能把读指针追到写指针之前。裸机开发里容易犯的错误是,在DMA完成中断里直接把整个缓冲区拷贝走,但DMA可能已经开始了下一轮写入。
Linux下串口子系统其实已经帮我们把DMA的这些逻辑封装在serial_core里了。普通的tty驱动开发者通常不直接碰dmaengine接口,而是改8250或者全志、瑞萨的串口驱动里的DMA回调。但我建议即使不写串口底层,也可以把环形缓冲区的设计思路吃透,这几乎是所有流式DMA外设驱动的共用模板。
4.2 SPI DMA发送的高频踩坑点
SPI用DMA发送有一个经典陷阱:SPI主机在没有时钟时是不会主动输出的,DMA往SPI的TX FIFO塞数据,如果SPI没有启动传输,数据会积压在FIFO里不动。反之,SPI接收时要产生时钟才有数据进来,如果DMA接收准备好之前主机已经开始时钟,那前面的数据可能只发不收,缓冲区里就是空洞。
所以SPI DMA发送的驱动里,使能DMA和外设的先后顺序非常关键。正确顺序通常是:先配置好DMA描述符并提交,再把SPI主机切到DMA模式并启动传输,让SPI的时钟由FIFO空/满状态自然驱动。反过来,如果先启动SPI、再等DMA,那第一批数据会延迟,时序就对不上了。
另一个坑是SPI的DMA传输和数据宽度。很多SPI外设支持8位或16位模式,DMA数据宽度要跟着配。8位模式下DMA每搬运1字节,16位模式下每搬运2字节。如果数据宽度配错,收到的数据会是错位的:两个字节挤在一个16位单元里,恢复起来费神。这个在逻辑分析仪上看得非常清楚,但不少人第一反应是SPI相位极性配错了,实际上SPI模式根本没问题。
还有一种情况是,SPI DMA发送完成后,SPI外设的TX FIFO数据还没有完全移位出去,你就直接关了SPI时钟或者切了GPIO,导致最后一两个字节丢失。这类问题在发送完成中断里加一个小的延时或者轮询SPI状态寄存器的BSY标志位就能解决。我习惯的做法是:DMA完成中断触发后,先读SPI的忙状态位,确认不忙了再close或者启动下一次DMA传输。
4.3 DMA带宽测速的正确打开方式
热词里反复出现“DMA测速”,确实,DMA的带宽不是datasheet峰值,实测中受总线仲裁、Cache命中率、描述符开销、中断频率等多重因素影响。测速时常见的错误是把DMA搬运数据和CPU搬运混在一起量,测出来的是“CPU+总线”的综合结果,不是纯粹的DMA能力。
我推荐的测速方法是:驱动里分配两块一致性内存缓冲区,分别作为源和目的,用dmaengine_prep_dma_memcpy提交一个固定大小的内存到内存传输,同时记录起始ktime,在完成回调里用ktime相减得到耗时,反复跑多次取稳定值。关键点如下:
- 源和目的缓冲区都要物理连续、且地址对齐到DMA控制器的最大传输粒度和Cache line大小。
- 传输大小要按DMA的最大burst对齐,比如512字节对齐,否则某些控制器会把末尾的小尾巴用单次传输完成,带宽统计不准确。
- 内存地址不要选择同一个page或者紧密相邻的地址,否则DMA内存控制器可能有bank冲突,带宽被压低,这不是你驱动的问题。
- 统计平均值之外,还要看最大最小值的抖动。DMA带宽抖动过大说明总线上有其他主设备抢占,比如LCD控制器在刷屏、SD卡在做DMA搬运,这在多主设备系统里很常见。
我在项目里实测过一片主控的DMA memcpy带宽,峰值能到600MB/s左右,但实际跑外设(SPI)时只有几十MB/s,瓶颈在SPI时钟和外设FIFO深度,不在DMA。所以“DMA测速失败”不代表DMA不行,很可能是链路里的最短板在外设端。调试时要一级一级拆:先测memcpy,再测外设到内存,逐层定位瓶颈。
5. 常用调试手段与错误定位技巧
5.1 从硬件寄存器入手确认DMA配置是否生效
DMA出问题,第一件事不是翻代码,而是确认配置有没有真正写进硬件。Linux下可以用devmem直接读DMA控制器寄存器的值,比如:
devmem 0x40030000 32对照datasheet的寄存器位定义,确认SAR(源地址)、DAR(目的地址)、TCR(传输计数)和自己配置的值一致。如果SAR/TCR和你预期完全不一样,说明DMA engine驱动在配置过程中出了问题;如果一致,说明配置本身没问题,问题在中断或者缓冲区一致性上。
裸机环境下更简单,调试器里直接watch这几个寄存器的值,单步走一遍配置流程,看CNDTR的变化是否符合预期。CNDTR在执行过程中是递减的,如果你发现它没动,极可能是DMA没有实际传输,检查外设的DMA请求信号是否生效。
5.2 日志、Trace和中断计数定位丢数据问题
DMA丢数据是驱动开发里最头疼的bug,排查思路要按层级拆:数据丢在源头(外设侧),丢在搬运路线上(总线、DMA控制器侧),还是丢在缓冲区到应用侧的拷贝上。每层用一个独立的观测点。
内核驱动里可以在关键流程处加tracepoint或者简单的printk计数,比如:
static void rx_done_cb(void *param) { struct my_dev *dev = param; dev->stats.done_irq++; // ... }配合中断计数和缓冲区长度统计,能定位出到底是DMA没产生完成中断,还是完成了中断但数据被覆写。我在串口DMA上遇到过一类问题:DMA完成中断触发频率远高于预期,导致每次只搬运了几个字节,吞吐极低。查下来是DMA通道配置里burst没生效,控制器把每个字节当成一次单次传输,并且每传输一个字节就触发一次中断。配置burst之后,中断频率立刻降到原来的百分之几。
5.3 不用逻辑分析仪也能抓DMA时序问题的土办法
很多嵌入式项目没有逻辑分析仪,但不代表没法定位DMA时序问题。一个简单的方案是:在驱动里用GPIO输出脉冲标记关键事件点。DMA提交完成(dma_async_issue_pending之后)、DMA完成中断刚开始时、数据处理完成时,各翻转一次GPIO,用示波器看这几个点的时间关系。
这个方法在裸机和Linux下都适用。有次调试一个SPI+DMA的音频输出驱动,声音有周期性卡顿,靠GPIO脉冲一眼就看出来了:DMA完成中断到下一次dmaengine_prep_*提交之间,间隔不稳定,有一阵子长达几十毫秒,那是因为驱动在中断里做了一次mutex_lock,被另一个持锁进程阻塞了。优化掉锁之后,脉冲间隔变得均匀,问题消失。
注意:GPIOToggle本身会引入开销,只适合定性看时序,不适合做精确的时间测量。要精确测量还是用
sched_clock或者内核的ftrace。
6. 常见问题与排查技巧实录
6.1 问题速查表
我把嵌入式DMA开发里反复出现的问题整理成一个速查表,每个问题都标记了典型特征和首选排查方向,实战中可以直接对着这张表排雷。
| 问题现象 | 最可能的原因 | 首选排查动作 |
|---|---|---|
| 数据错位/花屏/乱码 | 数据宽度配置不对,或Cache未同步 | 核对src_addr_width和外设寄存器位宽,检查dma_alloc_coherent是否使用 |
| 偶发丢数据,且与CPU负载相关 | Cache一致性问题,缓冲区未用一致性内存 | 统一改用dma_alloc_coherent后观察是否稳定 |
| 中断频率异常高,吞吐极低 | burst未生效,控制器按单次传输执行 | 读寄存器确认MAXBURST值,对照驱动源码确认映射关系 |
| 数据停留在缓冲区处理不完 | 环形缓冲区读指针追过写指针 | 检查缓冲区剩余空间计算,确认DMA描述符是否已重新提交 |
| DMA传输不启动,CNDTR不动 | 外设DMA请求信号未使能 | 检查外设寄存器里的DMA使能位,确认请求线编号 |
| 中断丢失,但数据实际到达 | ISR里睡眠或阻塞,中断被长时间屏蔽 | 检查回调函数是否有锁、kmalloc、msleep |
| 描述符返回NULL或提交失败 | 描述符池耗尽 | 检查提交速率和描述符队列深度,加错误统计 |
| 最后一个字节丢失 | 外设移位寄存器数据未清空就关闭 | 在完成中断里等待BSY位清零后再复位外设 |
| memcpy测速低于预期 | 源地址未按Cache line对齐,总线bank冲突 | 改用dma_alloc_coherent分配并确保对齐 |
6.2 Cache一致性冲突的经典排查案例
我之前遇到一个很典型的案例:SPI+DMA收数,数据量小时完全正常,数据量大时随机出现几十个字节的旧数据。排查时我先怀疑SPI时序,调了相位极性,没用。后来用dma_map_single替换了kmalloc缓冲区,在每次DMA提交前做dma_sync_single_for_device,接收完成做dma_sync_single_for_cpu,问题消失。事后来看,就是CPU在缓冲区里写入控制参数后留了Cache脏数据,DMA读到了旧的参数描述符,导致描述符内容错乱。
这个案例给我最大的教训是:DMA的开发不要等出问题再补一致性,应该在写第一版驱动的时候就按照“凡是DMA访问的内存都要显式管理”的标准来写。一致性管理的代码模板并不复杂,但漏掉一个位置就是随机故障。
6.3 面试八股里DMA最常被问的几个点
因为是驱动岗位,DMA几乎是必考点。面试官通常不会问API怎么调用,而是喜欢追问这些点:
- DMA传输需要满足哪些硬件条件?——物理连续、地址对齐、方向明确、外设请求正确。
- 为什么DMA能提升CPU利用率?——减少中断次数,数据搬运由硬件完成,但这不是零成本,描述符管理、中断处理、缓冲区拷贝仍然占用CPU。
- Cache一致性怎么解决?——一致性DMA映射(
dma_alloc_coherent)和非一致性映射(dma_map_single+sync)两条路线,讲清楚适用场景。 - 什么是突发传输?和FIFO深度有什么关系?——burst是DMA一次总线上连续读写的长度,不能超过外设FIFO容量,否则溢出。
- 描述符在DMA中的作用?——描述符装载源、目的、长度、下一个描述符指针,实现链表式连续传输,避免频繁CPU介入。
- 多通道DMA同时工作会发生什么?——总线仲裁、通道优先级、FIFO共享,低优先级通道可能有延迟,高优先级通道可能饿死低优先级。
这些问题看着是知识题,实际上是在考察你有没有在真实环境里被DMA坑过。回答的时候把自己的项目经历、调试手段、踩坑复盘讲清楚,比背概念强很多。
7. 从驱动框架到产品化的几点体会
DMA这块内容如果只停在“能用”的程度,和“靠谱”之间还有不少距离。真正做产品化的时候,我建议把这几件事也纳入驱动设计的考量范围。
第一,DMA缓冲区的生命周期管理要提前规划。驱动里同时存在三种DMA内存:一致性内存、流式映射缓冲区和描述符缓冲区。它们的分配时机、释放时机、错误处理路径都不同。我在项目里吃过亏的是:在remove函数里先释放了DMA通道,再释放缓冲区,结果通道释放回调里还在访问缓冲区数据,直接crash。现在我的做法是:任何DMA通道释放之前,先确保已经停掉所有传输、清空pending队列、等待所有完成回调执行完毕,再释放缓冲区。
第二,电源管理和DMA的关系经常被忽视。系统休眠时DMA控制器进入低功耗状态,如果外设在休眠前没有停掉DMA传输,醒来后描述符和通道状态可能是脏的。Linux的dmaengine_pause、dmaengine_resume、device_prep_dma_*的幂等性要仔细测试,休眠唤醒循环跑几百次才能放心。
第三,DMA驱动要主动暴露性能统计接口。哪怕只是debugfs里的一组计数器,也能在客户现场出问题时快速定位。我习惯的统计项包括:提交的传输次数、完成回调次数、超时次数、描述符分配失败次数、每次传输的最大/最小耗时。这些数据在问题面前比一千行日志都管用。
DMA驱动的开发,硬件的坑基本是死的,最大的变量是CPU和总线的行为。把硬件行为吃透、把内存一致性管好、把自己的统计手段做扎实,这套驱动就能站得住。做驱动的时间越长越觉得,DMA不是“外设驱动的一部分”,而是整个系统性能不可忽略的基础设施,值得花时间把它彻底学透。