最近在树莓派 Pico 上做一个小采集板,串口数据一多,MicroPython 的uart.read()就开始吃 CPU,主循环明显发卡。折腾了一天把 RP2040 的 DMA 接进 UART 链路之后,才算真正体会到一个道理:在 MicroPython 这种解释型环境里,能用外设硬件干掉的活,千万别让 Python 循环去扛。这篇文章把我从踩坑到跑通的过程完整写出来,内容包括 RP2040 DMA 的寄存器级原理、DREQ 触发机制、UART 与 DMA 的握手方式、可复现的 MicroPython 配置代码,以及我在调试中遇到的典型问题和排查思路。涉及具体 API 的地方我会顺带提一下固件版本差异,方便你用自己手上的 Pico 直接抄作业。
如果你正在 MicroPython 下做串口数据采集、GPS 解析、传感器数据流接收,或者单纯想让 CPU 从逐字节搬运中解放出来,这篇内容应该对你有用。
1. 整体设计思路拆解
1.1 为什么要在 MicroPython 下折腾 DMA
先说一个现实:MicroPython 是解释执行的,一条while循环里读一串串口数据,开销比 C 语言大得多。RP2040 的 Cortex-M0+ 主频最高也就 133MHz,在没有硬件浮点的情况下,1200 行 Python 语句可能就跑掉好几个毫秒。如果数据速率稍微上去(比如 460800 或 921600 波特率),轮询模式基本是灾难。
传统做法有三种:
| 方案 | CPU 占用 | 实时性 | 实现难度 | 适用场景 |
|---|---|---|---|---|
轮询read() | 极高,数据密集时几乎占满 | 差,容易丢数据 | 低 | 低频、零散串口数据 |
| 中断 + 缓冲区 | 中等,每字节都进中断 | 较好,但中断频繁 | 中 | 大多数通用串口场景 |
| DMA 自动搬运 | 极低,搬运由硬件完成 | 好,数据不丢 | 中高 | 大数据块、高频连续串口流 |
DMA 的核心价值,是把"从外设寄存器把数据搬到内存"这件机械劳动交给硬件完成,CPU 只需要在传输开始前配置好通道,在传输结束后处理结果。MicroPython 下这个优势会被放大:因为一条 Python 语句的解释开销本来就大,把逐字节搬运从 Python 代码里拿掉,等于直接把 CPU 释放给了真正需要处理的业务逻辑。
顺带说一句,Pico 的第二个核心(Core1)如果跑着 MicroPython 固件,本身也是共享同一块 SRAM 的,DMA 不会额外占用 CPU 对总线的访问时间,这对双核应用很友好。
1.2 RP2040 DMA 的工作框架
RP2040 的 DMA 控制器其实是挂在内核总线上的一个独立外设,不在 Cortex-M0+ 处理器内部。共有 12 个通道,每个通道可以独立配置源地址、目标地址、传输长度、传输数据宽度、地址递增策略,以及触发请求源(DREQ)。
这里的关键是 DREQ 握手机制。UART、SPI、I2C、ADC 这些外设会主动向 DMA 控制器发出"我有数据要给你"或"我需要数据"的信号,DMA 通道收到这个信号才开始搬运一个数据单元。以 UART RX 为例,当 UART 的接收 FIFO 里至少有一个数据时,UART 就会持续拉高 DREQ,DMA 看到这个请求后完成一次读 DR 寄存器并写 RAM 的操作。这样传输节奏完全由外设的数据流决定,不会多搬,也不会漏搬。
在 MicroPython 下使用 DMA,本质上有两条路线:一是直接通过machine.mem32读写 DMA 寄存器,二是使用较新固件(v1.20 之后)提供的rp2.DMA封装类。无论走哪条路,原理都是控制同一组寄存器,所以接下来先把寄存器细节讲透,后面看代码才能心里有数。
2. 核心细节解析与实操要点
2.1 DMA 通道寄存器地图
DMAC 控制器基地址是0x50000000。每个通道占0x40字节地址空间,CH0 从0x50000000开始,CH1 从0x50000040开始,依次类推。我实际用到的寄存器主要是这几个:
| 寄存器名 | 相对 CHx 偏移 | 作用 |
|---|---|---|
| READ_ADDR | 0x00 | 源地址,DMA 从这里读数据 |
| WRITE_ADDR | 0x04 | 目标地址,DMA 往这里写数据 |
| TRANS_COUNT | 0x08 | 待传输的数据单元个数,递减到 0 表示完成 |
| CTRL_TRIG | 0x0C | 控制位与触发写:写入即启动传输 |
| CTRL | 0x10 | 同 CTRL_TRIG 的控制位,但不触发启动 |
CTRL_TRIG 是整个 DMA 配置的核心,我挑几个最关键的位说明:
- EN(bit 0):通道使能。
- DATA_SIZE(bit 2 到 bit 3):0 表示按字节搬,1 表示半字(2 字节),2 表示字(4 字节)。UART 的一次数据传输通常是 1 字节,所以固定用 0。
- INCR_WRITE(bit 4):写地址是否自动递增。往 RAM 缓冲区写时置 1,往外设寄存器写时清 0。
- INCR_READ(bit 5):读地址是否自动递增。从 RAM 缓冲区读时置 1,从外设寄存器读时清 0。
- CHAIN_TO(bit 15 到 bit 20):传输结束后续接的通道,可以实现链式传输。
- TREQ_SEL(bit 21 到 bit 26):选择哪个外设的 DREQ 作为触发源。
- BUSY(bit 29):只读,为 1 表示通道正在传输中。
我在实际调试时最喜欢看 TRANS_COUNT,因为它会从初始值递减,能直观看到 DMA 有没有在动。如果等了半天这个数没变,那问题多半出在触发配置上而不是搬运本身。
2.2 DREQ 触发源怎么选
DREQ 编号不是乱来的,RP2040 数据手册里面有一张完整的表。和 UART 相关的部分如下:
| DREQ 号 | 含义 |
|---|---|
| 0 | UART0 RX |
| 1 | UART0 TX |
| 2 | UART1 RX |
| 3 | UART1 TX |
比如我用 UART0 做接收,TREQ_SEL 就填 0。使用 MicroPython 的rp2.DMA封装时,不同固件版本可能允许直接填这个数字,也可能提供字符串形式的枚举,运行时可以用help(DMA.config)查看现场固件的真实签名。
这里有个坑:UART 的 DREQ 信号和 FIFO 深度是联动的。UART0 RX 的 FIFO 深度是 16 字节,只有当系统时钟使能了 FIFO 功能后,DMA 请求才会按 FIFO 水位自动发出。如果初始化 UART 时没有设置FEN(FIFO 使能)位,有些情况下 DREQ 行为会变得不可控。所以我的习惯是在寄存器初始化时明确把 UART 的 FIFO 打开,避免后面排查半天。
2.3 缓冲区管理:MicroPython 最容易踩的隐形坑
在 C 里用 DMA,你拿着一个数组的地址直接填寄存器就行。但 MicroPython 里 Python 对象是被解释器管理的,GPIO、内存分配、垃圾回收都可能影响缓冲区。最怕的是这样两个问题:
第一个是缓冲区被垃圾回收。MicroPython 不使用压缩式 GC,对象地址在存活期间不会移动,但如果你把bytearray传给 DMA 之后,后续代码里没有变量再引用它,GC 可能会回收这块内存,DMA 还在后台往里面写,轻则数据错乱,重则内存越界导致 Reboot。对策是让缓冲区对象活到 DMA 结束,比如保存在全局变量,或者用一个列表兜底引用。
第二个是 CPU 与 DMA 并发访问竞争。RP2040 的 Cortex-M0+ 没有数据 cache,所以不存在缓存一致性问题,但存在"同一块缓冲区,DMA 正在写入,Python 循环也在读"的竞争。稳妥做法是把 buffer 切成多块,一块给 DMA 填,另一块给 Python 处理,两块交替使用。后面我在不定长接收方案里会具体演示。
2.4 MicroPython 下的两种 DMA 操控姿势
如果你想快速实现功能,优先用rp2.DMA封装类,它会帮你处理对象到地址的转换,省去很多底层麻烦。示例大致是这样:
from rp2 import DMA from machine import UART, mem32 UART0_BASE = 0x40034000 UART_DR = 0x40034000 # 数据寄存器 uart = UART(0, 115200) rx_buf = bytearray(128) dma = DMA() dma.config( trigger=0, # DREQ 编号,0 对应 UART0_RX source=UART_DR, # 固定读 UART0 数据寄存器 dest=rx_buf, # 写入字节数组 count=len(rx_buf), read_increment=False, write_increment=True, data_size=0, # 8-bit 数据传输 ) dma.start()不同 MicroPython 版本的参数名可能有差异,我在电脑上实测时发现有些版本把trigger写成dreq,有些版本count必须写成关键字参数,所以运行前最好在 REPL 里执行import rp2; help(rp2.DMA.config)看签名。
如果你要更精细地控制中断、链式传输或者调试寄存器状态,那就直接上底层方式:
DMA_BASE = 0x50000000 def ch_addr(ch, offset): return DMA_BASE + ch * 0x40 + offset # 配置 CH0 mem32[ch_addr(0, 0x00)] = UART_DR # READ_ADDR mem32[ch_addr(0, 0x04)] = buffer_addr # WRITE_ADDR mem32[ch_addr(0, 0x08)] = 128 # TRANS_COUNT ctrl = (1 << 0) | (0 << 2) | (1 << 4) | (1 << 21) # EN + 8bit + INCR_WRITE + TREQ_SEL=0 mem32[ch_addr(0, 0x0C)] = ctrl # CTRL_TRIG 启动不过底层方式会卡在"如何拿到 MicroPython 字节数组的内存地址"这个问题上,所以我还是更推荐优先使用封装类,等你真正需要的时候再去看固件源码了解它是怎么转换的。
3. 实操过程与核心环节实现
3.1 第一步:把 UART 初始化到可用状态
DMA 搬运的目标是外设寄存器,但 UART 本身的波特率、FIFO 打开与否也得先准备到位。直接用machine.UART类初始化有一个隐患:这个类默认会开接收中断,并向内部缓冲区填充数据,虽然不一定会和 DMA 打架,但我更喜欢在 DMA 场景下用寄存器初始化,把控制权完全握在自己手里。
RP2040 的 UART0 基地址是0x40034000,波特率分频寄存器是IBRD(偏移 0x24)和FBRD(偏移 0x28)。计算公式是:
BRD = PERCLK / (16 × baud)
Pico 的默认外设时钟频率是 125MHz。以 115200 波特率为例:
125000000 / (16 × 115200) = 67.817
整数部分 IBRD = 67,小数部分 0.817 乘 64 约等于 52,所以 FBRD = 52。如果用 921600:
125000000 / (16 × 921600) = 8.477,IBRD = 8,FBRD = 30
设置好分频后,再配置 LCR_H(偏移 0x2C)和 CR(偏移 0x30)。LCR_H 写 0x60 就代表 8 位数据长度加 FIFO 使能,CR 写 0x301 是同时打开 UART、发送和接收。
MicroPython 里的寄存器初始化片段:
from machine import mem32 UART0_BASE = 0x40034000 UART0_DR = 0x40034000 UART0_IBRD = 0x40034024 UART0_FBRD = 0x40034028 UART0_LCR_H = 0x4003402C UART0_CR = 0x40034030 baud = 115200 ibrd = 125000000 // (16 * baud) fbrd = int(((125000000 / (16 * baud)) - ibrd) * 64 + 0.5) mem32[UART0_IBRD] = ibrd mem32[UART0_FBRD] = fbrd mem32[UART0_LCR_H] = 0x60 # 8 位数据 + FIFO 使能 mem32[UART0_CR] = 0x301 # UARTEN | TXE | RXE这里有个小技巧:计算 FBRD 时加 0.5 再取整,是避免浮点误差导致最接近的分数值偏差。实际使用中 115200 波特率下配置误差远小于误码容限,跑飞的可能性很低。
3.2 第二步:实现 DMA 接收
假设我要把 UART0 收到的一串固定长度数据(比如 128 字节)自动存进 RAM。用rp2.DMA封装,核心代码就是配置好通道,然后让 DMA 在后台干活。
这里我用一个全局变量持住缓冲区,防止 GC 回收:
from rp2 import DMA from machine import UART, mem32, Pin UART0_DR = 0x40034000 rx_buf = bytearray(128) dma = None def setup_dma_rx(): global dma, rx_buf dma = DMA() dma.config( trigger=0, # UART0 RX source=UART0_DR, dest=rx_buf, count=len(rx_buf), read_increment=False, write_increment=True, data_size=0, ) dma.start()启动之后,UART 每收到一个字节,DMA 就自动写入rx_buf。当rx_buf填满 128 字节后,TRANS_COUNT 会递减到 0,通道自动停止。如果后续还有数据,需要重新配置 count 并再次start()。
需要说明白的是,这里说的"零 CPU 干预"更准确的理解是"数据搬运过程零 CPU 干预",启动、处理和停止仍需要 CPU。真正的高负载场景下,DMA 传输结束后应该触发 IRQ,让 MicroPython 的回调函数在后台把数据取走。如果对实时性要求不高,也可以在主循环里轮询 DMA 是否完成。
轮询版本也很简洁:
while dma.active(): pass print("接收完成,前 16 字节:", rx_buf[:16])但我建议在正式产品中不要用这个忙等版本,因为active()查询语句本身也有开销,而且主循环会卡死。配合 IRQ 才是正路。
3.3 第三步:用 DMA 发送数据
发送方向的结构完全对称,但地址递增方向反过来。我要把内存里的一段数据,从源地址连续递增地读到 UART 的 DR 寄存器,源地址要递增,目标地址是固定寄存器,DREQ 选择 UART0_TX。
from rp2 import DMA from machine import UART, mem32 UART0_DR = 0x40034000 tx_buf = bytearray(b"Hello RP2040 DMA!\n") tx_dma = DMA() tx_dma.config( trigger=1, # UART0 TX source=tx_buf, dest=UART0_DR, count=len(tx_buf), read_increment=True, write_increment=False, data_size=0, ) tx_dma.start()发送完成后,如果还想继续用这个通道,记得count重新赋值。RP2040 DMA 通道在 count 变为 0 后不会自动重置,这是和某些 MCU 的循环 DMA 模式不一样的地方。如果数据长度变来变去,每次发送前都要设置新的 count。
这里还要注意:UART 的发送是慢速设备,而 DMA 是按 DREQ 节奏跑的,所以不会出现"DMA 一下子把 FIFO 塞爆"的问题。FIFO 有空位,UART 才拉高 DREQ,DMA 才搬一个数据过去,天然形成背压机制。
3.4 不定长数据接收:DMA 加空闲检测的思路
串口项目里最常见的需求其实是"不定长数据帧"处理。DMA 通道傻乎乎地只会搬固定长度,怎么处理一帧长度可变的包?我试过几种思路,最终觉得最可靠、也最省事的是:DMA 持续接收 + 定时器判断线路空闲。核心逻辑是这样的:
- DMA 配置成环形缓冲区或指定长度的大 buffer,比如 512 字节。
- UART 每收到一个字节都会由 DMA 写进 buffer。
- CPU 单独跑一个定时器,比如每 1ms 检查一次 UART 的 FR 寄存器的 RXFE 位(RX FIFO 空标志),并记录当前 DMA 的 TRANS_COUNT。
- 如果连续几个周期发现 TRANS_COUNT 没有变化,说明线路已经空闲,这时把 DMA 已经接收的数据一次性取出。
这个方案不是"零 CPU 干预"的极致状态,但胜在实现简单、容错好。你也可以反过来用 UART 的空闲中断(某些 MCU 的 UART 有 RTO 或 IDLE 状态),但 RP2040 的 UART 没有像 STM32 那样的 IDLE 中断,所以我倾向于用定时器空闲判断。
在 MicroPython 里可以这样实现:初始化一个machine.Timer,周期 1ms,回调函数里读取 DMA 当前剩余计数字节dma.count(),然后把偏移位置算出来,把已收到的数据交给业务逻辑。注意定时器回调不要在中断上下文里做重活,用micropython.schedule()把数据处理放到主循环空闲时执行。
3.5 演示主程序
把上面的模块拼起来,一个最简单的 DMA 串口回显程序长这样:
from machine import UART, mem32, Pin, Timer from rp2 import DMA import micropython UART0_DR = 0x40034000 rx_buf = bytearray(128) rx_dma = None def on_data_ready(): print("DMA 完成,数据:", bytes(rx_buf)) def setup(): global rx_dma # 寄存器方式初始化 UART0,115200 8N1 mem32[0x40034024] = 67 mem32[0x40034028] = 52 mem32[0x4003402C] = 0x60 mem32[0x40034030] = 0x301 rx_dma = DMA() rx_dma.config(trigger=0, source=UART0_DR, dest=rx_buf, count=len(rx_buf), read_increment=False, write_increment=True, data_size=0) rx_dma.start() setup()跑这个程序,然后用串口调试助手给 Pico 发 128 字节数据,你会看到打印输出。这个程序本身没有太多实用性,但它验证了整条 DMA 通路是否正常。我建议所有刚接触 DMA 的人都从这样一个最小回显开始,不要一上来就搞环形缓冲加中断链,那样出了问题反而不知道是哪一环坏了。
4. 常见问题与排查技巧实录
4.1 五个最容易踩的坑
我把自己实操中碰到的问题按频率排了个序,基本上能覆盖新手阶段的大多数翻车现场。
问题一:DMA 完全没有触发,TRANS_COUNT 一直不递减。
八成是 DREQ 编号填错,或者 UART 的 FIFO 没有使能。检查 UART0 RX 对应的是 TREQ_SEL = 0,UART0 TX 对应的是 1,不要和 UART1 混淆。再看 LCR_H 的 FEN 位是不是 1,FIFO 关闭时有些 DREQ 行为不会按预期工作。
问题二:数据错位或者每次接收都少几个字节。
最常见的原因是 DATA_SIZE 没设对。UART 一帧传 8 位数据,DATA_SIZE 必须是 0(按字节)。如果设成 2(按字搬),每次 DMA 会从连续地址读 4 字节,结果就是数据前后错位、长度也对不上。另一个原因是 INCR_WRITE 或 INCR_READ 方向搞反,导致每次都把数据写到同一个地址。
问题三:MicroPython 的 bytearray 被 GC 回收,DMA 还在写。
表现为程序跑一会儿后死机或者打印出乱码。检查一下你是否在while之外的函数内部创建了缓冲区,函数返回后没有全局变量引用它。解决方法是把rx_buf设置为全局变量,或者用gc.mem_alloc()观察内存变化。如果你用rp2.DMA,建议保持对dma对象的引用同样重要。
问题四:DMA 传输完成后程序没有反应。
看看通道是否真的结束:轮询dma.active(),或者读mem32[ch_addr(ch, 0x08)]的计数值是否为 0。也有可能是你反复start()但没重新设置count,导致第二次传输立即完成。RP2040 DMA 的 count 是递减模式,不会自动重置。
问题五:同时开多个 DMA 通道时,通道互相干扰。
RP2040 有 12 个通道,rp2.DMA()不传参时会自动分配空闲通道,但如果你手动指定了固定通道号,要避免两个任务共用同一个通道。特别是在链式传输或者中断回调里创建 DMA 对象时,生命周期管理一定要清楚。
4.2 调试验证手段
没有逻辑分析仪的情况下,调试 DMA 也不是完全瞎猜。我在调试时最常用的三件套:
第一,打印寄存器状态。用machine.mem32直接读 DMA 通道的 CTRL_TRIG 和 TRANS_COUNT,输入以下命令观察是否变化:
from machine import mem32 mem32[0x50000000 + 0 * 0x40 + 0x08] # CH0 TRANS_COUNT第二,用 Pico 板载 LED 做状态指示。在 DMA 完成的回调里翻转 LED,如果灯闪了,说明 CM0+ 确实收到了 DMA 中断或回调有被触发,可以快速区分是"没传输"还是"传输了但处理有问题"。
第三,降低波特率测试。波特率降到 9600 后,即使逻辑有问题,单字节的时间很长,字节能观察得清楚,排查错位问题更容易。等低波特率完全正常,再逐步提升。
4.3 关于 MicroPython 固件版本的一点建议
不同版本 MicroPython 对 RP2040 DMA 的支持变化挺大。较老的 1.19 版本里rp2.DMA可能还不完整,我在 1.23 和 1.24 上测试是正常的。如果你拿到的是某个定制固件,最好先跑一下:
import rp2 print(hasattr(rp2, "DMA"))如果返回 False,要么更新固件,要么就只能走machine.mem32寄存器直写的底子。另一个经验是先读固件源码里的rp2_dma.c,里面会说明config的每个参数对应哪个寄存器位,这个文件在 micropython 官方仓库的ports/rp2目录下。自己读一遍源码,比在论坛里搜零散答案效率高得多。
根据我的个人经验,DMA 不是越底层越高级,而是要结合你的固件能力选。MicroPython 的rp2.DMA封装已经封装好了地址转换和通道管理,除非你有非常极端的性能或者中断时序要求,否则直接用它就是最省心、也最不容易翻车的方案。先跑通最小回显,再做环形缓冲和空闲检测,慢慢把对 DMA 的理解建立起来。这套思路放到后续改用 C SDK 开发时也同样适用,因为底层寄存器、DREQ 编号、缓冲区管理这些知识是相通的。