
在嵌入式驱动开发里DMA 是我一直觉得最该单独写一篇的话题。这次是《嵌入式驱动开发经验》系列第 6 期专门聊 DMA。很多人把 DMA 理解成“把数据从 A 搬到 B”这个说法没错但驱动开发里真正的难点从来不是搬运本身而是搬运前的外设配置、搬运中的内存一致性问题、搬运后怎么高效地把数据交给业务层。这篇内容我不会只贴寄存器配置而是把一个真实项目里用到的 DMA 设计思路、串口和 SPI/ADC 场景下的配置方法、Cache 一致性处理、连续传输设计以及 Linux 侧 DMA 驱动和用户态协作的常见做法按我自己的调试和踩坑经验整理出来。适合刚开始接触嵌入式驱动的新人也适合已经在写外设驱动但被 DMA 各种疑难杂症折磨的工程师读完至少能少走不少弯路。1. 先搞清楚DMA在嵌入式驱动里到底解决什么问题1.1 一个搬运工引发的思考CPU 是不是被外设绑架了我先用一个很直接的类比来说明 DMA 的角色。驱动开发里外设每准备好一个字节CPU 就要去读一次数据寄存器的动作相当于你把整个公司的所有订单都交给老板一个人去核对、签字、装箱。老板确实能干但干这个的时候没法看邮件、没法开会、更没法处理突发问题。外设特别多、数据量特别大时CPU 的时间全耗在“搬运”上了整个系统的实时性就被拖垮了。DMA 做的事情就是请一个专门的搬运工他只听 CPU 安排把外设缓冲区里的数据往内存搬或者把内存里的数据搬往外设。搬运完成的信号通过中断告诉 CPUCPU 只需要关心数据到了没有不用关心每个字节是怎么到的。在嵌入式驱动开发的实际场景里DMA 通常出现在这几类明显有“大量重复搬运”需求的地方串口通信尤其是要在高波特率下持续收发数据的情况ADC 连续采样数据不断由 ADC 产生CPU 如果每采样一次就读一次中断会被压垮SPI Flash 或 SPI 从设备的数据块读写带 PRP 或 PDM 的音频采集和播放外设与内存之间的批量数据搬运比如定时器触发的 PWM/DMA 脉冲输出。如果你做过一个stm32f103c8t6之类的项目大概率也经历过串口一字节一字节地进中断项目功能多起来之后中断一多主循环就跑不动。这种时候DMA 不是可选项而是必需品。1.2 哪些驱动场景必须上 DMA哪些硬上反而更糟不是所有驱动都应该无脑上 DMA。这一点我觉得比“怎么配置 DMA”更重要。数据量小、频率低的外设比如一个简单的按键扫描、一个每 100ms 才读一次的温度传感器CPU 直接读寄存器完全够用完全没有必要引入 DMA。强行上 DMA反而要面对缓冲区管理、中断优先级、Cache 一致性问题等于用大炮打蚊子项目复杂度和出错概率都会上涨。什么时候必须上 DMA我总结了一个很实用的判断依据数据速率超过某个量级CPU 轮询或中断已经占用率过高比如每秒产生 10 万次中断外设数据是持续不断的流而不是一次性事件最典型的就是 ADC 连续采样和串口高速收发搬运的数据块较大或者需要和其他任务并行处理不能让 CPU 一直阻塞在里面。反过来低频、小数据、响应要求非常快的场景比如 GPIO 模拟时序读取传感器不建议用 DMA。因为 DMA 一次配置和启动的固定开销有时候比 CPU 直接读几个字节还要慢。还有一个经典误区很多人以为 DMA 一定会比 CPU 中断更快实际上 DMA 不会减少总的数据传输带宽它只是把 CPU 从搬运工作中解放出来。传输效率的真正关键是外设接口本身的速度和缓冲区的设计。1.3 裸机 MCU 和 Linux 侧的 DMA 管理思路完全不同这一点我一定要单独拎出来说。很多人从单片机驱动转到嵌入式 Linux 驱动时会拿单片机那套 DMA 思路往 Linux 里套结果被折腾得很难受。原因很简单裸机 MCU 上你的程序直接访问物理内存DMA 也直接访问物理内存两者看到的是同一个地址空间情况相对单纯。而 Linux 侧有虚拟地址、页表、Cache 管理、DMA 子系统缓冲区的分配和使用有一套完全不同的规则。在 STM32/GD32 这类 MCU 上你要关心的是 DMA 通道、外设地址、内存地址、数据宽度、传输方向、优先级、是否循环模式。在 Linux 驱动里你需要关心的变成了struct dma_device、dma_alloc_coherent、dmaengine_prep_slave_single、dma_submit、DMA 完成回调、设备树里的dmas和dma-names属性。我建议初学者先掌握裸机侧 DMA因为可以把精力集中在“搬运过程”本身等理解了内存与 DMA 的关系再切到 Linux 侧把精力放在“资源管理”上上手会顺很多。下面我会分别讲裸机和 Linux 两种场景的核心问题。2. 驱动开发中 DMA 最容易踩的三个硬件级深坑2.1 Cache 一致性为什么缓冲区正确还要先 clean/invalidate这是我见过最多人栽跟头的地方也是做 DMA 驱动开发绕不开的第一道坎。CPU 和 DMA 都会访问内存但 CPU 通常不是直接访问内存而是经过 Cache。Cache 相当于 CPU 手边的一个快速草稿本CPU 修改过的数据先写在草稿本上草稿本上的内容和真实内存里的内容可能不一致。DMA 外设是直接访问真实物理内存的它不会帮你去看草稿本于是问题就来了。有两个典型场景。场景一CPU 准备好一串数据写在 Cache 里还没回写到内存然后启动 DMA 把内存数据发给串口。因为 Cache 里的脏数据没刷回内存DMA 读到的是老数据发出去自然就不对。这时候你得先做 Cache clean把 CPU 写的数据强制刷到内存再启动 DMA。场景二DMA 从 ADC 连续采了一批数据写进内存然后 CPU 去读结果发现读到的全是旧数据。原因是 DMA 写内存时Cache 里可能还留着这个地址区域的旧副本CPU 读到的是 Cache 里的旧副本以为 DMA 没把数据搬完。这时候你得先做 Cache invalidate把 Cache 中的旧数据作废再让 CPU 去读内存。用生活类比就是仓库货架上放着旧货物DMA 已经往仓库里送了一批新货但 CPU 不看仓库货架而是看自己手里的老库存清单结果算错账。你必须先把老清单作废invalidate或者每次记账后强制同步给仓库clean。在 Linux 驱动里这个问题被抽象成一致性 DMA 映射和流式 DMA 映射。dma_alloc_coherent分配出来的缓冲区驱动框架保证 CPU 访问和 DMA 访问之间没有 Cache 一致性问题适合长时间、频繁传输的场景。dma_map_single这类流式映射则是告诉内核“这块内存接下来 DMA 要用”让内核决定先刷 Cache 还是做 invalidate适合一次性、短暂的传输。裸机侧没有内核帮忙你必须在 DMA 启动前手动调用 clean/invalidate 操作比如 ARM Cortex-M 系列通常需要操作 SCB 或者 CMSIS 库里的SCB_CleanDCache_by_Addr、SCB_InvalidateDCache_by_Addr函数。2.2 对齐比你想的更严格分配 buffer 不是 malloc 就完了DMA 对缓冲区是有物理要求的很多新手在这里翻车。典型的 Linux 驱动开发面试题里常会问为什么 DMA 缓冲区必须对齐因为你必须保证数据在内存中的地址和 DMA 传输的数据块大小对齐否则外设可能无法正确读写。以 STM32 为例DMA 外设要求内存地址、外设地址和数据宽度对齐虽然具体 MCU 会有些差异但经验上至少要做到 4 字节对齐甚至 32 字节对齐。使用malloc或普通全局数组时地址对齐往往不可控。很多人写驱动时用uint8_t buffer[1024]DMA 一启动就出现数据错位就是因为地址不对齐。我在实际项目里常用的几种做法如下裸机工程里定义一个全局数组用编译器属性强制对齐比如__attribute__((aligned(32)))需要更稳妥时使用一个专门的分配函数只从 DMA 专用的连续内存池里拿 bufferLinux 驱动里不要用kmalloc从普通内存池随便拿一块地址就给 DMA 用要用dma_alloc_coherent或dma_alloc_attrs这样内核能保证物理连续性和对齐要求如果必须用kmalloc配合流式映射也要保证内存已经放在 DMA 可访问的区域并认真做好映射操作。还有一个容易被忽略的点DMA 传输的不是“字节数”这个概念而是“数据项”。配置数据宽度为字、半字、字节时总传输的数据字节数和缓冲区剩余空间的计算方式不一样。很多人把缓冲区长度直接设成字节数再配合dma_engine里的传输长度配置结果算出来的实际收包长度差了 2 倍或 4 倍。调试时一定要先确认外设寄存器的数据寄存器宽度。另外我强烈建议你在 DMA buffer 后面留 16 字节以上的“熔断区”不要因为越界写坏导致无法定位。这块额外的缓冲空间不参与传输但能在内存越界时大概率保下你的堆栈和全局变量。2.3 描述符链表与连续请求把一次搬运升级成一条流很多驱动开发场景不只是“搬一次”而是“连续不断地搬”。比如 ADC 连续采样数据量一天到晚都在产生你不能每搬完一次都让 CPU 去重新启动一次。这时候就要用到 DMA 的描述符链表、循环模式或者 dual-buffer 模式。描述符链表descriptor ring / scatter-gather list是 DMA 控制器自己维护的一组传输任务每个描述符里保存了源地址、目的地址、长度和下一个描述符的地址。CPU 只需要一次性把描述符列表建好DMA 会按顺序执行执行完一个自动跳转到下一个。这种机制很适合高速连续数据采集CPU 可以在 DMA 执行的同时处理其他任务。在嵌入式 Linux 上这个思路对应dmaengine_prep_slave_sg和dma_map_sg等接口适配多段不连续缓冲区。裸机侧比如 STM32F4 的 DMA 控制器支持 linked-list mode你可以在启动前构造多个描述符让 DMA 在多个缓冲区之间轮流传输。连续请求还有一个常见问题DMA 完成回调太晚数据被覆盖。最典型的是串口一包接着一包地发上一包还没被协议栈处理下一包已经写进同一个 buffer。解决办法通常是使用双缓冲buffer 分成 A/B 两块DMA 在搬 A 时 CPU 处理 B搬完 A 后再自动切到 B同时把 A 的数据交给处理逻辑。这样采集和处理可以流水线并行不浪费任何一拍。在设计连续传输的驱动时我习惯先用“数据流”的眼光看问题。不要去想“这次搬运完成”而是想“什么时候数据量达到可以交给上层处理”然后用一半传输完成中断、连续传输完成中断、dma_poll或者消息队列把数据流导出去。这个思维转变是 DMA 驱动开发从入门到熟练的关键。3. 实操从串口到 SPI ADC完整过一遍 DMA 驱动开发流程3.1 裸机侧用 STM32/GD32 搭串口 DMA 空闲中断收发先做一个非常经典的实验串口 DMA 空闲中断收发。这个实验几乎把 DMA 驱动开发里所有基础点都覆盖了外设配置、地址、缓冲区、中断、连续接收。先说思路串口 DMA 接收让 DMA 帮我们把串口收到的数据源源不断搬到内存缓冲区串口空闲中断负责告诉我们“这波数据收完了”。因为串口空闲表示总线上暂没有新数据这是一个天然的“一帧结束”标志。这样 CPU 不需要每个字节都进中断每帧数据只需要在串口空闲时处理一次。具体配置我以 STM32 系列为例步骤大致如下#define UART_RX_BUF_SIZE 512 // 强制对齐防止 DMA 地址不对齐 static uint8_t uart_rx_buf[UART_RX_BUF_SIZE] __attribute__((aligned(32))); static uint8_t dmabuf_read_index 0; void uart_dma_init(UART_HandleTypeDef *huart) { // 使能 UART DMA 接收并将数据放入 uart_rx_buf // 使用空闲中断来标记一帧结束 __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); HAL_UART_Receive_DMA(huart, uart_rx_buf, UART_RX_BUF_SIZE); } void UART_IdleCallback(UART_HandleTypeDef *huart) { // 读取当前 DMA 剩余计数算出实际收到多少数据 // 比如使用 __HAL_DMA_GET_COUNTER(huart-hdmarx) uint16_t rx_count UART_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart-hdmarx); // 交给协议解析层注意数据起始位置和跨 buffer 边界 parse_uart_data(uart_rx_buf, rx_count); }这里有几个要点。第一HAL_UART_Receive_DMA是循环接收吗不同库版本行为不同建议先确认是否开启循环模式。如果是普通模式搬满 buffer 之后 DMA 会自动停止你需要重新启动下一次接收如果缓冲区满了却没有及时处理后续数据就丢了。更稳妥的做法是用双缓冲再配合空闲中断同一时刻保护两块 buffer 交替切换。第二空闲中断里读取rx_count的时机必须在 DMA 还没被重新启动前完成否则计数已经被清掉。我之前踩过一个坑在空闲中断里先调用HAL_UART_Receive_DMA重启 DMA再去读__HAL_DMA_GET_COUNTER结果已经读到的是清零后的值数据长度永远为 0。正确顺序是先读出剩余计数再重新启动 DMA。第三要注意多字节之间的竞争问题。DMA 还在搬运数据时空闲中断发生了说明此刻总线上已经没有新数据但缓冲区里可能还有最后几个字节正在被 DMA 写入。理论上此时 DMA 已经写完了最后一批数据但为了万无一失最好加一个小延时或者等待 DMA 传输结束标志。这个实验的重点不是代码有多难而是让你理解“外设、DMA、内存、中断”这四者之间的关系。每一步配置背后都有一个原因为什么对齐、为什么用空闲中断、为什么先读计数器再重启 DMA。把这些想通了其他外设的 DMA 配置基本可以触类旁通。3.2 高速采集SPI DMA 跑 AD7606 连续采样的设计要点接下来是一个更实战的场景用 SPI DMA 从 AD7606 这类 ADC 连续采集数据。AD7606 是 16 位、8 通道同步采样的 ADC在电力监控、工业数据采集里很常见。它的关键行为是一次触发可以同步采样 8 个通道数据通过串行接口逐通道往外传。硬件接入时通常使用 SPI 主机处理器作为 SPI master 去读数据。驱动设计的最大难点在于AD7606 采样率可能很高每个通道 16 位8 个通道就是 128 位也就是 16 个字节。如果采样率是 10 kSPS每秒要读 160 KB 数据如果采样率到 50 kSPS就是 800 KB/s。数据量上来以后靠 CPU 在 SPI 中断里一字节一字节地读基本会打满一个中端芯片的算力。这里就非常依赖 DMA。我建议的设计流程如下初始化 SPI 为主模式开启 DMA 接收通道外设地址指向 SPI 数据寄存器内存地址指向接收缓冲区。每次触发一次 ADC 转换后读取 BUSY 引脚等待转换完成然后启动一次 SPI DMA 接收一次传输 16 字节对应 8 通道 × 2 字节。为了连续采集可以先把 AD7606 的 CONVST 用定时器 PWM 触发PWM 频率就是采样率。每次转换完成后DMA 把 16 字节数据搬到接收缓冲区主循环或中断里收到 DMA 完成事件后再把数据按通道拆分、转换成实际电压值。关键参数上SPI 时钟分频不能太激进。你可能会想“SPI 时钟越高读取越快”但 AD7606 的串行时钟有上限超过它读到的基本都是噪声。做驱动开发时要先看 ADC 数据手册里的最大串行时钟频率再配置 SPI 分频值。还有一个细节我在实际项目里反复遇到过SPI DMA 接收时如果设置的数据宽度和外设寄存器宽度不匹配就会出现通道数据错位。比如 SPI 数据寄存器是 8 位你 DMA 设置了 16 位驱动会一次读走两个字节虽然速度上快了但两通道的数据顺序就乱了。AD7606 是 16 位 ADC串行时序上每个通道数据是 16 bit但 SPI 接口的 FIFO 可能按 8bit 或 16bit 访问必须根据主控的寄存器宽度和 SPI 帧格式来配置。在连续采样模式下我强烈建议把 DMA 缓冲区开成环形一次 DMA 传输长度设成 256 或 512 个样本块传输完成时自动回到缓冲区开头继续写同时开启“一半传输完成”中断。这样可以让 CPU 在前半块数据处理完之前DMA 已经在写后半块了。如果缓冲区太小、数据产出速度又太快就会出现“数据覆盖”问题。冲一下缓冲区大小的计算假设采样率 (f_s 10\text{kSPS})每个样本 16 字节每秒产生 160 KB。配置 8 KB 缓冲区DMA 循环模式CPU 最多有 (8192/160000 0.0512) 秒 51.2ms 来处理一块。如果业务层处理时间超过这个值数据一定会丢。所以缓冲区大小必须配合数据处理耗时来设计而不是拍脑袋选。3.3 嵌入式 Linux 侧DMAEngine 驱动与 dma-buf 协作到了 Linux驱动开发的方式变成基于内核子系统。Linux 提供了一套叫 DMAEngine 的通用框架把各种 DMA 控制器的差异封装起来驱动开发者需要做的是从设备树里拿到 DMA 通道然后配置传输参数、提交请求、等待完成回调。设备树里的描述通常长这样serial0: serial10000000 { compatible vendor,uart-dma; dmas dma0 0, dma0 1; dma-names rx, tx; };驱动里通过dma_request_chan(dev, rx)拿到接收通道。然后准备缓冲区用dma_alloc_coherent分配一块物理连续且地址对齐的内存拿到它的 dma_addr再调用 DMAEngine API 启动传输。static struct dma_chan *rx_chan; static void *rx_buf; static dma_addr_t rx_dma_addr; static int xxx_probe(struct platform_device *pdev) { struct device *dev pdev-dev; rx_buf dma_alloc_coherent(dev, BUF_SIZE, rx_dma_addr, GFP_KERNEL); if (!rx_buf) return -ENOMEM; rx_chan dma_request_chan(dev, rx); if (IS_ERR(rx_chan)) { dma_free_coherent(dev, BUF_SIZE, rx_buf, rx_dma_addr); return PTR_ERR(rx_chan); } // 配置 DMA slave 设备传输参数 dma_set_slave_config? 这里省略根据具体控制器配置 return 0; }真正启动一次传输时会用到dmaengine_prep_slave_single。这里要非常小心DMA_PREP_INTERRUPT标志它决定了这次传输完成后是否通知你。如果你在高速采集场景下每个小的传输块都产生中断中断频率依然会很高Linux 系统也未必吃得消。更好的方式是把 DTS 里的缓冲区设大配合dma_request_chan获取通道一次提交多个描述符减少中断频率。Linux 侧更复杂的场景是 DMA buffer 需要被用户态访问。比如固件采集、AI 推理、图像采集DMA 直接把数据从传感器搬到分配给用户进程的dma-buf上用户态用mmap就可以看到。这里就会用到dma_buf映射、struct dma_buf_attachment和 cache 同步操作。编写驱动时要注意 dma-buf 导入导出的方向生产者在内核里把 DMA 地址放到 dma-buf 里消费者通过dma_buf_map_attachment得到 sg table然后配置 DMAEngine 去访问这些分散的内存页。总之Linux 侧 DMA 驱动不是单纯会调 API 就行了它背后涉及内存管理、设备树、中断、并发和调度的综合能力。第 6 期这个位置我可以先给你一套能跑通的最小框架配置 dmas、申请通道、分配一致性缓冲区、提交传输、完成回调中唤醒等待队列。顺着这条路后续再谈 dma-buf 和用户态零拷贝会更容易理解。4. DMA 常见故障、调试顺序与测速验证实录4.1 丢数、错位、乱码按这个顺序排查通常半小时搞定DMA 驱动开发里故障现象最常见的是“数据不对”。我经常收到类似的问题“串口 DMA 接收收到的数据偶尔乱码偶尔丢几个字节到底哪里有问题”这种问题不能瞎猜最好按顺序排查。我把自己的排查顺序整理成一张速查表现象优先排查项原因方向偶发乱码、错位Cache 一致性、缓冲区覆盖CPU/DMA 访问同一块内存互相覆盖每次都在固定位置丢字节缓冲区大小、数据宽度配置DMA 传输长度或外设地址宽度不一致数据完全不对全是重复旧值未做 invalidate读到 Cache 旧副本DMA 写内存后 CPU 读到旧数据启动 DMA 后无法进入中断DMA 中断使能、NVIC 优先级、通道映射中断配置遗漏或优先级被阻塞接收一帧后不再收循环模式未开或 buffer 满后未重新启动普通 DMA 模式传输完成后自动停止Linux 下 DMA 超时设备树 dmas 错误、dmaengine 未正确启动通道申请失败、时钟未使能核心排查方法是先确认“搬运过程本身是否完成”。如果 DMA 确实把数据从寄存器搬到 buffer 了但 CPU 读出来不对那就优先怀疑 CPU 和 DMA 之间的一致性。如果 DMA 根本没有搬完那就从外设触发、DMA 通道、中断使能一路查上去。一个特别反直觉的经验不要一上来就用调试器单步跑 DMA 驱动。DMA 是硬件行为时间上跟 CPU 单步并不一致你在断点处看到的 buffer 状态往往已经变了。我遇到数据错乱问题时一般会在启动 DMA 前把 DMA 控制器相关寄存器完整地打印一遍然后在完成中断里用 DMA 控制器自带的剩余计数值反推实际传输长度对比预期长度很快就能判断问题出在配置还是数据处理阶段。4.2 中断风暴、DMA 挂死与 half-transfer 调度DMA 驱动跑起来以后另一个难题是性能相关的中断频率太高或者 DMA 在连续请求中挂死。中断风暴最常见的成因是缓冲区太小、传输块太小。比如一个 1KB 缓冲区DMA 每搬完 128 字节就中断一次高速场景下每秒中断几十万次系统根本没法响应别的任务。解决思路有两个把单次 DMA 传输块加大或者用循环模式配合半传输中断只有在半满和全满时才处理数据。在裸机上可以开启 DMA 的 half-transfer 中断和 transfer-complete 中断配合双缓存轮流使用。这样每次中断处理一个半缓冲区处理完之后另一半缓冲区还在被 DMA 填消费和产出的节奏就能匹配上。DMA 挂死的现象通常是没有完成中断也没有错误中断像是永远卡住了一样。我遇到过的原因主要有这几类DMA 控制器和外设之间的时钟没有使能配置寄存器写进去没反应DMA 请求信号一直有效但外设没有及时应答控制器一直等待使能了错误中断但错误处理只是在中断里打印日志没有清错误标志导致中断反复进入死循环在 Linux 下dmaengine的完成回调里调用了可能导致休眠的函数比如msleep或wait_event_interruptible阻塞了工作队列。最后一条尤其值得注意。DMA 完成回调运行在中断上下文或软中断上下文不能做长时间等待。如果需要把数据转交给用户态正确做法是唤醒一个等待队列让内核线程或应用层去处理数据而不是在回调里做完整的数据处理。我个人在项目中会额外加一个“DMA 看门狗”在 DMA 启动后开一个短定时器如果规定时间内没收到 DMA 完成中断就触发错误状态主动复位 DMA 外设避免整个数据流长期停摆。这种处理在量产型设备上非常管用至少不会让设备因为一次偶发的 DMA 挂死就彻底失联。4.3 DMA 测速的两种姿势裸机 GPIO 翻转和 Linux 吞吐统计做 DMA 驱动光能跑通还不够你得知道它到底跑得有多快、瓶颈在哪里。测速不能只看理论带宽我推荐两种真实的测速姿势。裸机侧最简单实用的办法是 GPIO 翻转法。选择两个空闲的 GPIO 引脚一个在启动 DMA 前置高一个在 DMA 完成中断里置高在完成回调后立刻置低然后用逻辑分析仪或者示波器观察两个脉冲之间的时间间隔。这个间隔就是“DMA 从配置到完成中断产生”的固定开销以及中断响应延迟。重复测试多次如果间隔抖动很大那就说明中断调度上有问题可能有其他高优先级中断抢占了 DMA 完成回调。再进一步可以在 DMA 完成中断里翻转 GPIO在主循环里翻转另一个 GPIO看两个波形的时间差能直观评估 CPU 处理数据的耗时。这个方法虽然土但在底层开发中比任何 profiling 工具都直观。Linux 侧测速我一般同时看两个指标吞吐率和中断延迟。吞吐率可以用perf或者自己写一个内核计数器统计单位时间内完成的数据量。中断延迟可以在 DMA 中断里记录时间戳ktime_get用户态读出两次中断之间的时间差。需要特别注意不要拿上位机 ping 包来回的延迟来评估 DMA 速度那不是 DMA 的瓶颈而是网络协议栈的瓶颈。另外我做 DMA 测速时经常会调试一个错误码叫-EBUSY或-EAGAIN。这种错误通常说明 DMA 通道已经被占用或者上一次传输还没结束你又提交了一次新的。出现这种问题时不要去改超时时间“拖”过去而是确认这次的提交动作是否发生在上一次传输完成之前。在连续请求设计里这是最常见的并发一致性错误。我的经验是DMA 驱动的性能问题绝大多数不是 DMA 控制器本身不够快而是 CPU 侧处理数据的流程设计得不对。测速不是为了让数字好看而是为了找到真正浪费时间的环节。最后分享一个我长期在用的习惯任何 DMA 驱动我都会在注释里把 buffer 地址、大小、对齐方式、外设地址宽度、传输模式和 Cache 操作写清楚。看着有点啰嗦但三个月后再去维护代码你会发现这些注释比当初写的“启动 DMA”这句话有用得多。做嵌入式驱动开发最贵的不是芯片也不是代码而是调试时间。DMA 这种对时序敏感、对一致性要求高的模块每一分提前写清楚的说明都能帮你省下后面一次通宵排查。