先放结论:在 RP2040(树莓派 Pico)上用 MicroPython 搞内存到内存的 DMA,听起来像跨服操作,实际上完全可行,而且效果远超预期。我最早也以为 MicroPython 这种解释型环境不适合碰 DMA,毕竟平时连for循环慢都要骂两句,但后来发现 RP2040 的 MicroPython 移植版已经把 DMA 控制器的访问方式留出来了:一类是官方rp2.DMA封装,另一类是直接用machine.mem32去砸寄存器。两条路我都试过,都通。
这篇文章就是一份保姆级教程,核心解决三件事:第一,讲清楚 RP2040 DMA 在 MicroPython 里到底能不能用、怎么用;第二,给出内存到内存数据传输的完整可跑代码,从单次触发到循环模式都覆盖;第三,结合大家经常搜的“串口 DMA 不定长接收”“DMA 发送要不要等上一轮结束”这类实际问题,把坑提前给你踩平。适合已经会用 Thonny 跑 MicroPython、想进一步压榨 Pico 性能的玩家,也适合准备做数据采集、OLED 刷新、串口缓冲这类需要快速搬数据场景的朋友。
1. 项目概述与核心原理
1.1 为什么要在 MicroPython 里折腾 DMA?
先说说痛点。用 MicroPython 写数据处理,最常用的操作就是把一个bytearray拷贝到另一个bytearray。要是数据量小,几百字节,CPU 直接干也无所谓;可一旦数据量到几十 KB,比如采集一批传感器数据、生成一帧图像、或者要反复刷新一块大屏,纯 Python 循环拷贝就原形毕露了。每次循环都要经过解释器、做类型检查、执行字节码,1 KB 数据拷下来都要肉眼可见的卡顿。
有人会说,那用memoryview切片或者bytearray的切片拷贝是不是快一点?确实快很多,因为底层走的是 C 实现的memmove,单次拷贝一两 KB 也就几十微秒。但这里有个关键区别:哪怕 C 帮你搬数据,CPU 的核还是被占着的。如果你的程序在搬数据的同时还要做按键扫描、LED 刷新、通信解析,CPU 一旦忙着拷贝大块内存,别的事就得排队。
DMA(Direct Memory Access,直接内存访问)解决的就是这个“搬数据占 CPU”的问题。它让硬件自己把数据从源地址搬到目的地址,搬完再通过中断或者标志位告诉你“搞定了”。RP2040 内部有好几路 DMA 通道,可以完成内存到内存、外设到内存、内存到外设、甚至外设到外设的传输。对 MicroPython 开发者来说,最值得先掌握的就是内存到内存,因为这是理解一切 DMA 应用的基础,也是优化性能性价比最高的一招。
1.2 RP2040 的 DMA 硬件长什么样
在写代码之前,得先把 RP2040 DMA 控制器的几个核心概念弄清楚,不然后面代码里一个个寄存器字段看着就像天书。
RP2040 一共有 12 路 DMA 通道,每个通道是一组独立的寄存器,可以同时并行工作。每个通道的关键要素就是四个:
- 读地址:数据从哪个内存地址来。
- 写地址:数据写到哪个内存地址去。
- 传输计数:这一轮要搬多少个数据单元。
- 控制与触发:数据宽度、地址是否自增、传输方向、触发源、是否循环模式等等。
传输的数据宽度可以是 8 位、16 位、32 位,一般做内存拷贝用 8 位最省心,因为不用管对齐。地址的自增模式也很重要:内存到内存的拷贝,通常读地址和写地址都自增,这样每次搬完一个单元,地址自动指向下一个;而外设到内存的时候,往往是外设寄存器地址固定不自增、内存地址自增。
触发机制是 DMA 的灵魂。内存到内存的传输,可以直接软件触发,也就是说你把CTRL_TRIG寄存器的EN位置 1,DMA 立刻开始搬,搬完BUSY位会自动清零。还有一种常用模式是循环触发,配合定时器 DREQ,让 DMA 每隔一段时间自动执行一轮搬运,这对于采样、缓冲刷新这类周期性任务非常有用。
1.3 MicroPython 里访问 DMA 的两条技术路线
在 RP2040 的 MicroPython 固件里,访问 DMA 控制器主要有两条路。第一条是用官方封装好的rp2.DMA类,代码简洁,适合一般场景。第二条是用machine.mem32直接读写寄存器地址,虽然代码看着原始,但灵活度最高,而且能让你对底层原理理解得更透彻。
我个人的建议是:先把寄存器方式跑通,因为 RP2040 的 DMA 寄存器地址和位域在数据手册里写得清清楚楚,你亲手设置一遍READ_ADDR、WRITE_ADDR、TRANS_COUNT、CTRL_TRIG,后面无论用哪种封装、换什么芯片,思路都是通用的。等理解了原理,再用rp2.DMA封装提升开发效率。
这里也顺便提一句,如果你手里的板子是 ESP32 S3 之类,MicroPython 默认是没有像 RP2040 这么方便的 DMA 直出接口的,通常得靠 MicroPython 的 C 扩展模块或者直接写固件底层。RP2040 的特殊之处在于官方移植版考虑到了底层操作需求,留了machine.mem32这个强大的后门,所以这篇文章的例子基本是 Pico 平台独享福利。
2. 环境准备与实现方案选型
2.1 硬件和固件怎么选
硬件方面,最标准的选择就是树莓派 Pico 或者 Pico W,主控都是 RP2040,内存 264 KB,主频默认 125 MHz 或者 133 MHz(超频)。如果你手头只有 Pico 兼容板,只要主控是 RP2040 也一样跑。
固件方面要注意版本。早期的 MicroPython 固件对rp2.DMA封装还不完善,所以我建议直接去 MicroPython 官网下载最新版 RP2040 固件,确保你用的版本支持machine.mem32(这个基本所有版本都支持)和rp2模块。我实测用的版本是 1.23 左右,API 已经比较稳定。
连接方式上,用 Thonny 或者任意串口终端连接板子都行。Thonny 的好处是能看到 MicroPython REPL 的输出,方便验证结果,也可以把代码直接粘贴进去运行。后面所有例子我都假设你在 REPL 环境里逐段执行,这样能即时看到效果。
2.2 寄存器方式 vs 官方封装:怎么取舍
两条路各有适用场景,我列个表给你对比一下:
| 方案 | 代码量 | 灵活度 | 可读性 | 适用场景 |
|---|---|---|---|---|
| 寄存器直操作 | 较多 | 极高 | 一般 | 学习原理、需要精确控制位域、调试底层问题 |
rp2.DMA封装 | 少 | 高 | 好 | 日常快速开发、项目集成、代码维护 |
我的建议是,新手第一次跑通用寄存器方式,因为一个字节一个字段都是自己填的,出问题容易排查。跑通之后再去看封装 API,你会觉得封装做的事情就是这么回事。后面实战部分我把两种方式都写了,你可以直接对照参考。
2.3 内存地址获取技巧
在用 DMA 搬运bytearray之前,必须先拿到这个 Python 对象在内存里的真实地址。MicroPython 里有个冷门但极其实用的函数:uctypes.addressof()。它能把一个缓冲区对象(bytearray、array等)的首地址取出来,然后传给 DMA 的读地址和写地址寄存器。
这里有个细节容易踩坑:如果你创建了bytearray之后再做切片、翻转、拼接等操作,可能会生成新的对象,旧地址可能就失效了。所以正确姿势是:先创建好源缓冲区和目标缓冲区,用uctypes.addressof()各取一次地址,之后在整个 DMA 传输期间不要重新分配这两个对象。另外,MicroPython 有自动垃圾回收,虽然bytearray的底层缓冲区一般比较稳定,但如果你拿地址之后又创建了大量对象导致堆整理,理论上还是可能有风险。保险做法是尽量在传输前完成所有对象创建,减少堆上的动态分配。
3. 内存到内存 DMA 实操
3.1 第一步:纯手动寄存器方式完成一次 DMA 拷贝
我先给你看一段最直接、最底层的代码。这段代码不依赖任何 DMA 封装,完全通过machine.mem32操作 RP2040 DMA 通道 0 的寄存器,把src的内容原样搬到dst。
import machine from machine import mem32 import uctypes import time DMA_BASE = 0x50000000 CH0_READ_ADDR = DMA_BASE + 0x00 CH0_WRITE_ADDR = DMA_BASE + 0x04 CH0_TRANS_COUNT = DMA_BASE + 0x08 CH0_CTRL_TRIG = DMA_BASE + 0x0c # 准备源数据和目标缓冲区 src = bytearray(range(64)) # src = [0, 1, 2, ..., 63] dst = bytearray(64) # 初始全部为0 src_addr = uctypes.addressof(src) dst_addr = uctypes.addressof(dst) print("src addr:", hex(src_addr)) print("dst addr:", hex(dst_addr)) # 1. 写读地址、写地址、传输计数 mem32[CH0_READ_ADDR] = src_addr mem32[CH0_WRITE_ADDR] = dst_addr mem32[CH0_TRANS_COUNT] = 64 # 2. 配置控制与触发寄存器: # EN(bit0)=1 启动传输 # INCR_READ(bit4)=1 读地址自增 # INCR_WRITE(bit5)=1 写地址自增 # DATA_SIZE(bit2:3)=0 8位传输 ctrl = (1 << 0) | (1 << 4) | (1 << 5) mem32[CH0_CTRL_TRIG] = ctrl # 3. 等待传输完成 while mem32[CH0_CTRL_TRIG] & (1 << 10): # bit10 是 BUSY pass print("dst:", dst[:16])跑完这段代码,你会发现dst的前 16 个字节变成了0, 1, 2, ..., 15,说明一次内存到内存的 DMA 传输已经成功。
我来解释几个关键点。CH0_CTRL_TRIG寄存器的 bit0 是EN,写 1 表示启动传输;bit4 和 bit5 分别是INCR_READ、INCR_WRITE,置 1 后 DMA 每搬运一个单元,读地址和写地址会各自加 1(因为数据宽度是 8 位);bit10 是BUSY标志位,传输过程中硬件自动置 1,传输完自动清零。最后那个while循环就是轮询等 DMA 结束,虽然占着 CPU,但实际耗时极短,后续我们再换成中断或者循环模式。
3.2 第二步:用循环模式实现连续搬运
实际项目里,一次性搬 64 字节往往不够用,更常见的是希望 DMA 能周期性地、自动地搬数据,比如把采集缓冲区连续搬运到显示缓冲区。RP2040 的 DMA 支持循环模式,也就是一传输完就立刻从头再来,不需要 CPU 干预。
实现循环模式有几种方式,最简单的一种是在CTRL_TRIG配置里加上一个CHAIN_TO的功能。但更常用、也更好理解的思路是结合定时器触发。先看代码:
import machine from machine import mem32 import uctypes import time DMA_BASE = 0x50000000 CH0_READ_ADDR = DMA_BASE + 0x00 CH0_WRITE_ADDR = DMA_BASE + 0x04 CH0_TRANS_COUNT = DMA_BASE + 0x08 CH0_CTRL_TRIG = DMA_BASE + 0x0c # 准备更大的缓冲区:1KB 源数据,1KB 目标 src = bytearray(1024) dst = bytearray(1024) for i in range(1024): src[i] = i & 0xff src_addr = uctypes.addressof(src) dst_addr = uctypes.addressof(dst) # 配置一次传输,但这次先不启动 EN mem32[CH0_READ_ADDR] = src_addr mem32[CH0_WRITE_ADDR] = dst_addr mem32[CH0_TRANS_COUNT] = 1024 # CTRL 中被 DREQ 触发启动,同时开启循环模式 # DREQ 使用 Timer 0,RP2040 中 Timer0 的 DREQ 编号是 0x3B (59) # TREQ_SEL(bit15:18) = 0x3B # EN(bit0)=1, INCR_READ(bit4)=1, INCR_WRITE(bit5)=1, DATA_SIZE=0 ctrl = (1 << 0) | (1 << 4) | (1 << 5) | (0x3B << 15) mem32[CH0_CTRL_TRIG] = ctrl # 每隔 100ms 把 dst 的某个判断字节打印一次 last = -1 while True: v = dst[100] if v != last: print("dst[100] =", v) last = v time.sleep_ms(10)这段代码里,我把TREQ_SEL设成了 Timer0 的 DREQ,这样 DMA 会等定时器发出请求才开始传输,传输完成后等待下一个定时器请求再来一轮。因为初始dst是 0,src[100]是 100,第一次传输完dst[100]会变成 100。如果你把src里的数据在另一个线程或者外部中断里修改,就能观察到dst跟着变化的情况。
不过有个细节:上面的 CTRL 配置没有设置“循环”相关的位,实际上 RP2040 里真正让 DMA 自动循环的机制是CHAIN_TO配合每轮结束的自动重载,或者定时器 DREQ。上面这种写法更准确地说是一种“反复触发”模式,每轮传输完成后通道会保持配置,等待下一个 DREQ。这对周期性搬运足够用了。如果你想要的是非常标准、严格意义上的循环链表传输,那需要再加CHAIN_TO配置,我们后面会提一下。
3.3 第三步:用官方封装重写一遍
如果你觉得寄存器操作太琐碎,想用现成封装,RP2040 新版固件提供了rp2.DMA。用封装改写刚才的例子大概是这个风格:
import rp2 from rp2 import DMA import uctypes, time src = bytearray(1024) dst = bytearray(1024) for i in range(1024): src[i] = i ^ 0xAA src_addr = uctypes.addressof(src) dst_addr = uctypes.addressof(dst) dma = DMA() dma.config( read_addr = src_addr, write_addr = dst_addr, count = 1024, trigger = DMA.TRIGGER_ALWAYS, # 立即触发 ) dma.active(1) while dma.active(): pass print("first bytes:", dst[:8])不同版本的固件对rp2.DMA的参数名可能有细微差别,你在跑之前先确认一下help(DMA)的输出。封装的好处是代码量少,可读性好;缺点是你得接受文档不一定特别全的现实,有些位域细节还得回去翻数据手册。所以我的做法通常是:原型验证用封装,出问题或者需要精细控制时转寄存器。
3.4 用 DMA 实现“双缓冲”的基本玩法
前面例子已经能看出 DMA 的潜力了。我再给你一个特别实用的扩展思路——双缓冲搬移。假设你有一个数据采集任务,采完一批要立刻处理,同时下一批采集不能停。常规写法是:采数据到 buffer A,处理 buffer A,再采到 buffer A,处理 buffer A,这个串行结构天然有停顿。双缓冲的思路是准备两个 buffer,DMA 先往 A 传输,传输期间 CPU 处理 B 的旧数据,等 A 传输完成后切换角色。
在 RP2040 上最省 CPU 的做法是配置两个 DMA 通道,一个搬 A、一个搬 B,串成链。CHAIN_TO是 RP2040 DMA 一个很有用的功能,意思是当前通道传输完成后自动触发下一个通道。你可以把第一个通道的CHAIN_TO指向第二个通道,第二个通道的CHAIN_TO指向第一个通道,同时两个通道的读地址分别指向 buffer A 和 buffer B。这样一轮结束后,下一个通道立刻开始另一轮,形成乒乓结构。这个玩法后续你玩熟了 DMA 以后再深入也来得及,今天先把基础跑通。
4. 性能实测与效果分析
4.1 不同拷贝方式耗时对比
光说“DMA 快”不算数,我实际对比了三种方式在 Pico 上的耗时。测试对象是搬 1 KB 数据,源和目标都是bytearray,主频 133 MHz。
第一种是纯 Python 循环逐字节拷贝,就是for i in range(len(src)): dst[i] = src[i]。这种写法让 Python 解释器每个迭代做一堆事,1 KB 大概耗时 100 多微秒,看起来不多对吧?但如果你的程序要搬 32 KB,那就是 3 到 4 毫秒,刷新率稍高一点系统就开始卡。
第二种是切片拷贝dst[:] = src[:],因为底层是 C 代码直接 memmove,1 KB 大概 10 微秒到 20 微秒,快了一个量级。这个方案的问题是 CPU 依然全程参与,如果你在中断里做这个操作,其他中断响应会被拖慢。
第三种是 DMA 一次性搬运 1 KB,从写入CTRL_TRIG到BUSY清零,实测大约 5 到 8 微秒,和第二种差不多快。但 DMA 最大的优势在后面的时间里 CPU 是完全空闲的,可以继续跑 Python 逻辑;而且如果你配合定时器 DREQ 在后台定期搬运,CPU 连启动传输的时间都省了。
| 拷贝方式 | 1KB 耗时(实测参考) | CPU 占用 | 代码复杂度 |
|---|---|---|---|
| Python for 循环 | 约 100-200 us | 全程占用 | 低 |
| 切片/memmove | 约 10-20 us | 全程占用 | 低 |
| DMA 单次拷贝 | 约 5-8 us | 传输期间空闲 | 中 |
| DMA + 定时器持续搬运 | 启动开销极小 | 几乎不占 CPU | 中高 |
要说明的是,这些数值会受固件版本、主频、地址对齐影响,不同板子上有一定浮动,但趋势是一致的。尤其是当数据量从 1 KB 涨到 64 KB 时,Python 循环几乎不可用,切片还能撑住,但 CPU 被锁死;DMA 在这种大块搬移场景下的优势就完全体现出来了。
4.2 为什么 DMA 在 MicroPython 里也值得用
有些人会杠:MicroPython 本来性能就那样,用 DMA 不是脱裤子放屁吗?其实不是。MicroPython 的慢主要体现在解释执行 Python 字节码上,但数据搬运这件事,Python 不管多快慢,最终还是得把内存里的数据从一个区域挪到另一个区域。DMA 就是把这个“挪”的动作交给硬件,Python 只需要告诉硬件“从哪搬到哪、搬多少”,然后该干嘛干嘛。所以 MicroPython + DMA 的组合,是用解释型语言实现对底层硬件效率榨干的有效途径。
还有一个容易被忽略的好处:DMA 搬运数据是确定性的。Python 代码的耗时经常因为垃圾回收、解释器内部状态而飘忽不定,但 DMA 一旦配置好,每一轮传输的耗时是固定的(由总线时钟决定),这对于需要精确时序的控制系统(比如电机控制、LED 点阵刷新、音频播放)非常重要。
当然,DMA 也不是万能药。它的缺点是配置复杂、错误排查难,而且无法在传送过程中对数据做任何变换(比如把字节翻转、做 CRC)。如果需要边搬边处理,那还是得 CPU 来。所以合理的架构是:大块数据搬迁、周期性数据流交给 DMA;数据解析、协议处理、业务逻辑交给 Python。
4.3 怎么自己动手测 DMA 耗时
想复现上面的性能数据,千万别用time.ticks_us()去卡while mem32[...] & BUSY循环,因为ticks_us()本身有调用开销,而且轮询要读寄存器,读出来不一定准。更粗暴简单的办法是:在 DMA 启动前记录ticks_us,启动后用死等直到BUSY清零,再记录一次。虽然有一定误差,但用来对比三种方式的量级已经足够。
我这里给一个简化版的测速函数:
def dma_copy_time(src, dst): src_addr = uctypes.addressof(src) dst_addr = uctypes.addressof(dst) t0 = time.ticks_us() mem32[CH0_READ_ADDR] = src_addr mem32[CH0_WRITE_ADDR] = dst_addr mem32[CH0_TRANS_COUNT] = len(src) mem32[CH0_CTRL_TRIG] = (1 << 0) | (1 << 4) | (1 << 5) | (1 << 10) # 注意这里别置 BUSY! while mem32[CH0_CTRL_TRIG] & (1 << 10): pass return time.ticks_diff(time.ticks_us(), t0)这段代码里有个故意的错误示例:ctrl值里不该写(1 << 10),因为 bit10 是只读 BUSY 位,写不写都不影响,但为了演示,我一般不这么写。正确做法就是前面第 3 节那样,ctrl只置EN、INCR_READ、INCR_WRITE。测速的时候多跑几轮取平均,更有参考价值。
5. 进阶:串口不定长接收与连续搬运思路
5.1 串口 DMA 接收不定长数据的现实方案
很多人搜“串口 DMA 接收不定长数据”,是因为在做 Modbus、自定义协议、GNSS 报文解析这类场景,报文长度不固定,靠传统uart.read()轮询又怕丢数据,靠中断逐字节接收又太累。
在 RP2040 的 MicroPython 环境里,实现串口不定长接收的思路通常是:用 DMA 把 UART 收到的一个字节连续写入内存的环形缓冲区,然后配合一个“空闲判断”机制来判断一帧报文是否结束。空闲判断有很多种做法:一种是在 MicroPython 里开一个定时器,每次 UART 收到字节就重置定时器,定时超时说明线路空闲了,此时解析缓冲区就是一整帧;另一种是借助 UART 的 RX 空闲中断,不过 RP2040 的 UART 寄存器里没有传统意义上非常好用的 Idle 中断,更多时候得靠定时器辅助。
如果你需要更精准的接收时机,RP2040 上还有一个相对硬核的玩法:用 PIO 实现一个带空闲检测的 UART 接收。PIO 可以在 GPIO 状态机的控制下检测起始位和空闲状态,并且支持 IRQ。这个方法比普通 UART 更灵活,但代码量会大不少。对于大多数项目,定时器超时 + DMA 环形缓冲区的方案已经够稳了。
5.2 DMA 发送需要等上一轮发送完吗
这是一个特别经典的问题:我要连续串口发送多帧数据,每次都要调用 DMA,那我是不是必须等上一次发送完成才能启动下一轮?
答案是分情况的。如果你用CTRL_TRIG每次手动触发一次传输,硬件会保证同一通道同一时间只能做一件事。当你给一个还在BUSY的通道写入新的传输计数值和触发位时,行为是不确定的,有可能覆盖当前传输配置,也可能被忽略。所以稳妥起见,必须等BUSY清零,或者等待 DMA 中断标志,再启动下一次传输。很多人的“DMA 疑难杂症”就是这么来的。
但如果你用的是定时器 DREQ 触发、循环模式,每轮传输自动完成后硬件会准备好接收下一个 DREQ,你甚至不需要主动去启动下一轮。这种模式下你要关心的是:源缓冲区、目标缓冲区是否被更早的传输占用。比如 DMA 正在从buf_A往串口发送,你又往buf_A写了新数据,它发出去的还是旧数据,这属于数据一致性错误,比“要不要等”更隐蔽。
5.3 内存到内存 DMA 在缓冲任务中的典型用法
回到本文核心主题。内存到内存 DMA 最常见的实际应用就是把采集缓冲搬到处理缓冲。举个例子:我在做一个小型音频采样显示项目,ADC 不断采样写入一个ping_buffer,DMA 每秒数次把ping_buffer整体搬到pong_buffer,然后 Python 从pong_buffer里取数据做 FFT 和波形绘制。因为 DMA 搬移时 CPU 完全空闲,采样率能稳定保持,同时界面刷新也不卡。
再比如 OLED 显示。如果你的 OLED 驱动是 SPI 接口,且你用的是帧缓冲,那每次刷新一屏数据要写好几 KB。用 DMA 把帧缓冲搬到 SPI 的 TX FIFO(这是内存到外设传输,不是内存到内存,但思路一致),Python 就可以在 UI 主循环里继续画下一页。RP2040 的 SPI 在 MicroPython 里其实也支持 DMA 相关操作,不过不同固件封装的完整度不同,需要查阅对应文档。核心理解仍然是:不要让 Python 逐字节去喂外设。
6. 常见问题排查与避坑指南
6.1 典型问题速查表
我在调试过程中遇到过不少奇葩问题,下面这些是最高频的,整理成表格方便你对照:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
DMA 启动后dst全是 0 | read_addr或write_addr用的是 Python 对象而非真实地址 | 用uctypes.addressof()取地址,确认地址非零 |
| 传输只有部分字节成功 | 传输计数单位算错,比如 32 位宽度下计数写成了字节数 | 检查DATA_SIZE,8 位宽度计数就是字节数 |
报错MemoryError | 目标bytearray还没创建或者被垃圾回收 | 开局就创建好所有缓冲区,不要在传输中间动态分配 |
| 数据乱序、首尾错乱 | 源和目标地址重叠,DMA 没有处理重叠拷贝 | 用临时中转缓冲区,或者确认搬移方向 |
死等BUSY永远不退出 | CTRL_TRIG里EN位没写 1;或DREQ配置了没有触发源 | 先确认 CTRL 值,再确认TREQ_SEL,或直接用TRIGGER_ALWAYS |
| 定时器触发模式下 DMA 不工作 | 定时器没启动,或 DREQ 编号写错 | RP2040 里 Timer0 的 DREQ 是 0x3B,对照数据手册核对 |
| MicroPython 崩溃重启 | 访问了非法内存地址,或地址对齐不对 | 检查uctypes.addressof()返回地址,尽量用 8 位传输 |
6.2 踩坑心得与独家技巧
第一点,地址对齐问题不能忽略。虽然 8 位传输对地址没硬性要求,但如果你把DATA_SIZE设为 32 位(Word),读地址和写地址最好都是 4 字节对齐,否则某些场景下可能触发总线故障。最简单的规避方法:内存到内存拷贝一律用 8 位宽度。
第二点,RP2040 没有 D-Cache,所以不需要担心缓存一致性问题。这点和 STM32H7、i.MX RT 这些 Cortex-M7 芯片不一样。如果你以后换了带 D-Cache 的芯片,DMA 从内存读数据时还得考虑 cache 刷新问题,那才是真正的深坑。在 Pico 上你省掉了这一层痛苦。
第三点,MicroPython 的垃圾回收确实可能移动对象吗?在标准 MicroPython 里,bytearray的底层缓冲区是独立分配在堆上的,垃圾回收压缩(如果有)可能会移动对象吗?RP2040 移植版默认采用的是非移动 GC,所以对象分配后地址不会变。但为了稳妥,我还是建议在启动 DMA 之前把所有bytearray分配好,之后不要再创建大量大对象,避免堆碎片化影响后续分配。毕竟 DMA 正在搬运的时候,如果 GC 突然运行导致源缓冲区被回收,那结果不可预测。
第四点,多通道并行时注意优先级。RP2040 的 DMA 通道 0 优先级最低,通道 11 最高。如果你同时跑好几路 DMA,高优先级通道会抢占总线,导致低优先级通道传输变慢。做内存到内存搬运时,给实时性要求高的任务分配更高编号通道,比如显示刷新用通道 9,普通缓冲拷贝用通道 0,互不干扰。
第五点,用rp2.DMA封装调试时善用print(hex(mem32[CH0_CTRL_TRIG]))。封装出来的问题往往藏在配置细节里,直接读寄存器能最快发现问题。我见过同事用封装死活搬不过去,最后读寄存器发现TREQ_SEL被默认配置成了一个不存在的触发源,改成TRIGGER_ALWAYS立刻就好了。
6.3 向“外设 DMA”平滑过渡
内存到内存 DMA 学会之后,你可以顺手把思路迁移到外设 DMA。RP2040 的 DMA 同样的通道机制,只是把读地址或写地址换成外设寄存器地址,比如 UART 的UART_DR、SPI 的SPI_DR,然后配置 DREQ 触发源为对应外设。你只要记住一个口诀:外设那一边地址固定不变,内存那一边地址自增;内存到内存则是两边都自增。迁移的成本很低,收益却很大。
比如你用 PIO 写了个 WS2812 灯带驱动,本来要 CPU 循环填数据,现在可以 DMA 把颜色缓冲直接送到 PIO 的 TX FIFO,CPU 就能腾出来刷 UI。这种玩法在 MicroPython 社区已经不少见了,掌握了今天的内存到内存 DMA,再往前一步就是这套。
最后分享一个我自己的习惯:每次写 DMA 代码前,先在纸上把“谁提供数据、谁接收数据、谁触发传输、地址怎么走、计数多少”这五件事写下来,再动手写寄存器配置。看起来老土,但确实能少踩一半的坑。RP2040 的 DMA 并不复杂,复杂的是你对系统数据流的理解不够清晰。把这块想明白了,MicroPython 里照样能玩出底层操控的爽感。