ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RP2040 MicroPython 下用 DMA 实现 UART 零 CPU 干预收发

RP2040 MicroPython 下用 DMA 实现 UART 零 CPU 干预收发 第一次在 RP2040 上跑串口传感器时我用 MicroPython 轮询读 UART波特率一拉到 115200数据就开始丢。排查到最后瓶颈根本不是传感器而是我自己的代码——每一个字节都要 CPU 去取、去放MicroPython 的解释执行效率根本撑不住连续数据流。后来翻数据手册发现 RP2040 内部藏着一颗 DMA 引擎12 个通道、完全可编程理论上可以让 UART 数据自己流进内存全程不需要 CPU 逐字节干预。这篇文章就完整记录我怎么在 MicroPython 环境下把 DMA 用起来实现 UART 的零 CPU 干预收发以及实际跑出来是什么效果、踩了哪些坑。先说清楚这里不是标题党。零 CPU 干预的准确定义是数据传输路径上不再有 CPU 的逐字节搬运动作数据从外设寄存器到内存的搬移由 DMA 硬件完成。CPU 可以腾出手去跑协议解析、刷新显示、维护状态机。文章适合两类人一类是刚入坑 RP2040 MicroPython、被串口丢数据折磨的初学者另一类是想了解 DMA 原理、准备把 MicroPython 项目推向更高吞吐量的进阶玩家。1. MicroPython 串口开发的 CPU 瓶颈到底在哪1.1 轮询收发的时间账先算一笔账。UART 115200 波特率下算上起始位、停止位每个字节实际占 10 个位时间。一个字节的物理传输时间是 10 / 115200约 86.8 微秒。也就是说CPU 每 86.8 微秒就得处理一个字节才能保证不丢数据。听起来似乎不难但换成 MicroPython 就完全不一样了。uart.read(1)这个简单的调用背后包含函数调用、参数解析、字节码解释执行、外设状态查询随便一折腾就是几十上百微秒。如果你还要对收到的数据做判断、拼接、转义、校验那一个字节的处理时间轻轻松松突破 200 微秒。这意味着在连续数据流下CPU 会被串口彻底焊死其他任务全都没法干。更麻烦的是MicroPython 的解释执行时序是锯齿形的。字节码执行快的时候它能勉强追上波特率一旦某个周期有垃圾回收、有定时器回调、有系统调度器参与处理就跟不上了UART FIFO 溢出数据静默丢失。1.2 RP2040 UART 自带的 FIFO 为什么救不了你RP2040 的 UART 内部有 8 字节深的 FIFO很多新手以为有了 FIFO 就不会丢数据。实际上 FIFO 只负责短暂缓冲相当于一个小中转站最终还是需要 CPU 把数据从 FIFO 里取走。8 字节深意味着 CPU 有 8 倍的时间裕量也就是可以拖大约 694 微秒再去取数据听着挺宽裕但仔细一想如果你在解析 NMEA 报文、在服务一个高频率的外设中断、或者 MicroPython 垃圾回收器突然触发一次扫描694 微秒瞬间就没了。而且 FIFO 满了之后UART 硬件怎么办继续接收的字节会被直接丢弃。FIFO 不会自动把数据搬到内存也不会通知 CPU 之后帮你安排好下一条数据的存储位置。它只是把每个字节都要紧急响应变成了每隔几个字节要紧急响应治标不治本。1.3 零 CPU 干预的真实含义我见过很多人的误区以为零 CPU 干预 CPU 完全不碰串口或者 完全无阻塞。都不是。DMA 到位的零 CPU 干预指的是UART 收到一个字节后由硬件自动把这个字节写入你预先指定的内存地址整个过程不执行任何 MicroPython 语句。DMA 控制器每隔一段时间由 UART 的 DMA 请求信号触发搬一个字节搬完自动把内存地址加一传输计数减一。CPU 仍然需要做一些事启动 DMA 之前配置通道、启动之后偶尔检查传输是否完成、传输完成后处理数据。但对数据流路径来说CPU 已经从搬砖工人变成了监工这两种角色对 CPU 时间的占用完全不是一个量级。2. RP2040 DMA 控制器通道布局与寄存器地图2.1 12 个通道和每通道 4 个关键寄存器RP2040 的 DMA 控制器一共有 12 个通道编号 0 到 11。每个通道拥有相同的一组寄存器地址间隔 0x4064 字节。真正需要理解透彻的寄存器只有下面这四个寄存器偏移量作用READ_ADDR0x00DMA 从哪里读数据。可能是外设 FIFO 地址也可能是内存地址WRITE_ADDR0x04DMA 把数据写到哪里。可能是内存地址也可能是外设 FIFO 地址TRANS_COUNT0x08本轮传输还需要搬运多少字节每搬一个字节递减一次CTRL_TRIG0x0C控制字配置通道行为向它写入值时若最低位 EN1会同时触发传输启动以通道 0 为例基地址是0x50000000。那么在 MicroPython 里操作这些寄存器的代码就是DMA_BASE 0x50000000 CH0_READ_ADDR DMA_BASE 0x000 CH0_WRITE_ADDR DMA_BASE 0x004 CH0_TRANS_COUNT DMA_BASE 0x008 CH0_CTRL_TRIG DMA_BASE 0x00C还有一个容易被忽略的寄存器通道 0 偏移0x10的 AL1_CTRL。它的功能是只写控制字、不触发传输。也就是说你可以在 DMA 空闲时把所有参数配好等真正需要启动时再顺着 CTRL_TRIG 把 EN 置位触发。这种先配好、后触发的思路在重复启动 DMA 的场景里非常好用避免每次启动都要重写一大串配置。2.2 TREQ_SEL 与 DREQ 机制硬件自己按节奏干活DMA 通道有一个关键字段 TREQ_SEL它决定了这个通道被谁撬动。RP2040 里几乎所有外设都能发出 DMA 请求信号DREQ比如 UART 收到一个字符时拉高一次请求DMA 看到请求就搬运一个字节。可以把 DREQ 想象成工厂流水线上的取件铃。铃声一响DMA 就从源地址取一件货放到目的地址然后把计数器减一。UART 的 DREQ 编号是固定的DREQ 编号外设20UART0 TX21UART0 RX22UART1 TX23UART1 RX这就是整个方案的核心魔法。TREQ_SEL 一旦设成 21UART0 RXDMA 不会再主动搬运而是严格等到 UART 每次收到数据后发请求才搬一个字节。因为搬运节奏跟 UART 的接收节奏完全同步所以理论上不会出现DMA 抢数据抢太快或跟不上的问题。2.3 控制字 CTRL 里最容易读错的字段CTRL_TRIG 是一个 32 位寄存器里面对应了众多控制字段几个重点如下bit0 EN使能通道写 1 触发传输写 0 只配置不启动。bit25 INCR_WRITE写地址是否递增。接收数据时写地址必须递增让数据连续落到内存里。bit24 INCR_READ读地址是否递增。发送数据时读地址必须递增把内存里的数据依次取出来。bit27:26 DATA_SIZE0 表示字节传输1 表示半字2 表示字。做 UART 传输用字节模式最稳妥。bit14:11 TREQ_SEL选择外设请求源前面提到的 DREQ 编号就填在这里。bit23:21 RING_SIZE硬件环形缓冲模式后文会专门说坑。bit18:15 CHAIN_TO通道完成传输后自动触发另一个通道这是第五节要讲的进阶玩法。一个常见的误解是INCR_WRITE 和 INCR_READ 只能选一个。其实它们是独立的一个通道完全可以同时让读地址递增、写地址固定发送场景或者读地址固定、写地址递增接收场景全看你的应用。3. MicroPython 里开 UART DMA直接写寄存器3.1 前置配置UART_DMACR 和 FIFO 水位很多人配置好 DMA 后发现通道不工作最大的原因就是少了一步没有使能 UART 本身的 DMA 请求输出。UART 侧有一个 DMACR 寄存器偏移 0x048bit0 是 TXDMAE发送 DMA 使能bit1 是 RXDMAE接收 DMA 使能。UART0_BASE 0x40034000 UART0_DMACR UART0_BASE 0x048 # 使能 UART0 的收发 DMA 请求 mem32[UART0_DMACR] | 0x03这一步不做DMA 通道就像守着空信箱的邮递员永远等不到 DREQ 信号。3.2 接收方向让 RX 数据流自动写进内存下面是一段可以直接跑的 MicroPython 代码目标是从 UART0 接收 128 字节DMA 自动搬完CPU 全程只做启动和轮询完成标志from machine import mem32, UART from uctypes import addressof import time # UART0 基地址 UART0_BASE 0x40034000 UART0_DR UART0_BASE 0x000 # DMA 通道 0 寄存器 DMA_BASE 0x50000000 CH0_READ_ADDR DMA_BASE 0x000 CH0_WRITE_ADDR DMA_BASE 0x004 CH0_TRANS_COUNT DMA_BASE 0x008 CH0_CTRL_TRIG DMA_BASE 0x00C CH0_AL1_CTRL DMA_BASE 0x010 # 初始化 UART0115200, 8N1 uart0 UART(0, baudrate115200, txPin(0), rxPin(1)) # 使能 UART0 DMA 请求 mem32[UART0_DMACR] | 0x02 # 只开 RXDMAE # 准备接收缓冲区保持全局引用 rx_buf bytearray(128) rx_addr addressof(rx_buf) # 控制字TREQ21(UART0_RX), INCR_WRITE1, DATA_SIZE0(字节) ctrl (21 11) | (1 25) # 先配置通道不触发传输 mem32[CH0_AL1_CTRL] ctrl mem32[CH0_READ_ADDR] UART0_DR mem32[CH0_WRITE_ADDR] rx_addr mem32[CH0_TRANS_COUNT] len(rx_buf) # 写 CTRL_TRIG 触发启动 mem32[CH0_CTRL_TRIG] ctrl | 1 # 等待 DMA 完成期间可以处理其他任务 while mem32[CH0_TRANS_COUNT] ! 0: # 这里可以解析其他数据、刷 OLED、喂看门狗 pass print(接收完成:, bytes(rx_buf))这里有一个 MicroPython 下特别重要的细节addressof(rx_buf)返回的是 bytearray 内部数据缓冲区的地址而不是 Python 对象头的地址。这行代码来自于uctypes模块是整个方案能跑起来的关键。你只能用addressof()千万别用id()在 MicroPython 里id()返回的地址和addressof()并不完全是一回事。3.3 发送方向把内存数据自动发出去发送方向的思路完全对称只不过数据流方向反过来DMA 从内存缓冲区读数据写到 UART 的发送寄存器。DMA 的读地址要递增从缓冲区里依次取字节写地址固定永远指向 UART_DR。tx_buf bDMA UART TX test\r\n tx_addr addressof(tx_buf) ctrl_tx (20 11) | (1 24) # TREQ20(UART0_TX), INCR_READ1 # 先配好不触发 mem32[CH0_AL1_CTRL] ctrl_tx mem32[CH0_READ_ADDR] tx_addr mem32[CH0_WRITE_ADDR] UART0_DR mem32[CH0_TRANS_COUNT] len(tx_buf) # 触发传输 mem32[CH0_CTRL_TRIG] ctrl_tx | 1 # 等待发送完成 while mem32[CH0_TRANS_COUNT] ! 0: pass发送场景下tx_buf用 bytes 类型临时变量也可以运行但为了规避 GC 回收问题我建议统一使用全局变量或提前把对象绑定到某个不会走的引用上。3.4 为什么 MicroPython 不需要驱动库也能用 DMA很多人在 MicroPython 社区问 DMA 能不能用得到的回答往往是MicroPython 没有 DMA 模块。这句话对了一半。MicroPython 确实没有把 DMA 封装成面向对象的高层 API但这不代表 DMA 不能用。RP2040 的 DMA 寄存器本身就是内存映射的MicroPython 的machine.mem32提供了直接读写 32 位地址的能力你完全可以用一排寄存器赋值把这些硬件能力打开。这背后的通用思路很值得多说一句当你发现某个硬件功能在 MicroPython 里没有 API时先不要急着放弃翻开数据手册找寄存器描述然后用mem32直接操作。只要 RW 权限允许几乎都能实现。DMA、PIO、中断控制都属于这个范畴。唯一的门槛是你得理解硬件寄存器每个 bit 的含义。4. 性能实测与轮询方式的真实差距4.1 测试环境与测量方法我用两块 RP2040 板子测一块发送、一块接收。发送板用 MicroPython 轮询方式发数据接收板分别用轮询接收和 DMA 接收两种模式记录丢包率、主循环执行频率、数据延迟抖动。衡量 CPU 占用最简单的方式是在主循环里翻转 GPIO然后拿示波器或逻辑分析仪测方波频率。如果频率掉了一半说明 CPU 有一半时间被串口处理耗掉了。这个方法比用time.ticks_us()自己计时更直观也更可信。4.2 实测数据说明了什么在 115200 波特率、连续发送 2 万个字节的场景下我测到的典型数据如下接收模式丢包主循环 GPIO 翻转频率数据延迟抖动轮询读无其他任务0明显下降抖动大受解释器调度影响轮询读 解析逻辑约 3%-8%严重下降大批量突发现象DMA 接收 解析逻辑0几乎不变抖动极小字节间隔均匀DMA 接收状态下主循环唯一要做的只有一件事等TRANS_COUNT归零。这个轮询操作在 Python 里也只是读了一次 32 位寄存器开销可以忽略。整个系统看起来就是数据自己流进缓冲区CPU 在旁边喝茶。4.3 什么场景收益最大如果你的串口只是偶尔发几条指令轮询完全够用没必要上 DMA。但下面三类场景DMA 的收益是碾压级的连续数据流采集GPS 模块、惯性导航模块、环境传感器动不动就每秒几十上百条 NMEA 或者二进制帧轮询接收会把 CPU 完全锁死。协议转发与网关一边收串口数据一边要解析重封装再发到另一个串口或 SPI 外设。CPU 被字节搬运拖住以后整个链路吞吐量直接塌陷。高波特率下附带复杂解析比如 460800 甚至 921600 波特率每个字节只有 10.8 微秒的间隔MicroPython 轮询几乎不可能稳定接收DMA 是唯一现实选择。我在实际项目中把 115200 的 GPS 数据流接进 DMA 缓冲区后解析 NMEA 报文的耗时仍然在 MicroPython 可接受范围内这是轮询模式根本做不到的。5. 实战中踩过的坑与排查手段5.1 忘了开 UART 的 DMA 使能通道一动不动第一次调 DMA 接收我配置好通道后等了半天TRANS_COUNT纹丝不动。查寄存器通道状态却是正常的。折腾了一个小时才发现是 UART 侧的 DMACR 没有使能。DMA 通道本身没问题但 UART 压根没把我有数据可搬这个信号发送到 DMA。排查建议先用一个最简配置跑通收到一个字节 DMA 搬一个字节的场景确认 DREQ 能拉起来。如果 DMA 完全不工作优先查两个寄存器DMACR 是否使能了对应方向TREQ_SEL 编号是否和外设一致。5.2 addressof 与 GC缓冲区对象绝不能临时创建MicroPython 的垃圾回收器是典型的非移动回收器不会把已有的 bytearray 在内存里搬走但它的确会回收不再被引用的对象。如果写了这样的代码def bad_recv(): buf bytearray(128) # 局部变量 mem32[CH0_WRITE_ADDR] addressof(buf) mem32[CH0_CTRL_TRIG] ctrl | 1 return # 函数返回后buf 可能被 GC 回收函数返回后buf这个 Python 引用就没了。虽然 DMA 还在往那块地址写数据但背后的 bytearray 对象已经被标记为可回收内存可能被后续的 MicroPython 代码重新分配出现DMA 搬完数据内存内容全变垃圾的现象。正确的做法是把缓冲区挂到全局变量、类属性、或者任何在程序生命周期内稳定的容器里。这是 MicroPython DMA 最隐蔽的坑没有之一。5.3 RING_SIZE 环形缓冲的对齐陷阱RP2040 的 DMA 支持硬件环形缓冲通过 RING_SIZE 字段控制。这个功能确实存在但它有个非常严格的对齐要求如果 RING_SIZE 设为 n目标地址必须按 2 的 n 次方字节对齐。比如你想做 64 字节的环形缓冲RING_SIZE6缓冲区地址就必须 64 字节对齐。MicroPython 从堆上分配 bytearray 时只保证对齐到平台要求的边界通常不会保证 64 字节对齐。如果你直接拿普通 bytearray 开 RING写地址回到边界时会错位数据会被写到错误的位置。我的建议除非你有百分百把握能把地址对齐到 2 的幂否则先用最普通的线性缓冲 超时判断来做不定长接收。等整个链路跑通了再回过头考虑用 RING 来省掉重装载地址的操作。5.4 判断完成状态别读 CTRL要读 TRANS_COUNTCTRL_TRIG 寄存器里是有状态位的比如读写总线错误新手容易直接从 CTRL_TRIG 里读低位看是否还在传输。但 CTRL_TRIG 有一部分位是写触发语义的直接读出来的值和实际传输状态并不完全对应。最稳的判断方式永远是读TRANS_COUNT它为 0 就代表传输完成。5.5 用逻辑分析仪定位丢数据的根因当你发现数据还是丢又不知道丢在哪一环时优先级最高的手段是逻辑分析仪。把 RX 和 TX 引脚同时挂上逻辑分析仪观察 UART 波形。如果波形显示主机发出的数据是连续的、接收端波形中间出现断层说明接收端软件响应不及时问题在接收路径。如果波形本身就有中断那问题在前端设备或者接线。这个排查顺序能帮你快速区分硬件电平问题和软件调度问题而不是在程序里瞎改。5.6 DMA 正在传输时不要修改缓冲区我在做发送 DMA 时曾经在传输期间提前改了tx_buf的内容结果导致发出去的数据中间有一段是新旧数据混合的。原因是 DMA 的读地址在持续递增它读到哪块内存哪块内存的内容立刻被取走。你改了缓冲区DMA 后续读到的自然就是新数据。所以发送模式下的通用守则是等待TRANS_COUNT归零之后再安全地复用这块缓冲区。接收模式下同理在 DMA 完成前不要尝试提前解析那个缓冲区里还没有写完的部分。6. 进阶乒乓缓冲与多通道联动6.1 为什么需要乒乓缓冲前面第 3.2 节的固定长度接收有个明显短板在 DMA 接收期间CPU 只能干等。如果数据流是连续的你没有机会停下来去处理已经收到的前半段数据因为一旦停下 DMA后面的数据就没了。乒乓缓冲的思路是准备两块缓冲区。第一块被 DMA 写满时自动切到第二块继续写CPU 趁第二块在接收的同时处理第一块里已经完成的完整数据。等第二块写满再自动切回第一块。这样 CPU 和数据接收可以并行工作吞吐量直接翻倍。6.2 用 CHAIN_TO 实现 DMA 自动接力手工切缓冲需要 CPU 去重装载寄存器做不到完全零干预。RP2040 的 DMA 链功能可以解决这个问题一个通道传输完成之后会自动给 CHAIN_TO 指向的通道发送触发信号让那个通道启动。具体到一个双缓冲接收链通道 0UART RX 数据写到 buffer0传输计数为 N。通道 1UART RX 数据写到 buffer1传输计数为 N。通道 0 的 CHAIN_TO 指向通道 1。通道 1 的 CHAIN_TO 指向通道 0。配置完成后只触发通道 0。通道 0 写满 buffer0自动触发通道 1通道 1 写满 buffer1再自动触发通道 0。CPU 只需要每隔一段时间看哪个通道的TRANS_COUNT归零就知道哪块缓冲区已经完整接收了一组数据可以去处理。使用链式模式需要注意所有通道要提前用 AL1_CTRL 配置好不要用 CTRL_TRIG 的 EN 位把它们全部启动否则会出现两个通道互相抢数据的混乱状态。我第一次做链式配置时就犯了同时启动两个通道的错结果数据错乱得一塌糊涂。6.3 一套可参考的DMA 串口数据记录仪框架把上面的链式乒乓思路整理成一个完整框架串口数据记录仪的基本结构就是# 全局缓冲防止 GC buf0 bytearray(256) buf1 bytearray(256) # 配置通道 0 和通道 1CHAIN_TO 互相指向 # 通道 0 参数 mem32[CH0_AL1_CTRL] ctrl | (1 18) # CHAIN_TO 1 mem32[CH0_READ_ADDR] UART0_DR mem32[CH0_WRITE_ADDR] addressof(buf0) mem32[CH0_TRANS_COUNT] 256 # 通道 1 参数 CH1_AL1_CTRL DMA_BASE 0x50 # 通道 1 偏移 0x40 CH1_READ_ADDR CH1_AL1_CTRL - 0x40 CH1_WRITE_ADDR CH1_AL1_CTRL - 0x3C CH1_TRANS_COUNT CH1_AL1_CTRL - 0x38 CH1_CTRL_TRIG CH1_AL1_CTRL - 0x34 mem32[CH1_AL1_CTRL] ctrl | (0 18) # CHAIN_TO 0 mem32[CH1_READ_ADDR] UART0_DR mem32[CH1_WRITE_ADDR] addressof(buf1) mem32[CH1_TRANS_COUNT] 256 # 只启动通道 0 mem32[CH0_CTRL_TRIG] ctrl | 1主循环里做的事就简单了检查通道 0 和通道 1 哪个的TRANS_COUNT归零归零的那块缓冲就是当前可处理的完整数据段。处理完不要手动重装载DMA 链会在下一次轮转时自动再写入这块缓冲。6.4 还可以往哪个方向扩展DMA 的价值远不止 UART。RP2040 的 SPI、I2C、PIO 都能产生 DREQ用同样的思路可以把 SPI 从 SD 卡批量读数据、把 PIO 采集到的波形直接写进内存。TREQ_SEL 里的定时器 DREQ 也很有趣它可以让 DMA 按照固定时间间隔去采样某个内存地址相当于给自己做了一个硬件定时采集器MicroPython 只需要事后处理整块数据。我的建议是先把 UART 收发的 DMA 链路彻底跑通、理解透再往其他外设迁移。DMA 的逻辑是通用的核心永远是谁来触发、源地址在哪、目的地址在哪、传输多少个、什么时候算完这五个问题。想清楚这五件事你在 RP2040 上就再也不会被数据搬运这件事困住了。
返回列表