MicroPython操作DMA:RP2040寄存器级内存拷贝实战指南

📅 发布时间:2026/9/9 0:48:18
MicroPython操作DMA:RP2040寄存器级内存拷贝实战指南 先说一句大实话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 控制器的寄存器基地址是0x50000000SRAM 起始地址是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_ADDR0x00源数据起始地址DMA 从这里读数据WRITE_ADDR0x04目标地址DMA 把数据写到这里TRANS_COUNT0x08要传输的数据元素个数和下面 DATA_SIZE 配合使用CTRL_TRIG0x0C控制字寄存器写入这个寄存器的同时会触发传输开始CTRL0x10控制寄存器和 CTRL_TRIG 布局一样但不会触发启动注意CTRL_TRIG和CTRL的区别。虽然它们的位布局完全一样但CTRL_TRIG是你把整个配置准备好后最后一下“点火”的开关写入即启动传输CTRL则是单独配置用的不会触发启动。如果你先写CTRL配置好所有参数之后再写CTRL_TRIG也可以但通常我们图省事会把所有配置一次性写进CTRL_TRIG。2.2 内存到内存传输的触发逻辑TREQ_SEL0x3fDMA 传输必须要有一个“请求信号”来推动这个信号就是 DREQData Request。不同的外设对应不同的 DREQ 编号比如 UART 发送有对应的 DREQSPI 收发有对应的 DREQPIO 也有对应的 DREQ。只有外设提出请求DMA 才搬一个数据单元。这种机制保证了 DMA 不会搬太快把 FIFO 搞乱。但内存到内存传输不依赖任何外设没有谁会给 DMA 发 DREQ。那怎么办RP2040 的 DMA 控制器专门留了一个特殊值TREQ_SEL 0x3f代表“请求信号永远有效”也就是只要通道使能了DMA 就会全速搬运不需要等待任何外设信号。这是我这次配置里最重要的一个值很多人搞内存到内存时卡住不动十有八九就是这里没配对。我把0x3f写进去之后DMA 就像被接通了电源一样一波数据直接搬完。2.3 控制字的位域拆解照着配就行接下来是控制字CTRL_TRIG里各个关键位的含义不用全背把常用的几个搞清楚就够用了位域名称作用与建议值bit0EN通道使能必须为 1bit1HIGH_PRIORITY高优先级内存拷贝一般不需要设 0bit2:3DATA_SIZE传输粒度0字节1半字(16bit)2字(32bit)。内存拷贝建议用 2bit4INCR_READ源地址是否递增1每传一个元素源地址加对应字节数bit5INCR_WRITE目标地址是否递增1每传一个元素目标地址加对应字节数bit6:10RING_SIZE/RING_SEL环形缓冲区相关设置本例不用设 0bit11:14CHAIN_TO链式传输的下一个通道不用链式时建议设 0xF 表示不链bit15:20TREQ_SEL请求信号选择内存到内存用 0x3f 永久请求bit24BUSY只读状态位1 表示传输进行中0 表示传输完成或未启动汇编成十六进制太容易算错我建议直接用移位来写清晰又能一眼看懂(1 0) | (2 2) | (1 4) | (1 5) | (0x3f 15)。这在代码里就代表“使能 32位传输 源地址递增 目标地址递增 永久请求”。拿到这个值再对照上面的表看一遍基本不会配错。3. 实操寄存器操作实现内存到内存高速拷贝3.1 准备工作缓冲区、地址和注意事项实操前先想清楚我们要干什么程序里准备一块源数据然后让 DMA 把它原封不动复制到另一块目标内存里。为了后面验证方便我会先把源缓冲区填上进 0 到 255 的循环序列目标缓冲区全填 0DMA 搬运完成后去读目标缓冲区的数据跟源数据对比如果完全一样就说明 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内联加速版本运行效率更接近Cuctypes的好处是可读性好但 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]访问的是地址0x50000000dma[1]是0x50000004dma[2]是0x50000008dma[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 彻底不动。因为没有外设 DREQDMA 通道一直在“等请求”永远不会开始搬数据。这种问题从现象上特别迷惑因为寄存器好像都填了控制字也写进去了但就是不工作。排查方法很简单把控制字里的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 一直为 1TREQ_SEL 没配成 0x3f没有外设请求控制字里加上(0x3F 15)数据只传了一部分TRANS_COUNT 单位搞错传了字节数而非元素数32位传输时用字节数 // 4程序跑到一半数据被改坏目标缓冲区太小DMA 越界写确保 dst 长度不小于 src数据偶尔对、偶尔错bytearray 被 GC 回收地址已失效在传输期间保持对象引用用 viper 版本地址不对ptr32 下标按字计算偏移没乘 4确认 dma[3] 对应 0x5000000CMicroPython 打印 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 代码丢给他看。