ARTICLE DETAIL

资讯详情

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

MicroPython rp2.DMA实战:数据搬运、API配置与避坑指南

MicroPython rp2.DMA实战:数据搬运、API配置与避坑指南 先说一个我经常遇到的场景一上电要连续采集几十个传感器数据或者要给 PIO 状态机喂一大包数据最开始都是老老实实写 while 循环read 一下、write 一下。循环一开CPU 占用率直接拉满其他逻辑跟着卡顿。这时候就该请出 rp2.DMA 了。DMA 全称 Direct Memory Access通俗讲就是硬件级的数据搬运工。你只需要告诉它从哪搬、搬到哪、搬多少、什么时候搬它就能在后台自己完成几乎不占用 CPU。在 RP2040/RP2350 平台上MicroPython 把它封装成了 rp2.DMA 类。这篇文章我会把 rp2.DMA 的 API 参数、完整配置流程、以及我实际踩过的各种坑一次说清楚目标读者是正在用 PIO、UART、SPI、ADC 做数据搬运、又不想被中断和循环拖垮的 MicroPython 玩家。1. 先把概念想明白rp2.DMA 到底在干什么1.1 DMA 解决什么问题你平时收发串口数据写一句uart.write(buf)底层其实是 CPU 把一个字节一个字节搬到 UART 的发送寄存器里。数据量小看不出来一旦 buffer 是几 KB或者传输频率很高CPU 就被这种机械劳动占满了。DMA 的出现就是把 CPU 从这类重复劳动里解放出来。可以这样理解普通搬数据是老板亲自一箱一箱搬DMA 是你请了个专职搬运工。你只要交代清楚搬运路线和数量搬运工自己跑老板继续开会干正事。RP2040/RP2350 片内自带 DMA 控制器专门负责内存到内存、内存到外设、外设到内存的搬运。MicroPython 的 rp2.DMA 就是这个控制器的最小封装没有把底层寄存器全暴露但也保留了最核心的配置入口。从硬件角度看DMA 一次传输完整动作由四个要素决定读地址、写地址、传输次数和触发条件。四个要素到位DMA 就开始干活。后面所有 API 参数本质都是在配置这四个要素。你如果能把这四个问题回答清楚配置基本不会跑偏。1.2 MicroPython 里的 rp2.DMA 三步走使用 rp2.DMA 一般是三步创建通道、config 配置参数、active 启动。创建通道一行代码dma rp2.DMA()。这个调用会从芯片的空闲 DMA 通道里自动分配一个你不需要记通道号。config 是核心所有搬运参数都在这里填。最后调用dma.active(True)通道才会真正开始跑。为什么 config 之后不能直接搬跟很多外设驱动一样rp2.DMA 把“配置”和“启动”拆成两步目的是让你先把所有参数准备好再统一触发。如果你改了参数想重新开始也是一样先active(False)停掉再 config再active(True)。很多第一次用的人只 config 不 active发现数据纹丝不动其实就是少了最后一步。顺带提醒一句DMA 通道不是无限的RP2040 一共 12 条RP2350 我手头的板子是 16 条。程序里每 new 一个rp2.DMA()就占一条通道占完不释放后面再创建就可能报错。所以用完记得active(False)或者把变量复用到同一个对象上别写一个函数就 new 一个。2. API 逐字段拆解config 的每一个参数都不是白设的2.1 地址三件套read_addr / write_addr / countconfig 里最重要的三个参数是read_addr、write_addr和count。read_addr 是数据从哪里来write_addr 是数据到哪里去count 是搬多少。三个参数决定一次 DMA 传输的基本规模少了 MicroPython 一般会直接报错或者静默不动作。read_addr 和 write_addr 可以传两类东西一类是 Python 的 buffer 对象比如 bytearray、array、memoryview另一类是外设寄存器的绝对地址比如 UART0 的 DR 寄存器地址 0x40034000。传 buffer 对象时MicroPython 会自动把对象内部的连续内存地址解析出来你不需要自己取地址。传外设寄存器时直接写一个整数地址就行。count 有三种典型理解方式这也是一个容易踩的坑。配合 bytearray 这类字节型 buffer一个 count 基本就是一个字节如果你用array(H)一个元素占两个字节那么 count 和 len(array) 就不是一回事。最好把 count 理解为“DMA 按当前数据宽度搬了多少次”而不是“搬了多少个 Python 元素”。官方例子里 UART 发送的 count 填 len(buf)是因为 buffer 是 bytearray每个元素刚好占一个字节两者恰好相等。2.2 递增开关read_incr / write_incrread_incr 和 write_incr 决定每次传输后地址是否递增。从内存里连续读数据read_incr 就是 True把数据写入一个固定的外设寄存器write_incr 就是 False。很多初学者把这一对参数搞反结果数据像“螺旋”一样错位或者整个地址越界。举个例子内存到内存拷贝两边都是连续地址read_incrTrue、write_incrTrue。UART 发送源内存地址要递增目的寄存器地址固定所以 read_incrTrue、write_incrFalse。ADC 循环采样源是 ADC 数据寄存器固定地址read_incrFalse目标是数组内存write_incrTrue。场景read_incrwrite_incr内存到内存TrueTrue内存到外设寄存器TrueFalse外设寄存器到内存FalseTrueFIFO 到 FIFOFalseFalse总结起来一句话哪边是连续内存哪边就开递增哪边是固定寄存器或 FIFO哪边就关递增。不要记死场景每次配置前先在草稿纸上画一条搬运路线来源和目标各标一个箭头箭头方向就是地址递增方向。2.3 触发源 triggerDREQ 才是 DMA 的灵魂trigger 参数填的是 DMA 请求信号 DREQ。DMA 本身是个傻搬运工你让它搬它就搬但实际工程里我们经常希望 DMA 跟外设节奏同步UART 发送寄存器空了才允许写入SPI RX FIFO 有数据才允许读取。这个节奏就是靠 DREQ 信号控制。比如 UART0 发送trigger 填rp2.DMA.DREQ_UART0_TX。这样 DMA 通道会一直等 UART 的 TX FIFO 发出“可以写”的信号写一个字节等一个信号再写一个字节。反过来如果 trigger 配错比如整个工程根本没有启用 UART0DMA 可能一直等在那里dma.active()永远为 True这就是最常见的“卡死”现象。MicroPython 内置了一批 DREQ 常量比如 DREQ_UART0_TX、DREQ_SPI0_RX、DREQ_PIO0_TX0 之类。不同固件版本覆盖程度不太一样最靠谱的办法是打开交互终端敲dir(rp2.DMA)看看当前固件给了哪些常量。如果做的是纯内存搬运没有外设 DREQ 可用直接靠dma.active(True)触发是最常见的做法只要固件默认行为允许如果发现不跑优先查 trigger 相关配置。2.4 mode、source、dest 与其余参数mode 控制通道是单次还是循环。传 0 是单次模式传 1 是循环模式。循环模式适合需要持续不断搬运的场景传输完一轮后读写地址回到初始值继续下一轮。典型用法是配合 ADC 或 PIO 做连续数据采集DMA 永远在后台转一轮接一轮往同一个缓冲区里填。source 和 dest 参数默认情况下不需要动默认对应“内存”这一路。如果配合 PIO 状态机使用再去查官方例程里怎么填。乱改 source/dest 不会让代码变快反而容易让搬运路线指向错误的总线地址。MicroPython 把这些参数封装成 keyword 形式就是为了让你一眼看懂每个值的意思所以配置时尽量都用命名参数别用位置参数硬塞。3. 从零跑通一个 DMA 传输完整实操3.1 内存到内存的最小验证工程想验证 rp2.DMA 能不能跑通第一步不建议上复杂外设先用最简单的方式看通道行为。最小验证工程只需要一块树莓派 Pico 或者其它 RP2040/RP2350 开发板固件用最新的 MicroPython然后打开 Thonny 或者 mpremote。下面这段代码把 256 个字节从一个 bytearray 搬到另一个 bytearray然后比较两边是否一致。这是理解 read_addr、write_addr、count、read_incr、write_incr 的最小示例import rp2 src bytearray(range(256)) dst bytearray(len(src)) dma rp2.DMA() dma.config( read_addrsrc, write_addrdst, countlen(src), read_incrTrue, write_incrTrue, ) dma.active(True) while dma.active(): pass dma.active(False) print(src dst)代码里没有指定 trigger对于大多数 MicroPython 固件调用dma.active(True)会让通道按普通模式直接跑完。如果你运行后发现 while 循环一直出不来大概率是固件把 trigger 默认为某个外设 DREQ而不是立即执行。这时候在 config 里补上项目里实际可用的外设 DREQ或者查一下固件文档里 trigger 不传时的默认行为。先把这个最小例子跑通后面接外设才有底气。跑通以后你会看到True。整个传输过程中CPU 没有参与逐字节搬移只是启动 DMA 然后等待状态位。等到项目里真正需要连续采样时这段等待时间还可以用来干别的事。3.2 把字符串交给 UART 发送下面这个例子是我实测过的从官方思路延伸出来。先初始化一个 UART然后准备要发送的字符串再用 DMA 将字符串搬运到 UART0 的发送寄存器全程不调用uart.write。from machine import UART, Pin import rp2 uart UART(0, baudrate115200, txPin(0), rxPin(1)) UART0_BASE 0x40034000 UART_DR_OFFSET 0x00 msg bytearray(bhello rp2.DMA\r\n) dma rp2.DMA() dma.config( read_addrmsg, write_addrUART0_BASE UART_DR_OFFSET, countlen(msg), triggerrp2.DMA.DREQ_UART0_TX, read_incrTrue, write_incrFalse, ) dma.active(True) while dma.active(): pass dma.active(False) print(send done)解释一下msg 是 bytearrayread_addr 直接传对象MicroPython 会自动解析出 buffer 地址。write_addr 填 UART0 的 DR 寄存器这是 UART 数据寄存器写入即发送。write_incrFalse 是因为每次都要写同一个寄存器。trigger 用 UART0_TX DREQ这样 DMA 会等 UART 的发送 FIFO 腾出位置再写下一个字节。程序跑完串口应该能看到那行字。注意这段代码不会用到uart.write但 UART 外设还是要先初始化因为波特率、引脚配置都由硬件完成。如果 UART 没有初始化DREQ 不会正常产生DMA 大概率一直卡在 active 状态。实际项目里我通常把 DMA 发送封装成一个函数传入 bytearray内部完成 config 和等待返回发送是否完成。3.3 PIO 联动把 DMA 数据喂给状态机在 RP2040 上PIO 和 DMA 是一对黄金搭档。PIO 状态机本身不带大数据缓存如果你要连续输出很多数据靠sm.put()逐个填会占用 CPU。DMA 可以直接把内存数据搬到 PIO 的 TX FIFO 地址状态机按自己的节奏消费形成一条高效流水线。思路是这样的PIO0 的 TX FIFO 物理地址是 0x50200010你把它当作 write_addr数据源是内存 bytearrayread_incrTruewrite_incrFalsetrigger 用rp2.DMA.DREQ_PIO0_TX0。当 PIO 状态机启动运行TX FIFO 有空间时 DREQ 就会拉起来DMA 自动补一个数据进去。import rp2 data bytearray([1, 2, 3, 4, 5, 6, 7, 8]) dma rp2.DMA() dma.config( read_addrdata, write_addr0x50200010, # PIO0 TX FIFO 0 countlen(data), triggerrp2.DMA.DREQ_PIO0_TX0, read_incrTrue, write_incrFalse, ) dma.active(True)这段代码还没有包含 PIO 状态机的程序加载严格来说不能直接看到现象但配置骨架就是这样。你只需要把状态机程序写好、sm.active(1)启动再配合这段 DMA 配置数据就会持续流进 PIO。这个组合是很多高性能外设项目的基础比如 WS2812 驱动、并行总线模拟、精确时序输出。3.4 循环模式让数据搬运停不下来循环模式 mode1 适合连续采样的场景。MicroPython 里配置 ADC 的 FIFO、使能采样触发需要额外写寄存器篇幅比较长这里给一个更能说明原理的示意假设你已经让某个外设持续产生 DREQ用 DMA 往一个环形缓冲区里填数据。buf bytearray(256) dma rp2.DMA() dma.config( read_addr0x4004c008, # ADC_DRADC 数据寄存器 write_addrbuf, countlen(buf), trigger36, # RP2040 datasheet 里 ADC 的 DREQ 编号 mode1, # 循环模式 read_incrFalse, write_incrTrue, ) dma.active(True)这段代码不能直接复制就跑因为 ADC 的 FIFO 触发开关、free-running 模式都要先在寄存器层面配好。放出来是想说明循环模式和普通模式在参数上的差别多了一个 mode1地址递增规则也变成源地址固定、目标地址递增。等数据填满 bufferDMA 会自己绕回开头继续填。很多人在循环模式里最困惑的一点是写地址到底回不回绕答案是在 mode1 下会回绕到初始地址而不是停在末尾。你可以在主循环里定期处理 buffer处理完不用手动重置 DMA。这个特性做音频采集、波形记录特别方便等于一个永远在自动更新的环形缓冲区。4. 配置避坑指南这些坑我基本都踩过4.1 count 的语义与缓冲区越界我见过最多的问题是 count 填错导致外部数据没收到或者内存被写飞。count 不是 Python 列表长度也不是 buffer 总字节数那么绝对而是按 DMA 搬运粒度的次数。MicroPython 官方 API 目前没有暴露数据宽度参数最稳妥的做法是拿 bytearray 做缓冲区这时候 count 可以直接写 len(buf)。如果非要用array(H)或array(I)每个元素是 2 或 4 字节你需要算清楚 DMA 按多宽的粒度搬别直接拿元素的个数去填。另一个常见错误是 count 超过目标 buffer 长度。DMA 是硬件搬数据没有 Python 的边界检查写完目标地址范围之外就会踩到相邻变量甚至踩到堆管理结构。轻则数据错乱重则直接死机。我现在的习惯是分配 buffer 时多留一点余量或者写一个统一的配置函数在进入 config 之前查一遍 count 和 buffer 长度长度不够直接抛异常。4.2 递增方向搞反数据错乱read_incr/write_incr 这一对参数我踩过两次。第一次是把 UART 发送的 write_incr 设成了 True数据倒是发出来了但地址一直往前走越过了寄存器区DMA 访问到奇怪的地址。第二次是把外设到内存的 read_incr 设成 True结果每个数据都从连续的地址读源寄存器被当成了内存完全乱套。判断方法很笨但有效先画一条搬运路线。左侧是读地址的走向右侧是写地址的走向。哪一边是连续 buffer就开哪边的 incr哪一边是单个寄存器就关掉。确认完再落代码比盯着寄存器猜要快很多。还有一种情况是只递减的环形缓冲但 MicroPython 的 rp2.DMA 目前没有暴露递减模式所以你需要额外考虑缓冲区回绕逻辑。别想着靠 read_incrFalse 去手动控制步进那只会让配置变得更难排查。4.3 trigger 没生效外设 FIFO 和 DREQ 的关系trigger 配了但不跑是另一个高频问题。DREQ 不是你想触发就触发的它依赖外设本身处于可工作状态。比如用 SPI 的 RX DREQSPI 外设没有初始化、没有时钟使能DREQ 永远不会有信号用 UART TX 的 DREQUART 没使能或者 TX 引脚没配置同样不会有信号。还有一种情况外设 FIFO 使能和 FIFO 水位配置不对。RP2040 的很多外设比如 SPI、UART内部 FIFO 的触发阈值会影响 DREQ 产生的时机。你在 MicroPython 里用 machine.UART 初始化的外设FIFO 一般会正常开如果你自己写寄存器配置外设一定要先去数据手册确认 DREQ 触发条件别把 FIFO 关着就去等 DREQ。另外注意同一个 DREQ 编号在不同芯片上可能有差异。RP2040 数据手册里的 DREQ 映射表和 RP2350 不一定完全一样。跨芯片移植代码时不要直接照抄 trigger 常量先查目标芯片的数据手册。踩过一次之后我每次都会在代码里加一行注释写上“这个 trigger 对应 RP2040 datasheet 第几节的 DREQ 表”。4.4 通道复用与残留状态用同一个 dma 对象反复 config 前我的习惯是先dma.active(False)停掉再重新 config。直接覆盖 config 不是不行但现场很危险如果上一次传输还在跑新参数已经写进寄存器读地址和写地址对不上可能搬出脏数据。另外创建了新的rp2.DMA()但一直不释放通道会越占越少。程序跑久了突然报错先查是不是通道被占满。想释放通道调用dma.active(False)再把 dma 变量删掉或者让对象被回收大多数情况就能空出来。为了省通道我一般会把 DMA 对象设计成全局单例需要搬不同数据时只改 config而不是每次都 new。4.5 source/dest 别乱改通道数要省着用MicroPython 的 rp2.DMA 在普通外设场景里不需要碰 source/dest。这两个参数主要是给 PIO 用的因为 PIO 的数据来源与去向和普通内存不同。如果你只是 UART、SPI、内存之间搬运保持默认就完事。乱改 source/dest 的后果是地址解析跑偏搬运出来的数据跟你想象的完全不是一回事。还有一点通道数量有限。项目里如果多个外设都想用 DMA不要起一堆rp2.DMA()裸对象建议写一个简单的分配器或者维护一份通道占用表按需创建、用完关闭。尤其在做长时间运行的项目时通道泄漏比内存泄漏更隐蔽因为它不会立刻报错往往要等到第 12 个或第 16 个 DMA 对象创建时才会炸出来。5. 常见问题与排查技巧实录5.1 现象对照速查表这些年下来我把 rp2.DMA 遇到的问题整理成一张速查表。遇到问题先别急着翻代码对照现象找最可能的原因往往能省一半时间。现象可能原因优先检查dma.active() 一直 Truetrigger 没信号或外设未初始化检查外设配置和 DREQ 常量数据错位、乱码read_incr/write_incr 方向错检查递增开关数据搬一半就停count 不匹配或 buffer 太小检查 count 是否等于 buffer 长度死机、内存被踩坏count 超过目标 buffer减小 count加长度校验通道创建失败DMA 通道被占满释放不用的 dma 对象输出重复发送mode 被误设为 1 循环模式检查 mode 参数这张表不是万能药但覆盖了我和周围人遇到的大部分问题。如果你遇到了表格之外的现象优先怀疑你用的 MicroPython 固件版本和官方文档不一致因为 rp2.DMA 相关 API 在不同版本里有调整。5.2 排查流程把 DMA 拆成三个环节我的排查习惯是把 DMA 拆成三个环节逐个验证。第一步验证地址环节只做内存到内存两边都用 bytearray先不管 trigger直接用 active(True)完成后比较两个 buffer 是否一致。这一步能确认 read_addr、write_addr、count、地址递增逻辑没有问题。第二步验证外设环节把目的地址换成外设寄存器比如 UART DR但 trigger 暂时先不关注看看数据有没有写进外设。第三步验证触发环节加上 DREQ让外设的节奏参与控制。这套流程看起来多花三分钟但能帮你把问题快速定位到某一层。有一次我配错 trigger折腾了半天后来按这个流程一拆发现地址环节没问题外设写入没问题就是 DREQ 配到了 UART1 上而工程用的 UART0。这种低级错误在完整代码里非常隐蔽如果你不拆环节永远只能看着dma.active()一直为 True 发愁。5.3 几个提高成功率的小习惯第一常量名不要硬背在 REPL 里敲dir(rp2.DMA)把当前固件支持的 trigger 常量、模式常量全部打出来对照再写。第二调试期间把缓冲区长度设小一点比如 16 或 32方便打印出来对比。第三不要在 DMA 传输的 while 循环里做太多耗时操作尤其不要在回调中频繁申请内存这会让时序变得不可预测。第四同一份代码在 RP2040 上能跑不代表在 RP2350 上一定能原样跑不同芯片的 DREQ 编号和寄存器基地址有差异跨芯片移植时先看数据手册。想到一个非常实际的技巧配置好后不要马上加复杂业务逻辑先单独写一个 test 函数反复搬运同一个固定 pattern比如 0x00-0xff跑个几百次都不出问题再把它接入业务。DMA 这种硬件功能一旦搬错就是静默脏数据没有 Python 异常可以捕获。最后分享一个我自己的习惯每次写完 DMA 传输我都会主动打印一条dma.active()的结果传输完成后果断 active(False)。表面看是多写一行代码实际上这行代码帮我省了大量排查时间。rp2.DMA 这个类 API 确实简洁但简洁不等于简单参数之间互相咬合得很紧。你只要把“从哪搬、搬到哪、搬多少、什么时候搬”这四个问题想清楚再对照这篇文章的避坑清单过一遍基本就能一次跑通。后续如果再深入可以研究 PIO 与 DMA 的搭配、环形缓冲、以及多通道链式传输那些才是真正把 RP2040 的数据搬运能力榨干的玩法。
返回列表