
用 MicroPython 写 RP2040 的程序最舒服的一点是不用管寄存器底层随手 import 一下GPIO、I2C、PWM 全都有了。但真到了要搬运大块内存、或者跟外设高频交换数据的时候光靠 Python 层循环拷贝就会明显感觉力不从心。这篇文章我要把 RP2040 的 DMA 从“知道有这个功能”讲到“能在 MicroPython 里跑通一次完整的内存到内存数据传输”整个过程不写 C 固件不加模块用 machine.mem32 直接操作 DMA 寄存器就能实现。适合刚接触 Pico 但想深入底层一点的玩家也适合那些用 MicroPython 做数据采集、图像帧缓存、双缓冲显示被拷贝耗时折磨的朋友。读完你不仅能在板子上复现这个实验还能顺手搞明白 DMA 通道、请求源、传输计数这些概念为后面玩串口 DMA 收书、ADC 连续采样、PWM 波形生成打好底子。1. 为什么要在 MicroPython 里折腾 DMA1.1 DMA 到底帮你省了什么DMA 的全称是 Direct Memory Access直译过来就是“直接内存访问”。很多教程喜欢把它叫“数据搬运工”这个类比其实挺准的。CPU 如果要拷贝一块数据得一条指令一条指令地循环每搬一个字节都要参与一次哪怕是用 C 语言写的 memcpy本质上也占着 CPU 的时间。而 DMA 控制器是一个独立于 CPU 的硬件模块它可以自己在内存和内存之间、内存和外设之间搬数据搬完再告诉你一声。在 MicroPython 这种解释型环境下问题更明显。Python 的循环语句是需要逐行解释执行的一个简单的字节拷贝循环运行起来比 C 慢几个数量级。有人会说可以用dst[:] src这种切片拷贝它确实快得多因为底层是 C 实现但它的场景有限你要把数据搬到外设 FIFO、要实时处理串口不定长帧、要按固定速率触发采样光靠切片就做不到了。这时候 DMA 的价值就体现出来了数据搬运不占 CPU传输速率由硬件时钟和总线带宽决定而且可以设定触发源和链式通道实现很多纯软件很难做到的花活。1.2 RP2040 DMA 控制器的“家底”RP2040 内置了 12 个 DMA 通道每个通道都有自己独立的寄存器组可以单独配置读地址、写地址、传输字节数和控制字段。通道之间还能通过 chain 机制串联一个通道传输完成可以自动触发另一个通道启动这个特性做 ping-pong 缓冲非常方便后面我会专门聊。DMA 可以访问整个内存映射空间包括 SRAM、外设寄存器、XIP flash 映射区等也就是说它不仅能内存到内存还能内存到外设、外设到内存。每种传输方向的本质差别主要在于“源地址/目标地址是否递增”和“用什么事件作为传输请求”。控制器的基地址是0x50000000每个通道占 0x40 字节空间通道 n 的关键寄存器偏移大概是READ_ADDR 0x00、WRITE_ADDR 0x04、TRANS_COUNT 0x08、CTRL_TRIG 0x0c。你并不需要把所有寄存器都背下来只要知道这几个就足够跑通第一个实验了。毕竟 MicroPython 不是 C我们要做的就是把正确的值塞进正确的寄存器然后等它出结果。1.3 选内存到内存作为第一个实验为什么第一个实验不做“内存到外设”或者“外设到内存”因为内存到内存是最干净、最可预期的场景。不涉及外设 DREQ 的时序不涉及 FIFO 水位不涉及 PIO 状态机你只需要把源地址、目标地址、传输长度、控制字段配好一次性触发数据就搬过去了。搬完可以用 Python 遍历两个数组逐一比对结果直观排查也容易。等你把这条链路跑通了理解了寄存器字段的意思再去碰串口 DMA 收不定长数据就只剩下“选哪个 DREQ 源、数据从哪个寄存器读”的问题了。2. 准备工作环境、地址和寄存器读取2.1 硬件固件准备硬件方面一棵树莓派 Pico 就行不需要外接任何东西。主控 RP2040 内置 264KB SRAM足够放实验用的缓冲区。固件方面建议刷官方最新的 MicroPython UF2至少在 1.19 版本以上后面代码里用到的方法都是标准功能。开发环境我习惯用 Thonny它自带的 Shell 窗口非常适合这种“改两行代码立刻跑一下”的调试节奏。如果你习惯用命令行装好 mpremote 也可以只是交互体验稍微折腾一点。注意一点MicroPython 固件版本不同内存堆布局和底层对象结构可能会有差异。下面代码里用到的array对象的buffer_info()方法是各个版本都稳定的接口放心用。2.2 用 machine.mem32 触碰寄存器MicroPython 虽然没有直接提供 DMA 模块但它留了一扇后门machine.mem32。这是一个 32 位内存读写对象你可以像操作字典一样读写任意物理地址from machine import mem32 # 写 mem32[0x50000000] 0x12345678 # 读 value mem32[0x50000000]这段代码的本质是利用 Python 的__getitem__和__setitem__方法最终调用了底层 C 实现的地址读写函数。由于 ARM Cortex-M0 没有 MMU这个地址就是真实的物理地址所以你可以直接访问 RP2040 的寄存器和 SRAM。这一点非常重要后面我们拿到数组的缓冲区地址后也可以直接用 mem32 去读那个地址的内容来验证数据。不过 mem32 没有越界保护你写错地址可能直接触发 HardFault 让机器重启。操作时务必小心最好先用一个变量把寄存器地址定义清楚避免到处写魔法数。2.3 拿到数组的真实内存地址MicroPython 的array.array对象在底层维护了一块连续的内存缓冲区这个缓冲区的首地址可以通过buffer_info()拿到import array src array.array(I, range(64)) dst array.array(I, [0] * 64) src_addr src.buffer_info()[0] dst_addr dst.buffer_info()[0] print(hex(src_addr), hex(dst_addr))这里有个容易踩的坑不能用uctypes.addressof(src)去拿数组的 buffer 地址。addressof拿到的通常是 Python 对象本身在堆上的地址而 DMA 需要的是数组数据缓冲区的地址两者不是一回事。用buffer_info()[0]是最稳妥的。另外array(I, [0] * 64)这种方式分配缓冲区时要注意[0] * 64会先创建一个 Python list再转成 array如果列表很大GC 压力会比较高。更高效的做法是dst array.array(I, [0]) * 64这样直接让 array 对象自己创建 64 个元素省掉中间 list。这批地址拿到之后只要对应的 array 对象还活着地址就是稳定的因为 MicroPython 的 GC 是标记-清除算法不会移动对象。3. 核心细节解析DMA 通道配置逐项拆解3.1 四个必配寄存器读写地址、传输计数、控制触发通道寄存器的配置顺序理论上没有硬性要求但实际写代码时我习惯按这个顺序先写读地址再写写地址再写传输计数最后写控制触发寄存器。因为CTRL_TRIG一旦写入如果 EN 位置 1DMA 通道会立刻开始干活所以它必须是最后一个“点火”的寄存器。假如你先写了 CTRL_TRIG再去改地址DMA 可能已经把碰巧是旧地址的内容搬了一轮了行为就会变得难以捉摸。读地址寄存器源数据首地址写地址寄存器目标首地址传输计数寄存器待传输的字节数控制触发寄存器传输方向、数据宽度、地址递增方式、请求源等这四个寄存器配合起来就是 DMA 的完整一次传输描述。RP2040 每个通道还有 AL1/AL2/AL3 一组“别名”寄存器可以通过写别名地址来修改读地址、写地址和传输计数而不影响控制寄存器这个特性在链式传输里非常有用但第一个实验用不上。3.2 CTRL_TRIG 关键字段怎么填控制触发寄存器是整个 DMA 配置里最需要理解的部分。字段很多我建议先关注这几个EN使能位置 1 表示通道启动DATA_SIZE数据宽度0 表示 byte1 表示 halfword2 表示 wordINCR_WRITE写地址是否递增置 1 则每次传输后写地址增加一个数据宽度INCR_READ读地址是否递增TREQ_SEL传输请求源选择。内存到内存场景下填入 0x3F表示“永久请求”意思是只要通道使能就立刻开始连续传输不需要等待外设信号。这几个字段的组合决定了数据搬运的方式。比如你只想把一块数据写到固定地址比如循环写同一个外设寄存器就应该把 INCR_WRITE 置 0如果你在做内存到内存拷贝读地址和写地址都必须递增所以 INCR_READ 和 INCR_WRITE 都要置 1。数据宽度建议用 word也就是 32 位一次因为 RP2040 内部总线和 SRAM 都是 32 位架构word 宽度传输效率最高而且源地址和目标地址如果都按 4 字节对齐不会碰到 unaligned access 的额外开销。具体编码每一位的位置不同数据手册版本画法略有差异拿到板子后对照 RP2040 Datasheet 第四章 DMA 章节确认一下最稳妥。我在实验里使用的配置模式是把 EN、DATA_SIZEword、INCR_READ、INCR_WRITE、TREQ_SEL0x3F 一起写进 CTRL_TRIG。3.3 等待完成与错误状态检查DMA 触发之后你没法像调用普通函数那样直接拿到返回值。RP2040 的每个通道CTRL_TRIG寄存器里有一个 BUSY 位表示通道是否仍在忙碌。内存到内存的搬运非常快可能你刚写完 CTRL_TRIG还没来得及轮询就完成了但代码上我们还是要加上等待逻辑BUSY_MASK 1 24 while mem32[CH0_CTRL_TRIG] BUSY_MASK: pass这个循环在 MicroPython 下是很轻量的因为它只是反复读取一个寄存器值没有 Python 对象分配。等 BUSY 位清零传输就完成了。更严谨的做法是在等待结束之后再去读READ_ERROR和WRITE_ERROR位如果发现置位说明传输过程中发生了非法地址访问这通常意味着你的缓冲区地址有问题。错误位会锁存需要手动清除后重试。3.4 缓冲区对齐、数据宽度和字节序缓冲区对齐是个容易被新手忽略的问题。RP2040 DMA 在 word 宽度下如果源地址或目标地址没按 4 字节对齐硬件可能会行为异常或者触发内部错误。MicroPython 的array.array在堆上分配时内存地址一般情况下是自然对齐的但也需要注意大数组分配时 GC 是否插入了什么偏移。保险起见你可以在触发前打印缓冲区的地址检查是不是 0、4、8、c 这样的结尾。如果不是可以改用 byte 宽度或者把数据宽度降为 halfword但性能会有一些损失。字节序方面RP2040 是 little-endianMicroPython 的array(I)存的也是 little-endian所以两者天然一致不需要额外处理。只要你的数据本身不是跨大小端环境传输就不用关心这个问题。4. 实操过程内存到内存拷贝的完整实现4.1 准备源缓冲区和目标缓冲区实验目标很简单把一块源数据通过 DMA 搬到另一个空缓冲区然后逐个元素校验。我先创建两个array(I)一个填充递增序列一个全填零import array N 1024 src array.array(I, range(N)) dst array.array(I, [0]) * Narray(I)的每个元素是 4 字节无符号整数所以N1024时缓冲区总大小是 4096 字节。这种规模对 MicroPython 的堆来说是小事但已经足够看出 DMA 和循环拷贝的差距。如果你希望更接近真实使用场景可以把 N 改成 8192 甚至 16384只要 Pico 内存还够就行。要注意的是如果你的程序里同时跑着 WiFi、一些 GUI 或者别的对象内存碎片可能导致大块连续 RAM 分配失败所以实验时尽量保持环境干净。4.2 配置 DMA 通道并触发一次传输接下来把寄存器地址定义出来。我直接用通道 0因为它是默认可用且不会被其他外设占用的from machine import mem32 DMA_BASE 0x50000000 CH0_READ_ADDR DMA_BASE 0x000 CH0_WRITE_ADDR DMA_BASE 0x004 CH0_TRANS_COUNT DMA_BASE 0x008 CH0_CTRL_TRIG DMA_BASE 0x00c拿到源和目标缓冲区的地址后按前面说的顺序依次写入src_addr src.buffer_info()[0] dst_addr dst.buffer_info()[0] mem32[CH0_READ_ADDR] src_addr mem32[CH0_WRITE_ADDR] dst_addr mem32[CH0_TRANS_COUNT] N * 4这里N * 4是因为TRANS_COUNT寄存器以字节为单位即使我们是 word 宽度传输计数也要写入总字节数。最后一个关键动作是配置控制并触发# EN 1 # DATA_SIZE word (0b10) # INCR_READ 1 # INCR_WRITE 1 # TREQ_SEL 0x3F 永久请求 CTRL (1 0) | (2 1) | (1 4) | (1 5) | (0x3F 10) mem32[CH0_CTRL_TRIG] CTRL写过 CTRL_TRIG 的一瞬间DMA 通道就开始搬运了。因为你用的是永久请求所以它不会等任何外设信号直接以最快速度把数据从src_addr搬到dst_addr。4.3 轮询完成、校验数据、对比性能传输开始后我们在 BUSY 位上自旋等待。等它清零就说明搬运完成while mem32[CH0_CTRL_TRIG] (1 24): pass然后验证dst的内容是否和src一致ok True for i in range(N): if src[i] ! dst[i]: ok False break print(DMA copy:, OK if ok else FAILED)我强烈建议你保留这个校验步骤。别看它只是跑一遍 Python 循环它的作用不只是验证 DMA 是否正常工作还能帮你确认buffer_info()拿到的地址确实是数据区而不是对象头。如果你发现dst全是零但程序没报错大概率是地址拿错了DMA 把数据搬到了一个你看不见的地方。性能对比的话array的切片拷贝速度快到几乎可以忽略DMA 的主要开销在寄存器配置代码本身。但如果你把同样大小的数据用普通 Python 循环从头到尾复制一遍再对比 DMA 的耗时差距会非常直观。想测时间的话time.ticks_us()包住两段代码分别统计就行。注意 MicroPython 的响应时间本身有抖动这个对比只适合看量级不用抠小数点。4.4 完整可运行示例代码把上面的步骤拼到一起就是一个完整脚本import array from machine import mem32 DMA_BASE 0x50000000 CH0_READ_ADDR DMA_BASE 0x000 CH0_WRITE_ADDR DMA_BASE 0x004 CH0_TRANS_COUNT DMA_BASE 0x008 CH0_CTRL_TRIG DMA_BASE 0x00c N 1024 src array.array(I, range(N)) dst array.array(I, [0]) * N src_addr src.buffer_info()[0] dst_addr dst.buffer_info()[0] print(src:, hex(src_addr), dst:, hex(dst_addr)) mem32[CH0_READ_ADDR] src_addr mem32[CH0_WRITE_ADDR] dst_addr mem32[CH0_TRANS_COUNT] N * 4 CTRL (1 0) | (2 1) | (1 4) | (1 5) | (0x3F 10) mem32[CH0_CTRL_TRIG] CTRL while mem32[CH0_CTRL_TRIG] (1 24): pass ok True for i in range(N): if src[i] ! dst[i]: ok False break print(DMA copy:, OK if ok else FAILED)这个脚本在 Thonny 里直接运行丝滑顺畅。打印两行结果第一行是两个缓冲区的地址第二行是你的校验结果。看到OK的那一瞬间你对 DMA 的陌生感基本上就消除一大半了。5. 常见问题与排查技巧实录5.1 数据没变化问题多半出在地址或寄存器顺序如果你跑完上面的脚本dst里还是全零先别怀疑硬件大概率是CTRL_TRIG里的 EN 位没生效或者地址写错了。我调试时习惯先打印src_addr和dst_addr观察是不是0x20000000开头的 SRAM 区域。如果打印出来的地址长得奇怪比如是0x1f...这种小数字那基本就是buffer_info()[0]用错了对象或者 buffer 被 GC 处理了。另一个常见原因是寄存器写入顺序CYWR 如果先写 CTRL_TRIG 再写地址DMA 可能已经按默认值完成了一次传输那后续写的地址就只对下一次传输生效而你已经进入等待完成状态了。5.2 设备重启或死机通常是访问了非法地址mem32 的寄存器操作没有保护机制如果你把读地址或写地址写到了一个不存在的内存区域DMA 在搬运时就会触发总线错误严重时直接 HardFault 重启。这个问题的排查方法是缩小缓冲区规模先试N16确认稳定后再慢慢加大。另外array对象分配出来后不要对其做任何可能导致对象销毁的操作比如重新赋值给src否则旧缓冲区会被 GC 回收而 DMA 拿着的地址就成了悬空指针什么时候爆随机性很强。5.3 传输完成后数据对不上检查数据宽度和对齐如果校验发现前面几个元素对后面越来越乱那多半是数据宽度配置错了。比如你配了 word 宽度但传输计数写的是元素个数而不是字节数DMA 只会搬运数据总数的一部分后面自然全是零。还有一种情况是你把源和目标缓冲区定义成了不同数据类型的 array比如源是array(I)目标是array(B)那 DMA 按 word 宽度搬运时目标地址的递增步长就错了。保持两个 array 类型一致是最省心的做法。地址对齐方面如果打印出来源地址不是 4 的倍数建议把array(I)换成array(B)并把 DATA_SIZE 改为 byte 模式宁可慢一点也不要赌 unaligned 行为。5.4 缓冲区被 GC 移动了吗其实不会很多从 C 转过来的朋友会有这个疑虑MicroPython 运行过程中GC 会不会把我的 array 移到别的地方去这一点可以放心MicroPython 的 GC 实现是标记-清除mark-sweep算法不移动对象所以 array 一旦分配好它的缓冲区地址在整个生命周期内都是固定的。真正需要小心的不是 GC 移动而是对象被释放。比如你在函数里创建了 src 数组DMA 触发之后函数提前 return 了src 引用计数归零缓冲区被回收DMA 后面的传输就会写到一块已经释放的内存上。解决办法很简单确保 DMA 传输期间相关的 array 对象一直有一个变量指着它。5.5 常见问题速查表现象可能原因排查/解决办法校验结果 FAILED地址错误、数据宽度或计数不对打印地址检查buffer_info()核对 TRANS_COUNT 字节数Pico 反复重启非法地址触发总线错误缩小缓冲区确认地址落在 SRAM 范围DMA 传输后 dst 全零EN 位没置位或触发前配置顺序不对检查 CTRL_TRIG 写入值确认先地址后触发程序卡在 while 等待BUSY 位一直为 1可能地址异常导致无法完成打印 CTRL_TRIG 值看错误位状态大数组分配失败内存碎片或堆空间不足减小数组、重启解释器、释放其他大对象6. DMA 还能这样扩展6.1 串口 DMA 接收不定长数据跑通内存到内存之后最自然的下一步就是串口 DMA。思路是把 UART 的 RX FIFO 映射成 DMA 的读地址然后利用空闲中断判断一帧数据结束。因为 MicroPython 层没有直接暴露 UART 的 DMA 请求接口很多做法是先把 UART 寄存器直接操作起来再用 DMA 配合接收。这种做法在 RP2040 上完全可行只是配置要比内存到内存复杂一些核心区别在于 TREQ_SEL 要选择 UART RX 对应的 DREQ 号不要用永久请求。你可以先用本文的办法把 DMA 搬运目标指向一块环形缓冲区再配合 Python 层检查缓冲区内容就能实现一个基础的 DMA 串口接收框架。6.2 定时器/PWM DMA 生成连续波形另一个很经典的应用是 PWM 加 DMA。你可以把一组预先算好的占空比值放在内存里然后用 DMA 定时地把这些值写入 PWM 的通道比较寄存器这样就能生成任意形状的波形比如正弦波、锯齿波、甚至自定义时序。这里用的就是内存到外设方向的传输目标地址写的是 PWM 控制寄存器地址且 INCR_WRITE 要置 0因为每次写入的都是同一个寄存器。和本文的内存拷贝相比核心差异就两点TREQ_SEL 选择定时器或 PWM 的请求源写地址不递增。理解了这一点你就能从“拷贝数据”升级到“用数据驱动外设”。6.3 多通道链式与 ping-pong 缓冲DMA 的链式功能是它最强大的地方。你可以让通道 0 拷贝一块数据到显示缓冲区完成后自动触发通道 1 去搬下一块数据同时通道 0 又可以去填第二块缓冲区实现双缓冲交替。这在做 OLED 刷新、音频播放、连续 ADC 采样时非常有用能有效避免数据断流。链式配置用到的就是各个通道的 AL1/AL2/AL3 别名寄存器和 CTL_TRIG 里的 CHAIN_TO 字段事件流完全由硬件控制软件只需要在主循环里检查各个通道的完成标志。我在实际使用中最深的体会是DMA 在 MicroPython 里能玩的花样完全不比 C 少关键是把寄存器基础打牢。很多人一听到“寄存器操作”就被吓退其实在 Pico 上也就是 mem32 读写几个地址而已。你花十分钟把这个内存拷贝实验跑通了后面所有以 DMA 为核心的需求都会变得顺理成章。另外建议你把这个实验的校验环节保留下来之后每次改配置、改通道、换缓冲区类型都先跑一遍自检确认数据一致再去接外设能帮你省掉大量排查时间。RP2040 的数据手册第四章内容不算长抽个下午翻一遍配合这篇文章的视角再去理解你会发现自己对这块芯片的控制能力上了一个台阶。