
写这篇教程之前我先说个真实感受很多玩 RP2040 的朋友一听到“DMA”就觉得是 C 语言、寄存器、甚至 SDK 的专利MicroPython 这种解析执行的环境似乎不应该碰这种底层东西。但 RP2040 这颗芯片的特殊之处在于它的 MicroPython 固件从一开始就把 DMA 能力开放出来了而且官方维护得很积极。你完全可以用几行 Python 代码把一块内存里的数据搬运到另一块内存整个过程 CPU 不参与数据由 DMA 控制器自动处理。这篇文章就是以“内存到内存数据传输”这个最基础、也最能看清 DMA 工作逻辑的场景为入口带你把 RP2040 的 DMA 从概念到实操完整走一遍。这个需求听起来简单价值却不小。Framebuffer 的快速滚动、音频波形数据合成、传感器采样结果的批量搬移、甚至做小型图像处理都可能用到“内存到内存”的搬运。在 MicroPython 里如果靠 for 循环逐字节复制256 字节倒还好一旦到了几 KB 甚至几十 KB那个耗时是你肉眼能感受到的卡顿。用 DMA 之后搬运动作在后台完成Python 层该干嘛干嘛。适合谁来读我觉得两类人最合适一是刚接触 RP2040、想看看 MicroPython 到底能碰多底层的新手二是已经用 MicroPython 写了点小项目、发现 CPU 被数据搬运拖累的老手。保证不依赖 C 和 SDK只需要一块 RP2040 开发板以及最新版官方 MicroPython 固件。1. 为什么要在 RP2040 上做内存到内存 DMA1.1 内存到内存传输到底解决了什么问题“内存到内存”这个说法翻译成大白话就是把 SRAM 里的一个连续区域整体复制到 SRAM 里的另一个连续区域。可能很多人第一反应是这有什么用Python 里一个切片赋值不就完了吗比如dst[:] src[:]。在桌面上这确实轻松但在单片机上这句话背后是 CPU 在一条一条指令地搬运。MicroPython 作为解释型语言每执行一个索引访问、每次给字节赋值中间都夹着大量解释器开销几个 MB 的数据复制会让你以为程序死机了。DMA 的方案是让数据搬运脱离 CPU。你给 DMA 控制器三个关键信息从哪读、写到哪、写多少个。然后启动它剩下的搬运由硬件完成。搬完再告诉你一声。这样一来CPU 在那段时间里可以去刷新屏幕、处理按键、跑算法逻辑这在实时性要求高一点的项目里就是质的区别。你要理解的是DMA 不是让“搬运这个动作”变快了多少而是让“CPU 被搬运占用”这件事变成了“CPU 不用管搬运”。所以做内存到内存传输本质是在优化 CPU 的利用率。1.2 RP2040 的 DMA 控制器资源速览RP2040 里的 DMA 控制器一共有 12 个独立通道编号 0 到 11。每个通道都有自己独立的读地址寄存器、写地址寄存器、传输计数寄存器、控制寄存器。通道之间默认互不干扰可以同时跑。这个资源和 STM32 上那种“内存到外设、外设到内存、内存到内存”三选一的工作模式不太一样RP2040 的 DMA 更粗暴任意来源、任意目标、只要源和目标地址对 CPU 可访问就能搬。每个通道支持 8 位、16 位、32 位三种数据宽度分别用 0、1、2 表示。数据宽度决定了“一次硬件搬运操作”搬几个字节也决定了地址自增的步长。传输计数是一个 20 位的值最大能数到 1048575。这还没完RP2040 的通道支持链式连接也就是一个通道搬完硬件自动触发下一个通道这条链最长可以把 12 个通道串起来。这些特性叠加起来让这颗芯片的 DMA 可玩性非常高。1.3 MicroPython 里能不能碰 DMA固件版本与前提先说结论能碰而且官方一直在维护。RP2040 的 MicroPython 端口在比较早的版本里就加入了rp2模块里面有PIO、StateMachine、Flash这些类。后来官方又往rp2里加入了DMA类用来操作 DMA 控制器。我不太建议用太老的固件因为早期固件里模块 API 不稳定。建议直接去 micropython.org 下载针对 Raspberry Pi Pico 的最新版固件至少在 1.20 以上比较稳妥。你可以在板子上跑import os; print(os.uname())确认版本。硬件方面任何 RP2040 芯片的板子都可以不限品牌Pico、Pico W或者各种合宙、微雪的核心板都行。不需要额外元件纯开发板就可以把本教程的实验做完。程序运行在 MicroPython 的 REPL 环境里你可以先用交互模式快速试确认没问题了再写进main.py。这个教程的所有示例都按“在 REPL 跑也能验证结果”的方式设计。2. 核心概念DMA 通道、触发源和寄存器2.1 一条 DMA 传输的最小三要素一条最简单的 DMA 传输只需要三样东西源地址、目标地址、传输次数。你可以把它们想象成一场运货任务源地址是仓库目标地址是工地传输次数是车要跑几趟。源地址对应 DMA 通道的READ_ADDR寄存器目标地址对应WRITE_ADDR寄存器传输次数对应TRANS_COUNT寄存器。但有个细节和“运货”不一样。普通的仓库取货你从第 0 个货架取完第二次应该从第 1 个货架取这叫源地址自动递增。DMA 不会默认帮你递增。如果不开递增它永远从同一个地址读、写同一个地址。所以CTRL_TRIG寄存器里专门有READ_INCR和WRITE_INCR两个位分别控制读地址和写地址是否在每传输完一个数据单元后自动加一步。做内存到内存搬运时这两个位通常都要置 1否则你就会看到“数据始终是第一个字节重复写满目标区”的诡异现象。2.2 内存到内存传输的触发源 DREQ_FORCEDMA 本质上是一个可以独立运行状态机但它需要一个“开始”信号这个信号叫触发源对应寄存器里的TRIG_SEL字段。RP2040 的触发源非常多UART 的发送请求、ADC 的采样完成、PWM 的周期翻转、PIO 的 RX/TX甚至定时器中断都能触发 DMA。但对于“内存到内存”这种场景我们不需要等任何外设只希望 DMA 拿到 CPU 配置之后立刻开干。这时候就要用TRIG_SEL 0x3F。这个值在官方文档里被称为DREQ_FORCE含义是忽略外设信号由软件直接启动且每次传输都无条件继续。凡是做纯内存搬运触发源都用这个。很多初学者在抄代码时看到trigger0x3F不明所以直接把这段代码抄到外设场景里结果发现 DMA 根本不听外设节拍。记住0x3F 是强推模式只适合不需要同步的搬运任务。2.3 MicroPython 的 rp2.DMA 封装和它背后的寄存器rp2.DMA类可以让你用 Python 对象去管理 DMA 通道底层封装的内容其实就是那一组寄存器。它的核心方法是config()用来设置读地址、写地址、计数、数据宽度、触发源然后通过active(1)启动传输wait()等待传输完成active(0)关闭通道close()释放通道。硬核一点看底层DMA 控制器的基地址是0x50000000每个通道占 0x40 字节的寄存器空间。以通道 0 为例偏移 0x00 是读地址READ_ADDR偏移 0x04 是写地址WRITE_ADDR偏移 0x08 是传输计数TRANS_COUNT偏移 0x0C 是控制寄存器兼触发寄存器CTRL_TRIG。通道 1 就在基地址加上 1 乘以 0x40也就是从 0x50000040 开始。用 Python 操作时只需要machine.mem32[地址] 值就能读能写非常直接。我建议你刚开始先用rp2.DMA类把程序跑通毕竟它代码量小、不容易出错。但如果后面想调一些特殊功能比如链式传输、字节交换、自定义触发源直接操作寄存器会更自由因为你看到的每一个位都是硬件文档里白纸黑字的定义。2.4 关键约束数据宽度、地址对齐和计数上限DMA 最怕的就是“没对齐”。数据宽度设为 2 代表 32 位传输每次搬运 4 字节。这种情况下源地址和目标地址必须是 4 的倍数否则硬件会直接抛错。同样的道理16 位传输要求 2 字节对齐8 位传输没有对齐要求。新手拿一个bytearray去搬因为 bytearray 的数据区通常是 4 字节对齐的所以 8 位、16 位、32 位都能跑但如果你从bytearray中间某个偏移位置开始搬比如memoryview切到偏移 1 处再配合 32 位传输就很容易踩到对齐问题。传输计数上限 20 位也就是最多 1048575 次。假设你用 32 位宽度一次 DMA 最多能搬 4194300 字节接近 4 MB。看着挺大但如果你的数据是连续大块超过这个数就必须手动拆分成多次传输。注意这个上限是指“传输次数”不是“字节数”具体多少字节还要乘上数据宽度。这块搞不清楚的话后面做大文件搬运很容易莫名其妙只搬了一部分。3. 保姆级实操第一个内存到内存 DMA 程序3.1 工程准备与固件检查先把开发板连接到电脑打开 Thonny 或者你习惯用的串口终端进入 REPL。先确认固件没问题输入下面这两行import os, sys print(os.uname())应该能看到类似(versionv1.23.0, machine Raspberry Pi Pico with RP2040)这样的输出。如果版本特别老建议先升级固件。接着检查rp2.DMA是否存在from rp2 import DMA help(DMA)能看到类的文档就说明固件支持。如果报错说明你用的固件没有编译这个类解决办法是刷最新官方固件。我不鼓励为了 DMA 去用第三方魔改固件官方维护的路线在稳定性和文档匹配度上都会好很多。3.2 代码一用 rp2.DMA 完成 256 字节搬运下面这段是完整可跑的第一个程序。它的作用是把源数组src里的 256 字节复制到全零的dst数组里。from rp2 import DMA SRC_SIZE 256 src bytearray(SRC_SIZE) dst bytearray(SRC_SIZE) for i in range(SRC_SIZE): src[i] (i * 7 1) 0xFF dma DMA() dma.config( readsrc, writedst, countSRC_SIZE, data_size0, # 0 代表 8 位宽度 trigger0x3F, # DREQ_FORCE强制立即传输 ) dma.active(1) dma.wait() dma.active(0) dma.close() print(match:, src dst) print(dst first 8 bytes:, list(dst[:8]))把这个代码粘贴进 REPL正常情况下输出会是这样match: True dst first 8 bytes: [1, 8, 15, 22, 29, 36, 43, 50]如果看到match: True恭喜第一次内存到内存 DMA 已经跑通了。整个过程没有 C 代码没有编译没有刷自定义固件就是 MicroPython 环境里用官方类完成的。3.3 逐行拆解这段代码在干什么src bytearray(SRC_SIZE)创建了一个 256 字节的可写缓冲区dst同理初始全是 0x00。这里我建议用bytearray不要用bytes因为bytes是不可变对象部分固件在处理bytes传地址时行为不一致bytearray是官方测试最充分的缓冲区类型。dma.config()是核心配置。readsrc指定源对象固件内部会从src取出它真正存储数据的地址这个地址就是 DMA 要读取的READ_ADDR。writedst同理。countSRC_SIZE表示传输 256 次配合data_size0每次 8 位正好是 256 字节。trigger0x3F是内存到内存的标准触发源。注意dma.active(1)这句它不仅是“打开通道”更重要的作用是真正让 DMA 跑起来。wait()会一直阻塞直到传输完成。如果你不希望程序卡在这里后面可以换轮询或者中断的方式。传输完成后用active(0)关闭通道用close()把通道资源还给系统。如果不调close()一直创建 DMA 对象的话等 12 个通道用完新的DMA()就会分配失败。3.4 验证结果如何在 REPL 里确认传输成功上面代码里用src dst判断两个数组完全相等。这个判断在 MicroPython 里对bytearray是逐字节比较的结果是布尔值。看到True就说明数据内容一致。为了更直观我还打印了dst[:8]的前 8 个字节。如果没有而是全 0那问题基本出在触发源、地址递增或者 DMA 根本没启动上。我建议你做个反向实验帮助理解 DMA 到底写入的是“数据”而不是“地址引用”。把dst初始化为全 0xFF再跑一次传输最后打印出来。如果前面的dst前 8 字节变成 1、8、15……而不是 0xFF说明 DMA 确实把源数据覆盖进去了。比如dst bytearray([0xFF] * SRC_SIZE) # 重新配置 dma 并启动... print(list(dst[:8]))这个实验很值得做因为它能帮你确认目标缓冲区是被真实写入的而不是 Python 层面发生了对象引用共享。如果读者对 DMA 抱有“这玩意儿是不是只是个拷贝魔术”的怀疑做完这个实验基本就踏实了。3.5 深入底层不用 rp2.DMA直接操作寄存器接下来我们撇开rp2.DMA类直接控制寄存器。这会让你对 DMA 的工作过程有更本质的理解。首先需要拿到缓冲区的真实数据地址。MicroPython 里uctypes.addressof(buf)返回的是bytearray对象头的地址而不是数据区的地址。在官方 RP2040 固件里bytearray的数据区指针存放在对象头偏移 12 字节处。可以用下面这个小函数获取import uctypes from machine import mem32 def buffer_addr(buf): return mem32[uctypes.addressof(buf) 12]如果你的固件版本比较新也可以试试ctypes模块用 ctypes 数组创建缓冲区后ctypes.addressof(obj)直接返回数据区地址逻辑上更干净import ctypes src (ctypes.c_ubyte * 256)() dst (ctypes.c_ubyte * 256)() src_addr ctypes.addressof(src) dst_addr ctypes.addressof(dst)拿到地址后就可以直接写寄存器了。下面是一个完整示例from machine import mem32 import uctypes SRC_SIZE 256 src bytearray(SRC_SIZE) dst bytearray(SRC_SIZE) for i in range(SRC_SIZE): src[i] (i * 7 1) 0xFF src_addr buffer_addr(src) dst_addr buffer_addr(dst) DMA_BASE 0x50000000 ch 0 base DMA_BASE ch * 0x40 mem32[base 0x00] src_addr # READ_ADDR mem32[base 0x04] dst_addr # WRITE_ADDR mem32[base 0x08] SRC_SIZE # TRANS_COUNT ctrl (1 0) | (0 1) | \ (1 22) | (1 23) | \ (0x3F 15) mem32[base 0x0C] ctrl # CTRL_TRIG写入即启动 while mem32[base 0x0C] 1: pass print(match:, src dst) print(dst first 8 bytes:, list(dst[:8]))这里的ctrl含义要看清bit0 是使能位 EN置 1 表示启动bit1 到 bit2 是 DATA_SIZE0 表示 8 位bit22 是 READ_INCRbit23 是 WRITE_INCR都置 1让读写地址自动递增bit15 到 bit21 是 TRIG_SEL0x3F 左移 15 位就是把这个值写到触发源字段里。写完CTRL_TRIG的瞬间 DMA 就启动了之后的 while 循环是不断读CTRL_TRIG的 EN 位传输完成后硬件会把 EN 自动清 0循环跳出。这个版本虽然代码里多了buffer_addr函数但只要理解了它你对 MicroPython 内存管理和 DMA 硬件底层的理解都会上一个台阶。如果不想管偏移值直接用 ctypes 数组更省心。3.6 性能观察DMA 对比 Python for 循环搬运做完了能跑肯定想知道它到底快多少。我建议你做一个直观对比。用 4096 字节数据分别用 for 循环搬运和 DMA 搬运记录耗时。代码可以这样写from rp2 import DMA import time SRC_SIZE 4096 src bytearray(SRC_SIZE) dst bytearray(SRC_SIZE) for i in range(SRC_SIZE): src[i] i 0xFF # 方法一for 循环 start time.ticks_us() for i in range(SRC_SIZE): dst[i] src[i] print(for loop:, time.ticks_diff(time.ticks_us(), start), us) # 方法二DMA dma DMA() dma.config(readsrc, writedst, countSRC_SIZE, data_size0, trigger0x3F) start time.ticks_us() dma.active(1) dma.wait() dma.active(0) dma.close() print(dma:, time.ticks_diff(time.ticks_us(), start), us)在我自己测试过的环境里for 循环耗时通常是几千到上万微秒DMA 这边往往只有几十微秒。不过必须说清楚程序里dma.active(1)这一行在 Python 层也有调用开销所以测到的时间不完全是纯 DMA 搬运时间。但即便如此数量级差距已经足够说明问题。数据量越大DMA 优势越明显数据量如果只有几十字节那就不一定划算。项目里要是只搬 8 个字节老老实实用 Python 循环反而更简单。4. 进阶玩法一次 DMA 不够怎么组合用4.1 循环触发用定时器/PWM 做周期采样内存到内存只是 DMA 最基础的形态。真正体现 DMA 威力的是“外设触发 批量搬运”的组合。RP2040 的 DMA 触发源表里有大量外设事件比如 PWM 的周期事件、定时器事件、ADC 完成事件。你可以让 DMA 每次等 PWM 信号到来才搬运一个数据这就实现了“按节拍搬运”。比如你要连续采集传感器波形常规做法是 CPU 不断读 ADC 寄存器很占时间。换成 DMA 后可以配置 DMA 从 ADC 结果寄存器读取数据写入一个 1024 字节的数组触发源选 ADC 转换完成事件。ADC 每完成一次转换DMA 自动搬一个字到数组CPU 完全不用参与。这在 MicroPython 里也可以组合你需要把触发源从 0x3F 改成对应外设的 DREQ 编号。具体编号建议查阅 RP2040 数据手册的 DMA 章节因为版本不同DREQ 编号表是固定的但比较多这里先不硬搬表格以免抄错。4.2 链式传输一个通道结束自动启动下一个单个通道搬完就结束了但很多项目要搬的不是一块连续内存。比如你想把三个不同地址的碎片化数据拼接到同一个目标缓冲区里一次 DMA 做不到CPU 逐个处理又慢。RP2040 的CHAIN_TO字段就是干这个的。配置方式不复杂。通道 0 的CTRL_TRIG里CHAIN_TO字段写入通道 1 的编号 1那么通道 0 一旦完成DMA 控制器会自动把通道 1 的配置寄存器加载过来并启动通道 1。你可以把三个通道分别配成三个不同的源地址、相同的目的地址加上不同的偏移数据就能连续拼接起来。MicroPython 的rp2.DMA类多多少少会暴露这个能力如果类的参数里没有底层寄存器方式一定是能做的。这个特性做双缓冲时特别有感觉一个通道在搬前半段另一个通道在搬后半段CPU 则在处理已经搬完的部分。4.3 字节交换与半字/字宽度传输做音频或者图像处理时经常会遇到字节序问题。比如一个 16 位的音频采样值在内存里是小端排列但你要输出成大端格式交给某个编码协议。逐字节交换太蠢RP2040 的 DMA 控制器提供了对换位byte swap可以在搬运动作完成时自动调整字节顺序。这个功能在寄存器里是由BSWAP字段控制的具体它把多少字节做翻转、翻转粒度如何强烈建议打开数据手册对照着配。因为不同宽度下表现不太一样软件上如果一次性配错出来的是乱数据。数据宽度方面data_size1时一次搬运 16 位地址自动递增 2 字节data_size2时一次搬运 32 位地址自动递增 4 字节。如果你要把一个 U16 数组从源地址搬到一个 DMA 外设的 FIFO 里半字模式就非常贴合。内存到内存场景下32 位模式在单次搬运吞吐量上最高但需要对齐。比如做一个 RGB565 的帧缓冲水平滚动用半字模式一次搬一个像素就比 8 位模式高效很多。4.4 和 ADC/UART/PIO 结合从外设到内存最后给个延展方向。RP2040 特别好玩的一点是 DMA 和 PIO 可以打通。PIO 是一种可编程的 IO 状态机它能在 GPIO 上生成非常精确的时序但产生的数据总要有个地方放。如果你用 PIO 采集一串曼彻斯特编码信号或者做了一路逻辑分析仪的波形采样数据到达 PIO 的 RX FIFO 后与其用 Python 死等不如让 DMA 自动把 FIFO 里的数据搬到内存数组里。PIO 的 RX 请求就是 DMA 的一个触发源。配置 DMA 时读地址填 PIO 的 FIFO 地址写地址填内存数组地址触发源选对应的 PIO RX 事件。这样一套下来波形采样的速度上限远比 Python 轮询高得多。我一直觉得RP2040 的 DMA 不是孤立的一个外设它更像连接内存、PIO、ADC、UART 这些模块的高速传输通道。理解了内存到内存的底层流程其他所有“外设到内存”或“内存到外设”的玩法都能举一反三无非是把读地址或者写地址从普通 SRAM 地址换成一个外设 FIFO 地址罢了。5. 高频踩坑与排查记录5.1 常见问题速查表我在 RP2040 MicroPython 上折腾 DMA 时踩过不少坑也帮群友排查过很多类似问题下面这个表基本覆盖了 90% 的初学者情况。现象可能原因排查与解决传输后目标缓冲区全 0触发源配成了某种外设信号DMA 没等到信号内存到内存场景把 TRIG_SEL / trigger 设为 0x3F目标缓冲区全部是第一个字节忘了开 READ_INCR源地址永远不递增READ_INCR / write_incr 置 1程序卡在 wait() 不返回通道没有正确启动或配置未生效先确认 active(1) 调用检查是否通道被占用只有部分数据被搬运TRANS_COUNT 超过 20 位上限拆分多次传输或减少单次计数出现 Hard Fault 或板子重启源/目标地址不在合法内存区域或对齐错误检查地址范围32 位宽度要求 4 字节对齐二次运行时报通道不可用上一次 DMA 对象没释放每次用完后调用 close()数据顺序反了或字节错乱字节交换位被意外打开检查 BSWAP 配置确认数据宽度匹配大数组搬运速度没有提升数据量本身不大Python 层启动开销占大头改用更大数据量测试或考虑中断方式5.2 如何定位 DMA 是否真的在跑写寄存器版本的代码时有时你没法判断 DMA 到底是没启动还是跑完了但数据不对。最简单的方法是分步验证。第一把count改成极小值比如 4搬运 4 字节。第二传输前读一次READ_ADDR寄存器传输后再读一次看看读地址有没有前进。对于增量模式如果 DMA 跑过读地址会自动变成源地址加上 4 或者 8。在 MicroPython 里可以这样看print(hex(mem32[base 0x00])) # 传输后读 READ_ADDR如果读地址确实前进说明 DMA 逻辑在跑问题出在目标缓冲区地址或者验证方式上。如果读地址完全没变说明通道可能压根没启动或者触发源配置有问题。还有一个通用技巧把控制寄存器CTRL_TRIG的值先读出来看看你写入的位是不是真的写进去了。MicroPython 的mem32写入一般不会失败但有时候你可能会写错通道基地址导致写进了某个不存在的寄存器。5.3 从 MicroPython 侧保护缓冲区的 3 个习惯用 Python 操作 DMA 有一个和 C 语言很大的区别Python 对象的内存管理不由你完全掌控。虽然 MicroPython 的bytearray数据区在内存里是连续且固定的但你的代码里其他操作可能在不知不觉中触发垃圾回收虽然不会搬移已分配块但也可能释放掉你不再引用的缓冲区。有 3 个习惯我建议尽早养成。第一确保 DMA 搬运期间源和目标bytearray对象一直被引用着。如果你把bytearray作为局部变量传给某个函数函数返回后局部变量可能被回收。最好在调用dma.active(1)之前把缓冲区对象赋值给一个模块级变量或函数外层的变量。第二如果要在 DMA 还在跑的时候做其他计算尽量不要再分配大对象因为大量分配可能触发堆整理虽然不移动但影响实时性。真要在关键时刻杜绝内存操作可以用micropython.heap_lock()和micropython.heap_unlock()但这两个接口比较激进只能在确定不会分配到内存的时候用。第三用完 DMA 务必close()否则通道被占满后下次DMA()会分配失败这时程序倒不会崩溃只会抛异常但排查起来特别浪费时间。回到这次实验本身。我现在做一个项目时凡是涉及批量数据搬运第一反应都是在草稿纸上先画清楚源是什么、目标是什么、数据宽度多少、要不要递增、能不能用 DMA。RP2040 的 DMA 确实是我在嵌入式开发里用起来最顺手的控制器之一因为它直接把通道、触发源、链式这些概念拆得很干净。希望这篇教程能让你从第一个 256 字节的内存搬运跑通开始一点点建立起对这种底层机制的掌控感。我最后再说一个小技巧以后看到别人代码里trigger0x3F或者0x3F 15别跳过去那往往就是内存到内存传输的“血液”搞懂它比抄十遍代码都管用。