玩 MicroPython 的朋友应该都有过这种体验:业务逻辑写得飞快,一到性能敏感的地方就得开始抠底层。尤其当你同时接了好几路传感器或者采集多通道数据时,每路数据各自攒在各自的缓冲区里,最后要把它们拼成一份完整的数据去做处理或上传,最朴素的做法就是在 Python 层一段一段地拷过去。数据量几百字节还好,一旦上了几 KB、几十 KB,或者采集间隙还有别的事要处理,CPU 就开始吃紧,中断响应也跟着变差。
这时候就该轮到 DMA 出场了。DMA 链式触发配合 Scatter-Gather(分散/聚集)机制,可以把“把 N 个分散缓冲区按顺序拼到一块连续内存”这件事整个交给硬件,CPU 只需要在最后听个结果。题目里说的“Scatter-Gather 数据聚合实现”,本质上是把多块不连续的数据源,通过一串 DMA 描述符自动搬移到连续目标区。这篇文章我会用 RP2040(Raspberry Pi Pico 那颗芯片)加 MicroPython,直接在 Python 层通过 machine.mem32 操作 DMA 寄存器来实现一套可以抄的链式聚合方案。整个过程不需要改固件、不需要写 C 扩展,只要一个能跑 MicroPython 的 Pico 板子就能复现。
适合谁看?一是跑 MicroPython 但被性能瓶颈卡住的朋友,二是想搞明白 RP2040 DMA 链式触发到底怎么用、Scatter-Gather 到底是怎么运作的嵌入式爱好者。我会把寄存器细节、完整代码、实测数据和踩过的坑都摊开讲,尽量让你看完就能自己上手改。
1. MicroPython 做数据聚合,痛点到底在哪
1.1 一个几乎每个采集项目都会遇到的拼数据问题
先说一个特别常见的场景:你在用 Pico 同时采集三路传感器的数据,每路数据存到各自的 bytearray 里,最终要按顺序合成一个完整的帧发出去。代码写起来非常简单:
# 假设 buf1、buf2、buf3 是三个已经填好数据的缓冲区 out = bytearray(len(buf1) + len(buf2) + len(buf3)) out[0:len(buf1)] = buf1 offset = len(buf1) out[offset:offset + len(buf2)] = buf2 offset += len(buf2) out[offset:offset + len(buf3)] = buf3这段代码在 MicroPython 里其实已经算“不错”的写法了,因为切片赋值底层走的是内存拷贝,不是逐字节循环。但问题的关键不在于拷贝本身快不快,而在于这个操作是阻塞的。CPU 必须老老实实等这段搬运完成才能去处理别的事情,比如响应下一个外部中断、刷新显示、处理网络协议栈。
当数据量变大,或者采集频率变高,这种搬运就会频繁打断主流程。我见过不少项目最后被迫把 MicroPython 换成 C 语言,原因不是 Python 算不过来,而是“搬运”这件事消耗了太多 CPU 时间切片。实际上,这部分工作完全可以让 DMA 去干。
1.2 Scatter-Gather 的本质:把“搬运方案”交给硬件
Scatter-Gather 这个词听起来很高端,其实理解起来并不复杂。它解决的是“源数据不连续,目标数据连续”的搬运问题。你有一堆散落在不同地址的数据块,想按顺序填到一个连续的内存区里,普通的 memcpy 只能处理“源连续、目标连续”的场景,遇到“源不连续”就只能一段一段地拷。
Scatter-Gather 的做法是:为每一段搬运任务建立一个描述符,描述符里面记录“从哪搬到哪、搬多少、搬完下一步干什么”。这些描述符本身又是一个链表,DMA 控制器读完一个描述符、执行完一次搬运,自动从链上取下一条接着执行。整个过程 CPU 只需要在最开始把链表的头地址交给 DMA,之后就可以去忙别的。
在 RP2040 上,DMA 通道的 CHAIN_TO 字段就是用来实现这种“自动接力”的。每个通道传输完成后,硬件会根据 CHAIN_TO 的值自动触发下一个通道。这不就是 SG 描述符链么?把每段数据源交给一个 DMA 通道,源地址不同,目标地址按偏移递增,所有通道首尾相接,一条链跑完,数据就聚合完了。
1.3 为什么是 RP2040 而不是 ESP32
选 RP2040 而不是 ESP32,不是因为 ESP32 不行,而是因为 RP2040 的 DMA 控制器设计得对“直接操作寄存器”非常友好。
- RP2040 的 DMA 控制器有 12 个通道,每个通道的寄存器布局非常规整,基址加上固定偏移就能访问,用 machine.mem32 可以轻松读写。
- ESP32 系列虽然也有 DMA(特别是 ESP32-S3 的 GDMA),但 MicroPython 固件默认没有把 GDMA 的寄存器访问暴露出来,而且 GDMA 的链式描述符需要放到特定的 DMA 描述符内存区,配置流程更绕,直接改固件的成本比较高。
- RP2040 的 DMA 寄存器就是普通的内存映射,MicroPython 的 machine.mem32 可以直接访问,不需要修改固件或写 C 模块,对纯 MicroPython 用户来说这是最现实的一条路。
另外 RP2040 的 DMA 支持链式触发、支持固定优先级/轮询模式、支持字节/半字/字三种数据宽度,做 Scatter-Gather 需要的能力它都有。所以选它作为实验平台,算是最省事的选择。
2. 吃透 RP2040 DMA 的关键寄存器再动手
2.1 machine.mem32 直通硬件总线
在 MicroPython 里直接操作寄存器,靠的是 machine.mem32 这个模块。它把 32 位内存地址映射到 Python 的整数读写,写起来非常直接:
from machine import mem32 # 读取 DMA 通道 0 的 CTRL_TRIG 寄存器 val = mem32[0x50000000 + 0x00 + 0x0C]RP2040 的 DMA 控制器基址是 0x50000000,一共有 12 个通道,每个通道占用 0x40 字节的地址空间。所以任意通道的任意寄存器地址可以这样算:
寄存器地址 = DMA_BASE(0x50000000) + 通道号 * 0x40 + 寄存器偏移下面这张表是每个通道里最常用的几个寄存器,后续代码全部围绕它们展开:
| 寄存器 | 偏移 | 作用 |
|---|---|---|
| READ_ADDR | 0x00 | 源地址 |
| WRITE_ADDR | 0x04 | 目标地址 |
| TRANS_COUNT | 0x08 | 传输元素个数(不是字节数) |
| CTRL_TRIG | 0x0C | 控制寄存器,写入会触发启动 |
| AL1_CTRL | 0x10 | 备用控制寄存器,用于不触发启动的预配置 |
注意 TRANS_COUNT 计的是“元素个数”,元素大小由 CTRL_TRIG 里的 DATA_SIZE 字段决定。后面写代码的时候很容易在这里搞混,把字节数直接填进去,导致搬运长度变成原来的 1/2 或 1/4。
2.2 CTRL_TRIG 是整台 DMA 的中枢
CTRL_TRIG 这个寄存器太重要了,它既负责配置,也负责触发。这个寄存器的各个位段含义如下:
| 位段 | 位宽 | 含义 |
|---|---|---|
| CHAIN_TO | [1:0] | 当前通道传输完成后,自动触发哪个通道(0~3) |
| RING_SEL / RING_SIZE | [2] / [5:3] | 环形缓冲区模式,本文用不到 |
| INCR_WRITE | [6] | 目标地址是否自增 |
| INCR_READ | [7] | 源地址是否自增 |
| DATA_SIZE | [9:8] | 每元素大小:0=字节,1=半字,2=字(4字节) |
| HIGH_PRIORITY | [10] | 高优先级仲裁 |
| EN | [13:11] | 使能位,写入非零值会触发通道启动 |
| BSWAP | [15] | 字节交换 |
| TREQ_SEL | [20:16] | 触发源选择,0x3F 表示轮询/永久触发 |
| BUSY | [24] | 只读,1 表示传输中 |
很多人第一次看到 CTRL_TRIG 会有个疑问:它既是配置寄存器又是触发寄存器,那条理在哪?实际上写入 CTRL_TRIG 时,如果 EN 位写了非零值,通道就会被启动;如果 EN 位写 0,则只是配置,不会启动。利用这个特性,我们可以先把所有链式通道配置好(EN 写 0),最后只对第一个通道补一次“启动写入”,就完成了整个链的触发。
2.3 数据宽度、地址自增和 DREQ 触发源
有三个坑容易在配置时踩到,我提前说。
第一个坑是 DATA_SIZE。它决定 TRANS_COUNT 里填的数值单位。如果你要搬运 4096 字节,而 DATA_SIZE 配成了字(4 字节),TRANS_COUNT 应该填 1024,不是 4096。如果填 4096,DMA 会搬运 16384 字节,直接越界。
第二个坑是地址自增。INCR_READ 和 INCR_WRITE 分别控制源和目标地址是否自动递增。做 Scatter-Gather 时,源地址是否自增取决于每个通道是不是固定读同一块数据,目标地址一般都要自增,因为我们要把数据连续排到目标区。
第三个坑是 DREQ 触发源。内存到内存的搬运不需要等外设信号,TREQ_SEL 填 0x3F(轮询模式)就行。但如果你想让 DMA 等外设数据,比如等待 SPI 接收 FIFO 非空、等待 ADC 转换完成,TREQ_SEL 就要填对应的 DREQ 编号。这些编号官方固定在数据手册的 Table 66 里,不同外设对应不同的值,背错一个整个 DMA 就“卡住不动”或者“疯狂乱跑”。后面第 5 章我会讲怎么用内存模式去验证 DREQ 配置是不是对的。
3. 给 MicroPython 加一套链式 Scatter-Gather 聚合器
3.1 怎么拿到缓冲区的真实内存地址
想让 DMA 搬运 MicroPython 里的 bytearray,必须先拿到这个 bytearray 的数据区地址。方法是用 uctypes.addressof:
import uctypes buf = bytearray(256) addr = uctypes.addressof(buf) print(hex(addr)) # 类似 0x2001f000在 RP2040 上,MicroPython 的堆分配在内置 SRAM 里(地址范围大概在 0x20000000 到 0x20042000),这个地址不需要任何转换,DMA 可以直接访问。
这里有个非常重要的前提:DMA 是异步的,传输期间代码可以继续往下跑。如果在 DMA 还没搬完的时候,源 bytearray 被 GC 回收了,DMA 就会访问一块已经被释放的内存,数据完全不可控。MicroPython 的堆里的对象不会像 CPython 那样移动地址,但对象会被回收。所以正确做法是:在传输完成之前,保证源缓冲和目标缓冲一直有引用存在,最简单的方式是把它们放到全局变量里,或者放在不会被函数退出时销毁的列表里。
3.2 核心配置函数:DMA 通道操作封装
下面这一段代码是整套方案的地基。我封装了四个函数:dma_configure 用来配置一个通道且不启动,dma_start 用来触发一个通道,dma_busy 检查通道是否还在忙,dma_wait 等待通道完成。
from machine import mem32 DMA_BASE = 0x50000000 CH_STRIDE = 0x40 CH_READ_ADDR_OFF = 0x00 CH_WRITE_ADDR_OFF = 0x04 CH_TRANS_COUNT_OFF = 0x08 CH_CTRL_TRIG_OFF = 0x0C CTRL_CHAIN_TO_POS = 0 CTRL_INCR_WRITE = 1 << 6 CTRL_INCR_READ = 1 << 7 CTRL_DATA_SIZE_BYTE = 0 CTRL_DATA_SIZE_HALF = 1 << 8 CTRL_DATA_SIZE_WORD = 2 << 8 CTRL_EN_MASK = 0x7 << 11 CTRL_TREQ_SEL_POS = 16 TREQ_PERMANENT = 0x3f def _base(ch): return DMA_BASE + ch * CH_STRIDE def dma_configure(ch, src_addr, dst_addr, count, data_width=2, chain_to=None, treq=TREQ_PERMANENT, incr_read=True, incr_write=True): base = _base(ch) mem32[base + CH_READ_ADDR_OFF] = src_addr mem32[base + CH_WRITE_ADDR_OFF] = dst_addr mem32[base + CH_TRANS_COUNT_OFF] = count ctrl = 0 if chain_to is not None: ctrl |= (chain_to & 0x3) << CTRL_CHAIN_TO_POS if incr_read: ctrl |= CTRL_INCR_READ if incr_write: ctrl |= CTRL_INCR_WRITE ctrl |= data_width << 8 ctrl |= (treq & 0x1F) << CTRL_TREQ_SEL_POS # EN 位保持 0,这样写 CTRL_TRIG 不会触发启动 mem32[base + CH_CTRL_TRIG_OFF] = ctrl def dma_start(ch): base = _base(ch) mem32[base + CH_CTRL_TRIG_OFF] = mem32[base + CH_CTRL_TRIG_OFF] | CTRL_EN_MASK def dma_busy(ch): return (mem32[_base(ch) + CH_CTRL_TRIG_OFF] >> 24) & 0x1 def dma_wait(ch): while dma_busy(ch): pass几个细节值得说一下:
- dma_configure 里没有把 EN 位置 1,所以写 CTRL_TRIG 只会把配置写进去,不会启动。这是“先配置整条链,最后只触发第一个通道”的关键。
- dma_start 用的是“读改写”,先把当前 CTRL_TRIG 值读出来,再 OR 上 EN_MASK 写回。EN_MASK 是 0x7 << 11,也就是 EN 三位全写 1,这是 RP2040 用户手册推荐的触发写法。
- CTRL_TRIG 的 bit 24 是 BUSY,dma_busy 直接读这一位。注意读回来的是整个 32 位寄存器,所以我们用右移 24 再与 1 来取值。
3.3 把三个分散缓冲聚合到一块连续内存
下面这段是完整的 SG 聚合示例。我用三个 0x2000 字节的源缓冲区,聚合到一个 0x6000 字节的目标缓冲区。为了确保字节数能被数据宽度整除,我这里统一用 4 字节宽度(word),这也是 RP2040 DMA 最快的数据宽度。
import uctypes import time def buf_addr(buf): return uctypes.addressof(buf) def sg_aggregate(src_bufs, dst_buf, width=2): data_size = width elem_bytes = 1 << data_size for b in src_bufs: if len(b) % elem_bytes != 0: raise ValueError("buffer len must align to elem_bytes") n = len(src_bufs) if n > 4: raise ValueError("CHAIN_TO only supports ch0~ch3 on RP2040") channels = list(range(n)) # 使用 0,1,2,... dst_off = 0 for i in range(n): ch = channels[i] src = src_bufs[i] count = len(src) // elem_bytes chain = channels[i + 1] if i + 1 < n else None dma_configure( ch, buf_addr(src), buf_addr(dst_buf) + dst_off, count, data_width=data_size, chain_to=chain, treq=TREQ_PERMANENT, ) dst_off += len(src) dma_start(channels[0]) dma_wait(channels[-1])调用方式很直白:
b1 = bytearray(0x2000) b2 = bytearray(0x2000) b3 = bytearray(0x2000) # 给源数据随便填一点东西 for i in range(0x2000): b1[i] = i & 0xff b2[i] = (i + 1) & 0xff b3[i] = (i + 2) & 0xff dst = bytearray(0x6000) sg_aggregate([b1, b2, b3], dst, width=2) # 验证:读回 dst 对应位置的值 print(dst[0], dst[0x2000], dst[0x4000]) # 应该分别对应 b1[0], b2[0], b3[0]这段代码跑完后,dst 里就是 b1 + b2 + b3 首尾相连的完整数据,CPU 在这整个过程中没有参与逐字节搬运。
3.4 非阻塞用法才是 DMA 的精髓
上面的 sg_aggregate 最后调用了 dma_wait,这实际上是“同步等待”用法。如果你不需要在搬运期间做任何事,这样写没问题。但 DMA 真正的价值是“非阻塞”:启动传输链之后,CPU 立刻回来继续跑 Python 代码,等需要数据的时候再去查状态。
改造非常容易,把最后的 dma_wait 换成返回最后一个通道号:
def sg_aggregate_async(src_bufs, dst_buf, width=2): # 配置部分和 sg_aggregate 一样 # ... dma_start(channels[0]) return channels[-1] # 使用 last_ch = sg_aggregate_async([b1, b2, b3], dst) # 这里可以做其他事,比如处理另一个传感器的数据 time.sleep_ms(1) # 等全部搬完了再继续 dma_wait(last_ch)注意:非阻塞模式下,src_bufs 和 dst_buf 的生命周期必须由调用方保证。如果它们在函数内部被临时创建,函数返回后引用消失,GC 可能把它们回收掉,DMA 就会访问到非法内存。这是异步 DMA 最容易翻车的点。
4. 实测:DMA 链式聚合的时间和收益边界
4.1 三种搬运方式对比测试
我在 Pico 上做了这样一个对比实验:准备 4 个 32KB 的源缓冲区,总数据量 128KB,目标缓冲区 128KB,分别用三种方式完成聚合:
- 纯 Python 逐字节循环拷贝;
- MicroPython 切片赋值拷贝(底层是 memcpy 一类的优化拷贝);
- 本文的链式 DMA 聚合。
计时用的是 time.ticks_us() 和 time.ticks_diff(),不要直接做减法,因为 ticks_us 存在回绕问题。
import time def test_loop(src_list, dst): off = 0 for s in src_list: for i in range(len(s)): dst[off + i] = s[i] off += len(s) def test_slice(src_list, dst): off = 0 for s in src_list: dst[off:off + len(s)] = s off += len(s)每组测试跑 10 次取平均值,结果大致如下(具体数字和固件版本、超频情况有关,但量级可以参考):
| 方式 | 耗时 | CPU 是否参与搬运 |
|---|---|---|
| Python 逐字节循环 | 约 60~80 ms | 全程阻塞 |
| 切片赋值拷贝 | 约 1 ms 以内 | 全程阻塞,但很快 |
| 链式 DMA(同步等待) | 约 0.5~1 ms | 等待时间内 CPU 实际上是空转轮询 BUSY |
| 链式 DMA(异步) | 启动后立即返回 | CPU 几乎不参与搬运 |
4.2 结果解读:DMA 不是魔法
看到这个表格,很多人会惊讶:切片赋值竟然也不慢,DMA 似乎没有压倒性优势。这个结论其实非常重要,也是我想强调的一点。
MicroPython 的切片赋值底层是 C 实现的 memcpy,它在单个 CPU 核心上搬内存的速度相当可观。DMA 走的是系统总线,速度受限于总线带宽,不一定比 CPU 的优化内存拷贝快。所以“用 DMA 加速内存拷贝”本身是个伪命题。纯 Python 循环慢是因为 Python 解释器每条字节操作都要经过好几层对象操作,切片和 DMA 快是因为它们都绕过了 Python 解释器的逐字节开销。
DMA 的真正价值不在“更快”,而在“不需要 CPU 看着它搬”。异步启动 DMA 后,CPU 可以立刻去处理中断、解析协议、刷新显示,等 DMA 完成后再回来取结果。这个特性在 C 语言里都很珍贵,在 MicroPython 里尤其珍贵,因为 MicroPython 的代码执行效率本来就不高,搬运期间省下的 CPU 时间可以用来跑真正的业务逻辑。
4.3 什么情况才值得用 DMA 链式聚合
根据我的实际体验,下面这些场景用 DMA 链式聚合是划算的:
- 数据量达到几 KB 以上,并且搬运是高频发生的;
- 搬运期间主循环还有其他任务要处理,不能阻塞等待;
- 数据来源是 UART、SPI、ADC 这类外设,外设本身有 DREQ 信号可以触发 DMA;
- 需要将多个分散缓冲区合并成连续帧发送,比如把协议头、多段传感器数据、校验字节拼成一个完整的发送缓冲。
反过来,如果只是偶尔搬几百字节,或者搬完数据后本来就没别的事可做,那直接用切片赋值就行,没必要给自己增加寄存器操作的复杂度。技术选型不是越底层越好,而是看收益是否值得维护成本。
5. 手写寄存器踩过的坑,按排查顺序列给你
5.1 CHAIN_TO 只能链到通道 0~3,不是任意通道
这是 RP2040 DMA 设计里最容易被忽视的一个限制。CTRL_TRIG 的 CHAIN_TO 字段只有两位宽,取值范围是 0~3,也就是说当前通道传输完成后,只能自动触发通道 0、1、2、3 中的一个,没法直接触发通道 7。
我第一次写 SG 聚合时,用了通道 0、5、6、7 来做四段搬运,配置完后怎么触发都不对,最后查手册才发现问题是 CHAIN_TO 装不下 5、6、7 这些通道号。解决办法有两个:
- 把链式通道全部映射到 0~3,就像前面代码里 channels = list(range(n)) 这样。这也意味着 RP2040 上单条链最多只能串 4 个通道。
- 如果确实需要超过 4 段,可以把链拆成多段,前一段链的最后触发一个“中断”,在中断回调里手动启动下一段,或者利用通道的 AL 寄存器做更高级的 DMA reload,但复杂度会明显上升。
对大多数数据聚合场景,4 段已经够用。真遇到超过 4 路聚合,我会优先考虑在源头就把数据拼好,而不是硬堆链式通道数量。
5.2 地址拿错了,DMA 搬了个寂寞
用 uctypes.addressof 拿地址时,拿到的确实是 bytearray 的数据区地址,但有几个变体容易搞混:
- uctypes.addressof(bytearray) 返回的是数据区地址;
- id(obj) 返回的是 MicroPython 对象头的地址,不能直接给 DMA 用;
- 如果 bytearray 是空的,addressof 拿到的是一个有效的但长度为 0 的地址,搬运长度为 0,DMA 可能立即完成也可能什么都不干,结果不可预期。
另外,源地址和目标地址都必须按数据宽度对齐。用 4 字节宽度时,如果源地址或目标地址不是 4 字节对齐的,DMA 会触发总线错误或者行为异常。MicroPython 分配的 bytearray 通常对齐到 4 字节以上,但如果你拿的是切片视图或者某些对象内部缓冲,就必须自己确认。我的习惯是:只要用 word 宽度,就一定先打印 hex(addr),确认末尾两位是 0。
5.3 轮询 BUSY 完成判断的隐蔽误判
dma_busy 函数读的是 CTRL_TRIG 的 bit 24。这个位在通道传输期间为 1,传输完成后为 0。但轮询时有一个隐蔽陷阱:如果通道被配置成等待某个 DREQ 信号,而这个 DREQ 一直没来,BUSY 会一直为 1;反过来,如果通道配置错误(比如 TRANS_COUNT 为 0),BUSY 会在极短时间内归零,看起来好像“完成了”,实际上什么都没搬。
我在调试时通常会多做一步验证:传输完成后直接检查目标缓冲区的内容。比如在 SG 聚合的 dst 里随机挑几个位置,看值是不是和源缓冲区对应位置一致。一旦发现 DMA“秒完成”但数据没过去,优先检查 TRANS_COUNT、TREQ_SEL 和地址对齐这三项。BUSY 只是“搬运中”的指示器,不是“搬运正确”的证明。
5.4 通道还在忙就重新配置,行为会很怪
DMA 通道正在传输时,重新写 READ_ADDR、WRITE_ADDR、TRANS_COUNT 这些寄存器,不同固件下的表现可能完全不一样:有的写入被忽略,有的会打断当前传输,有的会让通道陷入一种“半启动”的诡异状态。
我遇到过最头疼的例子是:在链式传输还没跑完时,下一轮采集代码就开始了,重新配置了通道 0 的地址,结果第一轮的数据被破坏了,第二轮的数据也不对。排查半天才发现是配置时机不对。
安全做法非常朴素:在重新配置任何通道之前,先 dma_wait 等它确认完成。如果一定要在中途停止传输,RP2040 没有一键 ABORT 寄存器,最稳妥的办法是等当前传输自然结束,或者在设计阶段就确保每轮传输的时间足够短,让主循环在两次采集之间有机会完成等待。
5.5 DREQ 编号别靠记忆,用内存模式先验证
第 2 章提到过 TREQ_SEL 决定 DMA 等什么信号。外设 DREQ 编号是个固定的查表项,但很多人(包括我)都试图靠记忆背下来,结果配置完通道永远等不到触发,BUSY 一直拉高。
后来我的调试方法是:先把通道配成 TREQ_PERMANENT(0x3F)也就是轮询模式,跑通整个链式逻辑,确认地址、计数、链式关系都没问题;然后把第一个通道改成外设 DREQ 模式,逐个观察行为。如果改成外设 DREQ 后 BUSY 拉高不动,基本可以断定 DREQ 编号用错了,回到数据手册 Table 66 核对。这个方法比对着手册反复读寄存器快得多。
6. 扩展思路:从内存聚合到外设采集聚合
6.1 ADC 多路采样聚合
如果你在 MicroPython 里用了 ADC 多通道采集,又想省去 CPU 逐次读取 ADC FIFO 的时间,可以把 DMA 链式聚合的思路延伸过去。
RP2040 的 ADC 有一个 DREQ 信号,对应编号在官方数据手册的 DMA 触发表里(我这边确认过的编号是 36)。配置思路是:把 DMA 通道的源地址指向 ADC FIFO 寄存器(0x4004c000 附近的 ADC_FIFO),目标地址指向某个缓冲,TREQ_SEL 填 ADC 的 DREQ 编号,这样每次 ADC 完成一次转换,DMA 就自动把 FIFO 里的数据搬走。
多通道场景下,每个通道的采样结果会按顺序进入 ADC FIFO,你可以利用链式 DMA 把不同阶段的 FIFO 数据分别搬去不同缓冲区,或者把多次采样的结果聚合到一个大缓冲里做后续处理。需要注意 ADC 的结果寄存器只有 12 位有效数据,数据宽度建议配置成半字(16 位),否则高字节会有无意义的填充数据。
6.2 UART/SPI 接收缓冲拼接
UART 接收是另一个非常适合 Scatter-Gather 的战场。比如一个通信协议里,帧头、负载、校验值可能来自不同的接收阶段,但你想让它们最终拼成一个完整的数据帧。把 DMA 通道的目标地址分别指向帧缓冲的不同偏移,源地址都指向 UART 的 RX FIFO,通过链式触发让整帧数据连续落地,每一段都不需要 CPU 介入。
SPI 接收也类似,特别是从多个片选不同的从设备读取数据时,可以把每个设备的数据块通过一个链节点搬到目标区的不同位置,整条链一次触发,最后数据已在连续缓冲区里排好。
在 MicroPython 里用这些外设时,要注意外设本身是否已经被机器模块占用。比如 machine.UART 已经接管了 UART 的收发逻辑,你再手动操作 DMA 就可能跟驱动冲突。比较干净的做法是:用 machine 模块初始化外设和引脚,但不要用它的 read/write 方法,而是把 DMA 接到外设的硬件 FIFO 上,绕过驱动层。这块需要摸清固件对硬件资源的占用情况,不同 MicroPython 版本差异不小。
6.3 双缓冲乒乓的设想
最后分享一个我一直在用的组合思路:链式 DMA 聚合 + 双缓冲乒乓。
用两块目标缓冲 A 和 B。CPU 处理 A 里已经聚合好的数据时,DMA 链正在往 B 里搬下一批。等 DMA 链完成,交换角色,A 重新变成接收缓冲。这样采集和处理可以完全重叠,配合 RP2040 的 DMA 中断或者 MicroPython 的 Timer 轮询,可以在不牺牲 MicroPython 开发效率的前提下把采集吞吐推到很高。
双缓冲和 SG 链式可以叠加,因为 SG 链的最后一个通道完成后,BUSY 位会归零,这时候只需要检查一次 BUSY,就知道整批数据已经就绪。我在实际项目里一般用一个短小的 Timer 中断定期检查 BUSY 状态,发现就绪就切换缓冲指针并启动下一轮链式传输,效果比一直死等好很多。
说到底,Scatter-Gather 在 MicroPython 里并不神秘,它只是把“多段拼接”这个重复劳动从 CPU 卸载给了 DMA 硬件。RP2040 的寄存器布局让这一切用纯 Python 也能操作,只要你愿意花半小时对着数据手册核对寄存器位段,完全可以在不碰 C 代码的情况下,把 Pico 的数据采集能力再往前压一截。希望这篇记录能帮你少走一点我走过的弯路。