ARTICLE DETAIL

资讯详情

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

嵌入式DMA驱动实战:从裸机到Linux的避坑指南

嵌入式DMA驱动实战:从裸机到Linux的避坑指南 做嵌入式驱动开发这么多年DMA 是我跟人聊得最多、也最容易翻车的一个外设。说起来大家都会配置打开时钟、选通道、配源地址和目的地址、设数据长度、使能中断、启动传输三分钟搞定。可真到了产线稳定性测试的时候反复出问题的往往还是 DMA——串口偶发丢一个字节、SPI 采回来的 ADC 数据偶尔错位、Linux 下用户态看到的 buffer 和 DMA 搬运的数据不是同一份。这一期是《嵌入式驱动开发经验》的 DMA 专题我不打算复述寄存器手册重点聊聊工程里怎么把 DMA 用稳、测准、调快以及从 MCU 裸机驱动切到嵌入式 Linux 驱动后思路要做哪些转变。适合正在写串口/SPI 驱动、调 ADC 连续采样、或者刚接触内核 DMA 子系统的开发者参考。1. 先聊一个资历越老越不敢忽视的问题DMA 到底帮驱动扛下了什么1.1 从一次串口接收中断引发的丢数据说起几年前我调一块 M4 平台的板子串口波特率提到 460800 之后原本能跑的中断接收开始不定时丢字节。当时第一反应是改缓冲区、提高中断优先级折腾了一下午问题反而更诡异一会儿连续收几十帧没问题一会儿又少两个字节。后来用示波器钩住 RX 引脚和一颗 GPIO把每次中断服务函数的时间戳打出来才看明白——CPU 根本没空及时响应新的接收中断。算笔账就清楚了。460800 波特率按常见的 8N1 格式每 10 个 bit 一个字节理论上一秒大约能收到 46080 字节也就是平均每 21.7 微秒就会有一个字节到达。如果在中断里只是把数据塞进环形缓冲那几十个周期还能扛可驱动里还带着协议解析、状态机切换、偶发日志打印单字节中断方式下 CPU 几乎被串口独占。115200 波特率时尚且能靠“高频中断简单处理”糊弄过去速率一上来单字节中断的方式就彻底撑不住了。DMA 的意义在这里不是“跑得更快”而是把“每字节打断 CPU”变成了“一整块数据搬完再通知 CPU”。串口 DMA 解决的就是这个场景外设的数据由 DMA 自动搬进内存缓冲区CPU 只需要在空闲中断或传输完成中断里处理一整块数据。我后来把这段驱动改成 DMA空闲中断后同样跑 460800CPU 占用从接近饱和降到了个位数。不是 CPU 变强了而是它不用再给每个字节当搬运工。1.2 DMA 的本质不是“快”而是“让 CPU 不必排队”很多初学者把 DMA 理解成“加速器”这个视角容易在调优时走偏。实际上 DMA 并没有让总线上的数据跑得更快它只是让传输这件事不再占用 CPU 的执行窗口。总线带宽、外设 FIFO 深度、时钟频率这些硬件限制还在DMA 改变的是资源的调度方式。我用一个生活化的类比普通中断接收相当于家里没人收快递快递员每按一次门铃你就得从二楼跑下来开门签字。DMA 相当于在门口装了一个带锁的快递柜快递员直接塞进去你忙完了再去取。快递没有变快但你不再被反复打断。驱动开发里真正值钱的设计是让 CPU 只在“非处理不可”的时刻被唤醒例如一帧数据完整到达、一次 ADC 转换完成、一个 DMA 描述符链路结束。其余时间 CPU 可以做协议处理、跑算法、响应更高优先级的事件。1.3 这篇文章适合谁看这期内容我按实际项目经验来写不按芯片手册结构走。适合几类朋友还在用单字节中断收串口、想换 DMA 但不确定怎么设计的人已经在用 DMA但遇到“偶尔丢数”“数据错位”“死锁”这类疑难杂症的人从裸机 MCU 驱动转去做嵌入式 Linux 驱动搞不清dma_alloc_coherent、dma_map_single该用哪个的人单纯想把 ADC 连续采样、SPI 高速采集的性能再压榨一下的人。如果你只是想看“怎么在 CubeMX 里点几下生成 DMA 代码”这期可能帮不上太多。但如果你想知道为什么生成出来的代码会在特定情况下翻车那这篇文章应该对你有用。2. 动手写 DMA 驱动前我先给你一张必查清单2.1 通道、请求号和外设的映射关系DMA 驱动写错一半以上的原因是把通道和请求号搞混了。不同厂商、不同系列DMA 控制器和外设之间的映射差异非常大。STM32F103/GD32 这类 M3 上DMA1 的通道 4 对应 USART1_TX通道 5 对应 USART1_RX可到了 STM32F407/GD32F4 这类带 DMA 流的芯片上外设请求变成了“DMA 流通道选择”的组合。再往后到 GPDMA 或者 DMA 请求路由器这类新外设映射方式又换了一套。写驱动之前我建议先做一件事把当前芯片参考手册里的 DMA 请求映射表打印出来用笔把工程里用到的每个外设请求圈出来。至少确认三句话这个外设的 TX/RX 分别挂在哪个 DMA 通道或 DMA 流上这个 DMA 实例的时钟是否已经使能外设是否还需要单独使能 DMA 请求位比如USART_CR3里的DMAR/DMAT。很多“DMA 不工作”的案例其实只是外设侧的 DMA 请求没打开。寄存器配置看着没问题但外设根本没能力发出请求。2.2 内存地址、数据宽度和对齐DMA 的源地址和目的地址都有对齐要求。外设寄存器地址通常是固定的比如串口数据寄存器地址低 2 位为 0用字节宽度没问题但内存缓冲区的地址如果不对齐在某些 Cortex-M 上会触发总线错误或者性能莫名下降。对 DMA 来说缓冲区首地址至少按数据宽度对齐这是底线。举个例子如果你配置 DMA 以 32 位字宽搬运数据却把一个定义成uint8_t buf[100]的缓冲区首地址直接丢给 DMA虽然很多芯片能跑但如果编译器恰好把数组放到了非 4 字节对齐的地址上轻则效率变差重则 hard fault。我的习惯是static uint8_t rx_buf[1024] __attribute__((aligned(4)));这句话值得写进团队代码规范。无论用 GCC 还是 Keil/AC5/AC6都应保证 DMA 缓冲区按最大访问宽度对齐。2.3 Cache 一致性裸机也躲不开的问题一提到 Cache 一致性很多人以为只有 Linux 和高端 ARM 处理器才需要考虑。现在很多 M7、M33、RISC-V 芯片也带 Cache 或者可配置的加速器裸机驱动同样会翻车。典型场景CPU 先把数据写在内存缓冲区里然后启动 DMA 把这段内存搬到外设。如果数据还留在 CPU 的 Cache 里没有写回内存DMA 读到的就是旧内容。反过来DMA 从外设收到的数据如果只写进了内存CPU 从 Cache 里读时可能命中陈旧行读出的还是旧数据。裸机上解决办法分两类要么使用 DMA 专用内存区域并禁用 Cache要么在传输前后做 Clean 和 Invalidate。Cortex-M 内核一般提供SCB_CleanDCache、SCB_InvalidateDCache这类函数。传输前清 Cache传输完成后再使 Cache 失效顺序不能反。不是所有芯片都会在文档里写清楚这一点但那不代表它不存在。2.4 中断标志和清理顺序DMA 中断标志的清理顺序是个隐蔽问题。很多寄存器是“读状态然后写事件位清零”的设计比如先读ISR判断是完成事件还是错误事件再向IFCR写对应位。如果代码里先清完成标志再清错误标志但此时错误事件刚好发生了操作顺序就可能把完成标志和错误标志搞混导致下一次传输进入异常状态。我通常把状态检查和标志清理放在同一个临界区里最好关中断或者使用原子操作。调试阶段还会加一个计数器记录每次中断进入时看到的事件位这样能快速发现“DMA 完成中断里混入了传输错误中断”的情况。很多人只关注TCIF忽略了TEIF、HTIF等系统跑一段时间后偶发超时才反应过来错误中断从来没被处理过。3. 串口 DMA 实测STM32/GD32 上位机框架3.1 初始化流程与关键配置串口 DMA 我最常用的组合是“UART 空闲中断 DMA 接收”。固定长度接收用 DMA 传输完成中断就够了但实际项目里报文长度经常不定这时候空闲中断是最好用的判定手段。下面这段以 STM32 HAL 为例GD32 库函数风格类似注意寄存器和标志名不同#define RX_BUF_SIZE 1024 static uint8_t rx_buf[RX_BUF_SIZE] __attribute__((aligned(4))); static volatile uint16_t rx_len; void uart_dma_rx_init(void) { __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); } void uart1_idle_isr(void) { if ((__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) (__HAL_UART_GET_IT_SOURCE(huart1, UART_IT_IDLE) ! RESET)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); HAL_UART_DMAStop(huart1); /* 计算本次接收到的数据长度 */ rx_len RX_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(hdma_uart1_rx); /* 在这里按帧处理 rx_buf 中的数据 */ handle_frame(rx_buf, rx_len); /* 重新接入 DMA 接收 */ HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); } }这段代码的关键在HAL_UART_DMAStop。空闲中断触发时DMA 计数器还有剩余值通过RX_BUF_SIZE - 剩余计数就能算出发送方实际发来了多少字节。处理完一帧后要重新启动 DMA 接收否则只能收到一次数据。GD32 上对应的写法有点区别尤其清空闲标志和中断标志的时候要按参考手册操作UART_STATUS、UART_STAT这类寄存器不能照搬 HAL 的宏。很多踩坑都是因为库版本不同标志位的定义名相同、语义不同。3.2 不定长接收的经典套路固定报文协议用 DMA 传输完成中断就够了DMA 搬完指定长度触发完成中断CPU 处理。问题是如果对端发到一半不发了DMA 永远也搬不够长度你的接收逻辑永远不被唤醒。所以才需要空闲中断来“截胡”。还有一种更激进的做法把 DMA 配置成循环模式缓冲区始终填满空闲中断到来时只记录当前写入位置上一帧的处理结果可以从环形缓冲里取出。这个方案的优势是 CPU 几乎不需要停 DMA数据无缝衔接。代价是缓冲区管理和读写指针同步要做对否则容易出现“上一帧数据被下一帧覆盖”的竞态。我在项目里通常先用最简单的“空闲中断DMA 停止重新启动”方案跑通了再优化成循环模式。不要一开始就上环形缓冲调试时出现数据覆盖很难分清是 DMA 问题还是环形缓冲代码问题。3.3 发送 DMA 的一个隐蔽坑完成标志不等于发送完毕DMA 发送完成中断触发时UART 的发送数据寄存器和移位寄存器可能仍有数据没发完。如果驱动在 DMA 完成中断里立刻做低功耗关闭、关时钟、或者切换 GPIO 复用最后几个字节很可能被截断。正确做法是等USART_ISR里的TCTransmission Complete标志或者至少确认TXE且数据寄存器为空。很多应用只开了 DMA 传输完成中断不知道还要等 UART 的 TC结果高速发送时最后几个字节丢失而且是偶发非常难查。我习惯在 DMA 发送完成中断里检查到 UART TC 置位后再触发业务回调如果不能阻塞等待就开启 UART 的 TC 中断在 TC 中断里做真正的收尾工作。3.4 实测不同波特率下的表现有一块 STM32F103 平台我做过一组对比。接收 64 字节定长帧统计 CPU 占用波特率单字节中断方式串口 DMA空闲中断方式115200约 8%约 2%460800约 45%约 5%921600丢包明显中断处理不过来约 8%稳定这些数据只能在当时的系统时钟、中断频率、主循环负载下作参考。但趋势是很明显的波特率越高DMA 收益越大。到了 921600单字节中断方式已经难以为继DMA 方式反而还有余量。别把“DMA 一定能提升性能”当成绝对结论如果系统主频足够高、帧很短、中断频率不高DMA 收益可能很小。工程上要按实测数据判断而不是按规格书想象。4. SPI DMA 搬运多通道 ADCAD7606 双缓冲设计复盘4.1 为什么高速 ADC 采集非要用 DMA我在项目里常用 AD7606 这类 8 通道同步采样 ADC。一次转换得到 8 个通道的数据每个通道 16 位在 SPI 模式下需要连续读不少字节。如果让 CPU 一个字节一个字节从 SPI 数据寄存器里取采样的同时还要做滤波、异常判断、数据打包很容易占满整片 CPU 的时间。SPI DMA 的作用是把连续多次 SPI 接收合并成一次批量搬运配置好 DMA 目的地址和长度启动后 SPI 每收到一个字节DMA 自动把它写进内存。CPU 等一整块采样数据搬完再去处理效率完全不一样。对于 AD7606还要注意它在转换期间 BUSY 引脚会拉高转换完成后拉低。驱动应该在 BUSY 下降沿之后启动 SPI DMA 读取或者靠定时触发保证时序否则可能读到未完成转换的数据。4.2 双缓冲交替使用两个采样缓冲区单缓冲 DMA 的问题很直接DMA 正在往缓冲区 A 写数据时CPU 不能去读 A只能等它完全结束。这段时间 CPU 闲置采样数据越多浪费越大。双缓冲的思路是准备两个缓冲区DMA 当前写 ACPU 处理 B下一次传输 DMA 写 BCPU 处理 A。在支持双缓冲的 DMA 控制器上可以直接配置 DMA 的 memory-to-memory 或多缓冲模式。更通用的做法是在传输完成中断里交换缓冲区索引#define ADC_BUF_SAMPLES 1024 static uint16_t adc_buf[2][ADC_BUF_SAMPLES]; static volatile uint8_t current_buf; void adc_dma_complete_isr(void) { uint8_t done_buf current_buf ^ 1; process_adc_data(adc_buf[done_buf], ADC_BUF_SAMPLES); current_buf done_buf; }这个框架保证了 DMA 始终在用其中一个缓冲区接收数据CPU 在处理另一个缓冲区。要注意的是如果没有硬件双缓冲切换机制需要在完成中断里“重新配置 DMA 目的地址到另一个缓冲区”切 buffer 的瞬间要关 DMA、改地址、再启动。关 DMA 的窗口越短越好否则丢失采样的概率会增加。4.3 同步点如何知道一整轮采样已经落地调 AD7606 SPI DMA 时“什么时候知道数据已经完整”是个关键同步点。我的做法分两级第一级是 BUSY 下降沿表示 ADC 已完成本轮采样可以启动 SPI 读取。第二级是 SPI DMA 传输完成中断表示所有通道数据已经搬运到内存缓冲区。这两级之间必须保证时序合理。如果 BUSY 下降沿触发的外部中断里直接启动 DMA那中断响应延迟决定了启动时刻的抖动抖动过大可能让 SPI 采样太早或太晚。我曾经用逻辑分析仪量过 EXTI 中断到 DMA 第一次读 SCLK 的延迟高优先级系统繁忙时会相差接近 2 微秒。对几十 MHz 的 SPI 来说2 微秒意味着可能损失几十个时钟周期。后来我改成在 EXTI 中断里只设置标志并唤醒一个高优先级任务SPI DMA 的启动由该任务负责配合定时器同步稳定性明显改善。驱动里同步点不要只看“DMA 完成”还要看“什么时候启动 DMA ”。这两个时间点里的任何一个抖动都会直接影响采样数据的有效性。5. 嵌入式 Linux 下的 DMA从裸机思维切到内核的 DMA API5.1 裸机驱动和内核驱动的最大差异裸机环境下驱动直接访问物理地址DMA 缓冲区就是物理内存里的一段连续区域简单直接。到了嵌入式 Linux 下CPU 通过 MMU 访问虚拟地址而 DMA 控制器看到的是物理地址或经过 IOMMU 映射后的地址。驱动程序员最容易犯的错就是把kmalloc得到的虚拟地址直接交给 DMA 控制器然后发现设备读的不是同一份数据。更麻烦的是有些缓冲区在虚拟地址上是连续的物理页却分散在不同位置。没有 IOMMU 的设备无法直接对这种物理散页做 DMA。所以 Linux 内核专门提供了一套 DMA 映射 API既解决物理连续性也解决 Cache 一致性问题。在嵌入式 Linux 里写 DMA 驱动最重要的不是背寄存器而是搞清楚你用哪套映射方式。5.2 dma_alloc_coherent 与 streaming DMA 映射怎么选内核里最常见的两套 DMA 映射方式特性dma_alloc_coherentdma_map_single / dma_map_sg适用场景一致性内存、控制结构、小数据大数据块传输、流式数据物理连续性保证由参数决定sg 支持离散页Cache 维护自动处理一致性需要驱动显式 sync生命周期分配后一直存在适合长期缓冲一次传输映射用完解除dma_alloc_coherent典型用法是给 DMA 描述符、网络接收环形队列分配一段 CPU 和设备都能直接访问的内存void *cpu_addr; dma_addr_t dma_addr; cpu_addr dma_alloc_coherent(dev, size, dma_addr, GFP_KERNEL); if (!cpu_addr) { return -ENOMEM; } /* 此时 dma_addr 是设备可以使用的 DMA 地址cpu_addr 是 CPU 访问的虚拟地址 */dma_map_single则用于一次性的流式 DMA比如把用户传入的缓冲映射给 DMA 设备传输结束后解除映射dma_addr_t addr; addr dma_map_single(dev, buf, len, DMA_FROM_DEVICE); if (dma_mapping_error(dev, addr)) { return -EIO; } /* 启动 DMA 传输等待完成 */ dma_unmap_single(dev, addr, len, DMA_FROM_DEVICE);如果漏了dma_map_single或者该用dma_alloc_coherent的地方用了普通内存DMA 偶发数据错误、Cache 不一致问题就会随机出现。Linux 的 DMA API 看似只是封装实际把架构相关的 Cache 和总线行为都处理掉了不要自己用virt_to_phys手工计算物理地址绕过它。5.3 “连续 DMA 请求”在内核里的调度方式题目里提到dma continuous requests我理解这是指设备需要持续不断地发起 DMA 传输而不是一次性的“搬运完就结束”。音频采集、连续 ADC 采样、高速数据接收都属于这种模型。内核 DMA engine 子系统提供了几种描述符类型最常用的是dmaengine_prep_slave_single处理单次请求dmaengine_prep_dma_cyclic处理循环请求dmaengine_prep_slave_sg处理分散/聚集请求。循环 DMA 非常适合“连续请求”场景驱动提交一次环形缓冲区描述符DMA 持续写数每写满一个 period 触发一次回调业务层在回调里取走一个 period 的数据。这样驱动不需要每完成一次传输就重新提交描述符减少了反复配置的时间开销CPU 中断频率也得到控制。之前我在 C6678 风格的多核 DSP 平台用过类似机制换到 Linux 下用dmaengine_prep_dma_cyclic非常顺手。另一个思路是提交多个单次描述符让 DMA 引擎自动排队。Linux DMA engine 内部有 pending 队列一批描述符用dmaengine_submit提交后dma_async_issue_pending会尽量连续执行。但这依赖具体 DMA 控制器驱动的实现并不是所有平台都支持完美接续。做高吞吐连续采集优先考虑 cyclic其次才是多描述符排队。5.4 一个极简的 Linux DMA 字符设备框架我平时写测试驱动结构基本固定probe 里请求 DMA 通道read 里提交描述符DMA 完成回调唤醒等待的进程。简化代码如下static struct dma_chan *dma_chan; static struct completion dma_complete; static void dma_callback(void *param) { complete(dma_complete); } static ssize_t mydev_read(struct file *file, char __user *user_buf, size_t size, loff_t *pos) { struct dma_async_tx_descriptor *desc; dma_addr_t addr; void *buf; buf kzalloc(size, GFP_KERNEL); addr dma_map_single(dev, buf, size, DMA_FROM_DEVICE); desc dmaengine_prep_slave_single(dma_chan, addr, size, DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT); desc-callback dma_callback; desc-callback_param NULL; reinit_completion(dma_complete); dmaengine_submit(desc); dma_async_issue_pending(dma_chan); wait_for_completion_interruptible(dma_complete); dma_unmap_single(dev, addr, size, DMA_FROM_DEVICE); copy_to_user(user_buf, buf, size); kfree(buf); return size; } static int mydev_probe(struct platform_device *pdev) { struct dma_slave_config cfg {0}; dma_chan dma_request_chan(pdev-dev, rx); cfg.direction DMA_DEV_TO_MEM; cfg.src_addr (unsigned long)get_my_peripheral_addr(); dmaengine_slave_config(dma_chan, cfg); init_completion(dma_complete); return 0; }这段代码不是完整驱动但体现了核心流程。实际项目中还要处理copy_to_user失败、DMA 回调里的上下文限制、通道释放、错误恢复等。至少先弄清楚这些 API 的调用关系再去看具体 DMA controller 驱动的实现会轻松很多。6. DMA 测速不是跑个工具就完事我的调优方法和判断标准6.1 怎么测出真实吞吐而不是缓存里的假数据搜索引擎里经常看到“DMA 测速工具”“dma 测速失败代码”这类词。很多人跑测速只盯着“搬了多少字节 / 用了多长时间”的比值忽略了一个大坑CPU 和 DMA 之间存在 Cache 和预先取指的影响缓冲区很小时测出来的速度可能远高于真实可用带宽。我常用 GPIO 翻转加示波器测时间启动 DMA 前拉高一个空闲 GPIO。在 DMA 完成中断里把 GPIO 拉低。示波器量高电平持续时间。用数据长度 / 实际时间计算真实吞吐。这个方法能把 DMA 搬运本身的时间和中断响应时间一起算进去。只测“函数里 DMA 启动到完成之间的周期数”是不够的驱动实际感知到的延迟还包括中断响应、总线仲裁、Cache 同步。多测几组不同长度的数据至少用 64KB 和 1MB 两档才能看出是否存在带宽上限或 Cache 掩盖效应。6.2 总线仲裁、时钟树与外设时钟的影响DMA 看起来在“自动搬运”但它仍然要走总线。MCU 里 CPU、DMA、以太网、USB 等主设备共享总线谁占用总线哪个优先级更高直接影响 DMA 实测速度。很多工程师只看 DMA 控制器时钟是否使能忽视了外设时钟和总线时钟的分频关系。我曾经在某个平台上发现 SPI DMA 速率始终上不去最后定位到 SPI 外设时钟来自 APB2而 APB2 的分频系数被配置成系统时钟的一半。SPI 的波特率发生器确实也是从 APB2 得到的DMA 的时钟来自 AHB两边各说各话看起来配置没问题实际传输速率被外设侧限制住了。所以测速调优时要把三张时钟树图拉出来DMA 时钟、外设时钟、总线仲裁优先级。6.3 能提升 DMA 效率的几个小改动在确认硬件无误、总线没有瓶颈之后有几个软件层面的改动值得尝试尽量用更宽的数据宽度。能 32 位搬运就不要 8 位搬运总线对宽宽度访问的处理更高效也减少访问次数。保证内存缓冲区对齐到突发宽度。有些 DMA 支持 burst 模式源地址没对齐时控制器可能不得不拆成多次非突发传输性能下降。合理利用 FIFO 和 burst。如果外设带 FIFO先让 FIFO 积攒几个字节再触发 DMA可以降低 DMA 请求频率。减少完成中断里的耗时操作。不要在 DMA 完成中断里做printk、FLASH 写入、延时这类操作把它们推迟到工作队列或任务里。考虑多描述符预提交。在 Linux 下有 queue 机制在裸机下可以提前配置好下一个描述符等当前传输完成立刻启动减少空窗期。这些改动不是万金油但大部分平台的 DMA 性能瓶颈并不在于 DMA 引擎本身而在于没对齐、中断响应太慢、或者外设时钟配置不合理。6.4 什么情况该停下来怀疑硬件问题如果配置完全按照手册走测速结果依旧离谱就不能再反复调软件了。我见过好几次类似情况SPI DMA 数据错位软件里调了三天最后拿逻辑分析仪抓波形发现 MISO 上数据确实在跳变但采样点刚好落在边沿上相位极性配置错了。这不是 DMA 问题是 SPI 模式配置问题。还有一次是 DMA 测速结果很低排查了很久发现 PCB 上某颗电阻阻值不对导致信号边沿变缓SPI 时钟一高就误采样。这种问题靠改驱动永远解决不了必须回到示波器和逻辑分析仪这一层。驱动开发经验再丰富也不能取代测量尤其是涉及高速信号时。7. 驱动“偶尔失灵”的五个隐藏地雷7.1 关闭 DMA 后立刻操作外设HAL_UART_DMAStop、dmaengine_terminate_all这类接口只是“请求停止 DMA”不代表硬件立刻停下来。如果关闭 DMA 后立刻重新配置外设、改变缓冲区地址、或者进入低功耗很可能出现外设还有一次请求没完成DMA 又被重新使能状态错乱。最安全的处理是停止后查 DMA 通道使能位确认已经从硬件上真正禁掉再继续后面的操作。有些芯片的通道停止需要等待几个总线时钟周期不能省。驱动里一旦出现“偶发性丢数且复位后恢复”优先怀疑关断时序。7.2 缓冲区地址越界后的“假死”DMA 没有 MMU 保护它会忠实地把数据写到寄存器告诉它的地址。如果配置的数据长度比缓冲区真实大小大DMA 就可能写到相邻变量、堆栈、甚至中断向量表区域。表现通常是系统跑着跑着突然死机或者某个无关变量被篡改。调试时如果怀疑 DMA 越界我建议把缓冲区放在内存段里的独立区域并且使用调试器设置硬件断点或数据观察点在缓冲区末尾地址附近监控写入。工程上更要严格控制长度来源例如串口空闲中断计算出的rx_len一定不能超过RX_BUF_SIZE任何协议解析的长度字段都要先做边界校验。7.3 低功耗模式导致 DMA 配置丢失进入低功耗模式后有些 DMA 寄存器的内容会丢失或者 DMA 时钟被关闭唤醒后外设继续工作、但 DMA 没有恢复。很多“睡眠唤醒第一次收发正常第二次数据错乱”的 bug 都来自这里。正确做法是在低功耗恢复钩子里重新初始化 DMA 和缓存状态不要假设配置能保留。同时检查唤醒后外设是否还会发出多余数据如果外设侧 FIFO 里有残留要在第一次收发前清掉。7.4 中断里重复启动 DMA 导致的 Race Condition接收传输完成中断触发时CPU 在中断服务函数里处理数据如果处理没完成就重新启动 DMA下一帧数据可能立即到达并被 DMA 写入同一个缓冲区把刚才还没读完的数据覆盖掉。这种问题比较隐蔽因为不是每次都触发取决于下一帧到达的时间。我的做法是把“处理数据”和“重新启动 DMA”分离中断里先关 DMA 并保存有效数据或者把数据搬到临时区确认不再使用原始缓冲区后再重新启动。宁可多一次 memcpy也不能让读写指针产生竞态。7.5 错误中断不处理驱动会越陷越深DMA 控制器有传输错误、总线错误等事件。有些驱动只开传输完成中断错误中断被忽略或者错误中断触发后没有清理标志。结果错误状态一直挂在状态寄存器里后续每次重新启动 DMA 都因为遗留的错误标志而异常退出。我给驱动加状态统计的习惯已经保持了很多年每次 DMA 错误中断都记录错误码和发生时的通道状态。平时不显眼一旦产线出现偶发问题靠这些统计能快速缩小范围。别觉得加几行日志是多余很多疑难问题最终就是靠这些被“遗弃”的错误中断定位的。8. 最后分享一个产线定位 DMA 问题的土办法8.1 用 GPIO 脉冲测量真实时间线不要只靠软件断点和调试器很多时候调试器一停外设还在跑现场就变了。准备一个空闲 GPIO在 DMA 启动、完成、中断进入、中断退出这些关键点分别翻转用逻辑分析仪或示波器把时间线拉出来能直观看到哪里耗时长、哪里时序不对。这个方法我在裸机和 Linux 下都用简单但非常有效。8.2 在 DMA 完成中断里记录时间戳如果芯片有微秒级定时器在 DMA 完成中断里读取时间戳并放入环形计数器连续跑一晚上之后把时间戳间隔打印出来。正常情况下时间间隔应该是稳定的如果偶发间隔突然变大说明出现了总线冲突或者中断被阻塞。这比单纯统计吞吐量更容易暴露“偶发问题”。8.3 数据错位先怀疑相位/字节序不要动 DMASPI DMA 读出数据错位十有八九是 SPI 时钟相位、极性、帧格式和从机不匹配而不是 DMA 搬错了。先拿逻辑分析仪抓读到的原始波形确认每一位的排列顺序再检查 DMA 缓冲区里的字节序和小端/大端转换。ADC 的高位和低位字节顺序尤其容易搞混优先确定通道字节顺序再怀疑 DMA。8.4 我常用的最小复现工程结构遇到 DMA 疑难问题我习惯把整段驱动从产品工程里抽出来编译成一个最小工程一个外设、一个 DMA、一个 GPIO、一个串口打印。去掉协议栈、RTOS、业务算法只保留能稳定复现问题的路径。最小工程的好处是排除了所有“看起来不相关但可能影响时序”的代码能让问题更快暴露。等最小工程稳定了再逐步加回其他模块找到触发条件。DMA 这个东西配置本身从来不是难点难点在于时序、缓存、总线、中断优先级这些“看不见的上下文”。每个平台都不一样但排查思路是相通的先边界、后时序、再测量、最后怀疑硬件。这套流程帮我解决过好几次看起来毫无规律的 DMA 故障希望对你有用。
返回列表