rp2.DMA 从原理到实战:RP2040 的 DMA 配置与避坑指南
2026/9/8 16:47:03 网站建设 项目流程

在 Pico 上用 MicroPython 折腾到一定阶段,很多人会卡在同一个问题上:CPU 太忙,数据搬不动。要么是 ADC 采样跟不上,要么是 WS2812 灯带刷起来卡顿,要么是 PIO 收进来的数据来不及取。答案基本都指向同一个东西——rp2.DMA。这篇文章要讲的就是用 rp2.DMA 这个 API 做软件配置的全过程,每个参数对应 RP2040 硬件的哪个寄存器、背后是什么原理,以及我实际调 DMA 时踩过的各种坑。适合已经会写 PIO 程序、但对 DMA 还一头雾水的朋友,也适合 DMA 配了半天数据不对、想系统排查一遍的人。

1. rp2.DMA 到底在干什么:先建立硬件直觉

1.1 一句话理解 DMA 与“软件控制”的含义

DMA 的全称是 Direct Memory Access,直译就是“直接内存访问”。你可以把它理解成一个专职搬运工:你告诉它货在哪个仓库(源地址)、要放到哪个货架(目的地址)、搬多少箱(传输次数)、每次搬多大(传输位宽)、货车什么时候出发(触发条件),然后它就开始干活,期间完全不需要 CPU 插手。

标题里强调“软件控制”,是因为在 MicroPython 生态里,很多库会在背后偷偷帮你配置 DMA,比如一些 sensor 驱动、音频播放库,你根本感知不到 DMA 的存在。而 rp2.DMA 是把 DMA 控制器赤裸裸地暴露给你,让你用 Python 代码直接操作那 12 个 DMA 通道的寄存器。理解了这一层,以后不管看什么 DMA 代码,你都能把它翻译成“搬运工从哪搬到哪、等什么信号才出发”这个模型。

1.2 RP2040 的 DMA 资源:通道、寄存器与 DREQ

RP2040 内部有 12 个独立的 DMA 通道,编号 0 到 11。每个通道都拥有一组自己的寄存器,地址从0x50000000开始,每 0x40 字节一个通道。最关键的 4 个寄存器是:

偏移寄存器作用
0x00CHn_CTRL控制字,包含位宽、递增方向、触发源、BUSY 状态等
0x04CHn_READ_ADDR当前读地址(源地址)
0x08CHn_WRITE_ADDR当前写地址(目的地址)
0x0CCHn_TRANS_COUNT剩余传输次数,每次传输后自动减一

这里有个概念必须理解,就是 DREQ。DREQ 是外设发给 DMA 的“硬件请求信号”,相当于外设在喊“我准备好了,你可以搬了”。比如 PIO0 的 SM0 发送 FIFO 有空位时,会拉高DREQ_PIO0_TX0;接收 FIFO 里有数据时,会拉高DREQ_PIO0_RX0。DMA 通道一旦配置了某个 DREQ,就会老老实实等这个信号,信号不来,通道就一直挂着。如果你没配 trigger,固件会把触发源设成“持续请求”,也就是无条件一直搬,纯内存拷贝就是这么跑的。

1.3 什么场景该用它,什么场景别硬上

该用的场景很明确。一是高频数据采集,比如用 PIO 以 1MHz 采样数字信号,数据量一上来,Python 轮询读 FIFO 必然掉数据,DMA 可以一边采一边往内存里堆。二是对时序要求苛刻的外设驱动,最典型的就是 WS2812 灯带,PIO 负责产生精确的 800kHz 波形,DMA 负责源源不断往 PIO 的 TX FIFO 里喂数据,中间一旦 CPU 被中断打断,灯带就会闪。三是大块内存搬运,比如图像 buffer 的拷贝、格式化,纯 Python 循环逐字节搬速度惨不忍睹。

不该用的场景也有。如果只是搬几个字节,或者搬运过程中要根据数据内容做判断,那就老老实实用 CPU 处理。DMA 是个“无脑搬运工”,它不理解数据,只会按地址和数量机械执行。为了用 DMA 而用 DMA,反而会把简单问题复杂化。

2. rp2.DMA API 详解:从构造到释放

