资讯动态

RP2040 DMA编程:MicroPython内存搬运与性能优化

发布时间:2026/9/7 11:43:13 来源:尧图企业网站定制
在 RP2040 上折腾 MicroPython迟早会撞上“大批量搬数据”这个需求。我第一次是给一个 128x64 OLED 做显存拼接32KB 的 buffer 用 for 循环一字节一字节复制直接肉眼可见的卡换成切片赋值后速度上去了但程序在复制那一下还是会停顿。后来把 RP2040 的 DMA 用起来才发现原来这块芯片早就内置了 12 条“搬运工”只是 MicroPython 默认不会教你用它们。这篇文章会用保姆级的方式从原理到寄存器再到可直接运行的 MicroPython 代码把 RP2040 内存到内存数据传输这件事讲透。适合想优化 MicroPython 性能、准备做屏幕/音频/传感器采集或者纯想搞懂 DMA 的读者。1. 为什么要用 DMA 干“搬运工”的活1.1 先理解 DMA不经过 CPU 的内存搬运DMA 全称 Direct Memory Access中文叫直接存储器访问。可以把它想象成一个只负责搬货的仓库管理员你告诉它“从 A 仓库取 100 箱货放到 B 仓库”它就能自己一趟一趟跑完不会中途喊老板来搬。老板CPU只需要下完指令然后去干别的。对于 RP2040 来说内存地址空间里既包括 SRAM也包括各种外设寄存器。DMA 和普通程序拷贝最大的区别在“谁在干活”。普通拷贝无论你写 for 循环还是切片赋值数据移动的过程都需要 CPU 参与DMA 则是一条独立于 CPU 的硬件通道只要配置好源地址、目标地址和传输次数它自己会在系统总线上把数据搬完。整个过程中 CPU 可以继续跑代码或者休眠等待。在 MicroPython 这种解释执行环境里这个优势更加明显。MicroPython 本身就是慢的Python 每处理一个字节都要走对象模型、边界检查、指令分发这个开销比 C 大几个数量级。而 DMA 是纯硬件操作一旦配置完成传输过程完全由硬件状态机推动MicroPython 代码可以马上往下走不需要一行一行等搬运完成。1.2 MicroPython 里的“内存搬运”到底慢在哪很多人觉得 MicroPython 里复制数组不就是一个dst src[:]的事吗对但那是在 Python 层把控制权交给了底层 C 库函数底层确实快可 CPU 还在忙。对单线程程序来说复制大数组那段时间程序是卡住的。更要命的是如果你在循环里写for i in range(len(src)): dst[i] src[i]这个循环每跑一圈都要解释执行一次16KB 就可能跑到几十毫秒这是肉眼可见的卡顿。DMA 的第一个优点是搬运过程没有解释器参与配置完那几十个寄存器后真正搬数据的工作交给硬件CPU 可以继续跑主循环、处理按键、刷新其他变量。第二个优点是它可以被外设事件触发比如 UART 每收到一个字节、ADC 每产生一次采样结果硬件就会自动把数据搬到指定数组。这两个特点决定了 DMA 在批量搬运场景里是比 Python 逐字节复制强很多的存在。1.3 为什么不在 MicroPython 里用 memcpy 就好听到这肯定有人问既然 MicroPython 的切片赋值底层就是 memcpy那我为什么还要折腾 DMA问得好。如果是纯粹在内存里把一块区域复制到另一块切片赋值在绝大多数情况下已经够用DMA 写起来反而麻烦。DMA 真正有价值的地方是异步DMA 开始搬运后CPU 立刻回头继续干活只在需要结果的时候等待或者用中断回调处理完成事件。另一个有价值的地方是“内存和外设之间自动传输”也就是 UART、SPI、ADC、PIO 这些外设跟内存之间的事件驱动传输。这篇文章先讲内存到内存是因为它最容易把 DMA 的寄存器配置逻辑讲清楚学会之后再去接外设一通百通。很多优化 MicroPython 性能的项目本质都是在找“怎么把机械的、重复的数据搬运动作交给硬件”DMA 就是那把钥匙。2. RP2040 DMA 硬件速览寄存器、通道、触发2.1 12 条通道可以链式串联RP2040 的 DMA 控制器一共提供 12 条通道编号 0 到 11。通道是独立的你可以同时配置通道 0 搬缓冲区 A 到 B、通道 1 搬 C 到 D两条通道之间由硬件仲裁总线访问权。每一条通道都带一组寄存器用来配置源地址、目标地址、传输次数、传输宽度和触发源。除了单独使用RP2040 DMA 还支持链式传输一条通道完成传输后可以自动去配置或启动另一条通道甚至同一通道的 AL1/AL2/AL3 重装载。这对协议报文的多段发送、帧缓冲的连续滚动非常有用。不过别一开始就被这些高级功能劝退本文只要用一条通道和一组寄存器就能跑通第一个内存到内存传输先把最简单的路径走通后面遇到复杂场景再往链式传输上靠。2.2 4 个核心寄存器先背下来通道 n 的寄存器基址是0x50000000 n * 0x40然后在这个基址上加偏移。真正需要关心的核心寄存器是这 4 个寄存器偏移作用READ_ADDR0x00源数据起始地址WRITE_ADDR0x04目标数据起始地址TRANS_COUNT0x08需要传输的次数注意不是字节数CTRL_TRIG0x0C控制与触发寄存器写 1 到 EN 位启动例如通道 3 的 CTRL_TRIG 地址是0x50000000 3 * 0x40 0x0C 0x500000CC。在 MicroPython 里访问非常直接machine.mem32[0x500000CC] ...就能读写这个寄存器。CTRL_TRIG 是理解 DMA 的核心。用到的位有这些低位的几个一定要记住ENbit0启动位写 1 开始传输完成后硬件自动清 0。DATA_WIDTHbit1:20 表示字节1 表示半字16 位2 表示字32 位。INCR_READbit4源地址是否递增1 表示每传一次源地址往后移。INCR_WRITEbit5目标地址是否递增。TREQ_SELbit15:20触发源编号内存到内存用 0x3F。BUSYbit24通道忙碌标志1 表示传输还没完成。这些位我对照 RP2040 datasheet 核对过但不同 MCU 的定义差异很大换芯片时一定要重新查手册不能照搬。2.3 触发源决定 DMA“听谁的指挥”DMA 不是自己想搬就搬它需要一个“出发信号”这个信号由 TREQ_SEL 决定。内存到内存的场景里我们需要它立刻开始连续搬运所以把 TREQ_SEL 设为 0x3F这个值在 RP2040 里的含义是“持续请求/不等待”相当于告诉 DMA你一直搬别停。如果是外设场景则要指定对应的 DREQ 编号。比如 UART 的发送缓冲空、ADC 的 FIFO 有数据、PIO 状态机的 TX/RX 请求等。设置错误会怎样最典型的是 DMA 配好了但永远不开始因为它在等一个永远不会来的外设信号。新手踩坑大多踩在这。RP2040 手册里 DREQ 源编号是固定的我建议把当前项目对应的 datasheet 里 DMA 章节读熟或者直接搜“RP2040 DREQ table”。常见的触发源有 UART、SPI、I2C、PWM、ADC、PIO 等每个外设的多路请求又会映射到不同 DREQ。先用内存到内存跑通 0x3F再接外设可以少走很多弯路。3. MicroPython 环境准备与最小依赖3.1 这方案需要什么硬件和固件准备一块 Raspberry Pi Pico 或 Pico W 就行任何带 RP2040 的板子都可以。固件建议用较新的 MicroPython 稳定版因为需要machine.mem32和uctypes两个模块这两个在 rp2 端口一直是标准内置的。IDE 随便Thonny 或 mpremote 都行我习惯用 mpremote run 把脚本推上去跑。不需要额外装任何第三方库这可能是整个项目最省心的地方。如果你用的是老版本固件建议先升个级。MicroPython 的底层实现偶尔会调缓冲区管理、异常措辞这些细节都会变新版本对直接寄存器操作的兼容性更好出问题时也更容易在社区找到同样环境的经验。3.2 用 uctypes.addressof 拿到真实内存地址在 MicroPython 里bytearray是一个 Python 对象DMA 不认 Python 对象它只认总线地址。uctypes.addressof(buf)能返回buf内部缓冲区数据区的真实地址。注意这里不能偷懒用id(buf)id()返回的是对象头地址而且可能会被解释器缓存成小整数拿它喂 DMA 必出问题。import uctypes buf bytearray(64) addr uctypes.addressof(buf) print(hex(addr))你会在串口里看到一个类似0x2001xxxx的地址这是 Pico 的 SRAM 范畴。RP2040 的 264KB SRAM 基本都落在0x20000000 ~ 0x20042000区间如果打印出来不是这个区间检查你的板卡是否特殊或者是不是用了id()取到了错误地址。3.3 用 mem32 验证地址可读写光会拿地址还不够最好先验证地址是真的能读写这一步能过滤掉后面一半的问题。直接把bytearray当成一块裸内存操作import machine import uctypes test bytearray(4) addr uctypes.addressof(test) test[:] b\x11\x22\x33\x44 print(hex(machine.mem32[addr])) # 小端读出来应该是 0x44332211 machine.mem32[addr] 0xAABBCCDD print(test.hex()) # 小端写回应该是 ddccbbaa这里如果输出和注释一致说明你对地址、小端字节序和 mem32 访问已经建立了直觉。后面写 DMA 就可以相信地址本身没问题发抖的是配置或者触发源。4. 完整实现手写 dma_memcpy4.1 封装函数参数、宽度、地址对齐现在开始写 DMA 版内存拷贝。为了照顾使用习惯函数签名写成dma_memcpy(ch, dst, src, nbytes)和 C 的memcpy顺序一致。里面会自动决定传输宽度如果字节数能被 4 整除并且源/目标地址都按 4 字节对齐就用 32 位字宽度速度最快否则退回字节宽度。传输次数count等于nbytes / 每条传输的字节数千万别直接把nbytes写成次数这是最容易犯的错。import machine import time from uctypes import addressof DMA_BASE 0x50000000 def _dma_base(ch): return DMA_BASE ch * 0x40 def dma_memcpy(ch, dst, src, nbytes): if not (0 ch 12): raise ValueError(DMA channel must be 0..11) if len(dst) nbytes or len(src) nbytes: raise ValueError(buffer too small) if nbytes 0: return src_addr addressof(src) dst_addr addressof(dst) # 优先用 32 位字宽度拷贝更快长度或地址不满足时退化为字节传输 if nbytes % 4 0 and src_addr % 4 0 and dst_addr % 4 0: width 2 count nbytes // 4 else: width 0 count nbytes base _dma_base(ch) # 等上次传输完全结束避免把还在 busy 的通道配置改乱 t_wait time.ticks_us() while (machine.mem32[base 0x0C] 24) 1: if time.ticks_diff(time.ticks_us(), t_wait) 100_000: raise RuntimeError(channel busy timeout) machine.mem32[base 0x00] src_addr # READ_ADDR machine.mem32[base 0x04] dst_addr # WRITE_ADDR machine.mem32[base 0x08] count # TRANS_COUNT # CTRL_TRIG: # EN1 启动DATA_WIDTHwidthINCR_READ1INCR_WRITE1TREQ_SEL0x3F 连续搬运 ctrl 1 | (width 1) | (1 4) | (1 5) | (0x3F 15) machine.mem32[base 0x0C] ctrl # 等待完成加个超时保护防止配置错误导致死循环 t0 time.ticks_us() while (machine.mem32[base 0x0C] 24) 1: if time.ticks_diff(time.ticks_us(), t0) 100_000: raise RuntimeError(DMA timeout)代码里有两个细节值得展开。第一个是启动前先等 BUSY 清 0避免上一次传输还没完成就重写读地址/写地址硬件行为会不可预期。我在这里也加了超时防止通道被其他程序占用时卡死。第二个是完成等待用 BUSY 位而不是 TRANS_COUNT因为 TRANS_COUNT 在启动瞬间可能还没被硬件递减到可见的 0直接读它容易误判。超时 100ms 是宽松值16KB 数据传输在这个频率下用不了这么长时间。4.2 跑一个最小验证src bytearray(range(256)) # 0, 1, 2, ..., 255 dst bytearray(256) # 全 0 dma_memcpy(0, dst, src, 256) print(dst[:16]) print(dst src)如果前面的函数没错看到的第一个元素应该是 0 到 15。注意dma_memcpy的第三参是src别把源和目标填反了我第一次就填反过结果所有数据被复制到了源里把原始数据冲掉。dst src这个比较在两边都是 bytearray 且长度相等时是逐字节比较能快速确认结果对不对。另外要注意DMA 是内存到内存的直接拷贝不是 memmove它不会处理重叠区。如果源和目标区域有重叠结果跟预期可能不一致。MicroPython 的切片赋值底层用的是 memmove 语义能处理重叠DMA 不行这个差异要记住。需求里真有重叠搬运时要么分成两段要么换一条思路。4.3 缓冲区地址在 DMA 期间必须保证存活MicroPython 的 GC 是非移动式的对象一旦分配数据地址基本稳定但这不代表你可以掉以轻心。如果 DMA 启动后源或目标 bytearray 被重新赋值比如src bytearray(...)或函数返回后被释放原来那个缓冲区地址可能已经被复用甚至清除。DMA 拷贝的是物理地址和 Python 变量名无关变量名换了指另一块内存DMA 还是会往老地址写。安全做法是传输期间保持源和目标对象的引用比如把缓冲区放在全局变量里或者等 DMA 完成后再释放。批量搬运图像、音频时尤其要注意这个坑。我见过一个案例函数内部创建 bytearray 启动 DMA 后立刻 return结果目标缓冲区被 GC 回收DMA 完成后数据全被破坏排查了很久才发现是生命周期问题。5. 性能实测DMA、for 循环、切片对比5.1 怎么测才公平对比性能前先统一条件。用同一块 16KB 数据对每个方案先跑一次热身排除初次缓存、MicroPython 模块载入的干扰然后连续测量多次取中位数。时间用time.ticks_us()在 MicroPython 里足够精确。for 循环版本长这样N 16 * 1024 src bytearray(range(256)) * 64 dst bytearray(N) t0 time.ticks_us() for i in range(N): dst[i] src[i] t1 time.ticks_us() print(for loop us:, time.ticks_diff(t1, t0))切片版本就一行t0 time.ticks_us() dst[:] src t1 time.ticks_us() print(slice us:, time.ticks_diff(t1, t0))DMA 版本也简单t0 time.ticks_us() dma_memcpy(0, dst, src, N) t1 time.ticks_us() print(dma us:, time.ticks_diff(t1, t0))每个方案跑之前先单独执行一次然后再开始计时这样拿到的是“热”状态下的数据更接近真实使用时的表现。5.2 我这边得到的结果Pico 工作在 125MHzMicroPython 较新固件数据量 16KB结果大概是这样的不同版本会有浮动但量级稳定拷贝方式耗时量级说明Python for 循环十几到几十毫秒解释器逐字节执行最慢切片赋值1~2 毫秒底层 C 库 memcpy速度快DMA几百微秒配置寄存器后由硬件搬运且 CPU 可继续运行注意这个表格里 DMA 的耗时其实分两段MicroPython 写配置寄存器需要几十微秒真正搬运的耗时很短。所以数据量越小DMA 的启动开销占比越高可能反而不如切片。我在另一个 1KB 小拷贝测试里DMA 和切片几乎打平甚至切片更快。结论很明确纯内存拷贝数据量越大 DMA 优势越明显数据小就别折腾 DMA。5.3 什么时候该用 DMA什么时候别用综合来看我的建议是500 字节以下的数据搬运用切片或者 memoryview 赋值别上 DMA配置开销不划算。大块数据、循环搬运、需要释放 CPU 去做别的操作用 DMA 是划算的。真正让 DMA 发挥威力的是外设场景UART 不定长接收、ADC 连续采样、OLED 刷缓冲这些场景 DMA 能边接收边让你主循环干别的甚至通过中断回调处理已完成的批次。另外注意DMA 也会占用系统总线带宽如果程序本身在密集计算DMA 和 CPU 会互相影响必要时要测实际吞吐不能只看纸面参数。性能测试的目的不是证明“DMA 天下第一”而是帮你建立直觉什么量级的数据配什么手段。有了这个直觉后面做音频处理、屏幕刷新、传感器缓存才不会盲目堆代码。6. 常见问题与避坑手册6.1 数据全是 0 或者只有一部分对这是最高频的问题。从头按顺序排查地址取没取对。id(buf)不是数据地址要改用uctypes.addressof(buf)。长度单位错。TRANS_COUNT 是传输次数word 模式下count nbytes // 4如果填成nbytesDMA 会搬运超过目标缓冲区长度越界写坏内存现象会非常诡异。缓冲区太短。目标 bytearray 必须足够容纳 nbytes否则 DMA 会写穿目标地址破坏相邻变量。源和目标重叠。DMA 按顺序搬不是 memmove 语义重叠时的结果可能和预期不同。排查技巧先把源填成range(256)这种可预测 pattern目标预填0xAA跑完打印目标开头和结尾几个字节再结合地址检查能很快定位是地址问题、长度问题还是触发问题。6.2 配置好以后 DMA 完全不动或者一直 BUSY最常见原因是 TREQ_SEL 设置错了。内存到内存要把触发源设为 0x3F也就是持续请求模式如果填成了某个外设 DREQDMA 会在那里等外设信号而外设没有数据于是永远 busy。排查时可以打印 CTRL_TRIG 的值确认 bit15:20 是 0x3F。第二个常见原因是地址非法比如把地址写成了 0x00000000 或者栈区地址DMA 访问不到会触发 AHB 错误这时 BUSY 也不一定清。建议检查 READ_ADDR/WRITE_ADDR 是否落在 SRAM 或目标外设地址空间内。第三个原因是通道没空闲就重复配置所以函数里启动前等 BUSY 清 0 就是防这个。6.3 通道冲突和处理RP2040 只有 12 条 DMA 通道MicroPython 内部库在某些情况下可能已经占用了部分通道。如果你发现某个通道配置后行为异常换一条通道试试。我在项目里的习惯是通道 0~3 留给调试和通用搬运通道 8~11 留给外设流。实在不够再考虑链式传输复用通道。判断通道是否被占用可以在配置前读一下 CTRL_TRIG 的 BUSY 位如果一直为 1说明这条通道有东西在跑要么等它结束要么换一条。这块花不了多少时间却能在多任务项目里省下大把排查时间。6.4 从内存到内存扩展到 UART、ADC、屏幕刷屏搞懂 mem-to-mem 之后外设 DMA 只是换几个地址和 DREQ 的事。举个例子UART RX 不定长接收的思路是把 DMA 的 READ_ADDR 指向 UART 数据寄存器WRITE_ADDR 指向接收缓冲TREQ_SEL 配成 UART RX 的 DREQ每收到一个字节硬件自动把它搬到内存。要判断一包数据结束再配一个空闲超时或者利用 UART 的接收超时中断。发送方向反过来把 WRITE_ADDR 指向 UART 数据寄存器源指向待发送缓冲配合发送完成标志比自己 for 循环发送更快也更省心。ADC 连续采样的思路类似将 DMA 读地址指向 ADC FIFOTREQ_SEL 配成 ADC 的 DREQ中断或轮询采集次数数据就直接落在数组里。OLED 和屏幕刷屏则可以先把要显示的数据在内存里拼接好再用 DMA 一次性搬到 SPI 端口。其实很多常见的 ESP32 S3 OLED 提速教程本质也是想用类似的批量搬运思路只不过 ESP32 的 DMA 寄存器和 RP2040 不同。学会 RP2040 这一套换到别的平台核心思想是一样的查目标外设地址、查 DREQ 编号、配好源/目标/次数、启动。6.5 还想深入的话如果觉得一条通道不够用可以去研究 RP2040 DMA 的链式传输、访问通道 AL1~AL3 重装载以及和 PIO 状态机配合。PIO 可以自己定义时序和协议再加 DMA 批量送数据是 RP2040 的核心玩法很多复杂外设比如高速灯带、段码屏、智能传感器读取就是 PIO 加 DMA 做出来的。不过那已经不是一篇保姆级文章能塞下的内容了建议先把今天的 mem-to-mem 代码跑熟再往前探。最后说点个人体会。我在正式用 DMA 之前总觉得“把寄存器地址算出来”是搞嵌入式的大佬才会的事实际跟着手册操作几遍后发现真正难的不是写寄存器而是建立“这是物理地址不是 Python 对象”这个概念。步骤其实很固定先 addressof再 mem32 写 READ_ADDR、WRITE_ADDR、TRANS_COUNT最后写 CTRL_TRIG 启动。跑通第一个 256 字节拷贝后再做任何扩展你都会觉得心里有底。调试时最推荐的做法是把源和目标都填成有规律的 pattern比如交替的 0xAA、0x55跑完直接比对末尾几个字节能快速区分是长度算错、地址没对齐还是触发源配错。等你的板子能稳定搬内存了再去接 UART、ADC、PIO这条路的回报率会非常高。

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

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

免费获取报价