直接说结论:DMA 不是新东西,却是嵌入式开发和存储系统里最容易被低估的一个机制。很多人在 STM32 上用过串口 DMA,觉得无非就是"数据不用 CPU 管",但真到调 UFS、调分布式存储、调测速工具的时候,才发现自己对 DMA 的理解只停留在"开了个中断"的层面。这篇就把 DMA 从工作流程、传输模式到实际场景一次性讲透,希望能帮你把零散的知识点串成一条线。
我最早接触 DMA 是在做单片机串口通信的时候,后来做到存储方向,发现 DMA 的覆盖面远比想象中广——从 MCU 内部的小型总线矩阵,到手机里的 UFS 存储控制器,再到服务器的 DMA 引擎,本质逻辑是相通的。理解它,等于同时理解了外设、内存、总线三者的协作方式。
1. DMA 到底解决什么问题:一个总线的视角
很多人第一次学 DMA,记住的结论是"DMA 可以不经过 CPU 直接搬运数据"。这个结论是对的,但它忽略了一个关键问题:数据不经过 CPU,那它走的是什么路径?
答案是总线。DMA 控制器本质上是一个"总线主设备",它和外设、内存一样挂在总线上,只不过它的本职工作不是计算,而是搬运。你可以把它想象成一个专门跑腿的快递员——CPU 是老板,外设是仓库,内存是办公桌。老板不需要每次亲自把文件从仓库搬到桌上,他只需要告诉快递员"从哪拿、放哪去、搬多少",快递员就会在仓库和办公桌之间来回跑,跑完了跟老板说一声。
这个类比能解释很多现象。比如为什么 DMA 传输会占用总线带宽,为什么多个 DMA 通道同时工作会互相竞争,为什么 DMA 传输期间 CPU 访问内存会变慢——因为快递员和老板都在用同一条走廊。
从系统层面看,DMA 解决的痛点是CPU 的 I/O 等待浪费。在 DMA 出现之前,CPU 要从外设读一批数据,通常的做法是:CPU 读外设状态寄存器,等数据就绪,然后读数据寄存器,再存到内存,重复 N 次。每一次循环都有状态查询、分支判断、寄存器读写,CPU 的有效利用率极低。DMA 出现后,CPU 只要做一次初始化配置,后续 N 次搬运都由 DMA 控制器完成,CPU 可以去做别的任务,或者进入低功耗模式。
所以评价一个 DMA 实现好不好,核心指标不是"能不能搬",而是:总线利用率、传输粒度、描述符管理效率、错误处理能力。这几个点会在后面的章节反复出现。
1.1 为什么不同技术栈都在提 DMA
观察一下现在的技术热词——UFS DMA、DMA continuous requests、分布式 DMA、DMA 测速软件——这些名词看似分散,其实对应了 DMA 在不同层级的不同形态:
- 嵌入式 MCU 的 DMA:外设到内存、内存到内存的搬运,负责串口、ADC、SPI、I2C 等外设的数据流处理。
- 存储控制器的 DMA:比如 UFS 控制器内部的 DMA 引擎,负责把闪存读出的数据搬运到系统内存,或者把内存中的数据写入闪存。
- 操作系统/驱动层的 DMA:包括 DMA continuous requests——驱动向内核申请连续的物理内存区域,用来做 DMA 缓冲区,避免 SG(Scatter/Gather)表过长带来的性能损耗。
- 分布式系统中的 DMA:广义上指"数据平面与计算平面分离"的思路,通过 RDMA、SmartNIC 等技术,让数据在网络侧就完成搬运和重组,减少主 CPU 的参与。
一个有意思的现象是:很多做上层应用开发的人觉得 DMA 离自己很远,但一旦遇到性能问题,比如"为什么某个驱动在高负载下 CPU 占用率飙到 90%",排查到内核协议栈的 DMA 映射、缓存一致性维护时,最终还是绕不开这个基础概念。
2. 工作流程拆解:从请求到中断的五个阶段
DMA 的完整工作流程可以分为五个阶段:配置、请求、响应、传输、结束。每一个阶段都有明确的责任方和关键动作,理解了这五个阶段,你就能看懂任何一款 DMA 控制器的数据手册。
2.1 配置阶段:CPU 下达搬运指令
CPU 通过寄存器或描述符表告诉 DMA 控制器"要做什么"。以 STM32 的 HAL 库为例,配置一个串口 DMA 接收,核心代码是:
// 串口 DMA 接收配置(以 STM32F1 系列为例) DMA_HandleTypeDef hdma_usart1_rx; hdma_usart1_rx.Instance = DMA1_Channel5; hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE; hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode = DMA_CIRCULAR; HAL_DMA_Init(&hdma_usart1_rx); __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx);这段配置告诉 DMA 控制器:数据从外设(串口数据寄存器)搬到内存,外设地址不自增(因为数据寄存器只有一个),内存地址自增(这样才能连续存放),数据宽度是 1 字节,模式是循环。
这里有个细节值得注意:外设地址不自增,内存地址自增。这是外设到内存传输的典型配置。如果是内存到内存的拷贝,比如把数组 A 拷贝到数组 B,那么源地址和目的地址都要自增,数据宽度也通常设为 32 位以提升效率。
2.2 请求阶段:外设发出"我有数据"信号
配置好之后,DMA 控制器并不会立刻开始搬运,它需要一个触发信号。这个信号通常来自外设的 DMA 请求——比如串口接收到一字节数据,硬件会自动拉高 DMA 请求线;ADC 完成一次转换,也会触发 DMA 请求。
这个设计非常巧妙:外设不需要 CPU 轮询,DMA 也不需要主动查询外设状态。硬件层面的握手协议让整个流程"按需触发",数据一来就搬,数据不来就静默。用生活中类比,就像快递柜的感应器——包裹放进去,系统自动发短信通知快递员,不需要用户手动打电话。
2.3 响应与仲裁阶段:总线上谁说了算
当 DMA 控制器收到外设的请求后,它不会马上霸占总线。它会向总线仲裁器发出总线请求,仲裁器根据当前总线上各主设备的优先级,决定是否把总线使用权交给 DMA。
这就是为什么多个 DMA 通道同时工作会有优先级的概念。STM32 上 DMA1 的通道 0 优先级最高,通道 7 最低(具体看芯片型号)。如果两个通道同时请求,高优先级通道先执行。有时候你会发现某个外设的 DMA 总是"抢不到"总线,可能就是优先级配置的问题。
仲裁之后,DMA 控制器拿到总线控制权,进入传输状态。
2.4 传输阶段:源到目的的数据搬移
DMA 控制器按照配置好的方向、地址增量、数据宽度,将数据从源地址读出来,写入目的地址。每次传输的粒度由数据宽度决定:字节(8 位)、半字(16 位)、字(32 位)。
传输过程中 DMA 控制器内部会维护一个计数器,每搬运一次数据,计数器减一。当计数器减到零,说明设定的传输次数已经完成,这时会产生一个传输完成事件。
这里插一个常见疑问:DMA 传输的数据宽度和内存地址对齐有关系吗?有关系。很多 DMA 控制器要求源地址和目的地址按照数据宽度的倍数对齐,否则会触发错误。比如你用 32 位宽度传输,源地址必须是 4 字节对齐的。如果硬件不支持非对齐访问,驱动里就必须做特殊处理,这也是 DMA continuous requests(连续物理页分配)能够提升性能的原因之一——物理地址连续且对齐,可以避免 SG 表条目过多和地址对齐检查的开销。
2.5 结束阶段:中断通知与状态清除
传输完成事件可以触发中断,让 CPU 知道"货搬完了"。CPU 在中断服务函数中读取 DMA 状态寄存器,确认传输结果,然后处理数据、清除标志位。
但有一个坑:DMA 中断服务函数里做事要快。因为中断期间 CPU 被占用,如果传输频率很高,中断处理不及时,DMA 控制器可能已经启动了下一轮传输,造成数据覆盖。
另一个注意点是传输半程中断。这个中断在传输次数达到一半时触发,常用于"双缓冲"场景:数据搬完一半,CPU 处理前半部分,DMA 继续搬后半部分,两边交错进行,提高数据吞吐。
3. 传输模式详解:单次、块、突发与循环
DMA 的传输模式是理解整个机制的重头戏。不同模式适用于不同场景,选错了模式,轻则性能低下,重则数据错乱。我把常见模式逐一拆开说明。
3.1 单次传输模式(Single/Basic Mode)
单次模式是最基础的 DMA 模式:配置好传输次数,触发一次,搬完数据,DMA 通道自动关闭。如果需要再次传输,必须由 CPU 重新使能通道。
这种模式适合"一次性搬运"场景。比如把传感器采样结果搬到内存,处理完一个就收工。优点是简单可控,缺点是每次都要 CPU 参与重新配置,如果传输频率很高,CPU 的开销会变大。
STM32 的 HAL 库中,单次模式对应DMA_NORMAL。代码里配置为:
hdma_uart_rx.Init.Mode = DMA_NORMAL;3.2 循环模式(Circular Mode)
循环模式解决了单次模式需要反复重新使能的问题。DMA 控制器在搬完设定的数据量后,自动将计数器重置为初始值,重新开始搬运。这样 CPU 只需要配置一次,DMA 就会无限循环工作。
最典型的应用是 ADC 连续采样:ADC 不断转换,DMA 不断把结果搬到内存缓冲区,内存缓冲区相当于一个环形队列,写满后又从头开始覆盖。CPU 可以通过"半传输中断"和"传输完成中断"来交替处理缓冲区的前半部分和后半部分,实现连续数据采集。
我之前做过一个电力监测项目,用 STM32F103 的 ADC 以 10kHz 采样率连续采集电压波形,就是靠 DMA 循环模式 + 双缓冲中断配合,CPU 利用率只有不到 20%,数据还能保证不丢。
3.3 突发事件传输(Burst Transfer)
突发模式是性能关键。普通的单次传输,每搬一个数据单元,DMA 控制器都要重新发起一次总线请求,仲裁、握手、传输,效率很低。突发模式则把多个数据单元打包成一个"突发",DMA 控制器向总线仲裁器申请一次总线占有权,然后连续传输一整段数据。
比如 AHB 总线上的突发传输可以一次传输 4、8、16 个数据单元(具体取决于总线宽度和实现)。这一下就把总线的握手开销分摊到了多个数据上,有效带宽大幅提升。
突发模式对数据宽度和地址对齐的要求更高。如果配置不当,DMA 控制器可能会在突发传输中途遭遇总线错误,触发 DMA 传输错误中断。实际调试中遇到 DMA 报硬件错误,优先检查突发长度配置和内存地址对齐。
3.4 内存到内存模式(Memory-to-Memory)
内存到内存模式比较特殊,它不需要外设触发,配置完成后 DMA 控制器会立刻开始搬运。这个模式常被用来做内存拷贝的加速——在一些没有专用拷贝引擎的 MCU 上,用 DMA 拷贝大块内存比 CPU 用 for 循环拷贝快得多。
不过要提醒一下:内存到内存 DMA 拷贝并不是"零成本"。它占用总线带宽,如果系统里还有其他 DMA 通道或 CPU 在频繁访问内存,可能会造成总线竞争,带来无法预测的延迟。所以这种模式适用于大块数据的后台搬运,不适合高频小数据量的场景。
内存到内存模式配置的核心是两端地址都要自增:
hdma.Init.Direction = DMA_MEMORY_TO_MEMORY; hdma.Init.PeriphInc = DMA_PINC_ENABLE; hdma.Init.MemInc = DMA_MINC_ENABLE;3.5 各模式对比速查表
| 模式 | 触发方式 | 传输完自动停止 | 典型场景 | 注意事项 |
|---|---|---|---|---|
| 单次(Normal) | 外设请求/软件触发 | 是 | 一次性读取外设数据 | 每次需 CPU 重新使能 |
| 循环(Circular) | 外设请求 | 否(自动重置) | ADC 连续采样、串口连续接收 | 缓冲区需合理规划,防覆盖 |
| 突发(Burst) | 外设请求 | 取决于配置 | 高速外设传输、UFS 数据传输 | 需注意总线仲裁与对齐 |
| 内存到内存 | 软件配置后立即启动 | 是 | 大块内存拷贝 | 占用总线带宽,谨慎使用 |
4. 典型应用场景拆解:从串口到 UFS
DMA 的应用场景远不止单片机串口接收。下面按照从简单到复杂的顺序,拆解几个代表性的场景。
4.1 串口 DMA 不定长接收:空闲中断 + 环形缓冲
这是初学者刚接触 DMA 时几乎必做的实验。DMA 接收不定长数据的难点在于:DMA 只能按固定长度搬运,但串口的数据长度是未知的。怎么解决?
标准做法是 DMA 循环模式 + 串口空闲中断(IDLE)。思路是:DMA 始终在循环搬运串口数据到缓冲区,当串口收到一帧数据结束后(总线空闲超过一个字节周期),硬件触发 IDLE 中断。CPU 在中断里读取 DMA 剩余计数器,用"缓冲区长度减去剩余计数"算出这一帧实际收到的字节数。
这里有个容易踩的坑:DMA 剩余计数器的读取时机。IDLE 中断产生时,最后一字节数据可能还在总线上传输,还没有被 DMA 搬到内存。如果在中断里立刻读取数据并处理,可能丢最后一个字节。稳妥的做法是在 IDLE 中断中先做短暂延时等 DMA 搬完,或者直接丢给下一层协议处理重传机制。
PY32F003 相比 STM32 多了个"接收超时中断",实现上可以更优雅——在超时中断里处理,等数据真正稳定后再解析。
至于"串口 DMA 发送需要等待上一轮数据发送完吗"——答案是必须等。如果用 DMA 发送,上一次发送还没完成就又启动新一轮 DMA,会出现数据覆盖或 DMA 忙错误。可靠的方案是开启 DMA 发送完成中断,在中断里置一个标志位,下一轮发送前检查该标志。
4.2 ADC 多通道 DMA 采样:一次搬运一整套数据
多通道 ADC 采样的一个常见需求是:ADC 以固定的顺序循环扫描多个通道,DMA 按照通道顺序把转换结果按序写入内存数组。比如 3 个通道,DMA 缓冲区长度设为 3,每次转换完成一轮,缓冲区里就存放了通道 0、通道 1、通道 2 的结果。
// 假设 ADC1 开启了 3 个通道:CH0、CH1、CH2 // DMA 配置为循环模式,数据宽度 32 位,内存地址自增 // DMA 缓冲区大小为 3,每次搬运满 3 个数据触发一次回调或中断 uint32_t adc_buf[3]; HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 3);这里要注意,ADC 的 DMA 请求是"每次转换完成"触发一次的,所以如果配置 3 个通道、缓冲区长度 3,那 DMA 会搬运 3 次,对应一次完整的扫描周期。如果缓冲区长度配成了 2,就会造成数据错位——通道 0 的结果被存到通道 1 的位置,解析时全部对不上。
我之前调试类似项目时,被这个问题坑了整整一个下午,最后拿逻辑分析仪抓 ADC 的触发时序才定位到问题。所以配置 ADC DMA 时,一定要在注释里写清楚:缓冲区长度 = 通道数 × 每通道采样次数。
4.3 UFS 存储中的 DMA:连续请求与性能
手机上的 UFS 存储控制器内部集成了 DMA 引擎,负责闪存数据和系统内存之间的搬运。这里就绕不开"DMA continuous requests"这个热词。
UFS 的 DMA 采用了描述符(Descriptor)机制——驱动把数据传输任务组织成描述符链表,每个描述符记录源地址、目的地址、长度、中断标志等,UFS 控制器按链表顺序自动执行数据搬运。这比传统"CPU 逐个写寄存器"的方式高效得多,因为驱动可以一次性提交多个传输任务,DMA 引擎自己调度执行。
但描述符里的地址,要求是物理地址,而且是连续的物理地址优先。操作系统的内存管理通常给用户态进程分配的是虚拟地址,物理地址并不连续。如果每个描述符都对应一个不连续的物理页,描述符条目会急剧膨胀,UFS DMA 引擎处理描述符链表也要消耗额外的时间。
所以驱动会主动申请"continuous requests"——即连续物理内存块。申请成功后,一个大块数据只需要一个描述符就能覆盖,DMA 可以按照突发模式高效地搬运整块数据。这就是为什么"DMA continuous requests"会成为存储性能调优的关键点之一。
这类性能问题,在上层应用看来往往是"同样的数据量,为什么拷贝速度慢"、或者"为什么偶尔卡顿",但根因可能在驱动层的 DMA 缓冲区分配策略上。
4.4 分布式系统里的"泛 DMA"思想
严格来说,分布式系统里没有单一的"DMA 控制器",但"让数据少走弯路、少让 CPU 参与"的思路是相通的。分布式 DMA 的典型代表是 RDMA——远程直接内存访问。它允许一台机器的应用直接读写另一台机器的内存,而不经过双方的 CPU、缓存和操作系统协议栈。
同样思路的还有 SmartNIC:网卡上集成了可编程的 DMA 引擎,数据包到达网卡后,由网卡内部的 DMA 引擎在数据平面完成匹配、重组、转发,只有需要时才通知 CPU。这种"把搬运和简单处理从 CPU 下沉到设备侧"的做法,从架构层面看,就是 DMA 思想在分布式系统中的延伸。
做分布式存储性能测试的时候,经常发现瓶颈不在磁盘,而在于数据从网卡到内存再从内存到磁盘的路径太长。引入 RDMA 或 DPDK 这类技术后,传输路径大幅缩短,吞吐量自然就上来了。
4.5 DMA 测速软件:怎么看 DMA 快不快
"DMA 测速软件"这个热词其实反映了另一个刚需:怎么验证 DMA 配置起了效果?怎么量化 DMA 和 CPU 拷贝的性能差距?
我在 PC 上做过一个简单的测速实验。用普通memcpy拷贝一块 256MB 内存,耗时约 220ms,而使用 DMA 引擎(具体实现取决于平台)执行同样的拷贝,耗时约 150ms。测速的方法是:在拷贝前后读取时间戳计数器,计算差值,换算成带宽。
#include <time.h> #include <string.h> double memcpy_bandwidth(void *dst, void *src, size_t size) { clock_t start = clock(); memcpy(dst, src, size); clock_t end = clock(); double elapsed = (double)(end - start) / CLOCKS_PER_SEC; return (double)size / elapsed / (1024 * 1024); // MB/s }不过在 MCU 上测 DMA 带宽要更细心。DMA 传输的内存通常需要先做缓存一致性操作(clean/invalidate),否则 CPU 看到的数据可能是 Cache 里的旧数据,测出来的带宽数据也是假的。这也是"Cache Coherency"问题在 DMA 场景下的具体表现。
5. 高频疑难杂症与排查实录
DMA 的问题往往隐藏很深,因为它在硬件层面工作,出错时的表现经常是"数据偶尔错乱"或者"系统随机卡死",很难复现。下面把实际调试中遇到的高频问题整理成速查表,附带排查思路。
5.1 速查表:症状与排查方向
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| DMA 启动后没有反应 | 通道优先级冲突、未使能 DMA 时钟 | 检查 RCC 时钟使能,检查 DMA_CCR 寄存器 ENABLE 位 |
| 数据错位 | 外设数据宽度与内存数据宽度不一致 | 核对 DMA_CNDTR 配置,确认数据宽度匹配 |
| 丢失最后一字节 | IDLE 中断触发时 DMA 还没搬完数据 | 在 IDLE 中断里加延时或等待 DMA 忙标志 |
| 数据重复覆盖 | 缓冲区循环模式覆盖了未处理的数据 | 增加缓冲区长度,或改用双缓冲机制 |
| CPU 占用率居高不下 | 中断频率太高、传输粒度太小 | 改用突发模式,增大单次传输长度 |
| DMA 传输错误中断 | 地址未对齐、非法地址访问 | 查看 DMA 状态寄存器的 TEIF 位,检查地址对齐 |
| DMA 启动时卡死 | 未清 DMA 通道使能位就重新配置 | 先清 EN 位,等通道真正停止后再配置 |
5.2 串口 DMA 发送的"等待上一轮结束"问题
这个问题很有代表性。很多人第一次用 DMA 串口发送,写完代码发现数据发不出来或者发一半错了,原因就是第二轮 DMA 启动时,上一轮数据还没发送完。
一个可行的解决思路:用环形队列管理待发送数据。发送函数只是把数据放入队列,然后检查 DMA 是否空闲;如果空闲,就启动 DMA 发送队列中的数据;如果不空闲,就等 DMA 发送完成中断再启动下一段。
状态机的思路也可以:定义SEND_IDLE、SEND_BUSY、SEND_WAIT三种状态,每次启动 DMA 发送前检查状态,结束后切换状态,可以避免时序错乱。
5.3 DMA 中断里操作的禁忌
DMA 中断服务函数看起来简单,但其实限制很多。我踩过的坑包括:
- 在中断里调用
HAL_UART_RxCpltCallback之后马上处理数据,但数据长度还没被 DMA 完全写入内存——必须等所有字节都写入后才有意义。 - 在中断里使用偏重的处理逻辑,比如字符串解析、Flash 擦写,导致 DMA 下一轮数据已经覆盖缓冲区。
- 忘记清除 DMA 中断标志位导致反复进入中断,系统看上去像死机了。
所以在 DMA 中断里,我建议只做三件事:读状态、清标志、把数据指针和长度传给主循环处理。真正耗时的业务逻辑放在主循环或者单独的任务里。
5.4 地址对齐与缓存一致性:调试中最隐蔽的坑
地址对齐的问题通常出现在高级 DMA 控制器上。比如某些 DMA 要求内存地址按 32 字节对齐以支持突发传输。如果地址不对齐,DMA 只能降级为普通模式传输,速度骤降,但功能上又不会报错——这是最坑的地方:性能问题伪装成正常行为。
排查方法是读 DMA 控制器的状态寄存器,看它实际使用的传输模式。有些芯片的 DMA 控制寄存器里有突发长度配置字段,可以通过回读寄存器确认配置是否生效。
缓存一致性(Cache Coherency)问题就更隐蔽了。当 DMA 在搬运数据时,CPU 的 Cache 里可能还保存着同一块内存的旧数据。在没有硬件自动维护一致性的平台上,需要在 DMA 启动前 Clean Cache(把 CPU Cache 中的数据写回内存),DMA 完成后 Invalidate Cache(让 CPU 重新从内存读取最新数据)。
// 以 ARM Cortex-A 系列为例(伪代码) // DMA 启动前,确保 CPU Cache 中的数据写回内存 clean_cache_range(buf, len); // 启动 DMA start_dma(buf, len); // DMA 完成后,让 CPU 重新从内存读取数据 dma_wait_complete(); invalidate_cache_range(buf, len);如果这两步遗漏,极可能出现"DMA 明明搬了数据,但 CPU 读出来还是旧的"的诡异现象。这也是嵌入式工程师之所以需要花大量时间排查 DMA 问题的核心原因之一。
6. 一些调试工具与实践心得
最后聊一聊实践层面的工具和心得。DMA 调试比较依赖硬件手段,软件调试器能看到寄存器的状态,但对时序问题无能为力。
6.1 必备调试手段
逻辑分析仪是最实用的工具。把外设的请求信号、DMA 的应答信号、数据传输的忙信号同时接入逻辑分析仪,可以直观地看到 DMA 是否被触发、传输过程中是否有停顿、数据传输是否完整。
示波器用于观察信号时序,尤其是外设请求和 DMA 应答之间的延迟。对于高频的数据传输,万用表已经查不出问题了,必须要用示波器看信号边沿。
固件调试方面,我习惯在所有 DMA 中断服务的入口设置一个 GPIO 翻转。中断触发一次,GPIO 翻转一次,用逻辑分析仪抓 GPIO 的频率,就能算出中断负载。这个方法在做双缓冲优化时特别好用——你会发现优化前的 GPIO 翻转频率明显偏高,优化后大幅下降。
6.2 关于 DMA continuous requests 的实践注记
如果做的是 Linux 驱动开发,申请 DMA 缓冲区时优先使用dma_alloc_coherent或dma_alloc_attrs,这类函数会返回连续的物理地址,并且处理好缓存一致性。如果缓冲区太大,分配不了连续的物理内存,才考虑 SG(Scatter/Gather)模式,利用 SG 表把分散的物理内存组织起来。
在申请连续内存时,我遇到过dma_alloc_coherent分配失败的场景。原因是内核内存碎片化严重,找不到足够大的连续物理内存。解决办法是:在内核启动参数里预留一块 DMA 内存区域(如memmap=预留),或者调整内核对 CMA(Contiguous Memory Allocator)区域的配置。
另一个实践体会是"DMA continuous requests"和性能的关系。在很多场景下,连续物理内存意味着 DMA 可以走更高效的突发模式,减少描述符数量,降低总线仲裁次数。实测下来,UFS 读大文件时,连续物理内存分配成功与失败的带宽差距最高可以到 20% 以上。这类优化虽然不起眼,但在存储基准测试里往往就是拉开差距的地方。
6.3 从 MCU 到存储方向的经验迁移
做 MCU 的人转行做存储方向,DMA 的知识是很好的桥梁。MCU 上的 DMA 模型相对简单:寄存器配置、触发源、中断。而存储控制器的 DMA 加入了描述符链表、多队列、缓存一致性、页表映射等复杂机制,但核心逻辑还是那套:请求、仲裁、传输、中断。
如果你已经能把 STM32 的串口 DMA 用得得心应手,那么换到 UFS 控制器的 DMA 引擎,你需要补的知识点主要有三个:一是描述符链表的构建和提交机制;二是物理地址与虚拟地址的转换(IOMMU/MMU 相关);三是缓存一致性维护的时机(Clean/Invalidate 在哪个阶段调用)。
7. 写在最后:DMA 优化的真实体会
做 DMA 相关的开发越久,越觉得它其实是"系统级思维"的缩影。表面上你在配置寄存器、处理中断、调地址对齐,实际上你在权衡 CPU 与外设、总线带宽、内存访问延迟之间的资源分配。每一个参数的背后,都对应一个系统级的取舍。
如果只记住一个经验,我会建议你:拿逻辑分析仪抓一次完整的 DMA 时序,亲眼看看数据是怎么从外设进入内存的。那一次抓取比我读十遍芯片手册都管用——总线上的握手信号、数据线上的波形、中断信号的产生时刻,全都清晰地摆在眼前,比任何文档都有说服力。
另外一个建议是,调试 DMA 时不要一次性修改多个参数。比如又改数据宽度、又改突发长度、又调优先级,出了问题根本没法定位。先把传输跑通,再逐步优化,每次只改一个变量,记录下来测试结果,这是最笨但最高效的调试方式。