写这篇教程之前,我先说个真实感受:很多玩 RP2040 的朋友,一听到“DMA”就觉得是 C 语言、寄存器、甚至 SDK 的专利,MicroPython 这种解析执行的环境,似乎不应该碰这种底层东西。但 RP2040 这颗芯片的特殊之处在于,它的 MicroPython 固件从一开始就把 DMA 能力开放出来了,而且官方维护得很积极。你完全可以用几行 Python 代码,把一块内存里的数据搬运到另一块内存,整个过程 CPU 不参与,数据由 DMA 控制器自动处理。这篇文章就是以“内存到内存数据传输”这个最基础、也最能看清 DMA 工作逻辑的场景为入口,带你把 RP2040 的 DMA 从概念到实操完整走一遍。
这个需求听起来简单,价值却不小。Framebuffer 的快速滚动、音频波形数据合成、传感器采样结果的批量搬移、甚至做小型图像处理,都可能用到“内存到内存”的搬运。在 MicroPython 里如果靠 for 循环逐字节复制,256 字节倒还好,一旦到了几 KB 甚至几十 KB,那个耗时是你肉眼能感受到的卡顿。用 DMA 之后,搬运动作在后台完成,Python 层该干嘛干嘛。适合谁来读?我觉得两类人最合适:一是刚接触 RP2040、想看看 MicroPython 到底能碰多底层的新手;二是已经用 MicroPython 写了点小项目、发现 CPU 被数据搬运拖累的老手。保证不依赖 C 和 SDK,只需要一块 RP2040 开发板,以及最新版官方 MicroPython 固件。
1. 为什么要在 RP2040 上做内存到内存 DMA
1.1 内存到内存传输到底解决了什么问题
“内存到内存”这个说法,翻译成大白话就是:把 SRAM 里的一个连续区域,整体复制到 SRAM 里的另一个连续区域。可能很多人第一反应是,这有什么用?Python 里一个切片赋值不就完了吗?比如dst[:] = src[:]。在桌面上这确实轻松,但在单片机上,这句话背后是 CPU 在一条一条指令地搬运。MicroPython 作为解释型语言,每执行一个索引访问、每次给字节赋值,中间都夹着大量解释器开销,几个 MB 的数据复制会让你以为程序死机了。
DMA 的方案是让数据搬运脱离 CPU。你给 DMA 控制器三个关键信息:从哪读、写到哪、写多少个。然后启动它,剩下的搬运由硬件完成。搬完再告诉你一声。这样一来,CPU 在那段时间里可以去刷新屏幕、处理按键、跑算法逻辑,这在实时性要求高一点的项目里就是质的区别。你要理解的是,DMA 不是让“搬运这个动作”变快了多少,而是让“CPU 被搬运占用”这件事变成了“CPU 不用管搬运”。所以做内存到内存传输,本质是在优化 CPU 的利用率。
1.2 RP2040 的 DMA 控制器资源速览
RP2040 里的 DMA 控制器一共有 12 个独立通道,编号 0 到 11。每个通道都有自己独立的读地址寄存器、写地址寄存器、传输计数寄存器、控制寄存器。通道之间默认互不干扰,可以同时跑。这个资源和 STM32 上那种“内存到外设、外设到内存、内存到内存”三选一的工作模式不太一样,RP2040 的 DMA 更粗暴:任意来源、任意目标、只要源和目标地址对 CPU 可访问,就能搬。
每个通道支持 8 位、16 位、32 位三种数据宽度,分别用 0、1、2 表示。数据宽度决定了“一次硬件搬运操作”搬几个字节,也决定了地址自增的步长。传输计数是一个 20 位的值,最大能数到 1048575。这还没完,RP2040 的通道支持链式连接,也就是一个通道搬完,硬件自动触发下一个通道,这条链最长可以把 12 个通道串起来。这些特性叠加起来,让这颗芯片的 DMA 可玩性非常高。
1.3 MicroPython 里能不能碰 DMA:固件版本与前提
先说结论:能碰,而且官方一直在维护。RP2040 的 MicroPython 端口在比较早的版本里就加入了rp2模块,里面有PIO、StateMachine、Flash这些类。后来官方又往rp2里加入了DMA类,用来操作 DMA 控制器。我不太建议用太老的固件,因为早期固件里模块 API 不稳定。建议直接去 micropython.org 下载针对 Raspberry Pi Pico 的最新版固件,至少在 1.20 以上比较稳妥。你可以在板子上跑import os; print(os.uname())确认版本。
硬件方面,任何 RP2040 芯片的板子都可以,不限品牌,Pico、Pico W,或者各种合宙、微雪的核心板都行。不需要额外元件,纯开发板就可以把本教程的实验做完。程序运行在 MicroPython 的 REPL 环境里,你可以先用交互模式快速试,确认没问题了再写进main.py。这个教程的所有示例都按“在 REPL 跑也能验证结果”的方式设计。
2. 核心概念:DMA 通道、触发源和寄存器
2.1 一条 DMA 传输的最小三要素
一条最简单的 DMA 传输只需要三样东西:源地址、目标地址、传输次数。你可以把它们想象成一场运货任务:源地址是仓库,目标地址是工地,传输次数是车要跑几趟。源地址对应 DMA 通道的READ_ADDR寄存器,目标地址对应WRITE_ADDR寄存器,传输次数对应TRANS_COUNT寄存器。
但有个细节和“运货”不一样。普通的仓库取货,你从第 0 个货架取完,第二次应该从第 1 个货架取,这叫源地址自动递增。DMA 不会默认帮你递增。如果不开递增,它永远从同一个地址读、写同一个地址。所以CTRL_TRIG寄存器里专门有READ_INCR和WRITE_INCR两个位,分别控制读地址和写地址是否在每传输完一个数据单元后自动加一步。做内存到内存搬运时,这两个位通常都要置 1,否则你就会看到“数据始终是第一个字节重复写满目标区”的诡异现象。
2.2 内存到内存传输的触发源 DREQ_FORCE
DMA 本质上是一个可以独立运行状态机,但它需要一个“开始”信号,这个信号叫触发源,对应寄存器里的TRIG_SEL字段。RP2040 的触发源非常多:UART 的发送请求、ADC 的采样完成、PWM 的周期翻转、PIO 的 RX/TX,甚至定时器中断都能触发 DMA。
但对于“内存到内存”这种场景,我们不需要等任何外设,只希望 DMA 拿到 CPU 配置之后立刻开干。这时候就要用TRIG_SEL = 0x3F。这个值在官方文档里被称为DREQ_FORCE,含义是忽略外设信号,由软件直接启动,且每次传输都无条件继续。凡是做纯内存搬运,触发源都用这个。很多初学者在抄代码时看到trigger=0x3F不明所以,直接把这段代码抄到外设场景里,结果发现 DMA 根本不听外设节拍。记住:0x3F 是强推模式,只适合不需要同步的搬运任务。
2.3 MicroPython 的 rp2.DMA 封装和它背后的寄存器
rp2.DMA类可以让你用 Python 对象去管理 DMA 通道,底层封装的内容其实就是那一组寄存器。它的核心方法是config(),用来设置读地址、写地址、计数、数据宽度、触发源,然后通过active(1)启动传输,wait()等待传输完成,active(0)关闭通道,close()释放通道。
硬核一点看底层,DMA 控制器的基地址是0x50000000,每个通道占 0x40 字节的寄存器空间。以通道 0 为例,偏移 0x00 是读地址READ_ADDR,偏移 0x04 是写地址WRITE_ADDR,偏移 0x08 是传输计数TRANS_COUNT,偏移 0x0C 是控制寄存器兼触发寄存器CTRL_TRIG。通道 1 就在基地址加上 1 乘以 0x40,也就是从 0x50000040 开始。用 Python 操作时,只需要machine.mem32[地址] = 值就能读能写,非常直接。
我建议你刚开始先用rp2.DMA类把程序跑通,毕竟它代码量小、不容易出错。但如果后面想调一些特殊功能,比如链式传输、字节交换、自定义触发源,直接操作寄存器会更自由,因为你看到的每一个位都是硬件文档里白纸黑字的定义。
2.4 关键约束:数据宽度、地址对齐和计数上限
DMA 最怕的就是“没对齐”。数据宽度设为 2 代表 32 位传输,每次搬运 4 字节。这种情况下,源地址和目标地址必须是 4 的倍数,否则硬件会直接抛错。同样的道理,16 位传输要求 2 字节对齐,8 位传输没有对齐要求。新手拿一个bytearray去搬,因为 bytearray 的数据区通常是 4 字节对齐的,所以 8 位、16 位、32 位都能跑;但如果你从bytearray中间某个偏移位置开始搬,比如memoryview切到偏移 1 处,再配合 32 位传输,就很容易踩到对齐问题。
传输计数上限 20 位,也就是最多 1048575 次。假设你用 32 位宽度,一次 DMA 最多能搬 4194300 字节,接近 4 MB。看着挺大,但如果你的数据是连续大块超过这个数,就必须手动拆分成多次传输。注意,这个上限是指“传输次数”,不是“字节数”,具体多少字节还要乘上数据宽度。这块搞不清楚的话,后面做大文件搬运很容易莫名其妙只搬了一部分。
3. 保姆级实操:第一个内存到内存 DMA 程序
3.1 工程准备与固件检查
先把开发板连接到电脑,打开 Thonny 或者你习惯用的串口终端,进入 REPL。先确认固件没问题,输入下面这两行:
import os, sys print(os.uname())应该能看到类似(version=v1.23.0, machine= Raspberry Pi Pico with RP2040)这样的输出。如果版本特别老,建议先升级固件。接着检查rp2.DMA是否存在:
from rp2 import DMA help(DMA)能看到类的文档就说明固件支持。如果报错,说明你用的固件没有编译这个类,解决办法是刷最新官方固件。我不鼓励为了 DMA 去用第三方魔改固件,官方维护的路线在稳定性和文档匹配度上都会好很多。
3.2 代码一:用 rp2.DMA 完成 256 字节搬运
下面这段是完整可跑的第一个程序。它的作用是把源数组src里的 256 字节,复制到全零的dst数组里。
from rp2 import DMA SRC_SIZE = 256 src = bytearray(SRC_SIZE) dst = bytearray(SRC_SIZE) for i in range(SRC_SIZE): src[i] = (i * 7 + 1) & 0xFF dma = DMA() dma.config( read=src, write=dst, count=SRC_SIZE, data_size=0, # 0 代表 8 位宽度 trigger=0x3F, # DREQ_FORCE,强制立即传输 ) dma.active(1) dma.wait() dma.active(0) dma.close() print("match:", src == dst) print("dst first 8 bytes:", list(dst[:8]))把这个代码粘贴进 REPL,正常情况下输出会是这样:
match: True dst first 8 bytes: [1, 8, 15, 22, 29, 36, 43, 50]如果看到match: True,恭喜,第一次内存到内存 DMA 已经跑通了。整个过程没有 C 代码,没有编译,没有刷自定义固件,就是 MicroPython 环境里用官方类完成的。
3.3 逐行拆解这段代码在干什么
src = bytearray(SRC_SIZE)创建了一个 256 字节的可写缓冲区,dst同理,初始全是 0x00。这里我建议用bytearray,不要用bytes,因为bytes是不可变对象,部分固件在处理bytes传地址时行为不一致;bytearray是官方测试最充分的缓冲区类型。
dma.config()是核心配置。read=src指定源对象,固件内部会从src取出它真正存储数据的地址,这个地址就是 DMA 要读取的READ_ADDR。write=dst同理。count=SRC_SIZE表示传输 256 次,配合data_size=0(每次 8 位),正好是 256 字节。trigger=0x3F是内存到内存的标准触发源。
注意dma.active(1)这句,它不仅是“打开通道”,更重要的作用是真正让 DMA 跑起来。wait()会一直阻塞,直到传输完成。如果你不希望程序卡在这里,后面可以换轮询或者中断的方式。传输完成后用active(0)关闭通道,用close()把通道资源还给系统。如果不调close(),一直创建 DMA 对象的话,等 12 个通道用完,新的DMA()就会分配失败。
3.4 验证结果:如何在 REPL 里确认传输成功
上面代码里用src == dst判断两个数组完全相等。这个判断在 MicroPython 里对bytearray是逐字节比较的,结果是布尔值。看到True就说明数据内容一致。为了更直观,我还打印了dst[:8]的前 8 个字节。如果没有,而是全 0,那问题基本出在触发源、地址递增或者 DMA 根本没启动上。
我建议你做个反向实验,帮助理解 DMA 到底写入的是“数据”而不是“地址引用”。把dst初始化为全 0xFF,再跑一次传输,最后打印出来。如果前面的dst前 8 字节变成 1、8、15……而不是 0xFF,说明 DMA 确实把源数据覆盖进去了。比如:
dst = bytearray([0xFF] * SRC_SIZE) # 重新配置 dma 并启动... print(list(dst[:8]))这个实验很值得做,因为它能帮你确认目标缓冲区是被真实写入的,而不是 Python 层面发生了对象引用共享。如果读者对 DMA 抱有“这玩意儿是不是只是个拷贝魔术”的怀疑,做完这个实验基本就踏实了。
3.5 深入底层:不用 rp2.DMA,直接操作寄存器
接下来我们撇开rp2.DMA类,直接控制寄存器。这会让你对 DMA 的工作过程有更本质的理解。首先,需要拿到缓冲区的真实数据地址。MicroPython 里uctypes.addressof(buf)返回的是bytearray对象头的地址,而不是数据区的地址。在官方 RP2040 固件里,bytearray的数据区指针存放在对象头偏移 12 字节处。可以用下面这个小函数获取:
import uctypes from machine import mem32 def buffer_addr(buf): return mem32[uctypes.addressof(buf) + 12]如果你的固件版本比较新,也可以试试ctypes模块,用 ctypes 数组创建缓冲区后,ctypes.addressof(obj)直接返回数据区地址,逻辑上更干净:
import ctypes src = (ctypes.c_ubyte * 256)() dst = (ctypes.c_ubyte * 256)() src_addr = ctypes.addressof(src) dst_addr = ctypes.addressof(dst)拿到地址后就可以直接写寄存器了。下面是一个完整示例:
from machine import mem32 import uctypes SRC_SIZE = 256 src = bytearray(SRC_SIZE) dst = bytearray(SRC_SIZE) for i in range(SRC_SIZE): src[i] = (i * 7 + 1) & 0xFF src_addr = buffer_addr(src) dst_addr = buffer_addr(dst) DMA_BASE = 0x50000000 ch = 0 base = DMA_BASE + ch * 0x40 mem32[base + 0x00] = src_addr # READ_ADDR mem32[base + 0x04] = dst_addr # WRITE_ADDR mem32[base + 0x08] = SRC_SIZE # TRANS_COUNT ctrl = (1 << 0) | (0 << 1) | \ (1 << 22) | (1 << 23) | \ (0x3F << 15) mem32[base + 0x0C] = ctrl # CTRL_TRIG,写入即启动 while mem32[base + 0x0C] & 1: pass print("match:", src == dst) print("dst first 8 bytes:", list(dst[:8]))这里的ctrl含义要看清:bit0 是使能位 EN,置 1 表示启动;bit1 到 bit2 是 DATA_SIZE,0 表示 8 位;bit22 是 READ_INCR,bit23 是 WRITE_INCR,都置 1,让读写地址自动递增;bit15 到 bit21 是 TRIG_SEL,0x3F 左移 15 位,就是把这个值写到触发源字段里。写完CTRL_TRIG的瞬间 DMA 就启动了,之后的 while 循环是不断读CTRL_TRIG的 EN 位,传输完成后硬件会把 EN 自动清 0,循环跳出。
这个版本虽然代码里多了buffer_addr函数,但只要理解了它,你对 MicroPython 内存管理和 DMA 硬件底层的理解都会上一个台阶。如果不想管偏移值,直接用 ctypes 数组更省心。
3.6 性能观察:DMA 对比 Python for 循环搬运
做完了能跑,肯定想知道它到底快多少。我建议你做一个直观对比。用 4096 字节数据,分别用 for 循环搬运和 DMA 搬运,记录耗时。代码可以这样写:
from rp2 import DMA import time SRC_SIZE = 4096 src = bytearray(SRC_SIZE) dst = bytearray(SRC_SIZE) for i in range(SRC_SIZE): src[i] = i & 0xFF # 方法一:for 循环 start = time.ticks_us() for i in range(SRC_SIZE): dst[i] = src[i] print("for loop:", time.ticks_diff(time.ticks_us(), start), "us") # 方法二:DMA dma = DMA() dma.config(read=src, write=dst, count=SRC_SIZE, data_size=0, trigger=0x3F) start = time.ticks_us() dma.active(1) dma.wait() dma.active(0) dma.close() print("dma:", time.ticks_diff(time.ticks_us(), start), "us")在我自己测试过的环境里,for 循环耗时通常是几千到上万微秒,DMA 这边往往只有几十微秒。不过必须说清楚,程序里dma.active(1)这一行在 Python 层也有调用开销,所以测到的时间不完全是纯 DMA 搬运时间。但即便如此,数量级差距已经足够说明问题。数据量越大,DMA 优势越明显;数据量如果只有几十字节,那就不一定划算。项目里要是只搬 8 个字节,老老实实用 Python 循环反而更简单。
4. 进阶玩法:一次 DMA 不够,怎么组合用
4.1 循环触发:用定时器/PWM 做周期采样
内存到内存只是 DMA 最基础的形态。真正体现 DMA 威力的是“外设触发 + 批量搬运”的组合。RP2040 的 DMA 触发源表里有大量外设事件,比如 PWM 的周期事件、定时器事件、ADC 完成事件。你可以让 DMA 每次等 PWM 信号到来,才搬运一个数据,这就实现了“按节拍搬运”。
比如你要连续采集传感器波形,常规做法是 CPU 不断读 ADC 寄存器,很占时间。换成 DMA 后,可以配置 DMA 从 ADC 结果寄存器读取数据,写入一个 1024 字节的数组,触发源选 ADC 转换完成事件。ADC 每完成一次转换,DMA 自动搬一个字到数组,CPU 完全不用参与。这在 MicroPython 里也可以组合,你需要把触发源从 0x3F 改成对应外设的 DREQ 编号。具体编号建议查阅 RP2040 数据手册的 DMA 章节,因为版本不同,DREQ 编号表是固定的但比较多,这里先不硬搬表格,以免抄错。
4.2 链式传输:一个通道结束自动启动下一个
单个通道搬完就结束了,但很多项目要搬的不是一块连续内存。比如你想把三个不同地址的碎片化数据,拼接到同一个目标缓冲区里,一次 DMA 做不到,CPU 逐个处理又慢。RP2040 的CHAIN_TO字段就是干这个的。
配置方式不复杂。通道 0 的CTRL_TRIG里,CHAIN_TO字段写入通道 1 的编号 1,那么通道 0 一旦完成,DMA 控制器会自动把通道 1 的配置寄存器加载过来,并启动通道 1。你可以把三个通道分别配成三个不同的源地址、相同的目的地址加上不同的偏移,数据就能连续拼接起来。MicroPython 的rp2.DMA类多多少少会暴露这个能力,如果类的参数里没有,底层寄存器方式一定是能做的。这个特性做双缓冲时特别有感觉:一个通道在搬前半段,另一个通道在搬后半段,CPU 则在处理已经搬完的部分。
4.3 字节交换与半字/字宽度传输
做音频或者图像处理时,经常会遇到字节序问题。比如一个 16 位的音频采样值,在内存里是小端排列,但你要输出成大端格式交给某个编码协议。逐字节交换太蠢,RP2040 的 DMA 控制器提供了对换位(byte swap),可以在搬运动作完成时自动调整字节顺序。这个功能在寄存器里是由BSWAP字段控制的,具体它把多少字节做翻转、翻转粒度如何,强烈建议打开数据手册对照着配。因为不同宽度下表现不太一样,软件上如果一次性配错,出来的是乱数据。
数据宽度方面,data_size=1时,一次搬运 16 位,地址自动递增 2 字节;data_size=2时,一次搬运 32 位,地址自动递增 4 字节。如果你要把一个 U16 数组从源地址搬到一个 DMA 外设的 FIFO 里,半字模式就非常贴合。内存到内存场景下,32 位模式在单次搬运吞吐量上最高,但需要对齐。比如做一个 RGB565 的帧缓冲水平滚动,用半字模式一次搬一个像素,就比 8 位模式高效很多。
4.4 和 ADC/UART/PIO 结合:从外设到内存
最后给个延展方向。RP2040 特别好玩的一点是 DMA 和 PIO 可以打通。PIO 是一种可编程的 IO 状态机,它能在 GPIO 上生成非常精确的时序,但产生的数据总要有个地方放。如果你用 PIO 采集一串曼彻斯特编码信号,或者做了一路逻辑分析仪的波形采样,数据到达 PIO 的 RX FIFO 后,与其用 Python 死等,不如让 DMA 自动把 FIFO 里的数据搬到内存数组里。PIO 的 RX 请求就是 DMA 的一个触发源。配置 DMA 时,读地址填 PIO 的 FIFO 地址,写地址填内存数组地址,触发源选对应的 PIO RX 事件。这样一套下来,波形采样的速度上限远比 Python 轮询高得多。
我一直觉得,RP2040 的 DMA 不是孤立的一个外设,它更像连接内存、PIO、ADC、UART 这些模块的高速传输通道。理解了内存到内存的底层流程,其他所有“外设到内存”或“内存到外设”的玩法都能举一反三,无非是把读地址或者写地址从普通 SRAM 地址换成一个外设 FIFO 地址罢了。
5. 高频踩坑与排查记录
5.1 常见问题速查表
我在 RP2040 + MicroPython 上折腾 DMA 时,踩过不少坑,也帮群友排查过很多类似问题,下面这个表基本覆盖了 90% 的初学者情况。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 传输后目标缓冲区全 0 | 触发源配成了某种外设信号,DMA 没等到信号 | 内存到内存场景把 TRIG_SEL / trigger 设为 0x3F |
| 目标缓冲区全部是第一个字节 | 忘了开 READ_INCR,源地址永远不递增 | READ_INCR / write_incr 置 1 |
| 程序卡在 wait() 不返回 | 通道没有正确启动,或配置未生效 | 先确认 active(1) 调用;检查是否通道被占用 |
| 只有部分数据被搬运 | TRANS_COUNT 超过 20 位上限 | 拆分多次传输,或减少单次计数 |
| 出现 Hard Fault 或板子重启 | 源/目标地址不在合法内存区域,或对齐错误 | 检查地址范围;32 位宽度要求 4 字节对齐 |
| 二次运行时报通道不可用 | 上一次 DMA 对象没释放 | 每次用完后调用 close() |
| 数据顺序反了或字节错乱 | 字节交换位被意外打开 | 检查 BSWAP 配置;确认数据宽度匹配 |
| 大数组搬运速度没有提升 | 数据量本身不大,Python 层启动开销占大头 | 改用更大数据量测试,或考虑中断方式 |
5.2 如何定位 DMA 是否真的在跑
写寄存器版本的代码时,有时你没法判断 DMA 到底是没启动,还是跑完了但数据不对。最简单的方法是分步验证。第一,把count改成极小值,比如 4,搬运 4 字节。第二,传输前读一次READ_ADDR寄存器,传输后再读一次,看看读地址有没有前进。对于增量模式,如果 DMA 跑过,读地址会自动变成源地址加上 4 或者 8。在 MicroPython 里可以这样看:
print(hex(mem32[base + 0x00])) # 传输后读 READ_ADDR如果读地址确实前进,说明 DMA 逻辑在跑,问题出在目标缓冲区地址或者验证方式上。如果读地址完全没变,说明通道可能压根没启动,或者触发源配置有问题。还有一个通用技巧:把控制寄存器CTRL_TRIG的值先读出来,看看你写入的位是不是真的写进去了。MicroPython 的mem32写入一般不会失败,但有时候你可能会写错通道基地址,导致写进了某个不存在的寄存器。
5.3 从 MicroPython 侧保护缓冲区的 3 个习惯
用 Python 操作 DMA 有一个和 C 语言很大的区别:Python 对象的内存管理不由你完全掌控。虽然 MicroPython 的bytearray数据区在内存里是连续且固定的,但你的代码里其他操作可能在不知不觉中触发垃圾回收,虽然不会搬移已分配块,但也可能释放掉你不再引用的缓冲区。有 3 个习惯我建议尽早养成。
第一,确保 DMA 搬运期间,源和目标bytearray对象一直被引用着。如果你把bytearray作为局部变量传给某个函数,函数返回后局部变量可能被回收。最好在调用dma.active(1)之前,把缓冲区对象赋值给一个模块级变量或函数外层的变量。第二,如果要在 DMA 还在跑的时候做其他计算,尽量不要再分配大对象,因为大量分配可能触发堆整理,虽然不移动但影响实时性。真要在关键时刻杜绝内存操作,可以用micropython.heap_lock()和micropython.heap_unlock(),但这两个接口比较激进,只能在确定不会分配到内存的时候用。第三,用完 DMA 务必close(),否则通道被占满后,下次DMA()会分配失败,这时程序倒不会崩溃,只会抛异常,但排查起来特别浪费时间。
回到这次实验本身。我现在做一个项目时,凡是涉及批量数据搬运,第一反应都是在草稿纸上先画清楚:源是什么、目标是什么、数据宽度多少、要不要递增、能不能用 DMA。RP2040 的 DMA 确实是我在嵌入式开发里用起来最顺手的控制器之一,因为它直接把通道、触发源、链式这些概念拆得很干净。希望这篇教程能让你从第一个 256 字节的内存搬运跑通开始,一点点建立起对这种底层机制的掌控感。我最后再说一个小技巧:以后看到别人代码里trigger=0x3F或者0x3F << 15,别跳过去,那往往就是内存到内存传输的“血液”,搞懂它,比抄十遍代码都管用。