2.1 通道的获取与释放

rp2.DMA 的构造函数很简单,直接传通道号即可:

import rp2 dma = rp2.DMA(0) # 使用 0 号通道

但更推荐用rp2.DMA.channel()这个类方法自动分配一个空闲通道:

ch = rp2.DMA.channel() dma = rp2.DMA(ch)

为什么要自动分配?因为 RP2040 只有 12 个 DMA 通道,而很多第三方库,比如音频播放、NeoPixel 加速库,都在悄悄占用通道。你自己写死rp2.DMA(0),很可能和某个库撞车。用channel()分配的好处是,它会主动去找一个没被占用的通道,找不到就抛异常,至少不会出现那种“明明配好了却不工作”的诡异问题。

用完一定要调用dma.deinit()释放通道,否则这个通道会一直被标记为占用。deinit 之后这个对象就不能再用了,需要重新构造。

2.2 config() 核心参数逐项拆解

所有配置都在dma.config()里完成,它接受一系列关键字参数。我把常用参数整理成了表格:

参数作用默认值说明
read源地址None可传 buffer 对象或整数内存地址
write目的地址None同上
count传输次数1单位由 size 决定,不是字节数
size传输位宽32支持 8、16、32 位
trigger硬件触发源0(连续模式)用 rp2.DMA.DREQ_xxx 常量
sense触发沿类型LEVELLEVEL 电平触发 / EDGE 边沿触发
dir地址递增方向A_TO_B 或 B_TO_A
ctrl原始控制字0会与自动生成的位按位合并
word环形缓冲区字长0高级用法,配 RING_SIZE
nop额外空转次数0高级用法,链式传输相关

readwrite是最容易让人困惑的两个参数。它们既可以传array.arraybytearraymemoryview这类连续内存 buffer,也可以直接传一个整数内存地址。比如你想让 DMA 从 PIO 的 RX FIFO 读数据,可以直接传read=0x50200020,这就是 PIO0 SM0 接收 FIFO 的硬件地址。传 buffer 时,固件会自动取 buffer 的首元素地址,你不用自己算。

count的单位是“传输次数”,而不是“字节数”。size=32时,count=100 表示搬运 100 个 32 位数据,也就是 400 字节。这一点配错了,数据长度就会对不上,后面避坑部分我再细说。

dir的选择逻辑非常实用,记住两个经典场景就行。第一种是“内存到外设”,比如往 PIO 的 TX FIFO 写数据,内存地址要不断递增,FIFO 地址保持不动,用rp2.DMA.A_TO_B。第二种是“外设到内存”,比如从 PIO 的 RX FIFO 读采样数据,FIFO 地址固定,内存地址不断递增,用rp2.DMA.B_TO_A。如果只是做内存到内存拷贝,read 和 write 都传了,两边地址本来就该一起递增,这时候 dir 可以省略。

2.3 active()、pack() 与 unpack() 怎么用

config()只是把参数写进寄存器,并不会真正启动搬运。启动和停止靠active()方法:

dma.active(True) # 启动 dma.active(False) # 停止

注意 active 必须传参数,不能像某些库那样空调用查询状态。启动之后,DMA 通道的 BUSY 位会置 1,传输全部完成后自动清零,这个位可以直接从寄存器读出来轮询。

pack()unpack()这两个方法平时用得不多,但调试时很有价值。pack()能把当前配置打包成一个 32 位整数,unpack(value)则反过来把一个整数解析成配置。有些高级玩法是先把一组最优配置算好存成常量,下次直接用ctrl=xxx传进去,省得每次重复计算。更实际的用途是排查问题:别人给你一段 ctrl 寄存器的原始值,你可以 unpack 出来看看它到底配了什么。

2.4 trigger 与 sense:DMA 的“发车信号”

trigger 是 DMA 配置里最体现硬件思维的地方。不传 trigger 时,通道处于持续请求模式,数据一口气搬完,纯内存拷贝就是这么工作的。一旦你传了 trigger,比如trigger=rp2.DMA.DREQ_PIO0_TX0,通道就会等待 PIO0 SM0 的发送 FIFO 发出“有空位”的信号才搬一个数据。

