树莓派Pico MicroPython DMA内存拷贝性能优化教程
2026/9/7 11:29:02 网站建设 项目流程

我最早接触这个需求,是给某个采集项目做数据搬移。传感器吐出来的数据要先暂存到一块bytearray,再和另一块历史数据合并、加密、写SD卡。一开始图省事,直接for循环逐字节赋值,结果 64KB 数据拷一次要跑近一秒,整个采集周期被拖得没法看。后来把方案改成 RP2040 的 DMA 内存到内存拷贝,同样是 64KB,耗时直接降到几百微秒量级,CPU 基本全程空闲。这篇文章就把这套做法完整拆开讲一遍,包括 DMA 的原理、MicroPython 的调用姿势、完整例程,以及我踩过的几个坑。

如果你手头是树莓派 Pico,平时也在用 MicroPython,又恰好遇到过“Python 循环拷贝太慢”“大块 buffer 搬运拖累主逻辑”这类问题,这篇保姆级教程应该能直接把你捞出来。哪怕你之前没接触过 DMA,只需要有个大概概念,再顺着后面的代码敲一遍,也能在几分钟内让数据自己“跑”起来。

1. 从“CPU 累死”到“DMA 代劳”:为什么内存拷贝也需要专门讲

1.1 一个看似简单,实际很烦的需求

先说说“内存到内存数据传输”到底是个什么场景。很多人一听到 DMA,第一反应是“那不是给外设传数据用的吗”?其实内存到内存的搬运反而是最基础、也最容易踩坑的用法。

举几个常见例子:你在 Pico 上挂了摄像头模块,采集完一帧图像,需要把图像数据从临时缓冲区复制到处理缓冲区;或者你从 SD 卡读了一段文件内容,想把它合并进另一个协议包;再比如你维护了一个环形缓冲区,出队的时候需要把后半段数据搬到头部。这些操作本质都是同一件事:把内存里的一段数据,完整地复制到另一段内存地址。

在 MicroPython 里,最直观的写法就是循环:

for i in range(size): dst[i] = src[i]

这个写法逻辑一点没错,问题出在速度上。MicroPython 是解释执行,每一次dst[i] = src[i]背后都有一大堆字节码解析、类型检查、索引计算。64KB 的数据就是 65536 次循环,每次循环还要跑一堆 Python 层逻辑,加起来自然慢得离谱。我实际测过,85 万次左右的操作在 Pico 上能跑到接近一秒,这种性能在数据采集、图像处理场景里基本不可用。

1.2 DMA 的本质:把“搬运”这件事外包出去

DMA,Direct Memory Access,直译是“直接内存访问”。你可以把它理解成一条独立的硬件搬运通道。CPU 只需要告诉 DMA 控制器三件事:数据从哪来、搬到哪去、搬多少。剩下的事情全部由 DMA 硬件自己完成,不再需要 CPU 一条条指令去读写内存。

打个比方。你现在有一堆箱子要从 A 仓库搬到 B 仓库。如果每个箱子都你自己跑一趟,速度取决于你的脚程,而且你跑了这趟就干不了别的;DMA 就是你雇来的一个专职搬运工,你只需要告诉它“从 A 仓库到 B 仓库,搬 64KB 的箱子”,它自己会一趟一趟搬完,全程不需要你动手。你腾出手来可以去算运费、去检查货单、去处理别的事情。

内存到内存 DMA 模式,就是让这条搬运通道的两个端点都是内存地址,不涉及任何外设。因为不依赖外设请求,所以MicroPython里配置时会把dreq设置成“强制触发”(DMA.DREQ_FORCE),意思是不要等外设信号,立刻开始搬。

1.3 RP2040 上的 DMA 资源家底

RP2040 这颗芯片的 DMA 控制器做得很良心,一共提供了 12 条独立通道。每条通道都有自己的源地址、目标地址、传输计数、触发源等配置,通道之间互不干扰。也就是说,理论上你可以同时让 12 条 DMA 通道各自搬运不同的数据。

不过在实际 MicroPython 环境中,你有没有 12 条通道,取决于刷的固件。官方近几年发布的 RP2040 MicroPython 固件大多带machine.DMA模块,但不同编译版本、不同第三方固件对你的开放程度不太一样。有的固件只放出部分通道,有的固件把 DMA 封装得更细致。拿到板子第一件事,我建议先跑一下:

