
在 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 资源通道、寄存器与 DREQRP2040 内部有 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_Actrl原始控制字0会与自动生成的位按位合并word环形缓冲区字长0高级用法配 RING_SIZEnop额外空转次数0高级用法链式传输相关read和write是最容易让人困惑的两个参数。它们既可以传array.array、bytearray、memoryview这类连续内存 buffer也可以直接传一个整数内存地址。比如你想让 DMA 从 PIO 的 RX FIFO 读数据可以直接传read0x50200020这就是 PIO0 SM0 接收 FIFO 的硬件地址。传 buffer 时固件会自动取 buffer 的首元素地址你不用自己算。count的单位是“传输次数”而不是“字节数”。size32时count100 表示搬运 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)则反过来把一个整数解析成配置。有些高级玩法是先把一组最优配置算好存成常量下次直接用ctrlxxx传进去省得每次重复计算。更实际的用途是排查问题别人给你一段 ctrl 寄存器的原始值你可以 unpack 出来看看它到底配了什么。2.4 trigger 与 senseDMA 的“发车信号”trigger 是 DMA 配置里最体现硬件思维的地方。不传 trigger 时通道处于持续请求模式数据一口气搬完纯内存拷贝就是这么工作的。一旦你传了 trigger比如triggerrp2.DMA.DREQ_PIO0_TX0通道就会等待 PIO0 SM0 的发送 FIFO 发出“有空位”的信号才搬一个数据。MicroPython 在 rp2.DMA 上暴露了一整套 DREQ 常量常见的有rp2.DMA.DREQ_PIO0_TX0到DREQ_PIO0_TX3、DREQ_PIO0_RX0到DREQ_PIO0_RX3对应 PIO1 的一组DREQ_PIO1_TXx、DREQ_PIO1_RXxDREQ_SPI0_TX、DREQ_SPI0_RX、DREQ_UART0_TX、DREQ_UART0_RX、DREQ_I2C0_TX、DREQ_I2C0_RXDREQ_ADC、DREQ_PWM_WRAP0到DREQ_PWM_WRAP7sense 参数控制触发方式是电平还是边沿。绝大多数外设 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(readsrc, writedst, countlen(src), size32) 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 驱动 WS2812WS2812 灯带是 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_initrp2.PIO.OUT_LOW, out_shiftdirrp2.PIO.SHIFT_LEFT, autopullTrue, pull_thresh24) 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, freq800_000, sideset_basePin(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( readpixels, writePIO0_TXF0, countlen(pixels), triggerrp2.DMA.DREQ_PIO0_TX0, dirrp2.DMA.A_TO_B, size32, ) 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_shiftdirrp2.PIO.SHIFT_LEFT, autopushTrue, push_thresh8) def capture(): set(y, 7) label(sample) in_(pins, 1) jmp(y_dec, sample) sm rp2.StateMachine(0, capture, freq1_000_000, in_basePin(16)) sm.active(1) buf array.array(I, [0]) * 64 dma rp2.DMA(0) dma.config( readPIO0_RXF0, writebuf, countlen(buf), triggerrp2.DMA.DREQ_PIO0_RX0, dirrp2.DMA.B_TO_A, size32, ) dma.active(True) while (mem32[0x50000000] 24) 1: pass print(list(buf)) dma.active(False) sm.active(0)这个例子里DMA 的 read 地址是固定的 RX FIFOwrite 地址是递增的内存 buffer所以用dirrp2.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(writebuf, ...) 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 字节配size32时 count 直接填len(array)就对了。如果你用bytearray当 buffer想按字节搬就要用size8count 填字节数想按 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 基址是0x50200000PIO1 基址是0x50300000别把 PIO1 的 FIFO 地址填到 PIO0 的配置里。PIO 每个状态机的 FIFO 地址如下状态机TX FIFO 偏移RX FIFO 偏移SM00x100x20SM10x140x24SM20x180x28SM30x1C0x2C调试的时候我强烈建议你把手里的 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 或 bytearrayDMA 不动BUSY 一直为 1trigger 配错或外设没启动dump 寄存器看地址是否推进数据只搬了一半size 和 count 换算错误用公式 count 字节数 * 8 / size搬运中系统 HardFaultbuffer 被 GC 回收全局引用钉住 bufferWS2812 颜色乱RGB 顺序或字节序不对检查组包顺序 G16 | R8 | 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 和避坑清单存下来遇到问题逐个对照应该能少走一大半弯路。