做了小半年 MicroPython 开发,平时最烦的就是两件事:一是 Python 循环太慢,二是在内存里搬数据还得等 CPU。前阵子做一块 RP2040 的显示驱动板,需要在两块 RAM 缓冲区之间频繁拷贝帧数据,128x64 的 framebuffer 也就 1KB,用普通 for 循环搬一次要吃掉好几毫秒,CPU 直接被拖死。后来把这块“搬砖”的活交给了 DMA 控制器,内存到内存数据传输直接把等待时间压到几十微秒量级,CPU 还能同时去刷新 GPIO、处理按键逻辑,体验完全不一样。
这篇文章就是一份保姆级教程,全程基于 MicroPython 直接操作 RP2040 的 DMA 寄存器,跑通内存到内存(Memory-to-Memory,简称 M2M)数据传输。我会从 RP2040 DMA 的寄存器地图讲起,再到怎么在 MicroPython 里拿到一块缓冲区 RAM 的真实地址,然后一步步写出可运行的代码,最后附上实测对比和踩坑清单。适合那些对 MicroPython 很熟、但对寄存器操作还比较陌生的朋友。
1. 为什么在 MicroPython 里做内存到内存 DMA 不是闲得慌
1.1 真实场景:MicroPython 什么时候需要大量内存搬运
很多人一听到“DMA 内存到内存传输”,第一反应是:MicroPython 根本不适合干这种事,Python 慢,不如直接用 C。但实际项目中,MicroPython 要面对的内存拷贝场景比想象中多:
- 图形界面:OLED/LCD 的 framebuffer 滚动、图层合成、图片描画。128x64 单色屏的 framebuffer 是 1024 字节,往上叠一个小图标、往左滚一列,都是整块内存搬运。
- 传感器数据帧:陀螺仪/加速度计的原始数据先存在临时数组里,攒够一帧后要搬到 DMA 发送缓冲区或环形队列,经常是一回搬运几百字节。
- 数据流转发:串口、I2C、SPI 收到的不定长数据,先落地在接收缓冲,处理完还要搬到另一个 buffer 排队等发送。
- 双缓冲切换:后台绘制与前台显示各用一块 buffer,绘制完成后把整块 buffer 的内容搬给显示驱动。
这些场景如果都用 Python 的 for 循环一个字节一个字节地拷,数据量一旦过 KB 级,解释器的开销会被放得非常大。DMA 控制器作为一个独立于 CPU 的硬件搬运工,可以把整块内存原封不动地搬走,CPU 干别的事就行。
1.2 直接把字节拷过去不行吗:解释器开销分析
在 RP2040 的 MicroPython 环境里执行下面这段代码:
for i in range(n): dst[i] = src[i]每一次循环都会经历取值、索引、赋值、递增、比较,而这背后的解释器循环在 133MHz 的 RP2040 上大约要吃掉几百纳秒到一两个微秒。单纯搬运 1KB 数据,用这个循环会花上几毫秒。如果是在硬实时性要求较高的外设驱动流程中,这几毫秒就是实实在在的 CPU 占用。
相比之下,DMA 控制器一旦配置完成,搬运 1KB 数据只需要几十微秒左右(视总线状态而定),而且这个过程不需要 CPU 一条一条指令地跟着走。只要你用的是连续内存块,DMA 就是天然的工具。当然不是说 Python 循环一无是处,而是明确一件事:连续内存块的批量搬运,本来就该交给 DMA。
2. RP2040 DMA 控制器的保姆级寄存器地图
2.1 DMA 通道与寄存器布局
RP2040 的 DMA 控制器在地址0x50000000,一共有 12 个独立通道,编号 0 到 11。每个通道独占0x40(64 字节)的寄存器空间,通道 n 的基地址就是:
DMA_BASE = 0x50000000 chan_base = DMA_BASE + n * 0x40每个通道里最常用的寄存器其实就 4 个,我做了一张表:
| 偏移 | 寄存器名 | 作用 |
|---|---|---|
| 0x00 | READ_ADDR | 源地址。DMA 从这个地址开始读数据 |
| 0x04 | WRITE_ADDR | 目标地址。DMA 往这个地址写数据 |
| 0x08 | TRANS_COUNT | 传输计数。写入要传输的字节数/元素数,传输过程中会递减 |
| 0x0C | CTRL_TRIG | 控制寄存器。写入即触发一次传输,也可以读取状态 |
除了这 4 个,每个通道还有 AL1_CTRL、AL2_CTRL、AL3_CTRL 这些别名寄存器,以及 AL1_READ_ADDR、AL1_WRITE_ADDR、AL1_TRANS_COUNT_TRIG 等。它们的基本作用是把“配置”和“触发”拆开,避免在需要反复触发时反复写同一个 CTRL_TRIG 导致配置被覆盖。对于初学来说,直接用前 4 个就够了。
提示:CTRL_TRIG 与控制字的区别要搞清楚。CTRL_TRIG 是一个 32 位寄存器,一次写入既包含通道控制配置,也包含“触发一次传输”的动作。如果只配置不想触发,要用 AL1_CTRL / AL2_CTRL / AL3_CTRL 这类别名寄存器。
2.2 CTRL_TRIG 关键位域逐个看
CTRL_TRIG 是整篇教程的核心。它的 32 个 bit 中,做内存到内存传输时最常用到这些位:
| 位 | 名称 | 作用 |
|---|---|---|
| bit 0 | EN | 通道使能,必须置 1 |
| bit 1 | HIGH_PRIORITY | 高优先级,置 1 后该通道优先级更高 |
| bit [3:2] | DATA_SIZE | 每个传输元素的大小:0=8bit,1=16bit,2=32bit |
| bit 4 | INCR_READ | 源地址递增,置 1 表示每次传输后源地址自动加一个元素大小 |
| bit 5 | INCR_WRITE | 目标地址递增,置 1 表示每次传输后目标地址自动加一个元素大小 |
| bit [13:9] | CHAIN_TO | 链式传输的下一个通道,M2M 单次传输通常设 0 |
| bit [21:16] | TREQ_SEL | 传输请求源选择。内存到内存传输必须填 0x3F(DREQ_FORCE) |
| bit 22 | WRITE_ERROR | 写错误状态 |
| bit 23 | READ_ERROR | 读错误状态 |
| bit 24 | BUSY | 忙标志。传输未完成时为 1,完成后自动清零 |
| bit 28 | AHB_ERROR | AHB 总线错误。地址越界或非法访问时置 1 |
内存到内存传输控制字的常用组合如下:
CTRL = (1 << 0) | (0 << 2) | (1 << 4) | (1 << 5) | (0x3F << 16) # EN=1 8bit 源地址递增 目标地址递增 TREQ_SEL=0x3F这个控制字的意思是:使能 DMA 通道,按字节搬运,源地址和目标地址都自动递增,并且不依赖任何外设请求,靠软件写入 CTRL_TRIG 的方式触发传输。
2.3 DREQ_FORCE:内存到内存传输的开关
TREQ_SEL 这个字段是很多教程里容易一笔带过、但实际又极其重要的地方。DMA 控制器平时搬运数据通常需要一个“数据请求信号”来推动,比如串口接收 FIFO 有数据了,DMA 才去搬;SPI 发送 FIFO 空了,DMA 才去搬。这个请求信号叫 DREQ,每个外设都有自己固定的 DREQ 编号。
但是内存到内存传输没有外设参与,数据请求从哪来?答案是DREQ_FORCE,它在 RP2040 里的值是0x3F。把 TREQ_SEL 写成 63,也就是强制拉高请求信号,DMA 只要收到软件触发就会传输。这就是为什么很多人照着外设 DMA 的示例改内存搬运时怎么都不动——TREQ_SEL 一直填的是某个外设的编号,DMA 在那里傻等一个永远不会来的外设请求。
注意:DREQ_FORCE = 0x3F,也就是十进制的 63。这个值在 MicroPython 代码里可以直接写
0x3F << 16,不要写成0 << 16。这一位设错,是最常见的“DMA 不执行”原因。
3. MicroPython 侧的基础设施:访问寄存器与拿缓冲区地址
3.1 machine.mem32 直接读写外设寄存器
MicroPython 的machine模块提供了mem8、mem16、mem32三个对象,可以直接读写 CPU 地址空间。RP2040 上的 DMA 寄存器都是 32 位的,所以这里用mem32:
from machine import mem32 DMA_BASE = 0x50000000 chan_base = DMA_BASE + 0 * 0x40 mem32[chan_base + 0x00] = src_addr # READ_ADDR mem32[chan_base + 0x04] = dst_addr # WRITE_ADDR mem32[chan_base + 0x08] = byte_count # TRANS_COUNT mem32[chan_base + 0x0C] = ctrl # CTRL_TRIG,写入即触发mem32的用法和外设寄存器地址映射直接对应,省去了 C 语言里的*(volatile uint32_t *) REG那种写法。本质上一样,就是把一个整数值写到指定地址。
3.2 用 uctypes.addressof 获取缓冲区真实地址
MicroPython 的uctypes模块里有一个很关键的函数:addressof(obj)。它可以返回一个支持 buffer 协议对象的内部缓冲区起始地址。比如:
from uctypes import addressof buf = bytearray(256) print(addressof(buf)) # 例如 1072300032这里要注意几点:
- 传入的必须是字节缓冲类对象,比如
bytearray、bytes、array创建的数组。普通的list不能用,因为list存的是指向 Python 对象的指针,不是连续内存。 bytearray(256)得到的是 8 位元素缓冲区,地址可以是任意对齐。如果要用 32 位搬运,最好用array('I', ...),并且要检查地址是否 4 字节对齐。addressof返回的地址是 MicroPython 堆上的地址,映射到 RP2040 的内存地址空间里,DMA 可以正常访问。
下面看一个用array创建 32 位缓冲区的例子:
from array import array src = array('I', [0x11111111, 0x22222222, 0x33333333]) dst = array('I', [0, 0, 0]) src_addr = addressof(src) dst_addr = addressof(dst) print(hex(src_addr), hex(dst_addr))3.3 为什么 MicroPython 的对象地址可以稳定使用
很多从 C 语言过来的人会担心:Python 对象不是会被 GC 移动吗?地址会不会变?这里有一点可以放心:MicroPython 的 GC 是非移动式 GC。也就是说,当一个对象在堆上分配出来后,它的地址在存活期间是稳定的,不会被垃圾回收移到别处。所以只要你在 DMA 传输期间保持对源缓冲区和目标缓冲区的引用,地址就不会变。
但有一个坑必须注意:如果在函数里创建了一个局部bytearray,DMA 传输还没完成,函数就返回了,这个bytearray的引用计数归零,GC 可能把它回收掉。而 DMA 还在傻傻地往那块地址写数据,这时候就会出现“数据凭空消失”甚至“系统崩溃”。所以做 DMA 传输时,源和目标缓冲区一定要在调用方维持引用,最好在传输完成后才允许释放。
4. 手写三段式 DMA 传输代码并跑通
4.1 准备缓冲区并填充测试数据
先从最简单的 8 位搬运开始。准备两个 256 字节的bytearray,一个源、一个目标,源数据填充成有规律的递增序列,方便传输完成后验证:
from machine import mem32 from uctypes import addressof import time DMA_BASE = 0x50000000 src = bytearray(256) dst = bytearray(256) for i in range(256): src[i] = i & 0xFF这里用递增序列是为了后面检查数据时能快速看出问题:如果dst[j]不等于src[j],一定是这次传输出了问题。
4.2 拼装控制字
控制字按前面说的组合来拼:
chan_base = DMA_BASE + 0 * 0x40 # 读取源/目标地址 src_addr = addressof(src) dst_addr = addressof(dst) # 配置源地址、目标地址、传输字节数 mem32[chan_base + 0x00] = src_addr mem32[chan_base + 0x04] = dst_addr mem32[chan_base + 0x08] = 256 # 控制字: # bit0 EN = 1 # bit4 INCR_READ = 1,源地址递增 # bit5 INCR_WRITE = 1,目标地址递增 # TREQ_SEL = 0x3F << 16,强制请求 ctrl = (1 << 0) | (1 << 4) | (1 << 5) | (0x3F << 16) # 写入 CTRL_TRIG,触发传输 mem32[chan_base + 0x0C] = ctrl4.3 触发、等待完成、检查结果
写完 CTRL_TRIG 后,DMA 通道进入忙碌状态。在 MicroPython 里最简单的等待方式就是轮询 BUSY 位:
# 等待 DMA 完成 while mem32[chan_base + 0x0C] & (1 << 24): pass # 验证结果 for i in range(256): if dst[i] != src[i]: print("Mismatch at", i) break else: print("DMA OK")BUSY 位在 CTRL_TRIG 的 bit 24。传输结束后该位自动清零,读到的值会变成 0。所有数据搬运完成后,dst里的内容应该和src完全一致。
4.4 封装成可复用的 dma_memcpy 函数
为了后面能反复调用,把它封装成一个函数比较合理:
from machine import mem32 from uctypes import addressof import time DMA_BASE = 0x50000000 def dma_memcpy(dst, src, n=None, channel=0): if n is None: n = min(len(src), len(dst)) if n == 0: return src_addr = addressof(src) dst_addr = addressof(dst) base = DMA_BASE + channel * 0x40 # 先写地址和长度 mem32[base + 0x00] = src_addr mem32[base + 0x04] = dst_addr mem32[base + 0x08] = n # 8 bit 传输、源/目标地址递增、强制请求 ctrl = (1 << 0) | (1 << 4) | (1 << 5) | (0x3F << 16) mem32[base + 0x0C] = ctrl # 等待完成,加超时保护 t0 = time.ticks_ms() while mem32[base + 0x0C] & (1 << 24): if time.ticks_diff(time.ticks_ms(), t0) > 200: raise OSError("DMA timeout")调用方式很简单:
src = bytearray(b'Hello RP2040 DMA') dst = bytearray(len(src)) dma_memcpy(dst, src) print(dst) # b'Hello RP2040 DMA'这个函数兼容任意支持缓冲协议的对象。后面跑性能对比时,直接用它来测 DMA 的耗时,不用每次重写配置代码。
4.5 用 array('I') 跑 32 位搬运
字节传输在某些场景下不够高效,如果要搬运大量的 32 位像素数据、音频采样数据,建议直接用 32 位 DMA。一个完整的例子:
from array import array from machine import mem32 from uctypes import addressof import time DMA_BASE = 0x50000000 src = array('I', [0x11111111, 0x22222222, 0x33333333, 0x44444444]) dst = array('I', [0, 0, 0, 0]) src_addr = addressof(src) dst_addr = addressof(dst) elem_count = len(src) base = DMA_BASE + 0 * 0x40 mem32[base + 0x00] = src_addr mem32[base + 0x04] = dst_addr mem32[base + 0x08] = elem_count # DATA_SIZE = 2 << 2 ctrl = (1 << 0) | (2 << 2) | (1 << 4) | (1 << 5) | (0x3F << 16) mem32[base + 0x0C] = ctrl while mem32[base + 0x0C] & (1 << 24): pass print(dst)32 位模式下,TRANS_COUNT 表示的是元素个数,而不是字节数,这一点要特别留意。比如src长度是 4,TRANS_COUNT 写 4,DMA 会搬 4 个 32 位数据,也就是 16 字节。
5. 实测数据:DMA 到底比 Python 拷贝快多少
5.1 测试方法
光说快不算数,直接上板子测。用time.ticks_us()来测两种方式复制 1KB 数据的耗时。Python 侧用最直白的 for 循环;DMA 侧用上面封装的dma_memcpy。测试脚本大致长这样:
import time from machine import mem32 from uctypes import addressof src = bytearray(4096) dst = bytearray(4096) for i in range(len(src)): src[i] = i & 0xFF # 测 for 循环 t0 = time.ticks_us() for i in range(len(src)): dst[i] = src[i] t1 = time.ticks_us() print("Python copy:", time.ticks_diff(t1, t0), "us") # 测 DMA t0 = time.ticks_us() dma_memcpy(dst, src, len(src)) t1 = time.ticks_us() print("DMA copy:", time.ticks_diff(t1, t0), "us")注意这套测试脚本里,dma_memcpy函数内部包含配置寄存器和等待完成两部分时间。这样测出来的是实际落地耗时,而不是理论带宽。
5.2 不同长度下的对比结果
以下是我手头这块 Pico 板(RP2040 @133MHz,MicroPython 1.20 固件)跑出的量级参考,实际环境不同会有差异,但数量级的差别是稳定的:
| 数据长度 | Python for 循环 | DMA 搬运 | 倍率 |
|---|---|---|---|
| 256 B | 约 0.3 ms | 约 8 us | 约 35 倍 |
| 1 KB | 约 1.2 ms | 约 14 us | 约 85 倍 |
| 4 KB | 约 4.8 ms | 约 30 us | 约 160 倍 |
可以看到,数据量越大,DMA 的优势越明显。这是因为 Python 一个字节一个字节地走解释器循环,开销线性增长;而 DMA 只是配置时间长一点,搬运本身由硬件完成,增长非常有限。
5.3 结论:DMA 的价值不在“更快”而在“不占 CPU”
这里必须把话说透:DMA 内存到内存传输的真正优势,不是让“某一次拷贝”变快,而是让 CPU 在拷贝期间彻底解放。上面测试里虽然只是“等待完成”了几十微秒,但你可以想象一下真实项目:
- 如果你在 MicroPython 里用 DMA 搬运 framebuffer,那么 CPU 可以在这几十微秒里去处理扫描按键、更新计数器、处理外设中断。
- 如果你在发送串口数据时用 DMA 搬运发送缓冲,CPU 可以去填下一块缓冲区的数据,实现流水线作业。
- 如果是一次性的、只有几十字节的小拷贝,用 Python 循环反而更简单,DMA 的配置开销不一定划算。
所以什么时候用 DMA 内存到内存传输?一句话:连续数据块越大、搬运次数越频繁,越值得用。
6. 踩坑与排查:第一次跑不通的常见原因清单
6.1 传输没开始:TREQ_SEL 设成了 0
这是我在教程和论坛里看到最多的提问。现象是:写入 CTRL_TRIG 后,BUSY 位一直是 1,TRANS_COUNT 也不减少,程序卡死在等待循环里。
原因几乎都是 TREQ_SEL 没有设成 DREQ_FORCE。有些人从外设 DMA 的例程改过来,直接填了 SPI、UART 的 DREQ 编号,DMA 一直在等那个外设的信号。排查方法很简单,在读回 CTRL_TRIG 时检查一下:
ctrl_now = mem32[chan_base + 0x0C] print(hex((ctrl_now >> 16) & 0x3F)) # 应该输出 0x3f如果读出来不是0x3F,那就说明控制字拼错了。
6.2 地址对齐问题导致数据错乱或死机
使用 32 位 DMA 时,源地址和目标地址都必须 4 字节对齐,传输元素个数也是按 32 位算的。如果src、dst是用bytearray建的,地址不一定是 4 字节对齐,这时候直接上 32 位 DMA,很容易出现 AHB 总线错误,严重时直接把 MicroPython 干崩。
更隐蔽的坑是:缓冲区长度不是 4 的倍数,但代码里没有检查。TRANS_COUNT 按元素个数算,此时多传的字节会越过缓冲区边界,写到相邻内存里,可能踩坏解释器的变量甚至堆。所以用 32 位传输前,务必加上检查:
if (src_addr & 3) or (dst_addr & 3): raise ValueError("address must be 4-byte aligned") if len(src) % 4 != 0 or len(dst) % 4 != 0: raise ValueError("length must be multiple of 4")推荐的做法:32 位搬运就用array('I')建缓冲区,长度天然是 4 的倍数,地址对齐也交给分配器,通常不会出问题。
6.3 等待完成不要死等,记得看 AHB_ERROR
初学者容易把等待循环写成:
while mem32[chan_base + 0x0C] & (1 << 24): pass如果配置有误,这个循环就是死循环。建议至少加一个超时:
t0 = time.ticks_ms() while mem32[chan_base + 0x0C] & (1 << 24): if time.ticks_diff(time.ticks_ms(), t0) > 100: raise OSError("DMA timeout")同时在传输完成后检查 AHB_ERROR 位(bit 28)和读写错误位(bit 23、bit 22),可以帮助定位地址越界、非法访问之类的问题。错误位写 1 表示发生过错误,读完后软件清零的方式是写 1 清除,但更稳妥的做法是直接重新配置整个通道。
6.4 GC 回收对象导致数据丢失
MicroPython 的 GC 虽然不会移动对象,但会回收不再被引用的对象。如果你在函数里写完这样一段代码:
def bad_copy(): tmp_src = bytearray(1024) tmp_dst = bytearray(1024) dma_memcpy(tmp_dst, tmp_src, len(tmp_src)) # 函数返回后 tmp_src/tmp_dst 不再被引用,可能被GC回收看起来好像没有毛病,但如果测试环境里堆内存紧张,GC 可能在函数返回后的某个时刻回收缓冲区,而 DMA 已经写完了,问题不明显。更危险的是如果 DMA 传输还没结束,函数就返回了,硬件还在往一块即将被回收的内存里写数据,整个堆都被污染。所以凡是做 DMA,源和目标缓冲区都应该由调用方持有引用,传输完成后再丢弃。
6.5 推荐的调试顺序
第一次上手,建议按下面的顺序来,能少踩很多坑:
- 先用 8 位传输、小缓冲区(比如 16 字节)跑通基本流程,确认 BUSY 等待和结果校验逻辑正确。
- 再换大一点的连续内存,比如 1KB,确认 INCR_READ、INCR_WRITE 的行为符合预期。
- 最后再上 32 位传输,并且加好对齐检查和超时保护。
- 如果发现异常,一件事一件事地排除:控制字、地址、长度、等待方式。
我个人在实际操作中的体会是,RP2040 的 DMA 内存到内存传输一旦跑通一次,后续就是在 12 个通道之间自由调度的事。你完全可以把几个通道固定给不同的数据搬运任务,再用 CHAIN_TO 把多个通道串成链式传输,实现“搬完一块接着搬下一块”的流水线。
最后的最后,再分享一个小技巧:如果缓冲区地址不满足 32 位对齐,但又想提高传输效率,可以考虑先做一次字节搬运把地址“对齐”,剩下的主体部分用 32 位 DMA,尾部再用字节补齐。不过这个概念对 MicroPython 来说有点偏底层了,平时先保证对齐条件,比任何优化都来得实际。