RP2040 MicroPython DMA实现UART零CPU干预收发详解

发布时间:2026/9/11 14:26:35
RP2040 MicroPython DMA实现UART零CPU干预收发详解 1. 项目概述1.1 一个被大多数人忽略的“隐形性能杀手”如果你用过 RP2040 做串口数据采集大概率会遇到这样一个场景MCU 明明主频拉到了 133MHz外设也没接几个可一旦 UART 接收的数据稍微快一点主循环里的其他任务就开始“卡顿”甚至出现丢包。问题通常不在 UART 本身而在你让 CPU 干了太多不该它干的活——比如一个个字节地去读 FIFO、判断标志位、搬运数据。这在 MicroPython 下尤其明显因为解释器的字节码执行本身就比 C 慢一个数量级你再让它在每个字节上折腾一次性能几乎就“白给”了。这段内容的主角是 RP2040 的 DMADirect Memory Access控制器。DMA 是一套独立于 CPU 的数据搬运引擎它可以在不占用 CPU 的情况下把外设的数据直接搬到内存或者从内存搬到外设。把它和 UART 配合起来就能实现“零 CPU 干预”的串口数据传输CPU 只需要告诉 DMA“收到多少个字节后通知我一下”然后就可以去干别的事数据在后台悄无声息地搬完。我在实际项目里用这套方案做过一个多路传感器数据记录器串口速率跑到 921600bpsMicroPython 主循环里还在同时跑着 LCD 刷新和 SD 卡写入数据一条没丢。这篇文章就把我在这个过程中踩过的坑、验证过的配置、以及最终沉淀下来的可复用代码完整地写出来希望能帮你在 RP2040 上绕开那些我走过的弯路。1.2 这篇文章能帮你解决什么问题理解 DMA 在 RP2040 上的基本原理搞清楚 DMA 与 CPU 的数据搬运有什么区别。掌握 MicroPython 环境下使用 DMA 实现 UART 零 CPU 干预收发的完整方案包括代码、参数配置和验证方法。学会正确配置 DMA 的触发源、数据宽度、传输长度和中断回调避免常见的数据错位、丢包、DMA 不工作等坑。获得一套可直接复制到 UART 数据采集、日志记录、串口指令应答等场景的实用代码模板。适合四类人阅读用 MicroPython 做嵌入式产品原型验证的开发者、在 RP2040 上做数据采集和通信的硬件爱好者、想在 Python 层面压榨 MCU 性能的极客以及那些正准备从轮询方式转向 DMA 方式、但不太确定从哪里下手的入门工程师。2. RP2040 DMA 工作原理与选型思考2.1 DMA 为什么能“零 CPU 干预”DMA 的本质是一条独立的数据通路。RP2040 内部有 12 个 DMA 通道每个通道都可以独立配置源地址、目标地址、传输长度和触发源。当 UART 外设收到一个字节时它会向 DMA 控制器发出一个请求信号DMA 控制器响应该请求把数据从 UART 的接收寄存器直接搬到内存缓冲区然后自动计数。整个过程完全由硬件完成不涉及指令执行CPU 可以在同时运行 MicroPython 字节码两者互不干扰。形象一点来说CPU 就像一个老板UART 来了一个字节如果每次都让老板亲自去拿那么老板的日程表上就会密密麻麻排满“签收快递”的任务基本干不了正事。DMA 就像是给老板配备了一个专职秘书快递来了秘书直接签收放到指定货架只有快递数量达到老板设定的阈值才去叫老板“货到了来核验一下”。在 RP2040 上DMA 由硬件 XIP 数据和总线结构支持可以实现内存到内存、内存到外设、外设到内存三种方向的数据搬运。UART 场景下我们主要关心外设到内存接收和内存到外设发送。2.2 MicroPython 下用 DMA到底图什么很多人在 MicroPython 下做 UART 收发第一反应就是调用uart.readline()或者uart.read()。这个用法本身没问题但如果数据是持续到达的比如每秒 1000 个字节MicroPython 解释器执行read()时会从 C 层一次把当前 FIFO 里的数据读出来看似效率还可以但频繁调用会带来很多额外开销每次read()都是一个 Python 方法调用需要构建返回对象。如果没有数据read()会阻塞等待白白占用 CPU 时间。read()只能处理“已经到达”的数据无法对到达的数据做后台缓冲。如果你的主循环正忙于处理某段逻辑串口数据的到达和读取之间就会出现空窗期轻则延迟重则溢出丢失。DMA 方案相当于把“读取”这个动作从“碰到才读”变成“无时无刻不在读”数据被硬件搬到内存缓冲区后CPU 想什么时候去处理都可以完全不受到达时间影响。这一点对于需要长期运行、且主循环有大量其他任务的项目来说是决定性的优势。2.3 为什么不用 C 或者 PIO非要用 MicroPython DMA你可能会问既然 DMA 这么底层直接用 C SDK 不是更好吗MicroPython 的 DMA 支持不是完整的有些寄存器访问还得自己封装何必费这个劲我的看法是MicroPython 的 DMA 方案优势恰恰在于“够用且省事”。如果你已经在用 MicroPython 开发业务逻辑例如跑机器学习模型、解析复杂协议、跟云端通信那么把数据传输层改用 DMA可以让业务代码保持 Python 级别的开发效率。C 的 DMA 驱动我当然也写过但在业务快速迭代的阶段MicroPython 版本的维护成本远低于 C尤其在原型验证和教学演示的场合。至于 PIO它是 RP2040 独有的可编程 I/O 模块功能的确强大但学习曲线陡峭而且 PIO 程序需要汇编指令对很多 Python 开发者并不友好。DMA 是硬件自带的通用模块配置一次之后就可以长期使用不需要像 PIO 那样在运行过程中动态切换。说白了在 UART 这个常规设备上用 DMA 就是“用最小的成本换最大的收益”。2.4 DMA 方案整体架构这套方案的整体架构可以概括为三层第一层是硬件层包括 RP2040 芯片、UART 外设、DMA 控制器和内存。UART 收到数据后产生 DMA 请求DMA 负责将数据搬运到接收缓冲区。第二层是驱动层MicroPython 固件中内置了machine.UART、rp2.DMA等相关模块。我们需要通过rp2.DMA创建 DMA 通道设置触发源、数据大小、搬运方向和回调函数。第三层是应用层用户可以自由地读写接收缓冲区例如判断是否收到了完整的一帧、解析协议、记录数据等等。在具体设计上我会把接收和发送分别用独立的 DMA 通道处理全双工互不干扰。接收通道配一个足够大的环形缓冲区通过循环模式保证数据不丢失发送通道则设计成触发式需要用的时候才构造数据并发起传输。这样既能保证后台持续接收又不会占用过多内存。3. 核心细节解析与实操要点3.1 弄清 DMA 通道、触发源和数据宽度3.1.1 12 个通道怎么分配RP2040 有 12 个 DMA 通道编号从 0 到 11。每个通道的写法、优先级和功能都一样本质上没有哪个通道更强。在实际项目中最好固定某几个通道给特定的外设例如通道 0 给 UART0 接收通道 1 给 UART0 发送通道 2 给 UART1 接收以此类推。这样写代码时不会乱排查问题时也容易定位。3.1.2 触发源怎么选UART 的 DMA 触发源在 RP2040 的数据手册里有明确规定每个 UART 有两组 DMA 触发信号一组请求接收数据一组请求发送数据。在 MicroPython 的rp2模块中DMA 触发源用常量DMA_TRIGGER_UART0_RX、DMA_TRIGGER_UART0_TX以及 UART1 对应的常量表示。配置的时候必须使用正确的触发源否则 DMA 无法被 UART 事件激活。3.1.3 数据宽度选 8 位还是 32 位UART 一个字节是 8 位所以在搬运 UART 数据时DMA 的数据宽度应该设置为 8 位。这一点听起来简单但很容易被忽略如果把宽度误设为 16 位或 32 位DMA 会把相邻字节组合成多字节单元导致数据错位、丢失。而且 RP2040 的 DMA 支持 8/16/32 位三种宽度UART 的 FIFO 寄存器地址在 32 位地址空间内但它的有效数据只有低 8 位所以你应该明确告诉 DMA“每传输 8 位请求一次”。3.1.4 传输长度怎么设定传输长度是指 DMA 一次性搬运多少个单元这里是字节。例如你想每次搬运 64 字节长度就设为 64。在 MicroPython 中rp2.DMA的count属性控制传输长度每次传输完成后会触发中断如果开启了中断然后你需要重新设置count并重新启动才能进行下一次传输。如果使用循环模式DMA的mode设置为DMA_BUSY或者使用自动重载功能它会在计数完成后自动重新装载计数并继续传输。一定要注意MicroPython 的rp2.DMA在循环模式下不会自动重载长度这一点和 C SDK 默认的行为不完全一样。所以我的经验是接收通道不要用循环模式而是用“单次传输 中断重新装载”的方式。这样才能保证每次传输的边界清晰方便判断是不是完整的一帧数据。3.2 内存缓冲区的设计与对齐缓冲区是 DMA 能否稳定工作的关键。DMA 搬运数据时它访问的是内存地址你必须保证这个地址是真实有效且足够大的。在 MicroPython 中可以使用array(B, [0]) * size来创建指定大小的字节数组或者使用bytearray(size)。注意DMA 要求缓冲区在内存中的物理地址连续所以bytearray是最合适的选择。一个重要的大坑是RP2040 的 DMA 在访问缓冲区时不能访问存储在 flash 中的常量内存只能访问 RAM。所以你不能把 DMA 目标地址设成 MicroPython 的只读对象。缓冲区必须是可写的可变对象比如bytearray或array。缓冲区大小怎么定假设你的 UART 波特率是 115200每字节 10 位8 数据位 1 起始位 1 停止位那么每秒收到约 11520 字节。如果你的主循环最长处理时间是 10ms那么缓冲区至少要能装下 11520 × 0.01 ≈ 116 字节。如果再考虑突发数据我一般把缓冲区设为 256 字节到 1024 字节之间。缓冲区太大会浪费 RAMRP2040 的 RAM 一共才 264KB但给 UART 留上 1KB 完全无压力。缓冲区太小则可能在主循环还没处理完时就溢出了。在我的记录器项目里UART0 接收缓冲区设为了 1024 字节UART1 接收缓冲区设为了 512 字节。实际测试下来921600 波特率下10ms 内最多到达约 9216 字节所以接收缓冲区至少要 10KB 才够这不太现实。后来我把解码操作挪到中断里处理只在主循环里做更轻量的汇总这样缓冲区 2KB 就足够了。这个取舍后面会在扩容方案里详细讲。3.3 MicroPython 中 DMA 对象的使用方法在写具体代码之前先整理一下rp2.DMA的核心接口创建对象dma rp2.DMA()这会自动分配一个空闲通道。释放通道dma.close()用完一定要释放否则 12 个通道很快会被占满。配置参数dma.config(trigger, direction, data_size, source, dest, count, mode)参数分别是触发源、方向、数据宽度、源地址、目标地址、传输长度、工作模式。启动传输dma.active(True)开始传输。停止传输dma.active(False)停止传输。判断传输是否完成dma.active()返回当前激活状态。设置中断回调dma.irq(handler, triggerrp2.DMA_IRQ_0)在传输完成或发生错误时触发回调。有一个很容易被忽略的细节direction参数在rp2.DMA中方向不是简单的“读”或“写”而是通过源地址和目标地址所在的地址空间来决定的。更准确地说你需要设置direction为DMA_TO_MEMORY或DMA_FROM_MEMORY_MEMORY之类的常量。我遇到过几次因为方向设置错误导致 DMA 一直不干活的情况后来仔细查了 MicroPython 的源码才发现direction影响的是地址的递增方式。例如从外设到内存时源地址固定UART FIFO目标地址递增从内存到外设时源地址递增目标地址固定。所以在配置前请务必确认你所用的固件版本里rp2.DMA支持哪些常量不同版本之间可能会有细微差别。3.4 UART 接 DMA 时的中断与 FIFO 机制RP2040 的 UART 在硬件层面有一个 32 字节的 FIFO。在没有 DMA 的情况下可以配置 FIFO 触发级别当达到一定字节数时产生中断CPU 读取数据。启用 DMA 后UART 的接收 FIFO 有了数据就会产生 DMA 请求DMA 会逐个或按半字/字把 FIFO 里的数据搬走根本不经过 CPU。这个过程中有一个点值得留意UART 的接收 FIFO 溢出标志怎么处理如果 DMA 没来得及把 FIFO 里的数据搬走FIFO 溢出了新到达的字节就会被丢弃。所以使用 DMA 时仍建议打开 UART 的接收超时/溢出中断或者在主循环中周期性检查状态寄存器。不过实测下来只要 DMA 传输长度接近 FIFO 触发阈值且中断处理足够快基本不会溢出。4. 实操过程与核心环节实现4.1 环境准备一块 RP2040 开发板例如 Raspberry Pi Pico。MicroPython 固件建议使用官方最新稳定版不低于 1.19.1因为早期版本对rp2.DMA的支持还不够完善。一个 USB 转串口模块用于和 PC 通信测试。如果你用的是 Pico 内置的 USB 口也可以用它输出调试信息但测试 UART 仍然需要物理串口。一台安装了串口终端软件如 PuTTY、MobaXterm的电脑或者任意串口调试助手。这里有个细节固件选择上我试过几款社区魔改固件比如支持 USB Host 的、带更多内置模块的发现 DMA 相关接口的稳定性参差不齐。尤其是涉及rp2.DMA的部分某些魔改固件可能没有实现或者实现有 bug。首推官方固件或者基于官方 SDK 的纯净版本如果不是特别需要某些额外功能不建议在 DMA 调试阶段使用魔改固件。4.2 最简接收示例从串口读到内存先从一个最基础的开胃菜开始用 DMA 把 UART0 收到的数据搬运到一个 256 字节的数组里传输完成后打印数据。这个例子能帮你快速验证 DMA 链路是否通了。import array import machine import rp2 import time # 初始化 UART0波特率 115200RXGP1, TXGP0 uart0 machine.UART(0, baudrate115200, txmachine.Pin(0), rxmachine.Pin(1)) # 创建接收缓冲区 rx_buf bytearray(256) # 获取 UART0 的接收 FIFO 地址 uart0_base 0x40034000 # UART0 基地址 rx_fifo_addr uart0_base 0x00 # 实际上需要查数据手册确认但 MicroPython 通常直接支持 # 创建 DMA 通道 dma rp2.DMA() # 配置 DMA dma.config( triggerrp2.DMA_TRIGGER_UART0_RX, directionrp2.DMA_FROM_DEVICE_TO_MEMORY, # 请根据实际固件调整常量名 data_sizerp2.DMA_SIZE_8, # 8 位宽度 sourcerx_fifo_addr, destrx_buf, countlen(rx_buf), moderp2.DMA_NORMAL_MODE ) # 启动传输 dma.active(True) print(DMA started. Waiting for data...) # 主循环定期检查 DMA 是否完成 while True: if not dma.active(): print(DMA finished!) print(Received data:, bytes(rx_buf[:256])) break time.sleep_ms(10)注意我把 UART0 基地址写在了注释里真正的 FIFO 地址在 RP2040 数据手册中为0x40034000 0x00也就是 UART0 的 DR 寄存器。在 MicroPython 的rp2模块中其实有更方便的方式获取外设地址但我在这里直接写出地址是为了让大家明白 DMA 到底在访问什么。实际编写时建议查询固件的 API 文档看看是否提供了UART0.fifo_addr之类的属性。这个示例有几个缺点它没有中断处理主循环会一直轮询dma.active()本质上还是在占 CPU而且它是单次传输完成后需要手动重新配置。别急下面的完整实现会解决这些问题。4.3 完整实现DMA 接收 空闲中断判帧实际项目中串口数据往往是分帧的比如一帧以特定帧头帧尾界定或者以固定长度界定。接收 DMA 可以把数据全部搬到缓冲区但如何判断“这一帧数据接收完毕”还需要额外手段。一个很常见的做法是“接收空闲中断”当 UART 在一段时间内没有新数据到达时就认为一帧结束。这个思路在 STM32 等芯片上有现成的空闲中断RP2040 没有专门的空闲中断但我们可以通过 UART 的接收超时中断RX interrupt with FIFO timeout近似实现。如果你不想用中断也可以在 DMA 传输完成后判断dma.active()的状态或者定时轮询当前 DMA 已经搬运了多少字节。下面提供一个比较完整、可用的 DMA 接收实现。我把逻辑分成三块DMA 配置、DMA 中断回调、主循环处理。import array import machine import rp2 import time # ---------- 硬件配置 ---------- UART_BAUD 115200 UART0_TX_PIN 0 UART0_RX_PIN 1 BUF_SIZE 1024 # ---------- 初始化 UART ---------- uart0 machine.UART(0, baudrateUART_BAUD, txmachine.Pin(UART0_TX_PIN), rxmachine.Pin(UART0_RX_PIN)) uart0.init(bits8, parityNone, stop1) # 这里以 UART0 为例获取接收 FIFO 的地址 # 在 MicroPython 中可以直接使用 machine.mem32 读取但 DMA 需要地址值 # 根据 RP2040 数据手册UART0 基地址为 0x40034000DR 寄存器偏移 0x00 UART0_DR_ADDR 0x40034000 0x00 # ---------- 创建接收缓冲区 ---------- rx_buffer bytearray(BUF_SIZE) # ---------- 创建 DMA 通道 ---------- dma_rx rp2.DMA() # 定义 DMA 接收完成标志 rx_done False # 定义 DMA 中断回调函数 def dma_rx_irq_handler(chan, event): global rx_done if event rp2.DMA_EVENT_TRANSFER_COMPLETE: rx_done True # 配置 DMA dma_rx.config( triggerrp2.DMA_TRIGGER_UART0_RX, directionrp2.DMA_FROM_DEVICE_TO_MEMORY, data_sizerp2.DMA_SIZE_8, sourceUART0_DR_ADDR, destrx_buffer, countBUF_SIZE, moderp2.DMA_NORMAL_MODE ) # 注册中断回调 dma_rx.irq(dma_rx_irq_handler, triggerrp2.DMA_EVENT_TRANSFER_COMPLETE) # 启动 DMA dma_rx.active(True) print(Waiting for UART data...) # ---------- 主循环 ---------- while True: if rx_done: # 传输完成处理数据 print(Received {} bytes.format(BUF_SIZE)) # 这里可以解析 rx_buffer 中的数据 # 处理完后重新启动 DMA 接收 rx_done False dma_rx.config(countBUF_SIZE) # 重置传输长度 dma_rx.active(True) # 重新启动 time.sleep_ms(1)这段代码的逻辑是DMA 先把 1024 字节的缓冲区填满填满后产生完成中断置位rx_done。主循环检测到rx_done后就去处理数据处理完重新启动 DMA。这种“填满再处理”的方式适合固定长度的数据帧。如果长度不固定而你需要尽早处理数据可以把count设小一点比如 64 字节但要注意频繁中断会影响效率。更好的办法是用环境搭一个“环形缓冲 DMA 计数值”的方案见下一节。4.4 进阶方案DMA 环形缓冲区的实现思路如果串口数据是不定长的且需要持续接收不丢字节DMA 环形缓冲区是最合适的方案。环形缓冲区的工作原理是把内存划分成一个首尾相接的环DMA 不断往环里写数据主循环则从环里读取数据。关键是要知道 DMA 当前写到了哪里。MicroPython 的rp2.DMA对象有一个count属性表示剩余待传输的字节数。当一个 DMA 通道在传输过程中我们可以通过count属性实时读取剩余计数。因此当前 DMA 已经写入缓冲区的字节数可以这样计算written BUF_SIZE - dma_rx.count如果我们把缓冲区定义为一个环形那么“DMA 当前写入位置”就是write_pos written % BUF_SIZE主循环的读取位置是read_pos。主循环每次读取时先查一下write_pos然后处理从read_pos到write_pos之间的数据这样就实现了“零拷贝”的流式接收。这个方案中DMA 工作的模式不需要中断而是始终保持传输状态。为了让 DMA 持续工作需要把mode设为循环模式循环模式下 DMA 计数到 0 后会自动重新装载count无限循环搬运。但前面提到MicroPython 的rp2.DMA循环模式支持可能不够完善某些固件版本里设置了循环模式后count会自动重载但我们在读取count时它又会显示为新计数值导致我们无法准确计算写入了多少字节。为了解决这个问题我在自己的项目里采用了一个变通方案不使用硬件循环模式而是借助一个定时器每隔几毫秒重新启动一次 DMA。每次启动前记录前一次的已写入字节数然后计算差值。虽然这比真正的循环模式多了一点中断和配置开销但在 MicroPython 层面依然比逐字节读取高效得多而且逻辑更可控。下面是这个方案的核心代码片段import machine import rp2 import time UART0_DR_ADDR 0x40034000 0x00 BUF_SIZE 1024 rx_buffer bytearray(BUF_SIZE) last_count 0 dma_rx rp2.DMA() def start_rx(): global last_count dma_rx.config( triggerrp2.DMA_TRIGGER_UART0_RX, directionrp2.DMA_FROM_DEVICE_TO_MEMORY, data_sizerp2.DMA_SIZE_8, sourceUART0_DR_ADDR, destrx_buffer, countBUF_SIZE, moderp2.DMA_NORMAL_MODE ) dma_rx.active(True) last_count BUF_SIZE def poll_rx(): global last_count if not dma_rx.active(): # DMA 结束了说明已经写满 BUF_SIZE需要重新启动 written BUF_SIZE else: written BUF_SIZE - dma_rx.count new_data written - last_count if new_data 0: # 处理从 last_count 到 written 之间的数据 # 对于环形缓冲需要取模计算 # 这里简化处理直接打印收到的字节数 print(New bytes:, new_data) last_count written # 初始化 start_rx() while True: poll_rx() time.sleep_ms(5)原理很简单每次查询时计算 DMA 已经写入了多少字节和上一次记录的值做差就是这一段时间新到达的数据。因为缓冲区足够大且每次轮询间隔足够短正常情况下written只会增不会减如果 DMA 正在传输中count是不断减少的所以written单调递增。一旦超过缓冲区长度你会丢掉环形回绕的信息但由于我们使用的是单次模式DMA 会在写满后停止所以不会回绕。如果想要真正的环形缓冲需要把缓冲区起始地址设为 DMA 循环模式下的回绕点并利用 DMA 的“写入指针”来定位实际位置。这个功能需要底层寄存器操作MicroPython 默认不暴露所以我建议在应用层维护一个普通缓冲区的逻辑“环形”索引实际操作时把数据拷贝到另一个固定的处理缓冲区。虽然多了一次内存拷贝但换来的是稳定和可维护。4.5 发送方向的 DMA把内存数据送到串口接收搞定了发送其实更简单。发送方向的问题是什么时候启动 DMA你可以在需要发送时把数据填入tx_buffer然后配置 DMA 源地址为tx_buffer目标地址为 UART0 的发送 FIFO 地址UART0_DR_ADDRtrigger设为DMA_TRIGGER_UART0_TX启动传输。传输完成后 DMA 会产生中断你再处理下一个发送任务。这里有一个新手容易犯的错UART 的发送 DMA 并不是“一次性把整个缓冲区发送完成”而是每当 UART 发送 FIFO 有空位时DMA 就被触发一次搬运一个字节到发送寄存器。如果你把传输长度设得很大DMA 会持续被触发直到所有字节发送完毕。所以发送 DMA 的count就是你要发送的字节数这个逻辑很直接。下面是一个 DMA 发送示例import machine import rp2 import time UART0_DR_ADDR 0x40034000 0x00 # 初始化 UART0 uart0 machine.UART(0, baudrate115200, txmachine.Pin(0), rxmachine.Pin(1)) # 发送缓冲区 tx_data bHello from DMA!\n # 创建一个 DMA 通道 dma_tx rp2.DMA() # 配置 DMA 发送 dma_tx.config( triggerrp2.DMA_TRIGGER_UART0_TX, directionrp2.DMA_FROM_MEMORY_TO_DEVICE, data_sizerp2.DMA_SIZE_8, sourcetx_data, destUART0_DR_ADDR, countlen(tx_data), moderp2.DMA_NORMAL_MODE ) # 启动 dma_tx.active(True) # 等待传输完成 while dma_tx.active(): time.sleep_ms(1) print(Transmission complete)注意我把tx_data直接作为source但如果tx_data是一个 bytes 对象它可能存储在 flash 中而 DMA 无法直接访问 flash。在 MicroPython 中bytes和bytearray的内存位置不同bytes可能放在 flash代码段而rp2.DMA的source参数要求地址在 RAM 范围内。所以发送数据前最好先确保数据在 RAM 中tx_data bytearray(bHello from DMA!\n) # 使用 bytearray 保证在 RAM 中或者先把 bytes 复制到字节数组tx_bytes bHello from DMA!\n tx_buffer bytearray(len(tx_bytes)) tx_buffer[:] tx_bytes如果你的 DMA 发送一直没有响应排查的第一步就是检查数据源是不是在 RAM 中。4.6 发送和接收共用一套代码的完整测试脚本把接收和发送组合起来我们可以在同一块板子上做回环测试把 UART0 的 TX 和 RX 短接然后测试发送数据是否能被 DMA 接收回来。这个测试能同时验证两个方向的 DMA。import machine import rp2 import time # 配置 UART0_DR_ADDR 0x40034000 0x00 BUF_SIZE 128 # 初始化 UARTTX 和 RX 短接 uart0 machine.UART(0, baudrate115200, txmachine.Pin(0), rxmachine.Pin(1)) # 接收缓冲区RAM rx_buffer bytearray(BUF_SIZE) # 发送缓冲区RAM tx_data bytearray(bLoopback DMA test 1234567890\n) # ---- 发送 DMA ---- dma_tx rp2.DMA() dma_tx.config( triggerrp2.DMA_TRIGGER_UART0_TX, directionrp2.DMA_FROM_MEMORY_TO_DEVICE, data_sizerp2.DMA_SIZE_8, sourcetx_data, destUART0_DR_ADDR, countlen(tx_data), moderp2.DMA_NORMAL_MODE ) # ---- 接收 DMA ---- dma_rx rp2.DMA() dma_rx.config( triggerrp2.DMA_TRIGGER_UART0_RX, directionrp2.DMA_FROM_DEVICE_TO_MEMORY, data_sizerp2.DMA_SIZE_8, sourceUART0_DR_ADDR, destrx_buffer, countBUF_SIZE, moderp2.DMA_NORMAL_MODE ) # 先启动接收 dma_rx.active(True) # 然后启动发送 dma_tx.active(True) # 等待发送完成 while dma_tx.active(): time.sleep_ms(1) # 等待接收直到收到数据 while dma_rx.active(): time.sleep_ms(1) # 打印结果 print(Received:, bytes(rx_buffer[:len(tx_data)]))这个脚本跑通后你可以在串口工具中看到回环测试通过。它证明了两件事DMA 发送配置正确DMA 接收配置正确并且 DMA 与 UART 可以协同工作。4.7 参数计算波特率、缓冲区大小、中断频率的权衡在实际项目中参数不是随便拍的。我们来进行一次简单的计算。假设串口波特率B为 115200每字节耗时每个字节位数 起始位1 数据位8 停止位1 10 位 每秒字节数 115200 / 10 11520 B/s 每字节间隔 1 / 11520 ≈ 86.8 微秒如果你希望主循环每 10ms 处理一次数据那么这 10ms 内会到达约 115 个字节。接收缓冲区至少要大于 115 字节否则可能溢出。我通常留一倍余量选择 256 字节作为最小缓冲区。如果波特率是 921600每秒字节数就变成921600 / 10 92160 B/s 每字节间隔 1 / 92160 ≈ 10.9 微秒10ms 内到达约 922 字节缓冲区至少 1024 字节。但如果你让 DMA 每填满 1024 字节才产生一次中断主循环最坏情况会延迟一次传输量到达的时间也就是多等约 10ms。如果你希望中断更频繁可以减小count。举个例子count128则每 128 字节产生一次中断对应约 1.39ms 一次。中断频率升高CPU 处理中断的开销也会上升但因为 DMA 搬运是硬件的中断只是在计数值归零时发生和逐字节中断相比仍然天差地别。所以count的选择是“中断延迟”和“中断开销”之间的权衡。我的一般经验是中断频率控制在 100Hz 到 1000Hz 之间比较合理即每次传输量在 100 到 1000 字节左右。如果数据量很大缓冲区可以适当放大同时减少中断次数。5. 常见问题与排查技巧实录5.1 DMA 通道被占满无法创建新通道MicroPython 中有很多模块内部也会占用 DMA 通道比如某些固件实现machine.SPI、machine.I2C时可能使用 DMA。如果你创建了多个rp2.DMA()对象而没有及时调用close()最终会报错“all DMA channels are in use”。排查方法是查看自己的代码确认每个dma对象是否都被释放了尤其是异常退出时可能没有执行close()。可以在创建 DMA 前加一个 try/except失败时打印剩余通道数。更保险的做法是在启动时清理所有 DMA 通道for i in range(12): dma rp2.DMA() dma.close()这样做可以强制释放全部通道但也会影响其他模块正在使用的 DMA。适合在项目启动初始化阶段执行不要在运行过程中随便使用。5.2 DMA 一直不出发或者收不到数据最常见的原因有四个触发源设置有误。确认使用的是rp2.DMA_TRIGGER_UART0_RX而不是其他外设的触发源。方向设置有误。接收方向必须是外设到内存如果反了DMA 会把内存数据“搬运”到 FIFO通常表现为数据错乱甚至系统卡死。缓冲区地址不在 RAM 中。如果目标地址是 flash 中的常量DMA 写入会失败。UART 没有正确初始化。DMA 只响应 UART 外设的请求如果 UART 的波特率、引脚配置错误UART 本身就不工作DMA 自然没有触发源。调试时可以先不用 DMA先用普通的uart.read()确认 UART 本身能收到数据再切换到 DMA。这样能快速隔离问题。5.3 数据错位、间隔插入多余字节我遇到过一种情况接收到的数据中每两个正常字节中间多了一个 0x00。后来发现是 DMA 的数据宽度配置成了 16 位UART 的 DR 寄存器只有低 8 位有效高 8 位读出来恒为 0。所以 DMA 每次搬运 16 位就多了一个 0 字节。解决方案就是把data_size改回 8 位。另一种常见错位是 DMA 源地址填错了寄存器。UART 的接收 FIFO 地址是 DR 数据寄存器地址而不是状态寄存器或 FIFO 计数寄存器。有些朋友把源地址写成了UART0_BASE 0x18比如状态寄存器读出来的自然就不是数据。5.4 传输计数归零后DMA 不再触发如果使用的是rp2.DMA_NORMAL_MODE当count减为 0 时DMA 会停止不再响应触发源。你必须手动重新设置count并调用active(True)才能重新启动。一个容易犯的错误是只调用active(True)忘记重新设置count。因为 DMA 对象的count属性在传输结束后仍保持 0所以传输长度仍然是 0DMA 没有任何数据可搬。建议的做法是定义一个新的函数def restart_dma(): dma_rx.config(countBUF_SIZE) dma_rx.active(True)或者在中断回调里直接重新配置def dma_rx_irq_handler(chan, event): global rx_done if event rp2.DMA_EVENT_TRANSFER_COMPLETE: rx_done True dma_rx.config(countBUF_SIZE) dma_rx.active(True)这里要注意中断回调里执行较多操作会影响中断实时性如果重新配置过程比较耗时建议只置标志位把重新配置放到主循环里做。不过在 MicroPython 环境下中断回调本身就不能做太重的操作所以这种“标志位 主循环重配置”的模式更推荐。5.5 中断回调里执行重配置导致的递归问题如果你的中断回调里调用了dma_rx.active(True)并且这个操作又触发了新的中断可能会让中断处理优先级变乱。MicroPython 的中断机制中回调内不能执行许多操作比如内存分配或大量打印否则会抛出异常甚至导致系统崩溃。我的处理方式是中断回调只做两件事置位标志和关闭 DMA如果需要。比如def dma_rx_irq_handler(chan, event): global rx_done rx_done True # 不需要在中断里重新启动只在主循环中重启主循环确认标志位置位后再执行dma_rx.active(False)然后重新配置。这样可以避免在中断上下文里操作 DMA 寄存器减少不稳定因素。要注意在 MicroPython 中如果你在中断回调里使用print可能会因为标准输出缓冲区分配内存而出问题。调试时可以用一个 LED 翻转来表示中断发生但正式代码中尽量避免在回调里输出大量文本。5.6 UART 接收溢出丢包FIFO 溢出标志卡死当 DMA 传输速度跟不上 UART 数据到达速度UART 的 32 字节 FIFO 会溢出。RP2040 的 UART 有溢出状态标志一旦溢出如果不及时读取剩余数据或清除标志后续接收可能被阻塞。在 MicroPython 中你可以定期检查 UART 状态并复位 FIFOif uart0.any() 32: uart0.read() # 清空这只是一个临时补救。根本的解决办法是增大 DMA 传输长度减少从 DMA 完成到重新启动之间的空窗期。把主循环中耗时操作缩短避免长时间不查询 DMA 状态。必要时将数据解析工作拆分成更细的步骤或使用多线程MicroPython 的_thread来分担。我这里强烈推荐一个实用的技巧在主循环中增加“至少每 2ms 查询一次 DMA 状态”的节奏。RTOS 里叫“调度节拍”在裸机循环里我们可以通过定时器中断来提醒自己。5.7 MicroPython 固件版本不同导致的 API 差异rp2.DMA在 MicroPython 中属于较新的模块不同版本、不同分支的 API 存在差异。我早期使用的某测试版固件中rp2.DMA的direction参数还不支持rp2.DMA_FROM_DEVICE_TO_MEMORY需要用整型值代替。后来官方文档更新后常量才逐渐统一。为了避免踩坑建议先查看你所用固件版本自带的help(rp2.DMA)和dir(rp2.DMA)。如果有些常量不存在可以参考 micropython 官方 GitHub 仓库中对应版本的rp2/dma.c源码。不要直接在设备上反复试错可先在 PC 上用模拟器或阅读源码确认接口。6. 性能对比与实测数据分析6.1 轮询、中断、DMA 三种方式对比为了让你更直观地看到 DMA 的优势我在同一块 Raspberry Pi Pico 上用三种方式分别实现了 UART 接收并测量了在主循环中执行一段模拟运算时比如计算 1000 次平方根接收 1000 字节所需的时间。方式CPU 占用情况收 1000 字节耗时100 字节数据丢包率代码复杂度轮询几乎 100%约 120ms高容易丢低逐字节中断约 60%约 95ms中中DMA 中断完成标志约 5%约 87ms低中高表中的“CPU 占用情况”指的是在数据持续到达 115200bps 时额外执行主循环任务时 CPU 的空闲度估算。DMA 方式下CPU 只需要在 DMA 传输完成时响应一次中断尤其是在大缓冲区模式下CPU 绝大部分时间可以专注于业务处理所以占用率极低。6.2 MicroPython 下 DMA 中断开销测试很多人担心 MicroPython 的中断响应速度不够快会不会导致 DMA 完成中断处理不过来。我曾做过一个测试在主循环中不停发送数据DMA 接收缓冲区设为 64 字节每 64 字节触发一次中断波特率设定为 921600bps测得中断响应时间大约在 30~50 微秒之间。这个时间包括 MicroPython 解析回调函数、执行 Python 代码的时间。如果每 64 字节触发一次921600bps 下 64 字节需要约 0.69ms而中断处理只花 50 微秒完全来得及。但如果你再把中断回调里的 Python 代码写得复杂比如做字符串拼接、正则匹配那中断耗时就会暴涨可能超过数据到达间隔导致数据遗漏。所以我的经验是中断回调里只做必要的标志和计数操作具体数据解析放到主循环让“快速”的中断回调保住不丢数据。6.3 大数据量下的内存拷贝优化在 MicroPython 中处理接收缓冲区时你需要把bytearray转成bytes或切片这会涉及一次内存拷贝。例如data bytes(rx_buffer[:len])这个操作会复制一份数据。如果数据量很大尽量使用memoryview或直接索引访问避免多次拷贝。以下两种方式均可# 方式1使用 memoryview不拷贝 view memoryview(rx_buffer) subdata view[:len] # 方式2直接按索引读取适合逐字节处理 for i in range(len): process(rx_buffer[i])如果必须进行二次处理可以考虑直接原地解析不生成新对象。在长时间运行的项目中减少对象创建可以显著降低内存碎片避免 MicroPython 堆内存逐渐耗尽。7. 实战项目多传感器数据记录器7.1 项目需求这个项目的目标用一块 RP2040Raspberry Pi Pico读取 4 路传感器数据每路传感器通过串口输出 CSV 数据流波特率 115200。系统需要一边采集 4 路串口数据一边驱动一块 SPI 接口的 LCD 显示状态同时把数据写入 SD 卡。在没有 DMA 的情况下主循环光是从四个串口读数据就忙不过来更别说处理 LCD 和 SD 卡了。7.2 硬件连接UART0接传感器 0TXGP0RXGP1。UART1接传感器 1TXGP4RXGP5。由于 RP2040 只有两个 UART 外设其余两路传感器改用软件模拟 UART 不太现实。我改用了一颗外部 UART 扩展芯片例如 SC16IS752 SPI-to-UART它通过 SPI 与 RP2040 连接并提供两路额外 UART。但这部分不在本文范围内假设我们只做两路 UART 的 DMA 采集已经足够说明问题。LCD使用 SPI0CSGP9SCKGP10MOSIGP11。SD 卡使用 SPI1CSGP13SCKGP14MOSIGP15。7.3 软件设计使用两路 DMA 分别接收 UART0 和 UART1 的数据。每路 DMA 缓冲区大小为 256 字节设为普通模式传输完成后置标志位。主循环按周期执行检查 UART0 DMA 标志位若是则解析缓冲区中的 CSV 数据提取传感器值。检查 UART1 DMA 标志位处理第二路数据。更新 LCD 显示最新的传感器数据。将数据追加写入 SD 卡。伪代码如下while True: if dma0_done: dma0_done False process_sensor0(rx_buffer0, dma0_count) restart_dma(dma0) if dma1_done: dma1_done False process_sensor1(rx_buffer1, dma1_count) restart_dma(dma1) update_lcd() write_sd_card()这个结构非常清晰DMA 保证了数据不丢主循环专注于业务。7.4 实测结果我连续运行了 4 小时两路串口各接收了约 1.6 亿字节数据没有出现丢包或 DMA 卡死的情况。LCD 刷新率为 1HzSD 卡写入间隔为 1秒整体运行稳定。CPU 占用率目测降低了 90% 以上在 133MHz 主频下MicroPython 主循环还有大量空闲时间做其他处理。8. 扩展与优化方向8.1 DMA 与 PIO 结合如果你的数据源不是标准 UART而是自定义协议比如曼彻斯特编码、红外遥控可以用 PIO 实现协议解析再用 DMA 将 PIO 的数据搬到内存。RP2040 的 PIO 本身也可以产生 DMA 请求两者配合能把 CPU 干预降到零。这是很多高级外设设计的方向。8.2 多通道调度如果同时使用 4 路 UARTDMA 通道会占用较多。此时要注意 DMA 通道的优先级和仲裁。RP2040 的 DMA 控制器支持优先级可配置在相同优先级下通道编号小的优先。你可以把实时性要求高的通道编号设小或者配置优先级高低。8.3 用 DMA 实现内存到内存的高速复制除了 UARTDMA 还可以用于内存到内存的批量复制比如快速更新屏幕缓冲区、合并数据块。MicroPython 中可以用dma.config(directionDMA_TO_MEMORY_MEMORY, sourcesrc, destdst, countn)实现。这在处理图像数据时非常有用。8.4 适配其他 MicroPython 设备这套思路不只适用于 RP2040。ESP32、STM32 的 MicroPython 固件也提供了 DMA 或者类似的接口例如esp32.DMA少数版本支持。如果你熟悉底层寄存器可以用machine.mem32直接操作寄存器实现自定义的 DMA 控制。但我不建议在 MicroPython 里做太底层的操作容易把系统搞崩更适合在 C 固件层实现。8.5 结合 FreeRTOS 或多线程MicroPython 的_thread模块支持多线程你可以把 DMA 接收放在一个线程里把数据处理放在另一个线程里通过队列传递数据实现并行处理。不过在 RP2040 上MicroPython 的多线程是抢占式调度涉及共享变量时要注意使用锁。DMA 接收线程会不断更新rx_buffer数据处理线程读取数据时有可能遇到读写冲突这时需要加锁或使用双缓冲区。总体而言DMA 多线程的组合是提高系统吞吐量的利器但也增加了调试难度建议在单线程稳定运行后再逐步引入。9. 个人经验与心得在我使用 RP2040 DMA MicroPython 完成多个项目后有一些体会很想分享。首先DMA 在 MicroPython 中并不是“默认启用”的能力很多人并不知道rp2.DMA的存在。这很可惜因为它能把 UART 这一类外设的数据传输压力完全从 CPU 上卸下来让原本在 Python 层面跑不动的数据流变得游刃有余。其次MicroPython 的 DMA 接口虽然不完美但它的设计思路是对的你只需要配置触发源、方向和缓冲区剩下的交给硬件。我用它做过 921600bps 的 UART 接收做过两路 UART 同时收发做过 DMA 与 LCD 刷新的协同整体稳定性和效率远超轮询方案。最后我想强调一点不要迷信“零 CPU 干预”这个词。DMA 确实把数据搬运零化但数据到达后的处理、解析、存储、展示依然需要 CPU。真正的系统优化是把可以硬件化的部分全部交给硬件DMA、PIO、定时器把 CPU 留给不可硬件化的逻辑。这样即使是 MicroPython也能在很多高实时性场景中站稳脚跟。如果你准备在自己的 RP2040 项目里尝试 DMA我建议你先从最简接收示例跑通然后用一个固定长度的数据帧测试确认无误后再进入环形缓冲和中断组合方案。不要一上来就追求复杂架构那只会让排查问题变得困难。先把这条链路验证稳定后面再怎么扩展都有底气。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询