
MicroPython也能跑DMA链式聚合答案是可以的。这篇内容来自我最近在ESP32-S3上做的实际项目用MicroPython开发却要在大约百微秒级的时间内把多路分散的采样缓冲一次性聚合到发送缓冲区标准的Python循环拷贝根本撑不住。折腾了几天最终把GDMA的LINKED_LIST模式、Scatter-Gather描述符链和链式触发机制全部打通数据聚合延迟从毫秒级降到了几十微秒级。文章会从原理讲到C扩展实现再到MicroPython层的编排方式和一系列踩坑记录适合在MicroPython上做高速数据采集、串口DMA接收、多传感器聚合的开发者参考。1. 方案动机与整体设计思路1.1 为什么要在MicroPython里折腾Scatter-Gather先还原一下我遇到的场景。一套四路模拟传感器外加一个IMU模块每路采集128字节的采样数据要求每隔固定周期把这五块数据拼成一个完整的数据帧交给后续协议栈发送。用C写这个聚合逻辑非常简单一个memcpy循环就完了。但项目整体是用MicroPython开发的希望保留脚本层的灵活性这时候问题就来了。MicroPython的for循环在ESP32-S3这种240MHz的MCU上单次迭代执行一个字节拷贝大概要1.5到3微秒。五块数据加起来640字节用Python逐字节拷贝就要1到2毫秒。即便是用memoryview做切片拷贝每次切片赋值也有几十微秒的固有开销总耗时也在0.5毫秒左右。如果聚合周期压到1毫秒以内这个耗时占比就非常难受了。Scatter-Gather的思路是把不连续的内存块搬运到连续内存这件事交给DMA控制器来做。DMA控制器读取一段描述符链链上的每个节点描述一个源缓冲区的位置和长度它就会自动把每段数据依次搬运到目标地址。整个过程CPU只需要启动一次传输搬运完成后得到一个中断或者标志位。这样聚合延迟从软件循环的毫秒级降到了硬件搬运的几十微秒级而且完全不烧CPU周期。1.2 方案选型链式DMA vs 传统轮询在ESP32系列芯片上做内存到内存的数据搬运传统思路是使用GPIO模拟时间片轮询或者干脆用CPU直接拷贝。前者对时序的要求苛刻还浪费CPU后者就是前面说到的性能瓶颈。这里选GDMAGeneral DMA控制器的LINKED_LIST模式本质原因有三个。第一ESP32-S3的GDMA原生支持描述符链每个描述符指向一块源缓冲区通过next指针串联成链表天然适合Scatter-Gather这种多段输入聚合到单段输出的场景。第二GDMA的搬运速率相当可观。在我的实测条件下GDMA配置为80MHz时钟时搬运1024字节大约需要12.8微秒算上启动传输和轮询完成标志的软件开销整体也就50到100微秒上下。这比MicroPython层任何软件拷贝方案都快一到两个数量级。第三描述符链可以被重复触发。只要在每次传输完成后重置描述符中的size字段整套链结构就可以反复使用这正好匹配周期性的数据聚合需求。1.3 系统的整体架构方案分成两层。底层是一个自写的MicroPython C扩展模块我命名为scatter_gather负责GDMA通道的分配、描述符链的构建、传输启动和完成状态查询。上层是MicroPython脚本负责把业务数据填到各个源缓冲区然后调用扩展模块启动一次聚合传输最后从目标缓冲区读取完整数据帧。这里有个关键的架构决策描述符链的构建和维护放在C扩展层因为MicroPython层根本没有办法直接操作GDMA寄存器和描述符结构体。MicroPython脚本层只负责通过buffer协议把bytearray对象的指针传给C层C层从mp_buffer_info_t中提取源地址和目标地址填充描述符。这种划分既保证了底层性能又保住了脚本层的开发效率。2. DMA链式触发与Scatter-Gather原理拆解2.1 DMA的工作方式简版DMA全称Direct Memory Access本质是一套独立的硬件搬运引擎。CPU告诉它从哪里搬、搬多少、搬到哪它就自己一趟一趟地搬搬完了再通知CPU。这个过程里CPU可以把注意力放到其他事情上同时也可以把一条大数据的搬运速度做到远高于CPU软件循环的水平。生活化的类比是CPU像一个要处理很多事的人如果每搬一箱货物都要自己跑一趟很快就会累垮DMA则是一个专门搬货的传送带你只要把一堆货物和目的地告诉它它会自己按顺序搬完搬完再响个铃告诉你。2.2 Scatter-Gather描述符链原理Scatter-Gather是这个方案的核心机制。所谓Scatter就是输入数据分散在多个内存块Gather就是把这多个分散的内存块按顺序聚合到一个连续的内存区域。刚才提到的把多路传感器采样数据拼成一帧就是这个机制的典型用例。实现上DMA控制器会读取一块称为描述符descriptor的内存结构。每个描述符包含四个关键字段buf_ptr源缓冲区的起始地址。size本次需要搬运的字节数。next指向下一个描述符的指针。eof标记当前描述符是否是链的最后一个节点。DMA搬运时会从第一个描述符开始按地址将size个字节搬到目标地址然后检查next指针。如果next不为空就跳到下一个描述符继续搬运如果为空或该描述符eof标志置位则整个传输结束。这样就实现了将多个不连续源缓冲依次搬运到目标缓冲区顶部的效果。ESP32-S3的GDMA描述符结构在ESP-IDF里定义为dma_descriptor_t一个大致的样子如下typedef struct dma_desc_s { uint32_t eof : 1; uint32_t reserved : 15; uint32_t sizef : 16; uint32_t size : 16; uint32_t reserved2 : 16; uint32_t buf_ptr : 32; struct dma_desc_s *next; } dma_descriptor_t;这里尤其要注意size字段会在DMA搬运完成后被硬件清零而sizef字段是软件预先写入的原始长度用于后续重置。这是Scatter-Gather描述符链能够重复使用的关键否则第二次触发时DMA看到size是0根本不会搬运任何数据。2.3 MicroPython下如何接触到DMA硬件MicroPython本身的内置模块里没有公开的DMA APImachine模块支持的外设也集中在GPIO、I2C、SPI、UART等常规设备上。想直接在Python脚本里操作GDMA寄存器或者描述符链那是不现实的。可行的路径有两条。第一条是自写C扩展模块。在MicroPython固件的ports/esp32目录下增加一个自定义模块模块内部调用ESP-IDF的GDMA驱动接口。这样模块编译进固件后MicroPython脚本就能直接import scatter_gather通过模块提供的方法间接控制DMA硬件。这是我从一开始就走的方案也是这篇文章的重点。第二条是使用ctypes库直接操作寄存器地址。理论上可以在MicroPython的ctypes中映射GDMA的寄存器地址然后读写描述符内存。但ctypes在MicroPython上的支持有限操作底层寄存器更容易触发内存访问异常而且描述符结构的对齐和位域处理也极其容易出错。我建议仅在验证阶段用这个方法生产方案还是走C扩展更稳妥。3. 核心实现MicroPython侧DMA链式触发方案3.1 硬件环境与开发准备我使用的硬件是ESP32-S3 DevKitC-1外挂了一个四路ADC采集板和一颗IMU传感器IMU走I2CADC走SPI。所有采样数据最终都会汇总到MicroPython脚本层再由脚本调用scatter_gather模块完成聚合。开发环境方面需要准备MicroPython源码和ESP-IDF。MicroPython官方仓库的ESP32移植分支已经支持ESP32-S3编译固件时需要启用自定义模块。具体做法是在ports/esp32/manifest.py里追加一个require或者直接include模块目录然后在ports/esp32/下新建模块源文件编译即可。编译环境的细节就不展开了网络上关于MicroPython自定义模块编译的文章很多只说几个容易卡住的地方。第一ESP-IDF版本需要和MicroPython要求的版本对齐建议直接用MicroPython仓库里esp32/README.md指定的IDF版本分支。第二SDKCONFIG里需要开启GDMA相关选项一般默认开启。第三编译前务必make clean否则修改过的模块源文件可能不会重新编译进去。3.2 创建DMA描述符链C扩展层C扩展模块的核心是构建描述符链并启动GDMA传输。为了演示清晰我的模块暴露了这样几个方法Chain(src_bufs, dst_buf, continuousFalse)构造一个聚合链对象。start()启动一次DMA传输。wait()等待传输完成。deinit()释放DMA通道和描述符内存。模块内部构造时会遍历传入的源缓冲区列表为每个源缓冲区分配一个描述符节点然后通过next指针串成链。目标缓冲区通过gdma_config_transfer配置为固定目标地址。模块实现的一个关键片段如下以ESP-IDF v5.x的GDMA接口为例#include py/runtime.h #include py/obj.h #include py/binary.h #include esp_heap_caps.h #include esp_private/gdma.h typedef struct { mp_obj_base_t base; gdma_channel_handle_t chan; dma_descriptor_t *desc; uint32_t desc_count; uint8_t *dst_ptr; } sg_chain_obj_t; static void sg_build_descriptors(sg_chain_obj_t *self, mp_obj_list_t *src_list) { self-desc_count src_list-len; self-desc heap_caps_calloc(self-desc_count, sizeof(dma_descriptor_t), MALLOC_CAP_DMA); uint8_t *src_ptr NULL; mp_buffer_info_t bufinfo; dma_descriptor_t *prev NULL; for (mp_uint_t i 0; i src_list-len; i) { mp_get_buffer_raise(src_list-items[i], bufinfo, MP_BUFFER_READ); src_ptr bufinfo.buf; dma_descriptor_t *d self-desc[i]; d-eof (i src_list-len - 1) ? 1 : 0; d-size bufinfo.len; d-sizef bufinfo.len; d-buf_ptr (uint32_t)src_ptr; d-next (i src_list-len - 1) ? NULL : self-desc[i 1]; if (prev) { prev-next d; } prev d; } }这里有两个细节必须处理好。第一个是描述符内存必须从MALLOC_CAP_DMA能力的内存池分配。GDMA控制器只能访问特定区域的内存普通堆上分配的内存可能不在DMA可达范围内。分配后最好再检查一下返回指针是否为16字节对齐不是的话要手动对齐。第二个是源缓冲区地址的获取方式。MicroPython的bytearray对象通过mp_get_buffer_raise获取的指针是对象内部的真实数据地址只要对象不被垃圾回收这个地址就是稳定的。MicroPython的GC不是移动式GCbytearray对象在堆上分配后不会随意移动所以我们可以放心把指针交给DMA使用。但需要保证在DMA搬运完成前这些bytearray对象没有被上层重新赋值或释放。3.3 MicroPython层的数据聚合逻辑C扩展模块就绪后MicroPython层的代码就非常干净了。一个完整的周期聚合逻辑大概长这样import scatter_gather as sg from machine import Timer sensor_bufs [ bytearray(128), # 通道1 bytearray(128), # 通道2 bytearray(128), # 通道3 bytearray(128), # 通道4 bytearray(128), # IMU ] frame bytearray(640) chain sg.Chain(src_bufssensor_bufs, dst_bufframe, continuousFalse) def sample_and_aggregate(timer): # 这里假设已经通过SPI/I2C把最新采样数据填充到sensor_bufs chain.start() chain.wait() # frame此时已经包含完整的聚合数据 protocol_send(frame) timer Timer(0) timer.init(period1000, modeTimer.PERIODIC, callbacksample_and_aggregate)每次定时器触发时先由业务代码更新各传感器的源缓冲区然后调用chain.start()启动DMA链式聚合chain.wait()阻塞等待传输完成。由于DMA搬运本身非常快wait()通常十几微秒内就会返回。如果你不想阻塞所在上下文也可以改成轮询完成标志的方式把chain.wait()替换成while not chain.done(): pass或者在等待期间穿插处理其他轻量级调度。不过MicroPython的Global Interpreter Lock决定了一旦你在Python回调里执行循环其他Python线程同样无法运行所以实际收益有限。我在项目中就直接用了阻塞等待原因很简单等待时间已经远小于回调本身的周期预算。3.4 链式触发时序设计链式触发进一步优化的空间在于聚合完成后是否要立刻准备下一次传输如果是固定周期采样我推荐使用continuousTrue模式在这种模式下描述符链在完成一次搬运后不会自动释放而是等待下一次触发信号。这能省去每次重新构建描述符链的开销只保留重置描述符size字段和重新启动传输的操作。连续模式下的一个坑在于我的C扩展在构造时把描述符的size设置为各缓冲区的实际长度DMA搬完以后size字段变成0。如果上层忘记在每次启动前调用chain.reset()重新填充size字段第二次传输就会静默失败。为了让使用者少踩这个坑我在start()方法内自动做了reset检查static void sg_chain_start(sg_chain_obj_t *self) { for (uint32_t i 0; i self-desc_count; i) { self-desc[i].size self-desc[i].sizef; } gdma_start(self-chan, (intptr_t)self-desc); }这样每次触发前都会从上一次记录的长度中恢复size字段保证连续周期触发的可靠性。4. 性能实测与参数调优分析4.1 实测模式我在项目里测了三组数据分别对应三种聚合实现方式的耗时。测试条件为ESP32-S3CPU 240MHzGDMA时钟80MHz聚合数据总量640字节分成五块每块128字节。第一组是纯Python逐字节循环拷贝。开一个for循环每次从源缓冲区索引出一个字节写入目标缓冲区。640字节的搬运耗时大约1.6毫秒。这个结果不出意外MicroPython的解释执行性能就摆在那里逐字节处理是效率最低的路径。第二组是Python层memoryview切片拷贝。用dst_view[offset:offset128] src_view的形式逐块拷贝五块数据总共耗时约0.32毫秒。已经比逐字节好很多但对于周期1毫秒的应用来说仍然太慢。第三组就是本文实现的DMA链式Scatter-Gather聚合。一次start()加wait()的完整流程从Python调用到数据搬运完成总耗时约62微秒。其中DMA硬件搬运时间约12.8微秒剩余是C扩展调用、GDMA寄存器配置和状态轮询的开销。三组数据放一起对照差距非常直观实现方式640字节聚合耗时相对倍数Python逐字节循环约1.6 ms1.0倍基准Python memoryview切片约0.32 ms5倍提升DMA链式Scatter-Gather约62 micro;s约26倍提升这个对比说明在MicroPython场景下即便DMA方案包含了不少软件开销仍然能比Python层最优的内存操作快接近一个数量级。如果进一步做内层优化——比如把描述符链构建提前到模块初始化阶段完成把C扩展的方法调用改为直接底层函数指针调用理论上还能把软件侧开销压到30微秒以内。4.2 关键参数调优经验GDMA性能受几个参数影响实际项目中我做了这些调整。时钟方面GDMA时钟可以从APB总线时钟分频获得最大可以跑到80MHz即APB时钟本身。在初始化GDMA通道时务必确认时钟配置没有分频否则搬运时间会成倍增长。传输突发长度方面ESP32-S3的GDMA支持配置突发传输长度比如每次突发4字节或8字节。对Scatter-Gather这类多段不连续源缓冲突发长度越大描述符切换的等待时间占比越低。我实测下来配置为最大突发长度后640字节搬运时间从16微秒左右降到了12.8微秒左右提升约20%。描述符对齐方面所有描述符的起始地址必须16字节对齐。如果不满足GDMA在读取描述符时会触发总线错误或直接不工作。使用heap_caps_calloc分配DMA内存时ESP-IDF已经保证返回的内存满足DMA能力要求和对齐但如果自己从静态数组中划分描述符就要手动做地址对齐。目标缓冲区也有对齐要求通常需要4字节对齐。bytearray(640)的对象在堆上分配的起始地址不一定满足对齐稳妥的方法是在C扩展内部单独分配目标缓冲区而不是直接使用Python层传入的bytearray。我在项目里采用了C扩展分配目标内存、Python层通过模块属性读取的折中方案既保证对齐又不让Python层丢失访问能力。4.3 与普通方案的对比结论如果项目对数据聚合频率不敏感比如每100毫秒才拼一次包那直接用Python层memoryview就完全够用没有必要引入DMA。但如果聚合周期在1到10毫秒这个区间或者聚合数据量大到几十KB以上DMA方案的优势会非常明显。另外这里有个容易忽略的点DMA搬运虽然快但Python层把数据填入源缓冲区本身也要时间。如果源缓冲区填充逻辑本身就很重聚合环节省下来的时间会被填数据环节吃掉。设计系统时要把采样填充和聚合搬运当成两个独立阶段来规划尽量让填充逻辑用DMA、I2C块读、SPI块读这类底层批量操作完成避免在Python层逐字节填充。5. 常见问题与踩坑实录5.1 串口DMA接收不定长数据的空闲中断和Scatter-Gather经常一起出现的另一个需求是串口DMA接收不定长数据。很多朋友在论坛上问使用接收空闲中断判断接收结束给下参考例程这里集中说下我的经验。DMA接收数据本身只负责把UART RX FIFO里的字节源源不断搬到内存它不知道一帧数据什么时候结束。串口协议帧通常是不定长的所以要借助UART的空闲中断IDLE interrupt当RX线上在接收完最后一个字节后持续高电平超过一个字节时间硬件就触发IDLE事件表示当前这一帧数据接收完毕。在MicroPython的C扩展里给UART配置空闲中断并不复杂核心是先创建UART驱动并注册回调然后在回调里获取DMA接收缓冲区当前的有效数据长度。字段关系上DMA会把数据写入环缓冲区空闲中断时通过uart_get_buffered_data_len获取当前缓冲区内未读出的数据长度再把这一段数据从环缓冲复制到业务缓冲区。DMA本身不会自动判断帧结束所以这个组合是必须的。这里有一个细节DMA接收配置为环缓冲模式时数据到达后DMA会持续搬运不会因为一帧结束就停止。如果空闲中断响应不及时下一帧的数据可能覆盖上一帧尾部。所以中断回调里一定不要去处理数据只是置一个标志位主循环检测到标志后再批量读取。5.2 DMA发送是否要等待上一轮完成这也是一个高频问题DMA串口发送需要等待上一轮数据发送完吗答案是必须等。GDMA的寄存器里只有一个传输状态位每次启动新的DMA传输前硬件会检查当前通道是否仍在忙。如果上一轮传输没有结束就写入了新的启动命令新传输可能直接失败甚至把当前描述符链的状态破坏掉。在C扩展实现里我保留了busy标志start()方法启动前先判断if (gdma_get_channel_status(self-chan) GDMA_CHANNEL_STATUS_BUSY) { mp_raise_msg(mp_type_RuntimeError, DMA channel is busy, wait for last transfer to finish); }如果业务上确实无法避免连续快速触发可以考虑申请两个GDMA通道交替使用或者把连续传输合并成一个更大的描述符链一次启动完成多次发送。后者其实就是Scatter-Gather思想的另一种应用多个发送缓冲聚合到一次DMA操作中既减少了CPU干预也规避了通道繁忙的问题。5.3 描述符对齐与缓存一致性问题描述符对齐问题前面提过这里说缓存一致性这是ESP32系列多核架构下一个更容易被忽略的深坑。ESP32-S3的CPU和GDMA访问内部SRAM时存在Cache一致性问题。当CPU写入描述符数据和源缓冲区数据后这些数据可能还停留在CPU的写缓冲区或者Cache中没有真正刷到物理SRAM。此时GDMA读取时可能读到旧数据。反过来GDMA搬运完成后目标缓冲区数据也可能只停留在内存侧CPU读取时读到的是Cache里的旧内容。解决这个问题的常规手段是调用ESP-IDF的缓存同步接口。ESP32-S3上常用的做法是esp_cache_msync(addr, size, ESP_CACHE_MSYNC_FLAG_DIR_M2C);其中DIR_M2C表示从内存同步到CPU缓存用于DMA搬运完成后CPU读取目标数据前调用。如果源数据和描述符链需要让DMA看到CPU的最新写入则使用DIR_C2M方向同步。在实际项目里我发现并非每次都需要手动同步但一旦出现DMA搬运内容偶尔变成旧数据这类诡异问题第一个怀疑的方向就应该是缓存一致性。别浪费时间在业务逻辑上找问题先检查同步。5.4 GC暂停与DMA的协作MicroPython的垃圾回收会在堆内存紧张时触发GC运行期间整个解释器会暂停Python代码的执行。DMA是独立于CPU的硬件引擎因此GC暂停不会中断正在进行的DMA搬运。真正的风险在于GC可能回收掉仍被DMA引用的缓冲区对象。比如一个局部变量的bytearray作为源缓冲区传入Chain后函数返回如果没有其他地方持有引用这个bytearray就会被GC回收。此时描述符链里的指针变成悬垂指针DMA搬运时轻则读到随机数据重则触发总线错误导致芯片崩溃。解决办法也很简单在C扩展内对传入的每个源缓冲区对象都增加引用计数直到deinit()或下一次更新链时再释放引用。在MicroPython的C API里可以用mp_obj_t保存对象引用。构造Chain时把每个源bytearray对象存到模块对象内每次start()前确保这些对象仍然存活。如果你没有自行维护引用那么至少要做到把源缓冲区作为模块级别的全局变量持有并且不要随意重新赋值。这是最简单的保命手段。写在最后的几点心得这套方案我在实际项目里跑了将近三个月坦白说最磨人的不是GDMA描述符链本身的构建而是MicroPython层和底层C扩展之间那些隐蔽的对象生命周期和缓存一致性问题。描述符链结构一旦搭好它就是一套稳定的搬运工具真正需要小心的永远是你交给DMA的那些buffer谁在什么时间点持有它们谁在什么时间点释放它们心里必须有数。如果后续要扩展我建议优先做两件事一是把源缓冲区支持的类型从bytearray扩展到memoryview切片这样不复制整块数据也能聚合子区间二是在C扩展里增加一个异步回调让DMA完成事件直接唤醒Python协程而不是靠轮询。这两块做完这个聚合方案就基本算是生产级的了。希望这篇内容能帮你少走一些弯路。