
如果你跟我一样最近在 RP2040 上跑 MicroPython 做数据采集或者刷屏大概率会撞上同一个问题Python 循环拷贝几 KB 数据慢得肉眼可见而网上一搜 DMA教程默认你是用 C 和 Pico SDK 的MicroPython 玩家直接被劝退。这篇文章就用最笨的办法在 MicroPython 里手动操作 DMA 寄存器把一次内存到内存的数据搬运完整跑通全程不碰 C 语言。我会把原理讲到“会用就行”的程度不硬塞数据手册但寄存器怎么配、地址怎么拿、坑在哪里都会交代清楚适合刚接触 RP2040 DMA 的开发者也适合想在 MicroPython 里做高速数据搬运的朋友。1. 为什么要在 MicroPython 里折腾 DMA1.1 MicroPython 的数据复制到底慢在哪很多人觉得“内存到内存拷贝”这种操作不就是循环把数据搬过去吗在 C 里确实简单但在 MicroPython 里一个普通的for i in range(n): dst[i] src[i]要经历解释器取指令、类型检查、边界检查、字节码执行等一大串流程根本不是一次内存访问就完事。我自己实测过用 MicroPython 在 133MHz 的 RP2040 上循环拷贝几十 KB 数据体感就有明显卡顿。如果是 1MB 的数据跑起来甚至能让程序“卡死”一会儿。这不是 MicroPython 垃圾而是解释型语言天生就要为每一次操作付出额外的解释开销。所以当你需要频繁搬运大块缓冲数据时靠 Python 循环去搬基本等于浪费时间。1.2 DMA 能在 RP2040 上做什么DMA 全称 Direct Memory Access翻译过来就是“直接内存访问”。它的核心作用是把数据从一个地址搬到另一个地址整个过程不需要 CPU 逐字节参与。RP2040 里一共有 12 个 DMA 通道支持三种搬运方向内存到内存、外设到内存、内存到外设。对 MicroPython 用户来说最常用的其实是两类场景。一类是内存到内存比如把采集缓冲区拷贝到显示缓冲区、把协议帧从临时缓冲区搬到待发送缓冲区另一类是外设到内存比如 ADC 连续采样、PIO 接收高速数据流时用 DMA 把外设 FIFO 里的数据成批搬运到内存避免 CPU 一次次去读外设寄存器。1.3 什么场景该用什么场景别用DMA 虽然快但也不是银弹。它适合连续、大块、周期性明显的数据搬运。比如一次搬 1KB 以上的数据或者要以固定频率持续搬运数据这时候用 DMA 收益非常大。但如果只是偶尔搬几个字节或者每次搬运之间要 CPU 做复杂判断那我建议别折腾 DMA老老实实写循环就够了。因为 DMA 配置本身需要写几个寄存器、还要考虑通道是否空闲这些开销对小数据量来说并不划算。简单说批量大、频率高、逻辑固定用 DMA数据零碎、逻辑要现场判断用 CPU 循环。2. 内存到内存 DMA 的底层原理与硬件细节2.1 一条 DMA 事务需要配置哪些东西可以把 DMA 想象成一个传菜员。你作为厨师不需要自己端着盘子从厨房跑到包间只需要告诉传菜员三件事菜在哪个窗口、要送到哪个包间、一共端几趟。DMA 也一样一条完整的内存到内存事务至少要告诉它四件事源地址从哪个内存地址开始读目的地址写到哪个内存地址传输数量一共搬多少次控制信息每次搬多大、地址要不要递增、由什么触发把这四件事配置好再给一个“开工”信号DMA 就会自己把数据搬完搬完后通知你或者把状态位清零。整个过程 CPU 几乎不用管。2.2 核心寄存器与通道布局RP2040 的 DMA 外设基地址是0x50000000每个通道占用0x40字节的空间。通道 0 的寄存器起始就是0x50000000通道 1 从0x50000040开始依此类推。这里面几个关键寄存器我最常用寄存器偏移地址作用READ_ADDR0x00设置源地址传完后会变成最终读取地址WRITE_ADDR0x04设置目的地址传完后会变成最终写入地址TRANS_COUNT0x08剩余传输次数启动前写入要搬的次数CTRL0x0C控制寄存器配置数据宽度、递增、触发等CTRL_TRIG0x10控制寄存器写这个寄存器会同时启动传输注意CTRL和CTRL_TRIG的寄存器布局是一样的区别在于写CTRL只配置不启动写CTRL_TRIG是配置完立刻启动。所以在 MicroPython 里我们大多数情况下只写CTRL_TRIG就够了。2.3 控制寄存器和 DREQ_FORCE控制寄存器里最重要的几个位段我按经验给你划重点bit 0EN通道使能也是传输完成标志。传输过程中为 1完成后硬件自动清零。bit 1READ_ADDR_INCR源地址是否递增内存到内存拷贝必须置 1。bit 2WRITE_ADDR_INCR目的地址是否递增拷贝到连续内存时置 1。bit 3-4DATA_SIZE单次传输数据宽度0 表示 8bit1 表示 16bit2 表示 32bit。bit 16-21DREQ触发来源选择设为0x3F也就是 63表示无条件连续搬运。这个DREQ_FORCE 0x3F是内存到内存搬运的关键。DMA 默认需要等外设给出“数据请求”信号才会动一步但内存到内存没有外设参与所以必须把触发源设成“强制触发”让 DMA 永远处于就绪状态一次接一次地把数据搬完。以 32bit 传输、源地址递增、目的地址递增、强制触发为例控制寄存器的值就是CTRL_TRIG_VALUE 0x3F0017这个值拆开看就是EN(1) | READ_ADDR_INCR(11) | WRITE_ADDR_INCR(12) | DATA_SIZE_32(23) | DREQ_FORCE(0x3F16)。我在实际项目中一直用这个组合非常稳定。2.4 地址对齐、递增模式与数据宽度用 32bit 传输时源地址和目的地址必须按 4 字节对齐否则会触发硬件访问错误轻则数据错乱重则直接崩掉。MicroPython 里申请bytearray时缓冲区地址一般都会对齐到 4 字节以上但保险起见还是建议在代码里打印一下地址确认。另一个容易踩的坑是地址递增模式。拷贝连续内存时读写地址都要递增但如果目的是外设 FIFO比如 PIO 的 TX FIFO写地址就不能递增必须固定指向 FIFO 地址否则 DMA 会把后续数据写到外设地址空间里莫名其妙的位置。数据宽度也直接影响传输次数。比如 1024 字节用 8bit 传输需要搬 1024 次用 32bit 传输只要搬 256 次。每次传输对总线来说都有固定开销所以同样数据量32bit 传输明显比 8bit 快。能对齐的情况下优先选 32bit。3. 代码实操在 MicroPython 中把 DMA 寄存器“搬”起来3.1 环境准备与固件要求我使用的是 Raspberry Pi Pico 官方提供的 MicroPython 固件版本无所谓只要是正常构建版本都会带machine和uctypes这两个模块。如果你的固件是精简版或者是从某个定制分支刷的建议先到交互式终端里执行import uctypes确认一下没报错就能继续。唯一要强调的准备工作是确保板子供电稳定。DMA 搬运时内存总线压力大如果供电质量差偶尔会出现程序跑飞或数据错误听起来玄学但我在面包板上遇到过换 USB 口就好了。3.2 用 uctypes.addressof 获取缓冲区地址MicroPython 里怎么拿 bytearray 的地址有人会说id(bytearray)但这在 MicroPython 中返回的是对象头部地址不是 buffer 数据区的起点。正确做法是使用uctypes.addressof()它会直接返回底层缓冲区起始地址。from uctypes import addressof src bytearray(1024) dst bytearray(1024) src_addr addressof(src) dst_addr addressof(dst) print(hex(src_addr), hex(dst_addr))打印出来的地址应该落在 SRAM 范围内也就是0x20000000到0x2003FFFF之间。如果你看到的是其他范围的地址大概率拿错了地址后面 DMA 搬出来的数据肯定是乱的。这里有个 MicroPython 的特性要记住它的垃圾回收机制是非移动式的对象一旦分配出来地址在存活期间不会变。所以只要src和dst这两个引用还在DMA就能安全访问缓冲区。千万别在 DMA 搬运过程中del src或者让变量重新赋值为其他对象否则 GC 可能会把这块内存回收掉DMA 还在傻乎乎地写那块已经被归还的内存后果很酸爽。3.3 配置寄存器并启动 DMA拿到地址后按这个顺序配置寄存器写READ_ADDR把源地址填进去。写WRITE_ADDR把目的地址填进去。写TRANS_COUNT写入 32bit 传输次数也就是nbytes // 4。写CTRL_TRIG写入控制值触发启动。顺序不能随便换尤其是最后一步CTRL_TRIG必须在所有参数就绪后再写。因为CTRL_TRIG一写DMA 就开始干活了如果这时候地址或计数还是错的搬出来的就是垃圾数据。传输是否完成可以轮询CTRL_TRIG的 bit 0。传输过程中 EN 位是 1结束之后硬件自动清零。所以我用while mem32[CH0_CTRL_TRIG_REG] 1: pass等待完成简单可靠。3.4 完整可运行代码下面这段就是我在板子上验证过的完整示例把 1024 字节从src搬到dst然后对比内容from machine import mem32 from uctypes import addressof # DMA 通道 0 基地址 DMA_BASE 0x50000000 CH0_READ_ADDR_REG DMA_BASE 0x00 CH0_WRITE_ADDR_REG DMA_BASE 0x04 CH0_TRANS_COUNT_REG DMA_BASE 0x08 CH0_CTRL_TRIG_REG DMA_BASE 0x10 # 准备数据 src bytearray(1024) dst bytearray(1024) for i in range(1024): src[i] i 0xFF # 打印地址确认在 SRAM 范围内 print(src addr:, hex(addressof(src))) print(dst addr:, hex(addressof(dst))) # 配置寄存器 mem32[CH0_READ_ADDR_REG] addressof(src) mem32[CH0_WRITE_ADDR_REG] addressof(dst) mem32[CH0_TRANS_COUNT_REG] 256 # 1024 字节 / 4 字节 256 次 # EN | READ_INCR | WRITE_INCR | DATA_SIZE_32 | DREQ_FORCE CTRL_TRIG_VALUE 0x3F0017 # 写入 CTRL_TRIG立即启动 mem32[CH0_CTRL_TRIG_REG] CTRL_TRIG_VALUE # 等待传输完成加个超时保护防止死循环 timeout 1000000 while (mem32[CH0_CTRL_TRIG_REG] 0x01) and timeout: timeout - 1 if timeout 0: raise RuntimeError(DMA timeout) # 验证结果 print(copy ok:, dst src) print(src first 8:, list(src[:8])) print(dst first 8:, list(dst[:8]))运行逻辑很简单先把src填成 0 到 255 循环的数据然后配置 DMA 寄存器启动后等待完成。如果一切正常终端会打印copy ok: True并且两段数据的前 8 个字节完全一致。3.5 封装成通用函数实际项目里不可能永远只搬 1024 字节我建议把它封装成一个通用函数顺手处理 32bit 对齐和不齐字节的情况from machine import mem32 from uctypes import addressof DMA_BASE 0x50000000 CH0_READ_ADDR_REG DMA_BASE 0x00 CH0_WRITE_ADDR_REG DMA_BASE 0x04 CH0_TRANS_COUNT_REG DMA_BASE 0x08 CH0_CTRL_TRIG_REG DMA_BASE 0x10 def dma_memcpy(dst, src, nbytes): if nbytes 3: # 不是 4 的倍数用 8bit 传输模式 ctrl 0x3F0007 count nbytes else: # 可以 32bit 传输速度快 ctrl 0x3F0017 count nbytes // 4 # 简单对齐检查 if (addressof(src) 3) or (addressof(dst) 3): raise ValueError(source or dest not 4-byte aligned) mem32[CH0_READ_ADDR_REG] addressof(src) mem32[CH0_WRITE_ADDR_REG] addressof(dst) mem32[CH0_TRANS_COUNT_REG] count mem32[CH0_CTRL_TRIG_REG] ctrl timeout 1000000 while (mem32[CH0_CTRL_TRIG_REG] 0x01) and timeout: timeout - 1 if timeout 0: raise RuntimeError(DMA timeout)这个函数我平时就直接当 MicroPython 里的memcpy用。非 4 字节倍数时自动切换 8bit 模式虽然慢一点但保证能跑4 字节对齐时走 32bit 模式速度拉满。4. 提高效率与稳定性的几个关键点4.1 数据宽度与搬运次数怎么选从原理上讲每次 32bit 传输DMA 控制器在总线上搬一个 word总线周期开销比搬 8bit 更划算所以能走 32bit 就不要走 8bit。但前提是源和目的地址都要 4 字节对齐且数据总长度是 4 的倍数。如果数据长度不满足怎么办我个人习惯是分两段处理主体部分用 32bit DMA 搬运开头或结尾剩下的几个字节用 Python 循环手动补。这样既享受 DMA 的速度又不需要为几个字节牺牲整个事务的效率。4.2 注意 GC 和对象生命周期这一点值得单独强调。MicroPython 的 GC 虽然不会移动对象但它会回收不再使用的内存。如果你在函数里创建了一个 bytearray 作为 DMA 源然后函数结束后变量被丢弃GC 马上就可能把这块内存回收。此时 DMA 即使已经启动访问的也可能是一块被标记为空闲的内存轻则数据错乱重则直接 HardFault。所以正确的姿势是确保 DMA 事务期间源和目的 bytearray 的引用始终存在。要么把它们定义在全局要么在函数内等 DMA 跑完再返回要么把缓冲区对象作为参数传入并在函数内保持引用。4.3 多通道、优先级和谐共存RP2040 有 12 个 DMA 通道但并不是每个通道都永远空闲。比如有些 MicroPython 库在底层会用 DMA 做外设搬运你如果硬抢那个通道可能造成冲突现象就是 DMA 启动没反应或者数据不对。我的建议是先用通道 0 或通道 1这两个在 MicroPython 默认环境下被占用的概率比较低。如果你同时跑多个 DMA 任务再考虑给每个任务分配独立通道并通过控制寄存器里的优先级位调整高优先级通道。不过对于入门场景一个通道跑内存到内存拷贝已经够用别一上来就把并行调度搞得太复杂。4.4 在真实场景中落地内存到内存 DMA 最常见的落地场景之一是把采集缓冲区搬到显示缓冲区。比如你用 ADC 或 PIO 采集了一帧数据存在buffer_a然后希望快速拷贝到buffer_b供后续处理。用dma_memcpy(buffer_b, buffer_a, len)一条函数就搞定不需要 CPU 手动循环省下的时间可以做别的计算。如果你配合 PIO 使用还可以把 PIO 的 RX FIFO 地址作为 DMA 的目的地址让 PIO 收到的数据自动写入内存完全不占 CPU。这块玩法更复杂但理解了内存到内存 DMA后面再接触外设 DMA 就会顺很多。5. 常见问题与排查技巧实录5.1 搬运结果全是 0 或乱码结果全是 0大概率是目的地址写错了DMA 把数据写到了一块从未初始化的内存或者源地址没取到 buffer 数据区。先打印hex(addressof(src))和hex(addressof(dst))确认两者都在0x20000000到0x2003FFFF范围内。如果数据不是全 0而是错位、被截断重点检查DATA_SIZE和地址递增位。比如源地址没开递增DMA 就会反复读同一个地址目的地址没开递增所有数据会写进同一个位置。5.2 卡在等待循环里出不来这是新手最容易碰到的问题。代码跑起来后一直停在while等待说明 DMA 没有完成或压根没启动。先从这几方面查CTRL_TRIG值里的 EN 位有没有写 1写 0 等于没启动。DREQ是不是0x3F如果漏写这一项DMA 会一直等外设请求永远等不到。TRANS_COUNT是不是写成了 0。通道是不是已经被其他库占用尝试换通道 1。另外等待循环必须加超时保护。在实际调试中如果 DMA 配置错误它是不会主动报错的没有超时的话程序会被卡死连交互式终端都救不回来只能按复位键。5.3 速度没提升甚至更慢DMA 本身很快但如果每次只搬几十字节寄存器配置和轮询的开销反而超过 Python 循环就会出现“用了 DMA 还不如不用”的假象。建议单次搬运至少 256 字节以上再上 DMA。另外MicroPython 里写mem32本身也有解释开销所以如果搬运频率极高可以考虑一次性搬一个大缓冲区而不是拆成很多小事务。我自己测试时用 32bit 模式处理几百 KB 级别的数据收益非常明显几个字节级别的数据老老实实用 Python 循环。5.4 通道冲突与寄存器被改有一次我把 DMA 通道 0 用在项目里某天突然发现偶尔搬运失败查了半天才发现是另一个库在初始化时占用了通道 0我的配置被覆盖掉了。从那以后我习惯在配置 DMA 前先读一下CTRL_TRIG的 EN 位如果已经是 1说明通道正忙不能直接写。另外一个细节是每次搬运完成后READ_ADDR、WRITE_ADDR、TRANS_COUNT这几个寄存器的值都会被硬件改成最终状态。比如TRANS_COUNT会变成 0READ_ADDR会变成源地址加搬运长度。所以如果需要重复执行同一个搬运任务必须重新配置这些寄存器不要指望写入一次就能重复使用。5.5 避坑清单一览坑表现对策地址没对齐数据错乱或 HardFault打印地址确保 4 字节对齐DREQ 没设成 0x3F卡在等待循环控制值必须包含 DREQ_FORCE缓冲区被 GC 回收数据随机变化DMA 期间保持对象引用通道被占用启动无反应换通道先查 EN 位寄存器没重新配置重复执行结果不对每次搬运前重新写全部寄存器只搬几个字节速度反而更慢小数据用 CPU 循环大数据用 DMA这套代码我放在自己的工具库里很久了平时做采样缓冲、PIO 数据流、甚至简单的帧缓冲拷贝都直接套用。唯一要记住的就是别在 DMA 跑的时候乱碰缓冲区所有数据搬运让它安安静静干完。希望这篇能帮你把 RP2040 的 DMA 这块拼图补上少走几步弯路。