嵌入式DMA驱动开发实战:从设计思路到调试技巧 1. 为什么DMA是嵌入式驱动开发的必修课搞嵌入式驱动开发你可以不懂RTOS的任务调度细节也可以暂时不碰文件系统的挂载流程但DMA这个东西绕不过去。我做了十多年一线驱动开发从早期的8位机到现在主流的Cortex-M、Cortex-A系列DMA几乎是每个项目都要打交道的模块。你可以把它理解成CPU的“专职搬运工”——数据在内存和外设之间搬来搬去这件事本来CPU自己也能干但让CPU干这种活就像让一个架构师去贴发票不是干不了是浪费。DMA全称Direct Memory Access直接内存访问。它的核心价值就一句话让数据搬运不经过CPU。没有DMA的时候串口每收到一个字节就触发一次中断CPU进中断、读寄存器、写内存、退中断一个字节折腾一圈。波特率115200的时候每秒11520个字节CPU每秒被打断一万多次什么正事都别干了。有了DMA你告诉它“从串口数据寄存器搬到内存这个地址搬1000个字节搬完了叫我”然后CPU就可以去处理其他任务等DMA搬完再统一处理。这个差别在低负载场景下可能感觉不明显但一旦上了高速ADC采集、SPI大块数据传输、串口高波特率通信DMA就是决定系统能不能跑起来的关键。这篇文章适合谁看如果你正在学STM32、GD32、HC32F460这类MCU的驱动开发或者在做嵌入式Linux下的DMA相关调试又或者面试时被问到“串口DMA怎么配”“DMA测速怎么做”这类问题那这篇内容应该能帮到你。我会从设计思路讲到实操细节再到常见问题排查尽量把我在实际项目中踩过的坑和总结的经验都倒出来。2. DMA驱动的整体设计思路与方案选型2.1 什么时候该用DMA什么时候不该用不是所有数据传输都适合上DMA。我见过一些刚入行的朋友学了DMA之后恨不得所有外设都挂上DMA结果系统复杂度上去了问题也多了。判断标准其实不复杂数据量大且连续比如ADC连续采样几百上千个点、SPI驱动LCD刷屏、串口接收不定长数据包这些场景DMA优势明显。高频率小数据量比如某个传感器每秒中断几十次每次就传一两个字节这种用DMA反而增加配置开销中断直接处理更简单。实时性要求极高的场景DMA传输本身有延迟从触发到实际开始搬运需要几个时钟周期如果对响应时间要求是纳秒级DMA不一定比CPU直接操作快。我一般的做法是先估算数据速率和CPU占用率如果CPU花在数据搬运上的时间超过总时间的10%就考虑上DMA。这个10%不是拍脑袋是经验值——低于这个数优化收益不明显高于这个数DMA带来的复杂度增加是值得的。2.2 三种主流DMA架构的取舍嵌入式领域常见的DMA架构大致分三类选型时得心里有数架构类型典型代表优势劣势适用场景独立DMA控制器STM32的DMA1/DMA2通道多、优先级可配、支持循环模式配置相对复杂、占用总线多外设并发传输外设内置DMA某些UART自带的FIFODMA配置简单、延迟低通道数有限、功能单一单一外设高速传输系统级DMA嵌入式Linux的DMAEngine统一框架、支持分散聚集学习曲线陡、调试复杂复杂SoC系统以STM32F103C8T6为例它有两个DMA控制器每个控制器有7个通道总共14个通道。但注意通道和外设的对应关系是固定的不是你想把哪个外设映射到哪个通道都行。比如USART1_TX只能走DMA1_Channel4USART1_RX只能走DMA1_Channel5这个在参考手册的DMA请求映射表里写得清清楚楚。我刚开始搞的时候没注意这个对着代码调了半天发现通道选错了外设根本没反应。2.3 循环模式与普通模式的本质区别DMA的循环模式Circular Mode和普通模式Normal Mode是两种完全不同的使用哲学。普通模式传输完设定数量后DMA自动关闭需要重新配置才能再次传输。适合“一次性搬运”场景比如把一块数据从Flash搬到RAM。循环模式传输完设定数量后DMA自动重置计数器从头开始继续传输。适合“持续采集”场景比如ADC连续采样、串口不定长接收。这里有个坑循环模式下如果你不处理传输完成中断数据会一直覆盖旧数据。我见过一个项目ADC用DMA循环模式采集但中断处理函数里忘了读数据结果缓冲区里永远是最后一批数据前面的全被覆盖了。所以循环模式一定要配合双缓冲或者及时读取的策略。3. 核心细节解析与实操要点3.1 传输方向与数据宽度的配置逻辑DMA配置里有两个参数看起来简单但实际用起来容易出错传输方向和数据宽度。传输方向有三种外设到内存、内存到外设、内存到内存。串口接收是外设到内存串口发送是内存到外设内存到内存一般用于快速数据拷贝。配置错了方向DMA要么不工作要么把数据写到莫名其妙的地方。数据宽度更微妙。假设外设数据寄存器是8位内存缓冲区是32位你怎么配这里涉及到一个叫数据对齐的概念。DMA支持源和目标数据宽度不同的情况但需要硬件支持。以STM32为例如果外设数据宽度是8位内存数据宽度是32位DMA会把4个8位数据拼成一个32位写入内存。但反过来如果外设是32位内存是8位DMA会把32位拆成4个8位。这个特性在ADC多通道采集时特别有用——ADC数据寄存器是16位但你可以配置内存为16位直接一一对应。我个人的经验是除非有明确需求否则源和目标数据宽度保持一致。宽度不一致时传输完成中断的触发时机和传输数量计算都容易搞混。比如你设了传输10个数据但源和目标宽度不同实际搬运的字节数可能是10、20或40取决于宽度组合。3.2 优先级与仲裁机制的实际影响多个DMA通道同时工作时优先级决定了谁先占用总线。STM32的DMA优先级分四档最高、高、中、低。如果两个通道优先级相同通道号小的优先。这个机制在实际项目里影响很大。我曾经做过一个项目ADC用DMA1_Channel1采集串口用DMA1_Channel5接收两个都设成中优先级。结果串口数据量大的时候ADC采集的数据偶尔会丢。后来把ADC优先级调到最高问题解决。原因就是ADC对时序敏感串口数据晚一点处理没关系但ADC采样点丢了就补不回来了。注意优先级不是越高越好。如果所有通道都设最高优先级等于没有优先级仲裁器还是按通道号来。合理分配优先级的原则是对时序敏感的外设给高优先级对吞吐量敏感但容忍延迟的外设给低优先级。3.3 中断与标志位的正确使用姿势DMA的中断标志位有五个传输完成TC、半传输HT、传输错误TE、FIFO错误FE、直接模式错误DME。最常用的是TC和HT。TC中断在传输完设定数量后触发。HT中断在传输完一半时触发。HT中断的妙用在于双缓冲你准备两个缓冲区A和BDMA先填A填到一半触发HT你处理A的前半部分DMA继续填A的后半部分填完触发TC你处理A的后半部分同时DMA开始填B。这样数据处理和DMA传输完全并行效率最高。但这里有个细节清除标志位的时机。STM32的DMA标志位需要通过写1来清除而且要在中断处理函数里清不能在主循环里清。我见过有人在主循环里轮询TC标志然后手动清除结果中断和轮询同时存在标志位被清了两次导致中断丢失。正确的做法是要么用中断要么用轮询不要混用。4. 实操过程与核心环节实现4.1 串口DMA接收的完整配置流程以STM32F103C8T6的USART1为例配置DMA接收的步骤如下第一步使能时钟RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);注意DMA1挂在AHB总线上不是APB。这个在STM32F1系列里容易搞错F4系列DMA也在AHB1上但具体总线名称不同查手册确认。第二步配置DMA通道DMA_InitTypeDef DMA_InitStructure; DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)rx_buffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize RX_BUFFER_SIZE; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, DMA_InitStructure);这里的关键点DMA_PeripheralInc必须设为Disable因为外设数据寄存器地址是固定的不能递增。DMA_MemoryInc设为Enable因为内存缓冲区地址要递增。第三步使能DMA和串口DMA请求DMA_Cmd(DMA1_Channel5, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE);顺序很重要先使能DMA通道再使能串口的DMA请求。反过来可能导致第一次传输丢失。第四步处理接收完成普通模式下DMA接收完设定数量后触发TC中断。但在实际项目中串口数据往往是不定长的你不知道对方会发多少字节。这时候有两种方案方案一用空闲中断配合DMA。串口空闲中断在总线空闲时触发此时读取DMA的剩余传输数量就能算出实际接收了多少字节。void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { USART_ReceiveData(USART1); // 清除空闲中断标志 uint16_t remaining DMA_GetCurrDataCounter(DMA1_Channel5); uint16_t received RX_BUFFER_SIZE - remaining; // 处理received个字节的数据 // 重新配置DMA以便下次接收 DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUFFER_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }方案二用循环模式配合半传输中断。DMA循环填充缓冲区HT中断处理前半部分TC中断处理后半部分。这种方式适合数据流持续不断的场景。4.2 DMA测速的两种方法及代码实现DMA测速是面试和实际调试中经常遇到的问题。测速的目的是验证DMA的实际传输速率是否达到预期以及排查性能瓶颈。方法一GPIO翻转法在DMA传输开始前拉高一个GPIO传输完成后拉低用示波器测量高电平持续时间计算传输速率。GPIO_SetBits(GPIOA, GPIO_Pin_0); // 开始 DMA_Cmd(DMA1_Channel4, ENABLE); // 等待传输完成 while(DMA_GetFlagStatus(DMA1_FLAG_TC4) RESET); DMA_ClearFlag(DMA1_FLAG_TC4); GPIO_ResetBits(GPIOA, GPIO_Pin_0); // 结束假设传输了1000字节示波器测得高电平持续100微秒那么速率就是1000字节/100微秒 10字节/微秒 10MB/s。这个方法简单直接但精度受限于GPIO翻转速度和示波器采样率。方法二定时器计数法用一个高精度定时器在DMA传输前后读取计数值计算时间差。TIM_Cmd(TIM2, ENABLE); uint16_t start TIM_GetCounter(TIM2); DMA_Cmd(DMA1_Channel4, ENABLE); while(DMA_GetFlagStatus(DMA1_FLAG_TC4) RESET); DMA_ClearFlag(DMA1_FLAG_TC4); uint16_t end TIM_GetCounter(TIM2); TIM_Cmd(TIM2, DISABLE); uint16_t elapsed (end start) ? (end - start) : (0xFFFF - start end); float speed (float)TRANSFER_SIZE / elapsed * TIMER_FREQ;这个方法的精度取决于定时器频率。如果定时器跑在72MHz分辨率就是1/72微秒对于毫秒级的传输来说足够精确。实操心得测速时一定要关闭中断或者确保中断不会干扰测量过程。我有一次测速结果忽高忽低排查半天发现是SysTick中断在捣乱每次中断都插入几个时钟周期的开销。后来把测速代码放在临界区里数据就稳定了。4.3 ADC多通道DMA采集的参数计算ADC多通道采集是DMA的经典应用。以STM32F407VET6为例配置ADC1的3个通道IN0、IN1、IN2用DMA循环采集。参数计算过程假设ADC时钟为21MHz采样时间设为84个周期转换时间 采样时间 12个周期逐次逼近型ADC的固定开销 96个周期。单次转换时间 96 / 21MHz ≈ 4.57微秒。3个通道轮询一遍需要 3 × 4.57 ≈ 13.71微秒。也就是说DMA每13.71微秒搬运一次数据每次搬运3个16位数据。如果缓冲区大小设为300即100组3通道数据那么DMA传输完成中断每 100 × 13.71 ≈ 1.371毫秒触发一次。这个频率对于大多数应用来说是可以接受的。配置代码的关键部分// ADC配置 ADC_InitStructure.ADC_ContinuousConvMode ENABLE; ADC_InitStructure.ADC_ScanConvMode ENABLE; ADC_InitStructure.ADC_NbrOfConversion 3; ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_84Cycles); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_84Cycles); ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_84Cycles); // DMA配置 DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)adc_buffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize 300; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode DMA_Mode_Circular;注意ADC的DMA请求要在ADC使能之前配置好否则第一次转换可能丢失。5. 常见问题与排查技巧实录5.1 DMA传输不启动的排查思路这是最常见的问题代码看起来没问题但DMA就是不动。我总结了一个排查顺序时钟使能了吗DMA控制器和外设的时钟都要使能缺一不可。STM32F1的DMA1在AHB上F4的DMA1在AHB1上别搞混。通道选对了吗查参考手册的DMA请求映射表确认外设对应的通道号。DMA请求使能了吗外设这边要调用类似USART_DMACmd()的函数使能DMA请求光配置DMA通道不够。传输方向对吗外设到内存还是内存到外设搞反了DMA不会报错但数据不对。缓冲区地址有效吗确保内存地址是有效的RAM地址不是Flash地址除非是内存到内存传输且源是Flash。5.2 数据错位的典型原因数据错位表现为接收到的数据整体偏移、部分丢失或重复。常见原因有数据宽度不匹配外设8位内存16位导致字节序错乱。内存递增配置错误该递增的没递增数据全写到同一个地址。DMA和CPU同时访问同一块内存没有做互斥保护数据被覆盖。循环模式下缓冲区溢出数据处理速度跟不上DMA填充速度。避坑技巧调试DMA时先把传输数量设小一点比如10个字节用调试器观察内存变化。确认基本功能正常后再加大数据量。我见过有人一上来就设几K的缓冲区结果调了半天发现是缓冲区越界了。5.3 DMA测速失败的常见代码问题“dma测速失败代码”是热搜词之一说明很多人在这上面栽过跟头。典型问题包括问题现象可能原因解决方法测速结果为零DMA根本没启动检查时钟、通道、使能顺序测速结果波动大中断干扰测速期间关闭全局中断测速结果偏小定时器频率算错确认定时器时钟源和分频系数测速结果偏大传输数量设错核对DMA_BufferSize和实际传输量第二次测速失败标志位没清每次测速前清除所有DMA标志位还有一个容易被忽略的点编译优化等级。在-O0优化下循环等待标志位的代码可能被编译器优化掉导致测速逻辑异常。建议测速相关代码用volatile修饰标志位变量或者把优化等级调到-O1以上。5.4 嵌入式面试中DMA相关八股文的应对“嵌入式八股文”里DMA是高频考点。常见问题包括DMA和中断的区别是什么中断是CPU处理数据DMA是硬件搬运数据CPU只处理搬运完成的通知。DMA传输过程中CPU能访问内存吗可以但会有总线仲裁开销。如果DMA和CPU访问同一块内存需要软件层面做保护。DMA的传输完成中断和半传输中断有什么区别TC是全部传完触发HT是传一半触发HT常用于双缓冲。循环模式和普通模式怎么选持续采集用循环一次性搬运用普通。回答这类问题时不要只背概念结合具体项目经验说。比如“我在做ADC采集时用了循环模式配合HT中断实现了数据处理和采集的并行”这样面试官会觉得你真的用过。6. 进阶话题从MCU到嵌入式Linux的DMA思维转变6.1 嵌入式Linux下DMAEngine框架的核心理念从MCU转到嵌入式Linux驱动开发DMA的使用方式变化很大。MCU上你是直接操作寄存器Linux下你要通过DMAEngine框架来申请通道、配置传输、提交描述符。DMAEngine的核心抽象是通道Channel和描述符Descriptor。你不再关心具体是哪个DMA控制器、哪个通道只需要向框架申请一个通道然后提交传输描述符。框架负责调度和仲裁。struct dma_chan *chan dma_request_channel(mask, filter_fn, filter_data); struct dma_async_tx_descriptor *tx chan-device-device_prep_dma_memcpy(chan, dest, src, len, flags); dma_cookie_t cookie tx-tx_submit(tx); dma_async_issue_pending(chan); // 等待完成 dma_sync_wait(chan, cookie);这个框架的好处是屏蔽了硬件差异但代价是学习曲线陡。我刚开始接触时光理解dma_cookie和dma_sync_wait的机制就花了一周。6.2 设备树中DMA配置的注意事项嵌入式Linux下DMA通道通常要在设备树里声明。以SPI DMA为例spi1: spi40013000 { compatible st,stm32-spi; dmas dmamux1 0 0 0, dmamux1 1 0 0; dma-names rx, tx; };dmas属性里的四个数字分别代表DMA控制器句柄、请求线、通道、优先级。不同SoC的格式可能不同一定要查对应平台的绑定文档。注意设备树里的DMA配置错了内核启动时可能不报错但驱动probe时会失败。我遇到过一次dma-names写成了rx和tx但驱动里请求的是rx和tx看起来一样实际上设备树里有个隐藏字符导致匹配失败。用dtc工具反编译设备树才找到问题。6.3 从DMA看嵌入式架构师的知识体系“嵌入式架构师”这个热搜词背后反映的是大家对职业发展的关注。DMA看似是一个具体的技术点但它串联起了很多知识硬件层面总线架构、仲裁机制、外设请求映射驱动层面中断处理、缓冲区管理、并发控制系统层面性能优化、功耗管理、实时性分析框架层面Linux DMAEngine、RTOS的DMA抽象一个合格的嵌入式架构师看到DMA不能只想到“怎么配寄存器”而要想到“这个传输对系统总线带宽的影响是什么”“会不会和CPU缓存产生一致性冲突”“在低功耗模式下DMA能不能独立工作”。这些思考维度才是从工程师到架构师的差距所在。7. 我在DMA调试中积累的几个实用技巧第一个技巧用DMA内存到内存传输做自检。在系统初始化阶段配置一次内存到内存的DMA传输把一块已知数据从A搬到B然后比较A和B是否一致。这个自检能快速确认DMA控制器本身是否工作正常排除外设配置的干扰。第二个技巧DMA传输错误中断一定要开。很多人只开TC中断不开TE中断。但传输错误比如访问了非法地址如果不处理DMA会静默停止你还在等TC中断程序就卡死了。TE中断里至少打个日志方便定位问题。第三个技巧缓冲区地址对齐。虽然DMA不要求地址对齐但对齐的地址传输效率更高。我一般把DMA缓冲区用__attribute__((aligned(4)))修饰确保4字节对齐。在STM32上对齐的传输能减少总线仲裁开销实测速率能提升5%到10%。第四个技巧用DMA剩余计数器做超时判断。在等待DMA完成时不要死等TC标志而是定期读取剩余传输数量。如果数量长时间不变说明传输卡住了可以触发超时处理。这个技巧在调试不稳定硬件时特别有用。第五个技巧串口DMA接收配合环形缓冲区。DMA负责把数据搬进环形缓冲区CPU从环形缓冲区读数据。这样DMA和CPU解耦不会因为CPU处理慢而丢数据。环形缓冲区的读写指针用原子操作保护在RTOS下可以用信号量同步。这些技巧都不是教科书上的标准做法是我在实际项目中一点点试出来的。有些可能不适用于所有平台但思路是通用的。DMA这个东西配置本身不难难的是在各种边界条件下让它稳定工作。多调、多测、多记录慢慢就有感觉了。