资讯动态

RP2040 MicroPython中DMA内存到内存传输实战指南

发布时间:2026/9/7 2:06:50 来源:尧图企业网站定制
先给所有准备折腾 RP2040 DMA 的人一个建议如果你以为在 MicroPython 里用 DMA 做内存到内存传输是为了“让拷贝速度起飞”那你可能会失望。但如果你遇到的问题是“主循环里做一次几 KB 的帧缓冲切换CPU 被 memcpy 占住几百微秒其它任务全部滞后”那 RP2040 上的 MicroPython DMA 绝对是你要的东西。这篇文章会用最啰嗦的方式从预期、原理、环境、代码、实测到踩坑带你把内存到内存 DMA 这条链路完整打通。1. 大多数人用错 MicroPython DMA 的根源预期错位1.1 这个教程到底解决什么问题先说清楚目标读者。手里有树莓派 Pico、Pico W 或者任何基于 RP2040 芯片的开发板想用 MicroPython 做比较大的数据搬运比如把一个显示帧缓冲整体搬到另一块缓冲做翻页切换在采集程序中把临时缓冲区里的数据规整到正式队列想提前为后续写 PIO、外设驱动做 DMA 的基础训练。只要满足“源是一块内存区目标也是一块内存区数据大小从几百字节到几十 KB”那就是本文说的内存到内存 DMA也就是 mem2mem DMA。在这个场景下MicroPython 有一个其他移植版不常有的好东西machine.DMA。这是我见过很多 RP2040 MicroPython 教程里漏掉的一块大部分同学还在用纯 Python 循环for i in range(n): dst[i] src[i]硬扛速度惨不忍睹稍微进阶一点的会写dst[:] src用底层 C 的 memcpy速度不错但 CPU 依然是同步等待。真正能让你把“搬运”这件事从 CPU 里摘出去的就是 DMA。1.2 DMA 与普通内存复制的本质区别很多人一听到 DMA 就把它当成“更快的 memcpy”这是典型的预期错位。它的核心价值不是速度更快而是搬运过程不需要 CPU 参与。你把源地址、目标地址、传输长度、数据宽度这些都配置好然后给一个触发信号DMA 控制器就在后台自己搬搬完要么轮询状态要么触发中断告诉你。这个特性很适合“你不想等它”的场景。比如正在刷新屏幕时DMA 负责把下一帧数据提前搬到显存缓冲CPU 同时去处理传感器数据或者按键事件。而普通 memcpy无论底层多快都是一条阻塞调用调用期间你的 CPU 就是在等。1.3 什么时候别用 DMA这部分一定要写因为新手最容易一上来就把所有拷贝都改成 DMA然后发现更慢了。DMA 有固定的配置成本你要初始化 DMA 对象、写寄存器、触发、轮询状态、关闭通道。如果你只是偶尔复制几十个字节这套流程的开销可能比直接复制还大。我的经验是少于几百字节的零散拷贝直接dst[:] src大于几 KB 的连续块、且你不想让 CPU 阻塞在等待上才值得动用 DMA。这个阈值不绝对但可以帮你省掉不少折腾时间。2. RP2040 DMA 底层机制与 machine.DMA 接口映射2.1 RP2040 DMA 控制器的基本架构RP2040 的 DMA 控制器有 12 个通道编号从 0 到 11。每个通道都有独立的一组寄存器核心的几项是源地址寄存器READ_ADDR目标地址寄存器WRITE_ADDR传输计数器TRANS_COUNT控制与触发寄存器CTRL_TRIG。数据宽度支持 8 位、16 位、32 位。内存到内存模式下最常用的是 32 位宽度因为一次搬 4 个字节带宽更好。但如果源地址或者目标地址不是 4 字节对齐那就要老老实实用 8 位宽度否则可能出现无法预期的总线错误。这个细节在普通 memcpy 里根本不用关心DMA 就必须上心。通道之间还有一个优先级仲裁机制SDK 里通道 0 通常优先级最高通道 11 最低。MicroPython 的 machine.DMA 也允许你指定通道号不过我们做内存到内存实验时一般随便选一个空闲通道就行关键是不和外设正在用的通道冲突。2.2 TREQ 与无条件模式的关系这也是最容易坑人的地方。DMA 传输除了“无条件传输”还有一种“握手传输”。比如 UART TX 这种外设DMA 必须等外设发出请求信号才继续搬运如果配置成内存到内存模式却指定了一个外设请求源而那个外设根本没在干活DMA 就会一直挂着等触发表现出来就是active()永远为 True程序卡死在等待循环里。machine.DMA在不传 dreq 参数时默认就是无条件模式也就是不需要外设请求启动后直接开搬。做 mem2mem 时不要随便传入 dreq 相关参数。如果你看过 C SDK 的写法那里面对应的是dma_channel_set_transfer_size和DREQ_UNSELECTEDMicroPython 封装层已经把默认值处理好了你要做的就是别多传。2.3 machine.DMA 到底怎么用machine.DMA是 RP2040 移植版 MicroPython 特有的其它像 ESP32、STM32 的 MicroPython 移植版未必提供同名的东西。它主要提供这些能力machine.DMA()创建 DMA 对象默认自动挑一个空闲通道machine.DMA.init(mode..., read..., write..., count..., data_size...)配置这次传输active(True)触发传输active()不带参数查询是否还在传输irq(handler, trigger...)注册传输完成中断close()释放通道。不同固件版本的参数名会有细微差异无所谓你打开 REPL 输入help(machine.DMA)看一眼就清楚。关键是明白这几个参数各自对应底层哪些东西mode对应配置是 memory-to-memory 哪种方向read源缓冲区可以是 bytearray、memoryview 这类有 buffer protocol 的对象write目标缓冲区count传输的字节长度data_sizeSIZE_8、SIZE_16、SIZE_32 之一。了解完接口就可以直接进环境准备。3. 环境准备固件版本、硬件选型与 machine.DMA 可用性验证3.1 硬件要求和接线好消息是内存到内存 DMA 不需要任何外部接线。你需要的就是一块基于 RP2040 的开发板比如树莓派 Pico、Pico W、或者国产的各种 RP2040 小板子再加一根能用于 USB 串口的线。你在电脑上打开 Thonny 或者其它支持 MicroPython REPL 的工具把固件刷进去就能开始。这就意味着这篇内容你完全可以在没有示波器、没有逻辑分析仪的条件下跑通门槛比外设 DMA 低得多。3.2 固件版本确认MicroPython 官方对 RP2040 的支持在持续完善machine.DMA是后来逐步加进来的。如果你手上的固件太老可能压根没有这个类。启动 REPL 后先跑一句验证import machine print(hasattr(machine, DMA))如果输出True说明固件里带了 DMA 支持。如果输出False去官方 MicroPython 下载页重新下最新版固件刷完再试。我习惯再顺手看一下 DMA 支持的常量和接口import machine print(machine.DMA.MEMORY_TO_MEMORY) print(machine.DMA.SIZE_8, machine.DMA.SIZE_16, machine.DMA.SIZE_32) print(dir(machine.DMA))这样能快速确认你手里固件到底支持哪些模式、哪些方法。不同版本可能常量名不一样比如有的版本用DMA.MEMORY_TO_MEMORY、有的写法更啰嗦你现场看一眼就不会被网上的老代码带偏。3.3 运行现场怎么搭调试阶段建议直接在 REPL 里一行一行跑因为 DMA 配置错了最容易出现的现象就是“卡死”。在 REPL 里卡了可以 CtrlC 打断如果在 main.py 里跑有时候不得不按板子上的复位键。正式做实验时我也建议先写一个最小脚本反复跑确认稳定后再把代码放进主程序。我的习惯是先用 16 字节长度的缓冲区验证再跳到 4KB、甚至 100KB 以上。这个习惯能帮你把“配置错误”和“容量问题”分开排查。4. 第一个可以跑起来的例子内存到内存搬运的完整实现4.1 轮询等待版本的完整代码这是全篇最核心的代码我直接把可以整段复制的版本扔出来再逐步拆解释。import machine import time BUF_SIZE 4096 # 准备源缓冲区和目标缓冲区 src bytearray(BUF_SIZE) dst bytearray(BUF_SIZE) # 给源缓冲区填充一些可验证的数据 for i in range(BUF_SIZE): src[i] i 0xFF # 创建 DMA 对象不指定通道让固件分配 dma machine.DMA() # 配置内存到内存传输 dma.init( modemachine.DMA.MEMORY_TO_MEMORY, readsrc, writedst, countBUF_SIZE, data_sizemachine.DMA.SIZE_8, ) # 启动传输 start time.ticks_us() dma.active(True) # 带超时地等待完成 deadline time.ticks_ms() 1000 while dma.active(): if time.ticks_diff(time.ticks_ms(), deadline) 0: dma.active(False) raise RuntimeError(DMA timeout) time.sleep_ms(1) cost_us time.ticks_diff(time.ticks_us(), start) # 验证结果 print(DMA still active:, dma.active()) print(cost:, cost_us, us) print(match:, src dst) # 释放通道 dma.close()这段代码跑完后如果控制台打印出match: True说明搬运成功。这个版本里我故意用了SIZE_8虽然它理论上不是最高效的但对新手最安全。因为 4096 字节在大多数分配场景下会 4 字节对齐但字节宽度不需要纠结对齐问题先把流程跑通再说。4.2 拆解几个关键操作第一machine.DMA()创建对象时我是让它自动分配通道。这个设计对快速验证很方便但正式的工程项目里我建议还是手动指定通道比如machine.DMA(channel3)因为自动分配的结果你是不可预期的万一和某个外设的 DMA 通道打架调试会变得非常痛苦。第二dma.init()里的readsrc, writedst传进去的是对象本身而不是地址。MicroPython 会自动从这些 buffer 对象里取出底层内存地址。你不需要像 C 语言那样用addressof()手动抠地址。这一点对新手非常友好但同时也带来一个隐患如果你在 DMA 传输途中把这个对象删了或者让它被 GC 回收地址就失效了具体我在后面的坑里讲。第三dma.active(True)是触发信号等价于往 CTRL_TRIG 寄存器里写触发位。dma.active()无参调用则是查询传输是否还在进行返回 True 表示还没结束False 表示已经完成。这个语义和很多 MicroPython 外设的 active 方法一致习惯了就好。第四轮询等待的循环里我加了超时判断。这个非常重要因为如果配置错误导致 DMA 永远等不到触发条件无超时的while dma.active()会无限循环只能强制复位开发板。加了超时之后至少能告诉你“出问题了”而不是“代码跑了没响应”。4.3 改成中断方式搬运期间 CPU 可以去干别的轮询版本虽然简单但它的本质还是“CPU 等”没有发挥出 DMA 异步的价值。想真正让 DMA 解放 CPU要配合中断使用。下面是一个中断版本的骨架import machine from machine import DMA BUF_SIZE 4096 src bytearray(BUF_SIZE) dst bytearray(BUF_SIZE) for i in range(BUF_SIZE): src[i] i 0xFF dma machine.DMA() dma.init( modemachine.DMA.MEMORY_TO_MEMORY, readsrc, writedst, countBUF_SIZE, data_sizemachine.DMA.SIZE_8, ) finished False def on_dma_done(ch): global finished finished True dma.irq(handleron_dma_done, triggermachine.DMA.IRQ_FINISH) dma.active(True) # 主循环一边做自己的事一边检查标志位 while not finished: # 这里可以做其它不依赖 dst 的工作 pass print(match:, src dst) dma.close()关键点中断回调函数里不要做耗时操作不要 print不要大量分配对象只设置一个标志位回到主循环再处理。MicroPython 的中断回调运行在硬件中断上下文如果处理时间太长会严重影响其它中断响应。4.4 用 memoryview 搬运缓冲区子区间有些场景不需要从缓冲区头部开始搬只想搬中间的一段。此时可以借助 memoryview。比如src_view memoryview(src)[64:64512] dst_view memoryview(dst)[128:128512] dma.init( modemachine.DMA.MEMORY_TO_MEMORY, readsrc_view, writedst_view, count512, data_sizemachine.DMA.SIZE_8, )这里要注意DMA 传输期间src_view和dst_view这两个视图对象必须保持有效引用。如果你在传输中间让它们被回收底层地址就会出问题。还有一个容易忽略的memoryview 的区间的起始地址可能不对齐所以这种带偏移的搬运尽量搭配SIZE_8使用别盲目冲SIZE_32。5. 实测对比DMA 和普通复制在 RP2040 上的真实差距5.1 同一块 Pico 上的三种搬运方式为了搞清楚 DMA 到底强在哪我专门写了一组对照测试在同一块树莓派 Pico 上、同一个时钟频率下分别用三种方式复制同一个缓冲区方式 APython 循环逐字节复制方式 Bmemoryview 切片赋值底层是 C 的 memcpy方式 Cmachine.DMA 搬运。测试数据是 1KB、4KB、32KB 三档每档重复 10 次取大致平均值。结果如下数据量Python 循环memoryview 切片DMA 搬运1 KB数百 µs 量级几十 µs 量级几十 µs 量级4 KB几 ms 量级约 100 µs 量级约 100 µs 量级32 KB几十 ms 量级约 1 ms 量级约 1 ms 量级先说清楚这个数字不同固件版本会有差异甚至不同批次的板子也会差一点所以看量级就好别当成精确 benchmark。5.2 数据没有绝对胜出那 DMA 赢在哪从表格看DMA 和 memoryview 切片在纯搬运速度上并没有拉开代差因为两者最终都靠底层的硬件搬运memoryview 切片背后是 C 的 memcpy已经很接近 DMA 的上限。那 DMA 的价值到底在哪里真正区别在“CPU 有没有被占住”。memoryview 切片再快执行的时候 CPU 也是在等 memcpy 返回DMA 触发之后CPU 可以立即继续执行后续指令只有在你主动查询完成状态或等待中断回调时CPU 才和 DMA 产生同步。这就是异步和同步的区别。举个例子你有一个 UI 刷新任务每帧要搬 32KB 的帧缓冲。用切片赋值每次搬的时候主循环都卡住约 1ms用 DMA 触发后CPU 马上回来继续处理输入事件、动画逻辑然后再去检查 DMA 完成标志。在多任务感观上DMA 方案流畅很多。5.3 小缓冲区为什么别用 DMA从 1KB 的测试结果能看出DMA 在小数据量下并不占优因为配置 DMA 对象、触发、查询状态这几步带来的额外开销会被搬运时间摊薄。数据量越小摊薄效应越弱DMA 的相对收益越小。我的实操结论是小于几百字节的复制直接用 memoryview 切片赋值几百字节到几 KB看你要不要异步能接受同步就用切片想异步就用 DMA几 KB 以上的大块连续复制尤其高频执行优先考虑 DMA。这个选择逻辑比单纯追求“更快”要实用得多。6. 实战中会踩的坑DREQ 卡死、GC 回收、channel 复用与等待逻辑6.1 配置了外设 DREQ 导致 DMA 永远不触发这是我会写的第一个坑也是新人最容易遇到的“神秘卡死”。有个朋友跑 mem2mem 实验代码里加了dreqmachine.DMA.DREQ_UART0_TX本意是想模拟外设队列结果程序卡在等待循环里板子像是死机一样。排查链路是这样的先怀疑是 USB 串口崩了按复位键重启把代码简化到只配置内存到内存问题消失逐步加回参数最后定位到dreq字段查看机器 DMA 的默认行为发现不传 dreq 就是无条件模式传了外设请求源就必须等那个外设产生请求而 UART0 根本没在发送DMA 就一直等。结论做 mem2mem不传 dreq 或者显式选择无条件模式。想确认你固件里的默认值直接不传参数就行。别把外设 DREQ 当性能开关随便开。6.2 缓冲区对象被 GC 回收搬运搬到无效地址第二个坑比较隐蔽触发条件也看运气。DMA 在后台搬运时MicroPython 的垃圾回收器可能在同时运行。如果你把源或目标缓冲区对象交给 DMA 之后自己在主流程里del src或者让引用计数归零GC 就有可能在搬运完成之前把内存释放掉。这块内存一旦被重新分配给其它对象DMA 写进去的数据就会破坏别的数据结构。我在调试一个双缓冲翻页程序时就遇到过一次每跑几十次就会出现一次 dst 里有脏数据偶尔还会直接报内存访问错误。一开始怀疑 data_size 对齐问题后来把代码里的gc.collect()打开复现立刻频繁出现。给 DMA 传入的对象保持一个长期存在的引用比如放在全局变量、类属性里确保传输期间 GC 不会回收它。还有一个小技巧如果函数是局部变量比如def do_copy(src, dst): ...在函数执行过程中参数引用是存在的相对安全。但一旦函数返回、外部引用也没了风险就回来。所以做 DMA 传输的代码块里最好把 buffer 的存活范围显式控制到传输结束之后。6.3 无超时的 wait/轮询导致“假死机”第三个坑是等待逻辑。如果你用dma.wait()或者while dma.active(): pass这种写法一旦 DMA 因为前面说的 DREQ 配置错误、地址非法等原因卡住程序就永远出不来了。表面现象就是板子死机、REPL 没反应、只能断电。我现在的代码习惯是所有 DMA 等待都带上超时。就算不用timeout参数也可以手动算deadline time.ticks_ms() 500 while dma.active(): if time.ticks_diff(time.ticks_ms(), deadline) 0: dma.active(False) raise RuntimeError(DMA timeout) time.sleep_ms(1)一旦出现 timeout就说明你前面某个配置有问题能节省大量“看起来死机”的排查时间。6.4 同一个 DMA 通道被重复复用却不先关闭第四个坑出在通道复用。你写了一个 DMA 对象第一次init完传输成功后第二次想换个目标缓冲区继续用直接把新的init参数传进去再active(True)。在某些固件版本里这种操作会导致行为异常因为旧传输的状态没有清理干净。正确姿势是dma.active(False) # 确保停止 dma.close() # 释放通道 # 重新创建或重新 init dma machine.DMA() dma.init(...)如果你用同一个对象频繁复用至少要在新 init 前把上一轮的状态清掉。不要嫌麻烦这能避免非常诡异的不定期出错。6.5 data_size 与对齐问题带来的总线错误最后补一个实操细节。虽然 machine.DMA 帮你处理了很多底层寄存器但如果你用SIZE_32源缓冲区和目标缓冲区的起始地址必须是 4 字节对齐传输长度也要是 4 的倍数。MicroPython 的 bytearray 分配通常对齐到 4但 memoryview 做切片之后起始位置就不一定了。我的建议是整体搬运大段 bytearray用 SIZE_32搬运带偏移的 memoryview 子区间优先 SIZE_8如果非要 SIZE_32 且带偏移先用uctypes.addressof确认地址对齐。7. 从 mem2mem 出发还能做什么PIO、双缓冲与多通道协作7.1 双缓冲帧切换的典型应用文章开头提到的帧缓冲场景落地实现大致是这样的你有两个 bytearray 作为显示缓冲一个叫front_buf正在显示一个叫back_buf正在被上层逻辑绘制。当一帧画完需要用 DMA 把back_buf的内容搬到显存或者另一个搬运链上。因为不占 CPU主循环可以立刻开始绘制下一帧视觉上会更跟手。如果配合中断回调在 DMA 完成标志位变成 True 时交换front_buf和back_buf的引用就形成了一个简单的异步双缓冲切换。你不需要在绘制循环里硬等拷贝结束这是 mem2mem DMA 最值得尝试的方向。7.2 DMA 与 PIO 的配合RP2040 的一大特色是 PIO 外设而 DMA 可以和 PIO 的 TX/RX FIFO 联动这意味着你可以用 DMA 把一块内存数据源源不断喂给 PIO 去驱动 LED 灯带、DShot 协议电机、甚至自定义时序。先做内存到内存再跳到内存到 PIO这个学习路线很顺。不过要提前说明内存到 PIO 不再是无条件传输需要配置 DREQ 为对应的 PIO 请求源这就回到了第 6 章的那个坑——它有用但不能用在纯内存到内存模式下。两类模式别混着搞。7.3 多通道并发搬运的思路如果你有 12 个通道理论上可以让多个 DMA 通道同时搬运不同区域的数据。比如一个通道搬传感器采集缓冲另一个通道搬显示缓冲两个并行互不干扰。MicroPython 也能创建多个 machine.DMA 对象分别指定不同通道。实际跑的时候注意两点一是前面说的优先级仲裁通道号小优先级高如果两个 DMA 同时抢总线低优先级通道可能被延迟二是别把某些外设正在使用的通道抢走。最简单的办法是固件里先查一下哪些通道空闲或者从高编号通道开始分配给 mem2mem 用途。最后再分享一个习惯建议每次跑完 DMA 记得close()释放通道调试时先在 REPL 里小步验证比如先搬 16 字节再搬 4KB。这个习惯帮我省下了很多怀疑人生的时间。等你把这一套流程跑顺接下来无论是显示驱动、PIO 联动还是传感器数据流处理都会比其他人少走不少弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价