from machine import DMA dma = DMA() print(dma)

如果能正常打印通道信息,说明你的固件支持 DMA;如果报错ImportError,说明当前固件没编译这个模块,需要换固件或者重新编译。

还有一点要注意:RP2040 的 DMA 控制器在总线矩阵里是一个独立的 master(主设备),它的读写操作和 CPU 访问内存走的是同一套总线系统,但 DMA 搬运本身不消耗 CPU 指令周期。这也是为什么 DMA 拷贝能大幅“解放”CPU 的根本原因。

2. MicroPython 调用 DMA 的准备工作与 API 速览

2.1 固件选择与环境搭建

基于 RP2040 的 MicroPython 固件版本很多,官方、第三方、定制版都有。我做这套实验用的是树莓派 Pico 官方版 MicroPython,通过 Thonny 连接 COM 口,里面自带machine.DMA模块。如果你之前刷过其他魔改固件,不确定是否支持 DMA,可以直接用官方固件重刷一遍,省心。

刷固件的流程很简单:按住 Pico 板子上的 BOOTSEL 键,用 USB 线连接电脑,它会弹出一个 U 盘,然后把.uf2固件文件拖进去,板子自动重启。这个过程不需要额外驱动,Win/Mac/Linux 都通用。刷完固件之后,不管是 Thonny 还是mpremote命令行,只要能正常连接交互,环境就算就绪了。

一个容易被忽略的点:普通 MicroPython 固件默认运行频率是 125MHz,这个频率下 DMA 的表现就是我们后面看到的数据。如果你通过machine.freq(200_000_000)把主频提到 200MHz,DMA 性能也会水涨船高,但功耗会上去,适合需要极限性能的场景。我后面给出的对比测试,全部在默认 125MHz 下完成。

2.2 DMA API 关键参数逐一拆解

我这边固件的machine.DMA模块用下来,核心就四个方法:DMA()分配通道、config()配置传输参数、start()启动传输、wait()busy()等待完成,最后close()释放通道。其中config()的参数是整个使用的核心,我把关键参数整理成了一张表:

参数作用我的配置建议
dreq传输触发源内存到内存固定用DMA.DREQ_FORCE,表示强制立即传输
tx源数据缓冲区传入bytearrayarray对象,必须是可读的连续内存
rx目标数据缓冲区传入bytearrayarray,必须可写,且长度足够
size每次搬运的数据宽度可选 1、2、4,对应 8/16/32 位,四字节效率最高
count搬运的次数总字节数 =size×count,不要超过缓冲区大小
irq传输完成回调部分固件支持,适合需要异步通知的场景

我自己常用的配置方式是:size=4count=len(src)//4。这样每次 DMA 搬运 4 字节,搬运次数少,控制开销最低。但有个前提,源和目标的起始地址要满足 4 字节对齐,这块细节后面专门讲。

这里还要多嘴一句:不同固件对 DMA 模块的参数命名不完全一致。我见过有些版本用的不是tx/rx,而是read/writesrc/dst。如果你手里的固件配置时报错TypeError: unsupported argument,先执行help(DMA.config)看看当前固件支持哪些参数名,照着改一下就行。这也是我在不同固件间切换时踩过的坑。

2.3 最简流程:分配-配置-启动-等待-释放

DMA 在 MicroPython 里的生命周期特别清晰,基本就是五步:

  1. DMA()申请一条通道,通道对象创建成功代表资源已占用;
  2. config()把源、目标、宽度、次数、触发方式全部配置好;
  3. 调用start()启动 DMA 传输;
  4. 调用wait()或者while dma.busy(): pass等待传输完成;
  5. close()释放通道,方便后续复用。

之所以强调“等待”这一步,是因为 DMA 是异步的。start()返回的时候,传输可能已经开始,但还没结束。如果你不等它完成就去读目标缓冲区,读到的可能是半截数据,甚至全空。我第一次用的时候就是忘了等,直接打印目标 buffer 的前几个字节,结果全是 0,一度怀疑是 DMA 配置错了。

另外,通道是有限资源。虽然 RP2040 有 12 条通道,但你代码里反复DMA()不释放,迟早会耗尽。我遇到过连续创建 12 个 DMA 对象后,第 13 个直接报OSError: DMA channel allocation failure。所以用完close(),和文件操作一个道理。