MicroPython 在 rp2.DMA 上暴露了一整套 DREQ 常量,常见的有:

  • rp2.DMA.DREQ_PIO0_TX0DREQ_PIO0_TX3DREQ_PIO0_RX0DREQ_PIO0_RX3
  • 对应 PIO1 的一组DREQ_PIO1_TXxDREQ_PIO1_RXx
  • DREQ_SPI0_TXDREQ_SPI0_RXDREQ_UART0_TXDREQ_UART0_RXDREQ_I2C0_TXDREQ_I2C0_RX
  • DREQ_ADCDREQ_PWM_WRAP0DREQ_PWM_WRAP7

sense 参数控制触发方式是电平还是边沿。绝大多数外设 FIFO 类 DREQ 都是电平触发,比如“FIFO 非空”或“FIFO 未满”是一个持续的电平状态,用默认的rp2.DMA.LEVEL就行。EDGE 边沿触发用于少数需要“跳变才响应”的场景,日常基本碰不到,保持默认即可。

3. 可以直接抄的实战案例

3.1 最小案例:内存到内存拷贝

我拿到一块新板子,或者新固件升级之后,第一件事永远是跑一遍内存拷贝,确认 DMA 通道、寄存器地址、buffer 传递这些基础环节没问题。代码非常短:

from machine import mem32 import rp2, array DMA_BASE = 0x50000000 src = array.array("I", [10, 20, 30, 40, 50, 60, 70, 80]) dst = array.array("I", [0]) * len(src) dma = rp2.DMA(0) dma.config(read=src, write=dst, count=len(src), size=32) dma.active(True) while (mem32[DMA_BASE] >> 24) & 1: # 通道 0 的 BUSY 位 pass print(list(dst)) dma.deinit()

这个例子没有配 trigger,所以通道会一口气搬完 8 个 32 位数据。BUSY 位在 CHn_CTRL 寄存器的第 24 位,传输结束自动清零,轮询它就能知道什么时候搬完。如果打印出来的 dst 和 src 完全一致,说明你的环境没问题,可以放心上外设了。

3.2 经典案例:PIO TX + DMA 驱动 WS2812

WS2812 灯带是 DMA 最经典的应用场景。PIO 负责把 24 位颜色数据转成 800kHz 的时序波形,DMA 负责按灯珠数量把数据喂进 PIO 的 TX FIFO。整个过程中 CPU 只需要管业务逻辑,完全不用管时序。

import rp2, array from machine import Pin, mem32 PIO0_TXF0 = 0x50200010 # PIO0 的 SM0 发送 FIFO @rp2.asm_pio(sideset_init=rp2.PIO.OUT_LOW, out_shiftdir=rp2.PIO.SHIFT_LEFT, autopull=True, pull_thresh=24) def ws2812(): T1 = 2 T2 = 5 T3 = 3 wrap_target() label("bitloop") out(x, 1).side(0)[T3 - 1] jmp(not_x, "zero").side(1)[T1 - 1] jmp("bitloop").side(1)[T2 - 1] label("zero") nop().side(0)[T2 - 1] wrap() sm = rp2.StateMachine(0, ws2812, freq=800_000, sideset_base=Pin(22)) sm.active(1) pixels = array.array("I") for _ in range(24): r, g, b = 0x20, 0xFF, 0x10 pixels.append((g << 16) | (r << 8) | b) dma = rp2.DMA(0) dma.config( read=pixels, write=PIO0_TXF0, count=len(pixels), trigger=rp2.DMA.DREQ_PIO0_TX0, dir=rp2.DMA.A_TO_B, size=32, ) dma.active(True) while (mem32[0x50000000] >> 24) & 1: pass dma.active(False)

这里有两处值得展开。第一,颜色数据为什么要拼成(g << 16) | (r << 8) | b?因为 WS2812 的协议是 GRB 顺序,先发绿色高字节,最后发蓝色低字节。PIO 程序用out(x, 1)配合 SHIFT_LEFT,会从 32 位 FIFO 字的最高位开始输出,所以你要把“先发的位”放到高位。第二,为什么 write 地址直接写死0x50200010?这就是 PIO0 SM0 发送 FIFO 的硬件地址,DMA 往这个地址写 32 位数据,数据就进 FIFO 了。dir 用 A_TO_B,表示内存侧的 read 地址不断递增,FIFO 侧的 write 地址固定不变。

