ARTICLE DETAIL

资讯详情

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

嵌入式驱动开发必学:DMA原理、配置、调优与排障全攻略

嵌入式驱动开发必学:DMA原理、配置、调优与排障全攻略 做嵌入式驱动开发的人早晚都要跟 DMA 打交道。如果你还在用中断一个个字节收串口数据或者还在为 ADC 连续采样时 CPU 占用率太高发愁那这一期内容应该能让你少走不少弯路。我在驱动领域摸爬滚打十几年DMA 算是我最常用的外设之一也是踩坑最多的模块之一。所谓“DMA”全称 Direct Memory Access直译是直接存储器访问干的事就一句话不需要 CPU 参与直接在内存和外设之间搬数据。这期内容我想把 DMA 从原理、配置到调优、排障的完整经验拆开讲一遍适合刚接触驱动开发的同学也适合已经写过 DMA 驱动但被各种诡异 bug 折磨过的工程师。1. DMA 为什么是驱动开发的“必修课”1.1 从一次串口丢数据事故说起之前做过一个工业采集板卡主控是 STM32F4 系列外接 4 路串口传感器每路波特率 921600传感器每秒主动上报几十帧数据。刚开始图省事接收直接用串口中断每来一个字节进一次中断在中断里把数据丢进环形缓冲区。单路跑没问题四路同时开跑之后噩梦来了主循环偶尔卡顿更加诡异的是串口偶发丢字节逻辑分析仪上明明看到传感器发出来了程序里就是收不到。查了一圈最后定位到根因高频中断把 CPU 时间片吃掉了而且中断嵌套导致数据帧被撕裂。4 路串口在高波特率下中断频率能到几十 kHz每个字节都要压栈出栈、查标志位、写缓冲区、清中断CPU 几乎什么都没干光顾着伺候串口了。这时候才真正意识到DMA 不是锦上添花是刚需。1.2 DMA 到底解决了什么核心问题DMA 的核心价值我认为可以浓缩成三点解放 CPU、提升吞吐、稳定实时性。先看解放 CPU。传统方式下CPU 要反复执行“读数据寄存器-写内存”的循环DMA 接手之后这个动作由专门的硬件完成CPU 只需要配置好源地址、目的地址、传输长度然后去忙别的事等传输完成中断通知一声就行。用通俗的话讲CPU 不再当搬运工变成监工了。再看提升吞吐。DMA 是硬件搬运速度通常能做到与外设总线极限持平而且它支持 burst 突发传输一次可以搬多个数据单元。对于 ADC 连续采样、SDIO 读写、以太网收包这些大数据量场景吞吐差距不是一点半点。最后说实时性。中断驱动的数据搬运最怕高优先级中断把低优先级任务卡死DMA 因为是独立通道天然把“搬运”从 CPU 中断上下文中剥离开数据流更容易做到稳定可预期。1.3 DMA 的三种典型搬运模型DMA 传输方向按数据流路径分不外乎三种内存到内存、外设到内存、内存到外设。内存到内存最常见的就是 memcpy 的硬件加速版比如 DSP 里做矩阵运算前搬数据、Linux 内核里用 dmaengine 做 buffer 拷贝。这个模式考验的是带宽和 burst 配置。外设到内存典型场景就是串口接收、ADC 采样、摄像头采集。外设数据源源不断进内存CPU 只需要在帧结束或缓冲区满时处理即可。内存到外设典型场景是串口发送、DAC 输出、PWM 波形更新。比如做语音播放音频数据从内存流式灌给 I2S 外设CPU 只需要把数据填好剩下的交给 DMA。很多驱动工程师直到写 SPI 驱动、ADC 驱动时才第一次碰 DMA其实串口 DMA 才是入门最好的切入点因为串口帧结构清晰、节奏可控出问题也容易观察。下一部分我就从配置决策开始聊。2. 驱动开发前的关键决策DMA 配置与资源规划2.1 通道、请求与中断的选择拿到一个 MCU 的 DMA 模块第一件事不是写代码而是看芯片的 DMA 请求映射表。比如 STM32F4 的 DMA1 和 DMA2每个通道能响应哪些外设请求是固定的不存在“任意通道接任意外设”的说法。我见过很多新手配置 DMA 时想当然选通道 0结果外设请求根本没映射到这个通道数据传输纹丝不动。正确的做法是先查 datasheet 里的 DMA request mapping 表确认外设对应的通道和 DMA 控制器。比如 USART1_TX 在 DMA2 通道 7USART1_RX 在 DMA2 通道 5不同系列还不一样GD32、NXP 的映射规律也不同千万别靠记忆硬背。中断优先级的选择也有讲究。DMA 传输完成中断尽量放在比外设错误中断低、比一般任务高的位置。我之前犯过一个错误把 DMA 中断优先级拉满结果在高频 DMA 中断里处理回调把其他关键中断全部堵死系统直接“假死”。原则是中断回调只做轻量操作不要在里面做耗时处理重活交给主循环或任务队列。2.2 内存对齐与 Cache 一致性DMA 传输涉及物理内存很多人不知道缓冲区地址的对齐要求直接决定传输能否成功。以 STM32 为例DMA 控制器通常要求缓冲区地址按传输数据宽度对齐按字节传输时 1 字节对齐就行按半字16 位传输时要 2 字节对齐按字32 位传输时要 4 字节对齐。稳妥起见我会在定义 DMA buffer 时直接用编译器对齐属性把缓冲区对齐到 32 字节。static uint8_t dma_rx_buf[1024] __attribute__((aligned(32)));为什么是 32 字节因为很多 MCU 的 cache line 是 32 字节DMA 是直接访问内存CPU 通过 cache 读数据两边一旦不一致你会在接收缓冲区里看到“陈年老数据”。尤其是带 Cache 的高性能 MCU比如 Cortex-M7、A 系列芯片DMA 写内存后如果 CPU 读的是 cache 里的旧数据数据就永远不对。解决办法是在 DMA 完成中断里做 cache invalidate发送前做 cache clean。很多人一听“Cache 一致性”就头大其实理解成两组人看同一块黑板就行CPU 看的是自己手抄本CacheDMA 直接看黑板手抄本和黑板不同步信息就错了。MCU 没有 Cache 时不用操这个心但有 Cache 的芯片驱动里漏掉 flush/invalidate大概率数据会错乱而且这种错乱是间歇性的特别难查。2.3 环形缓冲区设计与阈值中断DMA 接收数据不能每次都等全满再处理那延迟太大了。成熟的做法是环形缓冲区 半满/全满中断 空闲中断。环形缓冲区的设计要点是读指针和写指针都由谁更新。DMA 写数据到内存DMA 硬件自己管理写位置驱动在中断里根据 DMA 当前计数算出本次接收了多少字节更新读指针。缓冲区大小必须是 2 的幂这样取模运算可以用位运算效率高。#define RX_BUF_SIZE 2048 #define RX_BUF_MASK (RX_BUF_SIZE - 1) static uint8_t rx_ring[RX_BUF_SIZE]; static volatile uint16_t rx_head 0; static volatile uint16_t rx_tail 0; static inline uint16_t ring_used(void) { return (uint16_t)(rx_head - rx_tail); }阈值中断的作用是及时取走数据防止 DMA 写指针绕过读指针造成覆盖。比如设置 DMA 传输一半时产生中断驱动立即把前一半数据取走后一半继续接收形成流水线。这在高速连续传输时几乎是标配。3. 手把手实现一个 DMA 串口接收驱动3.1 硬件环境与引脚规划这部分我以常见的 STM32F407 USART1 为例因为 F407 的资源多、例程多很多工程师手头就有开发板。串口 DMA 接收USART1_RX 默认是 PA10TX 是 PA9但 DMA 通道必须参照参考手册USART1_TX - DMA2 通道 7数据流方向内存到外设USART1_RX - DMA2 通道 5数据流方向外设到内存引脚规划阶段我习惯用表格列清楚避免后面接线和配置对不上功能引脚DMA 通道方向USART1 TXPA9DMA2_Stream7内存 - 外设USART1 RXPA10DMA2_Stream5外设 - 内存备用PA8--这里有个容易踩的坑开启 DMA 后串口中断是否还需要接收帧结束判断我强烈建议用串口空闲中断IDLE而不是 DMA 全满中断。IDLE 中断表示一帧数据结束了这时候 DMA 可能只收到一帧的一部分正好可以及时取出。全满中断是用来防溢出的两种配合使用才叫完整方案。3.2 初始化代码拆解初始化顺序很关键我的习惯是先配置 DMA 通道再配置串口最后开启 DMA 接收。void usart1_dma_init(void) { // 1. 使能 DMA2 时钟 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); // 2. 配置 DMA2 Stream5对应 USART1_RX DMA_InitTypeDef dma_init; dma_init.DMA_Channel DMA_Channel_4; // 查表得到 dma_init.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; dma_init.DMA_Memory0BaseAddr (uint32_t)dma_rx_buf; dma_init.DMA_DIR DMA_DIR_PeripheralToMemory; dma_init.DMA_BufferSize RX_BUF_SIZE; dma_init.DMA_PeripheralInc DMA_PeripheralInc_Disable; dma_init.DMA_MemoryInc DMA_MemoryInc_Enable; dma_init.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; dma_init.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; dma_init.DMA_Mode DMA_Mode_Circular; // 循环模式 dma_init.DMA_Priority DMA_Priority_High; dma_init.DMA_FIFOMode DMA_FIFOMode_Disable; DMA_Init(DMA2_Stream5, dma_init); // 3. 开启 DMA 传输完成中断、半传输中断 DMA_ITConfig(DMA2_Stream5, DMA_IT_TC | DMA_IT_HT, ENABLE); // 4. 配置串口使能 IDLE 中断 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); // 5. 开启 DMA 接收 DMA_Cmd(DMA2_Stream5, ENABLE); }关键参数解释一下。循环模式Circular是 DMA 接收串口数据的灵魂它让 DMA 在到达缓冲区末尾后自动回到起始地址继续接收不用 CPU 干预。方向是外设到内存外设地址固定是USART1-DR内存地址递增数据宽度都是字节因为是串口按字节传输。FIFO 模式我在这例子里选择了关闭。原因是串口数据是小数据流FIFO 反而会引入额外的触发阈值配置产生延迟。但后面讲优化时我会专门说明什么时候该开 FIFO。3.3 空闲中断 DMA 接收的完整流程数据接收流程分两个阶段第一阶段DMA 硬件持续把串口数据写入dma_rx_buf。这个阶段 CPU 完全不管串口来多少字节DMA 就写多少写完后自动增加地址和计数。第二阶段当一帧数据发送完串口总线变成空闲电平触发 USART IDLE 中断。在中断里做三件事清除 IDLE 标志注意要先读USART1-SR再读USART1-DR才能清掉标志位这是老生常谈的坑。读取 DMA 当前剩余数据量算出本次接收字节数。从 DMA 缓冲区拷贝数据到应用层环形缓冲区同时更新读指针。void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 读 SR 再读 DR清除 IDLE 标志 USART_ReceiveData(USART1); // 计算当前 DMA 已经收到多少字节 uint16_t remain DMA_GetCurrDataCounter(DMA2_Stream5); uint16_t received RX_BUF_SIZE - remain; // 把 received 个字节拷贝到应用缓冲区 memcpy(app_rx_buf, dma_rx_buf, received); // 因为 DMA 循环模式发出数据后指针自动回绕下一次重新计数 // 实际工程中要考虑读写指针回绕这里简化处理 } }这里写的是简化版实际工程还要考虑 DMA 缓冲区在传输过程中写指针可能越过当前读指针、半满中断时拷贝前一半等细节。但核心思想就是空闲中断标识“一帧结束”DMA 计数标识“这次收了多少”两者合起来就拿到了完整的一帧数据。3.4 实测性能对比在同样 4 路串口 921600 波特率的工程里我实测过两种方案的表现。中断接收时四路全开CPU 占用率在 70% 以上而且丢帧率不稳定偶尔一帧几十个字节里丢几个。换成 DMA 空闲中断后CPU 占用率直接降到 15% 以下丢帧率归零。再看中断频率。921600 波特率大约每秒 92160 字节。如果按每帧 100 字节算中断接收每字节进一次中断大概每秒 9 万多次中断DMA 模式每个帧结束进一次空闲中断每秒 900 多次数量级完全不一样。DMA 带来的收益不是“快了”而是“把 CPU 从低价值的重复劳动里解放出来”。4. 性能优化提升 DMA 吞吐的隐藏细节4.1 burst 传输与 FIFO 阈值串口那种低速场景关闭 FIFO 没问题。但到了 SPI 刷屏、SD 卡读写、以太网 DMA 这类高速场景FIFO 和 burst 就是绕不开的优化点。DMA 的 FIFO 相当于一个小蓄水池外设数据先进 FIFO凑够一定数量后再一次性 burst 写入内存。比如配置MSIZE32 位、PSIZE8 位、FIFO 阈值1/4时可以把 4 个字节合成一个 32 位字再搬运减少总线仲裁次数提高吞吐。burst 配置不能拍脑袋。FIFO 阈值越大burst 长度可以越大但延迟也越高。对于流式数据我一般把 burst 设为 4 或 8兼顾带宽和延迟。如果你做的是高精度 ADC 采样burst 太大会导致采样值到达内存的时间不均匀影响时序这时候宁可放弃一些带宽也要保证数据节奏稳定。4.2 描述符链与多缓冲更高端的 DMA 控制器比如 NXP 的 eDMA、STM32H7 系列带 Linked List支持描述符链。描述符链的意思是DMA 可以把多个传输任务串成链表每个描述符里包含源地址、目的地址、传输长度和下一个描述符指针硬件自动挨个执行。这个特性的价值在于不需要 CPU 一个一个配置传输请求。比如一个系统需要同时采集 ADC、处理串口、驱动 PWM可以把三种传输描述成一条链DMA 自动执行。处理完一个描述符后还可以通过回调通知 CPU。我在做多轴运动控制卡时用描述符链接收编码器数据实时性比中断逐次处理高很多。驱动开发时要注意描述符链内存必须保持有效不能放在可能被覆盖的临时变量里否则 DMA 在访问下一个描述符时可能读到非法地址直接 hardfault。4.3 双缓冲与乒乓 Buffer 的设计双缓冲Double Buffer是另一个极为实用的优化手段也常叫乒乓 Buffer。做法是准备两块缓冲区 A 和 BDMA 先写 A写满后自动切到 B同时通知 CPU 处理 A 的数据等 CPU 处理完 ADMA 可能已经在写 B之后切回 A如此交替。这套设计天然适合采集和处理并行的场景。比如音频采集DMA 往 A 写当前数据时CPU 正在处理 B 里的上一段数据两者互不干扰既不会因为 CPU 处理慢而丢数据也不会因为 DMA 立刻写下一帧而覆盖未处理的数据。实现时DMA 的 memory 地址在每次传输完成后更新到下一个 buffer。不少芯片直接支持双缓冲区模式连地址都不用手动改只要配置好两个内存基址即可。这个模式在我的工程里用来做录放音和 ADC 波形采集效果立竿见影。5. 常见问题与排查技巧实录5.1 DMA 通道被抢占多外设共用 DMA 控制器时最典型的问题就是“我配置了 DMA 但没反应”。检查顺序外设时钟有没有开DMA 时钟有没有开外设请求映射对不对是不是用了错误的 DMA 通道优先级有没有冲突如果两个外设共用同一个 DMA 控制器高优先级请求会抢占低优先级低优先级可能一直等不到位。特别提醒有的芯片 DMA 通道又分 Stream 和 Channel两者不是一回事。配置时漏掉 Channel 字段很多新手查半天查不出来。这类问题排查时最简单的办法是看寄存器。用调试器读 DMA 控制器的状态寄存器看当前通道有没有 enable、有没有 pending 请求。如果 enable 了但传输没启动基本可以确定是请求映射错误。5.2 Cache 一致性问题导致数据错乱有 Cache 的芯片上跑 DMA如果出现“第一次数据正常第二次开始乱码”“数据偶发性错误”这类现象第一怀疑对象就是 Cache 一致性。MCU 上Cortex-M7 以及部分 M4 带 CacheLinux 嵌入式环境下更是天天面对这个问题。解决办法分两类接收场景DMA 写内存CPU 读DMA 完成中断里对接收缓冲区执行 Cache Invalidate让 CPU 下一次读时重新从内存加载。发送场景CPU 写内存DMA 读DMA 启动前对发送缓冲区执行 Cache Clean把 CPU Cache 里的脏数据刷回内存。// 以 Cortex-M7 的 SCB 指令为例 SCB_InvalidateDCache_by_Addr((uint32_t *)dma_rx_buf, received_len); SCB_CleanDCache_by_Addr((uint32_t *)dma_tx_buf, send_len);很多工程师忽略发送前的 clean导致 DMA 读到的是内存里的旧数据。我调试过一例 SPI DMA 发送乱码最后发现就是 CPU 写入数据后DMA 直接读内存读到的还是 cache 更新前的旧值。加了一行 clean 操作问题立刻消失。5.3 调试工具与方法速查表DMA 不像普通代码可以打断点一步一步走因为它不经过 CPU断点难以捕捉。我的调试方法分三个层次寄存器层读 DMA 中断状态寄存器、当前数据计数寄存器、外设请求标志确认硬件状态。比如DMA_GetCurrDataCounter()返回值不变说明 DMA 根本没搬数据返回值变化说明搬运在进行。数据观测层把 DMA 缓冲区用调试器内存窗口看配合逻辑分析仪抓外设波形。如果波形有数据但内存没变化问题在 DMA 配置如果内存有数据但应用层解析不对问题在后处理逻辑。日志层在驱动关键节点打印标志位比如进入空闲中断、进入完成中断、进入半满中断确认中断有没有触发。我整理了高频问题速查表贴出来大家直接对照问题现象可能原因排查动作DMA 完全没有传输时钟未开 / 请求映射错查 RCC、查映射表数据传输一半停止FIFO 配置冲突 / 优先级抢占查 FIFO 模式、中断标志数据错乱Cache 不一致 / 地址对齐不对做 clean/invalidate、调整对齐完成中断不触发中断没使能 / 回调函数卡死查 NVIC、查 DMA ITConfig环形缓冲区数据被覆盖读指针更新慢 / 缓冲太小加阈值中断、加大缓冲偶发性丢帧DMA 写指针越过读指针双缓冲或半满中断取数5.4 两点独家避坑心得最后分享两个很少写在文档里、但实战特别有用的心得。第一个DMA 中断回调里不要调用printf。我见过太多人在 DMA 中断里打印调试信息结果串口发送本身又依赖 DMA形成互相等待系统直接卡死。调试串口建议另开一个独立的低速口并且只用轮询方式输出不要在 DMA 中断上下文里做任何可能阻塞的调用。第二个初始化 DMA 后不要立刻开启外设。正确顺序是先配置 DMA 并 enable再使能外设的 DMA 请求最后使能外设本身。反了可能会出现外设已经产生请求但 DMA 还没准备好第一个字节永远丢掉的诡异现象。这个顺序问题在很多芯片的 errata 里都有描述但新手文档很少强调。6. 一些个人经验之外的补充想再多说一句关于学习路线。很多人问我嵌入式驱动开发怎么快速上手我总说把 DMA 搞透你的水平能超过一半的面试者。面试时聊 DMA别只背概念能画出数据流、能说清通道映射、能讲明白 cache 一致性怎么处理比背一百道“八股文”有用得多。DMA 的扩展场景还有很多SPI DMA 刷屏、SD 卡 DMA 读写、ADC DMA 连续采样、Linux 下 dmaengine 框架驱动每一种都有不同的细节。但只要你理解了“谁搬数据、搬到哪、何时通知 CPU”这三个问题再复杂的 DMA 驱动也能拆成简单的三件套源地址、目的地址、传输控制。下一次当你再遇到“外设数据太多、CPU 忙不过来”的场景我建议你第一时间想起 DMA。它虽然不是万能的但绝大多数数据搬运问题DMA 都能给你一个干净优雅的解法。嵌入式这条路坑是踩不完的但每个坑踩过去之后留下的经验才是驱动工程师真正值钱的东西。
返回列表