ARTICLE DETAIL

资讯详情

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

RP2040 DMA深入解析:从寄存器到链式传输的实战指南

RP2040 DMA深入解析:从寄存器到链式传输的实战指南 前阵子调试一个数据采集项目MCU 要持续从 ADC 搬数据、同时还得处理传感器协议栈CPU 占用率直接飙到 60% 以上中断一多连主循环都快跑不动了。后来把数据搬运全部丢给 DMACPU 占用一下降到 10% 以内。这类“CPU 被数据搬运拖垮”的问题在很多嵌入式项目里都特别典型而树莓派 Pico 搭载的 RP2040 芯片刚好把 DMA 做得既强大又“折腾人”——通道多、触发源多、还支持链式传输但寄存器配置坑也不少。这篇文我从寄存器层面一层层剥开 RP2040 的 DMA 控制器。你会搞明白 DMA 到底怎么工作的、为什么需要配置那些寄存器、一个正经传输怎么从零写出来、链式传输又解决了什么问题最后再附上实际调板子时的经验和踩坑记录。不管是刚上手 Pico 的嵌入式新手还是想把 RP2040 性能榨干的老手这篇都值得收藏。1. 内容整体设计与思路拆解1.1 为什么 MCU 需要 DMA先理解 CPU 搬运数据的浪费先解决一个最基础的问题为什么要用 DMACPU 直接搬数据不就行了数据搬运这个动作听起来简单但背后是一整套指令流的开销。比如说要把外设 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_RXUART0发送 FIFO 为空DREQ_UART0_TXADC转换完成DREQ_ADCSPI0RX FIFO 收到数据DREQ_SPI0_RXPWM计数器归零/到达阈值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偏移 0x0032 位寄存器存放源地址。硬件每搬运一次会自动根据数据宽度和传输方向自增、自减或保持不变。WRITE_ADDR偏移 0x0432 位寄存器存放目的地址。同样可以配置自增/自减/固定。TRANS_COUNT偏移 0x0832 位寄存器存放剩余传输次数。硬件每搬一次就减 1减到 0 表示传输完成。这个寄存器是 DMA 搬运的“倒计时器”搬完一次就归零再开始新传输必须重新写入新的值。CTRL_TRIG偏移 0x0C既是配置寄存器也是触发寄存器。写入这个寄存器会同时完成“配置通道参数”和“触发启动”两件事。此外还有一个 CHAIN_TO偏移 0x10寄存器专门负责预置链式传输的下一跳通道号这是链式传输的灵魂。CTRL_TRIG 是配置的“核心中的核心”下面这张表整理了它关键位的含义配置的时候对照着填就可以了。位段名称含义31QUIET1 表示内存到内存传输时不产生 DREQ 请求只在 no-quiet 时等待触发30BUSY只读状态位1 表示通道正在忙28WRITE_SNIFF / READ_SNIFF是否在写入或读取时进行数据校验26:24TREQ_SEL选择触发源0x3A 表示定时器触发0x00 表示无条件触发23:22CHAIN_TO链式传输模式配置注意这个字段跨了两位21:20RING_SEL / RING_SIZE环状缓冲大小设置17:16WRITE_INCR目的地址自增模式15:14READ_INCR源地址自增模式13:12D/W_SIZE数据宽度0字节 8bit1半字 16bit2全字 32bit11:5DREQ_EN触发使能0 表示无条件搬运1 表示等待 DREQ4:0DATA_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_EN0时DMA 通道一旦被触发就会连续不断地把TRANS_COUNT次数据全部搬完中间不等待任何外部信号。这种模式适合内存拷贝、或者从已知长度的 Flash 缓冲区搬到 RAM。DREQ 握手搬运DREQ_EN1时DMA 的每一次搬运都要等对应外设发出“请求”信号。比如你配了TREQ_SELDREQ_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_INCRCTRL_TRIG的READ_INCR和WRITE_INCR决定了每次传输后源地址和目的地址怎么变化。READ_INCR0源地址固定。适合从外设 FIFO 连续读取比如 UART RX、SPI RX。READ_INCR1源地址每次自增幅度由数据宽度决定字节1、半字2、全字4。适合从内存缓冲区读取。WRITE_INCR0目的地址固定。适合往外设 FIFO 连续写入比如 UART TX、SPI TX。WRITE_INCR1目的地址每次自增。适合往内存缓冲区写入。不少新手容易犯的错是把“从外设读”配成“源地址自增”结果每个字节都往相邻地址搬最终数据全乱或者“往外设写”时忘了配WRITE_INCR0写地址越走越远外设 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 和 BDMA 先往 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 FIFOwrite_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 FIFOwrite_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 字节对应 08 字节对应 116 字节对应 2以此类推。RING_SEL位选择是让READ_ADDR参与回卷还是WRITE_ADDR参与回卷。这个功能官方文档讲得不够多实操时建议先在 RAM 里开一块 64 字节的缓冲区试配合打印观察地址回卷是否正常。注意环形缓冲开启后地址寄存器的低 N 位会被硬件“锁定”自增到边界时会清低 N 位而不是真正的“减法回退”。所以你设置的缓冲区首地址必须是环大小的整数倍否则回卷后地址跳变会错位。4. 常见问题与排查技巧实录4.1 DMA 传输不启动卡在 BUSY1现象代码调用了dma_channel_start()但数据没搬读CTRL_TRIG的BUSY位一直是 1。排查思路先确认是否使用了 DREQ 触发模式。如果配置成DREQ_EN1而对应的外设从来没有产生过请求比如DREQ_UART0_RX但 UART 没收到任何数据那 DMA 通道就会一直“等待触发”BUSY 一直为 1。这不是错误是设计如此。如果配置的是无条件搬运DREQ_EN0那看一下CTRL_TRIG的CHAIN_TO字段是否被链到了别的通道。链式传输时当前通道搬运完成会自动跳到别的通道如果你把不存在的通道填进去硬件可能直接挂起BUSY 卡住。看一下源地址和目的地址是否在有效范围内。比如源地址指向外设地址段但地址写错了总线访问直接 HardFaultDMA 通道也会异常终止。我之前调试时遇到过一种情况很有迷惑性使用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_INCR0每次都从同一个地址读那搬出来的数据自然是同一个字节的重复。你在串口打印看到大量重复数据要第一时间查这两个位。4.5 常见问题速查表现象可能原因解决动作DMA 不启动BUSY1DREQ 等待外设触发 / 链式配置错误查 DREQ_EN、TREQ_SEL查 CHAIN_TODMA 只跑一段就停控制块地址或布局错误打印控制块前 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 输出的引脚上”。这意味着你可以用 PIODMA 做出自定义波形发生器、WS2812 灯带控制器、逻辑分析仪等。每次 DMA 传输完成后通过链式切换到下一个缓冲区就能连续输出任意长度的数据流。我在写灯带驱动的时候用 DMA 把颜色数据一块一块喂给 PIO 的 FIFOCPU 只负责计算下一帧颜色效率非常高。这种玩法在你理解了 DMA 底层之后门槛会很低——其实原理就是PIO 触发 DMADMA 搬运数据到 PIO FIFODMA 完成后再链到下一个控制块。你之前学到的所有知识在这里直接融会贯通。如果你的项目需要“边采样边处理”“边接收边转发”“连续波形输出”这些复杂数据流RP2040 的 DMA 绝对是最值得花时间吃透的外设之一。从寄存器到链式传输本质上是把一个重复性的硬件任务“编排”得非常优雅。希望这篇长文能帮你省下我当初踩坑的那几个通宵。
返回列表