先说一句大实话:MicroPython 在很多人的印象里就是“随便点点灯、读个传感器”的玩具级脚本语言,跟 DMA 这种底到不能再底层的硬件机制几乎是八竿子打不着。但实际折腾过 RP2040 之后你会发现,MicroPython 的权限其实比你想象中大得多,只要你愿意绕过封装好的库,直接去操作寄存器,一样能让 DMA 跑起来,而且速度提升非常明显。这篇文章我就拿“内存到内存传输”这个最简单、最适合上手的场景,完整演示一遍在 RP2040 上用 MicroPython 配置 DMA 的保姆级流程,从寄存器原理到可直接抄走的代码,再到我踩过的几个大坑,一次性讲明白。
这个玩法适合谁?两类人最需要:一类是已经在用 MicroPython 做项目,但发现 Python 层面做数据搬运太慢、CPU 忙得不行,想用 DMA 把拷贝这件事丢给硬件的人;另一类是还没搞懂 DMA 是什么、寄存器怎么配,想找一个“够简单、够安全、能立刻看懂效果”的入门样例的人。读完你能做出一段真正跑在硬件上的 DMA 内存拷贝代码,并且明白每一行配置到底在干嘛。
1. 为什么要用DMA,以及MicroPython能做到哪一步
1.1 DMA到底解决了什么问题
DMA 全称 Direct Memory Access,直译是“直接内存访问”,本质就是一个专门干数据搬运的硬件小工。CPU 通常要花指令周期去取数据、存数据,而 DMA 控制器可以在不占用 CPU 运算能力的情况下,把数据从一块内存搬到另一块内存,或者从外设搬到内存、从内存搬到外设。它的好处就一句话:把 CPU 从“重复copy”这种毫无技术含量的劳动里解放出来,让 CPU 去干真正需要动脑子的活儿。
内存到内存传输是 DMA 里最纯粹的一种模式,源地址和目标地址都是内存,不涉及外设。它虽然看起来不如“ADC采集完直接丢进内存”“串口接收完自动进缓冲区”那么酷,但正因为不依赖任何外设请求,它最适合拿来理解 DMA 的工作逻辑。你只需要告诉它“从哪儿搬、搬到哪儿、搬多少个”,它就会一路搬到底,搬完了再告诉你一声。等你在 mem-to-mem 上把寄存器玩熟了,再去接 ADC、串口、PIO 这些外设,就是一通百通的事。
1.2 MicroPython对硬件的“本来面目”与灰魔法
MicroPython 官方 API 其实没有给你一个现成的dma.copy()函数,想在 RP2040 上让 DMA 跑起来,只能绕到硬件层面去操作寄存器。很多人一听“操作寄存器”就吓得不行,觉得这是 C 语言和汇编的专属领域。其实在 MicroPython 里,寄存器访问就是普通的读写内存操作,地址是确定的,格式是确定的,你完全可以用 MicroPython 代码把配置值写进 DMA 控制器的寄存器里,让硬件按你的要求干活。
RP2040 的 DMA 控制器的寄存器基地址是0x50000000,SRAM 起始地址是0x20000000。也就是说,DMA 寄存器在内存地址空间里占了一块区域,你只要往那块区域写特定的值,芯片的 DMA 硬件就会做出反应。这跟你在 C 语言里用*(volatile uint32_t *)0x50000000 = xxx本质上没有任何区别。MicroPython 里访问任意地址有两种比较优雅的方式:一种是用uctypes模块把寄存器区域映射成一个结构体,读字段、写字段都非常清晰;另一种是用micropython.viper装饰器加ptr32指针,直接按 32 位字长读写地址。两种方式我都会在后面的实操里给完整代码。
有一点需要先说清楚:MicroPython 的 GC 是标记-清除式的非移动 GC,也就是说一个bytearray对象一旦被创建,它在堆上的数据缓冲区地址在生命周期内是稳定的,不会像某些高级语言那样被搬来搬去。这一点对 DMA 至关重要,因为你得把一个稳定的物理地址告诉 DMA 控制器。只要你在复制期间保持bytearray对象的引用,不要让它被垃圾回收掉,地址就是可靠的。
2. RP2040 DMA控制器关键机制,先搞清楚再去写代码
2.1 12个通道的寄存器布局,一次看明白
RP2040 的 DMA 控制器一共提供了 12 个独立的 DMA 通道,编号从 CH0 到 CH11。每个通道都有一组完全相同的寄存器,用来描述“这一次传输从哪里读、写到哪里、传多少、怎么传”。通道之间互相独立,可以并行跑,也可以把一个通道设成另一个通道完成后再启动,形成链式传输。默认规则里编号越小的通道优先级越高,所以我们最常用的就是 CH0。
每个通道占用0x40字节的寄存器空间。CH0 的寄存器从0x50000000开始,CH1 从0x50000040开始,以此类推。下面这张表是 CH0 的寄存器布局,也是我们这次唯一需要关心的几个寄存器:
| 寄存器名 | 偏移 | 作用 |
|---|---|---|
| READ_ADDR | 0x00 | 源数据起始地址,DMA 从这里读数据 |
| WRITE_ADDR | 0x04 | 目标地址,DMA 把数据写到这里 |
| TRANS_COUNT | 0x08 | 要传输的数据元素个数,和下面 DATA_SIZE 配合使用 |
| CTRL_TRIG | 0x0C | 控制字寄存器,写入这个寄存器的同时会触发传输开始 |
| CTRL | 0x10 | 控制寄存器,和 CTRL_TRIG 布局一样,但不会触发启动 |
注意CTRL_TRIG和CTRL的区别。虽然它们的位布局完全一样,但CTRL_TRIG是你把整个配置准备好后最后一下“点火”的开关,写入即启动传输;CTRL则是单独配置用的,不会触发启动。如果你先写CTRL配置好所有参数,之后再写CTRL_TRIG也可以,但通常我们图省事,会把所有配置一次性写进CTRL_TRIG。
2.2 内存到内存传输的触发逻辑:TREQ_SEL=0x3f
DMA 传输必须要有一个“请求信号”来推动,这个信号就是 DREQ(Data Request)。不同的外设对应不同的 DREQ 编号,比如 UART 发送有对应的 DREQ,SPI 收发有对应的 DREQ,PIO 也有对应的 DREQ。只有外设提出请求,DMA 才搬一个数据单元。这种机制保证了 DMA 不会搬太快把 FIFO 搞乱。
但内存到内存传输不依赖任何外设,没有谁会给 DMA 发 DREQ。那怎么办?RP2040 的 DMA 控制器专门留了一个特殊值:TREQ_SEL = 0x3f,代表“请求信号永远有效”,也就是只要通道使能了,DMA 就会全速搬运,不需要等待任何外设信号。这是我这次配置里最重要的一个值,很多人搞内存到内存时卡住不动,十有八九就是这里没配对。我把0x3f写进去之后,DMA 就像被接通了电源一样,一波数据直接搬完。
2.3 控制字的位域拆解,照着配就行
接下来是控制字CTRL_TRIG里各个关键位的含义,不用全背,把常用的几个搞清楚就够用了:
| 位域 | 名称 | 作用与建议值 |
|---|---|---|
| bit0 | EN | 通道使能,必须为 1 |
| bit1 | HIGH_PRIORITY | 高优先级,内存拷贝一般不需要,设 0 |
| bit2:3 | DATA_SIZE | 传输粒度,0=字节,1=半字(16bit),2=字(32bit)。内存拷贝建议用 2 |
| bit4 | INCR_READ | 源地址是否递增,1=每传一个元素源地址加对应字节数 |
| bit5 | INCR_WRITE | 目标地址是否递增,1=每传一个元素目标地址加对应字节数 |
| bit6:10 | RING_SIZE/RING_SEL | 环形缓冲区相关设置,本例不用,设 0 |
| bit11:14 | CHAIN_TO | 链式传输的下一个通道,不用链式时建议设 0xF 表示不链 |
| bit15:20 | TREQ_SEL | 请求信号选择,内存到内存用 0x3f 永久请求 |
| bit24 | BUSY | 只读状态位,1 表示传输进行中,0 表示传输完成或未启动 |
汇编成十六进制太容易算错,我建议直接用移位来写,清晰又能一眼看懂:(1 << 0) | (2 << 2) | (1 << 4) | (1 << 5) | (0x3f << 15)。这在代码里就代表“使能 + 32位传输 + 源地址递增 + 目标地址递增 + 永久请求”。拿到这个值再对照上面的表看一遍,基本不会配错。
3. 实操:寄存器操作实现内存到内存高速拷贝
3.1 准备工作:缓冲区、地址和注意事项
实操前先想清楚我们要干什么:程序里准备一块源数据,然后让 DMA 把它原封不动复制到另一块目标内存里。为了后面验证方便,我会先把源缓冲区填上进 0 到 255 的循环序列,目标缓冲区全填 0,DMA 搬运完成后去读目标缓冲区的数据,跟源数据对比,如果完全一样就说明 DMA 正常工作。
代码里最关键的一步是获取bytearray的底层地址。MicroPython 里可以用uctypes.addressof()拿到对象的缓冲区起始地址,这是喂给 DMA 寄存器的核心参数。比如你创建了src = bytearray(4096),那么uctypes.addressof(src)返回的是一个整数,通常在0x20000000附近的 SRAM 区域内。这个地址就是 DMA 要读数据的源头。
有一点必须提前提醒:DMA 操作的是物理内存地址,不是 Python 对象句柄。你千万别想着把src这个变量直接赋值给 READ_ADDR 寄存器,那样 DMA 读到的就是对象的 Python 元数据而不是真正的数据。任何情况下传给 READ_ADDR 和 WRITE_ADDR 的都是uctypes.addressof()算出来的整型地址。
3.2 用uctypes映射寄存器,代码清晰可读
先上第一个版本,用uctypes把 CH0 寄存器区域映射成结构体。这个方式的好处是字段名清清楚楚,寄存器名和手册一一对应,出问题了也容易查:
import uctypes import time DMA_BASE = 0x50000000 CH0_LAYOUT = { "read_addr": uctypes.UINT32 | 0x00, "write_addr": uctypes.UINT32 | 0x04, "trans_count": uctypes.UINT32 | 0x08, "ctrl_trig": uctypes.UINT32 | 0x0c, "ctrl": uctypes.UINT32 | 0x10, } ch0 = uctypes.struct(DMA_BASE, CH0_LAYOUT) # 准备源缓冲区和目标缓冲区 N = 4096 src = bytearray(N) dst = bytearray(N) # 填充源数据:0,1,2...255,0,1,2...循环 for i in range(N): src[i] = i & 0xFF # 目标缓冲区先全部清零,方便后续对比 for i in range(N): dst[i] = 0 # 输入源地址和目标地址 src_addr = uctypes.addressof(src) dst_addr = uctypes.addressof(dst) print("src addr:", hex(src_addr)) print("dst addr:", hex(dst_addr)) # 配置 DMA 控制字:使能 + 32位传输 + 源递增 + 目标递增 + 永久请求 CTRL = (1 << 0) | (2 << 2) | (1 << 4) | (1 << 5) | (0x3F << 15) # 填入地址和传输长度 ch0.read_addr = src_addr ch0.write_addr = dst_addr ch0.trans_count = N // 4 # 32位传输,所以元素个数是字节数除以4 # 点火,写入CTRL_TRIG同时启动传输 ch0.ctrl_trig = CTRL # 轮询等待 BUSY 位清零 start = time.ticks_us() while (ch0.ctrl_trig >> 24) & 0x01: pass cost = time.ticks_diff(time.ticks_us(), start) print("DMA done, cost us:", cost) # 验证结果 match = True for i in range(N): if src[i] != dst[i]: match = False print("mismatch at", i) break if match: print("MATCH: DMA memcpy ok") else: print("ERROR: data mismatch")这段代码最核心的三行就是:
ch0.read_addr = src_addr ch0.write_addr = dst_addr ch0.trans_count = N // 4然后是最后的启动和轮询。这里trans_count的单位不是字节,而是你指定的数据元素个数。因为我们把 DATA_SIZE 设成了 32 位,也就是一个元素占 4 字节,所以 4096 字节的缓冲区要传4096 // 4 = 1024个元素。这个换算一定要算清楚,否则要么只搬了一部分数据,要么因为trans_count大于实际容量直接越界写坏内存。
轮询等待用的是CTRL_TRIG的 bit24,也就是 BUSY 位。它在传输过程中是 1,传输结束自动变 0。这套轮询方式对 mem-to-mem 这种又快又短的操作非常合适,没必要上中断,等它几十微秒就完事了。
3.3 用viper内联加速版本,运行效率更接近C
uctypes的好处是可读性好,但 MicroPython 毕竟是解释执行,结构体字段访问还是有一层开销。如果你想追求极限速度,可以用 MicroPython 的@micropython.viper装饰器。在 viper 模式里,ptr32可以直接当 C 语言指针用,按 32 位字长访问内存和寄存器,循环和位运算也会被编译成接近底层的机器码,执行效率会高很多。
import micropython import time @micropython.viper def dma_memcpy_word(src_ptr: uint, dst_ptr: uint, nwords: uint) -> uint: dma = ptr32(0x50000000) CTRL = (1 << 0) | (2 << 2) | (1 << 4) | (1 << 5) | (0x3F << 15) dma[0] = src_ptr # READ_ADDR dma[1] = dst_ptr # WRITE_ADDR dma[2] = nwords # TRANS_COUNT dma[3] = CTRL # CTRL_TRIG,写入即启动 while (dma[3] >> 24) & 1: pass return dma[2] # 返回剩余未传输元素数,正常应该是0 N = 4096 src = bytearray(N) dst = bytearray(N) for i in range(N): src[i] = i & 0xFF src_addr = uctypes.addressof(src) dst_addr = uctypes.addressof(dst) start = time.ticks_us() ret = dma_memcpy_word(src_addr, dst_addr, N // 4) cost = time.ticks_diff(time.ticks_us(), start) print("viper DMA done, ret:", ret, "cost us:", cost) match = True for i in range(N): if src[i] != dst[i]: match = False print("mismatch at", i) break print("MATCH" if match else "ERROR")注意 viper 版本里dma = ptr32(0x50000000)之后,dma[0]访问的是地址0x50000000,dma[1]是0x50000004,dma[2]是0x50000008,dma[3]是0x5000000C。这和寄存器偏移完全对应,比uctypes版本省去了许多中间操作,速度更快,写起来也更接近嵌入式 C 的感觉。不过 viper 模式对语法有限制,比如参数类型必须显式标注,不能随便用列表和对象方法,所以它更适合封装成一个小函数,把 DMA 操作藏在后面。
3.4 验证结果与简单测速
我在一块 RP2040 板子上分别跑了上面的uctypes版本和 viper 版本,复制的数据是 4096 字节。两个版本最终都打印出MATCH,说明数据搬运完全正确。测速方面,DMA 本身的执行时间非常短,我这块板上 4096 字节大概在几十微秒量级。具体数值会因为主频、总线负载、固件版本不同有浮动,但无论如何都比 MicroPython 用 for 循环逐个dst[i] = src[i]快一两个数量级。我之前用纯 Python 循环复制同样大小的数据,耗时经常到几毫秒,差异非常直观。
这里我强烈建议你拿到代码之后,自己加一个纯 Python 循环拷贝的对比测试,打印两边的耗时。眼见为实,只有亲眼看到 DMA 比 Python 循环快几十倍,你才会真正理解“数据搬运交给硬件”意味着什么。
4. 实战中最容易踩的坑,我替你趟了一遍
4.1 配置了DMA却不动,先查TREQ_SEL和地址
如果你运行代码发现while循环一直出不来,也就是 BUSY 位始终是 1,最常见的原因就是 TREQ_SEL 没配成0x3f。比如有人会把控制字写成(1 << 0) | (2 << 2) | (1 << 4) | (1 << 5),然后发现 DMA 彻底不动。因为没有外设 DREQ,DMA 通道一直在“等请求”,永远不会开始搬数据。这种问题从现象上特别迷惑,因为寄存器好像都填了,控制字也写进去了,但就是不工作。排查方法很简单,把控制字里的0x3F << 15加上,问题立刻消失。
另一个容易翻车的地方是把寄存器地址搞错,尤其是用 viper 的ptr32时容易把偏移当字节地址用。记住ptr32的下标是按 32 位字为单位的,dma[3]就是0x5000000C,不是0x50000003。如果你需要按字节偏移来访问,就改用ptr8,但那样每次要自己拼 4 个字节,没必要。
4.2 长度算错导致越界写,把堆都给干碎了
trans_count填的是“元素个数”,不是“字节数”,这是新手最容易忽视的坑。DMA 的 DATA_SIZE 是 32 位时,trans_count = 100表示搬运 100 个字,也就是 400 字节。如果你把N直接填进去,而目标缓冲区只有N / 4个字的大小,DMA 会一路写下去,越过目标bytearray的边界,直接写进后面堆内存里的其他数据。最直接的后果是另一个 Python 对象的数据被莫名其妙改掉,程序跑着跑着出现完全看不懂的异常。
我自己的习惯是在代码里写成N // 4,并且用变量名nwords明确语义,计算时心里始终记得“这是字数量”。另外建议目标缓冲区长度至少不小于源缓冲区长度,尽量用相同长度,省心。
4.3 缓冲区被GC回收导致诡异现象,MicroPython特性背锅
MicroPython 的 GC 是标记-清除、不移动对象,所以bytearray创建后地址不会变。但你要是把bytearray创建在一个函数里,函数返回后没有其他地方引用它,这个对象就可能被垃圾回收,缓冲区也会被后续的分配覆盖。如果 DMA 还在往那个地址写数据,轻则数据丢失,重则造成内存损坏。
我遇到过的场景是:先从一个函数里创建了dst = bytearray(4096),然后只在函数内部启动 DMA,函数一返回就把地址传给了某个全局变量,结果原本的对象已经被回收,DMA 往一块已被释放的内存里写数据。这种 bug 特别难查,因为程序不一定会立刻崩溃,只是偶尔数据不对。解决办法很简单:让源缓冲区和目标缓冲区在 DMA 传输期间保持有效的引用,通常把它们定义在函数外、全局作用域,或者至少在启动 DMA 和等待完成期间一直持有引用,不要提前释放。
4.4 常见问题快速排查表
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| DMA 完全不动,BUSY 一直为 1 | TREQ_SEL 没配成 0x3f,没有外设请求 | 控制字里加上(0x3F << 15) |
| 数据只传了一部分 | TRANS_COUNT 单位搞错,传了字节数而非元素数 | 32位传输时用字节数 // 4 |
| 程序跑到一半数据被改坏 | 目标缓冲区太小,DMA 越界写 | 确保 dst 长度不小于 src |
| 数据偶尔对、偶尔错 | bytearray 被 GC 回收,地址已失效 | 在传输期间保持对象引用 |
| 用 viper 版本地址不对 | ptr32 下标按字计算,偏移没乘 4 | 确认 dma[3] 对应 0x5000000C |
| MicroPython 打印 src 地址在很低位 | 可能是对象元数据而非数据缓冲区 | 必须用 uctypes.addressof() 取地址 |
这张表算不上什么高深理论,但都是我实际跑代码时真实碰到的场景。先把这排排查印在脑子里,后面玩 DMA 会省很多时间。
5. 从mem-to-mem出发,把DMA用到更实际的场景里
5.1 同样一套思路,搬到PIO/SPI/ADC上
理解了 mem-to-mem 之后,离真正的实战就差最后一个概念转换:把源地址或目标地址从“内存地址”换成“外设 FIFO 地址”。RP2040 的 DMA 是可以直接访问外设 FIFO 的。比如你想用 DMA 把内存里的灯效数据推给 PIO,让它去驱动 WS2812 灯带,那 DMA 的源地址是内存缓冲区,目标地址是 PIO 的 TX FIFO,触发源从“永久请求”改成 PIO 的 DREQ 编号。传输逻辑和 mem-to-mem 完全一样,只是控制字里的TREQ_SEL变了。
经常有人混淆“DMA 操作”和“驱动外设”,其实这是两件事。DMA 只负责按你给的地址和长度搬数据,至于数据到了外设之后怎么解释,那是外设的事。所以你把 mem-to-mem 这个基础打牢,后面接串口收发、SPI 屏、ADC 多通道采集,本质上都在倒腾同一组寄存器。像 STM32 那边很流行的“串口 DMA 空闲中断接收不定长数据”,RP2040 上思路是一样的,只是寄存器名和触发源不同,理解了原理就不容易被某个芯片的寄存器手册给绕晕。
5.2 链式DMA和IRQ,把数据搬运交给硬件
RP2040 的 DMA 还支持链式传输,也就是一个通道传完后自动拉起另一个通道。这个特性在驱动 LED 屏、音频播放这类场景里非常实用:你可以在内存里构建一张描述符表,把一个大的传输任务拆成好几段,DMA 自己会把它们按顺序串起来,CPU 完全不用介入。配合CHAIN_TO字段和多个通道的寄存器布局,这套玩法可以从单个 mem-to-mem 升级成复杂的数据流水线。
另外,如果你传输的数据量很大,不想让 CPU 空转等 BUSY 位清零,RP2040 的 DMA 还提供中断寄存器。DMA 传完可以触发 IRQ,在 MicroPython 里你也能通过注册中断回调的方式在传输完成时收到通知。不过 MicroPython 的处理函数难免有些开销,所以比较推荐的做法是:数据量小用轮询,数据量大或者对实时性要求高的时候考虑用中断和链式 DMA 组合,让 MicroPython 主线程去做更上层的事情。
5.3 MicroPython生态下的DMA学习路线
很多同学一上手就跑到各种固件论坛去搜现成代码,但我觉得第一个 DMA 程序千万别直接抄,自己动手配一遍寄存器才能建立起硬件直觉。学习路线可以这样走:先在 mem-to-mem 上跑通读写寄存器,把地址、长度、控制字的关系搞清楚;然后试着用 DMA 翻转一块内存的数据,拿time.ticks_us()测性能;接着找一个外设如 UART 或 PIO,把源或目标地址换成外设 FIFO,加上 DREQ,体验“外设请求驱动搬运”的完整流程;最后再挑战链式传输和中断。每一步都在前一步的寄存器基础上加一点东西,这样即便以后换芯片,你也能快速迁移。
顺带说一句,MicroPython 社区里关于 DMA 的资料确实比 C 语言少很多,但 RP2040 的数据手册写得很清楚,而且 DMA 寄存器并不复杂。你完全可以把 datasheet 的 DMA 章节当字典,把微处理器文档和数据手册对照着看。真到了要读某个外设的 DREQ 编号、确认 FIFO 地址时,谁的手上都得备着这两份文档。
我个人的实际体会是,在 MicroPython 里玩 DMA 最大的门槛不是硬件,也不是代码,而是“心态”。你一旦跨过“脚本语言不能碰底层”这个心理障碍,愿意静下心来看寄存器表、算位移、查 DREQ 编号,DMA 的全部秘密其实就那么几行配置。等你的 DMA 版本跑出比 Python 循环快几十倍的成绩时,那种成就感跟用 MicroPython 点灯完全不是一个量级。这套 mem-to-mem 的代码还可以继续往两个方向扩展:一个是去接更复杂的外设数据流,一个是把 viper 版本封装成可复用的库函数。下次再有人跟你说 MicroPython 不适合干底层,你可以把这段 DMA 代码丢给他看。