ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RP2040 MicroPython DMA内存搬运实战:手写dma_copy告别慢速循环

RP2040 MicroPython DMA内存搬运实战:手写dma_copy告别慢速循环 在 RP2040 上用 MicroPython 写数据搬运逻辑最让人崩溃的就是“循环复制”这个场景。你辛辛苦苦写了个for i in range(len(src)): dst[i] src[i]跑起来才发现速度慢到怀疑人生换成dst[:] src虽然底层是 C 实现但 CPU 照样被同步阻塞住大缓冲区一份就是一两毫秒什么都干不了。其实 RP2040 这颗芯片自带一套功能很完整的 DMA 控制器MicroPython 里也能直接用只是资料少、示例乱很多教程一上来就让你翻 SDK 源码搞得大家听到“DMA”就觉得高不可攀。这篇文章我准备用最朴素的方式把 MicroPython RP2040 下 DMA 做“内存到内存数据传输”这件事彻底讲透读完你不仅能写出自己的dma_copy还能理解背后的寄存器、触发机制和坑点特别适合被数据处理速度卡住、又不想放弃 MicroPython 的开发者。1. 为什么要做 DMA 内存搬运1.1 MicroPython 里的数据复制痛点MicroPython 跑在 RP2040 上Python 层的循环效率比 C 慢了不止一个数量级。我们经常遇到这样的场景传感器一次性采回几 KB 数据、图像帧需要做行列变换、音频缓冲需要前后搬移这些操作如果用 Python 层逐字节循环来做64KB 数据复制一次可能要花掉几百毫秒这在任何实时性敏感的场景里都是不可接受的。有人会说那用dst[:] src不就行了这个操作确实是由 MicroPython 内部的 C 代码实现的速度快很多但它仍然是“同步”的复制期间 CPU 被占用你的 MicroPython 线程只能干等着。在需要同时处理采集、显示、通信的任务里这种阻塞会带来明显的调度抖动。更关键的是RP2040 这套 Cortex-M0 双核芯片本身专门为这种数据搬运设计了 DMA 硬件不用白不用。1.2 RP2040 DMA 到底能做什么RP2040 的 DMA 控制器有 12 个独立通道可以在内存、外设、PIO、Flash XIP 之间搬运数据支持增加读地址、增加写地址、链式传输、环形缓冲甚至可以在一次传输完成后自动触发另一个通道继续干活。很多人以为 DMA 只能配合外设用比如 SPI 接收、UART 发送、ADC 采样其实它最基础也最实用的一种模式就是“内存到内存”。我们只需要把源地址、目标地址、传输次数填进寄存器再设置一个强制请求DMA 就会自己把数据搬完不需要任何外设参与。这里涉及到 RP2040 DMA 的触发机制DMA 的每一次数据传输都需要一个 DREQ 请求。普通外设模式下DREQ 来自 SPI、UART、PIO 等但如果我们把 DREQ 设置成DREQ_FORCEDMA 就会一直处于“请求有效”状态相当于软件强制触发这正是实现内存到内存搬运的关键。1.3 什么场景才真正值回票价不是所有复制都要上 DMA。数据只有几十字节、复制频率很低DMA 的配置开销反而比直接 C 层切片更大。真正值回票价的场景有几个特点数据块大典型是 1KB 以上的缓冲区复制频率高比如音频帧、图像帧、传感器批量数据搬运过程可以和 CPU 其他逻辑并行或者至少希望 CPU 不被长时间阻塞。比如说你要把一块图像帧缓冲搬到另一块缓冲做处理再搬运到 PIO 控制的屏幕接口这种“搬来搬去”的操作如果全用 Python 层代码CPU 被吃得死透用 DMA 把这些搬运甩给硬件CPU 就能腾出来跑协议栈、UI 逻辑或者其他计算任务。2. MicroPython 里怎么调 DMA寄存器直操作才是王道2.1 为什么不用 rp2.DMA 封装MicroPython 官方固件里其实提供了一个rp2.DMA类理论上可以直接用dir(rp2.DMA)查看方法。但这里有个现实问题不同固件版本的 API 差异很大有人用的 1.19、有人用 1.23还有各种第三方编译版本DMA类的属性和方法经常对不上网上抄来的代码在另一台设备上很容易直接报错。所以我更推荐用machine.mem32直操作寄存器。MicroPython 的machine.mem32可以直接读写 RP2040 的内存映射地址DMA 控制器的寄存器本质上就是一段内存地址这种方式不受固件封装变化影响只要寄存器地址和位段定义不变代码就能稳定跑。直操作寄存器还有一个好处你能真正理解 DMA 的工作流程。等你以后去看官方 C SDK 或者移植到其他芯片核心思路完全通用。2.2 寄存器地图与一次传输的执行流程RP2040 DMA 控制器的基地址是0x50000000。每个通道占 0x40 字节的寄存器空间通道 0 的四关键寄存器如下寄存器偏移作用READ_ADDR0x00源地址WRITE_ADDR0x04目标地址TRANS_COUNT0x08传输次数CTRL_TRIG0x0C控制参数写入后触发传输通道 1 的寄存器等于基地址加 0x40通道 n 的基地址就是0x50000000 n * 0x40。一次内存到内存 DMA 传输的流程只有三步把源地址填入READ_ADDR把目标地址填入WRITE_ADDR把传输次数填入TRANS_COUNT再向CTRL_TRIG写入控制字DMA 就会开始搬运。CTRL_TRIG这个名字很有意思“CTRL”是控制“TRIG”是触发也就是说向它写入配置的同时还会触发通道启动。2.3 CTRL_TRIG 位域与参数计算CTRL_TRIG是一个 32 位寄存器每个位段都要搞清楚否则 DMA 根本不会按你预想的方式工作。以下是本次最关键的几个位段位段位范围说明TREQ_SEL23:16DREQ 请求源选择设为 0x3F 表示强制请求CHAIN_TO15:11链式通道配置初始用 0 即可RING_SEL10是否启用环形缓冲选择位RING_SIZE9:6环形缓冲大小不用则填 0INCR_WRITE5每传一个单元写地址自动增加INCR_READ4每传一个单元读地址自动增加DATA_SIZE3:2传输数据宽度08位116位232位HIGH_PRIORITY1高优先级通道EN0使能写 1 表示启动通道参数计算的要点在于TRANS_COUNT存的不是“字节数”而是“传输单元的个数”。如果你设置DATA_SIZE0也就是 8 位模式那么传 1024 字节就需要填1024如果你设置DATA_SIZE2也就是 32 位模式那么传 1024 字节只需要填1024 // 4 256。这一点特别容易踩坑很多人逻辑看着不错结果数据只搬了一部分或者在末尾越界就是因为把字节数直接填进了TRANS_COUNT。内存到内存传输时INCR_READ和INCR_WRITE一般都设为 1这样 DMA 才会把源地址和目标地址依次递增。如果不设置DMA 会一直重复读写同一个地址结果就是整块数据全变成了第一个字节的复制。3. 保姆级实操从零写一个 dma_copy3.1 准备缓冲区并获取物理地址在 MicroPython 里我们能直接拿到 Python 对象的内部内存地址用uctypes.addressof。这段代码创建两个 256 字节的bytearray一个当源一个当目标from machine import mem32 from uctypes import addressof src bytearray(range(256)) # 0x00~0xFF dst bytearray(256) # 全 0 src_addr addressof(src) dst_addr addressof(dst) print(hex(src_addr), hex(dst_addr))bytearray对象在内存中的地址是物理地址RP2040 的 SRAM 从0x20000000开始DMA 可以直接访问这片地址。MicroPython 的 GC 是标记清除、不移动对象的所以只要你一直持有src和dst的引用地址就是稳定的。小提示bytearray分配出来的内存通常 4 字节对齐但这不是绝对保证。为了稳妥我建议在配置 DMA 时先检测地址对齐情况再决定用 32 位模式还是 8 位模式。3.2 配置寄存器并触发完成第一次内存搬运现在开始第一次真正的 DMA 内存到内存传输。为了安全先用 8 位模式也就是每个传输单元 1 个字节DMA_BASE 0x50000000 CH0_BASE DMA_BASE 0x00 # 填源地址和目标地址 mem32[CH0_BASE 0x00] src_addr mem32[CH0_BASE 0x04] dst_addr # 传输次数8位模式等于字节数 mem32[CH0_BASE 0x08] len(src) # 控制字TREQ_SEL0x3F 强制请求INCR_WRITE1INCR_READ1 # DATA_SIZE08位EN1 ctrl (0x3F 16) | (1 5) | (1 4) | (0 2) | 1 mem32[CH0_BASE 0x0C] ctrl # 等待传输完成 while mem32[CH0_BASE 0x08] ! 0: pass print(copy done:, dst src)运行后如果打印copy done: True说明 DMA 已经成功把 256 字节从src搬到了dst。这一步看着简单但背后已经包含了 DMA 工作的全部核心地址、计数、控制使能。while mem32[CH0_BASE 0x08] ! 0是轮询等待方式。DMA 每完成一个传输单元TRANS_COUNT就会递减一次减到 0 表示整批数据搬完了。虽然轮询会让 CPU 干等但作为教学和简单场景已经够用后面再聊更高级的异步方式。3.3 封装成可复用函数把上面的逻辑封装成一个函数顺便支持 32 位模式加速。我推荐这样写from machine import mem32 from uctypes import addressof DMA_BASE 0x50000000 DREQ_FORCE 0x3F def dma_copy(dst, src, nbytesNone): if nbytes is None: nbytes min(len(src), len(dst)) if nbytes 0: return 0 ch_base DMA_BASE # 固定用通道0 s addressof(src) d addressof(dst) # 地址4字节对齐时优先用32位模式 if (s | d) 3 0 and nbytes % 4 0: data_size 2 2 count nbytes 2 else: data_size 0 2 count nbytes mem32[ch_base 0x00] s mem32[ch_base 0x04] d mem32[ch_base 0x08] count ctrl (DREQ_FORCE 16) | (1 5) | (1 4) | data_size | 1 mem32[ch_base 0x0C] ctrl while mem32[ch_base 0x08] ! 0: pass return nbytes # 测试 src bytearray(range(256)) dst bytearray(256) dma_copy(dst, src) print(dst[:16])这个函数兼容性很好无论目标地址是否对齐都能工作。唯一要注意的是在 32 位模式下nbytes必须是 4 的倍数否则会漏掉末尾的 1~3 个字节所以我在判断条件里加上了nbytes % 4 0。3.4 注意TRANS_COUNT 不是字节数这个坑我栽过一次必须单独拿出来说。TRANS_COUNT寄存器存的是传输单元个数而不是字节数。同样搬 1024 字节8 位模式TRANS_COUNT 102416 位模式TRANS_COUNT 51232 位模式TRANS_COUNT 256如果把 32 位模式的TRANS_COUNT误填成1024DMA 会尝试读取源地址后面 4KB 的空间、写入目标地址后面 4KB 的空间结果就是数据错乱甚至可能把不相关的内存区域覆盖成垃圾数据。RP2040 的 DMA 不检查越界它只是忠实地按你的地址和计数搬运所以所有的边界检查都必须自己做。4. 性能实测与适用边界4.1 测试脚本口说无凭直接跑性能测试。测试对象是 64KB 的缓冲区对比三种方式Python 循环复制、bytearray 切片复制、DMA 复制。import time from machine import mem32 from uctypes import addressof src bytearray(65536) dst bytearray(65536) # 给源数据填充 0x5A for i in range(len(src)): src[i] 0x5A # 1. Python 循环复制 t0 time.ticks_us() for i in range(len(src)): dst[i] src[i] t1 time.ticks_us() print(python loop:, time.ticks_diff(t1, t0), us) # 2. bytearray 切片复制 t0 time.ticks_us() dst[:] src t1 time.ticks_us() print(slice copy:, time.ticks_diff(t1, t0), us) # 3. DMA 复制使用上面的函数 DMA_BASE 0x50000000 DREQ_FORCE 0x3F s addressof(src) d addressof(dst) ch_base DMA_BASE count len(src) // 4 mem32[ch_base 0x00] s mem32[ch_base 0x04] d mem32[ch_base 0x08] count ctrl (DREQ_FORCE 16) | (1 5) | (1 4) | (2 2) | 1 t0 time.ticks_us() mem32[ch_base 0x0C] ctrl while mem32[ch_base 0x08] ! 0: pass t1 time.ticks_us() print(dma copy:, time.ticks_diff(t1, t0), us)这里注意DMA 计时从写入CTRL_TRIG开始不包含配置寄存器的时间这是公平的对比方式。4.2 结果解读在 RP2040 默认 125MHz 下运行结果大概类似这样方式64KB 复制耗时CPU 占用Python 层 for 循环300ms~400ms全占bytearray 切片复制0.3ms~0.6ms同步阻塞DMA 复制0.2ms~0.4ms可后台执行看到这个数据你就明白DMA 和切片复制的真实耗时差距并没有传说中那么大因为切片复制底层也是高效的 C 内存拷贝。DMA 真正的优势在于两点第一它把搬运操作从 CPU 上卸载了虽然本轮询等待也会让 CPU 空转但如果你配合中断或异步逻辑CPU 完全可以在 DMA 搬运的同时去跑其他任务。第二DMA 可以用来衔接外设和内存比如 SPI 接收数据、PIO 输出波形这些场景下 CPU 根本没法手动介入那么快的时钟节奏DMA 是唯一靠谱方案。4.3 什么时候别用 DMADMA 不是银弹。每次配置寄存器、等待完成是有固定开销的如果只复制几十字节用dst[:] src可能更快也更简洁。判断标准很简单一次复制少于 256 字节用切片复制单次复制几百字节但低频也用切片复制大块数据 需要背靠背搬运 希望 CPU 腾出来干活再上 DMA。不要为了“用 DMA”而用 DMA硬件资源是有限的12 个通道真正繁忙时也要分配策略。5. 常见问题与排查实录5.1 问题速查表现象可能原因解决方法DMA 启动后卡死TRANS_COUNT不减TREQ_SEL没设为DREQ_FORCEDMA 在等外设请求将位段 23:16 设为 0x3F数据只复制了一部分32 位模式下TRANS_COUNT错填成字节数统一用 8 位模式或按字节数/4计算复制完成但目标数据全部是同一个值忘了设置INCR_READDMA 反复读同一个源地址设置第 4 位为 1程序提示 HardFault 或内存访问异常源地址或目标地址未对齐且使用了 32 位模式检查对齐回退到 8 位模式DMA 复制结果不稳定偶尔错乱缓冲区对象被 GC 回收或地址在多次调用之间变化确保src/dst引用持有不要用临时对象目标内存全是 0xFF 或乱码写入地址错误或者WRITE_ADDR没有正确计算用print(hex(addressof(dst)))先确认地址5.2 最容易踩的两个坑第一个坑就是边界检查。MicroPython 的bytearray是可变对象DMA 不会管你对象的长度是多少它只认你填进去的物理地址和计数。addressof拿到的是对象内部缓冲区的起始地址一旦你TRANS_COUNT超过了缓冲区长度DMA 就会越过边界读到相邻内存区域。这个行为不会报错但会让你的数据莫名其妙变成垃圾还会让调试过程异常痛苦。第二个坑是缓冲区生命周期。我在测试时曾经把一个bytearray直接用在了dma_copy函数内部结果函数返回后临时对象被 GC 回收下一次使用 DMA 通道时地址已经指向不可用的内存。解决方案是确保 DMA 使用的缓冲区在传输期间一直被强引用持有最简单的方法就是把它们定义成模块级变量或传给函数的外部变量。5.3 验证数据的小技巧调试 DMA 时不要整天看完整数据太费眼睛。我喜欢这样验证# 写入固定值和模式 src bytearray([0x11, 0x22, 0x33, 0x44] * 64) dst bytearray(len(src)) dma_copy(dst, src) # 快速校验 print(dst[:32]) print(dst src)如果要确认大块数据没有错位可以把dst打乱后做比对或者用一个异或校验函数计算整个缓冲区的校验值传给dma_copy之后再算一次目标区的校验值两个值一致基本就说明搬运成功。6. 进阶玩法把 DMA 能力延伸到外设和后台6.1 DMA SPI/UART/OLED 刷新内存到内存 DMA 只是第一层功夫真正常用的是 DMA 和外设配合。以 SPI 发送为例把TREQ_SEL设置成对应 SPI 的 DREQ 值DMA 就会在 SPI 准备好接收数据时自动把内存数据搬给 SPI 发送寄存器CPU 完全不用参与逐字节发送。这套思路也可以延伸到 OLED 刷新显存放在内存里通过 DMA SPI 刷到屏幕MicroPython 主逻辑只负责更新显存内容硬件自动搬运刷屏数据。对帧率有要求的场景这个优化立竿见影。6.2 链式 DMA 与缓冲区环形搬运RP2040 的 DMA 支持链式传输也就是一次传输完成后自动加载下一个通道的配置继续工作。配合双缓冲区可以做到完美拼接DMA 在搬缓冲区 A 的时候CPU 填充缓冲区 B传输完成触发链式通道去搬 B这样就能实现无间隙的连续数据流。环形缓冲模式也很有意思设置RING_SIZE后地址递增到边界会自动回卷非常适合 FIFO、音频环形队列这类场景。不过这两块都属于复杂功能建议先把基础的内存到内存搬运练熟再逐步尝试。6.3 我的后续计划就我个人而言接下来准备把 DMA 搬到 PIO 场景里。RP2040 的 PIO 可以自定义时序DMA 负责搬运数据两者配合能实现很多花活。等到这套组合跑通我再专门写一篇完整流程。现在先把本文的内存到内存版本用熟你的 RP2040 数据搬运效率一定会有一个质变。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进