ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式DMA驱动实战:从Cache一致性到串口/SPI与Linux DMA

嵌入式DMA驱动实战:从Cache一致性到串口/SPI与Linux DMA

在嵌入式驱动开发里,DMA 是我一直觉得最该单独写一篇的话题。这次是《嵌入式驱动开发经验》系列第 6 期,专门聊 DMA。很多人把 DMA 理解成“把数据从 A 搬到 B”,这个说法没错,但驱动开发里真正的难点从来不是搬运本身,而是搬运前的外设配置、搬运中的内存一致性问题、搬运后怎么高效地把数据交给业务层。这篇内容我不会只贴寄存器配置,而是把一个真实项目里用到的 DMA 设计思路、串口和 SPI/ADC 场景下的配置方法、Cache 一致性处理、连续传输设计,以及 Linux 侧 DMA 驱动和用户态协作的常见做法,按我自己的调试和踩坑经验整理出来。适合刚开始接触嵌入式驱动的新人,也适合已经在写外设驱动但被 DMA 各种疑难杂症折磨的工程师,读完至少能少走不少弯路。

1. 先搞清楚DMA在嵌入式驱动里到底解决什么问题

1.1 一个搬运工引发的思考:CPU 是不是被外设绑架了

我先用一个很直接的类比来说明 DMA 的角色。驱动开发里,外设每准备好一个字节,CPU 就要去读一次数据寄存器的动作,相当于你把整个公司的所有订单都交给老板一个人去核对、签字、装箱。老板确实能干,但干这个的时候没法看邮件、没法开会、更没法处理突发问题。外设特别多、数据量特别大时,CPU 的时间全耗在“搬运”上了,整个系统的实时性就被拖垮了。

DMA 做的事情就是请一个专门的搬运工,他只听 CPU 安排,把外设缓冲区里的数据往内存搬,或者把内存里的数据搬往外设。搬运完成的信号通过中断告诉 CPU,CPU 只需要关心数据到了没有,不用关心每个字节是怎么到的。

在嵌入式驱动开发的实际场景里,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 专用的连续内存池里拿 buffer;
  • Linux 驱动里,不要用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: serial@10000000 { 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 这种对时序敏感、对一致性要求高的模块,每一分提前写清楚的说明,都能帮你省下后面一次通宵排查。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进