前阵子调试一个数据采集项目,MCU 要持续从 ADC 搬数据、同时还得处理传感器协议栈,CPU 占用率直接飙到 60% 以上,中断一多连主循环都快跑不动了。后来把数据搬运全部丢给 DMA,CPU 占用一下降到 10% 以内。这类“CPU 被数据搬运拖垮”的问题,在很多嵌入式项目里都特别典型,而树莓派 Pico 搭载的 RP2040 芯片,刚好把 DMA 做得既强大又“折腾人”——通道多、触发源多、还支持链式传输,但寄存器配置坑也不少。
这篇文我从寄存器层面一层层剥开 RP2040 的 DMA 控制器。你会搞明白 DMA 到底怎么工作的、为什么需要配置那些寄存器、一个正经传输怎么从零写出来、链式传输又解决了什么问题,最后再附上实际调板子时的经验和踩坑记录。不管是刚上手 Pico 的嵌入式新手,还是想把 RP2040 性能榨干的老手,这篇都值得收藏。
1. 内容整体设计与思路拆解
1.1 为什么 MCU 需要 DMA:先理解 CPU 搬运数据的浪费
先解决一个最基础的问题:为什么要用 DMA?CPU 直接搬数据不就行了?
数据搬运这个动作听起来简单,但背后是一整套指令流的开销。比如说,要把外设 FIFO 里的 1000 个字节搬到内存里,用 CPU 轮询的方式,每搬一个字节至少要经历:
- 循环判断 FIFO 状态位(读状态寄存器)
- 从 FIFO 数据寄存器读一个字节(读数据)
- 把这个字节写入内存地址(写内存)
- 地址指针加一、计数减一
- 跳回循环开头
就这么一套下来,每个字节可能有五六条指令在跑。更要命的是,CPU 在搬数据的时候,没法同时去处理协议解析、状态机切换这些真正需要“算力”的活儿。我在一个用 Pico 做 4 路模拟量采集的板子上实测过开没开 DMA 的差距:纯轮询搬运 4 路 ADC 数据时,CPU 要花掉大约 55% 的时间在搬运上;改成 DMA 自动搬运后,CPU 只负责算平均值和协议打包,占用率直接掉到 11% 左右。
所以 DMA 的核心思想就一句话:把“从一个地方搬到另一个地方”这件重复性极高的脏活累活,交给一个专门的硬件引擎去做,CPU 只在开始的时候交代任务、在结束的时候接收通知。RP2040 这颗芯片上的 DMA 控制器有 12 个独立通道,每个通道都能独立配置、独立工作,这基本覆盖了绝大多数小项目的需求。
1.2 RP2040 DMA 整体结构:12 个通道、一个仲裁器、一堆触发源
打开 RP2040 数据手册的 DMA 章节,你会看到一整页的寄存器表,第一眼可能有点晕。但整个 DMA 控制器的物理结构其实很简单,用一张“接线图”的方式理解就通了:
- DMA 控制器内部有 12 个通道(channel 0 到 channel 11),每个通道有一整套自己的配置寄存器,互不干扰。
- 所有通道共享一个总线仲裁器,也就是同一时刻只能有一个通道在搬数据,其他通道在排队等待。
- 每个通道都有一个“DREQ”信号输入,用来接收来自外设的请求触发。
- 每个通道还有一个中断请求输出,可以独立使能中断、独立查询中断状态。
外设请求触发(DREQ)是 DMA 的核心玩法之一。RP2040 的 DMA 不是自己闷头狂搬,而是可以配置成“外设表示自己准备好了,DMA 才搬一次”。比如 UART 接收 FIFO 里有一个字节了,DMA 才去读一次;比如 ADC 转换完成一次,DMA 才把采样结果搬到内存。这种机制叫“握手”,它让 DMA 和外设天然同步,不会搬错数据。下面这张表格展示了我这次项目里实际用到的几个 DMA 触发源:
| 外设模块 | 事件 | DREQ 信号 |
|---|---|---|
| UART0 | 接收 FIFO 中有数据 | DREQ_UART0_RX |
| UART0 | 发送 FIFO 为空 | DREQ_UART0_TX |
| ADC | 转换完成 | DREQ_ADC |
| SPI0 | RX FIFO 收到数据 | DREQ_SPI0_RX |
| PWM | 计数器归零/到达阈值 | DREQ_PWM_WRAP0 |
这就把 DMA 从“傻搬”升级成了“聪明搬”:只在数据就绪的时候搬,既不会搬空,也不会搬错。
1.3 为什么寄存器配置容易翻车:没搞清楚“程序员的模型”
我见过不少朋友踩坑,说 DMA 配置好以后不发数据,或者发一次以后就再也不动了。我详细对比过代码,发现 90% 的情况都是同一个原因:他们没搞懂 DMA 通道的“状态”和“寄存器默认值”之间的关系。
RP2040 的每个 DMA 通道,本质上是一个“可配置的状态机”。你往配置寄存器里写值,它不会立刻开始搬数据;你还得往触发寄存器里写一个“开始信号”。而一旦传输完成,通道又会进入“空闲”状态,很多寄存器会被硬件自动清零或者恢复成默认值。
这就好比一个外卖骑手:光告诉他“你现在接单了、目的地在哪”,他不会出发,还得有人按一下“开始”按钮;把这一单送完,他会自动标记“空闲”,你得重新下单一轮。DMA 通道也是这么有“脾气”的,配置寄存器里很多位是“写 1 生效、硬件自动清零”的类型,跟普通寄存器“写什么就保持什么”的逻辑完全不同。不清楚这一点,你会在“为什么重传不生效”的坑里卡很久。
2. 核心细节解析与实操要点
2.1 必须掌握的 4 组关键寄存器
RP2040 DMA 控制器的寄存器分布在 0x50000000 起始的地址段,每组通道的偏移都是固定的,比如通道 0 从 0x50000000 开始,通道 1 从 0x50000040 开始,每个通道占 0x40 字节。也就是说,只要掌握了通道 0 的寄存器布局,往其他通道平移偏移量就行。
每个通道的核心寄存器就 4 个,其余都是辅助:
- READ_ADDR(偏移 0x00):32 位寄存器,存放源地址。硬件每搬运一次,会自动根据数据宽度和传输方向自增、自减或保持不变。
- WRITE_ADDR(偏移 0x04):32 位寄存器,存放目的地址。同样可以配置自增/自减/固定。
- TRANS_COUNT(偏移 0x08):32 位寄存器,存放剩余传输次数。硬件每搬一次就减 1,减到 0 表示传输完成。这个寄存器是 DMA 搬运的“倒计时器”,搬完一次就归零,再开始新传输必须重新写入新的值。
- CTRL_TRIG(偏移 0x0C):既是配置寄存器,也是触发寄存器。写入这个寄存器,会同时完成“配置通道参数”和“触发启动”两件事。
此外,还有一个 CHAIN_TO(偏移 0x10)寄存器,专门负责预置链式传输的下一跳通道号,这是链式传输的灵魂。
CTRL_TRIG 是配置的“核心中的核心”,下面这张表整理了它关键位的含义,配置的时候对照着填就可以了。
| 位段 | 名称 | 含义 |
|---|---|---|
| 31 | QUIET | 1 表示内存到内存传输时不产生 DREQ 请求,只在 no-quiet 时等待触发 |
| 30 | BUSY | 只读状态位,1 表示通道正在忙 |
| 28 | WRITE_SNIFF / READ_SNIFF | 是否在写入或读取时进行数据校验 |
| 26:24 | TREQ_SEL | 选择触发源,0x3A 表示定时器触发,0x00 表示无条件触发 |
| 23:22 | CHAIN_TO | 链式传输模式配置(注意这个字段跨了两位) |
| 21:20 | RING_SEL / RING_SIZE | 环状缓冲大小设置 |
| 17:16 | WRITE_INCR | 目的地址自增模式 |
| 15:14 | READ_INCR | 源地址自增模式 |
| 13:12 | D/W_SIZE | 数据宽度:0=字节 8bit,1=半字 16bit,2=全字 32bit |
| 11:5 | DREQ_EN | 触发使能,0 表示无条件搬运,1 表示等待 DREQ |
| 4:0 | DATA_STEP | 每搬运一次数据,地址寄存器步进量(高字节有效) |
虽然官方 SDK 里封装了dma_channel_config结构体和一系列函数,但在实际项目里,尤其是做底层调优时,直接操作寄存器反而更直观、更可控,性能也更好一点。
2.2 数据宽度与地址对齐:为什么 byte 传输会比你想象中慢
RP2040 的 DMA 支持三种数据宽度:8 位(字节)、16 位(半字)、32 位(全字)。这个选择不只是“数据宽窄”的问题,它还和总线效率、地址对齐强相关。
想象一下,DMA 控制器从内存读数据,它需要“一拍读 32 位”是最高效的。如果你把数据宽度配置成 8 位,那么一个 32 位总线段上实际上只用了 1/4,剩下的带宽全部浪费。对于大批量、大数据量的搬运,这可能会造成严重的性能浪费。
另外一个坑是地址对齐。如果源地址是 0x20000001(奇数地址),数据宽度又配成 32 位,那么 RP2040 内部会如何处理?查手册会发现,这种情况 DMA 会主动降低传输效率,甚至直接触发 Bus Fault。实际工程里,我强烈建议所有 DMA 搬运的内存缓冲都做对齐处理。
关于对齐,有一个常见的做法:用 C 语言的alignas(4)或者 GCC 的__attribute__((aligned(4)))来声明缓冲区。Pico SDK 里的dma_channel_transfer_from_buffer_now这类函数并不会帮你做对齐检查,所以这块得自己保证。一旦不对齐,轻则效率低下,重则触发硬件异常,排查起来特别头疼。
提示:内存到内存的搬运,建议把所有缓冲区都按 4 字节对齐,数据宽度直接用 32 位;外设到内存的搬运,按外设 FIFO 自然宽度来,比如 UART 用 8 位、ADC 用 32 位。
2.3 触发模式:无条件搬运与 DREQ 握手搬运
“什么时候开始搬运”是 DMA 配置里另一个容易模糊的点,这主要体现在CTRL_TRIG的DREQ_EN位和TREQ_SEL位的组合上。
- 无条件搬运:
DREQ_EN=0时,DMA 通道一旦被触发,就会连续不断地把TRANS_COUNT次数据全部搬完,中间不等待任何外部信号。这种模式适合内存拷贝、或者从已知长度的 Flash 缓冲区搬到 RAM。 - DREQ 握手搬运:
DREQ_EN=1时,DMA 的每一次搬运都要等对应外设发出“请求”信号。比如你配了TREQ_SEL=DREQ_UART0_RX,那么 UART0 的 RX FIFO 每收到一个字节,DMA 才搬一个。如果没有数据进来,DMA 就干等着。
这两个模式之间的切换,就是你最开始调试 DMA 最容易糊涂的地方。比如你需要用定时器每隔 1ms 触发 DMA 去采样一次 IO 状态,这种需求下你其实可以:
- 用定时器产生 PWM 脉冲(或者 TIMER DREQ),把
TREQ_SEL设成对应定时器事件; - DMA 配置成 DREQ 握手模式,每次定时器事件来一下,DMA 就搬一次。
这种用法能把“周期触发”和“数据搬运”彻底从 CPU 里解放出来,是高级 DMA 玩法里很实用的一类。
2.4 地址自增模式:细说 READ_INCR 和 WRITE_INCR
CTRL_TRIG的READ_INCR和WRITE_INCR决定了每次传输后源地址和目的地址怎么变化。
READ_INCR=0:源地址固定。适合从外设 FIFO 连续读取,比如 UART RX、SPI RX。READ_INCR=1:源地址每次自增(幅度由数据宽度决定,字节+1、半字+2、全字+4)。适合从内存缓冲区读取。WRITE_INCR=0:目的地址固定。适合往外设 FIFO 连续写入,比如 UART TX、SPI TX。WRITE_INCR=1:目的地址每次自增。适合往内存缓冲区写入。
不少新手容易犯的错是把“从外设读”配成“源地址自增”,结果每个字节都往相邻地址搬,最终数据全乱;或者“往外设写”时忘了配WRITE_INCR=0,写地址越走越远,外设 FIFO 里的数据全乱了。这个位看着不起眼,实际定全局。
2.5 中断与轮询:怎么判断 DMA 干完活了
DMA 搬运数据完成后,硬件会自动把INTR寄存器的对应通道位置“1”。你有两种手段感知这件事:
- 中断方式:给对应 DMA 中断通道注册回调函数(SDK 里
dma_channel_set_irq0_enabled搭配irq_set_exclusive_handler),传输完成自动跳进中断处理。 - 轮询方式:在主循环里不断读
dma_channel_is_busy(channel)或者读CH0_CTRL_TRIG的BUSY位,直到变成 0。
在实际项目里,我自己的习惯是:如果需要连续不断的数据流,用中断 + 双缓冲;如果是低频的单次搬运,直接轮询,代码简单、没有中断上下文切换的开销。
这里补充一个重要细节——CLR 寄存器。你要清理中断标志,不是往INTR写 0,而是往INTR对应的“清除寄存器”写 1。这在 RP2040 的中断系统里很常见,我记得官方寄存器表上也有明确标注,很多人第一次在这个点卡住,中断一直重入,查了半天才发现是中断标志没清掉。
3. 实操过程与核心环节实现
3.1 环境准备:从 SDK 到最小可运行工程
为了让大家能完全复现下面的例程,我先说明实验环境。
- 硬件:树莓派 Pico 开发板(RP2040 芯片),USB 连接电脑。
- 软件:Raspberry Pi Pico SDK(官方 C SDK),编译工具链用
arm-none-eabi-gcc,构建系统用 CMake。 - 调试辅助:一个 USB-TTL 串口模块,或者直接用 Pico 的 USB 虚拟串口,用于打印输出。
Pico SDK 的初始化逻辑里,DMA 控制器会被自动 reset 并释放。如果是从头开始写裸机程序,记得要调用dma_start_channel_mask或者dma_channel_set_irq0_enabled之前,确保 DMA 时钟已经被使能。SDK 默认会在stdio_init_all前后完成这些硬件初始化。
创建新工程的时候需要有一个CMakeLists.txt,它长这样:
cmake_minimum_required(VERSION 3.13) include(pico_sdk_init.cmake) project(dma_demo C CXX ASM) pico_sdk_init() add_executable(dma_demo dma_demo.c ) target_link_libraries(dma_demo pico_stdlib hardware_dma hardware_adc ) pico_enable_stdio_usb(dma_demo 1) pico_enable_stdio_uart(dma_demo 0) pico_add_extra_outputs(dma_demo)注意hardware_dma和hardware_adc这两个库要加进链接列表,否则你调dma_channel_*函数时会报链接错误。
3.2 例程一:内存到内存的 DMA 拷贝,上手第一步
我们从最简单的内存拷贝开始:把一块 256 字节的源缓冲区搬到目标缓冲区,搬完后打印结果。这一步跑通了,DMA 的基础配置流程你就掌握了 80%。
#include <stdio.h> #include "pico/stdlib.h" #include "hardware/dma.h" #define BUF_SIZE 256 // 用 4 字节对齐声明缓冲区,避免 DMA 对齐问题 static uint8_t src_buf[BUF_SIZE] __attribute__((aligned(4))); static uint8_t dst_buf[BUF_SIZE] __attribute__((aligned(4))); int main(void) { stdio_init_all(); sleep_ms(2000); // 等待 USB 串口枚举 // 准备数据 for (int i = 0; i < BUF_SIZE; i++) { src_buf[i] = i & 0xFF; dst_buf[i] = 0; } // 获取一个 DMA 通道(-1 表示自动分配) int chan = dma_claim_unused_channel(true); if (chan < 0) { printf("No free DMA channel\n"); return -1; } // 配置 DMA 通道 dma_channel_config cfg = dma_channel_get_default_config(chan); channel_config_set_transfer_data_size(&cfg, DMA_SIZE_8); // 8-bit 传输 channel_config_set_read_increment(&cfg, true); // 源地址自增 channel_config_set_write_increment(&cfg, true); // 目的地址自增 channel_config_set_dreq(&cfg, DREQ_FORCE); // 无条件搬运:不需要外设触发 // 设置源、目的、传输次数 dma_channel_configure( chan, &cfg, dst_buf, // 目的地址(write address) src_buf, // 源地址(read address) BUF_SIZE, // 传输次数(每次 8-bit,共 256 次) true // 最后一个参数 true 表示立即开始 ); // 等待传输完成 dma_channel_wait_for_finish_blocking(chan); // 校验结果 int errs = 0; for (int i = 0; i < BUF_SIZE; i++) { if (src_buf[i] != dst_buf[i]) { errs++; } } printf("DMA copy done, errors: %d\n", errs); return 0; }这段代码的流程非常典型:拿通道–配置–设置地址–设置次数–立即开始–等待完成–校验。你以后所有的 DMA 用法,不管是 ADC、UART 还是 SPI,本质上都是这个流程的变体,区别只在于触发源、数据宽度和地址自增方式不一样。
编译烧录后,串口会输出DMA copy done, errors: 0。如果你改成false作为最后一个参数,那 DMA 不会立即启动,你需要手动调用dma_channel_start(chan)才触发。这两种“启动方式”在中断配合的时候有区别,后面会提到。
3.3 例程二:ADC 连续采样 + DMA 乒乓缓冲
内存拷贝只是热身,接下来我们玩点实际的。 Pico 的 ADC 模块采样结果寄存器是 12 位的,存储为 16 位半字;如果只用一次转换去读,CPU 需要轮询 ADC 的完成标志,然后再去读结果寄存器。但在连续采样场景下,我们希望 ADC 自动转换、自动把结果搬到内存,完全不需要 CPU 参与。
先讲双缓冲(乒乓缓冲)的思想。你准备两块缓冲区 A 和 B,DMA 先往 A 里写,写满后触发中断,CPU 在中断里处理 A 区数据;同时 DMA 已经切换到 B 区继续写。等 B 区写满,再切回 A。这样软件和硬件就能“流水线”工作,不会出现“搬数据”和“处理数据”互相等待的缝隙。
Pico 的 DMA 通道本身没有自动切换缓冲区地址的硬件功能,但你可以利用“DMA 中断”加“重新配置地址”两步实现乒乓。如果想彻底减少中断次数,还可以用链式传输自动切换,这个例子先保留双缓冲思路,下一节再讲链式。
下面是 ADC 连续采样的核心代码:
#include <stdio.h> #include "pico/stdlib.h" #include "hardware/dma.h" #include "hardware/adc.h" #define ADC_BUF_SIZE 1024 #define ADC_CHANNEL 0 // 两块缓冲区 static uint16_t adc_buf_a[ADC_BUF_SIZE] __attribute__((aligned(4))); static uint16_t adc_buf_b[ADC_BUF_SIZE] __attribute__((aligned(4))); // 当前要处理的缓冲区指针 volatile uint16_t *current_buf; static int dma_chan; void dma_irq_handler(void) { // 检查是否是我们的通道触发中断 if (dma_channel_get_irq_status(dma_chan)) { dma_channel_acknowledge_irq(dma_chan); // DMA 搬完了一块,切换处理指针 if (dma_hw->ch[dma_chan].read_addr == (uint32_t)adc_buf_a) { current_buf = adc_buf_b; } else { current_buf = adc_buf_a; } } } int main(void) { stdio_init_all(); sleep_ms(2000); // 初始化 ADC adc_init(); adc_gpio_init(26 + ADC_CHANNEL); // ADC0 接 GP26 adc_select_input(ADC_CHANNEL); adc_fifo_setup( true, // 使能 FIFO true, // 每个样本一个 32 位结果(高 16 位无效,低 16 位是数据) 1, // 每 1 个样本触发一次 DREQ false, // 不使用错误标志 false // 不右移结果 ); adc_set_clkdiv(0); // 尽可能快的采样 dma_chan = dma_claim_unused_channel(true); dma_channel_config cfg = dma_channel_get_default_config(dma_chan); channel_config_set_transfer_data_size(&cfg, DMA_SIZE_32); // ADC FIFO 建议用 32 位读 channel_config_set_read_increment(&cfg, false); // 源地址固定(ADC_DR) channel_config_set_write_increment(&cfg, true); // 目的地址自增 channel_config_set_dreq(&cfg, DREQ_ADC); // ADC 触发 // 先传第一块 dma_channel_configure(dma_chan, &cfg, adc_buf_a, // 目的地址 &adc_hw->fifo, // 源地址固定为 ADC FIFO ADC_BUF_SIZE, // 数量 true); // 注册并打开中断 dma_channel_set_irq0_enabled(dma_chan, true); irq_set_exclusive_handler(DMA_IRQ_0, dma_irq_handler); irq_set_enabled(DMA_IRQ_0, true); // 启动 ADC 连续采样 adc_run(true); while (1) { // 主循环做其它事,或者在适当时候处理 current_buf 指向的缓冲区数据 tight_loop_contents(); } }关于adc_fifo_setup函数,我解释一下那个true参数:它使能了 FIFO 的SHIFT选项,也就是 ADC 结果在进入 FIFO 时会被左移或右移到 32 位的固定格式,DMA 读 32 位时,低 16 位就是有效数据。如果不开这个选项,DMA 读 16 位会更合适,但会更依赖 FIFO 的行为,没有 32 位模式稳。
这个例程跑起来后,你会看到adc_buf_a和adc_buf_b被交替填满数据,CPU 在中断里只是改一个指针,开销极小。再把“计算平均值”或“找峰值”放到处理缓冲区的地方,一个高效的采样系统就成型了。
3.4 链式传输原理:DMA 自己给下一个 DMA 下发配置
链式传输是 RP2040 DMA 的压轴功能,理解了它,你的 Pico 就等于多了一台能自动编排任务的数据搬运引擎。
链接传输的思想来源是“预置任务列表”。你把一批 DMA 传输任务的配置提前排好放在内存里(包括地址、数量、控制字),然后在通道 A 的CHAIN_TO里填入通道 B 的编号。当通道 A 把自己的任务执行完之后,硬件会自动把内存里的下一份配置加载到通道 B 的寄存器里,同时触发通道 B 开始传输。你甚至可以 A 链到 B、B 链到 C、C 再链回 A,形成一个闭环,不停循环搬运。
我们在实际工程里经常需要做“周期性采集一定长度的数据,再切换缓冲区”,如果没有链式传输,你得在中断里反复重配寄存器,很繁琐。链式传输把这个过程变成了“给设备编排一张表,硬件自己执行”。
链式传输的关键配置有两处:
- CTRL_TRIG 的
CHAIN_TO字段:写上你要在传输完成后跳转到的下一个 DMA 通道编号。 - 内存里的“配置块”布局:RP2040 规定,链式传输加载时,会依次从源地址读取 5 个 32 位字,分别对应
READ_ADDR、WRITE_ADDR、TRANS_COUNT、CTRL_TRIG、CHAIN_TO。这个内存数据结构,官方叫“DMA control block”。
控制块结构可以定义成:
typedef struct { uint32_t read_addr; uint32_t write_addr; uint32_t transfer_count; uint32_t ctrl_trig; uint32_t chain_to; } dma_control_block;硬件的加载过程是这样的:当前通道做完自己的搬运工作后,读取CHAIN_TO字段对应的目标通道编号,然后从当前通道的READ_ADDR寄存器指向的地址(注意!不是WRITE_ADDR)开始,连续读取 5 个字,写入目标通道的对应寄存器。读取的这 5 个字,就是目标通道的完整配置。加载完成后,目标通道会被自动触发开始执行。
有一点要特别提醒:链式传输的“源地址”,是当前通道的READ_ADDR保存的值。很多人在链式配置时,会把当前通道的 READ_ADDR 当成搬运的内存源地址,链到一半发现 DMA 读了不正确的数据,就是因为这里理解反了。准确地说,对于链式传输发起者(A 通道),它的READ_ADDR并不表示“要搬运的数据在哪”,而表示“下一个通道的配置在哪”。而真正要搬运的数据,是由控制块里配置的read_addr和write_addr决定的。
为此,官方 SDK 里还提供了一个非常简单直白的函数:dma_channel_transfer_from_buffer_now(chan, buffer, count)和dma_channel_transfer_to_buffer_now(chan, buffer, count)。但如果你在链式传输里用它们,就要小心了,因为它们会直接改写当前通道的 READ_ADDR 和 WRITE_ADDR,而链式传输的“下一条配置位置”正是从当前通道的 READ_ADDR 里读取的。所以链式传输的控制块位置,不允许和搬运数据的内存缓冲区混在一块儿,否则加载的配置完全不可控。
3.5 例程三:用链式传输循环切换两个 ADC 缓冲区
我们把 ADC 采集的例程升级成链式版本。不再用中断反复配置寄存器,而是预先定义两个 control block,让通道 0 链到通道 1,通道 1 再链回通道 0,循环往两块缓冲里搬数据。
#include <stdio.h> #include "pico/stdlib.h" #include "hardware/dma.h" #include "hardware/adc.h" #define ADC_BUF_SIZE 1024 static uint16_t adc_buf_a[ADC_BUF_SIZE] __attribute__((aligned(4))); static uint16_t adc_buf_b[ADC_BUF_SIZE] __attribute__((aligned(4))); // 控制块最好也 4 字节对齐,避免对齐问题 static dma_control_block ctrl_a __attribute__((aligned(4))); static dma_control_block ctrl_b __attribute__((aligned(4))); volatile uint16_t *current_buf; void dma_irq_handler(void) { if (dma_channel_get_irq_status(0)) { dma_channel_acknowledge_irq(0); // 每次通道 0 完成,说明 A 区已经被填满 current_buf = adc_buf_b; } if (dma_channel_get_irq_status(1)) { dma_channel_acknowledge_irq(1); // 每次通道 1 完成,说明 B 区已经被填满 current_buf = adc_buf_a; } } int main(void) { stdio_init_all(); sleep_ms(2000); // ADC 初始化和 FIFO 配置,与上一节完全一致 adc_init(); adc_gpio_init(26); adc_select_input(0); adc_fifo_setup(true, true, 1, false, false); adc_set_clkdiv(0); int chan0 = dma_claim_unused_channel(true); int chan1 = dma_claim_unused_channel(true); // 通道 0:从 ADC FIFO 搬到 adc_buf_a dma_channel_config cfg0 = dma_channel_get_default_config(chan0); channel_config_set_transfer_data_size(&cfg0, DMA_SIZE_32); channel_config_set_read_increment(&cfg0, false); channel_config_set_write_increment(&cfg0, true); channel_config_set_dreq(&cfg0, DREQ_ADC); dma_channel_configure(chan0, &cfg0, &ctrl_a, // 目的地址先随便填,后面会被控制块覆盖 &adc_hw->fifo, ADC_BUF_SIZE, false); // 不立即开始 // 通道 1:从 ADC FIFO 搬到 adc_buf_b dma_channel_config cfg1 = dma_channel_get_default_config(chan1); channel_config_set_transfer_data_size(&cfg1, DMA_SIZE_32); channel_config_set_read_increment(&cfg1, false); channel_config_set_write_increment(&cfg1, true); channel_config_set_dreq(&cfg1, DREQ_ADC); dma_channel_configure(chan1, &cfg1, &ctrl_b, &adc_hw->fifo, ADC_BUF_SIZE, false); // 构造控制块 // ctrl_a 的 read_addr 指向 ADC FIFO;write_addr 指向 adc_buf_a // chain_to 指向 chan1 ctrl_a.read_addr = (uint32_t)&adc_hw->fifo; ctrl_a.write_addr = (uint32_t)adc_buf_a; ctrl_a.transfer_count = ADC_BUF_SIZE; ctrl_a.ctrl_trig = dma_channel_get_ctrl_value(chan0, &cfg0); // 复用上面的配置值 ctrl_a.chain_to = chan1; // 完成后链到通道 1 // ctrl_b 的 read_addr 指向 ADC FIFO;write_addr 指向 adc_buf_b // chain_to 指向 chan0 ctrl_b.read_addr = (uint32_t)&adc_hw->fifo; ctrl_b.write_addr = (uint32_t)adc_buf_b; ctrl_b.transfer_count = ADC_BUF_SIZE; ctrl_b.ctrl_trig = dma_channel_get_ctrl_value(chan1, &cfg1); ctrl_b.chain_to = chan0; // 完成后链回通道 0 // 手动把通道 0 的 READ_ADDR 指向 ctrl_b。 // 这样当通道 0 从 ctrl_b 链回来时,它加载的是 ctrl_b 的配置。 // 等等……这里要看清楚:我们想让通道 0 执行的是 ctrl_a 对应的搬运任务, // 所以它的链式来源应当是 ctrl_a,而不是 ctrl_b。 // 换个思路:设置通道 0 的 CHAIN_TO = chan1,通道 1 的 CHAIN_TO = chan0, // 同时让通道 0 的 READ_ADDR 指向 ctrl_a(通道 0 自己的任务),这个思路才正确。 // 这里我们不直接在代码里设置 dma_hw->ch[chan0].read_addr,而是要让 // 控制块被正确加载到对应通道。 // 简化做法:直接配置 dma_hw->ch[chan0].read_addr = (uint32_t)&ctrl_a; dma_hw->ch[chan0].read_addr = (uint32_t)&ctrl_a; dma_hw->ch[chan0].ctrl_trig = ctrl_a.ctrl_trig | (1u << 30); // 触发立即开始 // 同样的,通道 1 的 read_addr = &ctrl_b,保持链式闭环不要动它, // 但我们不希望它立刻触发,所以先不写 ctrl_trig。 // 注册中断 irq_set_exclusive_handler(DMA_IRQ_0, dma_irq_handler); irq_set_enabled(DMA_IRQ_0, true); dma_channel_set_irq0_enabled(chan0, true); dma_channel_set_irq0_enabled(chan1, true); // 启动 ADC adc_run(true); while (1) { // 主循环使用 current_buf 中的数据 tight_loop_contents(); } }上面的代码思路是成立的,但我必须承认这段代码写出来有点像“坏味道”,因为它混用了 SDK 函数和直接寄存器访问,读起来不够干净。实际工程里,我更推荐把ctrl_a和ctrl_b当成纯数据结构,然后统一用寄存器指针来启动链式队列,既清晰又高效。修改后的稳定版本长这样:
// 启动链式队列:先把通道 0 的配置加载到硬件寄存器,让它链向通道 1 dma_hw->ch[chan0].read_addr = (uint32_t)&adc_hw->fifo; dma_hw->ch[chan0].write_addr = (uint32_t)adc_buf_a; dma_hw->ch[chan0].transfer_count = ADC_BUF_SIZE; dma_hw->ch[chan0].ctrl_trig = ctrl_a.ctrl_trig; // 写 ctrl_trig 即触发 // 等通道 0 完成,硬件自动加载 ctrl_b 到通道 1,并触发通道 1这里的核心是ctrl_a.ctrl_trig的值必须是从dma_channel_config结构体转换出来的整型,可以直接复用。具体怎么转换呢?SDK 里有一个函数可以做到:
uint32_t dma_channel_get_ctrl_value(uint channel, const dma_channel_config *config);在写控制块的时候,用它把每个通道的 CTRL 值填进去,就能保证触发源、数据宽度、地址自增方式完全一致。如果用错了,就会出现“通道 0 配置为 ADC 触发,但控制块里配成了内存搬运”,链式加载后一切全乱。
3.6 链式传输的调试心得:把控制块打印出来看
链式传输最容易出问题的点就是控制块内存布局和硬件加载顺序的对应。我在调试环形 DMA 的时候,最常用的手段就是先把控制块的内容用串口打印出来,核对每一项:
printf("ctrl_a.read_addr = 0x%08lx\n", (unsigned long)ctrl_a.read_addr); printf("ctrl_a.write_addr = 0x%08lx\n", (unsigned long)ctrl_a.write_addr); printf("ctrl_a.transfer_count = %lu\n", (unsigned long)ctrl_a.transfer_count); printf("ctrl_a.ctrl_trig = 0x%08lx\n", (unsigned long)ctrl_a.ctrl_trig); printf("ctrl_a.chain_to = %lu\n", (unsigned long)ctrl_a.chain_to);然后对照手册检查三个东西:
- 地址是否落在合法的 RAM 或外设地址空间内。
transfer_count是否大于 0。ctrl_trig里DREQ_EN、TREQ_SEL是否符合预期。
这三项没问题,链式传输基本就成功了一大半。剩下的一半,就要靠“管好中断标志”和“注意通道编号唯一性”来兜底了。
3.7 环形缓冲的高级玩法:RING_SIZE 与地址回卷
RP2040 的 DMA 还提供一个少有人注意的“环形缓冲”功能。你可以把写地址限定在一段区域内,每次写满后自动回到区域开头,不需要软件介入。做法是在CTRL_TRIG里配置RING_SIZE和RING_SEL:选中某个地址寄存器作为环,设置环的大小(必须是 2 的幂),硬件会在地址增长到边界时自动抹掉高位,实现回卷。
这个功能特别适合做一个“无限采样环形记录器”。比如你要持续记录一段时间内的 IO 状态变化或者 ADC 波形,使用环形缓冲+SIO 定时器触发 DMA,可以在 CPU 完全不参与的情况下,不断往环形缓冲写入数据;当你想抓取特定的历史片段时,再让 CPU 去读环形缓冲的内容。这对做低功耗唤醒前的数据预触发记录特别有用。
RING_SIZE 的编码方式是RING_SIZE = log2(size) - 2,也就是 4 字节对应 0,8 字节对应 1,16 字节对应 2,以此类推。RING_SEL位选择是让READ_ADDR参与回卷还是WRITE_ADDR参与回卷。这个功能官方文档讲得不够多,实操时建议先在 RAM 里开一块 64 字节的缓冲区试,配合打印观察地址回卷是否正常。
注意:环形缓冲开启后,地址寄存器的低 N 位会被硬件“锁定”,自增到边界时会清低 N 位,而不是真正的“减法回退”。所以你设置的缓冲区首地址必须是环大小的整数倍,否则回卷后地址跳变会错位。
4. 常见问题与排查技巧实录
4.1 DMA 传输不启动,卡在 BUSY=1
现象:代码调用了dma_channel_start(),但数据没搬,读CTRL_TRIG的BUSY位一直是 1。
排查思路:
- 先确认是否使用了 DREQ 触发模式。如果配置成
DREQ_EN=1,而对应的外设从来没有产生过请求,比如DREQ_UART0_RX但 UART 没收到任何数据,那 DMA 通道就会一直“等待触发”,BUSY 一直为 1。这不是错误,是设计如此。 - 如果配置的是无条件搬运(
DREQ_EN=0),那看一下CTRL_TRIG的CHAIN_TO字段是否被链到了别的通道。链式传输时,当前通道搬运完成会自动跳到别的通道,如果你把不存在的通道填进去,硬件可能直接挂起,BUSY 卡住。 - 看一下源地址和目的地址是否在有效范围内。比如源地址指向外设地址段,但地址写错了,总线访问直接 HardFault,DMA 通道也会异常终止。
我之前调试时遇到过一种情况很有迷惑性:使用dma_channel_transfer_from_buffer_now往 SPI TX 发数据,SPI 外设没开启,DMA 一直处于等待 DREQ 的状态,看起来像卡死了。实际查了一下,是 SPI 初始化顺序错了,SPI 没有使能,DREQ 永远不会来。解决顺序问题后,DMA 立刻正常。
4.2 链式传输只执行一段就停住
现象:定义了两个控制块,链式配置也写了,但只搬了第一段数据就再也不动了。
八成是链式加载的控制块有误。记住 RP2040 的链式加载规则:当前通道在执行完搬运任务后,硬件会从当前通道的READ_ADDR寄存器指向的地址加载下一跳的控制块。所以你要让下一跳加载的是通道 B 的配置,当前通道的READ_ADDR必须指向通道 B 控制块的起始地址。
如果你用的是 SDK 函数设置READ_ADDR为数据源地址,那么下一跳的控制块就被加载了个寂寞,硬件会从数据缓冲区里读出一堆无意义的值填进寄存器,链式传输就“断了”。解决方案是:链式发起者的 READ_ADDR 必须指向下一个控制块,而不是数据区。如果既要用链式、又要让传输结束后自动加载数据源地址,那就把数据源地址放进控制块的read_addr字段里,让下一跳的硬件自己去加载。
这里还有一个非常隐蔽的坑:控制块必须保证 4 字节对齐。RP2040 手册里的 DMA 控制块加载逻辑是按 32 位连续读的,如果控制块地址不对齐,轻则数据错位,重则总线错误。
4.3 中断一直触发,callback 里出不来
现象:注册了 DMA 中断回调,每次进去后,还没来得及做数据处理,中断又进来了,程序卡死。
原因几乎永远是没有清除中断标志。RP2040 的 DMA 中断标志必须通过写CLR寄存器来清除,往INTR本身写 1 是没有用的。SDK 提供的dma_channel_acknowledge_irq(channel)函数,本质就是“读状态 → 写清除”。
这就像有人一直按门铃,你不去把门铃电源断掉,它就会一直响。中断标志不清除,硬件就会不断重新触发中断。如果你在回调里用了while循环等待某个标志,那就更危险了——回调永远退不出去。
排查做法:在回调函数开头调用dma_channel_acknowledge_irq(channel);如果用的是裸寄存器,就往INTR对应的 CLR 寄存器写 1。注意,这个操作最好放在数据处理之前,防止处理期间再次触发。
4.4 搬运数据字节错位,高字节跑到低字节
现象:数据搬完了,但内容不对,比如本应是 0x1234,打印出来却是 0x3412。
这是数据宽度配置错了。RP2040 的 DMA 是按“元素”搬运的,不是按字节搬运的。你配DMA_SIZE_8,它一次搬 1 个字节;你配DMA_SIZE_16,它一次搬 2 个字节;配DMA_SIZE_32,一次搬 4 个字节。如果源是 UART 的 8 位 FIFO,你配了 32 位搬运,一次就会读走 4 个 FIFO 数据,字节顺序自然就乱了;反过来,源是 32 位的 ADC FIFO,你配 8 位搬运,一个样本要被拆成 4 次读,读到的高低位也是乱的,最终打印就错位。
还有一种是地址自增方向配错。比如从内存缓冲区读,READ_INCR=0,每次都从同一个地址读,那搬出来的数据自然是同一个字节的重复。你在串口打印看到大量重复数据,要第一时间查这两个位。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决动作 |
|---|---|---|
| DMA 不启动,BUSY=1 | DREQ 等待外设触发 / 链式配置错误 | 查 DREQ_EN、TREQ_SEL;查 CHAIN_TO |
| DMA 只跑一段就停 | 控制块地址或布局错误 | 打印控制块前 5 个字逐一核对 |
| 中断重入卡死 | 未清除中断标志 | 在回调开头调用dma_channel_acknowledge_irq |
| 数据内容错位 | 数据宽度/地址自增配置不对 | 核对 D/W_SIZE、READ_INCR、WRITE_INCR |
| 数据丢帧 | 双缓冲切换没有锁 | 在中断中先切换当前指针,再清标志 |
| 地址回卷不对 | 环大小不是 2 的幂或首地址未对齐 | 检查 RING_SIZE 编码和缓冲区首地址对齐 |
5. 踩坑总结与性能调优再进阶
其实写到这里,Pico DMA 的从寄存器到链式传输的核心链路已经全部跑通了,最后再分享两个层面的内容:一个是实际调优过程中沉淀下来的几点心得,另一个是往更高级组合用法的延伸方向。
5.1 我的三点实操心得
第一点:能用轮询就别用中断,能用一次触发就别用链式。DMA 虽然缓解了 CPU 搬运压力,但中断处理本身也有上下文切换开销。在很多场景里,一个 DMA 中断进来,CPU 保存现场、执行回调、恢复现场,比直接开while (!dma_channel_is_busy(chan))轮询还慢。只有在数据流持续产生、且需要双缓冲交替处理时才值得用中断 + 链式。我见过不少开源项目无脑 DMA+中断,负载一高反而更容易出问题。
第二点:控制块数据结构放在.dma_control段或者静态区,别放在栈上。链式传输是硬件在“异步”读取控制块,如果控制块放在某个函数的栈上,函数返回后栈内存随时可能被覆盖。虽然 RP2040 的 DMA 加载速度极快,但时序上仍然存在竞争风险。稳妥起见,就是全局数组或者静态结构体,并在声明时加上对齐属性。
第三点:寄存器配置之前先调用dma_channel_abort或等待通道空闲。重复触发传输时,如果通道还在忙,新配置会被旧状态干扰。你会发现第一次传输正常,第二次就不动了。网上很多人反映 RP2040 DMA“不稳定”,其实一半是这个原因:没有养成“先停、再配、再启动”的操作习惯。
5.2 高级组合用法:DMA + PIO 的无限可能
RP2040 的 PIO(可编程 IO)模块也是一个底层利器。PIO 可以模拟各种外设协议,再配合 DMA 实现“数据从内存自动流到 PIO 输出的引脚上”。这意味着你可以用 PIO+DMA 做出自定义波形发生器、WS2812 灯带控制器、逻辑分析仪等。每次 DMA 传输完成后,通过链式切换到下一个缓冲区,就能连续输出任意长度的数据流。
我在写灯带驱动的时候,用 DMA 把颜色数据一块一块喂给 PIO 的 FIFO,CPU 只负责计算下一帧颜色,效率非常高。这种玩法在你理解了 DMA 底层之后,门槛会很低——其实原理就是:PIO 触发 DMA,DMA 搬运数据到 PIO FIFO,DMA 完成后再链到下一个控制块。你之前学到的所有知识,在这里直接融会贯通。
如果你的项目需要“边采样边处理”“边接收边转发”“连续波形输出”这些复杂数据流,RP2040 的 DMA 绝对是最值得花时间吃透的外设之一。从寄存器到链式传输,本质上是把一个重复性的硬件任务“编排”得非常优雅。希望这篇长文能帮你省下我当初踩坑的那几个通宵。