ARTICLE DETAIL

资讯详情

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

嵌入式驱动开发DMA实战:数据搬运、中断设计与缓存一致性

嵌入式驱动开发DMA实战:数据搬运、中断设计与缓存一致性 一转眼嵌入式驱动开发经验这个系列已经写到第6期。前几篇聊过GPIO、中断、定时器和时钟树后台一直有同行问DMA什么时候写今天就把这部分经验整理出来。DMADirect Memory Access直接内存访问在嵌入式驱动开发里属于标配中的标配不管是裸机程序还是嵌入式Linux内核驱动只要涉及串口、SPI、ADC、I2S这类外设的批量数据传输几乎都绕不开DMA。很多新手容易把DMA当成一个加速外设觉得开了DMA数据就传得快。这个理解不能说错但不准确。DMA的核心价值不是让数据搬得比CPU快而是让CPU从枯燥的数据搬运中解脱出来去做更有价值的事情。驱动开发里引入DMA最直观的效果就是同样一个UART接收过程中断方式可能让CPU在整段时间里反复被打断而DMA方式只需要在整包数据完成后打断一次。这篇文章写给刚接触驱动开发的嵌入式软件工程师也写给正在为DMA数据错乱问题熬夜的Linux驱动新人当然如果你希望把驱动的稳定性从“能用”提升到“工程级”这篇内容也会对你有帮助。1. DMA 是驱动开发里的“代驾工程师”1.1 从一次串口数据搬运说起我讲一个实际工作中反复出现的场景MCU通过串口接收GPS模块发送的NMEA报文每秒钟输出一条几十到上百字节的数据。如果用普通中断接收每来一个字节就进一次中断CPU要完成现场保护、帧头判断、数据存储还要处理各种临时状态。波特率一旦提到115200以上高字节率下再做协议解析丢字节几乎是必然的。改成DMA接收之后数据由DMA控制器直接搬运到内存缓冲区CPU完全不参与逐字节搬运只有收到“这一帧数据完整到达”的通知时才去处理协议。这个改动带来的CPU占用率收益往往是断崖式的从百分之五十降到个位数一点都不夸张。这里要补充一个容易忽略的认知DMA并不是凭空帮你搬数据的它本质上也只是一个“执行者”只不过它的执行逻辑被固化成了专用硬件。CPU写驱动时仍然要把四件事告诉DMA数据从哪里来数据到哪里去搬运多少什么时候停下来。这跟给代驾报目的地和路线限制是同一个道理。驱动开发中如果先把这四件事想明白后面的寄存器配置基本就是查手册填空。写DMA驱动的第一步不是打开参考手册而是先在系统层面理清数据流。数据流的起点在哪、终点在哪、缓冲区的生命周期归谁管、出错以后怎么恢复这四个问题想清楚DMA代码才能写得干净。我在评审代码时经常看到的问题就是初始化DMA的代码写得很满但缓冲区管理逻辑一塌糊涂要么越界写坏内存要么半包数据上来就丢。这属于典型的“会配置、不会设计”。1.2 传输方向、数据流与三种典型模式DMA的传输方向主要有三种。第一种是外设到内存比如ADC采样数据连续写入缓冲区、串口接收数据。第二种是内存到外设比如DAC输出波形、串口发送数据、SPI给显示屏幕刷数据。第三种是内存到内存这种模式一般不需要外设触发由DMA自己发起搬运用于大块数据的快速拷贝或者图像帧的搬移。工作模式里我建议驱动工程师优先把两种吃透。第一种是普通模式传完设定数量就停适合一次性的定长传输第二种是循环模式缓冲区传完后自动回到起始地址继续工作适合连续采集、音频采样这类不需要重启外设的场景。至于突发模式它不是独立的工作模式而是DMA在一次外设请求到来时连续搬运的数据单元个数。突发模式能降低DMA占用总线的频次但会提高单次占用的持续时间这个后面会展开说。模式典型方向应用场景驱动层关键点普通模式外设到内存、内存到外设定长一次性传输传输结束必须重配计数才能再次使用循环模式外设到内存连续采样、音频采集配合半满/全满中断处理新旧数据内存到内存内存到内存大块数据搬运注意总线占用时间和优先级1.3 什么场景非用 DMA 不可什么场景尽量别碰有同行问过我是不是所有外设数据传输都换成DMA才显得专业我的答案一直很直接不是。DMA适合数据量大、频率稳定、搬运行为简单的场景。如果每次只传两三个字节中断方式反而更灵活、更实时。比如按键扫描本质是GPIO状态读取一次读一组IO总共一个字节没必要引入DMA。比如单字节的随机寄存器读写用DMA反而给自己找麻烦。我的判断标准大致是这样的满足下面任一条件就认真评估DMA的收益。第一单次数据量超过16字节且周期性产生第二外设每秒钟产生的中断次数已经开始影响CPU主逻辑第三数据搬运过程不希望被CPU调度打断比如音频播放时的数据填充。反过来如果传输零散、长度随机性很大、需要频繁根据状态决定是否继续传输先用中断方式把功能跑通再考虑DMA做优化也不迟。说到底DMA是驱动工具箱里的一个高效工具而不是所有问题的唯一答案。2. 写 DMA 驱动的方案选型与配置思路2.1 通道、请求线和外设之间的映射很多人在DMA这里栽的第一个跟头就是通道和外设的映射关系搞错了。以STM32系列为例DMA1和DMA2控制器下面挂着多个通道每个通道又对应若干外设请求。比如某些型号上UART1接收方向配DMA1通道4UART1发送方向配DMA1通道3换一个型号映射关系可能就完全不同。如果你随便挑一个通道就去初始化最典型的结果是外设的数据请求信号送达后DMA控制器根本不去响应。这也是为什么我一直坚持写DMA驱动前第一件事不是写代码而是打开芯片参考手册里的DMA请求映射表把外设和通道的对应关系确认一遍。查表时还要注意方向外设发送方向对应内存到外设外设接收方向对应外设到内存。有些初学者觉得只要通道选对了就行却漏了外设侧的方向使能位结果数据一直发不出去。这类问题有个共同特征系统整体不报错DMA就是不动作排查起来非常费时间。另外现代MCU的DMA控制器一般都不止一个比如STM32有DMA1和DMA2DMA1的通道优先级体系相对简单DMA2往往服务于带宽要求更高的外设。不同DMA控制器能服务的外设不一样参考手册里有明确的连接表。选错控制器甚至会出现“初始化成功但外设请求永远到达不了DMA”的诡异情况这一点在做平台移植时要格外注意。2.2 数据宽度、突发长度和优先级怎么定DMA传输时的数据宽度分为字节、半字、字三种对应8位、16位、32位。配置原则是尽量与外设寄存器的自然宽度一致。比如外设数据寄存器是32位DMA的源端和目标端数据宽度就配置成32位。虽然部分DMA控制器支持两侧宽度不一致会在内部做拼接或拆分但每做一次转换都会引入额外等待周期在高频率传输时影响还是挺明显的。能对齐就对齐这是驱动配置里的一个基本准则。突发长度和优先级是配套考虑的。优先级高的通道在总线仲裁时更容易抢占资源但不意味着所有通道都配高优先级。标准做法是对时延敏感的实时数据通道配高优先级比如ADC连续采样、音频输入对大批量且不敏感的数据通道配普通优先级比如给屏幕缓冲区刷数据。突发长度不要一上来就配成最大的16个数据单元先用4或8配合实际的系统负载和示波器观察效果再调。突发长度越大DMA占着总线不放的时间就越长对CPU中断响应的影响也越明显。优先级方面还要防止一个误区多个DMA通道都设成同一个高优先级并不能让它们雨露均沾。大多数DMA控制器在多个高优先级通道同时请求时会按通道号的固定顺序响应低编号通道可能被持续优先响应高编号通道反而长时间等不到总线。所以设计时要给不同通道明确划分优先级梯队而不是简单粗暴的全部设成最高。2.3 驱动分层设计与可移植性DMA驱动写得多了以后你会发现最核心的资产不是某一段寄存器配置而是一套方便移植的驱动结构。我习惯把DMA驱动拆成两层。底层叫DMA硬件抽象层负责通道、指令、中断、时钟这些跟芯片强相关的内容上层叫DMA服务层面向业务模块提供初始化、接收、发送、取消、状态查询这些接口。业务代码只跟服务层打交道换芯片平台时只需要重写底层上层协议解析、业务状态机完全可以不动。这个分层的价值在项目迭代中体现得特别明显。很多单片机的DMA通道资源是有限的裸机上一个通道可能要被多个外设分时复用。如果业务代码直接操作寄存器复用逻辑就到处散落改一个功能可能要牵连好几个文件。有了服务层之后通道的申请、释放、绑定、切换都收敛在一个模块里调试起来思路清楚得多。嵌入式Linux内核驱动里的DMA引擎框架其实也是这个思路只是抽象层次更复杂。有个原则我一直很看重DMA驱动里的回调函数不要做得太“重”。回调里可以设置标志、记录状态、唤醒等待队列但绝对不要做数据解析、协议处理、加解密这类耗时操作。DMA的省时红利很容易被一个在中断里耗时过久的回调消耗干净。这个原则放到裸机和Linux内核场景都成立。2.4 中断设计不是所有 DMA 都需要中断DMA驱动里的中断设计很多人的第一反应是“传输完成中断必须开”。实际项目中这个“必须”是分场景的。如果使用循环模式做ADC连续采集你真正需要的是半传输中断和全传输中断。半传输中断到达时CPU去处理缓冲区的前半段全传输中断到达时处理后半段。这样DMA在写当前半区时CPU可以对上半区做处理两个半区交替互不踩踏。如果只是做一次定长的内存拷贝CPU在启动DMA后可以立刻去做其他任务等传输完成中断再回来收尾。如果对实时性要求没那么高甚至可以用轮询方式检查完成标志省掉一个中断源。这里建议是把传输完成中断作为可选项把错误中断作为必选项。DMA传输过程中可能发生总线错误、FIFO溢出等异常如果不使能错误中断DMA就会悄悄停下来驱动层完全无感知数据就永远卡在那里。我的建议很简单哪怕错误中断里只记一个状态位也一定要开。这个习惯帮我在好几个项目里避免了漫长的“假死”排查。3. 从 0 到 1 实现 DMA 驱动的关键细节3.1 一次完整的 DMA 初始化流程下面这套初始化流程是我在实际项目里比较固定的思路平台可以不同但顺序最好不要乱。第一步使能DMA控制器时钟和外设时钟这两个时钟都开着DMA才能收到外设的请求信号。第二步把DMA通道配置结构体清零防止初始化后残留随机状态然后依次填写方向、外设地址、内存地址、数据宽度、传输计数、模式和优先级。初始化顺序这一点很多参考手册不会特别强调但实际出错概率不低。为了让你更好对照我给出一个简化的类标准库风格初始化代码平台差异先不管重点是框架和顺序DMA_Channel_TypeDef *dma_ch DMA1_Channel4; uint8_t rx_buf[64]; /* 使能DMA和外设时钟 */ RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); /* 初始化通道参数 */ DMA_InitTypeDef dma_cfg; memset(dma_cfg, 0, sizeof(dma_cfg)); dma_cfg.DMA_DIR DMA_DIR_PeripheralSRC; /* 外设到内存 */ dma_cfg.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; dma_cfg.DMA_MemoryBaseAddr (uint32_t)rx_buf; dma_cfg.DMA_BufferSize sizeof(rx_buf); dma_cfg.DMA_PeripheralInc DMA_PInc_Disable; /* 外设地址不递增 */ dma_cfg.DMA_MemoryInc DMA_MInc_Enable; /* 内存地址递增 */ dma_cfg.DMA_PeripheralDataWidth DMA_PDataAlign_Byte; dma_cfg.DMA_MemoryDataWidth DMA_MDataAlign_Byte; dma_cfg.DMA_Mode DMA_Mode_Circular; dma_cfg.DMA_Priority DMA_Priority_High; DMA_Init(dma_ch, dma_cfg); NVIC_Config_DMA_IRQ(); DMA_ITConfig(dma_ch, DMA_IT_TC | DMA_IT_HT | DMA_IT_TE, ENABLE); DMA_Cmd(dma_ch, ENABLE);第三步配置DMA通道对应的NVIC中断优先级让通道的中断能正常被CPU响应。第四步使能DMA通道再使能外设侧的DMA请求。这里有个高频踩坑点外设的DMA使能位没有打开。比如串口要发数据除了把串口本身配置好还要在USART控制寄存器里把发送DMA使能位置1接收方向同理。漏掉这一步DMA配置得再漂亮也不会工作。还有一个关键习惯每次DMA传输结束后如果要重新启动必须先关闭通道重新设置缓冲区大小再打开通道。普通模式下传输计数是单次有效的不会自动复位。很多人在任务复跑时忘了这个步骤结果DMA通道还处于结束状态看似配置没变实际上完全不动了。把“关通道、重装计数、开通道”三步封装成一个函数能减少很多重复劳动。3.2 串口空闲中断 DMA 的不定长接收实战DMA最经典的实战组合之一就是串口空闲中断加DMA循环接收。它解决的问题是不定长的串口报文怎么做到既省CPU又不错帧。GPS NMEA报文、私有协议帧、AT指令响应这类数据的特点是长度不固定无法预知每一帧该传多少字节所以不能简单地配一个固定传输计数。实现思路是这样的DMA配置成循环接收模式缓冲区大小设为一帧最大长度同时使能串口的空闲中断。空闲中断在串口总线上出现一个字节时间以上的空闲时触发非常符合“一帧数据结束”的语义。在空闲中断服务函数里读取DMA的当前剩余传输计数寄存器用缓冲区总大小减去剩余计数就得到本次接收到的数据长度。随后把这一帧数据复制到协议缓冲区交给上层处理DMA则继续保持循环状态等待下一帧数据。我踩过的一个典型坑是空闲中断触发后没有及时清除空闲标志导致连续触发多次中断。很多芯片的清标志顺序有讲究比如需要先读状态寄存器再读数据寄存器。这类细节光靠SDK的HAL封装不一定能完全掩盖还是要回到参考手册确认。另外如果一帧数据刚好填满缓冲区DMA的循环模式会覆盖起始地址此时要额外做溢出判断必要时在DMA全满中断里同步处理。这种组合的功耗和CPU占用优势非常明显但代码复杂度也比纯中断接收高了一截。建议先把不依赖空闲中断的轮询DMA版本跑通再逐步加入空闲中断逻辑这样每一步出问题都容易定位。3.3 环形缓冲区与双缓冲区的实战经验循环模式和双缓冲是一对黄金搭档。双缓冲其实就是用两块缓冲区轮流切换一块在接收数据另一块在处理数据。DMA半传输中断到达时处理前半块全传输中断到达时处理后半块。因为DMA一直在写CPU一直在读只要两边节奏对得上数据就不会互相踩踏。以64字节的缓冲区为例半满时处理0到31字节全满时处理32到63字节处理完就设置对应标志位等待下一轮。这样即使DMA不停止CPU也能稳定消费数据。环形缓冲区则要格外注意读写指针的保护。DMA和CPU是并行工作的CPU读缓冲区时DMA可能正在往同一片内存写数据所以读端要维护自己的读索引不能依赖缓冲区里头的某个“有效数据长度”字段。一个比较稳妥的做法是在中断服务函数里只更新写索引和一个数据量计数器应用程序在关中断或使用临界区保护的条件下读取这些索引。操作共享变量时关中断的成本很低但换来的数据一致性很高。实际项目中我最喜欢把双缓冲和环形结合使用DMA配循环模式中断里维护写指针应用层维护读指针两边各自管好边界。这个方案读写的冲突点最少CPU的开销也低。当然小项目没必要一上来就搭这么复杂的结构先用一个足够大的数组把功能跑通再按需升级缓冲区策略更符合工程节奏。3.4 内核与裸机场景下都要注意的 cache 问题Cache一致性问题很多嵌入式Linux开发者在做DMA驱动时一定踩过但实际上只要CPU带Cache或者MMU裸机环境也会遇到同样的坑。DMA访问的是物理内存CPU读的是Cache两边看到的数据副本可能不一致。最典型的现象就是DMA已经写好了一串数据CPU去读内存却发现是旧数据或者CPU在内存里摆好了数据DMA发出去却是乱码。在Linux内核驱动里正确做法是使用dma_alloc_coherent分配一致性缓冲区或者使用dma_map_single映射缓冲区在传输完成后调用dma_unmap_single做同步。裸机环境下如果芯片提供内存region的Cache属性配置可以为缓冲区单独配置成非Cache区域如果芯片不提供就要在DMA传输前执行Cache Clean传输完成后执行Cache Invalidate。这两个动作必须成对出现只Clean不Invalidate或者反过来都会在某个不起眼的时刻以数据错位的方式还给你。我见过好几个项目功能调试时一切正常一旦数据量增大就开始间歇性出错最后排查下来都是Cache一致性惹的祸。所以写DMA驱动时缓冲区地址对齐、长度对齐、Cache操作这三件套应该和寄存器配置放到同等重要的位置。还有一个小经验DMA缓冲区最好静态分配或由专用的内存池分配不要在栈上分配一个普通数组直接丢给DMA因为栈地址的对齐和生命周期都是不可控的很容易踩到未定义行为。4. DMA 性能评估与常见坑排查4.1 怎么量化 DMA 带来的收益很多同行喜欢用“测速”软件或者跑分工具来判断DMA效果但嵌入式驱动开发里真正的DMA收益应该体现在系统整体上而不是某一段搬运的峰值速度。我的做法是量化三个指标CPU占用率、中断次数、传输耗时。CPU占用率比较好办用逻辑分析仪抓一个GPIO的时间戳在正常数据处理主循环里翻转GPIO对比开DMA和关DMA两种情况下GPIO翻转周期的高电平占比变化。中断次数更容易量化。以串口接收1024字节为例中断接收方式至少触发1024次中断DMA接收方式可能只触发2次完成中断或空闲中断。中断次数的减少直接释放了CPU的执行带宽。传输耗时方面可以用一个计数器测量从发起DMA到完成中断之间的时间再对比相同数据量下CPU直接搬运的时间。这里有个细节DMA启动本身有配置开销数据量越小时DMA的收益越不明显数据量越大DMA的优势越突出。所以做性能评估时一定要按实际业务的最大数据量来测而不是用一个中间值想当然。还需要注意DMA传输占用的是总线带宽如果多个外设同时开启DMA总线可能成为瓶颈。只测一个DMA通道看起来效果不错全系统负载跑起来数据可能出现延迟。所以我习惯在性能评估阶段同时模拟最高负载场景把所有DMA通道和外设都打开再看整体时序是否达标。4.2 第一包正确、后续错乱这个坑我至少在不同项目里碰到过三次典型表现是第一次传输的数据完全正确第二包开始要么少数据、要么数据串位。排查下来最常见的原因有两个。第一缓冲区空间不够或者应用层处理完数据后没有把写指针复位。第二普通模式下第二次启动DMA之前没有重装传输计数。排查方法很直接在每次传输完成中断里把DMA的当前剩余传输计数寄存器打出来看一眼。如果这个值在第二次启动时不对那八成就是计数重装的问题。还有一种更隐蔽的情况是内存地址计算错误。比如内存地址配了数组首地址代码逻辑里又想通过移动指针来分块处理数据于是手动修改了内存地址指针却忘了同步更新DMA寄存器。DMA搬运到的位置和你实际想释放到的位置错位数据自然就串了。这类问题最好的规避方式是不要直接在回调里移动源地址而是通过计算索引去访问缓冲区对应区域DMA配置的基地址始终保持不变。4.3 传输完成中断“失踪”现象是DMA在跑数据也在更新但传输完成中断一直没有触发。最先要查的是NVIC配置确认这个DMA通道的IRQ确实使能了优先级也没有被其他高频中断压到完全无法响应。其次要查DMA的中断使能寄存器。很多时候为了快速验证功能代码先写成了轮询方式轮询跑通了后续改成中断方式时忘了把DMA_IT_TC加上中断自然永远不会来。更隐蔽的一个原因是DMA错误标志没有清除。部分芯片的DMA控制器在出现总线错误或FIFO错误时会把整个通道锁死同时错误标志一直置位。之后你再怎么触发DMA通道都不会恢复正常。解决办法是每次初始化或者每次进入错误中断处理时先读取DMA状态寄存器再把对应的错误标志位清除最后重新使能通道。把这一步固化到驱动初始化流程里能省掉很多“中断失踪”的无效排查。还有一种情况需要留意DMA中断里如果同时使能了半传输、全传输、错误三类中断而中断服务函数里没有正确读取状态寄存器就统一清标志可能把还没真正完成的中断状态误清掉。我在调试时曾经遇到过全传输中断标志被意外清掉CPU以为传输没完成一直等在那里。所以DMA中断服务函数里建议根据状态寄存器分类处理先判断是哪一类事件再执行对应逻辑最后清对应的标志。4.4 DMA 与 CPU 抢总线DMA传输本身会占用系统总线突发传输模式下DMA会连续占用总线好几个周期如果此时CPU正在处理实时性要求很高的中断延迟就会明显上升。系统表现为不开DMA时定时器中断非常稳定开了DMA之后偶尔出现抖动甚至产生周期性的执行延迟。出现这个问题不要急着关DMA而是让DMA更“克制”一些。具体做法有三个方向。第一降低突发长度配置从16降到8或者4单次占用的总线时间立刻变短。第二把实时性敏感的中断优先级提到DMA通道优先级之上确保这类中断可以在DMA占总线期间被插队响应。第三把低优先级DMA通道的优先级设低让高优先级通道自动抢占。多DMA通道设计里一定不要让所有通道都挤在同一个优先级否则低编号通道可能会持续占用总线高编号通道被迫饥饿。另外内存到内存的DMA尤其要注意总线占用问题。这种模式不需要外设触发软件一启动就会持续搬运数据而且它的优先级通常可以配得很高。如果一个大块的memcpy用了内存到内存DMA又满优先级运行系统中其他DMA通道和外设的中断延迟都有可能被拉长。所以内存到内存DMA的优先级我一般都不会配成最高数据量较大的场景还会分段启动给CPU和其他外设留出呼吸空间。4.5 排查 DMA 问题的三板斧第一招看参考手册的DMA请求映射表和寄存器位定义确认通道、请求线、方向配置到底对不对。这一招能排除掉八成的基础配置错误。第二招打印DMA的当前传输计数寄存器通过剩余值变化判断DMA到底有没有被触发、数据传输进度如何。如果DMA一直不动作这个寄存器的值就不会变化据此可以快速区分“根本没触发”和“触发但数据错误”。第三招在DMA回调里翻转一个空闲的GPIO用逻辑分析仪或示波器观察实际的中断频率和传输节奏。这一招比任何调试参数都直观能直接看出是否是中断风暴、回调耗时过长、或者周期不对。我排查DMA问题时很少死磕代码先通过这三招把现象圈定到一个方向上再去看代码就轻松多了。顺带分享一个二分定位法DMA出问题时先关闭DMA用轮询方式跑同样的功能。如果轮询正常说明问题一定出在DMA配置、同步或缓冲区处理上如果轮询也不正常那问题大概率在更上游的外设配置或硬件连接。用这种思路把嫌疑范围一分为二效率远高于一遍遍读代码。写驱动这十几年DMA是我用得最多、也最容易“出怪问题”的外设之一。很多问题翻来覆去看代码都正常最后往往败在Cache同步、通道映射或者寄存器清标志这种细节上。这期里写的每个坑基本都是从真实调试现场里捞出来的。如果你正在被DMA数据错乱折磨我建议先放下代码回到数据流本身理清谁在写、谁在读、生命周期归谁管同时动手检查缓冲区对齐和Cache操作这两个位置解决了大部分问题都会自动消失。DMA驱动之路没有捷径但每一步都踩在标准流程上会稳很多。后面我打算再写一篇嵌入式Linux环境下的DMA子系统分析到时候咱们接着聊。
返回列表