树莓派Pico MicroPython DMA内存拷贝性能优化教程

树莓派Pico MicroPython DMA内存拷贝性能优化教程
我最早接触这个需求是给某个采集项目做数据搬移。传感器吐出来的数据要先暂存到一块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 的本质把“搬运”这件事外包出去DMADirect 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)把主频提到 200MHzDMA 性能也会水涨船高但功耗会上去适合需要极限性能的场景。我后面给出的对比测试全部在默认 125MHz 下完成。2.2 DMA API 关键参数逐一拆解我这边固件的machine.DMA模块用下来核心就四个方法DMA()分配通道、config()配置传输参数、start()启动传输、wait()或busy()等待完成最后close()释放通道。其中config()的参数是整个使用的核心我把关键参数整理成了一张表参数作用我的配置建议dreq传输触发源内存到内存固定用DMA.DREQ_FORCE表示强制立即传输tx源数据缓冲区传入bytearray或array对象必须是可读的连续内存rx目标数据缓冲区传入bytearray或array必须可写且长度足够size每次搬运的数据宽度可选 1、2、4对应 8/16/32 位四字节效率最高count搬运的次数总字节数 size×count不要超过缓冲区大小irq传输完成回调部分固件支持适合需要异步通知的场景我自己常用的配置方式是size4countlen(src)//4。这样每次 DMA 搬运 4 字节搬运次数少控制开销最低。但有个前提源和目标的起始地址要满足 4 字节对齐这块细节后面专门讲。这里还要多嘴一句不同固件对 DMA 模块的参数命名不完全一致。我见过有些版本用的不是tx/rx而是read/write或src/dst。如果你手里的固件配置时报错TypeError: unsupported argument先执行help(DMA.config)看看当前固件支持哪些参数名照着改一下就行。这也是我在不同固件间切换时踩过的坑。2.3 最简流程分配-配置-启动-等待-释放DMA 在 MicroPython 里的生命周期特别清晰基本就是五步用DMA()申请一条通道通道对象创建成功代表资源已占用用config()把源、目标、宽度、次数、触发方式全部配置好调用start()启动 DMA 传输调用wait()或者while dma.busy(): pass等待传输完成用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( dreqDMA.DREQ_FORCE, txsource, rxdest, size4, countBUFFER_SIZE // 4, ) dma.start() while dma.busy(): pass print(match:, source dest) dma.close()直接说几个关键点。第一dreqDMA.DREQ_FORCE一定要设对。内存到内存不涉及任何外设信号如果不强制触发DMA 会一直等一个“外部请求信号”而这个信号永远不来传输就永远不启动。我之前看到有朋友配了dreq0结果start()之后目标缓冲区纹丝不动排查了半天把dreq改成DMA.DREQ_FORCE才跑通。第二size4配合countBUFFER_SIZE//4本质是“每次搬 4 字节一共搬 16384 次”总搬运量正好 64KB。这样比size1、次数 65536 次少了一半以上的控制开销实测速度会更快。但注意这个写法要求BUFFER_SIZE能被 4 整除。如果源数据长度不是 4 的倍数你得单独处理尾部几个字节比如最后 1~3 个字节用普通赋值搬过去。第三等待完成的方式我用了while dma.busy(): pass。busy()返回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(dreqDMA.DREQ_FORCE, txsrc, rxdst, size4, countlen(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 数据测了我板子上的实际结果拷贝方式耗时64KBCPU 占用备注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 通道都可以绑定不同的 DREQDMA 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 对齐问题为什么 size4 会崩第一次把size4配上去之后板子直接报错或者结果不对大概率是对齐问题。DMA 以 4 字节为单位搬运时源地址和目标地址都必须 4 字节对齐否则 RP2040 的 DMA 控制器会跑出奇怪的结果。普通的bytearray对象在 MicroPython 堆上分配时起始地址基本都能满足 4 字节对齐所以简单例程不会触发这个问题。但如果你用了memoryview切片比如view memoryview(source) dma.config(txview[1:], ...)源地址变成了source地址偏移 1 字节此时size4就是未对齐访问后果不可预知。我遇到过一次数据拷完以后目标缓冲区的前 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(..., irqdma_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 收发调通最后再碰 PIODMA 的组合玩法。每走一步你对“数据如何流动”的理解都会上一个台阶。再说一点个人体会。很多人觉得 MicroPython 跑 DMA 特别别扭觉得这类底层的东西应该用 C 写。但实际用下来MicroPython 封装后的 DMA 反而把硬件细节藏在模块后面使用门槛低了很多。你不需要关心 DMA 寄存器的每一位含义只需要知道“源在哪儿、目标在哪儿、搬多少、什么时候搬”剩下的交给固件。这种抽象水平对快速验证硬件方案、写测试工具来说简直不要太香。最后留一个实操小技巧每次在固件上折腾 DMA 之前先用dir(DMA)看看当前固件的常量和方法尤其是 DREQ 相关常量。不同固件之间的命名差异不小花十秒钟确认一下能帮你省下两小时的排错时间。

最新新闻

日新闻

周新闻

月新闻