尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MicroPython玩转RP2040 DMA:寄存器级内存拷贝实战

MicroPython玩转RP2040 DMA:寄存器级内存拷贝实战 把 MicroPython 和 DMA 放在同一句话里很多人第一反应是怀疑MicroPython 不是靠解释器跑字节码的吗怎么可能碰 DMA 这种底层外设我一开始也这么想直到有个项目要把几百 KB 的音频数据从一块缓冲区搬到另一块CPU 在 while 循环里搬了几万次数据板子卡得连 I2C 扫描都明显变慢。后来在树莓派 Pico 上研究了一晚上发现 MicroPython 可以通过machine.mem32直接操作 RP2040 的 DMA 寄存器实现内存到内存拷贝速度比 Python 循环快几十倍而且代码并不复杂。这篇文章就把整个过程整理成保姆级教程从寄存器原理到实操代码、常见坑全走一遍。1. 内容整体设计与思路拆解1.1 DMA 到底解决什么问题DMA 的全称是 Direct Memory Access直接内存访问。你可以把它理解成一条独立的“数据传送带”CPU 只需要把“从哪搬、搬到哪、搬多少”告诉 DMA 控制器剩下的重复搬运工作由 DMA 硬件自动完成搬运过程中 CPU 基本不用管。内存到内存传输就是把数据从 SRAM 的一块区域搬到另一块区域。你可能想这种事情不是一行for循环就能做吗问题在于MicroPython 的for循环是解释执行的每搬一个字节解释器都要解析指令、查询对象、更新循环计数器开销是巨大的。DMA 是纯硬件搬运没有解释器开销一个 AHB 时钟周期就能搬一个 word两者差距非常明显。1.2 为什么在 MicroPython 里也能配置 DMA很多初学者误以为 MicroPython 是“玩具语言”只能点灯、读传感器。其实 MicroPython 运行在真实硬件上天然可以通过内存映射寄存器访问外设。RP2040 和普通 Cortex-M0 芯片一样没有 MMU所有外设寄存器都映射在固定的地址空间里。MicroPython 的machine.mem32允许你直接读写在某个绝对地址上的 32 位整数。DMA 控制器的寄存器也在内存映射区所以理论上可以用machine.mem32操作 DMA 控制器的所有寄存器。这个方案不依赖任何第三方库通用性极强只要固件是官方 MicroPython 就能跑。1.3 内存到内存 DMA 的适用场景内存到内存传输最典型的场景是音频处理。音频采集 DMA 会把 ADC 或 I2S 的数据写进缓冲区你需要在缓冲区充满之后把它搬到另一个处理缓冲区这时候用 DMA 搬运CPU 可以腾出来做滤波、压缩、显示刷新。另一个场景是图形刷新。如果你用 Pico 驱动一块小 LCD可能需要频繁把图像 buffer 从一个缓存拷贝到另一个缓存DMA 能显著减少刷新耗时。还有缓冲区清零后面我会单独讲一个由 DMA 执行的快速清零技巧。但是如果只是几十个字节的搬运DMA 反而不划算。配置寄存器本身就有几条语句MicroPython 里执行这几条语句也需要时间。数据量越大DMA 的优势越明显个人经验是单次搬运超过 256 字节DMA 带来的收益就很可观了。1.4 为什么自己写寄存器而不是找现成库MicroPython 的 rp2 移植目前没有提供开箱即用的 DMA 高层封装网上能搜到的第三方库大多依赖特定固件版本而且功能不完整。自己写寄存器虽然看起来“底层”但恰恰是这类硬件外设最稳定的使用方式。寄存器一旦配好只要 RP2040 的地址映射不变代码在很长一段时间内都能直接用。另外理解了寄存器配置之后你会对 DMA 的工作机制有更深的理解后面再去用 SPI DMA、PIO DMA 都是顺理成章的事。2. 核心细节解析RP2040 DMA 控制器的寄存器与配置原理2.1 外设基地址与通道布局RP2040 的 DMA 控制器基地址是0x50000000一共 12 个 DMA 通道每个通道占 0x40 字节的寄存器空间。也就是说通道 0 的寄存器在0x50000000通道 1 的寄存器在0x50000040通道 N 的寄存器在0x50000000 N * 0x40。通道内最关键的四个寄存器是寄存器偏移作用READ_ADDR0x00读地址DMA 从哪里读数据WRITE_ADDR0x04写地址DMA 把数据写到哪里TRANS_COUNT0x08传输计数还剩下多少数据没搬完CTRL0x0C控制寄存器配置宽度、递增、触发源等这里特别强调一下TRANS_COUNT是一个“递减型计数器”DMA 每完成一次传输它就自动减一。当你读取它发现是 0 的时候说明传输已经完成。这个特性在后面的等待逻辑里会用到。2.2 CTRL 控制寄存器逐位拆解CTRL 寄存器是 DMA 配置的核心。我在实验中最常用的位段如下位段位数取值含义ENbit 0通道使能置 1 后通道开始响应触发DATA_SIZEbit 2-3传输宽度0 表示 8 位1 表示 16 位2 表示 32 位INCR_READbit 4读地址是否递增1 表示每次传输后源地址 数据宽度INCR_WRITEbit 5写地址是否递增1 表示每次传输后目标地址 数据宽度DIRbit 11传输方向1 表示从内存读取0 表示从外设读取BSWAPbit 16字节交换内存到内存通常不用CHAIN_TObit 21-25链式 DMA指定当前通道完成后自动触发哪个通道TREQ_SELbit 26-30触发源选择决定 DMA 通道以什么节奏传输这里最容易搞混的是DIR位。RP2040 的 DMA 通道可以用在“外设到内存”和“内存到外设”两个方向上。对于内存到内存传输我们希望 DMA 从内存读数据所以DIR必须置 1。如果DIR为 0DMA 会以为你是从某个外设读数据行为可能会很诡异。2.3 TREQ_SEL 设为 0x3F 是内存到内存传输的关键TREQ_SEL 是 DMA 的“触发源选择”。外设 DMA 的场景里SPI 发送一个字节就会有 DREQ 信号请求 DMA 喂下一个数据所以 DMA 的传输节奏由外设决定。但内存到内存传输没有外设信号你必须给 DMA 一个“永远有请求”的状态让它以最快速度连续搬运。RP2040 手册里专门定义了一个触发源叫DREQ_FORCE值刚好是0x3F也就是 63。把TREQ_SEL设为0x3FDMA 就会变成无条件的连续传输模式。很多人在网上抄代码发现 DMA 不工作一个常见原因就是TREQ_SEL没有设置为0x3F。这个字段在 CTRL 寄存器的 bit 26 到 bit 30如果写 0DMA 可能一直在等待外设触发TRANS_COUNT永远减不到 0。2.4 对齐、数据宽度和地址递增策略RP2040 的 DMA 在 32 位模式下要求源地址和目标地址都必须 4 字节对齐同时传输字节数也必须是 4 的倍数。如果不满足对齐条件DMA 可能直接触发总线错误甚至导致系统 HardFault。8 位模式没有对齐要求任意地址、任意长度都能搬。16 位模式要求 2 字节对齐。所以你的取舍是追求最快速度就用 32 位模式但要保证 buffer 对齐追求通用性就用 8 位模式牺牲一部分性能。地址递增策略也有讲究。如果INCR_READ 1且INCR_WRITE 1DMA 会从源地址一路递增读到目标地址一路递增写这是标准的“内存拷贝”模式。如果INCR_WRITE 0DMA 会把同一个值反复写到同一块地址的连续区域这个特性可以用来快速填充和清零后面第 5 节会详细讲。3. 实操过程MicroPython 下的完整实现3.1 准备工作与环境搭建实操前需要准备一块树莓派 Pico 或 Pico W一根 USB 数据线刷好官方 MicroPython 固件的开发环境Thonny 或 mpremote 都行RP2040 Datasheet遇到不明白的位段可以查固件版本建议至少 1.19 以上我实测用的 1.23 和 1.24 都正常。machine.mem32和uctypes都是标准模块不需要额外安装。连接开发板后先验证machine.mem32能否正常访问 DMA 寄存器import machine DMA_BASE 0x50000000 # 读通道0的CTRL寄存器初始值应该是0 print(hex(machine.mem32[DMA_BASE 0x0C]))如果输出了0x0说明内存映射访问正常可以继续。3.2 获取 bytearray 真实内存地址的正确姿势MicroPython 中bytearray是一个 Py 对象你需要的地址是它内部数据区在 SRAM 中的地址。id()返回的是对象头部的地址不是数据区地址所以不能用。正确做法是用uctypes.addressof()import uctypes buf bytearray(1024) buf_addr uctypes.addressof(buf) print(hex(buf_addr), buf_addr % 4)addressof返回的是 bytearray 数据区首地址。只要你的 buffer 变量一直存活这个地址就是有效的DMA 可以放心用。注意MicroPython 的 GC 不会移动对象所以即使发生垃圾回收这个地址也不会变但前提是引用不能丢否则对象被回收地址就悬空了。3.3 基于寄存器配置的内存到内存 DMA 完整代码下面是我整理的两个函数。第一个是 32 位模式速度最快但对齐条件严格第二个是 8 位模式无对齐要求适合通用搬运。from machine import mem32 from uctypes import addressof DMA_BASE 0x50000000 def dma_copy_word(dst, src, nbytes, ch0): 32位模式搬运要求dst/src地址4字节对齐nbytes是4的倍数 base DMA_BASE ch * 0x40 if nbytes % 4 ! 0: raise ValueError(nbytes must be multiple of 4) words nbytes // 4 # 先配置好地址和计数再写入CTRL启动 mem32[base 0x00] addressof(src) # READ_ADDR mem32[base 0x04] addressof(dst) # WRITE_ADDR mem32[base 0x08] words # TRANS_COUNT按word计数 # EN1, DATA_SIZE2(32位), INCR_READ1, INCR_WRITE1, DIR1, TREQ_SEL0x3F ctrl (1 0) | (2 2) | (1 4) | (1 5) | (1 11) | (0x3F 26) mem32[base 0x0C] ctrl # 等待传输完成TRANS_COUNT自然会减到0 while mem32[base 0x08] ! 0: pass def dma_copy_bytes(dst, src, nbytes, ch0): 8位模式搬运地址任意、长度任意最通用 base DMA_BASE ch * 0x40 mem32[base 0x00] addressof(src) # READ_ADDR mem32[base 0x04] addressof(dst) # WRITE_ADDR mem32[base 0x08] nbytes # TRANS_COUNT按字节计数 # EN1, DATA_SIZE0(8位), INCR_READ1, INCR_WRITE1, DIR1, TREQ_SEL0x3F ctrl (1 0) | (0 2) | (1 4) | (1 5) | (1 11) | (0x3F 26) mem32[base 0x0C] ctrl while mem32[base 0x08] ! 0: pass我先解释一下ctrl这行是怎么算出来的(1 0)EN 位置 1使能 DMA 通道(2 2)DATA_SIZE 设置为 2也就是 32 位模式(1 4)INCR_READ 置 1读地址递增(1 5)INCR_WRITE 置 1写地址递增(1 11)DIR 置 1表示本次传输从内存读取数据(0x3F 26)TREQ_SEL 设置为 0x3F使用 DREQ_FORCE 强制连续传输配置顺序也有讲究READ_ADDR、WRITE_ADDR、TRANS_COUNT 都必须在 CTRL 的 EN 位写 1 之前完成配置否则 DMA 可能在地址还没有准备好的时候就开始搬数据。最稳妥的方式就是把 CTRL 放在最后一步写。3.4 性能验证与结果分析下面跑一个简单的对比测试数据量 4KBimport time src bytearray(4096) dst bytearray(4096) # 给源数据填充规律值方便验证 for i in range(4096): src[i] i 0xFF t0 time.ticks_us() dma_copy_word(dst, src, 4096, ch0) t_dma time.ticks_diff(time.ticks_us(), t0) print(DMA copy: , t_dma, us) print(match:, src dst) # Python纯循环对照 t0 time.ticks_us() for i in range(4096): dst[i] src[i] t_py time.ticks_diff(time.ticks_us(), t0) print(Python copy:, t_py, us) print(speedup:, t_py / t_dma)我在 Pico 上实测4KB 数据用 DMA 也就几十微秒而纯 Python 循环通常要几毫秒差距在三到五倍以上。而且数据量越大这个差距越明显。注意dma_copy_word要求 src 和 dst 的地址是 4 字节对齐的如果遇到对齐错误改成dma_copy_bytes就不会出问题。4. 常见问题与排查实录4.1 数据拷贝完了但内容不对这种现象最常见的原因是用了id(buffer)去取地址。id()返回的是 PyObject 头部的地址不是数据区域DMA 从头部地址读出来的数据当然不是你想要的。解决办法统一用uctypes.addressof()。另一个原因是字节序。RP2040 默认小端模式如果你在 32 位模式下搬运数据内存布局是低字节在前当你把源和目标都看成统一数组时结果应该是对的。但如果你在其他架构上生成的数据文件再放到 Pico 上直接搬运就可能出现大小端问题。这种情况可以检查一下 CTRL 的 BSWAP 位。4.2 DMA 传输一直不结束如果你发现while mem32[base 0x08] ! 0卡死了先检查 CTRL 的 TREQ_SEL 是否写成了 0x3F。没有这个强制触发源DMA 可能一直在等外设 DREQTRANS_COUNT 不减到 0。另外也可能是通道被占用。MicroPython 的某些底层库比如 audio、PIO、I2S 驱动可能已经占用了某几个 DMA 通道。如果你使用通道 0 恰好在系统固件里被占用就会冲突。写代码前可以先检查一下通道是否空闲def is_dma_channel_free(ch): return (machine.mem32[DMA_BASE ch * 0x40 0x0C] 1) 0如果返回 False说明通道被占用换一个 ch 重新试。4.3 系统 HardFault 或死机HardFault 绝大多数情况是地址没对齐或地址非法。32 位模式要求源地址、目标地址都是 4 的倍数如果bytearray恰好不是按 4 对齐分配的就会出问题。遇到死机的时候可以先换成 8 位模式验证数据通路是否正常再考虑对齐问题。另外DMA 访问不能超过实际内存边界。RP2040 的内置 SRAM 是 264KB地址范围是0x20000000到0x20041FFF如果源 buffer 或目标 buffer 越界DMA 可能访问到不存在的地址系统不死才怪。4.4 DMA 没有比 Python 循环快多少如果你发现 DMA 带来的提速不明显先看数据量。几十字节的拷贝根本发挥不出 DMA 的优势至少几百字节往上再考虑。还有一个容易被忽略的问题MicroPython 的函数调用、寄存器配置本身也有执行开销。如果你在 DMA 的等待循环里加了额外的打印操作或者把配置写在一个低效的循环里整体耗时会被 Python 端拉高。最好的做法是设计一个长缓存一次 DMA 搬完而不是小段多次搬运。4.5 速查表现象可能原因解决办法数据是全零或乱码用id()取地址出错改用uctypes.addressof()TRANS_COUNT 卡着不动TREQ_SEL 未设 0x3FCTRL 写入0x3F 26HardFault / 死机地址未 4 字节对齐换 8 位模式或手动对齐DMA 通道冲突固件库占用通道换空闲通道提速不明显数据量太小至少 256 字节以上再 DMA5. 扩展玩法DMA 还能这么用5.1 快速清零与填充固定地址中间值DMA 的 INCR_READ 和 INCR_WRITE 是分开控制的这给我们留了一个很有意思的玩法。如果 Read 地址不递增Write 地址递增DMA 就会把同一个值反复写到一块连续区域。利用这个特性可以实现快速清零def dma_zero_fill(dst, value, nbytes, ch0): base DMA_BASE ch * 0x40 if nbytes % 4 ! 0: raise ValueError(nbytes must be multiple of 4) # 准备一个固定的word值的buffer val_buf bytearray(4) val_buf[0] value 0xFF val_buf[1] (value 8) 0xFF val_buf[2] (value 16) 0xFF val_buf[3] (value 24) 0xFF mem32[base 0x00] addressof(val_buf) # 源地址固定 mem32[base 0x04] addressof(dst) # 目标地址递增 mem32[base 0x08] nbytes // 4 # INCR_READ0, INCR_WRITE1, DATA_SIZE2, DIR1, TREQ_SEL0x3F ctrl (1 0) | (2 2) | (0 4) | (1 5) | (1 11) | (0x3F 26) mem32[base 0x0C] ctrl while mem32[base 0x08] ! 0: pass这个技巧在音频静音、图像清屏、帧缓冲初始化里特别实用。一次性 DMA 搬运几百个 word 比for循环快得多。5.2 双缓冲与环形缓冲DMA 通道自带的 RING_SIZE 和 RING_SEL 位可以实现环形缓冲DMA 在地址到达边界时自动回绕。这个在大批量连续数据采集场景下非常有用比如 ADC 持续采样到一个小缓冲区缓冲区满了自动从头覆盖CPU 只需要在间隙读取有效数据。内存到内存传输也可以用到环形缓冲比如音频播放循环播放一段短采样。把源地址配成环形回绕DMA 会自动循环播放采样数据CPU 几乎零负担。5.3 多通道并行搬运RP2040 有 12 个 DMA 通道实际项目中可以同时启用多个通道搬运多路数据。比如一个通道负责从 ADC 采集缓冲区搬到处理缓冲区另一个通道负责从处理结果搬到显示缓冲区两条流水线互不干扰。不过要注意多个通道共享总线和 DMA 控制器如果同时发起大量传输会抢占带宽。是否需要分配高优先级取决于你的实时性需求。CTRL 的 HIGH_PRIORITY 位可以给关键通道更高的总线优先级。5.4 配合 PIO 和 SPI 外设DMA 的真正价值不止内存到内存它还可以配合外设做“零 CPU 参与”的数据流传输。PIO 是 RP2040 的特色把 DMA 通道绑到 PIO 的 DREQ 上可以实现连续输出波形、读取特定时序的数据CPU 只需要配置一次剩下的数据流由 DMA 自动搬运。SPI 屏幕刷新也可以这样从显存 buffer 直接 DMA 到 SPI TX刷新过程完全不用 CPU 盯着。我实测用这种方案推 320x240 的小屏刷新率比逐像素写寄存器的方式高出一大截。6. 写在最后一点个人提醒折腾 RP2040 DMA 的时候我有两个很深的体会。第一不要迷信“底层就一定难”。DMA 无非就是“源地址、目的地址、数量、控制”四个要素对着手册把每个位段搞清楚比在论坛上零散抄代码靠谱得多。抄来的代码一旦出错你都不知道该怎么改。第二MicroPython 写 DMA 并不是“离经叛道”。很多场景下我们既要 Python 的开发效率又想要接近底层的性能直接操作寄存器就是这座桥。你完全可以在 MicroPython 里把 DMA 配置封装成通用函数然后把精力放在应用逻辑上。最后分享一个小技巧如果你不确定当前固件的 DMA 通道是否被其它库占用可以在初始化前执行一次全通道扫描打印所有通道的 EN 状态。这样既能避开冲突也能帮你快速定位“为什么 DMA 不响应”。希望这篇教程能帮你少踩几个坑顺利把 RP2040 的 DMA 用起来。
返回列表