拓冰建站拓冰建站
首页 / 资讯中心 / 正文

RP2040 DMA从寄存器到链式传输:高频实战与避坑指南

先聊一个很实际的场景你用树莓派 Pico 做数据采集ADC 连续出数主循环里还得同时响应按键、刷屏幕、跑算法。如果每来一个采样都让 CPU 去读寄存器那 MCU 一半的算力都耗在等待上了。DMA 就是为这种场景准备的但很多从 STM32 转过来的朋友刚接触 RP2040 的 DMA 时都会愣一下寄存器名不熟SDK 封装又太厚想查底层的链式传输网上资料不是照搬数据手册就是只讲 API 怎么调。这篇文章我打算从寄存器开始一路拆到 CHAIN_TO 链式传输再把 UART 不定长接收、ADC 连续采样、PIO 流式传输这几个高频场景的配置都过一遍最后把我在实际调试中踩过的坑拿出来晒一晒。不管你之前有没有用过 Pico 的 DMA读完至少能对着寄存器说清楚每个位是干嘛的。1. RP2040 的 DMA 和你在 STM32 上学到的不太一样1.1 它更像一个“可编程搬运工”而不是“描述符引擎”RP2040 内部有一个简单的总线结构两个 Cortex-M0 核心、Flash、SRAM、APB 外设和 PIO 都挂在同一个系统总线上DMA 在这里是一个独立的总线主机。它的任务很单纯把数据从某个地址搬到另一个地址搬完告诉你一声或者按预设继续搬下一段。很多 MCU 的 DMA 支持所谓的“内存描述符表”——你把一批传输描述放在 RAM 里DMA 从内存不断加载下一个描述从而自动完成一整串搬运。RP2040 没有这个机制。它的每一个 DMA 通道就是一组寄存器通道要做什么完全由寄存器里的值决定。那你想连续搬好几段数据怎么办答案是靠“多通道”和“链式触发”一个搬完了用硬件逻辑直接触发下一个通道这个触发链就是 RP2040 的“描述符”。理解这一点很重要因为后面所有设计都围绕它展开。只是背几个 SDK 函数一旦遇到需要连续搬不同源地址数据的场景你就会不知道从哪下手。1.2 12 个通道分配方案要提前想RP2040 一共有 12 个 DMA 通道编号 0 到 11。每个通道完全独立都有自己的源地址、目标地址、计数和控制寄存器。有些项目一个通道就够但复杂一点的项目比如 PIO 刷灯带 UART 收数据 ADC 采样同时跑就得提前规划通道分配。我给自己的规矩是项目一开始就画一张通道占用表哪个通道给哪个外设谁链到谁谁允许和谁共享中断都写清楚。否则后期加一个功能就得找空通道代码越改越乱。1.3 这篇文章里的示例代码怎么跑后面的代码都基于 Pico SDK 1.5 以上的版本开发环境用官方的 CMake 工程模板就行。硬件上任何一块 RP2040 开发板都可以不需要特殊外设。涉及 ADC、UART、PIO 的例子里GPIO 引脚自己按需调整。2. 寄存器地图一个通道就是一组“搬运说明”2.1 通道基地址与寄存器布局DMA 控制器的基地址是0x50000000每个通道占用 0x100 字节的空间。通道 0 的寄存器在0x50000000通道 1 在0x50000100依此类推。这种规整的布局让寄存器操作代码非常好写。单个通道里最常用的寄存器就那么几个我列个表寄存器偏移作用READ_ADDR0x00搬运的源地址WRITE_ADDR0x04搬运的目标地址TRANS_COUNT0x08搬运次数读回的是剩余次数CTRL_TRIG0x0C控制字写该寄存器可触发启动AL1_CTRL / AL2_CTRL / AL3_CTRL0x10 / 0x14 / 0x18三套影子控制字AL1_READ_ADDR0x20影子源地址 1AL1_WRITE_ADDR0x24影子目标地址 1AL1_TRANS_COUNT_TRIG0x28影子计数 1写该寄存器用影子配置触发AL2_READ_ADDR 等0x30 起影子集 2AL3_READ_ADDR 等0x40 起影子集 3CTRL_TRIG 这个寄存器名字很有意思它既是控制寄存器又是触发器。你在写这个寄存器的时候带上触发位传输就开始了。2.2 READ_ADDR / WRITE_ADDR / TRANS_COUNT搬运三元组DMA 最核心的逻辑就是“从哪读、写到哪、搬几次”。READ_ADDR源地址。外设到内存的传输里它通常指向外设的某个数据寄存器。WRITE_ADDR目标地址。内存到外设的传输里它通常指向外设发送 FIFO 的地址。TRANS_COUNT搬运的次数不是字节数。每次搬运的大小由 CTRL_TRIG 里的 DATA_SIZE 决定可以是 8 位、16 位或者 32 位。举个例子DATA_SIZE 选 32 位TRANS_COUNT 写 256那么总共搬运 256 个 32 位字也就是 1024 字节。如果你想搬 1024 字节但 DATA_SIZE 选 8 位TRANS_COUNT 就得写 1024。这里有个常见误解TRANS_COUNT 读回来是剩余次数不是初始值。程序跑到一半想看看进度读出来的数和初始值一减才是已经完成的次数。这个细节后面排查问题时会反复用到。2.3 CTRL_TRIG 的位域一个寄存器管住所有节奏CTRL_TRIG 是整个 DMA 通道里信息量最大的寄存器值得逐位看。位域名字作用bit 0BUSY通道正在搬运时为 1只读bit 2TRANS_COUNT_TRIGGER写 1 触发一次传输bit 3READ_ERROR源地址读异常时置位bit 4WRITE_ERROR目标地址写异常时置位bits 5-10TREQ_SEL选择数据请求源即 DREQbit 12IRQ_QUIET完成后不产生中断适合链中间的通道bit 13BSWAP字节序交换bit 14SNIFF_EN开启数据嗅探用于 CRC/校验bit 15EN通道使能bit 16HIGH_PRIORITY高优先级仲裁bits 17-18DATA_SIZE0 字节1 半字2 字bit 19INCR_READ源地址自动递增bit 20INCR_WRITE目标地址自动递增bits 21-25RING_SIZE回绕大小2 的幂次bit 26RING_SEL1 写地址回绕0 读地址回绕bits 28-31CHAIN_TO完成后触发的目标通道我最想强调两个容易被忽略的点一是 DATA_SIZE。它决定了一个“搬运次数”单位是多大。搬到外设发送寄存器时如果外设是按 32 位字收数据的你就要把 DATA_SIZE 设成字不然数据错位。二是 TREQ_SEL。它决定通道什么时候搬一个“节拍”。没有 DREQ 限制时TREQ_SEL 选 FORCE整个传输会以内存速度一口气跑完。选中某个外设的 DREQ 后DMA 会等外设发出请求才搬一个节拍比如 UART 每收到一个字节DMA 才搬一次。这样 DMA 就不会跑到外设前面。2.4 AL1/AL2/AL3三个影子配置每个通道除了主寄存器组还有三套影子寄存器组。影子寄存器的作用是让你在通道还在搬运的时候提前把下一轮搬运的地址、计数、控制字准备好。需要切换时直接写对应影子组的 TRANS_COUNT_TRIG 寄存器用一套全新的配置触发同一条通道。这套机制是 RP2040 实现 ping-pong 双缓冲的关键。比如 ADC 连续采样你在主配置里写缓冲区 A在 AL1 里写缓冲区 B。当前传输快完成时CPU 把 AL2 或主寄存器更新成下一块配合中断实现无缝交替填数据。不过日常项目中大部分情况下你只需要主寄存器就够了影子寄存器是在追求高吞吐时才需要碰的东西。3. 不依赖 SDK拿裸寄存器跑通第一次 DMA 搬运3.1 寄存器版内存搬运理解了寄存器我们直接上手写一个纯寄存器版本的内存拷贝。目标是把一个 64 个字的数组搬到另一块内存。这个例子不依赖任何 SDK 的 DMA 封装只用到硬件寄存器地址。#include pico/stdlib.h #define CH0_BASE 0x50000000u #define CH0_READ_ADDR (CH0_BASE 0x00) #define CH0_WRITE_ADDR (CH0_BASE 0x04) #define CH0_TRANS_COUNT (CH0_BASE 0x08) #define CH0_CTRL_TRIG (CH0_BASE 0x0c) void dma_memcpy_demo(void) { uint32_t src[64]; uint32_t dst[64] {0}; for (int i 0; i 64; i) src[i] i; // 先写地址和计数最后再写 CTRL_TRIG这个顺序是必须的 *(volatile uint32_t *)CH0_READ_ADDR (uint32_t)src; *(volatile uint32_t *)CH0_WRITE_ADDR (uint32_t)dst; *(volatile uint32_t *)CH0_TRANS_COUNT 64; // DATA_SIZE字(217) | INCR_READ | INCR_WRITE | EN | 触发位 *(volatile uint32_t *)CH0_CTRL_TRIG (2u 17) | (1u 19) | (1u 20) | (1u 15) | (1u 2); // 等 BUSY 位清零 while (*(volatile uint32_t *)CH0_CTRL_TRIG (1u 0)) ; }关键点是写入顺序地址、计数要先写好最后一次写 CTRL_TRIG 把 EN 和触发位同时置位。顺序反了的话通道可能在地址还没写全时就收到触发开始搬运结果就是搬运了错误的区域。TREQ_SEL 没设置时默认值就是 FORCE也就是每个节拍都处于“有请求”状态所以内存到内存的拷贝会直接跑完。这种场景下 DATA_SIZE、INCR_READ、INCR_WRITE 这三个字段决定数据怎么移动。3.2 用 SDK 做同样的事顺便看看它替你写了什么上面这段逻辑用 SDK 封装来写会更安全而且能帮你省掉对齐、位偏移之类的操心#include hardware/dma.h 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, true); channel_config_set_write_increment(cfg, true); dma_channel_configure(0, cfg, dst, src, 64, true); // 最后一个参数 true 表示立即触发 dma_channel_wait_for_finish_blocking(0);dma_channel_configure的内部就是把你传进去的参数拆开依次写入 READ_ADDR、WRITE_ADDR、TRANS_COUNT 和 CTRL_TRIG。dma_channel_start则是单独把 CTRL_TRIG 里的 EN 和触发位补上。我在实际工程里建议优先用 SDK因为hardware/dma.h里的dma_channel_configure会对通道申请、别名地址这些细节做处理。但裸寄存器版本能让你在排查问题时真正读懂硬件行为两套都要会。3.3 DREQDMA 是按节拍跑的不是乱跑刚才说 TREQ_SEL 选 FORCE 时内存搬运会一口气跑完但如果源或目标连的是外设情况就不同了。比如 UART 发送外设的发送 FIFO 满的时候你硬往里面写数据会丢。DMA 的解决办法是每个节拍都等外设的 DREQ 信号FIFO 有空位了才搬一个数。这就是 DREQ 存在的意义。DMA 通道本身像一个流水线工人DREQ 就是流水线传送带送过来的工件有工件才动手没工件就等着。你给通道配置 DREQ 时实际就是在告诉硬件“你听谁的指挥”。常见的外设 DREQ 枚举值在 SDK 的hardware/dma.h里都定义好了比如DREQ_UART0_RX、DREQ_SPI0_TX、DREQ_ADC。用到的时候直接查头文件不用背。3.4 用 DMA 定时器做周期性搬运有些场景下既不是内存搬运也不是外设 FIFO 搬运而是想要一个稳定的节拍源。RP2040 的 DMA 自带几个定时器可以作为 DREQ 源使用。// 让 DMA 定时器 0 产生一种节拍 dma_timer_set_fraction(DMA_TIMER0, 1, 10000); dma_channel_config cfg dma_channel_get_default_config(1); channel_config_set_transfer_data_size(cfg, DMA_SIZE_32); channel_config_set_read_increment(cfg, true); channel_config_set_write_increment(cfg, true); channel_config_set_dreq(cfg, DREQ_DMA_TIMER0); dma_channel_configure(1, cfg, dst, src, 64, true);这样每来一个定时器节拍通道就搬一个 32 位字。你不需要 CPU 参与也不需要外部中断。采集系统里经常用它生成周期性的触发脉冲配合 ADC 做到定频采样。dma_timer_set_fraction的具体频率参数语义建议翻一下 SDK 注释它内部用的是系统时钟分频加定时器周期具体参数与应用场景强相关。4. 链式传输拆解CHAIN_TO 才是把 12 个通道玩起来的核心4.1 链式触发到底做了什么链式传输这个词听起来高端底层其实就是 CTRL_TRIG 的 CHAIN_TO 字段。当一个通道的 TRANS_COUNT 减到 0传输完成时硬件会自动给 CHAIN_TO 指向的通道发一个触发信号效果等同于你手动写了那个目标通道的触发位。注意这里触发的是一整条目标通道不是“当前通道加载新配置”。也就是说每个链节点都是一条独立通道可以配置完全不同的源地址、目标地址、数据大小、DREQ 源。这是 RP2040 DMA 做“多段搬运”的主要手段。链式传输最反直觉的地方是目标通道被触发时它的 EN 位必须已经是 1。如果目标通道 EN 是 0触发信号来了它也不会动。SDK 里直接用dma_channel_configure(..., false)配置一个通道时默认不会设置 EN所以很多人在链式传输上翻车就是这个细节。用 SDK 配置链时记得给链上的每个通道都显式打开 ENdma_channel_config c0 dma_channel_get_default_config(0); channel_config_set_enable(c0, true); channel_config_set_chain_to(c0, 1); dma_channel_config c1 dma_channel_get_default_config(1); channel_config_set_enable(c1, true); channel_config_set_chain_to(c1, 2);4.2 一个三通道链的完整实现假设我要把三段不连续的数据拼成一个完整的发送帧一个 4 字节的头部一段 32 字节的负载一个 2 字节的尾部。直接写 CPU 循环当然可以但用链式 DMA 更优雅而且不占 CPU。#include hardware/dma.h void chained_transfer_demo(void) { uint8_t header[4] {0xAA, 0xBB, 0xCC, 0xDD}; uint8_t payload[32]; uint8_t footer[2] {0x0D, 0x0A}; uint8_t out[38]; for (int i 0; i 32; i) payload[i] (uint8_t)i; // 通道 0搬头部完成后触发通道 1 dma_channel_config c0 dma_channel_get_default_config(0); channel_config_set_transfer_data_size(c0, DMA_SIZE_8); channel_config_set_read_increment(c0, true); channel_config_set_write_increment(c0, true); channel_config_set_enable(c0, true); channel_config_set_chain_to(c0, 1); dma_channel_configure(0, c0, out, header, sizeof(header), false); // 通道 1搬负载完成后触发通道 2 dma_channel_config c1 dma_channel_get_default_config(1); channel_config_set_transfer_data_size(c1, DMA_SIZE_8); channel_config_set_read_increment(c1, true); channel_config_set_write_increment(c1, true); channel_config_set_enable(c1, true); channel_config_set_chain_to(c1, 2); dma_channel_configure(1, c1, out 4, payload, sizeof(payload), false); // 通道 2搬尾部最后一个通道不需要再链 dma_channel_config c2 dma_channel_get_default_config(2); channel_config_set_transfer_data_size(c2, DMA_SIZE_8); channel_config_set_read_increment(c2, true); channel_config_set_write_increment(c2, true); channel_config_set_enable(c2, true); dma_channel_configure(2, c2, out 36, footer, sizeof(footer), false); // 只启动通道 0后面完全由硬件自己接力 dma_channel_start(0); dma_channel_wait_for_finish_blocking(2); }执行完以后out里就是完整的 38 字节帧。这期间 CPU 只做了一次启动和一次等待所有搬运都是硬件自动完成的。如果我们在通道 2 的 CHAIN_TO 里再指向通道 0就能形成一个循环链适合持续的乒乓传输。4.3 一定要记住CHAIN_TO0 是不触发不是链到 0 号通道这是 RP2040 设计上一个特别容易踩的坑。CHAIN_TO 字段里0 被用作“无链式触发”的保留值所以你想链到 0 号通道是做不到的。如果你的链结构里 0 号通道恰好是第二段可以把链头放到别的通道上或者换一下通道编号让第一段不是 0 号。这个限制在数据手册里有明确说明但很多人不看注册表注释设计完发现链到 0 怎么也触发不了排查半天。我自己第一次用链式传输就栽在这里所以特意提醒一句。4.4 选型链式、环形、还是中断里重配连续多段搬运不止链式这一条路实际项目里可以根据情况选场景推荐方式原因固定分段段数少链式多通道硬件自动接力延迟最小缓冲区需要首尾相接单个通道 RING 回绕省通道硬件自动做环形索引每段长度和地址都动态变化中断里重配灵活但中断延迟要可控双缓冲高速采集两个通道互相链无缝交替CPU 只处理满缓冲区链式并不是万能的。如果你每一段的大小、源地址都要在运行中动态计算那不如用一个中断在传输完成时重新配置同一通道代码更简单。反过来如果段数和参数在编译期就确定链式明显更省心。5. 高频实战UART 不定长、ADC 连续采样、PIO 流式传输5.1 UART 不定长接收DMA 加环形缓冲是标准答案串口接收不定长数据是评论区反复被问到的需求。用 DMA 接收不定长数据核心思路不是“知道什么时候收完”而是“不管来多少字节先全部收进缓冲区主循环里有空闲再去解析”。最简单可靠的办法是用 DREQ_UART0_RX 把每个收到的字节搬入一个 256 字节的环形缓冲区。关键在 RING 回绕设置写地址回绕后DMA 写到缓冲区末尾会自动跳回开头天然形成一个环形缓冲。#include hardware/uart.h #include hardware/dma.h #define RING_SIZE_LOG2 8 #define RING_SIZE (1u RING_SIZE_LOG2) static uint8_t rx_ring[RING_SIZE] __attribute__((aligned(RING_SIZE))); void uart_rx_dma_init(uint uart, uint tx_gpio, uint rx_gpio) { uart_init(uart, 115200); gpio_set_function(tx_gpio, GPIO_FUNC_UART); gpio_set_function(rx_gpio, GPIO_FUNC_UART); 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, false); // 固定读 UART 数据寄存器 channel_config_set_write_increment(cfg, true); channel_config_set_dreq(cfg, DREQ_UART0_RX); channel_config_set_ring(cfg, true, RING_SIZE_LOG2); // 写地址 256 字节范围内回绕 // 计数设成一个极大的值正常使用永远不会“完成” dma_channel_configure(0, cfg, rx_ring, uart_get_hw(uart0)-dr, UINT32_MAX, true); }这里有两个必须注意的地方。第一环形缓冲区地址要对齐到回绕边界。RING_SIZE 是 2 的幂次缓冲区基地址也必须是同样大小的幂次对齐否则 DMA 计算回绕位置时会出错。我在代码里用__attribute__((aligned(RING_SIZE)))直接声明这个约束。第二DMA 读uart_get_hw(uart0)-dr这个数据寄存器会顺带把 UART 的接收 FIFO 标志清掉所以你的代码里不能用uart_getc跟 DMA 抢数据。主循环里要做的只是读 DMA 的写地址算出从上次处理位置到现在收到了多少数据。uint32_t pos dma_hw-ch[0].write_addr - (uint32_t)rx_ring;然后用pos和上次记录的位置做差从环形缓冲区里依次取出新数据。这个方法不管一帧来多少字节都不会丢代价是解析延迟取决于主循环多久来扫一次。如果你希望帧结束时立刻得到通知PL011 核还有接收超时中断RTIM可以在串口空闲一段时间后触发一次中断非常适合帧间隔固定的协议。SDK 没有完整暴露这个接口需要直接操作uart_get_hw(uart)-imsc寄存器// 只使能接收超时中断不使能逐字节中断 uart_get_hw(uart0)-imsc | UART_UARTIMSC_RTIM_BITS;具体宏名可以翻hardware/regs/uart.h确认平台上对应的位都有定义。5.2 ADC 多通道连续采样FIFO 加 DMA 一次搞定RP2040 的 ADC 只有一个逐次逼近型转换器但支持多通道轮询。它自带一个 4 深度的 FIFO开启 FIFO 的 DREQ 后每次转换完成都会产生一个 DMA 请求。我们只要让 DMA 把 FIFO 里的数据按顺序搬到内存数组里就能得到一波连续采样的数据CPU 完全不用参与。#include hardware/adc.h #include hardware/dma.h #define SAMPLE_COUNT 1024 static uint16_t adc_samples[SAMPLE_COUNT]; void adc_dma_init(void) { adc_init(); adc_gpio_init(26); adc_gpio_init(27); // 开启 FIFODREQ 使能阈值 1不启用错误位12bit 模式 adc_fifo_setup(true, true, 1, false, false); // 轮询 ADC0 和 ADC1 adc_set_round_robin(3); adc_select_input(0); dma_channel_config cfg dma_channel_get_default_config(0); channel_config_set_transfer_data_size(cfg, DMA_SIZE_16); channel_config_set_read_increment(cfg, false); // 固定读 FIFO channel_config_set_write_increment(cfg, true); channel_config_set_dreq(cfg, DREQ_ADC); dma_channel_configure(0, cfg, adc_samples, adc_hw-fifo, SAMPLE_COUNT, true); adc_run(true); }这里有个值得注意的点DREQ_ADC 每次转换完成都会触发而 FIFO 深度只有 4。如果 DMA 没能及时把 FIFO 数据搬走新一轮转换结果可能会覆盖旧数据或者触发错误标志。所以采样率越高DMA 通道的优先级越要保证必要时给这条通道开HIGH_PRIORITY。多通道轮询时采样数据是按轮询顺序排列的。比如轮询了 ADC0 和 ADC1那数组里偶数下标是 ADC0 的结果奇数下标是 ADC1 的结果。要拿平均值、做滤波都在主循环里对数组做后处理就行。5.3 PIO 流式传输DMA 和状态机天然匹配PIO 是 RP2040 最有特色的外设但它本身没有数据存储能力数据进出来都要靠 FIFO。当你的状态机需要持续消费一段较大的数据比如刷 WS2812 灯带、驱动自定义串行协议用 DMA 往 PIO 的 TX FIFO 里灌数据是最常规的玩法。DMA 的 DREQ 选择DREQ_PIO0_TX0写地址固定指向pio0_hw-txf[0]。PIO 的 TX FIFO 每空出一个位置DREQ 就会请求一个节拍DMA 自动补一个 32 位字。这就是纯硬件层面的“背压”状态机消费多快DMA 就搬多快天然不会溢出。dma_channel_config cfg dma_channel_get_default_config(4); channel_config_set_transfer_data_size(cfg, DMA_SIZE_32); channel_config_set_read_increment(cfg, true); channel_config_set_write_increment(cfg, false); channel_config_set_dreq(cfg, DREQ_PIO0_TX0); dma_channel_configure(4, cfg, pio0_hw-txf[0], led_data, led_count, true);当时我把这个 DMA 通道接在 PIO 后面跑灯带CPU 主循环完全可以去处理别的事只有整条灯带刷完后才需要重新触发下一次。这种“外设自己取数据”的思路才是 DMA 真正省 CPU 的地方。6. 排查实录这些坑我都是真正踩过之后才想明白的6.1 传输不动了先看 BUSY 和错误位DMA 出问题时最常见现象是 BUSY 一直为 1程序卡在等待循环里。很多人第一反应是数 TRANS_COUNT但真正有用的信息在 CTRL_TRIG 的 READ_ERROR 和 WRITE_ERROR 位。如果你给 READ_ADDR 写了一个未映射的地址DMA 会一直挂在那里读BUSY 永远不结束READ_ERROR 被硬件置位。排查时先把这个值读出来看一眼能省掉大量抓头发时间。恢复错误状态的方式是调用dma_channel_abort强制中止通道然后重新配置。6.2 TRANS_COUNT 读回的是剩余次数不是初始值这句话值得反复强调。我见过有人用 TRANS_COUNT 判断“还剩多少”用着用着发现数值不对仔细一看原来读回的就是剩余值。如果你要监控传输进度用初始值减当前值是最直接的办法但要注意计数在传输中随时变化读到的数据只是一个快照不能用于精确判断某个节拍是否已经完成。环形缓冲场景下更推荐直接读通道的 WRITE_ADDR通过地址差计算已经接收的数据量比读计数更直观。6.3 环形缓冲的地址对齐是硬条件channel_config_set_ring用起来很爽但它对缓冲区地址的要求非常严格缓冲区基地址必须按回绕大小对齐。回绕大小是 256 字节地址就必须是 256 字节对齐。如果不满足DMA 计算回绕位置时会把不该回绕的地址也当成回绕点数据写到缓冲区外面或者覆盖到开头之外的位置。这个坑在 UART 环形缓冲区里最容易出现。一旦遇到“数据前段正常后段跑到奇怪地址”的毛病先检查对齐再检查其他。6.4 长链传输记得开 IRQ_QUIET链式传输的好处是硬件自动接力但代价是链上每个通道完成时都会把自己的状态反映到中断标志里。如果你的链有 6 段每完成一段都触发一次中断中断服务程序会被高频打扰。此时给中间通道设置IRQ_QUIET只让最后一个通道产生中断就能极大减少中断压力。SDK 里对应的函数是channel_config_set_irq_quiet。我的习惯是链上的非末端通道全部开 IRQ_QUIET末端通道保持中断使能这样“整条链完成”的事件只触发一次。6.5 我的实际排查案例灯带尾部缺了 3 个灯有一次我用 PIO 刷 WS2812 灯带改造代码后出现了很诡异的现象灯带前段正常最后 3 个灯不亮。我一开始以为是 PIO 程序的问题反复看状态机代码没找到原因最后才想起来量了一下 DMA 的 TRANS_COUNT。问题出在我把 DMA 的搬运次数写成了LED_COUNT - 3因为当时把“前导码”的长度也算进去了没意识到 PIO 程序里已经处理了前导码。DMA 提前结束链式触发立刻把下一批数据启动看起来像是尾部灯带坏了实际上是数据根本没发过去。这个案例给我的教训是DMA 的计数是你和硬件之间唯一的“契约”写错了再合理的中断逻辑也救不回来。调试时第一件事就是把 TRANS_COUNT、源地址、目标地址都打印出来对着需求确认一遍。6.6 调试三板斧读寄存器、看地址、上逻辑分析仪最后分享一套我自己的调试习惯。DMA 出问题不要急着改代码按下面的顺序查读 CTRL_TRIG看 BUSY 状态和错误位。读 READ_ADDR、WRITE_ADDR确认地址确实是你想搬的地址特别注意是否出现非法地址。读 TRANS_COUNT确认剩余次数是否符合预期。如果数据内容不对但地址和计数都对把抓到的数据和源内存数据做逐字节比对看是错位还是丢了边界。涉及外设时序的问题上逻辑分析仪看一眼数据线上的波形比盯着寄存器猜快得多。这套流程看起来朴素但每次都能把问题范围缩小到很小。DMA 本身不复杂复杂的是它和外设、总线、中断之间的协作关系所以排查时要一层一层剥。最后多说一句现在我再做 Pico 项目凡是涉及 DMA 的都会先花半小时把通道分配、谁触发谁、哪些中断要开、哪些要静音这些画成一张表。这个过程看起来麻烦但比在调试器里对着寄存器猜半天要高效得多。DMA 的寄存器不用背但脑中一定要有那张地图。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门