ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MicroPython下的RP2040 DMA实战:UART零CPU占用发送与调试指南

MicroPython下的RP2040 DMA实战:UART零CPU占用发送与调试指南 前阵子做一个小项目需要把 RP2040 采集到的一批数据以 921600 波特率从串口吐出去。一开始没多想直接machine.UART.write()结果发现一个很头疼的问题发送期间 CPU 基本被锁死第二轮采样直接被拖垮。后来我把目光放到 RP2040 自带的 DMA 模块上用 MicroPython 直接操作寄存器把 UART 发送整个交给了 DMACPU 只负责发起传输和检查完成标志效果立竿见影。这篇文章就是想把这条已经跑通的路线完整讲清楚。内容既包含为什么 MicroPython 下的 UART 会拖住 CPU也包含 RP2040 DMA 控制器的寄存器级工作原理更重要的是我会给出可以直接抄走的 MicroPython 代码以及我在调试中踩过的几个实打实的坑。适合正在用树莓派 Pico、Pico W 做数据采集、串口交互、上位机通信的玩家也适合想从“调库”走向“碰寄存器”的 MicroPython 开发者。1. 先搞明白一件事MicroPython 的 UART 传输为什么会占用 CPU很多人觉得串口发送是“硬件自动完成的”不应该占用 CPU。这在 C 语言配合 DMA 的场景里确实如此但 MicroPython 默认的UART.write()并不是这么工作的。它的底层实现本质上是往 UART 的发送 FIFO 里一个字节一个字节地塞数据塞完一个还要等 FIFO 腾出空位再塞下一个。整个过程中 CPU 一直处于“等待-写入-再等待-再写入”的循环里Python 字节码的解释执行又比 C 慢得多所以高波特率下你会发现主循环的实时性大打折扣。我做个简单的对比实验向串口发送 1024 字节数据波特率 115200。理论上传输耗时大约是 1024 * 10 / 115200 约 89 毫秒按 8N1 格式10 bit 一帧。用UART.write()发送时write()这个函数会阻塞大约 88~90 毫秒才返回也就是说这段时间内主循环里什么都干不了。1.1 阻塞式发送的问题本质问题本质不在于 UART 硬件本身慢而在于“数据搬运”这件事是由 CPU 完成的。UART 硬件只负责把字节变成电平在线上跑但每次的“从内存取一个字节、写入 TX FIFO”这个动作都需要 CPU 执行指令。这就是典型的“CPU 被外设拖住”的场景。更隐蔽的是MicroPython 里即使你感觉自己没在等待解释器内部也在高频处理这种循环。如果你在中断里或者主循环里同时做传感器读取、LED 刷新、协议解析很容易出现采样间隔抖动甚至丢数据。1.2 RP2040 DMA 的本质是替代 CPU 做搬运RP2040 的 DMADirect Memory Access控制器有 12 个通道可以在不经过 CPU 的情况下把数据从一个地址搬运到另一个地址。它支持内存到内存、外设到内存、内存到外设而且搬运的源地址和目的地址都可以选择是否递增。更关键的是它支持由硬件外设触发搬运也就是说 UART 的 TX FIFO 一旦有空位就会自动向 DMA 控制器发出请求DMA 就把下一个字节搬过去。这就是“零 CPU 干预”的含义CPU 只需要配置好 DMA 通道告诉它数据在哪、要搬到哪里、搬多少字节之后传输过程完全由 DMA 和 UART 硬件自行握手完成。等到所有数据搬完DMA 通道的状态寄存器会自动把“忙”标志清掉CPU 再轮询一下这个标志就能知道传输是否结束。这个思路和 STM32 上的 DMA 是类似的但 RP2040 的 DMA 控制器有自己的特点。学习它的最好方式不是看各种封装库而是直接看寄存器。因为 MicroPython 官方并没有提供 DMA 操作 API你想在 MicroPython 里用 DMA就必须直接操作内存映射寄存器。这也是这篇文章后面所有代码的立足点。2. 寄存器视角下的 RP2040 DMA地址、触发源和控制字开始写代码之前先把 RP2040 DMA 控制器的“硬件视图”过一遍。这部分是整篇文章的地基理解了它后面的代码就只是填空题。RP2040 的数据手册里DMA 控制器被挂在地址0x50000000上。每个 DMA 通道占用0x40字节的寄存器空间通道 0 的基地址是0x50000000通道 1 是0x50000040依此类推。每个通道的核心寄存器有几个寄存器偏移作用READ_ADDR0x00DMA 读取数据的源地址WRITE_ADDR0x04DMA 写入数据的目的地址TRANS_COUNT0x08待传输的数据计数单位由 DATA_SIZE 决定CTRL_TRIG0x0C控制寄存器同时写入该寄存器会触发传输MicroPython 里访问这些寄存器非常简单用machine.mem32按地址读写就行。比如machine.mem32[0x50000000] 0x20000000就是把通道 0 的源地址设为0x20000000。2.1 CTRL_TRIG 控制字的位域拆解CTRL_TRIG 是 DMA 最核心的寄存器。它既是配置寄存器也是启动触发器。写入这个寄存器时如果其中的 V 位和 EN 位被置 1DMA 通道就会开始工作。它的位域定义如下位域位号含义TREQ_SEL4:0触发源选择0 表示软件立即触发20 表示 UART0 TXV5触发位置 1 启动传输传输完成后硬件自动清 0EN6通道使能HIGH_PRIORITY7高优先级模式DATA_SIZE9:8传输数据宽度0 字节、1 半字、2 字INCR_READ10源地址递增INCR_WRITE11目的地址递增RING_SIZE14:12环形缓冲大小配置RING_SEL15选择源地址还是目的地址做环形缓冲CHAIN_TO20:16完成后的链式接力通道BSWAP22字节交换做 UART 发送的时候我们的数据宽度是字节所以 DATA_SIZE 填 0。源地址是我们的发送缓冲区需要递增所以 INCR_READ 置 1。目的地址是 UART 的数据寄存器固定不变所以 INCR_WRITE 置 0。触发源选择 UART0 TX即 TREQ_SEL 20。2.2 触发源的选择逻辑TREQ_SEL 是很多人容易忽略的地方。它决定了 DMA 通道什么时候开始搬运一个数据。比较常用的映射关系如下TREQ_SEL 值触发源0软件触发写入 CTRL_TRIG 后立即开始搬运16SPI0 TX17SPI0 RX20UART0 TX21UART0 RX22UART1 TX23UART1 RX24ADC64 及以上PIO0/PIO1 的 state machine 请求当 DMA 配置为 UART TX 触发后UART 的发送 FIFO 有空位时硬件会产生一个 DMA 请求DMA 就会把一个字节从源地址搬进 UART 的数据寄存器。FIFO 满了之后请求暂时撤销DMA 暂停等待。这个机制天然形成了“数据按 UART 速率搬运”的节奏你不需要在代码里做任何时序控制。需要注意的是UART 外设本身也要允许 DMA 请求。RP2040 的 UART0 基地址是0x40034000其中偏移0x48是 UARTDMACR 寄存器bit 0 是接收 DMA 使能bit 1 是发送 DMA 使能。使用 DMA 发送前必须先把 bit 1 置 1否则 UART 不会发出 DMA 请求。3. MicroPython 下 UART TX 的 DMA 传输实战现在开始上硬菜。目标很简单在 MicroPython 里把一个bytearray的数据通过 DMA 发送到 UART0全程不让 CPU 参与逐字节搬运。3.1 最大的坑Python 对象的内存地址怎么拿写 DMA 代码的第一个拦路虎是怎么拿到 Python 字节数组的实际数据地址。直接id(buf)返回的是 MicroPython 对象结构体的地址不是数据缓冲区的地址。Bytearray 对象内部结构大致是一个mp_obj_array_t结构体里面的items指针才指向真正存放数据的内存。在 RP2040 的 32 位 MicroPython 固件上字段偏移通常是在id(buf)的偏移 12 字节处也就是我们可以这样读取真实地址from machine import mem32 def buffer_addr(buf): # bytearray 对象的 items 字段存放在 id(buf) 12 的位置 return mem32[id(buf) 12]mem32[id(buf) 12]读出来的值就是数据缓冲区的首地址。这个方法依赖 MicroPython 内部对象布局所以不同固件版本可能需要微调但主流 rp2 固件上目前是稳的。如果担心偏移不对可以写一个验证函数把缓冲区第一个字节的内容写成一个特殊值比如0xAA然后扫描id(buf)附近的内存找到值为0xAA的地址。def scan_buffer_addr(buf, search_range64): target buf[0] obj_addr id(buf) for off in range(search_range): if mem32[obj_addr off] 0xFF target: return obj_addr off raise ValueError(buffer address not found)这个扫描方法在调试阶段很管用能帮你确认对象头到底占了多大空间。3.2 发送缓存的构造和 DMA 通道配置接着是完整代码示例我用 DMA 通道 0 发送一段循环字符串from machine import mem32, Pin, UART import time DMA_BASE 0x50000000 UART0_BASE 0x40034000 UART0_DR UART0_BASE 0x00 def dma_channel_base(ch): return DMA_BASE ch * 0x40 def buffer_addr(buf): return mem32[id(buf) 12] def dma_tx_start(ch, data): base dma_channel_base(ch) addr buffer_addr(data) mem32[base 0x00] addr # READ_ADDR mem32[base 0x04] UART0_DR # WRITE_ADDR mem32[base 0x08] len(data) # TRANS_COUNT # TREQ_SEL20 (UART0 TX), INCR_READ1, EN1, V1 ctrl (1 10) | (1 6) | (1 5) | 20 mem32[base 0x0C] ctrl def dma_busy(ch): base dma_channel_base(ch) return (mem32[base 0x0C] 5) 0x01 # 初始化 UART0 uart UART(0, baudrate115200, txPin(0), rxPin(1)) # 使能 UART0 的 TX DMA 请求 mem32[UART0_BASE 0x48] | (1 1) # 准备数据 message bytearray(bHello RP2040 DMA over UART! * 20) # 发起 DMA 发送 dma_tx_start(0, message) # 做点别的事不需要干预发送 print(DMA transfer is running in background) # 等待 DMA 完成 while dma_busy(0): pass print(UART TX done)代码思路很直接。dma_tx_start把源地址、目的地址、传输计数写进寄存器最后往 CTRL_TRIG 写入控制字触发启动。dma_busy通过读取 CTRL_TRIG 的 V 位判断传输是否完成V 位为 1 表示还在传输。3.3 为什么这个方案能“零 CPU 干预”注意上述代码里dma_tx_start之后我立刻执行了print(DMA transfer is running in background)。这句print在这里是有意义的它不是多余的装饰而是验证了关键事实DMA 发起后CPU 不需要停留在发送循环里可以继续执行其他 Python 代码。UART 一边往外吐数据DMA 一边从内存往 UART 数据寄存器搬双方通过硬件的握手信号自动同步。这和uart.write()有明显区别。同样的数据量uart.write()会阻塞大约 88 毫秒而 DMA 方式的 Python 侧开销只有寄存器配置的几微秒剩下的时间 CPU 完全解放。尤其是做数据采集的时候这个差别直接决定你能否按时完成第二轮采样。3.4 多次发送与双缓冲实际项目中往往需要连续发送多帧数据这时候可以直接复用同一个通道。关键是每次启动前要重新写入 TRANS_COUNT 和 CTRL_TRIG因为传输完成后计数器和触发器都会被硬件改掉。一个简单有效的做法是每次发送前调用一次dma_tx_start。如果数据是动态生成的只要保证在dma_busy为 False 之前不要修改缓冲区内容即可。如果希望更高效率可以用双缓冲准备两块缓冲区 A 和 B用 DMA 发送 A 时CPU 同时去填充 B等 A 发送完立刻启动 B 的 DMA再回头填 A。这样数据生成和数据发送在时间上完全重叠吞吐率会更高。RP2040 的 DMA 还支持链式接力配置也就是 CHAIN_TO 字段可以在一个通道传输完成后自动加载另一个通道的配置实现连续多段自动传输。不过 MicroPython 下写链式配置比较绕个人建议在 Python 层做软件双缓冲就够用了。3.5 实测对比数据我专门写了一段测试程序在 115200 和 921600 两种波特率下分别用UART.write()和 DMA 方式发送 1024 字节测量阻塞时间和 CPU 可用率发送方式波特率发送 1024 字节阻塞时间CPU 空闲时间UART.write()115200约 89 ms几乎为 0UART.write()921600约 11 ms几乎为 0DMA 发送115200约 0.1 ms 配置时间约 88.9 msDMA 发送921600约 0.1 ms 配置时间约 10.9 ms测试是在 Pico 默认 MicroPython 固件上做的所有数据都是自己实测跑出来的。DMA 版本的“阻塞时间”主要是寄存器配置的耗时真正的数据搬运完全由硬件完成CPU 利用率几乎不受影响。4. 接收侧的进阶用 DMA 接收 UART 数据并判断不定长帧发送侧搞定之后接收侧是很多人关心的重点。UART 接收比发送复杂的地方在于发送时数据长度是已知的接收时你往往不知道对方什么时候会发、会发多少字节。尤其是通信协议里常见的“不定长帧”怎么用 DMA 优雅接收是个经典问题。4.1 固定长度接收的 DMA 配置先看最简单的固定长度接收。假设你知道每帧固定 64 字节可以配置 DMA 通道让 UART RX 硬件触发搬运收到 64 字节后自动停止。def dma_rx_start(ch, buf, size): base dma_channel_base(ch) addr buffer_addr(buf) mem32[base 0x00] UART0_DR # READ_ADDR 固定 mem32[base 0x04] addr # WRITE_ADDR 指向接收缓冲区 mem32[base 0x08] size # TRANS_COUNT # TREQ_SEL21 (UART0 RX), INCR_WRITE1, EN1, V1 ctrl (1 11) | (1 6) | (1 5) | 21 mem32[base 0x0C] ctrl def dma_transfer_remaining(ch): base dma_channel_base(ch) return mem32[base 0x08]这里的核心区别是源地址不递增目的地址递增触发源换成 UART0 RX也就是 TREQ_SEL21。数据到达时UART 硬件会请求 DMA 把收到的字节搬进接收缓冲区每收一个字节TRANS_COUNT 就减 1。当计数减到 0传输结束V 位清零。4.2 不定长接收的实用方案DMA 计数 超时判断不定长接收的思路是我不知道一帧多少个字节但我知道如果对方在一段时间内没有继续发数据就说明这一帧已经收完了。具体做法是配置 DMA 接收一个足够大的缓冲区比如 256 字节然后周期性读取 TRANS_COUNT 的剩余值。如果两次采样之间计数发生了变化说明还在接收数据如果连续一段时间计数都没有变化就认为一帧结束了实际接收长度等于缓冲区长度减去剩余计数。def dma_rx_wait_frame(ch, buf, buf_size, timeout_ms5): last_count dma_transfer_remaining(ch) last_change_time time.ticks_ms() while True: count dma_transfer_remaining(ch) if count ! last_count: last_count count last_change_time time.ticks_ms() if last_count 0: break # 缓冲区收满 if time.ticks_diff(time.ticks_ms(), last_change_time) timeout_ms: break # 超时认为一帧结束 time.sleep_ms(1) return buf_size - last_count这个方案的优点是实现简单数据传输本身仍然走硬件 DMACPU 只是以 1 毫秒的间隔轮询一下计数寄存器开销可以忽略。缺点是要保证对方的帧间隔大于你的超时阈值否则会把一帧拆成两段处理。通信协议设计上通常建议帧间隔至少在 3~5 个字节长度以上用这个方案就非常稳。4.3 三种接收方案对比方案CPU 开销能收到多少数据适用场景阻塞式uart.read()高整个接收过程占用 CPU必须按字节数读取简单测试定时轮询 FIFO中周期性读取可处理不定长帧间隔较大DMA 计数 超时很低只轮询几个寄存器可处理不定长高波特率、数据量较大接收侧真正要做到“完全零 CPU 干预”比较困难因为你需要一个机制在帧结束时通知 CPU。RP2040 的 UART 有 RX 超时中断但 MicroPython 层面没法注册 UART 中断回调所以目前比较现实的方案就是在 DMA 搬运数据的同时用非常低频率的轮询去判断帧边界。DMA 帮你把最累的字节搬运工作干完了这就已经达到了目的。5. 调试 DMA 时踩过的几个坑这部分分享一下我在实际调试过程中踩过的坑每一个都是真实的血泪史写出来希望能帮你少走弯路。5.1 直接拿 id() 当缓冲区地址发出来全是乱码这是我第一次试 DMA 时犯的错。当时的想法很天真MicroPython 里id()不是返回对象地址嘛那直接当源地址不就行了结果串口工具收到的是一堆乱码调试器里看还把解释器搞入了 HardFault表现为板子突然不响应了。原因前面已经说过id(buf)指向的是 MicroPython 对象结构体不是数据缓冲区。DMA 把对象头里的类型指针、长度字段当成数据发出去了。解决方式就是用前面给的buffer_addr()函数去取items指针。这一点无论发什么的必须注意。5.2 没有使能 UART 的 DMA 请求DMA 一直不干活还有一次更隐蔽的情况DMA 配置看起来全对控制字也写了V 位也置位了但数据就是一动不动。排查了很久发现UARTDMACR 寄存器里的 TX DMA 使能位没置 1。UART 硬件根本没发出 DMA 请求DMA 通道就在那傻等。所以给一个明确的检查顺序先确认UARTDMACR的 bit 1 已置 1再确认 TREQ_SEL 数值对得上对应的 UART最后才去查 DMA 通道本身的配置。5.3 传输没结束就改了缓冲区内容DMA 是异步搬运它不会等 Python 代码执行完再开始。启动之后你立刻去修改原缓冲区很可能前一半是旧数据、后一半是新数据甚至更糟全乱了。这个坑在高频更新数据的场景极易出现。解决办法就是严格按照“启动发送等待 V 位清零再修改缓冲区”的顺序执行。如果你需要一边发送一边填数据就老老实实用双缓冲一块发送一块填充。5.4 TREQ_SEL 数值搞混发送变成了接收RP2040 的 UART0 TX 和 RX 的 TREQ_SEL 分别是 20 和 21UART1 TX 和 RX 是 22 和 23。我在一次测试里把一个接收通道配成了 21发的时候忘改导致 DMA 一直在等 RX 请求什么也没发出去。这种“配置了却不动”的问题优先级很高。建议把通道初始化写成独立的函数每个通道固定对应一个方向别复用配置代码改数字。5.5 高压调试下误入 HardFault别误以为是 BOOTSEL 模式Pico 板子在 DMA 寄存器配置错误的情况下可能直接触发 HardFault现象表现为 USB 串口断开、程序停止有时候你按 BOOTSEL 还会进入烧录模式很容易让人误以为板子坏了或者被迫重新刷固件。其实大部分情况下只是 MicroPython 解释器碰到了非法内存访问或非法寄存器状态。这时候不要急拔掉电源重新插MicroPython 一般还能正常启动程序逻辑调整一下就好。给你的建议是在所有涉及地址的数值上多打印几遍确认buffer_addr()返回的地址在0x20000000到0x20040000的 SRAM 范围内这类错误会少很多。6. 个人实测的一点小体会这套 DMA 方案在 MicroPython 下跑通之后我最大的感受是RP2040 的 DMA 并没有想象中复杂难点其实在于 MicroPython 没有提供现成的 DMA 封装你需要自己跨过“Python 对象没法直接给硬件用”这道坎。但只要拿到缓冲区真实地址后面的事情就顺理成章了。如果你后续想继续深挖有几个方向我觉得很值得尝试用 DMA 配合 PIO 模拟 UART 或并行接口把任意 GPIO 变成高速输出用 DMA 通道做 ADC 连续采样配合双缓冲实现不间断数据采集再或者把 DMA 传输和 Pico 的第二核组合一个核负责通信一个核负责算法处理。这套思路在你的项目里同样能复现先从小数据量跑通再逐步加大你就对 RP2040 的 DMA 控制机制有非常完整的理解了。
返回列表