news 2026/9/7 3:53:15

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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。每个通道独占0x40(64 字节)的寄存器空间,通道 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通道使能,必须置 1
bit 1HIGH_PRIORITY高优先级,置 1 后该通道优先级更高
bit [3:2]DATA_SIZE每个传输元素的大小:0=8bit,1=16bit,2=32bit
bit 4INCR_READ源地址递增,置 1 表示每次传输后源地址自动加一个元素大小
bit 5INCR_WRITE目标地址递增,置 1 表示每次传输后目标地址自动加一个元素大小
bit [13:9]CHAIN_TO链式传输的下一个通道,M2M 单次传输通常设 0
bit [21:16]TREQ_SEL传输请求源选择。内存到内存传输必须填 0x3F(DREQ_FORCE)
bit 22WRITE_ERROR写错误状态
bit 23READ_ERROR读错误状态
bit 24BUSY忙标志。传输未完成时为 1,完成后自动清零
bit 28AHB_ERRORAHB 总线错误。地址越界或非法访问时置 1

内存到内存传输控制字的常用组合如下:

CTRL = (1 << 0) | (0 << 2) | (1 << 4) | (1 << 5) | (0x3F << 16) # EN=1 8bit 源地址递增 目标地址递增 TREQ_SEL=0x3F

这个控制字的意思是:使能 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模块提供了mem8mem16mem32三个对象,可以直接读写 CPU 地址空间。RP2040 上的 DMA 寄存器都是 32 位的,所以这里用mem32

from 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

这里要注意几点:

  • 传入的必须是字节缓冲类对象,比如bytearraybytesarray创建的数组。普通的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 传输期间保持对源缓冲区和目标缓冲区的引用,地址就不会变。

但有一个坑必须注意:如果在函数里创建了一个局部bytearray,DMA 传输还没完成,函数就返回了,这个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] = ctrl

4.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, n=None, channel=0): 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(b'Hello RP2040 DMA') dst = bytearray(len(src)) dma_memcpy(dst, src) print(dst) # b'Hello 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长度是 4,TRANS_COUNT 写 4,DMA 会搬 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 @133MHz,MicroPython 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 位一直是 1,TRANS_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 位算的。如果srcdst是用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 推荐的调试顺序

第一次上手,建议按下面的顺序来,能少踩很多坑:

  1. 先用 8 位传输、小缓冲区(比如 16 字节)跑通基本流程,确认 BUSY 等待和结果校验逻辑正确。
  2. 再换大一点的连续内存,比如 1KB,确认 INCR_READ、INCR_WRITE 的行为符合预期。
  3. 最后再上 32 位传输,并且加好对齐检查和超时保护。
  4. 如果发现异常,一件事一件事地排除:控制字、地址、长度、等待方式。

我个人在实际操作中的体会是,RP2040 的 DMA 内存到内存传输一旦跑通一次,后续就是在 12 个通道之间自由调度的事。你完全可以把几个通道固定给不同的数据搬运任务,再用 CHAIN_TO 把多个通道串成链式传输,实现“搬完一块接着搬下一块”的流水线。

最后的最后,再分享一个小技巧:如果缓冲区地址不满足 32 位对齐,但又想提高传输效率,可以考虑先做一次字节搬运把地址“对齐”,剩下的主体部分用 32 位 DMA,尾部再用字节补齐。不过这个概念对 MicroPython 来说有点偏底层了,平时先保证对齐条件,比任何优化都来得实际。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 3:52:41

分清两种OOK:Brainfuck/Ook!解码脚本与无线波形0和1的判断

简介&#xff1a;Brainfuck及其变体Ook是CTF赛事中常见的极简编程语言&#xff0c;选手常需将其解码为可读文本&#xff0c;但比赛环境经常无法访问在线解码网站。这套离线解码工具正是为解决这一痛点而设计&#xff0c;主要面向CTF参赛者、安全竞赛选手以及对异种编程语言有兴…

作者头像 李华
网站建设 2026/9/7 3:48:54

SQL LIKE 模糊查询全解析:从通配符、性能优化到防注入最佳实践

很多开发者第一次接触 LIKE&#xff0c;是在写“模糊查询”的时候&#xff1a;输入一个关键词&#xff0c;把包含它的记录全部捞出来。这个需求太常见了&#xff0c;常见到我们几乎不会停下来想一个问题——LIKE 真的是实现模糊匹配的最好方案吗&#xff1f;它有哪些容易踩的坑…

作者头像 李华
网站建设 2026/9/7 3:45:32

PEGASUS方法学:自动驾驶测试验证的底层逻辑与场景库构建指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 3:44:50

[Feature Name] Implementation Plan

[Feature Name] Implementation Plan 【免费下载链接】superpowers An agentic skills framework & software development methodology that works. 项目地址: https://gitcode.com/GitHub_Trending/su/superpowers For agentic workers: REQUIRED SUB-SKILL: Use su…

作者头像 李华