3.3 采集案例:PIO RX + DMA 抓取数字信号

反过来,从外设往内存搬数据,是 DMA 的另一个大用途。下面这个例子用 PIO 以 1MHz 采样一个引脚,每采 8 个 bit 打包成一个字节推进 RX FIFO,然后 DMA 把 FIFO 里的数据搬进内存 buffer。

import rp2, array from machine import Pin, mem32 PIO0_RXF0 = 0x50200020 # PIO0 的 SM0 接收 FIFO @rp2.asm_pio(in_shiftdir=rp2.PIO.SHIFT_LEFT, autopush=True, push_thresh=8) def capture(): set(y, 7) label("sample") in_(pins, 1) jmp(y_dec, "sample") sm = rp2.StateMachine(0, capture, freq=1_000_000, in_base=Pin(16)) sm.active(1) buf = array.array("I", [0]) * 64 dma = rp2.DMA(0) dma.config( read=PIO0_RXF0, write=buf, count=len(buf), trigger=rp2.DMA.DREQ_PIO0_RX0, dir=rp2.DMA.B_TO_A, size=32, ) dma.active(True) while (mem32[0x50000000] >> 24) & 1: pass print(list(buf)) dma.active(False) sm.active(0)

这个例子里,DMA 的 read 地址是固定的 RX FIFO,write 地址是递增的内存 buffer,所以用dir=rp2.DMA.B_TO_A。每个 FIFO 字 32 位,包含 4 个字节,也就是 32 次采样的结果,64 个字总共 2048 个采样点,在 1MHz 采样率下覆盖约 2 毫秒。如果引脚悬空,采到的可能全是 0 或全 1,你可以用一根杜邦线手动接高接低,观察 buffer 里的数据变化,感受一下 DMA 采集的流畅感。

4. 配置避坑指南

4.1 头号杀手:缓冲区生命周期

这是 MicroPython 里用 DMA 最容易踩的坑,而且是那种“死都不知道怎么死的”坑。DMA 是硬件搬运工,它拿到的只是内存地址,完全不感知 Python 对象的存在。如果你的 buffer 在传输过程中被垃圾回收机制回收了,DMA 还会傻乎乎地往那块已经释放的地址上写数据,轻则数据错乱,重则直接 HardFault。

比如你把 buffer 定义在函数内部,DMA 启动后函数返回了,局部变量没人引用,GC 一跑,buffer 就没了。等 DMA 往那块空洞里写,系统当场死给你看。解决办法很朴素:确保 DMA 传输期间 buffer 一直被引用。我的习惯是搞一个全局列表把 buffer“钉”住:

_hold = [] def start_capture(): buf = array.array("I", [0]) * 1024 _hold.append(buf) dma.config(write=buf, ...) dma.active(True) # 传输完成后 _hold.pop() 释放

插一句,如果情况实在紧急,你可以在传输期间临时禁用 GC,搬完再恢复:gc.disable()gc.enable()。但我一般不建议这么做,全局引用更干净。

4.2 数据类型、字节序与地址对齐

第二个高频坑是数据类型。read=write=必须传支持 buffer 协议的对象,也就是连续内存。list在 Python 里是对象数组,每个元素是一个 PyObject 指针,内存不连续,传给 config 会直接报TypeError: object with buffer protocol required。正确做法是用array.array("B" / "H" / "I")bytearray或者memoryview

然后是 size 和 count 的换算。再次强调,count 的单位是传输次数,不是字节数。array("I")每个元素 4 字节,配size=32时 count 直接填len(array)就对了。如果你用bytearray当 buffer,想按字节搬,就要用size=8,count 填字节数;想按 32 位搬,count 要填字节数 // 4。公式很简单:count = 总字节数 * 8 / size。配错了最常见的现象就是数据只搬了一半,或者尾部多出一堆垃圾。