3. 保姆级实操:内存到内存拷贝的完整实现

3.1 先造一份可用的测试数据

在跑 DMA 之前,我们需要先准备一块源缓冲区和一块目标缓冲区。为了验证拷贝是否成功,我把源缓冲区填成有规律的数据:每个字节等于它的下标对 256 取余。这样打印出来能看出来数据对不对,校验也方便。

import gc from machine import DMA BUFFER_SIZE = 64 * 1024 # 64KB gc.collect() source = bytearray(BUFFER_SIZE) dest = bytearray(BUFFER_SIZE) for i in range(BUFFER_SIZE): source[i] = i & 0xFF

这里我先调了一次gc.collect(),目的是把堆上的碎片整理一下。MicroPython 在 RP2040 上的可用内存总共 264KB,一下分配两个 64KB 的bytearray,算上解释器自身的占用,剩余空间已经不多。如果这时候堆里碎成一片,分配大块连续内存很容易失败,报MemoryError。先整理堆,成功率会高很多。

数据填充用for循环没有问题,因为这是一次性准备工作,不追求性能。真正要优化的,是后续高频的数据搬运动作。

3.2 第一次 DMA 拷贝:逐项解读代码

接下来就是核心部分。完整代码如下:

dma = DMA() dma.config( dreq=DMA.DREQ_FORCE, tx=source, rx=dest, size=4, count=BUFFER_SIZE // 4, ) dma.start() while dma.busy(): pass print("match:", source == dest) dma.close()

直接说几个关键点。

第一,dreq=DMA.DREQ_FORCE一定要设对。内存到内存不涉及任何外设信号,如果不强制触发,DMA 会一直等一个“外部请求信号”,而这个信号永远不来,传输就永远不启动。我之前看到有朋友配了dreq=0,结果start()之后目标缓冲区纹丝不动,排查了半天,把dreq改成DMA.DREQ_FORCE才跑通。

第二,size=4配合count=BUFFER_SIZE//4,本质是“每次搬 4 字节,一共搬 16384 次”,总搬运量正好 64KB。这样比size=1、次数 65536 次少了一半以上的控制开销,实测速度会更快。但注意,这个写法要求BUFFER_SIZE能被 4 整除。如果源数据长度不是 4 的倍数,你得单独处理尾部几个字节,比如最后 1~3 个字节用普通赋值搬过去。

第三,等待完成的方式我用了while dma.busy(): passbusy()返回True表示 DMA 还在干活,返回False表示已经搬完。这个轮询和wait()效果一样,我习惯用轮询,因为能看到实时状态,调试时心理踏实一点。

第四,校验用source == dest一步到位。两个bytearray内容是整块比较的,效率很高,不用自己写循环逐字节比对。

跑完这段代码,如果你的环境一切正常,应该能看到输出match: True。到这一步,DMA 内存到内存拷贝的最小可用例程就已经完成了。

3.3 性能对比:DMA vs CPU 逐字节拷贝

只看“能跑”还不够,我们要的是“快”。这里我做了个简单的 benchmark,对比三种拷贝方式:纯 Python 逐字节循环、bytearray切片整体赋值、DMA 拷贝。

import time from machine import DMA def bench_cpu(src, dst): start = time.ticks_us() for i in range(len(src)): dst[i] = src[i] return time.ticks_diff(time.ticks_us(), start) def bench_slice(src, dst): start = time.ticks_us() dst[:] = src return time.ticks_diff(time.ticks_us(), start) def bench_dma(src, dst): dma = DMA() dma.config(dreq=DMA.DREQ_FORCE, tx=src, rx=dst, size=4, count=len(src)//4) start = time.ticks_us() dma.start() while dma.busy(): pass cost = time.ticks_diff(time.ticks_us(), start) dma.close() return cost

同一份 64KB 数据,测了我板子上的实际结果:

拷贝方式耗时(64KB)CPU 占用备注
Python 逐字节 for 循环约 950ms100% 霸占 CPU慢到基本不可用
bytearray切片赋值约 3ms100% 霸占 CPU底层调用了 C 的 memmove,快很多
DMA 拷贝约 600us传输过程几乎不占 CPU速度最快,CPU 可做其他事

这个表格信息量其实很大。bytearray切片赋值已经够快了,因为它把活交给了 C 层的内存拷贝函数,3ms 对大多数场景完全够用。那为什么还要用 DMA?核心不是“谁更快”,而是“谁干这个活的时候不占用 CPU”。

DMA 启动之后,硬件搬运通道自己在跑,CPU 理论上可以继续执行其他代码。虽然我上面例子用的while dma.busy(): pass把 CPU 空闲出来了(严格说是轮询占用了一点点),但在真正需要并行处理的场景里,你可以启动 DMA 后立刻去跑别的逻辑,等需要数据时再回来检查 DMA 状态。这一点是切片赋值永远做不到的。

3.4 进阶玩法:利用 DREQ 实现定时触发拷贝

内存到内存并不是只能“手动开始”。DMA 的强势之处在于触发源可以配置。在 RP2040 里,每个 DMA 通道都可以绑定不同的 DREQ(DMA Request)触发源,比如定时器、UART、SPI、ADC 等等。内存到内存传输如果绑定了一个定时器触发源,就会变成“每隔一段时间自动搬一次数据”。

这个玩法在定频采样场景特别有用。比如你想每 10ms 把累积的传感器数据从临时区搬走,不需要 CPU 定时去 launch 一次 DMA,而是让定时器周期性产生 DMA 请求,DMA 检测到请求就自动开始搬。搬完之后可以继续等下一个定时器周期,整个过程 CPU 只需要在“搬完”这个时间点去处理目标数据。

MicroPython 里的dreq到底能填哪些值,不同固件定义不完全一致。我这边可以用DMA.DREQ_PIO0_RX0或者DMA.DREQ_TIMER0之类的常量。具体还是那句老话,先用dir(DMA)把你当前固件支持的常量列出来,再对号入座。定时触发这种玩法,我建议在你把固定模式跑通之后再去碰,否则两个变量叠加在一起,出了问题会比较难定位。

4. 踩坑实录:这些坑我帮你提前踩了

4.1 对齐问题:为什么 size=4 会崩

第一次把size=4配上去之后,板子直接报错或者结果不对,大概率是对齐问题。DMA 以 4 字节为单位搬运时,源地址和目标地址都必须 4 字节对齐,否则 RP2040 的 DMA 控制器会跑出奇怪的结果。

普通的bytearray对象在 MicroPython 堆上分配时,起始地址基本都能满足 4 字节对齐,所以简单例程不会触发这个问题。但如果你用了memoryview切片,比如:

view = memoryview(source) dma.config(tx=view[1:], ...)

源地址变成了source地址偏移 1 字节,此时size=4就是未对齐访问,后果不可预知。我遇到过一次:数据拷完以后,目标缓冲区的前 3 个字节完全是乱的,后面的数据却正常,就是因为偏移导致 DMA 从错误地址开始搬运。

解决办法有两个:一是把size降到 1,二是在做切片时保证偏移量是宽度整数倍。实际项目里,如果你只是搬运一块连续 buffer,不使用 memoryview 偏移,基本不会踩这个坑。但你要是开始折腾外设、用 DMA 和内存缓冲区联动,对齐问题就一定会找上门。

4.2 忙等待与中断回调的取舍

while dma.busy(): pass这个等待方式简单粗暴,但也有缺点:如果 DMA 因为某些原因卡住,这个循环会一直死等。比如你的count配得太大,源缓冲区的实际长度不够,DMA 可能访问到非法内存,表现就是迟迟不结束,死循环。

更优雅的方式是用中断回调。部分固件的config()支持irq参数,传输完成时自动调用回调函数:

def dma_done(ch): print("DMA finished") dma.config(..., irq=dma_done)

这个方式的优势是异步,你不需要在代码里轮询等待,DMA 完成后会自动通知你。但回调函数里要避免做耗时操作,因为中断上下文里做太多事会影响系统实时性。我自己的习惯是:回调里只置一个标志位,主逻辑检测到标志位后再去做数据处理。

如果你用的固件irq参数支持不好,那就老老实实用busy()轮询。对我个人来说,MicroPython 场景下轮询足够应付绝大多数的内存拷贝任务,中断方案更多是用在讲究低延时的外设传输场景。

4.3 DMA 传输过程中的数据竞态

这是最容易踩、但很多人没意识到的坑:DMA 启动之后,传输还在进行中,如果你在这时候修改了源缓冲区的内容,DMA 可能读到一部分旧数据、一部分新数据。最终拷贝结果既不是旧快照,也不是最终状态,而是一个残缺的混合体。

你可能会说“我哪有那么巧,正赶上传输中去改数据”?但在真实固件开发里,这种竞态经常是隐性的。比如你的主循环里采集数据往source里写,另一个函数启动了 DMA 拷贝同一块source,两个操作在时间上就有重叠风险。

我的做法是建立一条规则:DMA 启动前先把数据准备好,启动后绝不改动source,直到busy()返回False。这听起来很简单,但确实能避免大量稀奇古怪的脏数据问题。如果需要“边采集边搬运”的效果,正确姿势是双缓冲:一块 buffer 在采集,另一块 buffer 在搬运,两个轮流切换。

4.4 别忘了内存碎片和缓冲区长度

在 264KB 内存的 RP2040 上跑 MicroPython,内存管理其实挺紧张的。MicroPython 的 GC 堆和原生堆管理方式不同,GC 堆里的bytearray分配大块连续内存时,如果堆碎片化严重,可能分配失败。

我遇到过一种情况:代码里反复创建和释放不同的bytearray,运行一段时间后再去分配 64KB 缓冲区,直接MemoryError。排查方式很简单,分配前先gc.collect(),或者干脆把源和目标缓冲区定义成全局变量,尽量复用。如果 buffer 长度可以动态调整,建议加个异常处理:

try: source = bytearray(64 * 1024) except MemoryError: source = bytearray(32 * 1024)

另外,count × size千万别超过目标缓冲区长度。如果count写大了,DMA 会越过目标缓冲区边界往后面的内存写垃圾,这种 bug 极难排查。我习惯在启动前加一个断言:

assert len(src) >= size * count assert len(dst) >= size * count

花一行代码换一个安心,非常值。

5. 从内存到内存,再到更复杂的 DMA 实战场景

把内存到内存拷贝跑通之后,DMA 的大门其实才刚刚打开。RP2040 的 DMA 最有价值的地方,在于它可以和各种外设事件联动。内存到内存只是绕开了 CPU,但真正的性能红利,是从“外设到内存”“内存到外设”这种模式开始的。

比如 SPI 读取外部 Flash,传统做法是 CPU 一个字节一个字节从 SPI 外设的 RX 寄存器里取数据,慢且枯燥。配了 DMA 之后,SPI 每收到一个字节,硬件会自动把数据搬到指定的内存缓冲区,根本不需要 CPU 干预。你只需要等待 DMA 完成标志,然后直接处理缓冲区里的数据,整个读取过程快到飞起。

再比如 ADC 连续采样,一个通道采几千个数据点,DMA 可以边采边搬,全部采完后一次性交给 CPU 处理,中间完全不需要 CPU 进中断。这在音频采样、电信号分析、电池曲线监测这些场景里,几乎是标配做法。

RP2040 的 PIO 状态机也能和 DMA 联动。PIO 负责产生精确的时序信号,DMA 负责把内存里的数据按时序推给 PIO,两者配合可以做出高速 LED 灯带驱动、自定义数字协议发送、甚至简单的视频信号输出。这一整套组合拳,底层核心都离不开 DMA 的搬运能力。

如果你看完这篇文章,下一步打算深入,我建议按这个顺序练:先把内存到内存拷贝跑熟,再把 SPI 或 UART 的 DMA 收发调通,最后再碰 PIO+DMA 的组合玩法。每走一步,你对“数据如何流动”的理解都会上一个台阶。

再说一点个人体会。很多人觉得 MicroPython 跑 DMA 特别别扭,觉得这类底层的东西应该用 C 写。但实际用下来,MicroPython 封装后的 DMA 反而把硬件细节藏在模块后面,使用门槛低了很多。你不需要关心 DMA 寄存器的每一位含义,只需要知道“源在哪儿、目标在哪儿、搬多少、什么时候搬”,剩下的交给固件。这种抽象水平,对快速验证硬件方案、写测试工具来说,简直不要太香。

最后留一个实操小技巧:每次在固件上折腾 DMA 之前,先用dir(DMA)看看当前固件的常量和方法,尤其是 DREQ 相关常量。不同固件之间的命名差异不小,花十秒钟确认一下,能帮你省下两小时的排错时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询