MicroPython下用DMA实现RP2040 UART零CPU干预发送
发布时间:2026/9/7 14:43:22 作者:尧图编辑部 阅读量:1,286

最近调树莓派Pico的时候遇到一个很实际的麻烦用MicroPython通过串口往外部设备灌数据数据量一上来uart.write就卡得让人发慌CPU被占得死死的。一开始觉得MicroPython这层解释执行罩着DMA这种底层功能肯定没戏后来翻了一圈RP2040 datasheet发现完全可以在MicroPython里用machine.mem32直接操作DMA寄存器配合UART的DREQ请求信号把数据搬运从CPU手里交给DMACPU只负责启动和收尾传输过程基本不用管。这篇文章就把完整思路、寄存器配置、MicroPython代码、实测效果和踩过的坑全部写出来。适合已经会用MicroPython写UART程序、但对性能有一点要求的开发者。看完之后你可以在MicroPython下实现真正由DMA驱动的串口发送也能知道什么时候不该硬上DMA以及什么情况要老老实实回到C SDK。1. 思路拆解与方案选型1.1 为什么要想办法“零 CPU 干预”串口发送这件事看起来只是往寄存器里写数据但实际上每个字节都要经过UART外设按波特率一位一位移出去。以115200波特率为例一个字节含起始位和停止位共10位发送一个字节约耗时87微秒。数据量少还好一旦到了几KB甚至几十KB时间就非常可观。在MicroPython里这个问题会被进一步放大。解释执行Python本身就有开销逐字节搬数据时每写一个字节都要经过Python字节码解释、对象引用检查、硬件寄存器写入等步骤听起来好像没多少但累计起来比C语言慢两个数量级。我之前试过用MicroPython写一个逐字节发送函数发送4KB数据光Python层面的循环就占了很大一部分时间串口本身还没到瓶颈CPU先被拖死了。DMA要解决的核心问题就是CPU只负责一次性把源地址、目的地址、搬运长度告诉DMA控制器然后DMA自己按节奏把数据搬完搬运期间CPU可以掉头去干别的事。这就是业内常说的“零CPU干预数据传输”更准确一点是从逐字节干预变成了启动和收尾两次干预。1.2 DMA 的本质一个独立的“搬运工”类比一下CPU就像一个厨师UART是传菜口DMA是专门雇来的传菜员。没有传菜员的时候厨师每炒好一盘菜就得亲自端到传菜口炒一个端一个后厨一大半时间耗在跑路上。有了DMA厨师只要告诉传菜员“从备料区把这批菜端到1号传菜口共50份”传菜员就一趟一趟自己端厨师可以继续炒下一批菜。对应到RP2040上DMA控制器做的事情很简单从一个地址连续读取数据写到另一个地址重复指定次数每完成一次搬运就把计数器减一。关键点在于源地址和目的地址可以配置成“递增”或“固定”源和目的可以是内存也可以是外设寄存器UART的DR寄存器就是一个典型的目的地址。在MicroPython环境中RP2040的DMA控制器没有现成的高层API官方固件没有暴露类似dma.transfer()这样的方法。但这不等于没法用RP2040的寄存器地址在MicroPython里可以通过machine.mem32直接读写这就是我们操作DMA的入口。1.3 方案选型为什么用寄存器操作而不是换 C SDK有人会问既然要折腾DMA为什么不干脆换C SDKC SDK里确实有dma_channel_configure这种好用API配置起来非常直观。但现实场景里很多时候项目主体已经用MicroPython写好了传感器读取、业务逻辑、网络交互全都在Python层为了一个串口发送性能问题整体迁移到C SDK工程量太大不值当。另一个思路是用PIO加DMAPIO状态机非常适合做自定义时序协议但配置复杂度比普通UART DMA高不少还要写PIO汇编未必适合每个人。所以我最终选了一条有点“野”但完全可行的路线用MicroPython的mem32直接读写RP2040 DMA控制器寄存器绕过官方API的限制手动配置DMA通道。这个方法的好处是不改固件、不换工具链在现有MicroPython工程里加一个函数就行。风险也有主要是寄存器操作不经过任何驱动层保护配置错了可能卡死或者数据错乱但后面我会把要避开的坑都写清楚。2. DMA 与 UART 的硬件配合原理2.1 先搞懂 DMA 控制器的四个关键寄存器RP2040有12个DMA通道编号0到11每个通道的寄存器布局完全一样。通道0的基地址在0x50000000后面每个通道偏移0x40。真正使用时核心寄存器只有四个读地址寄存器、写地址寄存器、传输计数寄存器、控制触发寄存器。我用表格把通道0的地址列出来方便对照寄存器通道0地址作用READ_ADDR0x50000000DMA搬运数据的源地址WRITE_ADDR0x50000004DMA搬运数据的目的地址TRANS_COUNT0x50000008剩余待搬运的数据项数量CTRL_TRIG0x5000000C配置DMA模式写入后启动传输READ_ADDR和WRITE_ADDR容易理解一个是“从哪里搬”一个是“搬到哪”。TRANS_COUNT每次搬运完一个数据项就自动减一减到0表示传输完成。CTRL_TRIG是重点它里面的位域同时承担了“配置”和“启动”两个职责只要往这个寄存器写入一次值DMA就会立刻开始干活。2.2 DREQUART 怎么告诉 DMA“现在可以搬了”这里有个概念必须讲清楚就是DREQ。RP2040的外设UART、SPI、PIO等有一组DMA请求信号每个信号有一个编号。DMA在配置时通过CTRL_TRIG的TREQ_SEL字段选择监听哪个信号。只有当外设发出请求时DMA才会搬一个数据项。UART0的DREQ编号是固定的UART0_TX 20UART0_RX 21。TX方向上当UART的发送FIFO还有空位时DREQ就会被拉起来DMA看到请求后从源地址读一个字节写入UART_DR寄存器字节进入FIFO后由UART硬件按波特率移出。RX方向反过来FIFO里收到数据时DREQ触发DMA从UART_DR寄存器读走数据写入内存。这一步非常关键它保证了DMA不会盲目乱写。UART FIFO满了之后DREQ不再触发DMA就停下来等待不会发生数据覆盖。这比DMA自己闷头连续搬运要安全得多。2.3 增量模式、传输宽度与 DREQ 的搭配配置DMA时还要决定源地址和目的地址是否递增。发送方向源地址指向内存中的bytearray每个字节都要读到所以INCR_READ要置1目的地址是UART_DR寄存器不需要递增所以INCR_WRITE保持0。接收方向正好反过来源地址固定为UART_DR目的地址指向内存缓冲区所以要INCR_WRITE置1INCR_READ置0。传输宽度通过DATA_SIZE字段设置0表示按字节传输1表示半字16位2表示整字32位。串口数据天然是字节流所以DATA_SIZE固定为0。需要注意的是如果按半字或整字传输源和目的地址必须按相应宽度对齐否则会触发总线错误。字节传输对地址对齐要求宽松得多所以串口场景下几乎不会遇到对齐问题。另外TREQ_SEL还可以设置成63对应DMA的连续请求模式也就是不依赖外设请求信号DMA会尽最大努力连续搬运。这个模式在内存拷贝时很好用但千万别用在UART TX方向因为UART发送FIFO只有32字节深DMA高速灌数据会把FIFO塞爆后面写入的数据直接丢失。3. 实操MicroPython 下实现 UART DMA 发送3.1 环境准备与引脚规划我用的是树莓派Pico开发板固件是官方最新的MicroPython版本。要用的模块只有machine和uctypes不需要额外安装任何库。硬件连接非常简单Pico的GPIO0复用为UART0_TXGPIO1复用为UART0_RX波特率我测试时用921600这个速度下DMA的优势最明显。如果是115200效果也成立只是时间上没那么直观。代码里我会用machine.UART完成UART外设的初始化这一步很关键。启动DMA之前UART的波特率、数据位、停止位、FIFO开关都必须先配置好DMA只是搬运字节到UART_DR寄存器不会替你做串口初始化。3.2 核心代码直接操作 DMA 寄存器下面这段代码是我整理后可以直接用的版本发送一个预先准备好的bytearrayDMA负责搬运CPU在搬运期间可以去执行其他Python代码。import uctypes from machine import mem32, Pin, UART # 初始化 UART0GPIO0 TXGPIO1 RX921600-8-N-1 uart UART(0, baudrate921600, txPin(0), rxPin(1), bits8, parityNone, stop1) # RP2040 DMA 寄存器基地址 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 0x0C # UART0 数据寄存器地址写字节到此即进入发送FIFO UART0_DR 0x40034000 # DREQ 编号 DREQ_UART0_TX 20 # 预分配测试数据尽量保持全局引用 tx_buf bytearray(bHello RP2040 DMA! * 300) # 约6000字节 def dma_uart_write(data): # 等待通道0空闲避免上一次传输还没结束就重新配置 while mem32[CH0_CTRL_TRIG] (1 24): pass mem32[CH0_READ_ADDR] uctypes.addressof(data) mem32[CH0_WRITE_ADDR] UART0_DR mem32[CH0_TRANS_COUNT] len(data) # TREQ_SEL20UART0_TX DREQ # INCR_READ1源地址递增 # DATA_SIZE0按字节传输 # EN1启动DMA ctrl (DREQ_UART0_TX 15) | (1 22) | (1 0) mem32[CH0_CTRL_TRIG] ctrl注意函数里先轮询CH0_CTRL_TRIG的bit24也就是BUSY位。如果上一次DMA还没结束寄存器不能直接覆盖配置必须先等硬件把当前传输跑完。这是最容易被忽略的细节后面问题排查部分会细说。3.3 为什么这样配置 CTRL_TRIG关于CTRL_TRIG的数值我再拆开解释一遍。按RP2040数据手册第4.7.2.3节的位定义CTRL_TRIG的TREQ_SEL占bit15到bit20INCR_WRITE是bit21INCR_READ是bit22DATA_SIZE是bit23EN是bit0BUSY是bit24。发送方向要TREQ_SEL20所以我写成20 15。源地址指向内存缓冲区需要自动递增所以1 22打开INCR_READ。目的地址是UART_DR寄存器固定不变所以INCR_WRITE保持0。按字节传输DATA_SIZE为0不用显式设置。最后1 0是EN写入后DMA启动。这里有个很容易犯的错就是把INCR_WRITE也打开。一旦打开DMA会把数据写到UART_DR后面的寄存器地址上那些是UART的状态寄存器或者未定义寄存器结果是数据完全发不出去还可能把UART外设配置改乱。发送方向INCR_WRITE必须保持0接收方向才需要打开。TRANS_COUNT设置成len(data)意思是总共搬运这么多项因为DATA_SIZE是0一项就是一字节。DMA每搬完一个字节TRANS_COUNT自动减一减到0传输结束。3.4 怎么确认数据真正发完了DMA把数据全部写进UART FIFO并不代表物理发送完成FIFO里的字节还要按921600的波特率逐个移出。如果程序在DMA刚结束时就切换引脚状态或者释放缓冲区最后几个字节大概率被截断。判断“发完”有两个层次第一层是等待DMA传输结束也就是BUSY位清零。第二层是等待UART的发送FIFO和移位寄存器彻底清空。第二个层次可以通过读取UART_FR寄存器地址0x40034018的bit3也就是BUSY位当它为0时表示UART发送完全空闲。我习惯在DMA结束后加一个小等待循环确保数据完整发出去。UART_FR 0x40034018 while mem32[UART_FR] (1 3): pass这段代码不依赖任何MicroPython高层方法只读寄存器准确而且省事。3.5 实测效果CPU 从逐字节搬运中解放出来我用6000字节的buffer做了一次对比实验。不用DMA时MicroPython逐字节写入UART发送过程CPU完全卡在循环里期间任何其他Python代码都跑不动。用DMA之后dma_uart_write函数执行完寄存器配置立即返回DMA在后台按921600波特率搬运6000字节物理发送耗时约58毫秒而CPU在DMA搬运期间顺利执行了一个从0加到10000的循环完全不受影响。这就是“零CPU干预”的真实含义不是让发送时间消失而是让发送过程不占用CPU。对需要同时处理多个任务的系统来说这个区别非常大。4. 扩展DMA 接收与进阶玩法4.1 接收方向DMA 从 UART RX FIFO 搬到内存发送能用DMA接收自然也能。接收方向的思路正好过来源地址固定为UART0_DR目的地址指向内存缓冲区并递增DREQ使用UART0_RX编号21。配置代码类似DMA_BASE 0x50000000 CH1_READ_ADDR DMA_BASE 0x40 CH1_WRITE_ADDR DMA_BASE 0x44 CH1_TRANS_COUNT DMA_BASE 0x48 CH1_CTRL_TRIG DMA_BASE 0x4C # UART0 接收 DREQ DREQ_UART0_RX 21 rx_buf bytearray(64) def dma_uart_read_start(): while mem32[CH1_CTRL_TRIG] (1 24): pass mem32[CH1_READ_ADDR] UART0_DR mem32[CH1_WRITE_ADDR] uctypes.addressof(rx_buf) mem32[CH1_TRANS_COUNT] len(rx_buf) ctrl (DREQ_UART0_RX 15) | (1 21) | (1 0) mem32[CH1_CTRL_TRIG] ctrl接收方向比发送复杂因为串口数据不是你想收多少就有多少的。如果设定了接收64字节但对方只发了10字节DMA的TRANS_COUNT停在54BUSY一直置位你无法通过BUSY判断是否该处理数据。实用的办法是周期性读取CH1_TRANS_COUNT用len(rx_buf) - mem32[CH1_TRANS_COUNT]算出当前实际收到的字节数等它超过某个阈值再处理。还有一种方案是配合UART空闲中断但MicroPython下没有直接暴露这个中断实现起来比较绕。我的建议是如果要做不定长接收老老实实用MicroPython自带的uart.irq加 bytearray缓冲区代码简单得多。DMA接收适合固定帧长的协议场景比如每个数据包固定64字节DMA收满一整帧后一次性处理效率非常高。4.2 MicroPython 下 DMA 的边界什么时候切回 C SDK这套寄存器hack方案虽然能用但边界也很明显。最麻烦的是没有DMA完成中断回调你只能靠轮询BUSY或者TRANS_COUNT这在多任务实时性要求高的场景里不够优雅。其次是MicroPython解释层本身的性能限制即使DMA解放了串口搬运Python代码的解析速度也会成为新的瓶颈。如果你的项目最终要产品化或者吞吐量需求在10Mbps以上我建议直接切到C SDK。C SDK里DMA的中断、链式传输、多通道配合都有完善APIDMA完成中断可以把结果直接推送到队列配合FreeRTOS信号量通知任务处理这才是高性能系统的正确打开方式。MicroPython这套更适合原型验证、教学演示和对吞吐量要求不高的场景。4.3 进阶PIO DMA 的组合拳RP2040还有一个隐藏大招就是PIO状态机和DMA配合使用。PIO状态机可以自定义串行时序WS2812灯带、高速传感器、红外遥控这些协议都能用PIO实现而DMA可以把内存里的数据持续灌入PIO的TX FIFO不需要CPU参与。MicroPython的rp2模块支持加载PIO程序但DMA向PIO FIFO灌数这步官方同样没有封装最终还是得走mem32操作DMA寄存器。思路跟UART发送一样只要把目的地址改成PIO0的TXF寄存器DREQ改成对应的PIO TX请求编号即可。这部分比UART复杂但原理上完全是同一套东西。5. 常见问题与排查技巧实录5.1 问题速查表症状可能原因解决方法数据完全发不出去CTRL_TRIG配置错误INCR_WRITE误置1检查TREQ_SEL、INCR_READ、INCR_WRITE位数据发一半被卡死上一次DMA未结束就重新配置通道配置前轮询BUSY位清零最后几个字节丢失DMA结束但UART FIFO还没移出完成用UART_FR的BUSY位等待发送空闲收到的数据全是乱码波特率或数据位配置不对检查machine.UART的初始化参数高频使用后发现数据被覆盖bytearray被垃圾回收释放或地址复用保持全局引用或DMA完成后再返回函数DMA操作后系统卡死地址未按宽度对齐或访问了非法地址字节传输通常没问题检查READ/WRITE_ADDR是否属于有效内存范围与音频、PIO功能冲突DMA通道被其他MicroPython功能占用换用高编号通道如10、115.2 三个我踩过的重要坑第一个坑是GC回收。MicroPython的垃圾回收不会移动已分配对象但会回收不再引用的对象。如果dma_uart_write函数的参数data只是局部变量函数返回后没有外部引用GC有可能在DMA还没搬完时就把这块内存回收导致DMA读到已经被复用的内存数据发出去的就是错乱内容。对策很简单要么调用方保持data的引用到DMA完成要么函数内部等待DMA结束后再返回。第二个坑是DMA通道冲突。RP2040有12个DMA通道MicroPython固件内部某些功能比如音频模块、PIO的相关支持可能已经在使用其中一两个通道。我一开始用通道0跑得很顺后来测试音频功能时发现两边互相干扰数据错乱。建议固定使用较高编号的通道例如10或11冲突概率小很多。不过MicroPython没有提供“申请DMA通道”的接口最终还是要靠运行时测试确认。第三个坑是FIFO触发等级。UART配置FIFO启用后接收FIFO有一个触发阈值默认是读取到一定字节数才触发DMA请求。如果阈值设置不合理接收短数据时DMA可能迟迟不搬数据。发送方向上这个影响不大但接收方向最好将RX FIFO触发级别设置为1字节这样每个到达的字节都会立刻触发DREQ延迟最低。MicroPython初始化UART时通常不会暴露这个参数必要时可以手动修改UART_IFLS寄存器。5.3 什么时候不该用这套方案写了这么多我必须泼一盆冷水DMA在MicroPython下不是万能的很多场景里硬上DMA反而是负优化。如果你只是调试输出一些状态信息每次发几十个字节直接用uart.write就够了DMA的配置开销反而拖慢节奏。如果你的数据长度不确定、需要灵活解析用中断加缓冲是更成熟的做法DMA接收需要预先知道长度处理不定长数据非常别扭。最典型的反面例子是我早期强行用DMA接收不定长串口数据结果为了判断“什么时候接收结束”设计了各种超时轮询逻辑代码复杂度远远超过直接用中断最后只好推翻重写。工具是拿来解决问题的不是拿来做性能表演的。适合用DMA的场景是你已经明确知道要发送大块连续数据且希望CPU在这个过程中腾出手来干别的事这套方案的收益才最大。最后分享一点点实际体会折腾完这一圈我自己的感受是RP2040的寄存器设计非常直白DMA控制器更是所有外设中最容易理解的一个只要抓住READ_ADDR、WRITE_ADDR、TRANS_COUNT、CTRL_TRIG这四个寄存器配合UART的DREQ信号基本就能玩出花来。MicroPython虽然没给DMA封装API但mem32这个后门让底层操作成为了可能。如果你也想在自己的项目里试试我建议第一步不要直接写业务代码先拿一块Pico、开一个串口调试助手把我上面的发送示例跑通然后用逻辑分析仪或者另一个串口接收端看着数据出来确认DMA真的在干活再逐步加入业务逻辑。调试的时候把波特率降到115200用短数据观察等掌握了规律再上921600会省掉很多不必要的困惑。另外还有一个实用小技巧调试DMA问题时定义一个简单的read_dma_regs()函数把当前通道的四个寄存器值都打印出来出问题时看READ_ADDR和WRITE_ADDR是否发生了预期中的变化、TRANS_COUNT有没有在递减基本就能定位90%的问题。我靠这个方法排查过不少寄存器配置错误比蒙头改代码高效得多。