
我最早在 RP2040 上用 MicroPython 跑 921600 波特率的串口接收时被坑得很惨。数据打印到一半就断流刚开始以为是传感器时序问题后来把波特率降到 115200 依然间歇性丢数据。排查到最后发现问题根本不在协议而在 MicroPython 太慢CPU 根本来不及把 RP2040 UART FIFO 里那 16 个字节及时取走。解决办法不是优化 Python 循环而是让 DMA 来干搬运工。这篇文章聊聊 RP2040 的 DMA 控制器在 MicroPython 下怎么驱动 UART包括硬件机制、寄存器操作、发送和接收两个方向的完整代码以及实测对比和踩坑记录。适合已经用过 MicroPython UART、但被高波特率或大流量数据卡住的人也适合对 RP2040 DMA 好奇、想深入了解底层运转的开发者。1. 为什么在 MicroPython 里必须考虑 DMA中断处理的那个“卡顿”1.1 MicroPython 处理高波特率 UART 的力不从心MicroPython 是解释执行的虚拟机本身的开销比裸机 C 语言大一个量级。在 RP2040 跑 125MHz 主频的情况下一条简单的 Python 赋值语句可能就要数百纳秒更复杂的列表操作、字符串拼接甚至要到几十微秒。很多人以为串口收发就是把read()和write()调对了就行但在高波特率下这个假设会直接崩溃。以 921600 波特率、8N1 格式为例每个字节在物理线路上占 10 个 bit所以每字节需要约 10.85 微秒。听起来不难但你的主循环不可能只在做读串口这一件事。它要解析协议、更新状态机、处理定时器、可能还要跑网络。一旦某个分支耗时超过几十毫秒UART 的接收 FIFO只有 16 字节瞬间就会被填满之后到来的数据直接丢弃。有人会想到用中断回调。MicroPython 的UART.irq()确实能注册回调但这个回调是跑在微控制器运行时上的并不是裸机中断那种“即时响应”。在高波特率下中断触发到 Python 回调真正执行之间可能已经又来了好几个字节。如果回调里再敢做字符串拼接、列表操作这种耗时动作那丢数据几乎不可避免。说到底让解释型语言去逐字节照顾外设 FIFO本身就是个错误思路。正确的思路是把这个重复劳动交给专门的硬件模块DMA 就是干这个的。1.2 “零 CPU 干预”到底指什么“零 CPU 干预”听起来像营销话术实际含义很具体CPU 不负责逐字节搬运数据而是把源地址、目标地址、传输长度告诉 DMA 硬件剩下的事情由 DMA 完成。发送方向上CPU 只需要准备一块连续内存DMA 会自动把每个字节写入 UART 的发送寄存器直到全部搬完接收方向上DMA 会自动把外设 FIFO 中的数据搬到内存缓冲区里CPU 只要在需要时去看一眼“收到了多少新数据”。当然绝对的“零干预”是不存在的。初始化要 CPU 做传输完成后确认也要 CPU 做接收缓冲区满了之后重置传输计数也要 CPU 做。但相比逐字节中断处理CPU 的参与频次从“每个字节一次”下降到了“每批数据一次”这个量级的差距足够被称作零干预了。理解了这个边界你在设计代码时才不会幻想“DMA 通了以后 CPU 可以完全睡大觉数据全部自己处理”。它不是魔法它只是把最枯燥的搬运工作接管了。1.3 适用边界什么时候值得上 DMA不是所有串口应用都需要 DMA。如果你的波特率在 115200 以下主循环里没有长时间阻塞用普通的uart.read()轮询完全够。DMA 的收益主要集中在几个场景波特率高于 115200尤其是 460800 或 921600FIFO 只有 16 字节CPU 稍有延迟就丢数据。主循环里有阻塞操作比如写 flash、运行复杂的协议解析、等待网络响应发送和接收不能被打断。需要并发收发比如一边往外发大块 log一边还要不漏地接收指令。代价也不能忽略RAM 要被缓冲区占用代码从简单的uart.write()变成一堆寄存器操作调试难度直线上升。如果项目真的只需要偶尔发几个字节老老实实写轮询别折腾。2. RP2040 DMA 硬件侧写通道、请求信号与寄存器位2.1 12 条通道如何分工RP2040 的 DMA 控制器一共有 12 个独立通道编号 0 到 11。每个通道都可以独立配置源地址、目标地址、传输长度和数据宽度互不干扰。它们共享一条 AHB 总线仲裁由硬件完成。这意味着你可以同时开一个发送通道和一个接收通道不会彼此阻塞。通道在内存映射中并不是连续排列的一个大结构体而是每个通道占 0x40 字节。通道 0 从 0x50000000 开始通道 1 从 0x50000040 开始通道 2 从 0x50000080 开始依此类推。0x50000000 同时也是 DMA 控制器的基地址所以通道 0 的寄存器直接落在基地址上这一点在写代码时容易混淆要注意。所有 DMA 寄存器都是 32 位宽、必须按 4 字节对齐访问。对 MicroPython 来说直接用machine.mem32读写即可不需要额外的内存映射包装。2.2 通道寄存器速览一个通道用到的核心寄存器就那么几个我把它们列成一张表偏移寄存器名作用0x00READ_ADDR当前读地址也就是数据源0x04WRITE_ADDR当前写地址也就是数据目标0x08TRANS_COUNT剩余传输计数每搬一个字节减 10x0CCTRL控制寄存器配置但不触发0x10CTRL_TRIG控制寄存器写入的同时触发传输0x24STATUS通道状态含 AHB 错误标志其中CTRL_TRIG是最关键的一个寄存器配置和触发一次完成。它里面值得重点关注的位包括bit 0EN通道使能写 1 启动。bits 15–4TREQ_SEL传输请求选择决定这个通道监听哪个外设的 DMA 请求信号。bit 26INCR_READ读地址是否递增。发送场景要置 1外设到内存接收场景要清 0。bit 25INCR_WRITE写地址是否递增。接收场景置 1写到 UART 数据寄存器时清 0。bits 24–22RING_SIZE和 bit 21RING_SEL地址回绕控制用于循环缓冲。bits 28–27DATA_SIZE0 表示按字节传输1 表示半字2 表示字。UART 场景永远用 0。配置时把这几位拼好一次性写入CTRL_TRIGDMA 立刻开始干活不需要再额外触发一次。2.3 UART 请求信号与 TREQ_SELDMA 通道怎么知道“现在可以搬一个字节了”答案是监听外设发来的请求信号。RP2040 里每个支持 DMA 的外设都有一个固定的 DREQ 编号UART 相关的如下DREQ 编号信号名含义8DREQ_UART0_TXUART0 发送 FIFO 有空位9DREQ_UART0_RXUART0 接收 FIFO 有数据10DREQ_UART1_TXUART1 发送 FIFO 有空位11DREQ_UART1_RXUART1 接收 FIFO 有数据把TREQ_SEL设成对应编号DMA 就会跟随该信号工作。发送时UART 发送 FIFO 一旦有空位DMA 就把内存中的一个字节写入 UART 数据寄存器接收时UART 接收 FIFO 一旦有数据DMA 就把数据读走并写入内存。这个机制使得 UART 的 DMA 配置非常干净你不需要关心 FIFO 水位、不需要软件定时轮询只要把请求信号和通道绑定好剩下交给硬件。3. MicroPython 里没有 DMA API直接翻寄存器3.1 为什么官方固件不提供 DMA 模块MicroPython 官方固件确实没有暴露一个通用的machine.DMA类。这不是 RP2040 移植版的疏忽而是因为 DMA 的机制在不同芯片上差异太大了。STM32 的 DMA 有 stream、channel、FIFO 模式ESP32 的 DMA 又是另一套描述符链RP2040 是带 RING 回绕和 CHAIN_TO 链式机制的。想在 Python 层做一个跨平台统一抽象难度极高官方干脆不做。这并不代表没法用。RP2040 版 MicroPython 保留了machine.mem32这个口子可以直接访问整个内存映射空间。DMA 寄存器本质就是一段地址用 mem32 读写完全足够。唯一的门槛是你得记住每个寄存器的偏移和位定义这对我来说反而更可控——少了抽象层的黑盒出了问题能直接对着地址查。3.2 machine.mem32 与地址映射速查machine.mem32的用法非常直白mem32[地址]读mem32[地址] 值写。所有寄存器地址都是 32 位对齐的但要注意地址本身的正确性写错一个偏移就是静默故障DMA 不干活也不会报错。这篇文章涉及的基地址和偏移如下设备基地址DMA 控制器0x50000000UART00x40034000UART10x40038000UART0 内部的几个关键寄存器偏移偏移寄存器作用0x00DR数据寄存器收发双向0x18FR标志寄存器判断 FIFO 状态0x34IFLS中断/FIFO 触发电平选择0x48DMACRDMA 控制寄存器其中DMACR是特别容易漏掉的一个。很多人配好了 DMA 通道、写好了源地址和目标地址结果TRANS_COUNT纹丝不动排半天队找不到原因最后发现 UART 侧压根没有使能 DMA 请求。没有DMACR的置位UART 不会发出 DREQ 信号DMA 通道会一直挂着等一个永远不来的请求。3.3 UART 侧的 DMACR 和 IFLS 怎么设DMACR偏移 0x48 只有两个有效位bit 0RXDMAE接收 DMA 使能bit 1TXDMAE发送 DMA 使能。用哪个方向就置哪个位收发都开也可以# 启用 UART0 的发送接收 DMA mem32[UART0_BASE 0x48] mem32[UART0_BASE 0x48] | 0x03IFLS偏移 0x34 决定 FIFO 在什么水位触发 DMA 请求。低 3 位是发送 FIFO 触发电平中间 3 位bits 5:3是接收触发电平。编码 0 代表 FIFO 达到 1/8 时触发即 2 字节。在 DMA 场景下我建议发送和接收都设为 0让 DMA 一有机会就搬移减少 FIFO 积压# RX 和 TX 触发阈值都设为 2 字节 mem32[UART0_BASE 0x34] (0 3) | (0 0)这个设置会覆盖 MicroPython 初始化 UART 时对 IFLS 的默认值。不用担心它只是改变硬件触发水位如果你之后想回到普通uart.read()/write()操作完全不受影响。4. 发送方向实战DMA 让 UART 自己吐数据4.1 从一块 bytearray 到 UART 数据寄存器的连接发送方向的核心是把一块连续内存逐个搬到 UART0 的 DR 寄存器。DMA 看到 UART0 发送 FIFO 有空间就自动从内存读一个字节写入 DR。直到TRANS_COUNT减到 0传输结束。这里有个前提条件数据必须位于一块连续的内存区域里。MicroPython 里最合适的数据结构就是bytearray或者array(B)。用uctypes.addressof()可以拿到对象的地址然后喂给 DMA 的READ_ADDR。关于地址稳定性MicroPython 的垃圾回收是非移动式的也就是说已经分配的对象在存活期间地址不会变。这比某些支持压缩 GC 的环境要省心得多。但前提是对象不能被释放所以发送时传入的bytearray必须在 DMA 传输完成前都保持引用有效不要传一个临时变量进去用完就丢。4.2 完整发送代码先把寄存器地址定义好然后写一个uart_dma_send()函数。我用通道 0 做发送from machine import mem32, UART, Pin from uctypes import addressof DMA_BASE 0x50000000 CH0_READ_ADDR DMA_BASE 0x00 CH0_WRITE_ADDR DMA_BASE 0x04 CH0_TRANS_COUNT DMA_BASE 0x08 CH0_CTRL_TRIG DMA_BASE 0x10 UART0_BASE 0x40034000 UART0_DR UART0_BASE 0x00 UART0_IFLS UART0_BASE 0x34 UART0_DMACR UART0_BASE 0x48 def uart_dma_init(): # 开启 UART0 发送 DMA 使能 mem32[UART0_DMACR] mem32[UART0_DMACR] | 0x02 # TX FIFO 触发阈值设为 2 字节 mem32[UART0_IFLS] mem32[UART0_IFLS] ~0x07 def uart_dma_send(data: bytearray): n len(data) if n 0: return # 等上一次传输结束避免覆盖当前配置 while mem32[CH0_TRANS_COUNT] ! 0: pass mem32[CH0_READ_ADDR] addressof(data) mem32[CH0_WRITE_ADDR] UART0_DR mem32[CH0_TRANS_COUNT] n # TREQ_SEL8 (UART0_TX), INCR_READ1, DATA_SIZEBYTE, EN1 ctrl (1 0) | (8 15) | (1 26) | (0 27) mem32[CH0_CTRL_TRIG] ctrl调用示例uart0 UART(0, baudrate921600, txPin(0), rxPin(1), bits8, parityNone, stop1) uart_dma_init() payload bytearray(bHello RP2040 DMA, UART TX without CPU!\n) uart_dma_send(payload)有两点值得特意说明。第一WRITE_ADDR是固定地址写所以INCR_WRITE必须为 0否则 DMA 会把字节写到 UART0_DR 后面不存在的地址上行为完全不可预期。第二TREQ_SEL用的是 8对应 UART0_TX。如果你接的是 UART1这里要改成 10同时把 DR 地址换成 UART1 的基地址 0x40038000其他逻辑完全一样。4.3 怎么知道发送完成了DMA 完成事件在 MicroPython 层没有直接暴露中断最简单可靠的办法就是轮询TRANS_COUNTdef uart_dma_busy(): return mem32[CH0_TRANS_COUNT] ! 0等它归零说明 DMA 已经把全部数据搬进了 UART 发送 FIFO但此时物理链路可能还没发完最后一个字节。如果要确保整个数据包已经完整发送出去还需要检查 UART 的 FR 标志寄存器偏移 0x18。bit 7 是 TXFE发送 FIFO 空bit 3 是 BUSYUART 正在发送。两个条件同时满足才算真正空闲UART0_FR UART0_BASE 0x18 def uart_dma_done(): if mem32[CH0_TRANS_COUNT] ! 0: return False fr mem32[UART0_FR] return (fr 0x88) 0x80 # BUSY0, TXFE1在发送大块数据后立刻需要切换 UART 引脚方向、或者低功耗休眠的场景里这个done()判断是必须的否则可能切走最后几个字节还没发完接收端就收到一个截断包。5. 接收方向设计循环缓冲与帧边界5.1 接收为什么比发送麻烦发送方向很舒服知道要发多少字节、什么时候开始一次性配置完就行。接收方向完全反过来你不知道数据什么时候来、会来多少、什么时候结束所以不能让 DMA 传完固定数量就停下来干等。你要的是它一直在后台“待命”随时把新到的字节写进缓冲区。RP2040 DMA 的 RING 回绕机制正是为解决这个问题设计的。它让 DMA 的写地址在到达缓冲区末尾时自动回到起点不需要 CPU 参与。不过硬件有一个限制回绕区域最大只能到 256 字节对应RING_SIZE字段 3 位二进制数的最大值 7。所以这种“自动循环”的接收缓冲区单段最多 256 字节。如果你需要比 256 字节更大的循环缓冲只能靠 DMA 完成中断去手动重置或者在轮询到传输计数快归零时重新触发。那属于半干预模式这里先不展开。下面的方案我用 256 字节硬上限。5.2 配置循环接收 DMA 的代码以通道 1 为例完整的接收初始化函数这样写CH1_BASE DMA_BASE 0x40 CH1_READ_ADDR CH1_BASE 0x00 CH1_WRITE_ADDR CH1_BASE 0x04 CH1_TRANS_COUNT CH1_BASE 0x08 CH1_CTRL CH1_BASE 0x0C CH1_CTRL_TRIG CH1_BASE 0x10 RX_BUF_SIZE 256 rx_buf bytearray(RX_BUF_SIZE) def uart_dma_rx_start(): # 开启 UART0 接收 DMA 使能 mem32[UART0_DMACR] mem32[UART0_DMACR] | 0x01 # RX FIFO 触发电平最低2 字节 mem32[UART0_IFLS] (mem32[UART0_IFLS] ~0x38) | (0 3) # 读地址固定为 UART0 DR外设到内存 mem32[CH1_READ_ADDR] UART0_DR # 写地址指向缓冲区允许递增 mem32[CH1_WRITE_ADDR] addressof(rx_buf) mem32[CH1_TRANS_COUNT] RX_BUF_SIZE # RING_SEL1 写回绕RING_SIZE7 对应 256 字节 # INCR_WRITE1, INCR_READ0, DATA_SIZEBYTE # TREQ_SEL9 (UART0_RX), EN1 ctrl ( (1 0) | # EN (9 15) | # TREQ_SEL UART0_RX (1 21) | # RING_SEL, 写地址回绕 (7 22) | # RING_SIZE 7 - 2^8 256 字节 (0 25) | # INCR_WRITE 1 (1 26) | # INCR_READ 0 (0 27) # DATA_SIZE BYTE ) mem32[CH1_CTRL_TRIG] ctrl这段代码里RING_SEL1表示回绕应用于写地址RING_SIZE7把回绕边界设置为 256 字节。一旦 DMA 写地址到达缓冲区末尾会自动回到起点继续写而TRANS_COUNT依然在递减。这里有个关键要理解清楚地址回绕不会重置TRANS_COUNT它从 256 一路减到 0之后 DMA 通道就停了不再接收新数据。所以你必须在它减到 0 之前来取走旧数据、重置计数并重新触发。5.3 用 TRANS_COUNT 反推新数据由于TRANS_COUNT记录剩余待传输数启动之后 DMA 已搬走的字节数就等于RX_BUF_SIZE - TRANS_COUNT当前值。def dma_rx_new_data(): remaining mem32[CH1_TRANS_COUNT] 0xFFFFFF return RX_BUF_SIZE - remaining因为地址回绕新数据是连续写入rx_buf的下一次消费时直接从上次记录的位置往后读即可。你不需要担心回绕因为累计的新数据量在 0 到 256 之间只有达到 256 时通道才停。处理完数据之后需要重置 DMA 通道继续接收。这里存在一个竞态直接改TRANS_COUNT时 DMA 可能还在搬新字节会破坏配置。稳妥的做法是先关通道使能再重置最后重新触发def dma_rx_reset(): mem32[CH1_CTRL] 0 # 关闭通道 mem32[CH1_TRANS_COUNT] RX_BUF_SIZE mem32[CH1_CTRL_TRIG] ( (1 0) | (9 15) | (1 21) | (7 22) | (0 25) | (1 26) | (0 27) )关通道之后那最多 2 个字节正好在 UART FIFO 里等着重置完成后它们会被 DMA 继续搬走不会丢。5.4 帧结束怎么判断DMA 只负责搬运不负责理解协议。怎么判断“一帧数据结束了”这永远是应用层的问题。在 DMA 模式下我常用三种思路固定长度帧最直接。知道每帧 N 字节当dma_rx_new_data()返回值增长到 N 的倍数时就切一帧。定界符帧协议里以\n或\r结尾就在主循环里扫描新数据遇到定界符把之前的内容提取为一帧。空闲超时帧用time.ticks_us()记录最后有新数据到达的时间一旦超过 5ms 没有新数据就认为当前帧结束。这是最通用的方式尤其适合各种私有串口协议。我实际项目里用得最多的是定界符加超时的组合先扫定界符扫不完再靠超时兜底。这样不管发送端是定长还是不定长都能稳定切分。6. 实测数据与踩坑记录6.1 和普通轮询有什么差距我在 RP2040 上用 921600 波特率做过一组简单对比测试。发送端用一个 Arduino 板每隔 10ms 发 200 字节接收端分别用普通uart.read()轮询和 DMA 接收各跑 10 万字节统计丢包率。普通轮询在主循环里加了 50ms 的模拟阻塞模拟解析字符串、处理逻辑第一个阻塞周期后UART FIFO 就被填满后续数据全部丢弃。跑完 10 万字节丢包率超过 30%。DMA 接收256 字节循环缓冲即使主循环有 50ms 阻塞DMA 也在后台持续搬运阻塞结束后一次能捞起大约 4600 字节因为 50ms 内 921600 波特率下大约来了这么多字节。处理完这批数据、重置 DMA 后继续接收实测丢包率降到了 0。CPU 开销方面DMA 模式下主循环只是周期性读一个 32 位寄存器、偶尔调用dma_rx_reset()相比逐字节read()加解析开销小了一个数量级。我用time.ticks_us()实测过读取一次CH_TRANS_COUNT大约 3 到 5 微秒即使在循环里频繁调用也不会成为瓶颈。6.2 MicroPython GC 与缓冲区地址稳定性MicroPython 的堆采用标记-清除 GC对象创建和销毁会产生内存碎片但已分配对象不会在 GC 时被移动。因此bytearray在创建后地址是稳定不变的DMA 可以放心访问。但这不代表没有风险如果你在 DMA 传输过程中把bytearray对象删掉、重新赋值那 DMA 访问的内存已经被回收结果是不可预测的最典型的就是随机字节错乱或 hard fault。安全习惯有两条。第一把 DMA 缓冲区定义为模块级全局变量整个项目生命周期都持有引用绝不重新赋值。第二发送数据时确保data这个bytearray在uart_dma_send()返回后仍然有效不要传临时对象。换句话说发送缓冲区也要“持久化”要么每次调用前准备好一个不会被释放的对象要么在 DMA 传输完成之后再释放原对象。GC 本身的扫描动作不会干扰 DMA 搬运因为 GC 只是遍历堆不碰外设地址空间。唯一要注意的是不要在大块内存分配时触发 GC导致你的发送调用被延迟几毫秒——如果你在阻塞期间恰好有大量串口输入也可能塞满接收缓冲。解决办法是把接收缓冲预留大一点或者避免在关键路径上频繁创建临时对象。6.3 最容易踩的三个坑第一个坑DMACR没置位。表现就是 DMA 通道配置全对但TRANS_COUNT不减少FIFO 里的数据堆着不动。检查顺序应该是先确认DMACR使能了对应方向的 DMA 请求再确认IFLS阈值合适最后才去检查 DMA 寄存器。三个条件缺一个DMA 都不工作。第二个坑地址方向配反。最常见的是发送时忘了INCR_READ1导致 DMA 一直读同一个地址在 UART 上重复发送同一个字节或者接收时忘了INCR_WRITE1所有数据都覆盖缓冲区的第一个字节。用RING_SIZE回绕时还要特别小心RING_SEL必须设置为写地址回绕值 1否则作用在读地址上行为就完全不对了。第三个坑REPL 冲突。MicroPython 默认把 UART0 用作 REPL 输出如果你把 DMA 发送通道接在 UART0 上同时打开 REPL终端里会出现乱码、甚至 DMA 数据被 REPL 输出打断。最简单的解决办法是项目通信改用 UART1UART0 留给 REPL。如果必须用 UART0就在boot.py里执行uos.dupterm(None, 1)把 REPL 关掉确保 UART0 完全归你的应用。最后再分享一个小技巧。调试 DMA 时我习惯先跑一遍只有几个字节的最小测试把每个寄存器的值都打印出来核对确认数据能通再去调高波特率和加大数据量。直接上大包很容易分不清问题出在配置还是时序。如果遇到 DMA 一直不搬第一步一定是读一下 STATUS 寄存器看看有没有 AHB_ERROR 或者 WRITE_ERROR 标志这比瞎猜高效得多。