资讯动态

RP2040 DMA控制器软件控制全解析:寄存器、C SDK与MicroPython实战

发布时间:2026/9/7 22:09:02 来源:尧图企业网站定制
有一次调PIO驱动WS2812灯带状态机被CPU喂数据喂得手忙脚乱一个中断进来没及时处理整条灯带颜色就闪了一下。后来把数据搬运动作交给rp2.DMA控制器代码少了三分之一时序问题也消失了。这篇文章专门聊RP2040上DMA的软件控制也就是不把库函数当黑盒把API和配置过程彻底讲清楚。文章适合两类人一类是刚开始用树莓派Pico、总觉得hardware/dma.h是个黑盒的开发者另一类是已经在用DMA但偶尔被坑得莫名其妙、想系统排查一遍配置问题的朋友。我尽量把每个关键选择背后的为什么也讲明白而不是只给一堆能跑的代码。1. 为什么DMA是RP2040上最值回票价的控制器1.1 没有DMA时CPU到底在忙什么想象一个很常见的场景你想通过UART把缓冲区里4096字节的数据发出去。用最粗暴的轮询写法每个字节都要做“把数据写进UART发送寄存器→等TX FIFO出现空位→再写下一个字节”的循环。在115200波特率下发完这4096字节差不多要355毫秒而这355毫秒里CPU啥也干不了只能死等。你可能觉得“那我用中断不就行了”但中断方案也有代价。每来一个字节都触发一次UART中断CPU要保存现场、进ISR、写一个字节、恢复现场光上下文切换的开销就吃掉了不少性能。如果还要同时处理ADC采样、显示刷新、按键扫描中断优先级稍微没处理好就会出现某个外设被饿死的情况。我最早碰到的痛点就是PIO状态机。WS2812灯带的时序是ns级别的PIO状态机负责输出波形但颜色数据得有人喂给它。如果CPU中途被别的事情打断PIO的TX FIFO一旦空波形就断了灯带立刻闪一下。这种时序敏感场景靠CPU一条一条喂数据本质上就是在刀尖上跳舞。1.2 DMA的价值把“搬砖”和“调度”分离DMADirect Memory Access做的事很简单在不需要CPU介入的情况下把数据从一个地址搬到另一个地址。你可以把它理解成一个只会搬砖的工人老板CPU只需要告诉它“从哪搬、搬到哪、搬多少、什么时候搬”然后就可以去开会了。搬完之后工人会按一下门铃触发中断告诉老板“搬完了”。这种“搬砖与调度分离”的思路在RP2040这种双核MCU上特别有价值。两个核各有各的活如果谁都被数据搬运这种机械劳动缠住那多核的优势就发挥不出来。DMA把高频、机械、时序敏感的数据搬运动作接管之后CPU就能专心干逻辑判断、协议解析、界面响应这些真正需要智能的活。1.3 适合用DMA的场景清单不是所有搬运都需要DMA。要不要上DMA我一般看三个指标数据量大不大、频率高不高、时序要求严不严。下面是我认为RP2040上最值得用DMA的几个场景场景数据方向为什么用DMA内存到内存大块拷贝内存→内存释放CPU拷贝期间可做其他事UART/SPI数据收发内存↔外设避免高频中断配合FIFO自然流控ADC连续采样ADC→内存采样率固定DMA周期搬运不漏点PIO数据流灯带、步进电机内存→PIO FIFO时序敏感CPU不可靠定时器触发GPIO采样GPIO→内存DMA按固定节拍自动采集如果你只是偶尔发一两个字节、搬几十个字节DMA反而有点杀鸡用牛刀——配置开销都比搬运本身还大。判断标准很简单如果CPU做这件事超过1毫秒或者中断频率超过每秒几千次就值得考虑DMA。2. 摸清家底RP2040 DMA控制器究竟怎么工作2.1 16个通道和“四件套”寄存器RP2040的DMA控制器有16个独立通道channel 0到15每个通道都可以独立配置、独立搬运、独立触发中断。通道之间互不干扰仲裁器负责决定谁先占用总线。每个通道本质上是四组寄存器在驱动READ_ADDR源地址DMA从这里读数据。WRITE_ADDR目的地址DMA往这里写数据。TRANS_COUNT剩余传输次数每搬一次减1减到0传输结束。CTRL_TRIG控制寄存器配置数据宽度、地址模式、触发源、链式目标等写入这个寄存器还会触发通道启动。这组寄存器每个通道偏移0x40字节。通道0的四个关键寄存器地址分别是0x50000000、0x50000004、0x50000008、0x5000000C。后面讲MicroPython手写DMA控制时就是直接操作这些地址。特别注意TRANS_COUNT存的是“传输次数”不是“字节数”。如果你配置的是32位word传输那TRANS_COUNT1024代表搬运1024个字实际字节数是4096。这个单位和数据宽度绑定的设定是后面好多坑的根源。2.2 DREQ决定DMA什么时候“动一下”DMA不是一股脑从头搬到尾尤其在跟外设配合时它必须等待外设“准备好”才搬。这个等待信号就是DREQData Request数据请求。每个DREQ编号对应一个外设请求源。下面是我实际用得最多的几个DREQ编号请求源触发条件0-3PIO0 TX0-TX3PIO状态机TX FIFO可写4-7PIO0 RX0-RX3PIO状态机RX FIFO可读8-15PIO1 TX/RX同上PIO1状态机16UART0 TXUART0发送FIFO可接收数据17UART0 RXUART0接收FIFO有数据20SPI0 TXSPI0发送FIFO可写24I2C0 TXI2C0可发送36-39ADC0-ADC3ADC FIFO有样本40-43TIMER0-TIMER3定时器周期性请求630x3FDREQ_FORCE不等待任何外设持续搬运第63号请求非常关键。当DREQ配置成0x3F时DMA不依赖任何外设信号启动后就连续不断地搬直到TRANS_COUNT减到0。内存到内存的拷贝用的就是它。外设配合时的价值在于天然流控UART TX FIFO满时DREQ信号不会触发DMA自动暂停FIFO有空间了DMA继续搬下一个字节。CPU完全不用管发送节奏。2.3 地址增长模式与环形缓冲每个通道可以独立设置读地址和写地址是固定还是递增。典型组合内存→外设读地址递增写地址固定外设寄存器地址不变。外设→内存读地址固定写地址递增。内存→内存读写都递增。这里有个和STM32不太一样的点RP2040的DMA地址模式只有固定和递增两种不支持递减。很多从STM32转过来的朋友习惯用递减模式做反向拷贝在RP2040上行不通得在软件层面提前规划好源目地址。环形缓冲Ring是另一个特色功能。配置RING_SIZE和RING_SEL之后某一边的地址会在一个固定大小的范围内循环。比如要做一个ADC采样环形缓冲区可以让写地址在8字节范围内循环满了自动绕回开头非常适合做流式数据采集。RING_SIZE的单位是“2的幂”RING_SIZE3代表8字节范围RING_SIZE4代表16字节范围这个细节容易搞错。2.4 仲裁、优先级和中断状态16个通道共用总线同一时刻只有一个能访问内存。仲裁器会根据每个通道的HIGH_PRIORITY位决定优先服务谁。如果多个通道都配置了高优先级仲裁器会轮询调度如果高优先级通道数据量特别大低优先级通道可能长时间抢不到总线段这就是所谓的“饥饿”现象。中断状态和中断使能都存放32位寄存器里每一位对应一个通道。读取INTR可以得到原始中断状态读INTE0/INTF0/INTS0可以查看中断使能和中断状态。这个位图式设计让批量管理通道变得非常方便一次读一个32位寄存器就知道谁完成了。3. 首选武器C SDK的DMA API逐段拆解3.1 通道管理从claim开始Pico SDK推荐用dma_channel_claim()申请通道而不是直接写死用第几个通道。这个函数会遍历16个通道返回第一个空闲的并把它标记为“已占用”。用完用dma_channel_release(ch)释放这样别人也能复用。我见过不少示例代码直接#define DMA_CH 0然后所有程序都用通道0。如果你只有一个DMA任务这没问题一旦工程复杂了多个模块抢同一个通道就会出现“A模块配置被B模块覆盖”的灵异现象。用claim/release机制每个模块都能动态拿到空闲通道冲突概率大大降低。#include pico/stdlib.h #include hardware/dma.h int dma_ch dma_channel_claim(); // 用完之后 dma_channel_release(dma_ch);多核环境或RTOS环境下claim函数内部有临界区保护可以放心使用。3.2 配置结构体的核心字段拿到通道号后通过dma_channel_get_default_config(ch)拿到一份默认配置然后用channel_config_set_*系列函数逐项修改。这种“读默认值→改几个字段→写回”的设计比手动填一个巨大的结构体要安全得多因为你能看到到底改了什么。dma_channel_config cfg dma_channel_get_default_config(dma_ch); channel_config_set_transfer_data_size(cfg, DMA_SIZE_8); channel_config_set_read_increment(cfg, true); channel_config_set_write_increment(cfg, false); channel_config_set_dreq(cfg, DREQ_UART0_TX);几个关键函数的使用要点channel_config_set_transfer_data_size设置传输宽度DMA_SIZE_8/16/32分别对应字节、半字、字。外设寄存器有些人习惯32位访问但UART发送其实是8位就够了。channel_config_set_read_increment/set_write_increment控制地址是否递增。channel_config_set_dreq设置触发源。默认配置里这个值是DREQ_FORCE如果你忘记设置DMA会以最快速度连续搬运在某些外设场景下会直接搬空内存这是默认配置最容易被忽略的一个点。channel_config_set_chain_to设置链式传输目标通道后面进阶章节单独讲。channel_config_set_ring配置环形缓冲参数是“是否环形”和“环形大小的log2值”。channel_config_set_bswap开启字节交换。只对32位传输有效会把4字节的序完全倒过来不是简单的半字交换用之前想清楚。配置完成后dma_channel_configure(ch, cfg, write_addr, read_addr, transfer_count)会把写入地址、源地址、传输次数一次性写进寄存器。注意这个函数本身不会启动DMA真正启动要调用dma_channel_start(ch)。3.3 三个可以直接抄的例程例程一内存到内存大块拷贝#include pico/stdlib.h #include hardware/dma.h uint8_t src_buf[1024] __attribute__((aligned(4))); uint8_t dst_buf[1024] __attribute__((aligned(4))); void mem_copy_dma() { int ch dma_channel_claim(); dma_channel_config cfg dma_channel_get_default_config(ch); channel_config_set_transfer_data_size(cfg, DMA_SIZE_8); channel_config_set_read_increment(cfg, true); channel_config_set_write_increment(cfg, true); // 不设置dreq保持默认DREQ_FORCE连续搬运 dma_channel_configure(ch, cfg, dst_buf, src_buf, sizeof(src_buf)); dma_channel_start(ch); dma_channel_wait_for_finish(ch); dma_channel_release(ch); }注意源和目的我都加了aligned(4)。虽然传输宽度是8位不强制要求4字节对齐但RP2040的DMA走AHB总线如果将来改成DMA_SIZE_32对齐问题就会暴露出来先养成分页对齐的习惯没坏处。例程二用UART把缓冲区发出去#include hardware/uart.h #include hardware/dma.h void uart_send_dma(const char *buf, size_t len) { int ch dma_channel_claim(); dma_channel_config cfg dma_channel_get_default_config(ch); channel_config_set_transfer_data_size(cfg, DMA_SIZE_8); channel_config_set_read_increment(cfg, true); channel_config_set_write_increment(cfg, false); channel_config_set_dreq(cfg, DREQ_UART0_TX); dma_channel_configure(ch, cfg, uart0_hw-dr, buf, len); dma_channel_start(ch); }这里把写地址固定为uart0_hw-dr也就是UART0数据寄存器。DMA会等到UART的TX FIFO有空间才写入完美的硬件流控。如果UART的FIFO没使能DREQ信号可能一直不满足DMA就会“卡住”等在那里这一点在第5章避坑部分详细说。例程三ADC连续采样到缓冲区#include hardware/adc.h #include hardware/dma.h uint16_t samples[1024]; void adc_sampling_dma() { adc_init(); adc_gpio_init(26); adc_select_input(0); adc_fifo_setup(true, false, 1, false, false); adc_run(true); int ch dma_channel_claim(); dma_channel_config cfg dma_channel_get_default_config(ch); channel_config_set_transfer_data_size(cfg, DMA_SIZE_16); channel_config_set_read_increment(cfg, false); channel_config_set_write_increment(cfg, true); channel_config_set_dreq(cfg, DREQ_ADC); dma_channel_configure(ch, cfg, samples, adc_hw-fifo, 1024); dma_channel_start(ch); }ADC每出一个16位采样值DMA就搬一个到samples数组完全不需要CPU干预。等搬满1024个样本后DMA传输完成中断会通知你。这里DREQ_ADC配置是核心没有它DMA不知道ADC FIFO何时有数据。3.4 中断的正确注册方式DMA完成中断分DMA_IRQ_0和DMA_IRQ_1两组每组都是32位状态位图。用dma_channel_set_irq0_enabled(ch, true)把某个通道的中断使能到IRQ0组然后在DMA_IRQ_0的中断服务函数里查状态位。static int dma_ch; void dma_isr(void) { if (dma_hw-ints (1u dma_ch)) { dma_hw-ints 1u dma_ch; // 写1清零 // 在这里处理搬运完成后的业务逻辑 } } void setup_dma_irq() { dma_ch dma_channel_claim(); dma_channel_set_irq0_enabled(dma_ch, true); irq_set_exclusive_handler(DMA_IRQ_0, dma_isr); irq_set_enabled(DMA_IRQ_0, true); }中断服务函数里第一件事就是清中断标志否则ISR会反复触发。很多第一次用DMA中断的朋友只记得使能中断忘了写dma_hw-ints ...清标志结果就是系统一直卡在中断里出不来。4. MicroPython侧没有rp2.DMA类那就自己封一个4.1 先澄清一个现实MicroPython的rp2模块里有StateMachine、PIO这些类但你翻遍官方固件也找不到rp2.DMA这个现成的类。标题里提的“rp2.DMA”在实际操作中指的是“在RP2040上用软件方式直接操纵DMA控制器寄存器”也就是把C SDK里hardware/dma.h的功能用MicroPython的machine.mem32手动封装一遍。为什么不直接官方支持大概是MicroPython的定位是“快速原型验证”官方觉得DMA属于偏底层偏硬核的功能普通用户用不上。但实际做项目时ADC采集、PIO数据流这些场景确实需要DMA所以自己封装一个类非常值得。4.2 用machine.mem32直接操作寄存器先回顾一下关键寄存器地址DMA基址是0x50000000通道0的READ_ADDR/WRITE_ADDR/TRANS_COUNT/CTRL_TRIG偏移量分别是0x000、0x004、0x008、0x00C每往后一个通道地址加0x40。下面是一个最精简的DMA控制类from machine import mem32 DMA_BASE 0x50000000 DREQ_FORCE 0x3F # 无外部请求连续传输 class RP2040DMA: def __init__(self, channel): self.base DMA_BASE channel * 0x40 self.channel channel def config(self, src_addr, dst_addr, count, data_size0, incr_read1, incr_write1): # data_size: 0字节 1半字 2字 mem32[self.base 0x000] src_addr # READ_ADDR mem32[self.base 0x004] dst_addr # WRITE_ADDR mem32[self.base 0x008] count # TRANS_COUNT ctrl 0 ctrl | (DREQ_FORCE 4) (0x3F 4) # DREQ段 ctrl | (data_size 12) # 数据宽度 if incr_read: ctrl | (1 14) # 读地址递增 if incr_write: ctrl | (1 15) # 写地址递增 ctrl | (1 10) # EN使能并启动 mem32[self.base 0x00C] ctrl # CTRL_TRIG def wait_done(self): # BUSY位在bit28传输完成时自动清零 while mem32[self.base 0x00C] (1 28): pass这个类的核心就是最后往CTRL_TRIG寄存器写入的那一下。数据宽度、地址递增模式、DREQ触发源、使能位全部打包在一个32位整数里一次写入同时完成“配置”和“启动”。4.3 调用示例与地址获取MicroPython里bytearray对象不能直接拿来当源地址需要先取到它在内存中的数据区地址。这里用uctypes.addressof比较直接import uctypes src bytearray(1024) dst bytearray(1024) # 给源数据随便填点东西 for i in range(256): src[i] i dma RP2040DMA(0) dma.config( src_addructypes.addressof(src), dst_addructypes.addressof(dst), count1024, data_size0, # 字节传输 incr_read1, incr_write1 ) dma.wait_done() # 验证 print(dst[0], dst[1], dst[255])uctypes.addressof在不同固件版本下的表现略有差异有的版本返回的是对象数据区地址有的版本需要加偏移建议先在一个空bytearray上试一下再用实际地址验证一下搬过去的数据对不对。4.4 这段代码的边界这种手写寄存器的方式胜在灵活、没有额外依赖但也要求你对寄存器位定义了如指掌。不同固件版本、不同SDK版本寄存器位定义理论上是一致的毕竟硬件是固定的但为了保险配置出问题的时候优先去翻一下你对应版本的头文件。前面代码里的DREQ_FORCE 4等我标注了偏移量实际以你的SDK头文件为准别当金科玉律。另外MicroPython有GC垃圾回收bytearray在堆上可能被移动。DMA传输期间尽量不要创建大对象、不要做可能触发GC的操作否则数组地址变了DMA还在往老地址写数据就乱了。稳妥做法是传输前用gc.disable()临时关掉GC传完再恢复。5. 配置避坑六个亲测过的坑与完整排查链路5.1 坑一DMA“假死”TRANS_COUNT一动不动现象是dma_channel_start之后数据一点没动TRANS_COUNT还是原值BUSY置1看起来像在传输实则卡死。排查链路先确认DREQ配置。DMA不是连续传的它在等外设的DREQ信号。如果DREQ对应的是UART TX而UART FIFO根本没使能DREQ信号永远不会来DMA就一直等。我用UART时踩过这个坑uart_init之后没调uart_set_fifo_enabled(uart0, true)结果DMA等了一辈子。看外设有没有真正启动。ADC场景如果忘了adc_run(true)或adc_fifo_setupFIFO里永远没有数据DREQ也不会拉高。临时把DREQ换成DREQ_FORCE试试。如果换成DREQ_FORCE立刻能跑通说明问题不在DMA本身而在外设的DREQ信号链路。解决思路先排除DMA本身再排查外设。不要一上来就怀疑地址配错了。5.2 坑二中断回调永远不来现象DMA传输明明完成了数据也对但就是进不了中断服务函数。排查链路先看dma_channel_is_busy(ch)如果返回false说明传输已经结束问题确定在中断链路。读dma_hw-intr看对应位有没有置位。如果置位说明DMA硬件侧中断状态没问题问题在NVIC或者IRQ使能。检查irq_set_exclusive_handler(DMA_IRQ_0, dma_isr)是否调用。很多人写完dma_channel_set_irq0_enabled就以为完事了忘了注册handler。检查irq_set_enabled(DMA_IRQ_0, true)。这个和上面那个不是一回事前者配回调函数后者才真正打开中断。查看ISR里有没有清中断标志。没清标志的话第一次中断触发后后续中断永远卡住。我遇到过一个很隐蔽的情况在一个实时系统里ISR处理完了正常数据但没有做“从ISR唤醒任务”的动作导致CPU以为没活干业务逻辑永远不执行。在外设中断驱动到任务唤醒之间这段链路要完整。5.3 坑三地址未对齐导致的随机数据错乱现象偶尔数据对偶尔错几个字节或者某些固定偏移位置的数据总是乱。原因RP2040的DMA走AHB总线32位传输要求源地址和目的地址都4字节对齐16位传输要求2字节对齐。如果没对齐轻则数据错乱重则硬件错误。DMA不像CPU那样会自动处理非对齐访问。排查时先确认DATA_SIZE和地址是否匹配。复制一个大数组没问题但如果你从数组第3个字节开始搬同时配置了DMA_SIZE_32那就等着出事吧。解决方法是传输宽度降到字节传输DMA_SIZE_8代价是速度稍慢。把缓冲区声明为__attribute__((aligned(4)))保证地址自然对齐。如果源地址和目的地址其中一个偏移了优先考虑调整数据布局而不是强行对齐。5.4 坑四第二次启动忘了重写寄存器现象第一次传输正常第二次调用同样的配置函数结果TRANS_COUNT直接是0或者地址指向了缓冲区末尾。原因DMA的TRANS_COUNT寄存器是递减的READ_ADDR和WRITE_ADDR是递增的传输完成后这些寄存器不会自动恢复到初始值。第二次启动前如果不重新写入DMA会按已经耗尽的值继续“搬”自然什么都搬不出来。解决每次启动前重新调用dma_channel_configure把三个地址/计数值再写一遍。如果想减少配置开销可以研究一下每个通道的AL2_CTRL、AL3_CTRL这类备用寄存器提前预置下一次传输的参数这就是双缓冲的硬件基础。5.5 坑五Ring缓冲尺寸配错现象环形缓冲区数据乱序或者绕回后覆盖了不该覆盖的数据。原因RING_SIZE填写的是“2的N次方字节”里的N不是字节数。填3代表8字节范围填4代表16字节范围很多人填了3以为是3字节结果地址回绕非常频繁。另外RING_SEL选择的是哪边地址做环形读边还是写边两边都配置成环形在大多数场景下没有意义。解决建议环形大小必须是传输宽度的整数倍否则可能出现半个数据被截断的情况。比如16位传输环形大小至少要2的2次方4字节起步。做ADC环形采样时我习惯把环形大小设成采样缓冲区长度的1/4以上减少回绕频繁程度。5.6 坑六内存重叠拷贝结果不对现象源地址和目的地址有重叠时拷贝结果和memmove不一致数据被覆盖了一部分。原因RP2040 DMA的地址模式只有固定和递增没有递减。顺序递增搬运时如果目的地址在源地址后面且两者有重叠先搬走的源数据会被后续写入覆盖掉结果就和memmove反着来。解决思路尽量避免重叠。真需要重叠场景要么手动分成两段搬要么调整目标地址布局。这个不是DMA的bug是硬件特性设计数据结构时就得想清楚。坑典型现象核心原因快速解决DREQ卡住TRANS_COUNT不动外设DREQ信号未满足确认外设FIFO使能或换DREQ_FORCE验证中断不来传输已完成但无回调中断链路上某环节缺失按序检查handler、NVIC、清标志未对齐数据随机错乱AHB总线对齐要求降数据宽度或用aligned声明重复启动失败第二次传输无效寄存器未重新写入每次dma_channel_configureRing乱序环形缓冲错乱RING_SIZE理解错误确认log2值且是宽度的整数倍重叠拷贝数据被覆盖不支持地址递减避免重叠或分段拷贝6. 进阶玩法链式传输、定时触发和调度优化6.1 链式传输做双缓冲双缓冲是采集场景里的经典套路一个通道在往缓冲区A写数据CPU同时处理缓冲区B里的旧数据A写满后自动切到BB写满再切回A交替进行不让CPU等待。RP2040的链式传输chain让这个切换完全硬件化。配置理念是通道0结束时触发通道1通道1结束时触发通道0两个通道互相链起来配合各自绑定的中断就能形成乒乓接力。#define N 1024 uint16_t buf_a[N], buf_b[N]; int ch0 dma_channel_claim(); int ch1 dma_channel_claim(); dma_channel_config cfg0 dma_channel_get_default_config(ch0); channel_config_set_transfer_data_size(cfg0, DMA_SIZE_16); channel_config_set_dreq(cfg0, DREQ_ADC); channel_config_set_chain_to(cfg0, ch1); // ch0完成后触发ch1 dma_channel_configure(ch0, cfg0, buf_a, adc_hw-fifo, N); dma_channel_config cfg1 dma_channel_get_default_config(ch1); channel_config_set_transfer_data_size(cfg1, DMA_SIZE_16); channel_config_set_dreq(cfg1, DREQ_ADC); channel_config_set_chain_to(cfg1, ch0); // ch1完成后触发ch0 dma_channel_configure(ch1, cfg1, buf_b, adc_hw-fifo, N); dma_channel_start(ch0);这里链式配置的意义不只是自动切换缓冲还能把“CPU配置下一次传输”的延迟从关键路径上移除。即使CPU因为高优先级任务被抢占几毫秒DMA依然在两个缓冲区之间自动轮换不会漏掉一个采样点。6.2 用Timer DREQ做定时采样第2章提到的TIMER0-TIMER3 DREQ是一个被低估的功能。外设DREQ受外设节奏控制但如果你想做的是“以固定时间间隔从某个寄存器采一次数”普通的DREQ就不太合适了。这时可以用DMA定时器实现固定节拍触发。SDK里通过dma_timer_set_fraction设置定时器的触发频率它会按照numerator / denominator的比例对系统时钟分频产生周期性的DMA请求事件。配合DREQ_TIMER0DMA就会严格按这个节拍搬运非常适合做固定采样率的传感器数据采集。这种方法最妙的地方在于采样间隔由硬件定时器保证不受CPU负载影响。哪怕CPU正在跑协议栈、处理显示刷新DMA采样依然稳如磐石。6.3 CPU负载实测对比我简单测过一组数据通过UART发送4096字节分别用阻塞轮询和DMA两种方式。轮询方式在115200波特率下CPU从第一个字节到最后一个字节基本全程占用约355毫秒。期间别的事情全停。用DMA发送时只要配置好通道、启动一次CPU立刻解放DMA完成中断再回来收尾。真正的CPU开销只有配置启动和中断处理的十几微秒。当然DMA也不是零成本。它和CPU共享内存总线大量DMA搬运会在一定程度上拖慢CPU访问内存的速度。但如果CPU本来就在等外设这种总线争抢完全值得。6.4 组合拳PIODMA做WS2812回到文章开头说的WS2812灯带场景。实现方案是PIO状态机负责输出严格遵守ns级时序的波形DMA负责把颜色数据从内存按节奏喂给PIO的TX FIFOCPU只负责组装颜色数组。这样整个灯带控制链路里CPU的参与度降到了最低——初始化时配一次PIO和DMA传输过程中它甚至可以去处理网络协议。实测下来驱动几百个灯珠的灯带CPU占用率几乎没有变化。PIO和DMA是RP2040上配合最默契的一对硬件模块。PIO擅长精确时序但数据得有人喂DMA擅长数据搬运但不知道自己该什么时候搬。DREQ把它们缝合在一起一个负责节奏一个负责搬运CPU彻底从底层细节里解放出来。我个人在实际调试中的体会是DMA的难点从来不是API记不住而是配置时脑子里要时刻有一张“谁在等谁”的图DMA在等DREQDREQ在等外设FIFO外设FIFO在等数据到达。顺着这条链路排查大部分“灵异现象”都能找到合理的硬件解释。先跑通最简单的DREQ_FORCE内存拷贝再逐步加上外设DREQ最后才上链式传输和多通道调度——这是我建议的DMA学习路径。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价