资讯动态

嵌入式DMA驱动开发指南:原理、配置与实战避坑

发布时间:2026/10/4 20:14:10 来源:尧图企业网站定制
这一期废话不多说直接聊DMA。前几天帮同事调串口DMA发送他照搬标准库例程板子却老是丢最后一个字节排查半天才发现是发送完成标志用错了——DMA把数据搬到串口数据寄存器不代表移位寄存器已经把最后一位发出去了。这种问题在DMA驱动里太典型了。做嵌入式驱动开发这些年DMA是我见过“例程能跑、自己写就翻车”概率最高的外设之一只要把工作原理、资源映射、缓冲区生命周期这几个关键点吃透串口DMA、ADC多通道采集、连续数据流搬运这些场景其实就是同一套套路。这篇文章就按这个思路展开覆盖GD32、AT32、HC32F460、MSPM0G3507这些常见平台的差异但核心逻辑是通用的细节部分你还是要对着自己的参考手册核一遍。1. DMA到底在干什么原理先行驱动不糊涂1.1 用“快递小哥”的比喻把DMA讲清楚DMA全称Direct Memory Access直译是直接存储器访问。很多初学者把它想得很玄乎其实可以简单类比成公司里的快递小哥CPU是老板DMA是专门负责搬货的小哥。老板要做的事情只有一件——交代清楚“把这箱货从A仓库搬到B仓库”然后快递小哥就自己去搬搬完跟老板汇报一声“搞定”。这中间老板不需要站在旁边一件一件递货。放到单片机上这里的“货”就是数据“A仓库”和“B仓库”就是外设寄存器和内存缓冲区。比如串口发送传统的轮询方式是CPU死盯着发送缓冲区标志位每发一个字节都要CPU参与一次用了DMA之后CPU把要发的数据准备好配置好源地址内存、目的地址串口数据寄存器、搬运长度然后串口每出现一个发送请求DMA就自动把一个字节从内存搬到串口寄存器搬完整个缓冲区才产生一次中断通知CPU“这批货发完了”。为什么嵌入式驱动这么看重DMA算一笔账就明白了。以115200波特率的串口为例一帧数据包含1位起始位、8位数据位、1位停止位实际数据传输率大约是11520字节/秒每个字节之间间隔约86.8微秒。如果用轮询方式每个字节处理需要20微秒CPU占用率就超过23%如果把波特率提到1Mbps甚至更高没有DMACPU几乎就被串口拖死了。用DMA之后CPU只需要在整批数据开始前做配置、结束后做处理占用率可以降到个位数甚至更低。这就是DMA存在的根本意义把CPU从低效的“逐字节搬砖”中解放出来去做更有价值的协议解析、状态管理、算法处理。1.2 一次DMA搬运的完整生命周期要写好DMA驱动脑子里必须有一个清晰的“搬运过程”模型。一次完整的DMA传输通常包含以下几个阶段配置阶段。驱动代码把所有搬运参数写入DMA控制器的寄存器包括源地址、目的地址、传输方向、数据宽度、传输长度、中断使能等。这个阶段是驱动开发的重点参数错了后面全错。请求阶段。DMA使能之后并不会立刻搬运而是等待外设发出请求。以串口接收为例串口外设每收到一个字节就会产生一个接收事件驱动会把串口的DMA接收请求使能给上。这个“请求”本质是一个硬件电平信号或寄存器事件DMA控制器看到了才会动作。搬运阶段。DMA控制器响应请求从源地址读取数据、写入目的地址然后把内部计数器减一。如果配置了地址递增源或目的地址会自动递增让下一次搬运写到下一个内存单元。完成阶段。当计数器从初始值减到0一次DMA传输就结束了。这时候DMA控制器会产生传输完成事件可以触发中断。在普通模式下传输停止在循环模式下计数器自动重装初始值DMA继续等待下一次外设请求不需要CPU重新配置。关键点是DMA的每一步都是“事件驱动”的不是时钟一到就无脑搬运。这个特性决定了驱动代码必须配套管理外设的DMA请求开关很多“明明配了DMA却不搬运”的问题其实都是外设那边的DMA请求没有使能。1.3 驱动的边界哪些事必须CPU亲自做DMA不是万能的驱动开发里要明确它的职责边界。DMA只负责搬运不负责理解数据内容。收到的数据是什么协议、帧格式怎么解析、有没有校验错误、下一批数据什么时候发——这些决策都必须由CPU来做。实际驱动开发中CPU负责三块事情第一初始化配置包括DMA和外设的握手关系、中断优先级、缓冲区分配第二启动与停止即何时使能外设的DMA请求何时暂停DMA搬运第三数据处理包括中断回调里的标志位设置、主循环里的数据解析、异常时的错误恢复。很多新手写驱动时容易进入一个误区试图把DMA配置成“全自动万能搬运”甚至希望驱动层完全不关心数据内容。这在一些极端场景下或许可行但在复杂的多任务嵌入式系统里不加区分的全自动DMA反而会成为调试的噩梦。正确思路是DMA做“四肢”的执行CPU做“大脑”的决策各司其职。2. 驱动设计里的关键细节通道映射、缓冲区与中断2.1 通道、请求号、外设映射表——启动前先查这张表我见过太多人踩同一个坑例程里用的是USART1的DMA通道自己项目里换成USART2只改了通道号就以为万事大吉结果外设根本不触发DMA。问题的根源在于大多数MCU的DMA请求与外设不是随便配的而是通过一张固定的映射表关联起来的。以STM32F4为例DMA1和DMA2各有8个Stream每个Stream可选择的外设请求类型是有限集合。USART1_TX只映射到DMA2的某个Stream而USART2_TX可能映射到另一个Stream具体对应关系一定要查参考手册中的“DMA request mapping”章节。GD32的情况也很类似DMA通道与外设请求之间是固定映射同一个外设的不同功能如UART的TX和RX通常映射到不同通道。AT32则更进一步引入了DMA MUX多路选择器外设请求不再绑定到固定通道而是可以在一定范围内自由映射。这种设计的灵活性更高但代价就是驱动里需要多配置一个“请求选择”寄存器。HC32F460的DMA也有类似的事件通道配置项触发源选择错误会导致DMA永远等不到有效请求。MSPM0G3507使用SysConfig做图形化配置看起来省事但如果你不看TRM里的trigger mapping直接在代码层面改通道号照样会翻车。所以我的习惯是每接触一款新芯片第一件事就是打开参考手册把要用的外设对应的DMA映射表抄到自己的笔记里做成头文件注释。这个动作看起来慢实际上能省下后面排查硬件问题的数小时时间。2.2 数据宽度、突发模式、循环模式参数不要照抄例程DMA配置里最容易“照抄例程翻车”的就是数据宽度和地址递增方式。数据宽度决定DMA每次搬运的数据位数可选8位、16位、32位。串口的数据寄存器本质是8位寄存器ADC转换结果寄存器是16位或32位有效数据可能只有16位。源和目的地址的宽度可以不一样DMA会自动扩展或截断但你要清楚数据流里每个单元实际占多少位。比如ADC多通道扫描采集时转换结果寄存器是个固定地址的32位寄存器如果用16位宽度DMA会把低16位数据搬走如果配成32位搬运效率更高但要留意内存缓冲区里每个通道数据占4字节。这两种写法都能跑但缓冲区大小、数据排列方式完全不同。驱动里最忌讳的就是“例程配16位我也配16位”完全不思考为什么。地址递增设置也是同理。源地址和目的地址分别都有“递增”和“固定”两个选项一共四种组合。外设寄存器通常是固定地址内存缓冲区通常要递增。如果内存侧忘了开递增DMA会把所有数据写进同一个地址后面全被覆盖如果外设测不该递增却开了递增数据来源就漂掉了。突发模式burst和FIFO是很多带较高总线频率MCU才有的选项。突发模式允许DMA在一次总线仲裁中连续搬运多个数据减少总线切换开销提高传输效率。但要注意FIFO阈值必须匹配突发长度不然会出现“FIFO没满就启动搬运”或“FIFO满了但突发长度不满足”的情况表现为数据传输错位或偶发丢失。这个配置不像数据宽度那么直观踩坑后建议先关闭突发模式跑通了再优化。2.3 DMA buffer的管理对齐、生命周期、cache一致性的起点DMA buffer是整个DMA驱动里最容易埋雷的地方。先说对齐。很多MCU的总线和外设要求DMA缓冲区按某种边界对齐比如4字节、8字节、甚至32字节。如果你的缓冲区是一个裸数组编译器很可能只按默认边界对齐在跑内存到内存搬运时可能触发总线错误或性能骤降。我的习惯是给DMA缓冲区显式加对齐属性uint8_t dma_rx_buf[256] __attribute__((aligned(32)));在有MPU和Cache的高端芯片上对齐属性还直接影响缓冲区是否会被划分到cacheable区域。这个后面第4章会专门说这里先记住DMA缓冲区尽量静态分配、尽量显式对齐、尽量避免动态分配。嵌入式平台上malloc出来的内存地址不确定性太大做DMA缓冲区是下策。再说生命周期。DMA搬运是异步的驱动里不能认为“我调用了一次DMA启动函数缓冲区就可以随便改”。实际开发中经常发生这样的事发送函数把Buffer交给DMA后立即返回上层紧接着又修改了这块缓冲区结果DMA发出去的是被修改过的乱数据。正确做法是维护一个缓冲区状态机标记“空闲/繁忙/完成”或者更简单地使用双缓冲一块被DMA占用时另一块可以继续填数据。3. 实操串口DMA、ADC多通道DMA与连续采集3.1 串口DMA发送环形缓冲与发送完成时机串口发送用DMA的收益最直观不再需要CPU一个字节一个字节地等待发送寄存器空腾出来的时间可以处理协议栈或其他任务。但发送DMA真正写起来最容易出问题的是“发送完成”的时机定义。DMA传输完成中断表示“数据已从内存搬运到串口数据寄存器”但串口还有一个移位寄存器它把数据寄存器里的数据一位一位地移出去。如果DMA传来传完成信号时立刻把缓冲区释放或者把串口关掉最后几个字节可能还在移位寄存器里没发完就形成了那个经典的“丢最后一个字节”问题。我同事那次调试DMA传输完成标志置位时他去关串口最后一个字节就丢了换成串口自身的发送完成TXETC中断之后释放缓冲区问题立刻消失。串口DMA发送的常用架构是发送队列加环形缓冲。上层要发送数据时不进驱动直接操作而是把数据压入发送队列驱动在某个机会点把队列头部的数据交给DMA。DMA发送完成中断里驱动弹出已完成的节点如果队列不空就继续启动下一包发送。这样数据包之间无缝衔接DMA不会频繁启停大幅减少重复配置寄存器带来的开销。3.2 串口DMA接收空闲中断 动态帧长度串口DMA接收比发送麻烦因为接收的数据长度是动态的DMA本身只负责搬运不知道一帧从哪里结束。最简单的方式是固定长度接收一帧固定N个字节DMA搬够N字节触发一次中断。但真实场景里命令帧、日志帧、传感器数据帧长度几乎不可能恒定。业界最常见的方案是“DMA接收 串口空闲中断IDLE”。思路是DMA工作在普通模式接收缓冲区设置为最大可能长度同时使能串口的IDLE中断。当串口在一段时间内没有收到新数据时IDLE标志置位说明一帧已经结束了。在IDLE中断里暂停DMA读取DMA当前剩余计数寄存器用“缓冲区总长度 - 当前剩余计数”算出实际收到的字节数然后处理这一帧。实际驱动实现里处理完一帧后要重新配置DMA的目的地址或者重置计数器。有些芯片的DMA在普通模式下传输结束后地址停在最后一个字节位置需要手动改回去有些芯片支持循环模式DMA自己会回到起点配合IDLE中断能少写不少代码。两种方式我都用过循环模式在“持续接收、不定长帧”场景下更省心前提是缓冲区大小足够容纳最大帧长。还有一种更进阶的玩法DMA循环模式 半传输/全传输双中断把缓冲区虚拟成两个半区DMA持续向缓冲区写数据CPU在中断里消费“已经被DMA写满”的半区。这种方案适合高速数据流采集不需要等待帧结束只要保证CPU消费速度大于DMA写入速度就行。3.3 ADC多通道DMA采集STM32F407的CubeMX配置流程ADC配合DMA做多通道采集是嵌入式开发里非常活跃的需求。这里拿STM32F407VET6举一个实际配置流程其他平台思路完全一致只是寄存器名和配置工具不同。CubeMX里的基本步骤如下使能ADC1设置扫描模式Scan Conversion Mode把需要采样的通道加入转换序列。比如我常用的4通道采样通道顺序依次是CH0/CH1/CH2/CH3。然后进入ADC DMA设置使能DMA连续请求Continuous Requests这个选项的作用是让ADC完成一次序列扫描后自动开始下一轮转换不需要软件再次触发。如果关掉它ADC采集一次序列后就会停下来DMA也只搬运一轮数据。接着配置DMA方向选择Peripheral To Memory数据宽度外设和内存都选Half WordADC数据寄存器低16位有效。关键点是DMA缓冲区长度应该等于“转换序列长度×通道数”F407上我一般配一个数组volatile uint16_t adc_values[4];每完成一次通道序列扫描ADC会发出4次DMA请求把4个通道的转换结果依次写入adc_values[0]到adc_values[3]。DMA的传输完成中断可以在4个值都搬完后触发也可以在每次搬运完后触发取决于你配置的是Transfer Complete还是Half Transfer一般用Transfer Complete就够了。配置完之后CubeMX会自动生成MX_DMA_Init、MX_ADC1_Init以及HAL_ADC_Start_DMA(hadc1, adc_values, 4)这样的启动函数。初始化顺序上有个坑必须先初始化DMA再初始化ADC否则外设请求事件可能找不到可用的DMA通道。而且在实际产品代码里不要只在main里调HAL_ADC_Start_DMA要留一个重新启动函数在传感器异常或电源恢复后能重新拉起采集流程。3.4 双缓冲与continuous requests如何做到永不掉数连续采集场景最怕的是什么掉数。DMA搬运到一半CPU在中断里处理数据还没处理完下一个DMA传输就到了把正在处理的数据覆盖掉。解决这个问题的经典方案是双缓冲也叫乒乓缓冲。用循环模式做双缓冲是嵌入式开发的经典套路。假设有一个大小为2KB的缓冲区把它看成两个1KB的半区。DMA开启循环模式不断从外设向整个缓冲区搬运数据。当搬运到1KB位置时触发半传输中断这时前半区1KB数据已经被写满CPU可以安全地消费前半区当搬运到2KB位置时触发全传输中断前半区数据已经被CPU处理过了后半区1KB数据也已写满CPU处理后半区。如此反复循环DMA永远不停CPU只要保证在“一个半区搬运时间内”处理完数据就行。很多带DMAMUX的MCU在这个基础上还引入了连续请求continuous requests模式。传统DMA是“外设请求一次DMA搬运一次”如果外设请求脉冲太短DMA可能来不及响应导致丢失一次请求。连续请求模式下DMA一旦收到初始请求就会持续保持对总线的请求直到计数器减到0。这个特性特别适合高速ADC、连续数据流这类“一旦开始就不希望停”的场景。驱动里使用双缓冲和连续请求时最核心的同步问题是“CPU消费指针”和“DMA写入位置”的协调。半传输和全传输中断就是天然的同步点中断里只需要更新一个状态标志不要在中断函数里做耗时的数据处理把真正的解析工作放到主循环或任务里。这样既保证了数据不丢也避免长时间关中断影响系统实时性。4. 性能、测速与避坑实录4.1 DMA测速怎么测带宽测试与总线竞争做驱动开发不能只讲“能跑通”还得知道数据通路到底有多快。DMA测速在嵌入式这边没有PC上那种现成CrystalDiskMark工具但我常用的办法是用内核调试定时器或DWT周期计数器来测。Cortex-M3/M4/M7内核基本都有DWTData Watchpoint and Trace单元其中CYCCNT寄存器是一个自由运行的周期计数器可以在不打断程序执行的情况下统计周期数。测速思路很简单准备两块内存缓冲区源缓冲区和目的缓冲区大小例如64KB。启动DMA搬运前清零CYCCNT启动搬运等待传输完成读取CYCCNT值用“搬运字节数 / 消耗周期数 × 主频”就得出带宽。DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; DWT-CYCCNT 0; dma_mem2mem_start(src_buf, dst_buf, 64 * 1024); while (!dma_transfer_done()); float seconds (float)DWT-CYCCNT / SystemCoreClock; float mbps (64.0f * 1024.0f) / 1024.0f / 1024.0f / seconds;我实测下来在168MHz主频的STM32F407上SRAM到SRAM按32位宽度搬运DMA带宽大致在几百MB/s这个量级具体值受总线优先级、DMA所在总线域、是否开启突发模式影响很大。如果从Flash搬到SRAM因为Flash本身有等待周期带宽会明显下降。测速结果只代表这条数据通路在特定配置下的表现不要拿一个数值当通用指标。测速时还要注意一个现象DMA搬内存和CPU执行代码存在总线竞争。如果测试时开了中断DMA和中断里对同一总线的访问会互相拖慢测出的数据可能偏低。要测“极限带宽”就把中断暂时关闭让DMA独占总线要测“实际可用带宽”就保持正常工作状态这样更有参考价值。另外一个容易被忽略的点是优化等级和编译器对DMA测试代码的调度也会影响最终数值我一般固定用-O2做对比保证横向可比。4.2 Cache一致性M7/M33上最容易翻车的坑相比M3/M4Cortex-M7、M33这类核心的Cache特性让DMA驱动多了一层大坑。Cache的原理是CPU读写内存时先访问高速缓存但DMA直接访问SRAM不经过Cache。于是出现两种典型故障DMA往内存写了新数据但Cache里还保留着旧数据CPU读的时候拿到的是旧值或者CPU写好了发送缓冲区数据还停留在Cache里没有真正写回SRAMDMA去搬运时搬到的还是旧值。我同事曾在一块带D-Cache的芯片上做串口DMA接收DMA中断明明说收到了100个字节然而CPU打印出来的却是上一次的旧数据。原因就是DMA刷新了SRAM但CPU读的是Cache里的旧数据。排查这类问题第一步不是看逻辑而是确认这个芯片的SRAM是否默认被配置成cacheable以及你用来做DMA的缓冲区落在哪个内存域。解决方案有两种。第一种是配置MPU把DMA缓冲区对应的内存区域设为non-cacheable简单粗暴从此Cache不会碰这块区域DMA和CPU看到的数据永远一致代价是CPU访问这块缓冲区时性能稍有损失。第二种是在关键操作前后手工维护Cache一致性DMA搬运完成后CPU读数据前先调用Invalidate把Cache中对应地址的数据作废CPU写数据完成后、启动DMA搬运前先调用Clean把Cache数据写回SRAM。具体到CMSIS接口就是SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, sizeof(rx_buf)); SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, sizeof(tx_buf));选哪种方案我个人的习惯是少量固定DMA缓冲区用MPU设成non-cacheable省心大量动态数据处理缓冲区手工做Clean/Invalidate保持性能。最忌讳的是遇到Cache一致性问题时干脆把全局D-Cache关掉Cache带来的性能提升全没了属于杀鸡取卵。4.3 不同MCU的DMA差异速览GD32、AT32、HC32F460、MSPM0G3507国产MCU这几年在嵌入式开发里占比越来越高每种芯片的DMA外围设计各有脾气。我简单把接触过的几类做一个横向速览细节配置还是要以各自参考手册为准。芯片系列DMA控制器特点与STM32的相似度常见坑GD32F30x老树开新花DMA0/DMA1通道固定映射逻辑相似但寄存器不兼容FIFO和突发模式下阈值配置不注意会导致数据错位AT32F4xx引入DMA MUX外设请求可灵活映射思路接近ST后续系列但MUX表要查串口DMA发送需要正确选择握手机制和请求映射容易漏配HC32F460配置项多支持事件通道、块传输/重复传输差异大不能照着ST例程改DMA触发事件选择错误导致DMA永远不启动MSPM0G3507SysConfig图形化配置EDMA控制器差异很大底层是TI风格图形化配置也要核对trigger mapping代码里手改通道号容易失联拿GD32F30x来说它的DMA控制器和STM32F1/F3在外设请求映射上很像但寄存器名、位定义完全不同。很多从ST转过来的人直接套用ST的库函数编译都过不了。要顺利移植建议把GD32官方库里的dma_usart0_send和dma_usart0_receive例程先跑一遍再往里加自己的封装逻辑。AT32的DMA MUX是个双刃剑灵活但复杂。在AT32上配置串口DMA发送时除了设置DMA的源地址、目的地址、长度还必须把MUX寄存器里的请求选择位配成对应的USART_TX请求否则DMA即使配置正确也等不到触发。HC32F460在事件通道配置上很讲究需要明确触发事件来自哪个外设事件寄存器这个环节漏一步DMA就静默罢工。MSPM0G3507在SysConfig里拖拽配置很直观但它的DMA在启动代码里的初始化顺序和其他芯片略有不同而且它对缓冲区地址的32位对齐有一定要求。调试最大问题是“配置在图形界面里是对的但手动改代码后两边不同步”所以用这款芯片做开发时我建议从始至终用SysConfig生成代码不要手改生成文件的DMA部分否则回读配置很容易出状况。4.4 常见问题排查速查表与实战经验下面这张表是我多年调试DMA驱动总结出来的问题排查清单每次遇到DMA相关Bug我都会先对照一遍再深挖日志现象可能原因排查方法DMA配置完但完全不搬运外设的DMA请求未使能通道映射配错查参考手册映射表确认外设寄存器的DMA请求位是否置1只搬运固定长度就停没有配置循环/自动重装模式传输完成后未重新启动检查模式位或者配合半传输/全传输中断在回调里重新启动数据错位或乱码数据宽度与外设寄存器不匹配地址递增方向配置错确认外设DR和ADC DR的宽度检查递增/固定组合偶发丢数据DMA buffer被上层过早修改Cache一致性问题检查缓冲区生命周期状态机确认是否需要在CPU读前Invalidate死机、HardFaultDMA缓冲区地址非法或未对齐配置了不可访问的内存区域用静态对齐数组替代malloc排查内存映射配置进入中断后卡死或反复触发DMA中断标志未及时清除中断优先级与其它外设冲突在中断回调里先清标志搁置优先级配置用默认组别验证DMA中断进了但数据不全使用了循环模式但半传输中断未使能FIFO阈值和突发长度不匹配确认中断使能寄存器先关掉突发模式测试排查DMA问题我还有一个固定套路先把DMA模式退化成最简单的一次普通传输源地址和目的地址都放在内存里用手动软件触发跑通一次搬运确认DMA控制器本身没问题再接外设把外设的DMA请求使能打开确认外部触发没问题最后才开中断、开循环模式、加Cache管理。每次只动一个变量问题定位基本不会超过半小时。5. 最后我的一点建议这一期聊了不少DMA驱动的实操经验最后说一个我特别想让初学者知道的小技巧学会“手动触发一次DMA”。具体做法是在调试器里把DMA的触发源设成软件触发然后手动把源地址填一个数组、目的地址填一个数组长度填16字节执行一次搬运再检查目的数组里的数据。这个动作能帮你把“DMA到底搬没搬”和“我的外设配置对不对”拆成两个独立问题比一上来直接接串口调试直观得多。我自己每到一个新平台、写第一款芯片驱动时都会先用这套方法确认DMA通路是通的再去接外设。等你把DMA的“搬运者”角色真正内化了再看它的各种高端特性——双缓冲、连续请求、Cache维护本质都是在解决同一个问题如何让CPU和外设之间的大规模数据流动既高效又不错乱。这大概也是DMA驱动最让人着迷的地方。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