十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

MicroPython实战:用RP2040 DMA实现内存到内存高速搬运

MicroPython实战:用RP2040 DMA实现内存到内存高速搬运 如果你和我一样习惯用 MicroPython 在 RP2040 上快速做原型大概率会遇到这种尴尬业务逻辑写得挺顺一到数据搬运就卡壳。就算只是把 64KB 的缓冲区从一个地方复制到另一个地方纯 Python 的逐字节循环也能慢到让你怀疑人生。这时候就该让 RP2040 的 DMA 出场了——它专治内存到内存数据传输能在 CPU 几乎不参与的情况下把数据从源地址搬到目的地址。这篇文章我用最笨、最好复现的方式带你在 MicroPython 里直接操作 DMA 寄存器实现一个可用的 mem2mem 搬运函数顺便把时序、对齐、测速、避坑都讲清楚。适合已经能在 Pico 上跑 MicroPython、但还没碰过底层寄存器的新手也适合觉得buf[:] src[:]性能不够、想用 DMA 压榨硬件的进阶用户。1. 先搞懂 DMA 在 RP2040 上到底解决什么问题1.1 没有 DMA 时数据搬运为什么是笔糊涂账很多人第一次听到内存到内存 DMA第一反应是这不就是memcpy吗我直接dst[:] src[:]不就行了在 C 语言里这么想问题不大但在 MicroPython 里事情没这么简单。MicroPython 的解释器执行一条for i in range(1024): dst[i] src[i]每条语句都要经过词法解析、字节码分发、类型检查、索引绑定这些环节。每次循环几百纳秒到几微秒不等看起来单次不慢但乘以数据量就非常可观。我在 Pico 上实测过纯 Python 逐字节搬运 64KB 数据大约要 300 到 500 毫秒。这个速度放到传感器数据队列、音频 buffer、帧缓冲这类场景里基本没法用。就算你用dst[:] src[:]MicroPython 底层虽然会调用 C 的memcpy或类似快速拷贝速度能上去但这一整段拷贝仍然是同步阻塞的CPU 从第一条指令开始搬运直到全部结束才返回。如果这个搬运动作是周期性的、且耗时占了一个时间片的很大比例CPU 就会被反复占住业务逻辑只能干等。1.2 DMA 本质上是把搬数据这个动作外包给硬件RP2040 内部有一组 DMA 控制器一共 12 个 DMA 通道。它的工作方式特别像一个专门的搬运工你告诉它源地址在哪、目的地址在哪、搬多少、用什么规则搬它就在后台自己搬搬完了通过状态位告诉你结果。整个过程中CPU 只需要启动前设置参数、完成后查一下状态中间的搬运动作完全不占用 CPU 执行周期。不要小看这个差异。对内存到内存传输来说数据通路是这样的DMA 控制器通过总线矩阵发起读操作把源地址的数据读进自己的内部暂存再发起写操作写到目的地址。对 CPU 来说DMA 和正常执行指令都共享总线所以 DMA 不是零开销但它把开销从CPU 逐条执行循环变成了专用硬件逻辑在总线上做 burst 读写效率和并发性都显著提升。1.3 内存到内存和外设 DMA 有什么本质区别一说到 DMA很多从 STM32 过来的朋友会先想到串口接收、ADC 采样这类外设场景。那些场景的特点是外设产生一个请求信号DMA 被 DREQ 触发搬完一个数据单元后又等下一个请求。这是请求/应答模式DMA 和某个外设是绑定关系。而内存到内存 DMA 完全不需要外部请求。RP2040 的每个 DMA 通道都有一个 DREQ 选择位当选择值为 0 时表示always——也就是无条件连续搬运。你把 CTRL_TRIG 寄存器一写通道马上开跑一口气把整段内存搬完。这种模式最适合做内存池整理、双缓冲切换、图像帧拷贝、协议栈报文填充之类的批量拷贝任务。1.4 什么时候不值得用 DMA我也得泼盆冷水并不是所有内存拷贝都应该让 DMA 上。如果你只是搬几十个字节DMA 的初始化开销写 4 个寄存器、再轮询状态可能比直接memcpy更慢。我建议一个经验阈值单次搬运超过 256 字节或者搬运频率高到能把 CPU 时间片吃掉 10% 以上才值得用 DMA。低于这个量老老实实写dst[:] src[:]或者切片拼接简单又省心。2. 只能用寄存器操作MicroPython 里 DMA 的最小编程模型2.1 为什么 MicroPython 没有现成的 DMA 库MicroPython 的官方固件对 RP2040 做了不少封装machine模块里有Pin、PWM、ADC、I2C、SPI、UART这些常用外设的类但 DMA 并不在其中。原因也不难理解DMA 的场景太底层、太灵活官方不愿意为它提供一套稳如泰山的 Python API。再加上 RP2040 的 DMA 有环形缓冲、链式传输、多通道仲裁这些高级功能硬封装一个高级接口并不容易。所以想在 MicroPython 里用 DMA最直接的路线就是走寄存器直写这条路。好在 MicroPython 提供了machine.mem32你可以把它理解成一把能直接读写 32 位地址空间的钥匙。只要知道寄存器地址就能像 C 代码一样操作硬件。from machine import mem32 # 读一个寄存器的值 val mem32[0x50000000] # 写一个寄存器的值 mem32[0x50000004] 0x20000000Python 层做这件事的开销很小因为machine.mem32底层就是一次带volatile语义的内存访问。它跟你用 C 操作寄存器没有本质区别只是调用形式从 C 的指针变成了 Python 的[]语法。2.2 DMA 通道和寄存器地址怎么对得上RP2040 的 DMA 寄存器基地址是0x50000000。每个通道占用 0x40 字节的寄存器区域和通道号线性对应。也就是说通道 0 的寄存器基地址 0x50000000 0 * 0x40通道 1 的寄存器基地址 0x50000040通道 i 的寄存器基地址 0x50000000 i * 0x40每个通道最核心的 4 个寄存器如下偏移寄存器名作用0x00READ_ADDR源地址0x04WRITE_ADDR目的地址0x08TRANS_COUNT搬运的数据单元数量0x0CCTRL_TRIG控制字段写入后启动传输源地址、目的地址好理解。TRANS_COUNT 要注意它表示的是要搬运多少个数据单元而数据单元的大小由 CTRL_TRIG 里的 DATA_SIZE 字段决定。如果配置成 32bitword模式TRANS_COUNT 就是 4 字节为一个单位如果配置成 8bitbyte模式TRANS_COUNT 就是字节数。这个单位关系非常容易搞混后文我会专门给一个对齐处理的封装来绕开这个坑。2.3 CTRL_TRIG 的关键位域CTRL_TRIG 是 DMA 传输的控制开关也是内存到内存 DMA 最需要理解的一个寄存器。写这个寄存器时各个位域一起决定了整个搬运行为最后把 EN 位置 1DMA 通道就开始工作。读这个寄存器时最高几位会回传传输状态用于查询是否完成、是否发生错误。以下是我实际用到的关键位域以 RP2040 数据手册 DMA 章节为准ENbit 0置 1 触发通道开始传输。INCR_READbit 3置 1 时每次读取后源地址自动递增置 0 时每次都从同一个源地址读取。INCR_WRITEbit 4置 1 时每次写入后目的地址自动递增置 0 时每次都写到同一个目的地址。DATA_SIZEbit 6:50 表示 byte1 表示 halfword2 表示 word。BUSYbit 24只读状态位传输进行中为 1结束后为 0。READ_ERRORbit 26、WRITE_ERRORbit 25、AHB_ERRORbit 27只读错误位发生对应总线错误时会置 1。对内存到内存搬运来说我们需要的就是INCR_READ1、INCR_WRITE1、DATA_SIZE 设为 word 或 byte、EN1。DREQ 字段保持默认值 0 即可代表 always 无条件连续搬运。这里再提醒一句不同渠道看到的寄存器位号标注可能略有差异但以 Raspberry Pi 官方 RP2040 Datasheet 的CH0_CTRL_TRIG表格为准。写代码的时候用位掩码而不是魔法数字后面维护会舒服很多。3. 手写 mem2mem一个可复用的 MicroPython 工具函数3.1 设计思路对齐就上 word不对齐退回 byte现在来写核心函数。我的设计是这样接收源 bytearray、目的 bytearray、需要搬运的字节数底层自动判断地址和数据长度是否满足 4 字节对齐。都对齐就用 32bit 模式一次搬 4 字节总线带宽利用率高不够对齐就退回 8bit 模式保证任何内存布局都能搬。为什么要做这个判断因为 RP2040 的 DMA 在 32bit 模式下如果地址没有按 4 字节对齐行为是不保证的轻则数据错位重则触发总线错误。MicroPython 里bytearray的数据指针大多数时候是 4 字节对齐的但memoryview切片偏移之后就不一定了。所以函数入口做一次对齐检测是保姆级教程该有的安全兜底。还需要注意在 MicroPython 里取 bytearray 的内存地址可以用内置的addressof函数。如果你在固件里找不到这个函数可以从uctypes模块里导入。两者最终返回的都是对象的底层数据指针。3.2 完整代码dma_mem2memfrom machine import mem32 # DMA 寄存器基地址 DMA_BASE 0x50000000 # 通道内寄存器偏移 REG_READ_ADDR 0x00 REG_WRITE_ADDR 0x04 REG_TRANS_COUNT 0x08 REG_CTRL_TRIG 0x0C # CTRL_TRIG 关键位掩码 CTRL_EN 0x00000001 CTRL_INCR_READ 0x00000008 CTRL_INCR_WRITE 0x00000010 CTRL_DATA_SIZE_32 0x00000060 # DATA_SIZE 2即 32bit word CTRL_BUSY 0x01000000 CTRL_READ_ERROR 0x04000000 CTRL_WRITE_ERROR 0x02000000 CTRL_AHB_ERROR 0x08000000 CTRL_ERROR_MASK 0x0E000000 def dma_mem2mem(dst, src, nbytes, channel0, timeout1000000): 使用 RP2040 DMA 完成内存到内存搬运。 参数: dst: 目的 bytearray / memoryview src: 源 bytearray / memoryview nbytes: 搬运字节数 channel: 使用哪个 DMA 通道0~11 timeout: 轮询超时次数防止 BUSY 永远不清导致死循环 返回: True 表示搬运成功False 表示超时或发生错误 if nbytes 0: return True if channel 0 or channel 11: raise ValueError(channel must be 0..11) base DMA_BASE channel * 0x40 try: # 尽量取原始对象地址memoryview 无法直接 addressof src_addr addressof(src) dst_addr addressof(dst) except TypeError: # 对 memoryview先转成 bytearray 再取地址虽然多一次拷贝但安全 src bytes(src) dst bytearray(dst) src_addr addressof(src) dst_addr addressof(dst) # 对齐检查源、目的地址和字节数都按 4 字节对齐时用 32bit 模式 if (src_addr 3) 0 and (dst_addr 3) 0 and (nbytes 3) 0: ctrl CTRL_EN | CTRL_INCR_READ | CTRL_INCR_WRITE | CTRL_DATA_SIZE_32 trans_count nbytes // 4 else: # 不对齐就退回 byte 模式DATA_SIZE 保持 0 ctrl CTRL_EN | CTRL_INCR_READ | CTRL_INCR_WRITE trans_count nbytes mem32[base REG_READ_ADDR] src_addr mem32[base REG_WRITE_ADDR] dst_addr mem32[base REG_TRANS_COUNT] trans_count mem32[base REG_CTRL_TRIG] ctrl # 等待 BUSY 位清 0加超时保护 while mem32[base REG_CTRL_TRIG] CTRL_BUSY: timeout - 1 if timeout 0: return False # 传输结束后检查错误位 if mem32[base REG_CTRL_TRIG] CTRL_ERROR_MASK: return False # TRANS_COUNT 应被清零如果没清零说明传输被异常终止 if mem32[base REG_TRANS_COUNT] ! 0: return False return True3.3 为什么写 CTRL_TRIG 就等于启动传输有朋友可能注意到我把控制字段写到REG_CTRL_TRIG而不是先写一个不带 EN 的CTRL寄存器。这是因为 RP2040 的 DMA 设计里当 EN 位写在 CTRL_TRIG 这个寄存器上时它不仅是配置还会产生触发动作数据通路看到 EN 从 0 变成 1就立即开始传输。传输开始后硬件会自动把 EN 位清 0所以你后续读 CTRL_TRIG 时会看到 EN 为 0但 BUSY 仍为 1这完全正常不要以为是配置被冲掉了。如果你不想一写就启动而是想先准备好配置、等某个外设事件再触发那就应该使用通道内偏移 0x10 开始的 AL1_CTRL 等寄存器。但对最常用的内存到内存场景直接写 CTRL_TRIG 是最简洁的做法。3.4 调用示例搬一个 1024 字节的缓冲区import gc from uctypes import addressof gc.collect() size 1024 src bytearray(size) dst bytearray(size) # 给源数据填充一个可辨认的递增序列 for i in range(size): src[i] i 0xFF ok dma_mem2mem(dst, src, size) print(dma result:, ok) # 校验逐字节比对 match (dst src) print(data match:, match) # 打印开头 16 个字节看看 print(src[:16]:, bytes(src[:16])) print(dst[:16]:, bytes(dst[:16]))如果一切正常你会看到dma result: True、data match: True并且源和目的的前 16 个字节相同。如果data match是 False优先检查地址对齐和 DATA_SIZE 配置。这里也顺手说一句dst src在 MicroPython 中是比较两个 bytearray 的内容不是比较对象引用可以放心用。4. 搬运完成后如何判断、校验与测速4.1 轮询 BUSY 位 vs 读 TRANS_COUNT我推荐哪个在 MicroPython 里判断 DMA 是否结束最直接的方法是轮询 CTRL_TRIG 的 BUSY 位。我前面代码里用的就是这个方案。另一种办法是轮询 TRANS_COUNT 寄存器当硬件把计数减到 0说明搬运完成。两种都行但我个人更喜欢 BUSY 位它语义更完整传输还没启动、正在启动、正在传输时都能给出明确状态。有一个细节值得注意如果因为你配置错误DMA 通道根本没被触发BUSY 位可能一直是 0轮询会立即退出。这时候如果只看 BUSY 位你会误以为搬运成功了。所以我的代码里除了查 BUSY还额外检查了 TRANS_COUNT 是否归零和错误位。只有这三者同时满足才返回 True。新手最容易犯的错就是只查 BUSY结果配置错了也没发现。4.2 用测试代码验证搬运结果验证 DMA 搬运是否可靠不能只看一次结果。我建议至少做三类测试全 0、全 1 数据验证搬运本身不会破坏数据。递增序列验证地址递增是否正确如果 INCR_WRITE 或 INCR_READ 没配置好递增序列会是乱序的。8KB 以上的大块搬运验证数据量大时会不会卡总线或被中断打断。下面这段代码可以快速做递增序列测试import random def test_dma(size8192, channel0): random.seed(42) src bytearray(size) dst bytearray(size) for i in range(size): src[i] random.getrandbits(8) ok dma_mem2mem(dst, src, size, channelchannel) print(ok:, ok) print(dst src:, dst src) return ok and (dst src) print(test_dma())随机数填充的好处是能暴露出地址错位、长度截断这类问题。固定递增序列虽然直观但很多错位情况下递增序列也会看起来对随机数据更能校验底层逻辑。4.3 测速DMA 到底快了多少测速也是运维这类功能的刚需。用time.ticks_us()测一次大块搬运的耗时是最直接的确认方式。我在同一块 Pico 上分别测过三种方式搬运 64KB 数据搬运方式实测耗时约结论Python 逐字节循环300 ~ 500 ms基本不可用MicroPython 切片dst[:] src[:]1 ~ 3 ms日常可用DMA 32bit 模式搬运0.5 ~ 1.5 ms快且不占执行流DMA 的优势在 MicroPython 场景下非常明显尤其是和逐字节循环对比提升能达到几百倍。而且 DMA 模式的耗时主要在等待 BUSY 清 0这个等待过程是硬件在搬不是 CPU 在忙。测速代码很简单import time size 65536 src bytearray(size) dst bytearray(size) src[:] b\xAA * size t0 time.ticks_us() ok dma_mem2mem(dst, src, size) t1 time.ticks_us() dt time.ticks_diff(t1, t0) print(ok:, ok) print(time us:, dt) print(speed MB/s:, size / dt) print(speed Mbps:, size * 8 / dt)注意这里读到的速度包含了轮询和函数调用的 Python 开销。如果只是看纯 DMA 传输能力实际的 AHB 总线搬运会比这个结果更快。但对我们日常使用来说包含 Python 开销的数字才是真实可感知的性能。4.4 用 viper 装饰器优化轮询如果你对性能比较敏感还能用 MicroPython 的micropython.viper装饰器把轮询循环编译成本地代码省掉 Python 解释器逐行检查的开销。核心思路是利用ptr32类型直接按指针访问寄存器地址micropython.viper def wait_dma_done(reg_addr: uint) - bool: p ptr32(reg_addr) deadline 1000000 while (p[0] 0x01000000): deadline - 1 if deadline 0: return False return True调用时传入通道对应 CTRL_TRIG 的地址比如通道 0 就是0x5000000C。这个函数在轮询大块数据时能省出不少时间实测大约能让整体测速结果提升 20%~30%。不过它依赖底层编译能力如果固件没开 viper这个装饰器会被忽略或报错使用时要注意兼容性。5. 我实际踩过的坑对齐、轮询卡死与总线冲突5.1 地址没对齐结果数据全错我第一次写 DMA mem2mem 时犯了个经典错误直接从bytearray(100)里取了个中间偏移的memoryview当目标地址然后用了 word 模式。结果搬出来的数据前面 24 个字节全错后面却又是对的。排查了很久才发现问题出在memoryview偏移后的地址不是 4 字节对齐的。这个坑的教训是DMA 的 32bit 模式对地址对齐要求非常严格必须源地址 4 字节对齐、目的地址 4 字节对齐、字节数也是 4 的倍数。三者缺一不可。而 bytearray 本身的 data 指针通常是对齐的但通过memoryview(buf)[1:]这种切片拿到的地址就可能不对齐。为此我在工具函数里加了对齐检测不对齐就自动退回 byte 模式。实际项目中如果你能控制缓冲区创建建议统一使用bytearray(roundup(len, 4))的写法把缓冲区大小定成 4 的倍数这样大部分场景都能走 word 模式。5.2 轮询直接卡死是怎么回事另一个让我头疼的问题是程序运行到轮询 BUSY 的地方直接卡死了。原因是我的 CTRL_TRIG 配置里把 DREQ 字段设成了某个外设的编号比如串口发送请求。内存到内存场景下没有外设来触发请求DMA 通道就永远等在那里BUSY 永远为 1轮询自然出不来。所以再强调一次内存到内存搬运时 DREQ 必须保持 0也就是 always 模式。如果你从外设 DMA 例程里复制了一段配置代码一定要检查有没有把 DREQ 设置成具体的请求源。另外写 CTRL_TRIG 的代码本身如果有 Python 语法错误会导致 DMA 根本没启动BUSY 为 0但 TRANS_COUNT 又不会归零。我的代码里检查 TRANS_COUNT 的目的就是这个它能兜底识别压根没启动的情况。5.3 大 buffer 分配失败不是 DMA 的锅还有一次我在测 128KB 搬运时bytearray(131072)直接报MemoryError。一开始以为是 DMA 或寄存器问题后来用gc.collect()手动整理堆空间就好了。MicroPython 的堆是有限且不自动回收所有碎片的创建超大缓冲区前先做一次gc.collect()能把之前的临时对象清掉。如果板上 RAM 确实吃紧还可以考虑在外置 PSRAM 或文件系统缓存上做搬运但那样要走 QMI 接口不是本篇范围。就普通 Pico 来说264KB 内存里留出 64KB 做测试问题不大128KB 就要看你的固件配置了。5.4 目标地址写到外设寄存器区域要特别小心内存到内存 DMA 不只可以搬 RAM还可以搬外设寄存器。比如把一段数据直接 DMA 到 GPIO 的 OUTPUT 寄存器理论上可以非常快地生成并行数据波形。但这个操作风险极高万一地址写错可能触发不可预期的硬件行为甚至让系统挂死。我在项目里只建议在绝对清楚外设寄存器语义时才这么做而且搬完之后立即读回状态确认。新手阶段先把 DMA 限制在 RAM 到 RAM跑熟了再碰外设。5.5 高速搬运时的总线仲裁问题理论上一张总线上同时跑 12 个 DMA 通道加多个 CPU 核心访问会有总线仲裁。实际测试中单个 DMA 通道的 mem2mem 拷贝几乎不会把总线吃满所以冲突很少见。但如果你同时开了几个通道做并行搬运又在中断里操作寄存器可能会遇到偶发的 AHB 错误。遇到这种情况排查思路是检查 CTRL_TRIG 的AHB_ERROR位确认是不是源地址、目的地址落在了未映射的空间。另外DMA 传输的数据块不宜太小频繁触发反而会让总线 arbiter 不断切换性能不如一次大步长传输。5.6 MicroPython 里没有现成的 DMA 中断怎么办RP2040 硬件支持 DMA 中断每个通道完成传输后能触发 IRQ。但 MicroPython 固件并没有把 DMA 中断直接暴露成 Python 回调所以我不建议新手走中断路线。轮询已经足够应付绝大多数场景尤其是内存到内存这种瞬时性很强的操作轮询的延迟也就是几微秒到几十微秒。如果你的项目真的需要后台无缝搬运比如一边采集一边拷贝可以考虑下面两个方案一是用第二个核跑 MicroPython 线程一个核处理业务一个核只做 DMA 调度二是用汇编/viper 写一个极简轮询器把它跑在定时器回调里避免阻塞主线程。这两个方案都属于进阶玩法我放在最后一部分稍微展开讲。6. 进阶玩法链式传输与多通道配合6.1 Scatter/Gather 不是高端 SoC 的专利很多从 STM32 转过来的开发者以为 Scatter/Gather分散/聚集是很高端的功能。其实 RP2040 的 DMA 也支持它的实现方式是控制块链表每个控制块包含一组 READ_ADDR、WRITE_ADDR、TRANS_COUNT、CTRL_TRIGDMA 完成当前控制块后会自动根据 CTRL_TRIG 里的 CHAIN_TO 字段跳到下一个控制块继续执行。这对内存到内存场景太有用了。比如你要把三块不同位置的源数据拼接到一个连续的目标缓冲区里传统写法是三次memcpy用链式 DMA只需要准备三个控制块写第一个控制块的 CTRL_TRIG 触发启动剩下两个会自动接力完成。CPU 在整个过程中只需要配置一次省去了多次启动和等待的开销。6.2 控制块链表的极简代码下面这段代码演示了最简单的两段链式搬运。重点是控制块必须放在 RAM 中并且要保证搬运过程中不会被意外覆盖。import array from machine import mem32 from uctypes import addressof DMA_BASE 0x50000000 # 准备数据 src1 bytearray(bhello ) src2 bytearray(bworld!) dst bytearray(12) # 两个控制块每个控制块 4 个 word # [read_addr, write_addr, trans_count, ctrl_trig] cb array.array(I, [ addressof(src1), addressof(dst), 6, 0, addressof(src2), addressof(dst) 6, 6, 0, ]) # 控制块的 CTRL_TRIG 配置word 模式但传输是字节这里用 byte 模式更直观 # DATA_SIZE 默认 0byteINCR_READ1INCR_WRITE1 CTRL_BASE 0x00000019 # EN INCR_READ(0x08) INCR_WRITE(0x10) byte模式 cb[3] CTRL_BASE | (1 16) # 第一个控制块完成后跳转到控制块1 cb[7] CTRL_BASE # 第二个控制块不继续跳转 # 取控制块首地址 cb_addr addressof(cb) # 启动第一个控制块 mem32[DMA_BASE 0x00] cb_addr # 通道0 READ_ADDR 指向控制块 mem32[DMA_BASE 0x08] 1 # TRANS_COUNT 为 1 个控制块 mem32[DMA_BASE 0x0C] 0x00000019 | (1 11) # DREQ0, EN1 启动这个例子里我要解释一个关键点当 DMA 通道工作在控制块模式时READ_ADDR 指向的是控制块本身而不是源数据。DMA 会先去内存里读控制块再按控制块给出的源地址、目标地址和长度去搬运数据。第一个控制块执行完因为 CHAIN_TO 指向 1硬件自动加载第二个控制块继续执行。实际使用中控制块里的CHAIN_TO字段可以组成环形链表实现循环缓冲区的自动搬运这在音频处理里非常实用。你甚至可以让控制块指向自身形成自循环每次完成同一段搬运后自动重新开始。6.3 多通道并行搬运的思路如果你有大量独立的小块数据要搬运还可以把任务拆分到不同通道。RP2040 的 12 个 DMA 通道可以同时活动总线矩阵会对它们做仲裁。实测中两个通道同时跑 mem2mem总带宽会比单通道高不少但每个通道的速度会有所下降。适合的场景是多个传感器缓冲区各自有独立的搬运任务且数据量均匀。多通道的代码可以复用前面的dma_mem2mem给每个通道指定不同的源、目的和偏移即可。注意不要对同一个目标缓冲区同时发起两个通道的写操作否则会出现不可预期的交错写结果。如果确实有多路数据要拼到同一个目标区优先用链式传输而不是多通道并发。6.4 环形缓冲和 RING_SIZE 的想象空间除了链式传输RP2040 DMA 还支持环形寻址通过 RING_SIZE、RING_SEL 字段限制读地址或写地址在指定范围内回卷。比如把写地址限制在 0x1000 大小的环形缓冲区内DMA 写满后会回到起始地址继续写非常适合做数据采集的环形队列。不过这个功能在 MicroPython 里需要更谨慎地管理内存布局因为它要求环形区域边界按 2 的幂对齐。我的建议是等把基础 mem2mem、链式控制块都跑熟了再碰 RING 功能否则排查问题的难度会翻倍。6.5 什么时候该脱离纯 MicroPython 写 C 扩展如果你的 DMA 传输量极大、频率极高MicroPython 就算用 viper 优化轮询也仍然有一层寄存器读写和 Python 对象管理开销。我的经验是当单个数据帧超过 256KB、或者每秒要发起几千次传输时就该考虑把 DMA 调度逻辑用 C 扩展或者 PIO 程序实现让 MicroPython 只负责上层业务编排。RP2040 的官方 SDK 提供了hardware/dma库C 里配置一个 mem2mem 传输非常顺手。如果项目里已经用了 C 扩展还可以把 DMA 中断直接接到 FreeRTOS 的信号量或队列彻底摆脱轮询等待。纯 MicroPython 适合快速验证和中小流量场景这点要有清醒认识。最后补一个实用习惯文章写到这主体内容已经完整了。最后分享一个我在实际项目中养成的习惯我会把dma_mem2mem这个工具函数单独放到dma_utils.py里并且在里面预留一个性能开关。默认走 DMA但可以在测试时通过一个全局变量切回普通的dst[:] src[:]方便定位是 DMA 配置问题还是业务数据问题。另外所有通过 DMA 搬运的缓冲区我都会在创建时强制把长度向上取整到 4 的倍数这样大部分时候都能走 word 模式速度和可读性都兼顾。如果你也准备在 MicroPython 里大规模用 DMA建议一开始就把这些细节纳入规范后续排查问题会轻松很多。
返回列表