MicroPython 项目做久了,你会发现一个很拧巴的事实:MicroPython 本身擅长“粘合业务”,但底层数据搬运却交给 C 写的驱动、外部协议栈和中断服务函水;一旦你想在 Python 脚本里直接编排 DMA,语言层面的无力感会瞬间冒出来。我这次要拆的课题是“基于 MicroPython+DMA 链式触发的 Scatter-Gather 数据聚合实现”,关键词拆开看就是三件事:MicroPython 应用层、DMA 链式触发、Scatter-Gather 聚合。合在一起,它解决的是“多个不连续的缓冲区,如何不发中断、不拷内存,一路被 DMA 自动搬进对应外设或 DMA 接收描述符”的问题。很多人上来就盯寄存器,我建议你先想清楚边界:硬件 DMA 可以做到无 CPU 参与地把 A、B、C 三段不连续数据凑成一次完整报文发出去,但 MicroPython 的运行时并不直接给你暴露“缓冲区的物理地址”这种底层句柄。真正可复现的做法,是让 MicroPython 负责协议拼装、缓冲池分配和事件通知,把 DMA 通道编排、描述符链构建和完成中断收敛成极少数的原生接口。下面把我的工程判断和完整实现思路写出来,这套思路在 RP2040、ESP32-S3、以及支持链式描述符的 STM32 系列上都能落地,区别只在于谁帮你管理描述符。
1. 先把 MicroPython 到底能碰 DMA 的哪一层说清楚
1.1 纯 Python 读寄存器只是“看起来能碰硬件”
我在 RP2040 上试过直接用machine.mem32写 DMA 寄存器,启动一条内存到内存的搬运,确实可以跑。但一旦场景升级为“连续采样、多通道、每次传输长度不等”,纯 Python 方案立刻崩掉,因为 MicroPython 的运行时没有给你稳定拿到任意 Python 对象地址的接口。你用id(buffer)拿到的不是 buffer 数据区的起始地址,堆管理器内部的差异让你根本没法稳定构造 DMA 描述符;即便你用uctypes.addressof之类的技巧拿到一段地址,GC、内存碎片、对象头偏移都可能让这个地址在下一轮分配后变得不可依赖。更麻烦的是,链式触发本身是硬件层的“自动接力”,你在 MicroPython 脚本里用mem32配置链路,只是把每个字段当成一个普通数字写进去,一旦某个段配置写错,你没有足够快的错误反馈,现象往往是整条总线卡死或者外设收到半个包。
“能碰”和“能在工程里可靠地用”是两码事。MicroPython 适合做编排,不适合做需要纳秒级一致性的 DMA 描述符维护。
1.2 我的设计边界:固件层只留“最窄的缝”
所以我的做法是在固件层增加一个很小的本地扩展模块,它只提供三件事:注册缓冲区、构建 Scatter-Gather 链、启动链式触发。底层 DMA 通道的分配、描述符管理、中断收敛全部用 C 完成。MicroPython 脚本不直接见寄存器,它只见“buffer”和“事件”。这样做损失了一点“脚本里能读寄存器”的极客感,但换来的是可维护性和可复现性。
模块接口被刻意收窄之后,MicroPython 侧的数据聚合业务反而变得干净:
- 负责把业务数据切成段(头部、变长负载、尾部校验);
- 负责维护稳定、固定大小的缓冲池;
- 负责监听 DMA 完成事件,决定什么时候再提交下一轮数据。
如果你是在做数据采集聚合,比如多个传感器通道需要被 DMA 采集到同一块逻辑连续区域,也可以用同一个模型:将实际物理内存分成若干不连续区域,但通过 Scatter-Gather 描述符把它们逻辑上串成一个完整采集帧。MicroPython 只操作这个“虚拟聚合帧”的分段列表。
1.3 分层带来的直接收益
数据不用在 Python 层做整包 memcpy,CPU 不会被每个段完成中断打断。DMA 链走到最后一段才触发一次 IRQ,MicroPython 调度器只被唤醒一次,聚合后的数据已经全部落在外设或内存指定位置。接下来 MicroPython 的 asyncio 调度只需要处理“哪个缓冲池块空闲了”这种低频事件。
2. 链式触发真正“链”的是什么:一次搬运的自我接力
2.1 普通 DMA 的触发模型对照
普通 DMA 是“单发”:软件写一次启动寄存器,外设 DMA 请求或定时器触发后,DMA 控制器把一段数据从源地址搬到目的地址,搬完产生中断。整个过程 CPU 可以不参与数据搬运,但之后 CPU 需要处理完成中断、重新配置下一次搬运的数据源和目标。
Scatter-Gather 要解决的问题是“我不想去拼连续缓冲”。举个最常见的例子:一帧协议报文由 12 字节头部、2KB 传感器数据和 4 字节 CRC 共同组成。三块数据分别位于不同 bytearray。传统做法是先把三块数据 memcpy 到一块显式预留的发送缓冲区,再启动一次 DMA 发送。这个 memcpy 在 MicroPython 里尤其昂贵——它不仅是 CPU 时间,还意味着你必须准备一块等于整包大小的“聚合缓冲区”,内存碎片和双缓冲开销都会成倍上升。
2.2 链式触发让多个 DMA 通道自动接力
Scatter-Gather 的硬件基础是“描述符链”,但描述符链要工作,必须解决“谁触发下一段”的问题。一次链式搬运是这样发生的:
- MicroPython 调用原生接口,注册多个段:段 0、段 1、段 2;
- 原生接口把段信息写进 DMA 描述符或通道配置;
- 段 0 的 DMA 搬运结束后,DMA 控制器根据链式触发字段自动启动段 1;
- 段 1 结束后自动启动段 2;
- 段 2 是链尾,只有它产生完成状态和中断。
在 RP2040 上,这种“自接力”由通道的 chain-to 字段实现;在 ESP32-S3 GDMA 上,描述符节点的 next 指针天然支持链式跳转;在支持 linked-list DMA 的 STM32 系列上,则要维护一个内存中的 DMA 描述符数组。无论具体硬件叫什么,本质上 DMA 控制器是在自己做调度,CPU 只负责提交一次首地址。
我把这种模型叫“硬件调度队列”。队列的好处显而易见:链路一旦启动,中间不会出现 Python 层能感知到的缝隙。对某些严格依赖时序的外设(比如 SPI 屏刷新、ADC 连续采样、I2S 音频流),中间没有 CPU 参与反而更容易满足时序要求。
2.3 为什么要强调“数据聚合”,而不是单纯“多段发送”
Scatter-Gather 更常被低估的价值是用来做采集端的聚合。假设你要持续采集 4 路模拟信号,每路采集结果要放进不同长度的缓冲区;如果 DMA 是按缓冲区分段触发的,那么每一次触发之间的“间隙”可能造成采集丢点。用链式 Scatter-Gather,你可以把多个不连续缓冲区编排成一条逻辑上连续的数据链路,只要中断管理得当,整个采集过程看起来就像一次很长的 DMA 传输。这是“聚合”在标题里的实际含义:它不只是发送报文时的拼接优化,更是接收侧的数据连续性方案。
3. 从零实现一个最小可复现的 SG 引擎:以 RP2040 为例
3.1 为什么先用 RP2040 验证
我最早是在 RP2040 上把整个链路跑通的,原因有三个:寄存器模型简单、MicroPython 移植版本活跃、pico-sdk 里 DMA API 层级清晰。更重要的是,RP2040 的 DMA 通道数量足够多,拿来实验链式触发时不用像某些 MCU 一样抢通道。
我不建议新手一上来就碰 STM32H7 的 linked-list DMA,虽然那套 LLM 描述符很强,但缓存一致性、描述符放在哪个内存区域、对齐要求等问题会把第一个 Demo 拖得很难受。RP2040 的 DMA 直连 SRAM,没有 cache 一致性问题,最适合先把 Scatter-Gather 的编排逻辑验证清楚。
3.2 原生扩展模块的最小接口
实际实现时,我加了一个原生模块dma_sg,接口收敛成下面几个方法: