如果你玩过一段时间树莓派 Pico,大概率会有这样的感觉:GPIO 点灯、PWM 呼吸灯、I2C 读传感器这类例程跑起来毫无压力,但一旦想搞高速连续采样、刷一大片 WS2812 灯带、或者用串口以高波特率收发大块数据,CPU 就像被绳子绑住了,大量时间都耗在“搬数据”这种毫无技术含量的杂活上。这篇文章我打算把 RP2040 的 DMA 控制器从寄存器层面完整拆开讲透,包括通道结构、触发机制、环状缓冲,以及链式传输的底层逻辑。目标很简单:看完之后你能自己写出 DMA 驱动,而不是只会调 SDK 封装。
内容会有点长,但每个部分都尽量做到能直接落到代码上。适合已经跑过 Pico 基础例程、想深入嵌入式底层的朋友,也适合从 STM32 那类 MCU 迁移过来、对 RP2040 DMA 机制还不太熟的人。
1. 先算一笔账:不用 DMA,你的 CPU 都耗在哪儿了
1.1 中断里的搬运工:串口、SPI、ADC 的真实开销
很多人对 DMA 的认知停留在“用了能省 CPU”,但到底省了多少,心里没数。我举个实际算过的例子:Pico 的 ADC 最高能跑到 500kSPS 左右,也就是每 2 微秒产生一次转换结果。如果你用中断去读 ADC,那么每 2 微秒就要进一次中断。
Pico 默认主频 125MHz,2 微秒大概是 250 个周期。进一次中断,保存现场、读结果寄存器、把数据丢进数组、更新指针、恢复现场,这一套下来轻松吃掉五六十个周期。看起来占比不高,但问题是这些周期是碎片化的,你的主程序逻辑不断被打断,任何稍微复杂的实时处理都会变得很难受。
串口那边更明显。以 460800 波特率为例,每字节大约 10 个 bit,每秒能收 46080 字节,差不多 21 微秒一字节。对于 125MHz 的 Pico 来说,每个中断 50 个周期确实不算多,可一旦加上协议解析、环形队列管理、看门狗喂狗、UI 刷新,主循环的实时性就会变得不可预测。
我自己最深的体会是在用 SPI 驱动屏幕的时候。320x240 的 RGB565 屏,一帧数据 153600 字节,SPI 时钟拉到 62.5MHz,逐字节写入发送寄存器的话,CPU 几乎全程卡在 SPI 上,连按键扫描都得靠中断勉强维持。那个阶段我特别理解为什么做嵌入式的都爱说一句话:数据搬运不该是 CPU 的活。
1.2 DMA 的理念:一个可以自己跑的内存搬运工
DMA 的本质,就是一个可编程的搬运工。你只需要告诉它三件事:从哪读、写到哪、搬多少个数据项,它就会自己一趟一趟地搬,搬完再通知你。整个过程里 CPU 不需要参与每一次数据移动。
关键在这里:DMA 搬运一次数据,和你手动执行一条*dst++ = *src++;所花的时间差不多,但它不占用 CPU 流水线,也不打断主程序。而且它可以独立于 CPU 运行,在后台把数据准备好,等主程序需要用的时候直接查结果。
RP2040 的 DMA 还有个特点,它是挂在 AHB 总线矩阵上的,可以访问整个 4GB 地址空间里的绝大多数外设寄存器、SRAM、XIP flash。这意味着它不光能在内存和内存之间搬数据,还能在内存和外设 FIFO 之间搬。这个能力撑起了后面要讲的 PIO 协同、ADC 连续采样、串口不定长接收等一堆高级玩法。
1.3 RP2040 的 DMA 资源:12 条通道和它们的邻居关系
RP2040 的 DMA 控制器一共有 12 条独立通道,编号从 0 到 11。每条通道都有自己完整的一组寄存器,控制寄存器、源地址寄存器、目的地址寄存器、传输计数寄存器,一个不少。这 12 条通道之间通过仲裁器共享 AHB 总线,同一时刻可以有多条通道同时运行,但访问总线的顺序由优先级决定。
优先级通过 CTRL 寄存器里的 HIGH_PRIORITY 位设置。如果两条通道都是高优先级,硬件会按照通道号从小到大仲裁,通道号小的先获得总线访问权。实际项目中我一般只给实时性要求最高的那一路打高优先级,其他通道保持默认,尽量避免多条高优先级通道互相抢总线。
每条通道的寄存器基地址很好算,DMA 控制器基地址是0x50000000,第 N 条通道的寄存器组基地址就是0x50000000 + N * 0x40。头文件里用dma_hw这个结构体指针把整个控制器都封装好了,直接用dma_hw->ch[0]、dma_hw->ch[1]这种方式访问即可。这个地址关系值得记牢,后面调试的时候你会感谢自己记住了它。
2. 通道寄存器逐个过:READ_ADDR、WRITE_ADDR、TRANS_COUNT、CTRL 以及三组别名寄存器
2.1 四个主寄存器,先记住它们的职责
任何一条 DMA 通道,核心就是四个主寄存器:
READ_ADDR 存放当前读源地址。每搬运一个数据项,硬件会根据 INCR_READ 位决定这个地址是递增还是保持不动。传输过程中你可以随时读它看搬到哪里了。
WRITE_ADDR 存放当前写目标地址,行为逻辑和 READ_ADDR 对称。要特别注意,这两个寄存器在传输过程中是会变化的,不是只读一次的配置项。
TRANS_COUNT 是剩余传输计数,每成功搬运一个数据项就减一。它减到 0 的时候,通道的 BUSY 状态会硬件清零,同时可以触发中断或者链式触发下一个通道。这个寄存器是整个 DMA 状态机里的核心计数器,后面踩坑部分我会单独拿出来讲,因为它和字节数不是一回事。
CTRL 寄存器管所有控制逻辑,包括传输数据宽度、地址递增策略、触发源选择、是否使能等等。它在 SDK 里的全名叫 CTRL_TRIG,因为直接向这个寄存器写入时,除了设置控制位,还会把 EN 位置 1,产生一次软件触发。
下面这张表把四个主寄存器的职责列清楚了,便于对照:
| 寄存器 | 偏移 | 传输中的作用 | 常见误区 |
|---|---|---|---|
| READ_ADDR | 0x00 | 当前读地址,可递增/固定 | 以为配置后不可变 |
| WRITE_ADDR | 0x04 | 当前写地址,可递增/固定 | 以为只配一次 |
| TRANS_COUNT | 0x08 | 剩余数据项计数,每传一项减一 | 当成字节数用 |
| CTRL/CTRL_TRIG | 0x0C | 控制位集合,写入时触发传输 | 忽略触发副作用 |
2.2 CTRL 字段逐个说:EN、DREQ_EN、TREQ_SEL、INCR、RING、CHAIN、BSWAP
CTRL 寄存器是 DMA 里最值得花时间啃的东西。它不是几个简单的开关,而是把所有传输模式的选择都塞在了一个 32 位寄存器里。我按实际使用频率从高到低讲。
EN 是通道使能。写 1 以后通道才会响应触发信号并开始搬运。平时配置通道时,如果你用的是dma_channel_configure这个 SDK 函数,它默认写到 CTRL 寄存器的值是不含 EN 的,需要单独调用dma_channel_start或者直接写 CTRL_TRIG 来置位。
DREQ_EN 决定通道是否等待硬件请求信号。如果这个位是 0,通道被触发后立即开始搬运;如果是 1,通道必须等对应的 DREQ(Data Request)信号有效才能搬一个数据项。DREQ 是理解 Pico DMA 的核心概念,后面专门用一节讲。
TREQ_SEL 是触发源选择。它决定当前通道监听哪个 DREQ 信号。注意,TREQ_SEL 只是把“耳朵”对准某个信号源,真正用不用这个信号由 DREQ_EN 控制。
DATA_SIZE 选择每次搬运的数据宽度,0 表示 8 位,1 表示 16 位,2 表示 32 位。这个字段同时影响 TRANS_COUNT 的递减步长和地址的递增步长。
INCR_READ 和 INCR_WRITE 分别控制读写地址是否递增。置 1 表示每次搬运后地址递增一个数据项宽度,置 0 表示地址保持不变。这个设计很实用:从外设数据寄存器读数据时,读地址要保持固定,写到内存时写地址要递增,两边可以独立控制。
RING_SIZE 和 RING_SEL 一起实现环状缓冲,后面单独展开。CHAIN_TO 定义当前通道传输完成后去触发哪条通道,这是链式传输的开关。BSWAP 则是在每次搬运时对数据项做字节交换,主要应对大小端场景。
IRQ_QUIET 这个位容易被忽略。它置 1 后,通道只在发生总线错误时才产生中断,正常传输完成时不触发。对于高频、大批量、连续刷数据的场景非常有用,能大幅减少中断风暴。
2.3 别名寄存器(AL1/AL2/AL3):为什么一个硬件寄存器要给你三把钥匙
很多第一次看 RP2040 Datasheet 的人会被这一大堆 AL1、AL2、AL3 寄存器绕晕:明明一个通道才四个主寄存器,怎么一下子多出十几二十个带 AL 前缀的寄存器?
其实这些别名寄存器并不是额外的硬件存储,而是同一组物理寄存器的不同寻址视图。硬件这么设计的核心目的,是为了让你能“按不同顺序、带不同副作用”去写同一个寄存器。
我打个比方:主寄存器就像一间房子的正门,你从正门进去,看到什么就是什么。别名寄存器则是房子的侧门、后门和员工通道。你从不同门进去,硬件会自动帮你把一些默认设置打好,省得你每次都要手工拼一个完整的控制字。
三组别名寄存器的差异主要体现在触发和预装载两个意向上。AL1 这组特别适合“立即开始”的场景:你可以连续写 AL1_CTRL、AL1_READ_ADDR、AL1_WRITE_ADDR,最后写 AL1_TRANS_COUNT_TRIG,最后一次写入时硬件会自动把 EN 置位并立刻启动传输。AL2 和 AL3 则倾向于“预装载”场景:先把通道参数准备好,但不要立刻跑,等某个条件满足后再触发。
说直白点,主寄存器加别名寄存器的组合,让单条 DMA 通道也能玩出“双缓存预装载”的花样。这一机制是链式传输能够在不依赖 CPU 中断的情况下连续工作的基础,虽然配置起来比 STM32 的 DMA 描述符复杂一些,但理解了别名寄存器的意图之后,你会觉得这个设计其实很优雅。
3. 触发机制拆解:软件触发、DREQ 硬件触发和 PIO/ADC/UART 各自的脾气
3.1 软件触发:写一次就能跑,但别把它当洪水开关
最简单的触发方式就是软件触发。你配置好参数后,向 CTRL_TRIG 寄存器写入控制字,或者用 SDK 的dma_channel_start,通道就会立刻开始搬运。整个过程没有任何等待,适合内存到内存的拷贝、固定数据的搬运等纯软件控制的场景。
软件触发需要注意一点,它是一次性触发,不是持续触发。通道把 TRANS_COUNT 减到 0 后,BUSY 位会归零,通道停止,不会自己重新开始。如果你需要连续搬运多批数据,要么在完成中断里再次触发,要么用链式传输接上下一段任务。
我在早期调试时犯过一个很蠢的错误:以为触发一次之后通道会像定时器那样周而复始地跑,结果只搬完第一批数据就停了,主程序还傻傻地等着下一批。后来看 Datasheet 才搞清楚,DMA 通道更像是一个“一次性的任务执行者”,每次执行完任务都会停下来,除非你用链式传输或者中断把它再次推起来。
3.2 TREQ_SEL 查表:PIO、UART、SPI、ADC、PWM 的 DREQ 编号
硬件触发是 DMA 真正发挥威力的地方。RP2040 内部有一套 DREQ 信号网络,像 PIO、UART、SPI、I2C、ADC、PWM、USB 这些外设都能发出 DREQ 请求。DMA 通道通过 TREQ_SEL 字段选择自己监听哪个信号源。
下面这张表是我从 pico-sdk 头文件里整理出来的常用编号,实际以你所用 SDK 版本的hardware/regs/dreqs.h为准:
| TREQ_SEL 值 | 信号源 | 触发时机 |
|---|---|---|
| 0 | PIO0 TX0 | PIO0 状态机 0 的 TX FIFO 有空位 |
| 4 | PIO0 RX0 | PIO0 状态机 0 收到数据 |
| 8 | PIO1 TX0 | PIO1 状态机 0 的 TX FIFO 有空位 |
| 16 | UART0 TX | UART0 发送 FIFO 可写入 |
| 17 | UART0 RX | UART0 接收 FIFO 有数据 |
| 20 | SPI0 TX | SPI0 发送 FIFO 可写入 |
| 21 | SPI0 RX | SPI0 接收 FIFO 有数据 |
| 28 | ADC | ADC 转换完成并产生新结果 |
| 29~36 | PWM0~PWM7 WRAP | PWM 计数达到 wrap 值 |
TREQ_SEL 和 DREQ_EN 必须配合使用。只设置 TREQ_SEL 而不开启 DREQ_EN,通道依然会按软件触发方式工作。很多初学者只设置了 DREQ 编号但忘了开 DREQ_EN,结果 DMA 一次搬完所有数据,和预期完全不同。
3.3 DREQ_EN 与 SHARE_DREQ:多个通道抢一个数据源时的行为
当 DREQ_EN 置 1 后,通道进入“请求驱动”模式:每个数据项的搬运都等待对应 DREQ 信号有效后才会发生。这种模式下,DMA 的搬运节奏完全由外设决定。比如 ADC 连续采样时,ADC 每出一个结果就拉一次 DREQ_ADC,DMA 就搬一个结果,步调严丝合缝,不会多搬也不会漏搬。
SHARE_DREQ 则是一个比较少用但有点意思的位。默认情况下,每个 DREQ 信号同一时刻只分配给一个通道。如果两个通道都想监听同一个 DREQ,可以通过设置 SHARE_DREQ 让它们共享这个请求信号。
共享 DREQ 的实际场景往往是两个通道处理同一个数据源的不同批次数据,比如乒乓缓冲的两条通路都从 ADC DREQ 取数据。不过这个方案会带来一定的公平性问题,硬件不会保证两个通道严格交替,实际使用前最好实测一下行为是否符合预期。我自己的建议是:能避开就避开,乒乓缓冲用中断配合单通道可能更可控。
4. 环状缓冲 RING 机制:地址怎么在数组边界自动绕回
4.1 RING_SIZE 是对数不是字节数:2^N 边界到底怎么算
RING 机制是 RP2040 DMA 的一个亮点。它允许通道的读地址或写地址在到达某个边界后自动回绕到缓冲区起点,从而形成一个硬件级的环形缓冲区,不需要 CPU 干预地址重置。
RING_SIZE 字段设置的并不是缓冲区字节数本身,而是一个对数。硬件实际使用的回绕边界是 2^RING_SIZE 字节。比如你写 RING_SIZE 为 4,那么边界就是 16 字节;写 10,边界就是 1024 字节。0 表示禁用回绕。
这里有个特别容易踩的坑:缓冲区起始地址必须按 2^RING_SIZE 对齐。硬件在地址递增到边界时,会直接把地址的低 RING_SIZE 位清零,实现回绕。如果缓冲区起始地址本身不是 2^RING_SIZE 的整数倍,回绕后的地址不会指向缓冲区开头,而是指向某个“看起来对齐”的错误位置,数据传输会直接错乱。
我一般这样声明环形缓冲区,确保对齐没问题:
#define RING_SIZE_BYTES 1024 uint8_t adc_ring_buffer[RING_SIZE_BYTES] __attribute__((aligned(RING_SIZE_BYTES)));数组大小和对齐值保持一致,就省掉了手动算对齐的麻烦。SDK 里也有dma_channel_prepare_ring_buffer()这样的辅助函数,它会自动帮你设置 RING_SIZE 和 RING_SEL,但底层仍然是这个 2^N 的逻辑,原理得心里有数。
4.2 INCR_READ / INCR_WRITE:地址递增和固定地址的组合场景
环状缓冲必须和地址递增策略搭配才有意义。RING_SEL 决定回绕作用在读地址还是写地址。如果你把 RING_SEL 设为写地址回绕,那 WRITE_ADDR 就必须设置为 INCR_WRITE = 1,让写地址不断前进,到边界后再回绕。读地址则可以是固定的,比如固定读取外设数据寄存器。
反过来的场景也存在:从一大块内存读取数据写到某个固定地址的外设 FIFO,同时希望读地址在缓冲区范围内循环。这时就把 RING_SEL 设为读地址回绕,INCR_READ = 1,INCR_WRITE = 0。
最常用的组合是 ADC 连续采样写入环形缓冲区。ADC 结果寄存器的读地址固定,采样缓冲区的写地址递增并在 1024 字节边界处回绕。这样主程序只需要维护一个读指针,随时去取最新一批数据,不用担心 DMA 把数组写爆。
4.3 ADC 连续采样到环形缓冲区的配置示范
我直接给一个可用的配置片段。假设我要用 ADC 连续采样,DMA 自动把结果写入 1024 字节对齐的环形缓冲区,同时采集过程中不打断 CPU:
#include "hardware/dma.h" #include "hardware/adc.h" #define ADC_BUFFER_SIZE 1024 static volatile uint32_t adc_samples[ADC_BUFFER_SIZE / 4] __attribute__((aligned(ADC_BUFFER_SIZE))); void adc_dma_ring_init(void) { adc_init(); adc_gpio_init(26); adc_select_input(0); dma_channel_config cfg = dma_channel_get_default_config(0); channel_config_set_transfer_data_size(&cfg, DMA_SIZE_32); channel_config_set_read_increment(&cfg, false); channel_config_set_write_increment(&cfg, true); channel_config_set_dreq(&cfg, DREQ_ADC); channel_config_set_enable_dreq(&cfg, true); channel_config_set_ring(&cfg, true, 10); // 写地址回绕,2^10 = 1024 字节 dma_channel_configure( 0, &cfg, adc_samples, // 写地址 &adc_hw->result, // 读地址,固定不动 ADC_BUFFER_SIZE / 4, // 传输计数,单位是 32 位字 false // 不立即触发 ); dma_channel_start(0); adc_set_round_robin(0); adc_run(true); // 开启连续采样 }传输计数为什么是ADC_BUFFER_SIZE / 4?因为这里 DATA_SIZE 是 32 位,TRANS_COUNT 单位是 32 位字。如果直接写 1024,DMA 会以为要搬 1024 个 32 位字,等于搬了 4096 字节,缓冲区和回绕逻辑就全乱了。这个问题下面避坑部分还会再强调。
配置完成后,DMA 会跟着 ADC 的 DREQ 节奏,不停地把采样结果写进adc_samples,写到 1024 字节边界自动回绕到开头。主程序只需要记录一个自己的读指针,就能随时取走最新数据,不需要中断。
5. 链式传输 CHAIN 完全解读:一次搬运结束后自动点燃下一根接力棒
5.1 CHAIN_TO 的真正含义:不是链表,是接力
链式传输是 RP2040 DMA 里最有意思、也最容易被误解的机制。很多人一听到“链式”,第一反应是像 STM32 那样的 DMA 描述符链表——内存里放一串描述符,硬件自动加载到同一个通道继续跑。但 RP2040 不是这个思路。
RP2040 的 CHAIN_TO 字段含义是:当前通道的传输计数减到 0、传输结束后,硬件自动向 CHAIN_TO 指定的那个通道发出一次触发信号。它更像田径比赛里的接力棒交接,通道 A 跑完,把棒交给通道 B,通道 B 带着自己的任务出发。任务参数是提前就配置好的。
这一机制的实际价值在于:你可以把一个大任务拆成多个小任务,分别配置到不同通道上,然后用 CHAIN_TO 把这些通道按顺序串起来。CPU 只需要触发第一个通道,后面的全部由硬件自动完成。
要注意的是,目标通道必须在被触发前完成配置,并且处于空闲状态。如果目标通道此刻还在忙,触发信号会直接丢失,不会排队等待,这是链式传输最容易踩的坑。
5.2 为什么链式传输需要别名寄存器帮忙预装载
既然通道参数必须提前配置好,那就产生了一个问题:通道 A 还在跑的时候,通道 B 可以先配置好,但通道 A 完成后再要跑 A 的下一轮,谁来给 A 重填参数?这就是别名寄存器的用武之地。
通过 AL2、AL3 两组别名寄存器,可以在通道空闲时先“预装载”好下一个任务的控制字、读写地址和传输计数,但暂时不触发它。这样等 CHAIN_TO 触发信号到达时,通道立刻就能以新参数开始工作,中间不需要 CPU 介入。
我个人的经验是,设计链式传输时先画出任务序列,再决定每条通道承担哪一段,然后考虑哪些参数可以预装载、哪些只能在中断里填充。画出图再动手写寄存器,出错的概率会小很多。
5.3 三段式协议帧发送:包头、载荷、包尾的寄存器配置实例
链式传输最典型也最好用的场景,是发送一个由多个内存区域拼接而成的协议帧。比如我要通过串口发送一帧数据,包含 4 字节包头、256 字节载荷、4 字节包尾。如果用 CPU 手动发,要不断等待 TX FIFO 空位,或者依赖发送中断,代码繁琐且容易被更高优先级的中断打断。用链式 DMA,可以把三段数据交给三个通道,一次触发直接发完整帧。
配置思路如下:
- 通道 0 负责发送包头,读地址指向
frame_head,传输计数 4,DREQ 选择 UART0 TX,CHAIN_TO 指向通道 1。 - 通道 1 负责发送载荷,读地址指向
frame_payload,传输计数 256,同样使用 UART0 TX,CHAIN_TO 指向通道 2。 - 通道 2 负责发送包尾,读地址指向
frame_tail,传输计数 4,CHAIN_TO 设为 0xF(表示不触发任何通道)。
这三个通道都设置 INCR_READ = 1、INCR_WRITE = 0、DREQ_EN = 1、TREQ_SEL = DREQ_UART0_TX。触发通道 0 后,DMA 会等待 UART TX FIFO 出现空位,放入包头数据,全部放完后自动触通道 1 发载荷,最后通道 2 发包尾。
实现代码如下:
#include "hardware/dma.h" #include "hardware/uart.h" #define UART_ID uart0 static const uint8_t frame_head[4] = {0xAA, 0x55, 0x01, 0x00}; static uint8_t frame_payload[256]; static const uint8_t frame_tail[4] = {0x0D, 0x0A, 0x00, 0x00}; void dma_frame_init(void) { // 通道0:包头 dma_channel_config cfg0 = dma_channel_get_default_config(0); channel_config_set_transfer_data_size(&cfg0, DMA_SIZE_8); channel_config_set_read_increment(&cfg0, true); channel_config_set_write_increment(&cfg0, false); channel_config_set_dreq(&cfg0, DREQ_UART0_TX); channel_config_set_enable_dreq(&cfg0, true); channel_config_set_chain_to(&cfg0, 1); dma_channel_configure(0, &cfg0, &uart_get_hw(UART_ID)->dr, frame_head, 4, false); // 通道1:载荷 dma_channel_config cfg1 = dma_channel_get_default_config(1); channel_config_set_transfer_data_size(&cfg1, DMA_SIZE_8); channel_config_set_read_increment(&cfg1, true); channel_config_set_write_increment(&cfg1, false); channel_config_set_dreq(&cfg1, DREQ_UART0_TX); channel_config_set_enable_dreq(&cfg1, true); channel_config_set_chain_to(&cfg1, 2); dma_channel_configure(1, &cfg1, &uart_get_hw(UART_ID)->dr, frame_payload, 256, false); // 通道2:包尾 dma_channel_config cfg2 = dma_channel_get_default_config(2); channel_config_set_transfer_data_size(&cfg2, DMA_SIZE_8); channel_config_set_read_increment(&cfg2, true); channel_config_set_write_increment(&cfg2, false); channel_config_set_dreq(&cfg2, DREQ_UART0_TX); channel_config_set_enable_dreq(&cfg2, true); channel_config_set_chain_to(&cfg2, 0xF); // 链条结束 dma_channel_configure(2, &cfg2, &uart_get_hw(UART_ID)->dr, frame_tail, 4, false); } void dma_send_frame(void) { dma_channel_start(0); // 只需要触发第一个通道 }这段代码跑起来之后,整帧数据会由 UART TX FIFO 的 DREQ 节奏逐字节发送完。CPU 在触发后可以立刻去干别的,三个通道会自动接力,最后一次传输完成后如果有需要,还可以在通道 2 上开完成中断。这就是链式传输最典型的高价值场景。
6. 实战:DMA + PIO 驱动 WS2812 灯带,从头到尾不占用 CPU
6.1 为什么非要用 PIO + DMA 组合来刷灯带
WS2812 灯带的时序要求很刁钻。每一个 bit 要精确控制高低电平的时间比例,通常是 800kHz 左右的速率,靠 GPIO 翻转加 delay 很难做到稳定,尤其是一整条灯带上百个灯珠、几千个 bit,纯 CPU 刷灯会把主程序拖死。
RP2040 的 PIO 状态机天生就是干这个的。一个 PIO 程序可以精确产生 WS2812 需要的时序波形,每次从 TX FIFO 取一个字节的数据,根据 bit 高低输出不同宽度的高电平脉冲。
但即便用上 PIO,如果还是靠 CPU 把颜色数据一个字节一个字节地塞进 PIO TX FIFO,依然会占用大量时间。正确的做法是把 DMA 接到 PIO TX FIFO 上:DMA 从内存颜色缓冲区读取数据,PIO 的 TX FIFO 一有空位,DREQ 信号就通知 DMA 补充数据。整个刷灯过程,CPU 只需要更新颜色缓冲区内容,剩下的交给硬件。
6.2 灯带刷新任务的通道配置和触发选择
我假设你已经写好了一段 WS2812 的 PIO 程序,并且把状态机的 TX FIFO 接到了pio0的 state machine 0。接下来配置 DMA 通道 0,从颜色缓冲区读取数据,写入 PIO0 TX FIFO,DREQ 选择DREQ_PIO0_TX0。
颜色缓冲区每个灯珠 3 个字节,分别是 G、R、B。如果有 60 个灯珠,缓冲区就是 180 字节。每次刷新需要把这 180 字节全部送到 PIO,DMA 传输计数就设为 180。
配置代码大概长这样:
#include "hardware/dma.h" #include "hardware/pio.h" #include "ws2812.pio.h" #define NUM_LEDS 60 static uint8_t led_buffer[NUM_LEDS * 3]; void ws2812_dma_init(PIO pio, uint sm) { uint offset = pio_add_program(pio, &ws2812_program); ws2812_program_init(pio, sm, offset, 16, 800000); dma_channel_config cfg = dma_channel_get_default_config(0); 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_PIO0_TX0); channel_config_set_enable_dreq(&cfg, true); dma_channel_configure(0, &cfg, &pio->txf[sm], // 写地址固定是 PIO 的 TX FIFO led_buffer, sizeof(led_buffer), // 数据项数,这里是字节数 false); } void ws2812_refresh(void) { dma_channel_start(0); }DREQ_PIO0_TX0对应的 TREQ_SEL 值是 0,PIO0 状态机 0 的 TX FIFO 有空位时 DREQ 有效。DMA 会在 PIO 消耗掉一个数据后立刻补充一个,整个传输节奏完全由 PIO 消费速度决定。
6.3 实测感受:中断频率、CPU 占用和刷新率的真实变化
我之前在一款小项目里用这个方案刷 144 个灯珠,效果很明显。500 个灯珠的话,一帧颜色数据 1500 字节,DMA 一个字节一个字节往 PIO 灌,PIO 按照 WS2812 时序一 bit 一 bit 地送出去。整个刷新过程里,CPU 只需要触发一次 DMA,然后就可以继续跑主逻辑、处理传感器数据、更新 UI。
对比一下,用 GPIO 手动翻转加延时驱动同一批灯珠,CPU 占用率几乎拉满。换成 PIO 加 DMA 之后,我实测主循环的抖动明显减小,除了刷新颜色缓冲区的拷贝操作,CPU 基本没有额外开销。
这里有个细节值得留意:颜色缓冲区的更新和 DMA 的读取可能发生竞争。如果你的动画程序在 DMA 搬运过程中修改led_buffer,画面可能出现撕裂。我的做法是维护两份缓冲区,一份给 DMA 读取,一份给主程序写入,刷新前切换角色。也可以利用 DMA 完成中断做同步,等到一帧数据全部搬完再修改缓冲区。
7. 踩坑实录:总线保序、深空读、计数单位、链触发丢失
7.1 总线保序:DMA 的读写不是同一条路,别再裸读寄存器了
RP2040 内部是 AHB 总线矩阵,DMA 的读请求和写请求走的可能不是同一条总线路径。比如从 XIP flash 读数据、往 SRAM 写数据,读路径经过 flash 控制器,写路径经过 SRAM 仲裁,两者的延迟特性差别很大。
这带来的一个实际问题是,如果你在 DMA 完成中断里立刻去读外设状态寄存器或另一个 DMA 通道的寄存器,可能会读到旧值,因为总线上的写操作还没真正落到目标寄存器里。我遇到过一次 SPI DMA 发送完成后立刻拉低片选信号,结果最后一字节还没发完,片选就提前没了。
解决办法是在关键操作之间插入内存屏障指令。Cortex-M0+ 支持__dmb()和__dsb(),它们能保证前面的访存操作完成后才继续执行后面的语句。我通常在 DMA 完成中断里加一个__dmb(),再操作外设寄存器。
7.2 深空读:为什么读取 TRANS_COUNT 会得到 0x00
RP2040 DMA 有一个被人讨论过的现象:当 DMA 通道正在从 XIP flash 读取数据时,CPU 去读该通道的某些寄存器,可能直接读到 0x00,即使传输明明还没结束。这个现象在一些勘误讨论里被称为“深空读”。
原因和总线延迟有关。DMA 读 XIP flash 时,AHB 总线会在读事务未完成期间返回一个 wait 信号,如果 CPU 在同一个时间窗口去读 DMA 的寄存器,读到的数据可能没有被正确更新,表现为 0 值。
我踩过这个坑之后定了一条规则:尽量不让 DMA 直接从 XIP flash 搬运数据。Flash 访问本身就比 SRAM 慢,而且会独占 flash 控制器,导致 CPU 取指令都变慢。需要搬运的数据先拷到 SRAM 缓冲区,再交给 DMA。这样既能避开深空读,整体性能反而更高。
7.3 TRANS_COUNT 是数据项数不是字节数,别把缓冲区干穿了
这可能是新手最容易犯的错误。TRANS_COUNT 的单位取决于 DATA_SIZE,而不是字节。DATA_SIZE 为 8 位时,它等于字节数;DATA_SIZE 为 16 位时,它表示半字数量;DATA_SIZE 为 32 位时,表示字数量。
我在前面 ADC 例子里写过,1024 字节的缓冲区用 32 位传输,TRANS_COUNT 应该是 256,不是 1024。如果填了 1024,DMA 会搬 1024 个 32 位字,也就是 4096 字节,缓冲区早就越界了。
排查这类问题有一个比较快的办法:传输完成后看 WRITE_ADDR 和初始地址的差值,再乘以数据项宽度,判断实际搬运的字节数是否符合预期。如果发现多搬了,优先检查 TRANS_COUNT,而不是怀疑 DMA 硬件出了问题。
7.4 CHAIN_TO 触发丢失:目标通道忙时,触发不会排队
链式传输的“接力棒”机制有一个硬性前提:目标通道必须处于空闲状态。如果在目标通道的 BUSY 位还是 1 的时候,当前通道完成并向它发出链式触发,这个触发信号不会保存,直接丢失。
我调试三段式帧发送时遇到过一次非常诡异的现象:帧头发送完,载荷段偶尔没有发出去,整帧数据残缺。查了很久才发现,通道 1 因为前一次任务的残留配置没有清零,BUSY 位仍然为 1,通道 0 的链式触发过来后,硬件直接忽略,链路断掉。
从那以后,我每次配置链式通道前都会先显式调用dma_channel_abort()确保通道彻底停止,再写入新参数。对于预装载的场景,也要确认目标通道确实处于 idle 状态,再进行配置。这个习惯帮我避免了很多偶发性 Bug。
另一个相关注意事项是,CHAIN_TO 触发的目标是通道号,不是某个具体的“任务描述符”。如果你需要让同一批通道循环处理多段任务,硬件本身不会自动重新装载参数。这时候必须在合适的中断里更新通道参数,或者利用别名寄存器做预装载。理解了这一点,你才能真正驾驭 RP2040 的链式 DMA,而不是被它绕晕。
说到底,DMA 的底层原理并不复杂,寄存器数量也不算多,真正难的是把这些机制组合到符合你项目需求的状态。我在实际项目中体会最深的一点是:先画清楚数据流,再配置 DMA,永远比边写代码边猜要靠谱。希望这篇文章能让你少走点弯路。