
1. 内存到内存DMA先搞清楚它到底解决什么问题做 Pico 项目时最烦人的不是逻辑写不对而是数据搬运占用 CPU。比如用 I2S 采集音频DMA 把采样值送到缓冲区A你正准备在主循环里把缓冲区A的数据整理到缓冲区B结果刚把循环跑起来采样值已经覆盖了下半段。后来看到 RP2040 的 DMA 控制器支持内存到内存传输才意识到不光是外设要 DMA内存之间的数据搬运同样能交给硬件完成。但坑在于 MicroPython 官方没有提供现成的 DMA 类网上全是 C SDK 的例程能直接用 MicroPython 操作寄存器完成内存拷贝的教程几乎没有所以写下这篇。这篇文章的目标很明确在树莓派 Pico 上用 MicroPython 直接操作 DMA 寄存器把一块bytearray内存完整搬到另一块bytearray内存里。整个过程中不借助 C 扩展不写几万行的代码只靠machine.mem32和uctypes.addressof这两个接口就能完成。如果你已经在用 MicroPython 开发想深入理解嵌入式底层机制或者正在做音频、图像、传感器数据缓存这类需要批量搬运内存的工程这篇可以当一份拿来就用的参考。1.1 别把DMA只当成外设搬运工DMA 全称 Direct Memory Access直译是“直接内存访问”。大多数教程都把它和外设绑定在一起ADC 采样用 DMA、串口收发用 DMA、SPI 外设通信用 DMA。也正因为这样很多人形成了一种惯性思维DMA 就是外设和内存之间的快递员。但查一下 RP2040 的数据手册就会发现DMA 控制器根本不关心数据从哪里来、到哪里去它只认源地址、目标地址和传输长度。只要地址合法它就能把一块内存的数据复制到另一块内存这种传输方式就是 memory-to-memory DMA。内存到内存 DMA 在嵌入式系统里不是新鲜事但在 MicroPython 语境下确实少见。大多数 MicroPython 用户处理数据拷贝时要么用切片dst[:] src要么用memoryview转换要么老老实实写 for 循环。这三种方式本质上都消耗 CPU只是快慢不同。如果你想在高速采样不间断的情况下处理上一帧数据CPU 拷贝的时间越长丢数据的风险越大。这时候 DMA 就有了用武之地启动传输后CPU 就自由了。1.2 内存到内存的真实场景具体哪些项目会用到这个能力第一类是协议栈里的报文拼接比如把分散在不同缓冲区的包头、负载、校验字段合并成一个连续的发送缓冲区手动拷贝要花时间DMA 可以一次性搬完还能通过多个通道串联实现类 scatter-gather 的效果。第二类是双缓冲机制外设 DMA 正在往缓冲区A写数据时CPU 把缓冲区B里的数据搬去处理处理完交换两个缓冲区用系统 DMA 搬运数据能让 CPU 只关注算法逻辑不用每次手动搬几千字节。第三类是硬件定时器触发批量数据转移比如每毫秒把一块状态缓存同步到另一块输出缓存CPU 根本不需要介入。1.3 在 MicroPython 里用 DMA 是“钻空子”还是合理操作很多人会问MicroPython 本身已经很慢就算用了 DMA那 Python 代码的封装开销也会把性能优势吃干净。这话对但不全对。我们要做的不是绕过 Python 语法限制而是利用 MicroPython 提供的底层接口把关键的数据搬运交给硬件Python 层只负责配置寄存器、等待完成。这本身就是软件开发里很常见的“按需优化”思路热点在哪里就把哪里的开销降下来。2. RP2040的DMA通道结构寄存器偏移表比废话更有用写 MicroPython 操作 DMA你得先把 RP2040 的 DMA 寄存器当成自己的“API 手册”。因为 MicroPython 没有 DMA 封装你能依赖的就是物理地址读写。2.1 12个通道和它们的“分工逻辑”RP2040 的 DMA 控制器有 12 个通道编号从 0 到 11。每个通道都是独立的一套寄存器可以单独配置、单独启动、单独完成。通道之间通过仲裁器共享 AHB 总线如果多个通道同时请求优先级高的通道能先获得总线使用权。硬件上还有一个叫 CHAIN_TO 的字段让通道 A 完成传输后自动把通道 B 当“下一跳”形成链式传输适合分块转移。对我们这个内存拷贝任务来说一个通道就足够。但是理解多个通道的存在很重要如果你之后要在项目里同时做串口 DMA 接收和内存拷贝就需要选不同的通道避免相互干扰。2.2 通道寄存器偏移速查表每个通道占用 0x40 字节的地址空间。第 0 个通道的偏移如下偏移寄存器名作用0x00CTRL_TRIG写配置并触发传输读时返回当前控制状态0x04READ_ADDR源地址0x08WRITE_ADDR目标地址0x0CTRANS_COUNT传输字节数DMA 控制器基地址是0x50000000所以第 n 个通道的基地址公式就是0x50000000 n * 0x40比如通道 0 的READ_ADDR地址是0x50000000 0x04通道 1 的地址是0x50000000 0x40 0x04以此类推。这些偏移值在 RP2040 数据手册 2.5 节里都有你只需要掌握这个规律就能扩展到任意通道。2.3 CTRL_TRIG 寄存器里必须搞懂的字段CTRL_TRIG是整个 DMA 配置的核心。它既负责启动传输也负责描述传输参数。这里只看我们需要的几个关键位位段掩码含义bit 00x1EN置 1 启动传输bits 4:20x1CDATA_SIZE0字节1半字2字bit 50x20INCR_WRITE目标地址自增bit 60x40INCR_READ源地址自增bits 15:110xF800DREQ_SEL触发源选择0 表示无需外设请求bit 240x1000000BUSY只读状态1 表示通道正在工作内存到内存传输时DREQ_SEL置 0 意味着传输只要 EN 被置 1 就会全速进行不会等外设给请求信号。INCR_READ和INCR_WRITE都置 1源地址和目标地址就都会在每次传输后加 1这是连续内存拷贝的需求。DATA_SIZE选 0字节可以规避对齐问题适合新手入门。2.4 为什么 MicroPython 没有现成 DMA 类说实话如果 MicroPython 官方把 DMA 封装成dma.copy(src, dst)这种傻瓜接口这篇文章就不需要写了。但官方没有这么做原因是跨平台一致性考虑。MicroPython 要移植到不同芯片而各个芯片的 DMA 控制器差异极大很难抽象出一套通用 API。在 RP2040 上最直接的办法就是把这些寄存器当初级外设手册来操作。这反而是了解硬件的好机会因为你被迫读数据手册、理解位段含义而不是局限于调库。3. MicroPython里让DMA跑起来的前置操作mem32与addressof配置 DMA 前你需要三样东西访问寄存器的接口、获取内存地址的接口、以及一块连续且稳定的缓冲区。3.1 用 machine.mem32 把寄存器当成一个字典MicroPython 里machine.mem32是一个特殊的对象他允许你像读写字典一样读写物理内存地址。例如from machine import mem32 # 把 DMA 基地址的 CTRL_TRIG 寄存器清零不触发传输 mem32[0x50000000] 0 # 读取 DMA 通道 0 的状态 status mem32[0x50000000]当你要赋值的对象正好是寄存器时这个操作就是一次硬件写入。它的速度比uctypes.Struct快因为内部实现直接映射到单片机的 load/store 指令上。唯一要注意的是地址必须写对如果写错轻则没反应重则访问非法地址导致 HardFault。3.2 用 uctypes.addressof 获取缓冲区地址MicroPython 的uctypes.addressof()能返回对象内部缓冲区起始地址。重点这里要传对象本身比如bytearray或array而不是传buffer[0]。import uctypes buf bytearray(4096) addr uctypes.addressof(buf) print(hex(addr))在 RP2040 上MicroPython 分配的 RAM 地址一般在0x20000000附近没有任何 MMU 转换所以这个地址就是真实的物理地址你可以直接把它写入 DMA 的READ_ADDR或WRITE_ADDR寄存器。3.3 缓冲区选型bytearray 是首选内存拷贝用bytearray最稳因为它可变、底层内存连续、协议简单。bytes也可以当源但它是只读的如果当目标在逻辑上就不合理。array模块也可以只要保证数据类型长度一致。不建议直接用str或普通列表前者带编码和不可变限制后者不是连续内存DMA 无法直接寻址。还有一个大坑是 Python 对象可能被垃圾回收。MicroPython 的 GC 虽然不会移动对象地址但它的回收机制可能会把不再引用的bytearray内存释放掉。所以你要保证在整个 DMA 传输期间源和目标缓冲区都还活在当前作用域里最简单的方式是把它们定义为全局变量或者在函数调用时作为参数传入并保持引用。3.4 内存对齐字节传输是不是不需要关心对如果你的DATA_SIZE设为字节0那么源地址、目标地址和传输长度都不需要对齐。这在初学阶段是最省心的。当你以后想让传输速度更快把DATA_SIZE改成半字或全字时就必须保证地址按 2 字节或 4 字节对齐长度也要是对应单位的整数倍否则 DMA 会在传输过程中触发错误中断严重时直接挂死。新手刚入门从字节传输开始是最稳的路。4. 手写一个可用的内存DMA拷贝函数代码与拆解下面进入正题。我会给你一整套代码不仅包含寄存器定义还包含超时保护、状态检查以及简单的校验逻辑。你可以把它直接用在自己的项目里。4.1 定义寄存器地址和常量from machine import mem32 import uctypes import time # DMA 控制器基地址 DMA_BASE 0x50000000 # 每个通道的基址间隔为 0x40 def _dma_channel_base(ch): return DMA_BASE ch * 0x40 # 通道内寄存器偏移 DMA_CTRL_TRIG 0x00 DMA_READ_ADDR 0x04 DMA_WRITE_ADDR 0x08 DMA_TRANS_COUNT 0x0C # CTRL_TRIG 字段 CTRL_EN 1 0 CTRL_DATA_SIZE_BYTE 0 2 CTRL_INCR_WRITE 1 5 CTRL_INCR_READ 1 6 CTRL_DREQ_0 0 11这里我把CTRL_DREQ_0显式定义为 0目的就是告诉你内存到内存传输必须要让 DREQ_SEL 为 0。不显式清零的话如果寄存器里残留了别的外设选择值DMA 会傻等一个永远不会到来的请求信号通道就卡住了。4.2 编写核心 dma_memcpy 函数def dma_memcpy(src, dst, nbytes, channel0): 使用 RP2040 DMA 在内存间搬运数据。 src/dst 可以是 bytearray/array/memoryview也可以是整数形式的物理地址。 if isinstance(src, int): src_addr src elif isinstance(src, (bytearray, memoryview, array.array)): src_addr uctypes.addressof(src) else: raise TypeError(src type not supported) if isinstance(dst, int): dst_addr dst elif isinstance(dst, (bytearray, memoryview, array.array)): dst_addr uctypes.addressof(dst) else: raise TypeError(dst type not supported) base _dma_channel_base(channel) # 如果通道正在工作先等待 timeout 100000 while (mem32[base DMA_CTRL_TRIG] (1 24)) and timeout 0: timeout - 1 # 设置源地址、目标地址、传输字节数 mem32[base DMA_READ_ADDR] src_addr mem32[base DMA_WRITE_ADDR] dst_addr mem32[base DMA_TRANS_COUNT] nbytes # 构建控制字并触发传输 ctrl (CTRL_EN | CTRL_DATA_SIZE_BYTE | CTRL_INCR_READ | CTRL_INCR_WRITE | CTRL_DREQ_0) mem32[base DMA_CTRL_TRIG] ctrl # 等待 BUSY 位清除 timeout nbytes * 10 20000 while (mem32[base DMA_CTRL_TRIG] (1 24)) and timeout 0: timeout - 1 if timeout 0: raise RuntimeError(DMA transfer timeout) return nbytes注意一个细节在配置新传输前我先等待上一次可能存在的 BUSY 位清除。否则你往一个正在工作的通道里写地址和长度会破坏当前传输行为不可预测。这个等待虽然会增加一点点时间但换来的是健壮性值得。4.3 测试代码从填充到校验# 准备测试数据 src bytearray(256) dst bytearray(256) for i in range(len(src)): src[i] (i * 7) 0xFF # 执行 DMA 拷贝 start time.ticks_us() dma_memcpy(src, dst, len(src)) cost time.ticks_diff(time.ticks_us(), start) print(DMA copy time:, cost, us) # 校验 if src dst: print(check ok) else: print(check failed)我把源缓冲区每个字节赋值成一个等差数列然后 DMA 拷过去最后直接用比较两个bytearray。如果你的运行环境没问题应该会看到check ok。这一步能快速验证你是否正确配置了 DMA。4.4 把函数扩展成更稳的版本在实际项目里你可能会在同一时间想用不同通道。下面这个类可以帮忙管理通道资源class DMAMemCpy: def __init__(self, channel0): self.base _dma_channel_base(channel) def copy(self, src, dst, nbytes): if not isinstance(src, (bytearray, memoryview)): raise TypeError(src type not supported) if not isinstance(dst, (bytearray, memoryview)): raise TypeError(dst type not supported) src_addr uctypes.addressof(src) dst_addr uctypes.addressof(dst) while mem32[self.base DMA_CTRL_TRIG] (1 24): pass mem32[self.base DMA_READ_ADDR] src_addr mem32[self.base DMA_WRITE_ADDR] dst_addr mem32[self.base DMA_TRANS_COUNT] nbytes ctrl (CTRL_EN | CTRL_DATA_SIZE_BYTE | CTRL_INCR_READ | CTRL_INCR_WRITE | CTRL_DREQ_0) mem32[self.base DMA_CTRL_TRIG] ctrl while mem32[self.base DMA_CTRL_TRIG] (1 24): pass这个类适合在单个通道上反复使用省去每次传 channel 参数的开销。需要多个通道就实例化几个对象每个绑定一个通道号。5. 实测结果打破刻板印象DMA、切片拷贝和手动循环的差距很多人一听到 DMA就下意识认为肯定比 CPU 拷贝快。实际测下来你会发现情况很复杂。下面是我在树莓派 Pico 上使用 MicroPython 官方固件主频 125MHz 时的实测参考数据。5.1 测试方法我准备了一个 4096 字节的源缓冲区和目标缓冲区分别用三种方式拷贝DMA 内存拷贝即上面写的dma_memcpy函数。Python 切片赋值即dst[:] src这是 MicroPython 的 C 实现 memcpy。for 循环手动赋值即for i in range(4096): dst[i] src[i]。每种方式重复 20 次记录平均耗时。5.2 测试结果汇总拷贝方式平均耗时us备注DMA 内存拷贝约 350含寄存器配置和 BUSY 等待切片赋值 dst[:] src约 60底层是 C 层快速内存拷贝for 循环逐字节约 22000Python 解释器开销极大你没有看错DMA 在这个测试里并不是最快的切片赋值反而是黑马。原因很简单MicroPython 的dst[:] src直接调用了 C 语言的memcpy它对内存访问有高度优化而 DMA 拷贝需要配置多个 32 位寄存器、启动传输、等待 BUSY 位清除这部分固定开销至少要几百个时钟周期。当数据量小于几千字节时固定开销占比太高DMA 很难跑赢 C 函数 memcpy。5.3 为什么内存到内存 DMA 依然值得用问题的核心在于“CPU 占用”和“是否阻塞”。dst[:] src虽然快但是执行期间 CPU 被占用了。如果你的中断服务程序要求极端快速响应这种长时间占用是不可接受的。DMA 则不一样寄存器配置完成后剩下的事情完全由硬件完成CPU 可以在等待 BUSY 位的时候去处理其他任务。哪怕 DMA 总耗时稍长但 CPU 的平均负载大幅降低。另一个优势体现在与外设的协同场景。假设 ADC 用 DMA 不断把采样结果写入缓冲区A而你想每隔一秒钟把缓冲区A的数据复制到缓冲区B。如果复制动作交给另一个 DMA 通道那么 CPU 全程不需要碰这块数据只负责处理更复杂的逻辑。这就把“内存拷贝”从应用层任务变成了一个后台硬件任务。5.4 什么尺寸下 DMA 更划算根据寄存器配置开销估算一次 DMA 传输至少需要写 4 个寄存器、轮询状态大约几微秒。单纯从拷贝速度来说数据量小于 1KB 时 C 层 memcpy 通常更合适数据量达到几十 KB 时DMA 的传输效率和总线利用率会逐渐接近甚至超过 memcpy并且不占用 CPU 时间。不过在同一总线上DMA 也会占用内存带宽。真实收益主要体现在系统整体调度上而不是单项基准测试数据上。6. 避坑清单这些隐蔽问题不解决DMA会“神秘失效”内存到内存 DMA 看起来只涉及几个寄存器实际操作中仍可能遇到各种意外。下面这份避坑笔记是我自己调试时总结出来的每一条都是真实踩过的坑。6.1 寄存器偏移手滑写错DMA 直接触发 HardFault我在第一版代码里把READ_ADDR的偏移写成了0x00结果 DMA 把CTRL_TRIG当成了源地址读出来的值变成了一个随机数。更危险的是如果写到了非法的地址区域CPU 会直接进入硬件错误异常MicroPython 的 REPL 会打印Fatal error并重启。解决办法很简单所有偏移都先抠数据手册写完代码后先打印一遍地址确认不要想当然。6.2 DATA_SIZE 与地址未对齐导致的挂死把DATA_SIZE设置成 2全字后如果源地址或目标地址不是 4 的倍数RP2040 DMA 会在传输时触发一个硬件错误并停止。症状表现为函数卡在等待 BUSY 位清除的死循环里因为通道进入了错误状态。不要相信“碰巧能跑”建议先保证所有缓冲区都是 4 字节对齐的再尝试 word 模式。最快的对齐方法是分配时多加 3 个字节再手动找对齐地址或者用array(I, [0]) * N来确保 C 数组对齐。6.3 DREQ_SEL 忘记置 0内存到内存传输永远不启动有些教程会复制外设 DMA 的例子DREQ_SEL被设置成了某个外设的请求编号。在这种配置下DMA 会等待外部请求信号但你的源和目标是内存没有任何外设会发请求所以BUSY位永远是 1。这个坑的隐蔽之处在于代码看起来没错但传输就是不走。记得用CTRL_DREQ_0显式清零DREQ_SEL字段。6.4 等待完成不能用主循环的 BUSY 位做精确同步我一开始用while (mem32[base DMA_CTRL_TRIG] (1 24)): pass来等待。在小数据量下没问题但数据量大时DMA 完成写入后总线与 SRAM 之间可能还存在写缓冲BUSY 位清零并不代表数据已经完全可见。严格的做法是读取 DMA 的INTR中断状态寄存器偏移 0x400判断通道的完成标志位并读取目标缓冲区的最后一个字节确认。如果只是做数据拷贝和校验BUSY 位基本够用但如果你要用 DMA 送给外设还是要查数据手册的中断标志位。6.5 局部 bytearray 被 GC 回收地址变成“空指”如果在一个函数里创建src和dst然后把待传输的数据写到它们的地址上最后在函数返回后再调用 DMA 启动很可能会出现地址有效但内容被内存分配器改写的现象。因为函数返回后局部变量引用计数归零那块缓冲区可能被回收并重新分配给其他 Python 对象。解决办法很简单把缓冲区定义在函数外部或者作为参数传入并保持引用。最简单粗暴的法子是使用全局的bytearray。6.6 多个通道同时使用时的优先级问题RP2040 的 12 个 DMA 通道都有自己的中断标志和优先级。如果你在通道 0 做内存拷贝的同时通道 1 正在从串口搬运数据优先级默认由通道编号决定通道编号越小优先级越高。总体来说把实时性要求高的外设分配给小号通道更合理。内存拷贝通常可以容忍一点延迟所以放在大号通道也没问题。如果你需要串口接收不丢数据又想让内存拷贝跑得勤快点就把串口通道设为 0内存拷贝通道设为 1。6.7 在 MicroPython 线程里使用 DMA 的注意点_thread模块可以创建线程但 MicroPython 的 GIL 决定了同一时刻只有一个 Python 线程执行。当你的线程调用了dma_memcpy后另一个线程可能会抢占执行。如果两个线程同时操作同一个寄存器地址会出现数据错乱。建议所有 DMA 寄存器操作放在主线程里子线程只设置一个“需要拷贝”的标记。中断回调同理不要在中断处理函数里等待 BUSY 位容易造成中断嵌套混乱。我在实际项目里最后形成的经验是DMA 是硬件给你的免费劳动力但你要学会怎么把任务的边界划分清楚。内存到内存拷贝也好外设到内存传输也好你要的不是让某一项基准测试跑得飞快而是让整个系统在重负载下依旧稳定。如果你只是想在 MicroPython 里快速复制缓冲区dst[:] src已经够用如果你要做的是不阻塞 CPU 的后台搬运那这篇教程里的寄存器操作思路就是很好的切入点。最后分享一个小技巧调试 DMA 时不要直接在 REPL 里敲寄存器赋值然后立刻看结果因为 REPL 的垃圾回收可能在你按下回车后做内存整理导致传说中的“明明配置了地址却拷贝到错误位置”的幻觉。最好把测试代码写成一个.py文件用Thonny或rshell运行这样对象生命周期更可控调试也更舒服。