ARTICLE DETAIL

资讯详情

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

MicroPython下DMA与Scatter-Gather实战:让ESP32-S3多通道采集CPU彻底解放

MicroPython下DMA与Scatter-Gather实战:让ESP32-S3多通道采集CPU彻底解放 做嵌入式这么些年几乎每次遇到“数据采集跟不上”的问题最后都会绕到同一个答案上DMA。最近在ESP32-S3上用MicroPython做一套多通道传感器采样系统采集频率一上去CPU就被中断干到满负荷后来我把采集逻辑换成DMA链式触发配合Scatter-Gather描述符一次把8个通道的数据聚合到一块连续缓冲区CPU瞬间就闲下来了。这篇文章就把这次折腾MicroPythonDMA的经验完整记录下来包括描述符怎么配、缓冲区怎么管、回调怎么收以及MicroPython这套动态内存环境里最容易踩的坑。如果你也遇到过串口DMA接收不定长数据时丢帧、ADC单通道DMA多次采样时数据错位、或者数据从多个分散缓冲区搬到一起时CPU占用过高的问题这篇内容应该能给你一个完整的解决思路。1. 为什么MicroPython项目会需要DMA和Scatter-Gather1.1 MicroPython与DMA先确认你站在哪一层很多刚接触MicroPython的开发者会有一个误区觉得MicroPython是解释型语言性能有限像DMA这种底层机制根本轮不到它来操作。这个想法只对了一半。MicroPython里确实大量使用了底层驱动比如你用machine.SPI、machine.UART、machine.ADC时底层C代码早就把DMA配置好了你确实接触不到。但这意味着你只能在“别人设计好的API”框架里做事一旦你需要的不是“读一次数据”而是“高频连续读取8路采样通道并且要把每通道的数据分别放到独立缓冲区里”那MicroPython内置API就不够用了。比如我自己这个项目用ESP32-S3做电压、电流、温度多路信号同步采集。如果用普通的SPI readinto去读外部ADC每个通道要发一次CS选中、读一组字节、拼到结果数组里采集速率一上来CPU几乎全耗在等待和拼接上。更麻烦的是读到的数据还要做一次“多缓冲区聚合”把8路结果按通道顺序重排成一种固定结构这部分代码既丑又慢。要解决这类问题就得绕过MicroPython的内置封装自己把DMA暴露出来。不同固件对DMA的开放程度差别很大有的社区固件提供了machine.DMA接口比如部分RP2040固件有的需要自己写C扩展桥接到底层驱动ESP32-S3的官方MicroPython固件基本就是这个路子。所以第一步不是急着写代码而是先搞清楚你的固件有没有给你一条通路。1.2 Scatter-Gather的搬运逻辑到底强在哪DMA本身的作用很简单让硬件代替CPU搬运数据。普通DMA只支持“连续地址到连续地址”也就是源地址连续读出一块数据再连续写入目的地址。这在很多场景下够用但遇到数据分散的情况就麻烦了。举个生活化例子你面前有8摞纸每摞只有几页你要把这些纸按顺序合订成一本册子。普通DMA相当于你必须先把每一摞分别复印到同一个临时位置再重新整理成册而Scatter-Gather相当于给搬运工一张清单上面写着每摞纸的位置和页数搬运工按清单一次性把所有纸按顺序搬到目标册子里中间不需要你插一次手。放到数据采集里就是8个ADC通道的采样结果分别存放在8块缓冲区中硬件DMA引擎根据描述符链表依次从这些分散的缓冲区读取数据并聚合到一块连续内存中。整个过程CPU只需要启动一次剩下的搬运全部由DMA完成。“Gather”这个词非常贴切就是把散落的数据收集起来。有很多人问“stm32 hal库adc单通道dma多次采样怎么配”本质就是连续采样数据要按通道归拢而“串口dma接收不定长数据”更需要scatter-gather因为不定长数据的每一帧可能落到环形队列的不同分段里没有聚合能力就只能CPU一块块拷贝。1.3 链式触发如何把“一次搬运”变成“持续流水线”Scatter-Gather的核心是描述符链表。每个描述符里记录了一段数据搬运的源地址、目的地址和长度并且有一个指针指向下一个描述符。DMA引擎启动后会按照链表的顺序一个一个执行每完成一个描述符就自动跳到下一个这就是“链式触发”。更进一步如果把最后一个描述符的next指针指回第一个描述符就形成了一个环形链。DMA会一直循环执行不需要CPU反复参与配置。整个过程有点像工厂里的传送带你只需要第一次把8个工位的参数设好之后传送带就自己源源不断地把产品送到指定位置。这个机制对于连续采样的价值非常大。在MicroPython这种不能保证实时响应的环境里如果每次采样都要CPU重新配置DMA中断延迟稍微一抖动就会丢数据。改成环形链之后DMA一直在跑MicroPython的任务变成“定期去缓冲区里取最新结果”压力小很多。2. 核心细节解析描述符、缓冲区与触发时机2.1 描述符链表的字段拆解描述符是整个DMA链式传输的“指令卡”不同芯片的位域定义会有差异但控制逻辑大体一致。以我用的ESP32-S3 GDMA为例每个描述符通常是16字节核心字段包括缓冲区地址存放实际数据的物理内存地址一般要求4字节对齐。数据长度本次搬运的字节数通常有上限比如12位压缩表示一个描述符最多4095字节具体看芯片。控制位包括owner位、中断使能位、EOFEnd of Frame标记等。下一个描述符地址链表的关键决定完成本次后跳到哪。owner位特别值得一提。DMA和CPU对缓冲区是“互斥访问”的某个描述符归DMA管理时CPU不能去改里面的数据DMA搬运完成后会把owner位还给CPU表示这块缓冲区可以被安全访问了。很多第一次用链式DMA的人没检查owner位就直接读数据拿到的经常是上次的旧数据。用代码来理解这个结构会更直观下面是我在C扩展里定义描述符表的简化版本typedef struct { uint32_t size:12; // 缓冲区容量 uint32_t length:12; // 实际搬运长度 uint32_t reserved:8; uint32_t next_desc_ptr; uint32_t buffer_addr; uint32_t control; // owner/eof/int_en 等控制位 } dma_desc_t;这里我做了精简真实芯片的描述符字段排布可能不是这个顺序但核心概念不变。你在自己的芯片上实现时一定要对照参考手册确认位域位置。2.2 缓冲区设计对齐、容量和生命周期描述符只是“地图”真正存放数据的还是缓冲区。在MicroPython环境里缓冲区设计有三个关键点容易翻车。第一是地址对齐。DMA要求缓冲区地址按4字节对齐这在C语言里很自然但在MicroPython的bytearray里并不保证。所以获取地址后要先检查不对齐就得想办法在地址前面加偏移补偿。我一般会在分配缓冲区时多申请几个字节然后计算对齐后的地址import uctypes buf bytearray(64 4) # 预留对齐空间 aligned_addr (uctypes.addressof(buf) 3) ~3 offset aligned_addr - uctypes.addressof(buf)第二是容量和长度的区别。描述符里的size表示这块缓冲区有多大length表示这次实际搬了多少。在连续采集场景缓冲区通常开得比一次数据大形成一个“半满/满”的轮转结构。MicroPython端判断数据有效性的方式不是看缓冲区“有没有数据”而是按时序或标志位来判断。第三是生命周期。这点在MicroPython里尤其致命。DMA是硬件在后台搬运它不关心Python对象是否还被引用。如果你传给C扩展的bytearray被垃圾回收了DMA还在往那块已释放的内存写数据轻则数据错乱重则直接导致系统崩溃。解决方法是把缓冲区放进全局变量并且在整个采集周期内禁用相关对象的回收_buffers [] # 全局引用防止gc def alloc_dma_buffers(count, size): global _buffers _buffers [bytearray(size 4) for _ in range(count)] return _buffers2.3 怎么判断DMA链完成了链式DMA的执行是在后台的MicroPython不能像同步函数那样等着它结束。判断完成的方式常见有三种。第一种是查询owner位。DMA每完成一个描述符会把owner位从DMA交还给CPU。MicroPython侧轮询所有描述符发现全部归位就说明这一次链式搬运完成了。这种方式简单可靠适合数据采集速率不太高的场景缺点是CPU还要定时“看一眼”。第二种是中断回调。在描述符里使能中断标志DMA完成指定节点后触发中断在中断服务函数里设置一个事件标志。MicroPython侧可以通过micropython.schedule或者C扩展里维护一个标志变量来通知Python层。这种方式响应快但MicroPython的中断处理有延迟标志处理要在合适时机做。第三种最省心就是把描述符连成环形链让DMA永远不停。采集系统启动后DMA像心跳一样持续搬运数据MicroPython只需要周期性去读取最新一份数据。CPU不需要关心“这次搬运何时结束”只需要知道自己该在哪个时间点取数。我最终的项目就用了环形链方案8个通道的采样缓冲区各自独立描述符链把8块缓冲区和聚合区连成环DMA每转完一圈代表采集了一帧完整数据。MicroPython这边用轮询加时间戳比对发现聚合区里的帧序号变化了就取走一帧做后续处理既不丢数据CPU占用也极低。3. 实操在ESP32-S3上跑通Scatter-Gather数据聚合3.1 准备硬件和固件构建环境这次实验的硬件很简单一块ESP32-S3开发板一个通过SPI接口输出的多通道ADC采集板外加一块OLED屏用来显示聚合结果。OLED本身不是重点真正核心的是ADC那一路数据流。由于官方MicroPython固件没有直接暴露DMA接口我需要通过MicroPython的C扩展机制把DMA能力引入。具体的构建流程是在MicroPython源码仓库的ports/esp32目录下编写自己的模块配置好micropython.cmake然后编译用户模块并刷入固件。这个过程不算复杂但容易在环境配置上卡住建议直接参考MicroPython官方文档里的“Building the firmware”部分。我写的C扩展模块名定为uhadma对外暴露一个核心函数gather_collect参数依次是分散缓冲区的地址列表、长度列表、目标聚合区地址。这样一个函数就完成了“从多块分散缓冲区到一块连续缓冲区”的Scatter-Gather聚合搬运。3.2 C扩展与MicroPython调用示例C扩展底层做的事情本质上就是构造描述符链表并启动DMA。核心逻辑如下STATIC mp_obj_t uhadma_gather_collect(mp_obj_t src_addrs_obj, mp_obj_t lens_obj, mp_obj_t dst_addr_obj) { // 解析Python列表参数 mp_obj_t *src_items; mp_obj_get_array(src_addrs_obj, num, src_items); // 从 lens_obj 中解析每块长度 // 分配描述符链表内存 dma_desc_t *desc (dma_desc_t *)malloc(num * sizeof(dma_desc_t)); for (int i 0; i num; i) { uint32_t src_addr mp_obj_get_int(src_items[i]); desc[i].buffer_addr src_addr; desc[i].length lens[i]; desc[i].next_desc_ptr (i num - 1) ? (uint32_t)desc[i1] : (uint32_t)desc[0]; // 设置owner位归DMA、使能对应控制位 } // 启动GDMA mem2mem搬运 // 等待全部描述符执行完成轮询owner位 // 释放描述符内存 return mp_const_none; }因为这个模块最终要把DMA描述符的地址传给硬件所以上面的地址必须是物理地址。在ESP32-S3上DMA所见的内存地址和CPU地址通常一致但不排除个别平台存在差异移植时建议确认一下。MicroPython侧调用就非常简洁了import uctypes from uhadma import gather_collect # 8个通道的独立采样缓冲区 chan_bufs [bytearray(64 4) for _ in range(8)] # 目标聚合区 aggr_buf bytearray(8 * 64 4) # 计算对齐后的真实地址 src_addrs [] for b in chan_bufs: base uctypes.addressof(b) src_addrs.append((base 3) ~3) dst_addr (uctypes.addressof(aggr_buf) 3) ~3 # 构造长度列表假设每通道实际有效数据为60字节 lens [60] * 8 # 执行一次Scatter-Gather聚合 gather_collect(src_addrs, lens, dst_addr) # 此时aggr_buf里已经是按通道顺序排列好的完整数据帧整个过程从外部看就像一个“一次性把所有通道数据收齐”的魔法函数底层却是DMA硬件在描述符链的驱动下一块一块完成的。CPU没有参与逐块搬运所以即使实际业务逻辑再复杂主循环也不会被采集任务拖垮。3.3 效果验证与数据对比改完以后我在同样采样率下做了一组对比。使用普通SPI读函数逐通道读取并自己拼装数据8通道每帧耗时大约在2-3毫秒而且随着通道数增加线性上涨换成DMA链式聚合后每帧耗时下降到0.3毫秒左右8个通道的搬运基本被“并行”压到一次DMA传输里了。更明显的差异体现在CPU占用率。之前主循环经常被数据拼接拖到无法响应按键和OLED刷新改完后主循环的空闲时间多了一大截OLED刷新、按键扫描、串口日志这些事情全都能从容处理。这就是把“搬运”交给硬件、把“计算”留给CPU带来的实际收益。4. 常见问题与排查技巧实录4.1 高频问题速查表我把这次实验中遇到的典型问题整理成了表格方便你在自己的开发中快速对照。问题现象可能原因排查思路聚合结果全是0缓冲区地址传错了DMA读的是空洞打印uctypes.addressof确认对齐后的地址数据错位、顺序不对描述符顺序与通道顺序不一致逐个检查描述符里buffer_addr是否对应正确通道偶发丢帧owner位未检查读到DMA未完成的区域在C扩展里轮询所有描述符owner位后再返回系统随机卡死缓冲区被MicroPython垃圾回收全局引用缓冲区必要时关闭gc自动回收采集几轮后失效环形链描述符没有重置控制位检查eof位和owner位是否在完成后被清掉DMA一直不触发描述符地址未对齐确保desc链表本身也按芯片要求对齐4.2 我踩过的几个坑先说对齐这个坑。我第一次在ESP32-S3上跑DMA时直接把MicroPython的bytearray地址丢给描述符结果前两轮数据正常后面偶尔出现错乱。查了一下午最后还是按参考手册把地址按4字节对齐并把描述符本身的内存也做了对齐问题才彻底消失。DMA这东西看起来是“搬数据”实际上对内存布局极其敏感任何一处的非对齐都可能让你浪费一整天。另一个坑是MicroPython垃圾回收。当时我把缓冲区定义为函数里的局部变量DMA启动后函数返回缓冲区引用计数归零MicroPython顺手就把内存收走了。DMA还在傻乎乎地往那块地址写数据。这个问题排查起来特别隐蔽因为不是必现的内存碎片多到一定程度才开始随机崩。解决方式就是上面提到的把缓冲区挂到全局变量并且在C扩展执行期间通过mp_call_function_1等方式保留对象引用。还有一个很多人会忽略的点描述符里的EOF标记。在串口DMA接收不定长数据时EOF位常用来标志一帧数据的结束。但如果你在环形链里复用描述符EOF位在上一轮没有被清零下一轮DMA就会提前认为“这一帧结束了”导致数据被截断。每次代码迭代都要检查控制位的“复位”是否完整我一度怀疑是缓冲区不够大最后才发现是EOF位没清。最后给你一个经验性建议在MicroPython里做DMA开发不要一上来就直接接外设。先用内存到内存的方式打通一条Scatter-Gather链确认描述符逻辑和缓冲区管理没问题再换成真实的外设数据源。这样排查问题时你只需要面对一个变量而不是“DMA没跑通”和“外设没配好”两个因素叠加的疑难杂症。这次把数据聚合改成DMA链式触发之后我在实际使用中最大的感受是MicroPython并不排斥底层机制关键是找到合适的方式把能力暴露出来。后续如果想继续扩展可以把环形描述符链跑成常态化再配合OLED做实时波形显示整个系统即便在较高采集速率下也能保持稳定。希望这篇记录能帮你少走几步弯路有问题也欢迎在评论区交流。
返回列表