拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

DMA驱动开发实战:原理、配置与Cache一致性解析

DMA驱动开发实战:原理、配置与Cache一致性解析

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同步采样、高速传感器数据采集这类场景下,没有DMA,CPU基本就被绑架在数据搬移上,别的事都干不了。

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,驱动按名字拿通道。比如:

serial@40010000 { 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去读,映射后驱动需要确保已经写入的数据刷出Cache,DMA才能读到最新内容。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写完以后就要马上清理对应地址的Cache:

SCB_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这个东西,重要的不是把它配置出来,而是从一开始就留足规矩,让所有数据通路上的协作方都按同一个约定工作。

返回列表