资讯详情

资讯详情

MicroPython下RP2040 DMA驱动UART串口:寄存器原理与实战

最近在树莓派 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 就会持续拉高 DREQDMA 看到这个请求后完成一次读 DR 寄存器并写 RAM 的操作。这样传输节奏完全由外设的数据流决定不会多搬也不会漏搬。在 MicroPython 下使用 DMA本质上有两条路线一是直接通过machine.mem32读写 DMA 寄存器二是使用较新固件v1.20 之后提供的rp2.DMA封装类。无论走哪条路原理都是控制同一组寄存器所以接下来先把寄存器细节讲透后面看代码才能心里有数。2. 核心细节解析与实操要点2.1 DMA 通道寄存器地图DMAC 控制器基地址是0x50000000。每个通道占0x40字节地址空间CH0 从0x50000000开始CH1 从0x50000040开始依次类推。我实际用到的寄存器主要是这几个寄存器名相对 CHx 偏移作用READ_ADDR0x00源地址DMA 从这里读数据WRITE_ADDR0x04目标地址DMA 往这里写数据TRANS_COUNT0x08待传输的数据单元个数递减到 0 表示完成CTRL_TRIG0x0C控制位与触发写写入即启动传输CTRL0x10同 CTRL_TRIG 的控制位但不触发启动CTRL_TRIG 是整个 DMA 配置的核心我挑几个最关键的位说明ENbit 0通道使能。DATA_SIZEbit 2 到 bit 30 表示按字节搬1 表示半字2 字节2 表示字4 字节。UART 的一次数据传输通常是 1 字节所以固定用 0。INCR_WRITEbit 4写地址是否自动递增。往 RAM 缓冲区写时置 1往外设寄存器写时清 0。INCR_READbit 5读地址是否自动递增。从 RAM 缓冲区读时置 1从外设寄存器读时清 0。CHAIN_TObit 15 到 bit 20传输结束后续接的通道可以实现链式传输。TREQ_SELbit 21 到 bit 26选择哪个外设的 DREQ 作为触发源。BUSYbit 29只读为 1 表示通道正在传输中。我在实际调试时最喜欢看 TRANS_COUNT因为它会从初始值递减能直观看到 DMA 有没有在动。如果等了半天这个数没变那问题多半出在触发配置上而不是搬运本身。2.2 DREQ 触发源怎么选DREQ 编号不是乱来的RP2040 数据手册里面有一张完整的表。和 UART 相关的部分如下DREQ 号含义0UART0 RX1UART0 TX2UART1 RX3UART1 TX比如我用 UART0 做接收TREQ_SEL 就填 0。使用 MicroPython 的rp2.DMA封装时不同固件版本可能允许直接填这个数字也可能提供字符串形式的枚举运行时可以用help(DMA.config)查看现场固件的真实签名。这里有个坑UART 的 DREQ 信号和 FIFO 深度是联动的。UART0 RX 的 FIFO 深度是 16 字节只有当系统时钟使能了 FIFO 功能后DMA 请求才会按 FIFO 水位自动发出。如果初始化 UART 时没有设置FENFIFO 使能位有些情况下 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( trigger0, # DREQ 编号0 对应 UART0_RX sourceUART_DR, # 固定读 UART0 数据寄存器 destrx_buf, # 写入字节数组 countlen(rx_buf), read_incrementFalse, write_incrementTrue, data_size0, # 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_SEL0 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。如果用 921600125000000 / (16 × 921600) 8.477IBRD 8FBRD 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( trigger0, # UART0 RX sourceUART0_DR, destrx_buf, countlen(rx_buf), read_incrementFalse, write_incrementTrue, data_size0, ) 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(bHello RP2040 DMA!\n) tx_dma DMA() tx_dma.config( trigger1, # UART0 TX sourcetx_buf, destUART0_DR, countlen(tx_buf), read_incrementTrue, write_incrementFalse, data_size0, ) tx_dma.start()发送完成后如果还想继续用这个通道记得count重新赋值。RP2040 DMA 通道在 count 变为 0 后不会自动重置这是和某些 MCU 的循环 DMA 模式不一样的地方。如果数据长度变来变去每次发送前都要设置新的 count。这里还要注意UART 的发送是慢速设备而 DMA 是按 DREQ 节奏跑的所以不会出现DMA 一下子把 FIFO 塞爆的问题。FIFO 有空位UART 才拉高 DREQDMA 才搬一个数据过去天然形成背压机制。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 # 寄存器方式初始化 UART0115200 8N1 mem32[0x40034024] 67 mem32[0x40034028] 52 mem32[0x4003402C] 0x60 mem32[0x40034030] 0x301 rx_dma DMA() rx_dma.config(trigger0, sourceUART0_DR, destrx_buf, countlen(rx_buf), read_incrementFalse, write_incrementTrue, data_size0) rx_dma.start() setup()跑这个程序然后用串口调试助手给 Pico 发 128 字节数据你会看到打印输出。这个程序本身没有太多实用性但它验证了整条 DMA 通路是否正常。我建议所有刚接触 DMA 的人都从这样一个最小回显开始不要一上来就搞环形缓冲加中断链那样出了问题反而不知道是哪一环坏了。4. 常见问题与排查技巧实录4.1 五个最容易踩的坑我把自己实操中碰到的问题按频率排了个序基本上能覆盖新手阶段的大多数翻车现场。问题一DMA 完全没有触发TRANS_COUNT 一直不递减。八成是 DREQ 编号填错或者 UART 的 FIFO 没有使能。检查 UART0 RX 对应的是 TREQ_SEL 0UART0 TX 对应的是 1不要和 UART1 混淆。再看 LCR_H 的 FEN 位是不是 1FIFO 关闭时有些 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 编号、缓冲区管理这些知识是相通的。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →