MicroPython操作RP2040 DMA实现内存到内存数据传输保姆级教程

发布时间:2026/9/7 3:53:19
MicroPython操作RP2040 DMA实现内存到内存数据传输保姆级教程 做了小半年 MicroPython 开发平时最烦的就是两件事一是 Python 循环太慢二是在内存里搬数据还得等 CPU。前阵子做一块 RP2040 的显示驱动板需要在两块 RAM 缓冲区之间频繁拷贝帧数据128x64 的 framebuffer 也就 1KB用普通 for 循环搬一次要吃掉好几毫秒CPU 直接被拖死。后来把这块“搬砖”的活交给了 DMA 控制器内存到内存数据传输直接把等待时间压到几十微秒量级CPU 还能同时去刷新 GPIO、处理按键逻辑体验完全不一样。这篇文章就是一份保姆级教程全程基于 MicroPython 直接操作 RP2040 的 DMA 寄存器跑通内存到内存Memory-to-Memory简称 M2M数据传输。我会从 RP2040 DMA 的寄存器地图讲起再到怎么在 MicroPython 里拿到一块缓冲区 RAM 的真实地址然后一步步写出可运行的代码最后附上实测对比和踩坑清单。适合那些对 MicroPython 很熟、但对寄存器操作还比较陌生的朋友。1. 为什么在 MicroPython 里做内存到内存 DMA 不是闲得慌1.1 真实场景MicroPython 什么时候需要大量内存搬运很多人一听到“DMA 内存到内存传输”第一反应是MicroPython 根本不适合干这种事Python 慢不如直接用 C。但实际项目中MicroPython 要面对的内存拷贝场景比想象中多图形界面OLED/LCD 的 framebuffer 滚动、图层合成、图片描画。128x64 单色屏的 framebuffer 是 1024 字节往上叠一个小图标、往左滚一列都是整块内存搬运。传感器数据帧陀螺仪/加速度计的原始数据先存在临时数组里攒够一帧后要搬到 DMA 发送缓冲区或环形队列经常是一回搬运几百字节。数据流转发串口、I2C、SPI 收到的不定长数据先落地在接收缓冲处理完还要搬到另一个 buffer 排队等发送。双缓冲切换后台绘制与前台显示各用一块 buffer绘制完成后把整块 buffer 的内容搬给显示驱动。这些场景如果都用 Python 的 for 循环一个字节一个字节地拷数据量一旦过 KB 级解释器的开销会被放得非常大。DMA 控制器作为一个独立于 CPU 的硬件搬运工可以把整块内存原封不动地搬走CPU 干别的事就行。1.2 直接把字节拷过去不行吗解释器开销分析在 RP2040 的 MicroPython 环境里执行下面这段代码for i in range(n): dst[i] src[i]每一次循环都会经历取值、索引、赋值、递增、比较而这背后的解释器循环在 133MHz 的 RP2040 上大约要吃掉几百纳秒到一两个微秒。单纯搬运 1KB 数据用这个循环会花上几毫秒。如果是在硬实时性要求较高的外设驱动流程中这几毫秒就是实实在在的 CPU 占用。相比之下DMA 控制器一旦配置完成搬运 1KB 数据只需要几十微秒左右视总线状态而定而且这个过程不需要 CPU 一条一条指令地跟着走。只要你用的是连续内存块DMA 就是天然的工具。当然不是说 Python 循环一无是处而是明确一件事连续内存块的批量搬运本来就该交给 DMA。2. RP2040 DMA 控制器的保姆级寄存器地图2.1 DMA 通道与寄存器布局RP2040 的 DMA 控制器在地址0x50000000一共有 12 个独立通道编号 0 到 11。每个通道独占0x4064 字节的寄存器空间通道 n 的基地址就是DMA_BASE 0x50000000 chan_base DMA_BASE n * 0x40每个通道里最常用的寄存器其实就 4 个我做了一张表偏移寄存器名作用0x00READ_ADDR源地址。DMA 从这个地址开始读数据0x04WRITE_ADDR目标地址。DMA 往这个地址写数据0x08TRANS_COUNT传输计数。写入要传输的字节数/元素数传输过程中会递减0x0CCTRL_TRIG控制寄存器。写入即触发一次传输也可以读取状态除了这 4 个每个通道还有 AL1_CTRL、AL2_CTRL、AL3_CTRL 这些别名寄存器以及 AL1_READ_ADDR、AL1_WRITE_ADDR、AL1_TRANS_COUNT_TRIG 等。它们的基本作用是把“配置”和“触发”拆开避免在需要反复触发时反复写同一个 CTRL_TRIG 导致配置被覆盖。对于初学来说直接用前 4 个就够了。提示CTRL_TRIG 与控制字的区别要搞清楚。CTRL_TRIG 是一个 32 位寄存器一次写入既包含通道控制配置也包含“触发一次传输”的动作。如果只配置不想触发要用 AL1_CTRL / AL2_CTRL / AL3_CTRL 这类别名寄存器。2.2 CTRL_TRIG 关键位域逐个看CTRL_TRIG 是整篇教程的核心。它的 32 个 bit 中做内存到内存传输时最常用到这些位位名称作用bit 0EN通道使能必须置 1bit 1HIGH_PRIORITY高优先级置 1 后该通道优先级更高bit [3:2]DATA_SIZE每个传输元素的大小08bit116bit232bitbit 4INCR_READ源地址递增置 1 表示每次传输后源地址自动加一个元素大小bit 5INCR_WRITE目标地址递增置 1 表示每次传输后目标地址自动加一个元素大小bit [13:9]CHAIN_TO链式传输的下一个通道M2M 单次传输通常设 0bit [21:16]TREQ_SEL传输请求源选择。内存到内存传输必须填 0x3FDREQ_FORCEbit 22WRITE_ERROR写错误状态bit 23READ_ERROR读错误状态bit 24BUSY忙标志。传输未完成时为 1完成后自动清零bit 28AHB_ERRORAHB 总线错误。地址越界或非法访问时置 1内存到内存传输控制字的常用组合如下CTRL (1 0) | (0 2) | (1 4) | (1 5) | (0x3F 16) # EN1 8bit 源地址递增 目标地址递增 TREQ_SEL0x3F这个控制字的意思是使能 DMA 通道按字节搬运源地址和目标地址都自动递增并且不依赖任何外设请求靠软件写入 CTRL_TRIG 的方式触发传输。2.3 DREQ_FORCE内存到内存传输的开关TREQ_SEL 这个字段是很多教程里容易一笔带过、但实际又极其重要的地方。DMA 控制器平时搬运数据通常需要一个“数据请求信号”来推动比如串口接收 FIFO 有数据了DMA 才去搬SPI 发送 FIFO 空了DMA 才去搬。这个请求信号叫 DREQ每个外设都有自己固定的 DREQ 编号。但是内存到内存传输没有外设参与数据请求从哪来答案是DREQ_FORCE它在 RP2040 里的值是0x3F。把 TREQ_SEL 写成 63也就是强制拉高请求信号DMA 只要收到软件触发就会传输。这就是为什么很多人照着外设 DMA 的示例改内存搬运时怎么都不动——TREQ_SEL 一直填的是某个外设的编号DMA 在那里傻等一个永远不会来的外设请求。注意DREQ_FORCE 0x3F也就是十进制的 63。这个值在 MicroPython 代码里可以直接写0x3F 16不要写成0 16。这一位设错是最常见的“DMA 不执行”原因。3. MicroPython 侧的基础设施访问寄存器与拿缓冲区地址3.1 machine.mem32 直接读写外设寄存器MicroPython 的machine模块提供了mem8、mem16、mem32三个对象可以直接读写 CPU 地址空间。RP2040 上的 DMA 寄存器都是 32 位的所以这里用mem32from machine import mem32 DMA_BASE 0x50000000 chan_base DMA_BASE 0 * 0x40 mem32[chan_base 0x00] src_addr # READ_ADDR mem32[chan_base 0x04] dst_addr # WRITE_ADDR mem32[chan_base 0x08] byte_count # TRANS_COUNT mem32[chan_base 0x0C] ctrl # CTRL_TRIG写入即触发mem32的用法和外设寄存器地址映射直接对应省去了 C 语言里的*(volatile uint32_t *) REG那种写法。本质上一样就是把一个整数值写到指定地址。3.2 用 uctypes.addressof 获取缓冲区真实地址MicroPython 的uctypes模块里有一个很关键的函数addressof(obj)。它可以返回一个支持 buffer 协议对象的内部缓冲区起始地址。比如from uctypes import addressof buf bytearray(256) print(addressof(buf)) # 例如 1072300032这里要注意几点传入的必须是字节缓冲类对象比如bytearray、bytes、array创建的数组。普通的list不能用因为list存的是指向 Python 对象的指针不是连续内存。bytearray(256)得到的是 8 位元素缓冲区地址可以是任意对齐。如果要用 32 位搬运最好用array(I, ...)并且要检查地址是否 4 字节对齐。addressof返回的地址是 MicroPython 堆上的地址映射到 RP2040 的内存地址空间里DMA 可以正常访问。下面看一个用array创建 32 位缓冲区的例子from array import array src array(I, [0x11111111, 0x22222222, 0x33333333]) dst array(I, [0, 0, 0]) src_addr addressof(src) dst_addr addressof(dst) print(hex(src_addr), hex(dst_addr))3.3 为什么 MicroPython 的对象地址可以稳定使用很多从 C 语言过来的人会担心Python 对象不是会被 GC 移动吗地址会不会变这里有一点可以放心MicroPython 的 GC 是非移动式 GC。也就是说当一个对象在堆上分配出来后它的地址在存活期间是稳定的不会被垃圾回收移到别处。所以只要你在 DMA 传输期间保持对源缓冲区和目标缓冲区的引用地址就不会变。但有一个坑必须注意如果在函数里创建了一个局部bytearrayDMA 传输还没完成函数就返回了这个bytearray的引用计数归零GC 可能把它回收掉。而 DMA 还在傻傻地往那块地址写数据这时候就会出现“数据凭空消失”甚至“系统崩溃”。所以做 DMA 传输时源和目标缓冲区一定要在调用方维持引用最好在传输完成后才允许释放。4. 手写三段式 DMA 传输代码并跑通4.1 准备缓冲区并填充测试数据先从最简单的 8 位搬运开始。准备两个 256 字节的bytearray一个源、一个目标源数据填充成有规律的递增序列方便传输完成后验证from machine import mem32 from uctypes import addressof import time DMA_BASE 0x50000000 src bytearray(256) dst bytearray(256) for i in range(256): src[i] i 0xFF这里用递增序列是为了后面检查数据时能快速看出问题如果dst[j]不等于src[j]一定是这次传输出了问题。4.2 拼装控制字控制字按前面说的组合来拼chan_base DMA_BASE 0 * 0x40 # 读取源/目标地址 src_addr addressof(src) dst_addr addressof(dst) # 配置源地址、目标地址、传输字节数 mem32[chan_base 0x00] src_addr mem32[chan_base 0x04] dst_addr mem32[chan_base 0x08] 256 # 控制字 # bit0 EN 1 # bit4 INCR_READ 1源地址递增 # bit5 INCR_WRITE 1目标地址递增 # TREQ_SEL 0x3F 16强制请求 ctrl (1 0) | (1 4) | (1 5) | (0x3F 16) # 写入 CTRL_TRIG触发传输 mem32[chan_base 0x0C] ctrl4.3 触发、等待完成、检查结果写完 CTRL_TRIG 后DMA 通道进入忙碌状态。在 MicroPython 里最简单的等待方式就是轮询 BUSY 位# 等待 DMA 完成 while mem32[chan_base 0x0C] (1 24): pass # 验证结果 for i in range(256): if dst[i] ! src[i]: print(Mismatch at, i) break else: print(DMA OK)BUSY 位在 CTRL_TRIG 的 bit 24。传输结束后该位自动清零读到的值会变成 0。所有数据搬运完成后dst里的内容应该和src完全一致。4.4 封装成可复用的 dma_memcpy 函数为了后面能反复调用把它封装成一个函数比较合理from machine import mem32 from uctypes import addressof import time DMA_BASE 0x50000000 def dma_memcpy(dst, src, nNone, channel0): if n is None: n min(len(src), len(dst)) if n 0: return src_addr addressof(src) dst_addr addressof(dst) base DMA_BASE channel * 0x40 # 先写地址和长度 mem32[base 0x00] src_addr mem32[base 0x04] dst_addr mem32[base 0x08] n # 8 bit 传输、源/目标地址递增、强制请求 ctrl (1 0) | (1 4) | (1 5) | (0x3F 16) mem32[base 0x0C] ctrl # 等待完成加超时保护 t0 time.ticks_ms() while mem32[base 0x0C] (1 24): if time.ticks_diff(time.ticks_ms(), t0) 200: raise OSError(DMA timeout)调用方式很简单src bytearray(bHello RP2040 DMA) dst bytearray(len(src)) dma_memcpy(dst, src) print(dst) # bHello RP2040 DMA这个函数兼容任意支持缓冲协议的对象。后面跑性能对比时直接用它来测 DMA 的耗时不用每次重写配置代码。4.5 用 array(I) 跑 32 位搬运字节传输在某些场景下不够高效如果要搬运大量的 32 位像素数据、音频采样数据建议直接用 32 位 DMA。一个完整的例子from array import array from machine import mem32 from uctypes import addressof import time DMA_BASE 0x50000000 src array(I, [0x11111111, 0x22222222, 0x33333333, 0x44444444]) dst array(I, [0, 0, 0, 0]) src_addr addressof(src) dst_addr addressof(dst) elem_count len(src) base DMA_BASE 0 * 0x40 mem32[base 0x00] src_addr mem32[base 0x04] dst_addr mem32[base 0x08] elem_count # DATA_SIZE 2 2 ctrl (1 0) | (2 2) | (1 4) | (1 5) | (0x3F 16) mem32[base 0x0C] ctrl while mem32[base 0x0C] (1 24): pass print(dst)32 位模式下TRANS_COUNT 表示的是元素个数而不是字节数这一点要特别留意。比如src长度是 4TRANS_COUNT 写 4DMA 会搬 4 个 32 位数据也就是 16 字节。5. 实测数据DMA 到底比 Python 拷贝快多少5.1 测试方法光说快不算数直接上板子测。用time.ticks_us()来测两种方式复制 1KB 数据的耗时。Python 侧用最直白的 for 循环DMA 侧用上面封装的dma_memcpy。测试脚本大致长这样import time from machine import mem32 from uctypes import addressof src bytearray(4096) dst bytearray(4096) for i in range(len(src)): src[i] i 0xFF # 测 for 循环 t0 time.ticks_us() for i in range(len(src)): dst[i] src[i] t1 time.ticks_us() print(Python copy:, time.ticks_diff(t1, t0), us) # 测 DMA t0 time.ticks_us() dma_memcpy(dst, src, len(src)) t1 time.ticks_us() print(DMA copy:, time.ticks_diff(t1, t0), us)注意这套测试脚本里dma_memcpy函数内部包含配置寄存器和等待完成两部分时间。这样测出来的是实际落地耗时而不是理论带宽。5.2 不同长度下的对比结果以下是我手头这块 Pico 板RP2040 133MHzMicroPython 1.20 固件跑出的量级参考实际环境不同会有差异但数量级的差别是稳定的数据长度Python for 循环DMA 搬运倍率256 B约 0.3 ms约 8 us约 35 倍1 KB约 1.2 ms约 14 us约 85 倍4 KB约 4.8 ms约 30 us约 160 倍可以看到数据量越大DMA 的优势越明显。这是因为 Python 一个字节一个字节地走解释器循环开销线性增长而 DMA 只是配置时间长一点搬运本身由硬件完成增长非常有限。5.3 结论DMA 的价值不在“更快”而在“不占 CPU”这里必须把话说透DMA 内存到内存传输的真正优势不是让“某一次拷贝”变快而是让 CPU 在拷贝期间彻底解放。上面测试里虽然只是“等待完成”了几十微秒但你可以想象一下真实项目如果你在 MicroPython 里用 DMA 搬运 framebuffer那么 CPU 可以在这几十微秒里去处理扫描按键、更新计数器、处理外设中断。如果你在发送串口数据时用 DMA 搬运发送缓冲CPU 可以去填下一块缓冲区的数据实现流水线作业。如果是一次性的、只有几十字节的小拷贝用 Python 循环反而更简单DMA 的配置开销不一定划算。所以什么时候用 DMA 内存到内存传输一句话连续数据块越大、搬运次数越频繁越值得用。6. 踩坑与排查第一次跑不通的常见原因清单6.1 传输没开始TREQ_SEL 设成了 0这是我在教程和论坛里看到最多的提问。现象是写入 CTRL_TRIG 后BUSY 位一直是 1TRANS_COUNT 也不减少程序卡死在等待循环里。原因几乎都是 TREQ_SEL 没有设成 DREQ_FORCE。有些人从外设 DMA 的例程改过来直接填了 SPI、UART 的 DREQ 编号DMA 一直在等那个外设的信号。排查方法很简单在读回 CTRL_TRIG 时检查一下ctrl_now mem32[chan_base 0x0C] print(hex((ctrl_now 16) 0x3F)) # 应该输出 0x3f如果读出来不是0x3F那就说明控制字拼错了。6.2 地址对齐问题导致数据错乱或死机使用 32 位 DMA 时源地址和目标地址都必须 4 字节对齐传输元素个数也是按 32 位算的。如果src、dst是用bytearray建的地址不一定是 4 字节对齐这时候直接上 32 位 DMA很容易出现 AHB 总线错误严重时直接把 MicroPython 干崩。更隐蔽的坑是缓冲区长度不是 4 的倍数但代码里没有检查。TRANS_COUNT 按元素个数算此时多传的字节会越过缓冲区边界写到相邻内存里可能踩坏解释器的变量甚至堆。所以用 32 位传输前务必加上检查if (src_addr 3) or (dst_addr 3): raise ValueError(address must be 4-byte aligned) if len(src) % 4 ! 0 or len(dst) % 4 ! 0: raise ValueError(length must be multiple of 4)推荐的做法32 位搬运就用array(I)建缓冲区长度天然是 4 的倍数地址对齐也交给分配器通常不会出问题。6.3 等待完成不要死等记得看 AHB_ERROR初学者容易把等待循环写成while mem32[chan_base 0x0C] (1 24): pass如果配置有误这个循环就是死循环。建议至少加一个超时t0 time.ticks_ms() while mem32[chan_base 0x0C] (1 24): if time.ticks_diff(time.ticks_ms(), t0) 100: raise OSError(DMA timeout)同时在传输完成后检查 AHB_ERROR 位bit 28和读写错误位bit 23、bit 22可以帮助定位地址越界、非法访问之类的问题。错误位写 1 表示发生过错误读完后软件清零的方式是写 1 清除但更稳妥的做法是直接重新配置整个通道。6.4 GC 回收对象导致数据丢失MicroPython 的 GC 虽然不会移动对象但会回收不再被引用的对象。如果你在函数里写完这样一段代码def bad_copy(): tmp_src bytearray(1024) tmp_dst bytearray(1024) dma_memcpy(tmp_dst, tmp_src, len(tmp_src)) # 函数返回后 tmp_src/tmp_dst 不再被引用可能被GC回收看起来好像没有毛病但如果测试环境里堆内存紧张GC 可能在函数返回后的某个时刻回收缓冲区而 DMA 已经写完了问题不明显。更危险的是如果 DMA 传输还没结束函数就返回了硬件还在往一块即将被回收的内存里写数据整个堆都被污染。所以凡是做 DMA源和目标缓冲区都应该由调用方持有引用传输完成后再丢弃。6.5 推荐的调试顺序第一次上手建议按下面的顺序来能少踩很多坑先用 8 位传输、小缓冲区比如 16 字节跑通基本流程确认 BUSY 等待和结果校验逻辑正确。再换大一点的连续内存比如 1KB确认 INCR_READ、INCR_WRITE 的行为符合预期。最后再上 32 位传输并且加好对齐检查和超时保护。如果发现异常一件事一件事地排除控制字、地址、长度、等待方式。我个人在实际操作中的体会是RP2040 的 DMA 内存到内存传输一旦跑通一次后续就是在 12 个通道之间自由调度的事。你完全可以把几个通道固定给不同的数据搬运任务再用 CHAIN_TO 把多个通道串成链式传输实现“搬完一块接着搬下一块”的流水线。最后的最后再分享一个小技巧如果缓冲区地址不满足 32 位对齐但又想提高传输效率可以考虑先做一次字节搬运把地址“对齐”剩下的主体部分用 32 位 DMA尾部再用字节补齐。不过这个概念对 MicroPython 来说有点偏底层了平时先保证对齐条件比任何优化都来得实际。