
最近在ESP32-S3上做一个多路高速数据采集项目MicroPython的代码写起来很顺手但一跑起来就发现CPU资源全烧在数据搬运上了几路ADC和串口数据分落在不同的环形缓冲里要把它们聚合成一条连续报文要么反复memcpy要么用bytes拼接采集速率稍微提上来MicroPython的GC和堆分配就跟不上。后来翻了ESP-IDF底层的DMA驱动才把思路转到Scatter-Gather上——用描述符链表把不连续的内存区串成一条“流水线”让DMA控制器自己一组一组搬完最后一次性聚合到目标缓冲区。这篇就把这套MicroPythonDMA链式触发的实现过程完整拆开讲适合那些已经在用MicroPython做数据采集、通信处理又想压榨DMA性能的开发者。1. 为什么MicroPython项目会卡在“数据搬运”上1.1 一个典型的瓶颈现场很多人以为MicroPython慢是慢在Python字节码解释上但在I/O密集型的场景里真正的开销往往是Buffer拷贝和内存分配。我之前做的数据采集器就是这样外设侧用串口DMA接收不定长数据ADC用DMA多次采样每个通道的数据都进了各自的bytearray缓冲。到了应用层需要组帧上报时问题就来了# 典型的“数据搬运”代码看起来很简单 frame b for ch in channels: data read_channel(ch) # 每次返回新的 bytes frame data # bytes 拼接会产生大量临时对象这段代码在低速率下完全没毛病但数据量一上来bytes拼接的复杂度是O(n²)而且每次拼接都在堆上分配新对象GC频繁被触发CPU占用直线上升。表面上看是Python代码效率低深层原因其实是我们拿着DMA采好的数据却用CPU逐份拷贝的方式去做聚合DMA省下来的CPU时间又全还了回去。1.2 MicroPython的抽象层把DMA藏得太深MicroPython的machine模块里UART、SPI、I2S这些外设底层其实都用了DMA但上层API封装得太“干净”只暴露了read()、write()、readinto()这类流式方法。大多数情况下这很好用可一旦你想控制“从哪几个不连续的内存地址搬数据”抽象层就帮不上忙了。比如官方固件里的UART.readinto(buf)底层会帮你在一个buf上做DMA接收但它只处理“单块连续内存”。如果你的采集系统有4路通道每路数据落在独立的缓冲里想一次性调度DMA把这4块数据搬运到同一段发送缓冲区默认底层是不允许的——你只能要么分4次调用要么先把4块拷到连续内存再调用。前者浪费DMA启动配置开销后者又把CPU拷贝引入了关键路径。1.3 什么时候才需要Scatter-Gather不是所有项目都需要Scatter-Gather。普通DMA解决的是“从A搬一块到B”Scatter-Gather解决的是“从A1、A2、A3...多块非连续区域按顺序搬到一个连续目标区或者反过来从一个连续区搬到多个非连续区”。我把使用场景分了四类大家可以对号入座场景普通DMAScatter-Gather DMA单通道ADC采样到连续缓冲足够性能过剩多通道数据需要拼包上传需要CPU多次启动或拷贝一条链搞定不定长串口帧需要按帧回收缓冲可用环形队列判断描述符按时序串联天然记录边界多个外设事件需要汇聚成日志块需要中断里反复处理链式触发依次写入日志区当你的项目落到表格后三行时就要认真考虑用链式描述符了。否则等到采集速率翻倍再回头优化Buffer搬运改动面会非常大。2. Scatter-Gather描述符链表把不连续内存拼成一条流水线2.1 描述符到底是什么Scatter-Gather的核心是“描述符Descriptor”本质上是一个硬件/驱动能够识别的结构体里面至少包含三样东西内存地址、传输长度、下一个描述符指针。在ESP32-S3的GDMA驱动里描述符叫gdma_lliLinked List Item结构体定义大致长这样typedef struct { struct gdma_lli *next; // 下一个描述符 uint32_t buffer_ptr; // 数据缓冲区地址 uint32_t next_ptr; // 硬件直接使用的next地址寄存器 uint32_t buffer_size: 12; uint32_t eof: 1; // 是否是链表末尾 // ... 各种标志位和有效字节数 } gdma_lli_t;你可以把描述符想象成快递包裹上贴的“下一站地址”DMA控制器处理完当前这个数据块不需要CPU告诉它去哪它自己读取next字段就跳到了下一个数据块。多个描述符串起来就是一条完整的传输任务链。2.2 链式触发是怎么“自动”跑起来的普通DMA配置一次只能搬一段连续内存。链式模式则把传输任务拆分成多个描述符节点DMA硬件从链头开始每处理完一个节点就通过链表指针自动跳到下一个直到遇到标志为EOF的节点然后产生完成事件。整个过程CPU只在启动时写一次启动寄存器之后完全由DMA控制器自己跑。这就是标题里“链式触发”的含义不是靠CPU反复启动多个DMA而是靠描述符之间的指针形成硬件级连锁反应。ESP32-S3的GDMA还支持“连续请求continuous requests”也就是多个外设事件可以连续提交DMA请求DMA会在前一个链表传输完成后自动接受新的请求中间不需要软件重新配置。配合Scatter-Gather就能实现多路数据“源源不断汇聚到目标区”的效果。用生活类比来理解普通DMA像一个人搬砖一次搬一块Scatter-Gather DMA像流水线作业传送带上已经按顺序放好了n块砖工人只需要按一下开关砖块就自动传送完。2.3 EOF、完成中断和循环模式设计Scatter-Gather时有几个和“语义”强相关的东西要提前定清楚EOFEnd of Frame每个描述符可以设置EOF位表示这一链数据的最后一帧。如果做的是串口不定长接收EOF就可以用来标记一个完整数据帧的边界。完成中断DMA搬完整条链后会产生一个完成中断。实际工程中不要让中断回调去做Python层的复杂处理最好只设置一个标志量让MicroPython层轮询。循环模式如果外设持续产生数据描述符链可以做成环形采集完一轮后自动从头开始。这时要小心数据覆盖问题通常是双缓冲轮换或采集完成立刻切到另一组描述符。理解这些后续在C扩展里设计接口时就不会手忙脚乱。3. 自写MicroPython C扩展把DMA能力暴露给Python层3.1 为什么绕不开C扩展MicroPython的官方API到现在也没有一个“通用DMA”对象想在Python层直接操作描述符链表不太现实。最干净的方案是写一个C扩展模块把ESP-IDF的GDMA能力封装成MicroPython类。这样做有几个好处可以在C层直接调度描述符链表不用经过Python解释器逐条执行指令能安全地管理DMA描述符这块内存保证链表地址对齐让Python侧通过bytearray/memoryview传入缓冲区地址聚合完成后返回bytes既符合MicroPython习惯又保留底层性能。提示如果你用的是官方预编译固件C扩展需要自行编译。最省事的方式是fork MicroPython源码把自己的模块放进ports/esp32/mod目录然后编译固件。具体流程网上有不少现成参考这里不展开。3.2 模块接口设计我设计的模块叫sg_dma里面暴露的类叫SGCollector。一开始想做得复杂后来砍到只剩三个核心方法collector sg_dma.SGCollector() collector.configure(src_blocks, dst_block) # 配置源缓冲列表和目标缓冲 collector.collect() # 启动一次链式DMA搬运 data collector.result() # 返回聚合后的 bytesPython侧调用者只需要提供一块连续的目标bytearray和一组源bytearray具体怎么构建描述符链表、怎么处理对齐、怎么等待完成都封装在C模块里。在MicroPython里bytearray的底层内存是连续且不移动的这正好符合DMA对地址稳定性的要求。3.3 从mp_obj_t到C层缓冲区C扩展首先要处理Python对象和C缓冲区的转换。MicroPython提供了标准接口mp_get_buffer_raise可以拿到对象的缓冲区指针和长度mp_buffer_info_t src_buf; mp_get_buffer_raise(src_obj, src_buf, MP_BUFFER_READ); dma_lli-buffer_ptr (uint32_t)src_buf.buf;这里有个非常关键的坑“拿到指针”不等于“对象会一直存活”。如果Python侧在调用collect()之前临时创建的bytearray被GC回收C层指针就是悬垂的。所以在设计接口时要求调用者把源和目标缓冲作为对象传给C模块C层内部持有这些对象的引用通过mp_obj_t在完成DMA前不要释放。typedef struct _sg_collector_obj_t { mp_obj_base_t base; mp_obj_t src_blocks; // 持有源bytearray对象的引用 mp_obj_t dst_block; // 持有目标bytearray对象的引用 gdma_chan_handle_t gdma_chan; gdma_lli_t *lli_chain; ... } sg_collector_obj_t;3.4 构建描述符链表在C层构建链表的核心代码基于ESP-IDF v5.0的GDMA接口大致是这样的思路static esp_err_t build_lli_chain(gdma_lli_t *lli, uint8_t *addrs[], uint32_t sizes[], int cnt) { for (int i 0; i cnt; i) { lli[i].buffer_ptr (uint32_t)addrs[i]; lli[i].buffer_size sizes[i]; lli[i].next (i cnt - 1) ? lli[i1] : NULL; lli[i].eof (i cnt - 1) ? 1 : 0; } return ESP_OK; }实际使用时要特别留意地址对齐。ESP32-S3的GDMA描述符对buffer地址的字节对齐有要求一般要求按4字节对齐部分外设还要求更严格。如果传入的源缓冲不是从对齐地址开始DMA会直接报invalid状态。我的解决办法是在Python层强制规定缓冲起始偏移为4的整数倍或者用ntlv方式在C层做校验。接着把描述符链挂到通道上并注册传输完成回调gdma_channel_config(GDMA_CHANNEL_0, ...); gdma_register_rx_event_callbacks(chan, callbacks, NULL); gdma_start(chan, lli_chain);完成回调里绝不能调用MicroPython的mp_call_function_0去跑Python函数因为DMA中断上下文不是Python运行时环境容易死锁或触发断言。正确做法是设置一个C层的volatile bool done true让Python层轮询。3.5 编译固件的接入在自己的MicroPython端口目录里新建modsg_dma.c然后编辑CMakeLists.txtESP-IDF端口加一行源文件引用即可。编译命令就是标准的idf.py build编译成功后在MicroPython里用import sg_dma验证。如果遇到unresolved symbol一般是ESP-IDF的组件依赖没配好需要在main/CMakeLists.txt里添加REQUIRES gdma这类依赖声明。4. MicroPython侧聚合调度与缓冲区回收4.1 一个可以直接套用的调用模板C扩展写好后MicroPython侧的代码反而简单了。我最终在项目里的实际调用流程长这样import sg_dma from machine import UART, Pin BUF_SIZE 1024 CHANNELS 4 # 源缓冲4路数据各自独立 src_bufs [bytearray(BUF_SIZE) for _ in range(CHANNELS)] # 目标缓冲连续聚合区 dst_buf bytearray(BUF_SIZE * CHANNELS) collector sg_dma.SGCollector() collector.setup(src_bufs, dst_buf) # 主循环不断启动聚合 while True: # 假设外设已经把数据填充到 src_bufs collector.collect() frame collector.result() # frame 就是聚合后的连续 bytes process_frame(frame)这个模板把数据搬运从Python循环中完全移走了。真正搬运数据的是DMAPython只负责启动和接收完成标志。这样CPU才能真正解放出来去做协议解析或控制逻辑。4.2 双缓冲与缓冲池让采集和聚合并行实际项目里不能一直停着等collect()完成否则采集链路会被卡住。我采用的方法是“源缓冲池双目标缓冲”准备两组src_bufs采集线程把数据写入A组时DMA聚合线程搬B组数据两组dst_buf轮换保证DMA写目标缓冲的时候Python层还能访问上一轮的聚合结果。这套设计和“生产者-消费者”模型一样核心是让DMA和外设之间、DMA和CPU之间尽量重叠。Scatter-Gather在其中最大的价值是外设数据源本身的“分组”是天然分散的DMA链路可以直接按组处理不需要再额外拷贝成连续块。4.3 数据聚合的语义设计聚合不只是“拼在一块”还要让接收方能解析。我在目标缓冲前面留了一个16字节的头用来写魔数、通道数、总长度、时间戳C模块在聚合完成后把这些头字段也一并更新。这样MicroPython侧拿到的frame是含头的完整报文frame collector.result() magic, ch_cnt, total_len, timestamp struct.unpack_from(BBHL, frame, 0)因为Scatter-Gather的源块在物理上不连续聚合后各个源块的先后顺序由描述符链的顺序决定。我在setup时故意让src_bufs按通道ID排列这样C层建的链也自然按通道ID排列省去了排序步骤。4.4 保证缓冲区在整个DMA生命周期内存活这块是最容易被忽略的。MicroPython的GC是标记-清除不会对bytearray做“搬移”所以不需要担心DMA搬运过程中地址突然变化。但一定要防止对象被提前回收。我的做法是在模块初始化时把源和目标bytearray都存到self.src_bufs和self.dst_buf属性里让Python侧一直持有引用self._src_bufs src_bufs self._dst_buf dst_buf collector._ref_src_bufs src_bufs # 由C扩展内部再持有一份如果只是临时创建一个bytearray传给C层调用完就把Python侧引用丢了GC可能在你下一次collect()前就回收对象。这个坑我踩过一次表现为“偶发复位”或“DMA搬到错误地址”非常难定位。5. 实测性能、典型坑与替代方案5.1 实测数据DMA链式到底能快多少我用ESP32-S3 240MHz在MicroPython 1.20 自编译固件下做了个简单的聚合测试4路源缓冲每路1KB聚合成4KB目标缓冲。对比三种实现实现方式聚合耗时从启动到拿到结果CPU占用表现Pythonbytes拼接约 4.8 ms高GC频繁Pythonmemoryview struct.pack_into约 2.6 ms中无GC但仍有逐字节开销C扩展 Scatter-Gather DMA约 0.35 ms极低DMA完成前CPU可做别的事不同固件、外设下数字会有差异但方向是明确的DMA链式聚合把“拼接”从CPU职责中彻底删掉了。更重要的是CPU占用Python版本在聚合期间是100%忙等DMA版本只需要在启动后做一个轻量轮询。5.2 几个让我印象深刻的大坑描述符地址对齐不到4字节。一开始我图方便直接传Python层的bytearray地址给DMA结果某些源缓冲的起始地址是奇数地址GDMA直接报错。后来在C模块里做了一层检查不满足4字节对齐就返回EINVAL宁可让上层代码调整偏移也不要硬件跑错。DMA完成事件返回顺序和描述符顺序不一致。在测试多链连续请求时发现第二轮的完成事件偶尔比第一轮先到。查了一晚上发现是缓存一致性cache coherence问题CPU最近写过某个源缓冲但DMA读到的是cache里旧数据。ESP32-S3虽然不像某些MPU那么严重但在高频外设下也要注意必要时在每次collect()前刷新cacheESP-IDF里有专门的esp_cache_msync接口处理。MicroPython中断回调里调用Python函数会导致系统挂起。我最早尝试做个“聚合完成就自动调用Python回调”的丝滑设计结果一跑就死机。后来改成C层置标志Python主循环每几百微秒查一次既安全又简单。不要为了Scatter-Gather而Scatter-Gather。如果数据量只有几十字节启动一次DMA的配置开销可能比手动拷贝还高。我最后定的经验阈值是单次聚合超过512字节或者源块数量大于等于3才值得走DMA链式否则直接用Pythonb.join反而更稳。5.3 不写C扩展时的替代方案如果你不想折腾自编译固件其实也有“低配版Scatter-Gather”能在MicroPython层模拟一部分效果用memoryview连续切片配合spi.write、uart.write时很多驱动底层会把[buf1, buf2]列表内部的连续段用DMA串起来。用array.array或struct.pack_into减少中间临时对象至少在纯Python范围内把性能往前提一档。如果是串口接收不定长数据可以配合空闲中断把不同“帧”放到不同缓冲然后再用select.poll统一处理避免拼包。但这些方案都无法硬件级地形成“链式触发”语义数据量一大还是会遇到CPU瓶颈。真要在MicroPython上做高速数据聚合自写C扩展是绕不开的正路。5.4 这个方案后续还能怎么扩展我目前只在ESP32-S3上验证了GDMA链路。同样的思路完全可以推广到其他带描述符DMA的芯片比如STM32H7的DMA Linked List、NXP i.MX RT系列的eDMA都支持多个描述符构成的Scatter-Gather传输。如果哪天要把MicroPython固件迁移到这些平台只需重写C扩展里的build_lli_chain部分Python侧接口可以完全不动。另外还可以把链式触发和事件接收结合做成“外设事件到达后自动启动下一轮聚合”不过那就要更深入地改ESP-IDF的驱动回调属于进阶玩法了。就我自己而言做完这个模块后的最大体会是MicroPython并不排斥底层优化只是很多优化平时被藏在了固件的抽象层之下。真到了性能瓶颈打破砂锅问到底伸手把DMA、描述符链表这些“硬核”能力拽到Python层其实没有想象中那么难。希望这篇文章能帮你绕过我踩过的坑直接把Scatter-Gather用起来。