资讯详情

嵌入式DMA实战指南:从单片机串口到Linux排障的完整解析

📅 2026/9/10 4:25:42 | 华诺云谱 👁 阅读
嵌入式DMA实战指南:从单片机串口到Linux排障的完整解析
做了这么多年嵌入式我越来越觉得DMA是个有点“反直觉”的东西。刚入行那会儿总觉得DMA是给Linux内核、音视频编解码那种大场面用的单片机这边一个GPIO翻转都得小心翼翼算周期哪有闲工夫搞什么“直接存储器访问”。可真到做项目密集期串口要收发不定长帧、ADC要四个通道连续采样、灯带要刷WS2812的全彩呼吸效果CPU负载眼看着就顶不住了这时候才意识到DMA不是锦上添花而是真正能把单片机从“搬砖”里解放出来的关键机制。这篇文章我会直接以工程师的视角把DMA的工作流程、传输模式、典型应用场景一次讲透。文中会涉及STM32、GD32、ESP32-S3、RK3588等不同平台的实践也会把我在串口DMA、ADC多通道DMA、PWMDMA以及Linux下DMA报错排查里踩过的坑一并整理出来。无论你是刚开始学DMA还是已经写过几年HAL库代码但在疑难场景下偶尔卡住这篇都应该对你有实际帮助。1. DMA到底解决了什么问题先理解它为什么存在1.1 没有DMA的日子CPU都在忙什么要理解DMA的价值先得看没有DMA的时候数据是怎么走的。以串口接收为例最朴素的做法是CPU轮询主循环里不断读状态寄存器收到一个字节就存进数组。115200波特率下每个字节大约耗时87微秒单个串口似乎还能接受但一旦系统里同时跑着传感器采集、电机控制、屏幕刷新CPU就彻底沦为“搬运工”。明明该去做控制逻辑和复杂运算却把大量时间花在等待数据、逐个搬字上。换用中断方式会好一些字节到了触发中断CPU在中断里把数据挪进内存。可高速外设带来的问题是中断频率太高比如ADC连续采样配合UART高速输出时每秒几十万次中断每次中断都要压栈、跳转、出栈有效利用率被严重稀释。而且中断处理期间的打扰会影响实时控制任务的确定性这在电机控制类应用里是不可接受的。DMA的出现本质就是把“数据搬运”这项工作从CPU手里彻底接走。CPU只需要事先告诉DMA控制器三件事数据从哪里来、要放到哪里去、搬多少。之后外设与存储器之间的数据传输完全由DMA硬件独立完成传输过程中CPU可以并行去做别的事只有整批数据搬运结束才会通过中断通知CPU。1.2 DMA工作流程拆解从配置到结束的四步曲DMA控制器的完整工作流程可以拆成配置、请求、传输、结束四个阶段。配置阶段由CPU发起。CPU向DMA控制器的寄存器里写入源地址、目标地址、传输长度、数据宽度以及传输模式。这一步看似简单却是绝大多数问题的根源所在。以STM32为例DMA的源和目标地址各有独立的“地址自增”开关外设到内存的模式下通常外设地址不自增、内存地址自增但如果外设是FIFO缓冲或者你要反复读取同一个外设寄存器情况就完全不同。数据宽度也必须和外设寄存器匹配外设寄存器是16位DMA却配成8位数据就会出现错位。请求阶段是外设向DMA控制器发出“我需要搬运数据”的信号。这个信号可能是硬件引脚也可能是内部事件映射比如串口收到数据会产生接收请求ADC完成一次转换会产生转换请求。DMA控制器收到请求后并不会立刻响应而是先做优先级仲裁。STM32的DMA控制器会同时挂多个通道每个通道的优先级可以配置同优先级时通道编号小的优先。传输阶段是DMA控制器真正占用总线搬运数据的阶段。这里需要理解一个关键概念DMA虽然能解放CPU但它本身并不能凭空获取总线使用权。它要和CPU、其他总线主机竞争总线。常见的做法是“周期窃取”DMA在每个总线周期里偷偷插入一个自己的传输也有“突发传输”模式一次申请可以连续占用多个总线周期批量搬运数据效率更高但也会更长时间地阻断CPU访问总线。结束阶段当剩余传输计数减到零DMA传输完成。如果开启了传输完成中断DMA控制器会向中断控制器发出请求CPU在中断处理里读取结果、启动下一轮传输。注意这里还有一个半传输中断的概念很多高级用法会利用“传输到一半”的时机做双缓冲处理这个后面在环形缓冲区部分详细讲。2. DMA的传输模式与关键参数选错配置就是白忙2.1 按数据流向划分三种方向不能搞混DMA传输方向的配置决定了数据从哪边流向哪边也是最容易混淆的地方。外设到内存是最常见的场景典型代表是ADC多通道连续采样、串口接收。数据由外设的数据寄存器产生DMA负责把它搬运到指定的内存缓冲区。配置要点是外设地址固定、内存地址自增。内存到外设用于输出场景比如串口发送、DAC波形输出、PWM数据更新。CPU或者算法先把要发送的数据写入内存缓冲区DMA再把数据逐个喂给外设的数据寄存器。配置要点同样是外设地址固定、内存地址自增但方向寄存器和前一种完全相反我见过不少人把数据手册抄对了代码却写反结果数据全发了个寂寞。内存到内存是DMA的第三类传输方向不经过外设直接从一块内存搬到另一块内存。STM32系列通常需要在配置时把外设地址也设为内存地址并开启外设地址自增。这类方向适合做内存拷贝、图像数据平移等场景实测在低端MCU上DMA搬内存比CPU循环拷贝快很多因为CPU搬运每条指令都有取指、执行、回写的开销而DMA完全省去了这些。2.2 按传输节奏划分普通模式、循环模式与突发传输普通模式下DMA完成一整轮传输后就停止工作即使外设再次发出请求DMA也不会理会除非CPU重新设置传输计数。这种模式适合一次性搬运比如在外部触发事件到来时把一块准备好的数据发送到外设发送完就结束。循环模式则是一次性设置好传输计数后DMA每完成一整轮传输会自动把计数重新加载为初始值持续响应外设请求。这种模式特别适合连续数据流场景比如串口接收不定长数据、ADC连续采样。我在做四通道ADC采集时就是配置成扫描模式配合循环DMA四个通道转换结果会依次写入缓冲区DMA自动循环CPU只需要周期性去缓冲区里取最新数据。突发传输Burst则需要单独提一下。普通DMA传输是“搬一个数据释放总线再搬一个”突发传输是DMA一次性占用总线连续搬运一组数据比如4个、8个数据然后才释放总线。突发的优势是总线上主机切换次数少整体传输效率高。代价是每次突发期间CPU访问总线会被阻塞对实时性要求极高的控制任务突发长度不要配得太长。2.3 进阶玩法离散式DMAscatter-gather前面说的所有模式都是针对一段连续内存。实际工作中经常遇到数据分散在多个内存块的情况比如网卡收包时协议头在一块缓冲区、数据负载在另一块缓冲区或者视频采集的一帧图像按行分块存放如果DMA只支持连续搬运只能先把数据拼成连续区域再搬运既浪费内存又多一次拷贝。离散式DMAscatter-gather通过一个描述符链表解决这个问题。每个描述符里记录一块内存的地址、长度以及下一个描述符的指针DMA硬件按链表顺序依次搬运全部搬完后再触发中断。高端MCU、DSP以及Linux下驱动里的DMA引擎基本都支持这种模式。STM32的传统DMA不支持真正意义上的scatter-gather但部分系列通过对内存的地址偏移和缓冲区分块也能模拟出类似效果。如果项目中有大量非连续缓冲搬运需求选型时优先考虑带SG功能的DMA控制器能省掉非常多的软件拼接逻辑。3. 典型应用场景拆解串口、ADC、PWM与高速存储3.1 串口DMA不定长接收的正确姿势串口是DMA应用最广泛的战场。先说接收不定长数据这是新手的重灾区。理想方案是“DMA空闲中断循环DMA”串口接收配置成循环DMA后数据不断被搬进缓冲区当串口线上出现一段空闲时硬件触发空闲中断IDLECPU在空闲中断里根据DMA的当前剩余计数寄存器计算出本次收到多少字节。这里有个关键技术细节循环模式下DMA的当前计数寄存器STM32中是CNDTR并不是从初始值往下减到零就停而是减到零后自动重装。所以计算接收长度的公式是len buffer_size - CNDTR。如果拷贝完数据后还想继续接收可以什么都不做DMA会自动继续往缓冲区写但如果数据量很大需要把缓冲区指针重置否则两次长度计算结果会互相覆盖。我踩过的最典型的坑是在空闲中断里用了延时处理结果处理期间DMA还在往缓冲区写数据把前一次的数据覆盖了。解决办法是设置一个足够大的环形缓冲区或者空闲中断里只做标记、置标志位真正处理放到主循环。关于串口DMA发送是否需要等待上一轮发送完答案是肯定的。普通模式下你调用一次DMA发送后DMA通道会一次性把缓冲区数据搬运到串口数据寄存器但串口数据寄存器本身有移位寄存器缓存DMA认为的数据“搬运完成”和串口线上真正把最后一位电平发完存在一个时间差。也就是说DMA传输完成中断触发时最后一个字节可能还在移位寄存器里等待发送。如果此时立刻修改发送缓冲区内容就会出现字节错乱。稳妥做法是发送同样使用DMA但等待串口的“发送完成TC”标志或者捕获DMA传输完成中断后在中断里再等待TC标志确认后再允许下一次写缓冲区。3.2 ADC多通道DMA采集数据排列与缓存边界问题ADC多通道采集用DMA几乎是标准做法。以STM32四通道为例配置成扫描模式后ADC按顺序依次转换四个通道每次转换结束都会触发DMA请求DMA把转换结果依次存入数组。最终缓冲区里的数据就天然按通道顺序排列第0个元素是通道0第1个元素是通道1依此类推。循环DMA模式下DMA会持续搬运CPU读到的永远是最近几次的转换结果。这类方案看起来简单但我在GD32E230上真真切切遇到过“ADC四通道DMA数据紊乱”的怪问题通道0的数据偶尔出现在通道2的位置上而且毫无规律。最终排查发现GD32E230上多个ADC通道对应的DMA映射比较特殊DMA请求标志与ADC转换完成事件之间的同步存在极短时间窗如果ADC配置成连续转换模式DMA响应稍有延迟上一次转换结果会在下一次转换请求到来时被搬运导致整体错位一格。解决办法是ADC不要用连续转换改为由定时器触发启动一次扫描保证每次DMA请求都有明确的对应关系。所以如果你的ADC也是“一个数据永远错位但又查不出别的原因”优先检查触发源与DMA请求的时序对齐。3.3 PWMDMA一条龙驱动WS2812灯带PWMDMA这个组合在很多教程里不常被放在一起讲但在驱动WS2812这类单线协议灯带上非常实用。WS2812各位数据的编码依靠不同占空比完成一个PWM周期代表一个bit高占空比表示逻辑1低占空比表示逻辑0。常规做法是用定时器输出固定频率PWM然后通过CPU中断去修改比较寄存器值但如果灯带长度是60个灯珠、24bit颜色光中断就有1440次主频不高的MCU扛不住。DMA方案是从内存里拿一个颜色数据缓冲区把每bit预先转换成对应的定时器比较寄存器值DMA按节奏把数据逐个搬运到定时器的CCR寄存器硬件自动完成占空比切换。这样CPU只负责准备好缓冲区、启动一次DMA搬运剩下的交给硬件。关键点有三个一是缓冲区的数据宽度要和CCR寄存器一致习惯上直接用一个16位数组二是PWM频率决定数据传输速率典型是800kHzDMA触发频率必须和PWM周期严格匹配通常用定时器更新事件作为DMA请求源三是缓冲区长度要比灯珠数乘以每灯位数多预留搬运完所有灯珠数据后DMA就停了不需要循环模式。3.4 高速存储与网络场景中的DMADMA不止活在MCU世界。像UFS通用闪存存储这类高速存储设备主机和存储控制器之间的数据交换几乎全部依赖DMA来完成。UFS设备内部有专门的DMA引擎负责把闪存读出的数据搬到主机内存或把主机内存中的数据写入闪存传输速率动辄上千MB/s靠CPU逐字搬根本不可能。Linux内核中的DMA子系统为这类场景提供了一套标准API从早期的dma_alloc_coherent到现在的dmaengine框架目的都是让驱动开发者不必关心具体平台DMA寄存器细节。Linux平台上的DMA问题往往更复杂。热词里有一条“rk3588eth报failed to reset the dma”这类网卡DMA初始化失败的报错常见原因有几个网卡控制器挂死导致DMA引擎无法复位、IOMMU地址映射配置不正确、或者DMA使用的物理内存区域不足/未对齐。在RK3588这类带IOMMU的平台上DMA地址有时是经过IOMMU映射的动态地址如果驱动在申请DMA缓冲区时没有指定正确的地址宽度和一致性属性硬件访问时就会找不到对应的物理内存。排查时先看dmesg里是否有IOMMU相关报错再查DMA掩码设置最后检查设备树里对DMA区域的限制千万不要一上来就怀疑是网卡芯片本身坏了。4. 不同平台DMA配置实战与差异对比4.1 STM32 HAL库串口DMA从配置到能用STM32上使用串口DMA接收我习惯的完整步骤是先在CubeMX里把串口外设打开然后在DMA Settings选项卡中给UART_RX添加一个DMA通道方向选PeripheralToMemory模式选Circular数据宽度都设成Byte。这样做完CubeMX会自动生成串口和DMA的初始化代码。需要注意的是生成的初始代码顺序有讲究DMA初始化要放在串口初始化之前否则串口一旦收到数据DMA通道还没准备好会直接触发溢出错误。初始化完成后调用HAL_UART_Receive_DMA(huart1, rx_buffer, BUFFER_SIZE)启动接收。空闲中断需要在串口初始化之后手动打开__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE)。然后在串口中断处理函数里判断空闲标志一旦触发就读取__HAL_DMA_GET_COUNTER(hdma_uart1_rx)得到剩余计数用缓冲区总长度减去剩余计数就是当前收到的字节数。注意多个串口都用同一个DMA控制器时中断服务函数里不能用固定的缓冲区地址硬编码要按串口外设实例动态获取DMA句柄。HAL库还有个容易忽略的细节HAL_UART_Receive_DMA启动后如果从来没收到过数据空闲标志不会触发如果需要检测“超时没数据”的场景还得搭配定时器做超时管理。我习惯用一个基本定时器每毫秒检查一次DMA计数是否变化几毫秒没有变化就认为一段数据接收完毕。4.2 GD32固件库的DMA配置差异GD32的DMA在功能上基本对齐STM32但细节差异挺多。最典型的是DMA通道与外设请求的映射关系不完全一致GD32系列不同型号之间甚至都有差别。比如GD32E230的DMA默认可以映射多组外设请求但需要通过DMA通道的外设请求选择寄存器来指定这和STM32的固定映射逻辑不同。代码里配置DMA请求映射时必须先查数据手册里“DMA请求映射表”绝不能照搬STM32的代码。GD32固件库里初始化DMA通道时要在结构体里多关注periph_addr、periph_inc_en和memory_inc_en这三个字段。接收场景外设地址固定、内存地址自增发送场景反过来外设地址固定、内存地址自增方向和接收其实一样区别主要在源和目标地址的取值上。如果某个项目从STM32移植到GD32DMA配置参考芯片手册重新核对一遍不要只替换HAL函数名。我遇到过固件版本更新后GD32 DMA寄存器默认复位值有变化导致旧配置失效的情况最简单的处理是对DMA控制器整体执行一次dma_deinit再重新初始化保持配置的确定。4.3 ESP32-S3的DMA初始化和描述符ESP32-S3的DMA系统和STM32完全不是一个路数。它的DMA主要用于外设与内存之间的高速传输比如SPI、I2S、摄像头接口而且驱动底层普遍采用描述符链表Descriptor机制。也就是说DMA传输的内存缓冲区不是通过一个起始地址和长度指定的而是通过一个描述符数组。每个描述符里存储一块缓冲区的物理地址、长度、是否在传输结束后触发中断、以及下一个描述符的地址。初始化ESP32-S3的DMA核心工作是构建这个描述符链表然后把链表首地址写入DMA控制器的指定寄存器。踩过的坑主要有三个。第一个是长度字段的字节对齐限制ESP32-S3的DMA描述符对缓冲区的字节对齐有硬性要求一般要求按4字节或8字节对齐否则硬件报错或数据错乱。第二个是描述符本身必须放在内部RAM且地址能被DMA访问到某些外部PSRAM区域不是所有外设DMA都能直接访问。第三个是数据缓冲区的物理地址问题应用层拿到的指针往往是虚拟地址而DMA描述符里需要填物理地址在带MMU的芯片上如果你用普通malloc申请缓冲区并直接把地址填进描述符十有八九会读回全零或乱码正确做法是使用驱动框架提供的DMA-capable内存分配接口。4.4 Linux平台DMA与RK3588网卡报错思路Linux平台的DMA由内核统一管理驱动开发者通常不与具体DMA寄存器直接打交道但遇到“failed to reset the DMA”这类报错时理解底层机制仍然很重要。RK3588的网卡驱动在初始化时会对DMA引擎做复位如果复位超时驱动就会报错。常见引发原因是网卡的时钟或电源域没有完全使能DMA引擎还没上电也有可能是IOMMU配置异常导致驱动访问DMA寄存器时地址不可达。排查这类问题我一般按以下顺序第一步看设备树节点里网卡的compatible和时钟定义用cat /sys/kernel/debug/clk/clk_summary确认相关时钟是否开启第二步看iommu是否启用如果启用确认驱动是否正确调用了dma_set_mask_and_coherent函数第三步在驱动里请求DMA缓冲区的地方增加对齐和大小打印DMA掩码不支持超过32位地址时内核会自动使用SWIOTLB做反弹缓冲缓冲区地址和硬件期望的地址不同也会导致复位失败。用ftrace把DMA相关函数调用链打出来往往比猜测配置更有效。5. DMA疑难杂症排查与避坑指南5.1 数据错位和数据紊乱排查顺序DMA数据紊乱是最让人头疼的。这个热词背后是大量工程项目的真实痛点。我总结了一套排查顺序第一检查数据宽度。外设寄存器如果是32位DMA配置成8位就会只搬运低8位高字节丢失反过来外设8位、DMA配16位搬运时会把相邻地址的数据一起带走发生错位。第二检查地址自增设置。内存地址忘了自增所有数据都会写到起始地址表现为缓冲里永远只有第一个值在变化。第三检查缓冲区大小和DMA传输长度是否一致。DMA每次传输的剩余计数CNDTR是递减的如果传输长度大于缓冲区硬件就直接写到缓冲区后面的内存区域破坏其他变量。第四检查缓存一致性。带Cache的高端芯片上CPU写缓冲后如果Cache没有回写DMA读到的可能是旧数据DMA把数据搬到内存后CPU如果启用了Cache优化读到的话也可能是旧数据。针对Cache一致性问题我的建议很简单MCU优先选用带DMA一致性的内存属性或者对关键缓冲区做__ALIGN_BEGIN按Cache Line对齐Linux驱动使用dma_alloc_coherent申请缓冲区API本身会保证一致性。5.2 DMA中断处理不及时与双缓冲机制循环DMA如果每次都等传输完成中断再去拷贝数据中断处理期间DMA仍在继续写缓冲很有可能覆盖数据。双缓冲机制是成熟方案把DMA缓冲区从逻辑上分成两块DMA传输到前半块结束时触发半传输中断CPU拷贝前半块数据DMA继续传输后半块结束后触发传输完成中断CPU拷贝后半块数据。这样CPU处理数据的时间被分散在两个时间段里DMA写缓冲都不会追上程序读缓冲。具体到STM32循环模式下只需分别使能HAL_DMA_IRQHandler中的半传输中断和传输完成中断并在中断回调里按当前的计数范围处理数据。注意半传输中断触发时CNDTR计数已经减到一半此时缓冲区前半部分是被填满的、可以安全读取后半部分正在被写入。有了这个机制连续串口数据流也不会丢。5.3 DMA测速与性能评估技巧项目里偶尔会要求给出“DMA实际搬运速度”的数据。直接查数据手册里的总线时钟和突发宽度算理论峰值和实测往往有差异。我测速的常用做法是申请一块足够大的内存缓冲区配置DMA从内存A向内存B搬运用定时器测量从启动到传输完成中断触发之间的时间。传输的字节数除以时间就是实际吞吐率。实测时要小心缓存一致性的影响DMA开启前对源缓冲区执行一次Cache Clean操作结束之后对目标缓冲区执行Cache Invalidate。另外要注意放测速代码时不要让编译器把两个缓冲区都优化掉用全局变量或volatile修饰。通过测速还能间接判断总线拥塞情况用DMA连续搬内存时CPU跑个简单浮点计算如果浮点运算时间明显变长说明DMA对总线占用比较大后续可以降低突发长度或者给其他总线主机提高优先级。6. 写在最后DMA的调试心得我做DMA相关调试的经验是先用最笨的方法确认数据对不对再谈效率。DMA一旦工作起来数据都是硬件在搬不可见、不可打断调试难度比普通软件逻辑高很多。所以测试阶段尽量用小缓冲区、低速外设把DMA初始化和中断回调里都加上一个GPIO翻转用示波器或者逻辑分析仪观察DMA传输的起始与结束这会直接帮你确认DMA到底有没有触发、有没有及时响应。如果手头没有示波器就利用调试器的寄存器查看窗口盯着DMA的对应状态寄存器和计数寄存器看数值变化也能定位不少问题。另外“DMA传输完成中断”和“数据真正使外设完成输出”是两回事这一点无论MCU还是Linux下都适用。传输完成只代表DMA把数据交给了外设不代表外设已经处理完。比如串口发送后的TC标志、SPI发送后的BUSY标志都要额外判断。我习惯把所有DMA相关的初始化、中断回调、错误处理都集中放在一个文件里不分散到业务代码中一旦出问题直接查这个文件能省大量排查时间。DMA真正让人着迷的地方是它把“数据传输”从CPU的线性执行逻辑中抽离出来变成一种并行的硬件行为。理解它并不难难的是在每一个具体场景里选对模式、配好参数、处理好边界。希望这篇内容能帮你在下一块板子上少走点弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。