还有两个硬件层面的细节。一是 32 位传输要求源地址和目的地址按 4 字节对齐,array("I")默认对齐没问题,但如果你用uctypes手工拼结构体,要检查成员的偏移量。二是 RP2040 是小端字节序,多字节重组时要注意,比如 WS2812 组包如果把 RGB 顺序搞反,颜色会完全不对。

4.3 DREQ、通道与 FIFO 地址排错

DMA 不动,百分之八十是 DREQ 配错了。记住这条铁律:往 FIFO 写数据用 TX 对应的 DREQ,从 FIFO 读数据用 RX 对应的 DREQ。往 PIO0 SM0 的 TX FIFO 写,用DREQ_PIO0_TX0;从 PIO0 SM0 的 RX FIFO 读,用DREQ_PIO0_RX0。选错的表现非常统一:BUSY 位一直为 1,但 READ_ADDR 和 WRITE_ADDR 纹丝不动,因为 DMA 在苦等一个永远不会来的信号。

FIFO 地址也别记混。PIO0 基址是0x50200000,PIO1 基址是0x50300000,别把 PIO1 的 FIFO 地址填到 PIO0 的配置里。PIO 每个状态机的 FIFO 地址如下:

状态机TX FIFO 偏移RX FIFO 偏移
SM00x100x20
SM10x140x24
SM20x180x28
SM30x1C0x2C

调试的时候,我强烈建议你把手里的 dump 函数常驻项目里。它能直接打印通道的关键寄存器,一眼看出问题在哪:

from machine import mem32 def dump_dma(ch): base = 0x50000000 + ch * 0x40 for name, off in (("CTRL", 0x00), ("READ_ADDR", 0x04), ("WRITE_ADDR", 0x08), ("TRANS_COUNT", 0x0C)): print("%-12s %08x" % (name, mem32[base + off]))

启动 DMA 后立刻调一次,隔一段时间再调一次,对比 READ_ADDR 和 WRITE_ADDR 有没有推进。没推进就是 trigger 没等到,推进了但值不对就是地址配错了。这个函数帮我省了无数 debug 时间。

4.4 常见问题速查表

最后把经典问题整理成一张速查表,遇到问题先对号入座:

现象大概率原因快速排查方法
config 报 buffer protocol 错误传了 list 而不是 array/bytearray换成 array.array 或 bytearray
DMA 不动,BUSY 一直为 1trigger 配错或外设没启动dump 寄存器看地址是否推进
数据只搬了一半size 和 count 换算错误用公式 count = 字节数 * 8 / size
搬运中系统 HardFaultbuffer 被 GC 回收全局引用钉住 buffer
WS2812 颜色乱RGB 顺序或字节序不对检查组包顺序 G<<16 | R<<8 | B
通道 0 不可用或诡异报错被第三方库占用用 rp2.DMA.channel() 自动分配
DMA 完成后重新配置不生效没先 active(False)先停再配,流程:stop → config → start

另外提醒一句,Pico 2 用的 RP2350 芯片和 RP2040 的 DMA 控制器有差异,通道数量、寄存器布局都变了,本文的地址和通道数只针对 RP2040。如果你在 Pico 2 上跑,先把两个芯片的 datasheet 对照一遍再动手。

5. 写在最后的调试习惯

我自己踩过太多 DMA 的坑,现在养成了一个固定流程:新项目里绝不直接写外设代码,先跑一次内存到内存的最小拷贝,确认通道和 buffer 机制正常,再上 PIO、再上 ADC,一层一层叠加。这个习惯看着笨,但能帮你把变量控制住,每次只引入一个新变量,出问题定位特别快。

再有就是 register dump 这个调试函数,我基本每台设备的固件工程里都会保留。MicroPython 的好处是machine.mem32直接读硬件寄存器,调试起来跟写 C 一样爽。遇到 DMA 行为诡异,先 dump 再看现象,比纯靠猜快得多。

最后分享一个偷懒技巧:如果你只是驱动 WS2812,其实直接装一个封装好的库调 API 就行,根本不用自己写 PIO 和 DMA。但如果你要处理的是 PIO 高速采集、音频播放、或者任何需要“外设到内存”连续搬运的场景,那 rp2.DMA 这套东西迟早要啃下来。把这篇文章里的 API 和避坑清单存下来,遇到问题逐个对照,应该能少走一大半弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询