
做嵌入式的人大概都经历过这种场景串口接了个传感器模块每秒上报几十上百个字节你想着在主循环里慢慢 read结果一进来数据就错位换成中断方式每来一个字节就进一次中断CPU 被频繁打断主逻辑反而跑不动。如果用的还是 MicroPython 这种解释型语言高波特率下想逐字节处理 UART 数据基本上是灾难现场。RP2040 这颗芯片有个经常被忽略的强力外设——DMA。它的作用非常直接把数据从一边搬到另一边全程不经过 CPU也不让 CPU 参与每一次搬运。搭配 UART 的 FIFO 和外设请求线路就能实现真正意义上的“数据搬运零 CPU 干预”。这篇文章我会从原理讲到实操覆盖 MicroPython 下的两种实现路线新版固件自带的 rp2.DMA 模块以及老版本也能用的 uctypes 寄存器直操作方式。不管你是刚玩 Pico 的新手还是已经把 MicroPython 用于产品的开发者只要想搞清楚串口 DMA 到底怎么配、为什么这么配、踩过哪些坑这篇都能给你一份能直接抄作业的答案。1. 为什么是 DMA串口搬运的痛点与 RP2040 的解法1.1 高波特率下的 CPU 焦虑先算一笔账。以最常用的 115200 波特率为例1 个起始位 8 个数据位 1 个停止位也就是 10 个 bit 传输 1 字节理论上一字节耗时约 86.8 微秒。听着挺慢对吧但如果你用 MicroPython 在主循环里执行uart.read()一次调用背后可能涉及字节判断、缓冲区拷贝、解释器开销等这一轮处理完下一字节已经到 FIFO 甚至被覆盖了。如果强行开中断每 86 微秒打断一次 CPU主循环里的显示刷新、按键扫描、控制逻辑全都会被拖慢。更极端的场景是 1M、2M 波特率下的批量数据收发。这时候一字节只有不到 10 微秒用轮询肯定丢数据用传统中断也会让 CPU 满负荷跑在“读寄存器—搬数据—清标志”这套机械动作上真正该做的业务逻辑反而没时间跑。解决思路很朴素既然 UART 硬件自己有 FIFO能不能让一个专门的硬件模块去观察 FIFO 状态有需要就自动搬数据这就是 DMA 存在的意义。1.2 RP2040 DMA 硬件结构简述RP2040 内部有 12 个 DMA 通道编号 0 到 11每个通道都有一组独立的控制寄存器。通道之间仲裁有优先级编号越小优先级越高。每个通道最核心的寄存器无非这几个读写地址寄存器、传输计数寄存器、控制/触发寄存器。数据从哪读、写到哪、传输多少次这三件事配置好之后剩下的就是触发方式。RP2040 DMA 支持两类触发源一类是定时器触发另一类是外设请求。串口场景用的就是外设请求芯片里有一套 DREQ 编号UART0_TX 对应 20UART0_RX 对应 21UART1 是 22 和 23。DMA 每搬完一次数据会去检查对应 DREQ 信号是否为有效状态有效就继续搬下一笔无效就停下来等待。我用一个生活化类比来理解DMA 是个搬运工UART 的发送 FIFO 就是仓库出货口。仓库门前的灯亮了说明货架有空位搬运工就搬一件过去灯不亮就原地等着。整个过程里老板CPU只需要交代“从这批货搬到那批货”之后不用管搬运工怎么搬。1.3 什么才算“零 CPU 干预”需要先澄清一个概念零 CPU 干预不等于零 CPU 参与。数据搬运这个动作不占 CPU但数据到了内存之后的解析、存储、业务判断还是需要 CPU 来做。比如 DMA 把一帧传感器数据搬进了 bytearray总得有人去解读这串字节里哪个是温度、哪个是湿度。严格来说发送场景是最接近“零 CPU 干预”的CPU 配置完 DMA 后可以完全放手去干别的直到需要确认发送完成。接收场景稍微复杂一些因为 CPU 总要知道“数据到了没”这个“通知”可以通过 DMA 完成中断、UART 超时中断、或者定时器轮询传输计数来实现。但无论用哪种通知方式CPU 都不再需要逐字节搬运了这一点在高流量场景下是本质区别。换句话说你的 Python 代码不再和串口字节逐个打交道而是成块地处理数据开销差一个数量级。2. MicroPython 里操作 DMA 的两条路线2.1 官方捷径rp2.DMA 模块MicroPython 从 1.23 版本开始在 rp2 端口加入了官方 DMA 支持。如果你用的固件比较新直接在 Python 层面就可以创建 DMA 通道并配置不需要碰底层寄存器。典型用法类似这样from machine import UART, Pin import rp2, uctypes, time uart UART(0, baudrate115200, txPin(0), rxPin(1)) buf bytearray(bHello from RP2040 DMA\r\n) dma rp2.DMA() dma.config( reaductypes.addressof(buf), write0x40034000, # UART0_DR 数据寄存器地址 countlen(buf), trigrp2.DMA.UART0_TX, # 外设请求选择 UART0 发送 data_sizerp2.DMA.SIZE_BYTE, inc_readTrue, # 源地址自增 inc_writeFalse, # 目标地址固定 ) dma.active(True)这段代码的核心是trig参数它把 DMA 通道和 UART0 的发送请求线路绑定在一起。只要 UART 的发送 FIFO 有空位DMA 就自动把一个字节推进去直到整个缓冲区发送完毕。有一点需要注意rp2.DMA是 1.23 才加入的不同固件版本的字段名和参数可能略有差异。如果你在某个版本上写config()报错先执行help(rp2.DMA)确认当前固件的接口一切以实机为准。官方模块胜在便捷适合快速开发但对原理学习者来说它把 DMA 底层细节封装得太干净反而容易让人产生“知其然不知其所以然”的困惑。2.2 底层路线uctypes 直接读写寄存器如果你的固件版本比较老或者想彻底搞懂 DMA 的工作原理我建议直接用uctypes模块操作寄存器。MicroPython 的uctypes.mem32可以直接读写 32 位内存映射寄存器RP2040 所有外设寄存器都是统一编址的所以完全绕得开高级 API。RP2040 DMA 控制器基地址是0x50000000每个通道占 0x40 字节。以通道 5 为例它的关键寄存器地址是这么算出来的DMA_BASE 0x50000000 CH 5 CH_OFF CH * 0x40 READ_ADDR DMA_BASE CH_OFF 0x00 WRITE_ADDR DMA_BASE CH_OFF 0x04 TRANS_COUNT DMA_BASE CH_OFF 0x08 CTRL_TRIG DMA_BASE CH_OFF 0x0c选择通道 5 是为了避开编号靠前的通道减少和 MicroPython 内部其他驱动抢占 DMA 的概率。实际使用中可以根据项目情况调整但一般不推荐用通道 0芯片里优先级最高的通道往往被基础驱动占用。配置发送 DMA 的函数可以写成这样import uctypes, time UART0_DR 0x40034000 def dma_send(data): src uctypes.addressof(data) uctypes.mem32[READ_ADDR] src uctypes.mem32[WRITE_ADDR] UART0_DR uctypes.mem32[TRANS_COUNT] len(data) # TREQ_SEL20 (UART0_TX), INCR_READ1, DATA_SIZEbyte, EN1 ctrl (20 2) | (1 17) | (0 18) | (1 21) uctypes.mem32[CTRL_TRIG] ctrl while uctypes.mem32[CTRL_TRIG] (1 10): # BUSY 位 time.sleep_us(10)配置发送通道时源地址是内存缓冲区目标地址是 UART 数据寄存器每次搬运一字节。由于写地址固定DMA 会把源缓冲区里的内容一个接一个推到 UART 的发送 FIFO 中。CTRL_TRIG寄存器的 bit 21 是 EN 位写入 1 表示使能并触发本次传输bit 10 是 BUSY 状态位置 1 表示传输还在进行中。2.3 两种方案的取舍有人会问既然官方模块都出了为什么还要折腾寄存器我实际测试下来的感受是rp2.DMA写起来快、代码短适合原型验证但当你需要控制环形缓冲、精细管理传输时机、或者排查为什么数据卡在某个环节时懂寄存器的人一眼就能看出问题在哪。反过来如果只是想让串口别丢数据直接用官方模块就够了没必要把设备树和寄存器手册翻烂。下面这个对比表是我在使用中的直观感受对比项rp2.DMA 官方模块uctypes 寄存器直操作固件要求MicroPython 1.23任意版本代码量较少较多可读性高低需对照手册对原理理解帮助有限帮助很大排查问题效率受封装限制可直接看状态寄存器适合场景快速开发、原型验证学习原理、兼容老固件、深度集成3. 发送侧完整实现从配置到验满3.1 关键参数逐项配置发送 DMA 的配置看起来就是几个寄存器赋值但每一个位都对应一个具体行为。CTRL_TRIG里我用的(20 2)是 TREQ_SEL 字段它告诉 DMA 通道“你要监听哪个外设请求”这里 20 就是 UART0_TX。如果芯片上有两个 UARTUART1 的发送请求编号是 22别搞混。(1 17)是 INCR_READ表示每次传输后源地址自增。这是发送方向的标准配置从内存缓冲区读取时地址要递增写到 UART 数据寄存器时地址则保持固定。反过来如果是接收方向就变成读地址固定、写地址自增。DATA_SIZE 字段需要在bit 18和bit 19之间选一个值0 表示字节搬运1 表示半字2 表示字。UART 数据寄存器虽然物理宽度是 32 位但有效数据只有低 8 位所以串口场景固定用字节模式即可。有一点建议如果希望 DMA 通道在通道仲裁中更有话语权可以把 bit 20 的 HIGH_PRIORITY 位也置 1不过大多数串口场景下默认优先级就够用。3.2 数据真正发完的判断DMA 完成不等于物理发送完成这是串口 DMA 方案里最容易踩的坑。DMA 的TRANS_COUNT递减到 0只代表 DMA 已经把最后一字节写进了 UART 的发送 FIFO但这并不代表数据已经从 TX 引脚发出去了。UART 内部还有移位寄存器它负责把数据逐位移出这个动作需要时间。如果 DMA 完成之后马上切换 TX 引脚功能、关闭 UART、或者进入低功耗模式最后一两个字节很可能被截断。我在实际项目中就吃过这个亏DMA 中断触发后立刻打印完成标志逻辑上没问题但对端设备收到的数据就是少了尾巴。正确的做法是等待 UART 发送 FIFO 清空并且移位寄存器不忙。RP2040 的 UART 控制器有状态寄存器 FR偏移0x18。PL011 风格的 FR 寄存器里bit 3 是 TXFE表示发送 FIFO 已空bit 4 是 BUSY表示移位寄存器正在发送。所以发送函数最后要补这样一段UART0_FR 0x40034018 # 等 FIFO 空 while not (uctypes.mem32[UART0_FR] (1 3)): pass # 等移位寄存器空闲 while uctypes.mem32[UART0_FR] (1 4): pass不要觉得这一步多此一举。如果你接的是对时序敏感的从机设备比如需要通过 UART 唤醒的模块或者后续紧接着要切换 GPIO 复用功能这个等待是必须的。想省事的话也可以在 DMA 完成后固定延时几个字节的时间但最严谨的方式还是读 BUSY 位。3.3 发送侧实操心得与注意事项第一条心得MicroPython 里的bytearray对象虽然持有数据但 DMA 是直接通过内存地址找数据的。如果你在发送过程中重新给变量赋值或者创建了一个临时bytearray传给函数后在函数外不再持有引用DMA 可能搬着搬着发现内存内容变了甚至触发内存访问异常。稳妥的做法是把缓冲区做成模块级变量确保它的生命周期覆盖整个 DMA 传输过程。第二条心得DMA 直通 UART 数据寄存器时不要混用machine.UART的write()方法。MicroPython 的 UART 驱动底层也管理着同样的 FIFO 和状态两个模块同时写同一个数据寄存器会互相干扰出现莫名其妙的乱码。如果你想在项目里既用普通串口打印日志又用 DMA 发送数据帧建议把日志输出和 DMA 数据帧分开在两个 UART 上物理隔离省得排查半天。第三条心得dma_send()函数里等待 BUSY 清零用的是轮询这在 MicroPython 里虽然可行但如果发送的数据量很大比如几十 KB这个轮询会阻塞相当长的时间。实际产品中更推荐把 DMA 完成事件通过中断通知主逻辑主循环只在收到通知后继续下一步工作。MicroPython 里可以注册 DMA 中断回调但每个硬件平台的中断注册方式不一样代码可移植性会差一些。4. 接收侧完整实现DMA 搬运由外设触发4.1 接收通道配置与计数读取接收方向 DMA 的原理和发送完全对称。DMA 读地址固定为 UART0 数据寄存器写地址指向内存缓冲区并自增。DREQ 选择 UART0_RX编号 21。每次 UART 收到一个字节并放进接收 FIFODMA 通道就会收到一个请求把数据搬到内存。接收初始化函数RX_BUF bytearray(256) def dma_recv_start(): dst uctypes.addressof(RX_BUF) uctypes.mem32[READ_ADDR] UART0_DR uctypes.mem32[WRITE_ADDR] dst uctypes.mem32[TRANS_COUNT] len(RX_BUF) # TREQ_SEL21 (UART0_RX), INCR_WRITE1, DATA_SIZEbyte, EN1 ctrl (21 2) | (1 16) | (0 18) | (1 21) uctypes.mem32[CTRL_TRIG] ctrl配置完成后DMA 会静默地在后台工作。CPU 想知道当前收到了多少字节最直接的办法是读TRANS_COUNT寄存器它记录的是剩余待传输次数def dma_rx_count(): return len(RX_BUF) - uctypes.mem32[TRANS_COUNT]用这个函数配合定时器每 1 毫秒查一次就能做到“不逐字节处理只按消息块处理”。如果业务对实时性要求不高这个方案已经足够如果要求再高一些可以改成 DMA 完成中断——当缓冲区收满时产生一次中断CPU 一次性处理整块数据。4.2 不定长数据的处理策略实际串口通信很少是严格定长的更多时候是一条不定长命令、一帧可变长度的协议数据。DMA 固定长度接收天然不擅长处理这种场景所以需要额外策略。第一个策略是“轮询 TRANS_COUNT 差值 超时判断”。原理很简单DMA 每收到一字节剩余计数就减一所以两次查询之间差值就是新收到的数据量。配合一个定时器在确认连续一段时间没有新数据进来后就认为一帧数据接收完毕然后处理缓冲区里的内容。这个方案的优点是代码简单不依赖 DREQ 以外任何硬件特性缺点是有少量轮询开销且在超时时间设置上需要根据波特率调整。波特率越高超时时间越短一般取 3 到 5 个字节的传输时间即可。第二个策略是“双缓冲”。准备两块缓冲区 A 和 BDMA 先往 A 搬运A 接收满后切换地址到 B同时 CPU 处理 A 中的数据。这样 DMA 和 CPU 可以并行工作几乎不丢数据。在 MicroPython 里实现双缓冲需要先配置 DMA 完成中断在中断回调里修改WRITE_ADDR和TRANS_COUNT操作起来比 C 语言繁琐一些但对于数据量大的项目来说很值得。第三个思路是“环形缓冲”利用 RP2040 DMA 的 RING 模式实现地址自动回卷。RING 模式可以让 DMA 传输地址在到达某个对齐边界时自动回到缓冲区开头这样 DMA 可以一直在缓冲区里循环写入CPU 只需要记住上次处理到哪了。不过环形缓冲有一个很麻烦的约束缓冲区地址必须按 2^RING_SIZE 对齐而 MicroPython 的bytearray分配地址并不保证对齐。我曾在项目里尝试过多次分配、检查地址偏移来绕开对齐问题最后还是选择了双缓冲方案因为稳定性更好、逻辑更容易验证。4.3 最小可运行回环 Demo说了这么多原理上点能跑的东西。下面的代码把 Pico 的 UART0 TX 引脚和 RX 引脚短接用 DMA 发送一组数据再用 DMA 接收回来最后比较接收数据和原始数据是否一致。这个回环测试是验证 DMA 配置是否正确的黄金标准建议每个做串口 DMA 的开发者都先跑通这一步。from machine import UART, Pin import uctypes, time # DMA 寄存器地址通道 5 DMA_BASE 0x50000000 CH_OFF 5 * 0x40 READ_ADDR DMA_BASE CH_OFF 0x00 WRITE_ADDR DMA_BASE CH_OFF 0x04 TRANS_COUNT DMA_BASE CH_OFF 0x08 CTRL_TRIG DMA_BASE CH_OFF 0x0c # UART0 寄存器 UART0_DR 0x40034000 UART0_FR 0x40034018 # 初始化 UART0 uart UART(0, baudrate115200, txPin(0), rxPin(1)) # 准备测试数据 TX_BUF bytearray(bRP2040 DMA loopback test 12345\r\n) RX_BUF bytearray(len(TX_BUF)) def dma_send(data): src uctypes.addressof(data) uctypes.mem32[READ_ADDR] src uctypes.mem32[WRITE_ADDR] UART0_DR uctypes.mem32[TRANS_COUNT] len(data) ctrl (20 2) | (1 17) | (0 18) | (1 21) uctypes.mem32[CTRL_TRIG] ctrl while uctypes.mem32[CTRL_TRIG] (1 10): time.sleep_us(10) # 等待移位寄存器发完 while not (uctypes.mem32[UART0_FR] (1 3)): pass while uctypes.mem32[UART0_FR] (1 4): pass def dma_recv_start(buf): dst uctypes.addressof(buf) uctypes.mem32[READ_ADDR] UART0_DR uctypes.mem32[WRITE_ADDR] dst uctypes.mem32[TRANS_COUNT] len(buf) ctrl (21 2) | (1 16) | (0 18) | (1 21) uctypes.mem32[CTRL_TRIG] ctrl # 启动接收 dma_recv_start(RX_BUF) # 发送数据发送完说明接收也基本完成了 dma_send(TX_BUF) # 稍微等接收完成 while uctypes.mem32[CTRL_TRIG] (1 10): time.sleep_us(10) # 校验 if RX_BUF TX_BUF: print(DMALOOP OK) else: print(DMALOOP FAIL) print(TX:, TX_BUF) print(RX:, RX_BUF)这个 Demo 跑通之后说明 DMA 通道、DREQ 选择、地址递进三个核心要素全部正确。如果结果不正确优先检查引脚短接是否可靠其次检查通道 5 是否被其他逻辑占用最后确认波特率是否匹配。5. 常见问题与排查技巧实录5.1 发送只发一部分就停住这个问题在新手那里出现频率极高。表现为DMA 函数调用后对端只收到了开头几个字节然后就没有然后了。排查路径一般按三步走。第一步检查 TREQ_SEL 是否选对。发送要用 UART0_TX20如果误填成 UART0_RX21发送方向的数据根本不会得到请求信号DMA 可能搬了几个字节后就因为请求线路没有信号而停滞。第二步检查源缓冲区地址和内容。地址没问题但缓冲区内容在传输中被改动也会导致只剩前几个字节正确。第三步检查 UART 发送 FIFO 是否被禁用或者被其他代码复位。MicroPython 初始化 UART 后不会主动关 FIFO但如果你在前后调用过uart.deinit()再重新初始化时状态可能和预期不一样。5.2 接收数据错位或乱码接收乱码的原因相对集中。最常见的是引脚接反、TX 和 RX 交叉没接对其次是波特率两边不一致用示波器量一下波形就能确认。如果是 DMA 接收方向配置错误比如读地址没有指向 UART_DR而是指向了某个随机内存地址那 DMA 根本不会接收到有效数据只会静默地把错误内存里的内容搬到缓冲区表现就是乱码中夹杂着固定的重复模式。还有一种情况容易被忽略MicroPython 的machine.UART对象和 DMA 通道同时在接收。当你调用uart.read()时MicroPython 内部会从同一个接收 FIFO 读取数据这部分字节 DMA 就再也收不到了两边会互相抢。排查时先把 UART 对象切换成不接收状态或者干脆不要调用任何read方法。5.3 关于“DMA 发送需要等上一轮发完吗”的结论这个问题在论坛上反复出现答案是分两层的。如果问的是“DMA 会不会因为 UART FIFO 没空间而覆盖数据”答案是“不会”因为 DMA 是根据 UART 的请求信号搬运的FIFO 满时请求信号无效DMA 自动等待。如果问的是“下一轮 DMA 启动之前要不要等上一轮完全结束”答案是“要”。这里的“完全结束”包含两层含义第一上一轮 DMA 的 BUSY 位必须清零否则你重新写入TRANS_COUNT和地址寄存器可能覆盖掉正在进行的传输配置第二如果两轮数据要连续发送还要等 UART 的移位寄存器把 FIFO 里最后的字节排空否则会出现数据段之间的间隙不完整。稳妥的做法是在共用同一个 DMA 通道时始终先等待 BUSY 位清零再根据场景决定要不要等 UART 排空。5.4 问题排查方法速查现象可能原因排查手段DMA 启动后无动作TREQ_SEL 配置错误读 CTRL_TRIG对照手册确认 DREQ 编号发送只发前几字节缓冲区被回收或内容被改将缓冲区设为模块级变量发送期间不修改发送完成但对端少尾字节UART 移位寄存器未排空等待 FR 寄存器的 TXFE 和 BUSY 状态接收乱码RX/TX 接反或波特率不匹配示波器测量波形核对两边速率接收和 uart.read 数据冲突两个模块同时操作接收 FIFO只保留一种读取方式DMA 通道卡死通道被其他驱动占用换一个空闲 DMA 通道最后再分享一个我自己的习惯凡是涉及 DMA 的改动我都会先把 UART0 的 TX 和 RX 短接跑一遍上面的回环 Demo确认收发完整后才去接真实外设。这个步骤看起来简单但能筛掉一大半由于接线和配置导致的低级问题剩下需要排查的就只有业务逻辑本身了。说实话串口 DMA 在 MicroPython 里并不复杂真正决定项目成败的往往是这些不起眼的工程细节。