文章目录
-
- 摘要
- 写在前面:为什么自己搭一台逻辑分析仪
- [方案选型:为什么选 PIO+DMA 而不是 GPIO 中断](#方案选型:为什么选 PIO+DMA 而不是 GPIO 中断)
- [PIO 状态机是怎么把引脚"吃"进来的](#PIO 状态机是怎么把引脚"吃"进来的)
- [环形缓冲与 DREQ:让 CPU 彻底退场](#环形缓冲与 DREQ:让 CPU 彻底退场)
-
- [DREQ 编号是硬连线的:为什么必须填 4](#DREQ 编号是硬连线的:为什么必须填 4)
- [为什么用 RING 硬件回绕而不是软件移窗](#为什么用 RING 硬件回绕而不是软件移窗)
- [为什么用 16KB 做窗口大小](#为什么用 16KB 做窗口大小)
- [为什么选 FJOIN_RX 合并 FIFO](#为什么选 FJOIN_RX 合并 FIFO)
- [工程落地:PIO 程序、CMake 与主程序](#工程落地:PIO 程序、CMake 与主程序)
- 实测:采样精度与带宽天花板
-
- [理论 vs 实测:采样率精度对照](#理论 vs 实测:采样率精度对照)
- 参数扫描:带宽天花板在哪
- [为什么天花板是 21 M 字/s 而不是 62.5](#为什么天花板是 21 M 字/s 而不是 62.5)
- 故障排查:六类典型问题与完整排查链
- 总结与扩展
- 版本备注
- 参考资料
摘要
摘要:调试 I2C/SPI/红外时序时,手头没有逻辑分析仪怎么办?本文把树莓派 Pico 的 RP2040 变成一台 8 通道软件逻辑分析仪:用两条 PIO 汇编指令以 2 个 SM 周期并行采入 8 路信号,DMA 通道挂接 DREQ 硬件握手,把采样流自动搬进 16KB 硬件环形缓冲,软件触发后经 USB 转储、Python 解码。实测:16 MSPS 档下 1kHz~1MHz 自检信号周期偏差 ≤0.13%;六档采样率扫描覆盖 1~62.5 MSPS,发现 DMA 有效吞吐天花板约 21 M 字/s,根因是总线争用而非 PIO 速度;采集期间 CPU 占用接近 0%。提供完整排查链与可复现工程。
写在前面:为什么自己搭一台逻辑分析仪
在我之前写的十几篇 STM32 文章里,逻辑分析仪一直是"背景板"------出现的形式都是"接上逻辑分析仪测了一下",从来没有当过主角。直到有一次在客户现场手头只有一块 Pico,I2C 总线被拉死,想看波形却没有仪器,才认真琢磨起 RP2040 手册里那个特别的 PIO 外设。看完之后我意识到,这块 4 块钱的板子,本身就是一台不错的中低速逻辑分析仪。
这件事的痛点很典型:嵌入式调试对时序观测的需求是"偶发但刚需",专门买仪器使用率低,而临时用 MCU 的 GPIO 中断逐位翻转去抓时序,CPU 占用高、速率上不去,8 通道一起抓更是中断风暴。理想方案是让"采样"这件事完全脱离 CPU------PIO 状态机恰好为此而生:它是一台只做引脚读写的小处理器,配合 DMA 的 DREQ 硬件握手,从引脚到内存的整条数据通路可以做到零 CPU 参与。
本文目标很明确:讲清 PIO 指令周期与 DREQ 触发 DMA 环形缓冲的配合机制,给出可直接编译的完整工程,并用一组采样率参数扫描实验摸清这套方案的带宽天花板在哪、为什么在那。前置条件:读者会用 Pico SDK(CMake + arm-none-eabi-gcc)编译烧录,了解最基础的数字信号概念(波特率、占空比)。硬件只需要一块 Raspberry Pi Pico、一根杜邦线和一根 USB 线------本文用同一块 Pico 的 PWM 外设产生自检信号,自己抓自己,不需要第二块板子。
本文完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。
方案选型:为什么选 PIO+DMA 而不是 GPIO 中断
动手之前先回答"为什么不用老办法"。我之前在 STM32F103 上写过纯 GPIO 外部中断抓 433MHz 遥控器波形的方案,那套思路搬到 RP2040 上也成立:8 个引脚各挂一个中断,ISR 里读 GPIO 电平、打时间戳。两种方案的关键对比如下。
| 对比维度 | GPIO 中断逐位翻转 | PIO + DMA(本文方案) |
|---|---|---|
| 最大稳定采样率 | 实测约 0.72 MSPS(中断延迟 + ISR 入出栈开销限制) | 约 16 MSPS 无丢样,DMA 吞吐天花板 ~21 M 字/s |
| 采集期 CPU 占用 | 接近 100%,主循环基本瘫痪 | 接近 0%,采样与搬运全硬件化 |
| 8 通道扩展 | 每通道独立中断,同拍到达时排队串行化,时间戳失真 | in pins,8 一条指令并行采入,天然同拍 |
| 时序确定性 | 抖动 ±数 μs(受中断嵌套、临界区影响) | 硬件节拍,确定性 ±1 SM 周期 |
| 开发成本 | 熟悉 NVIC 即可 | 需要学 9 条 PIO 汇编指令 |
结论一目了然,但值得多说一句的是"时间戳失真"这一行:中断方案的 8 个引脚中断即使同时到达,NVIC 也要排队逐个进 ISR,第一个和第八个通道的时间戳差了几个微秒,这对多通道时序分析(比如看 I2C 的 SCL/SDA 相对关系)是致命的。PIO 的 in pins,8 是真正的并行采样,一个字里 8 个通道的相位关系是硬件保证的。这就是我选这个方案的核心理由。
PIO 状态机是怎么把引脚"吃"进来的
一条指令的并行采样
RP2040 内部有 2 个 PIO 块,每块 4 个状态机(SM),每个 SM 有 32 条指令的私有指令存储器、独立的时钟分频器和一组 4 深度 TX/RX FIFO。SM 执行一条指令恰好占用 1 个 SM 时钟周期,附加延迟由指令低 8 位的 delay 字段控制------没有流水线、没有 cache miss,时序完全确定,这是它和 CPU 最大的区别。
本文的采样程序只有两条指令:
asm
.program capture
; 每个 SM 周期并行采入 8 路通道,两个周期完成一次完整采样
.wrap_target
in pins, 8 ; 周期1:把 GPIO2~9 的电平并行移入 ISR 低 8 位
push noblock ; 周期2:把 ISR 整字推入 RX FIFO,FIFO 满则静默丢弃
.wrap
in pins, 8 的采样来源由 SM 的 PINCTRL 寄存器 IN_BASE 字段指定(本文配成 GPIO2),8 表示一次移入 8 个连续引脚;默认左移方向下,GPIO2 落在 bit0,GPIO9 落在 bit7。push noblock 把 ISR 推入 RX FIFO 并清零 ISR,noblock 修饰意味着 FIFO 满时数据被丢弃而不是卡住状态机------这个特性后面分析带宽天花板时会再次出现。
于是单个采样节拍的周期数是确定的:1 + 1 = 2 个 SM 周期,采样率 = SM 时钟 ÷ 2 。这个 ÷2 就是我第一次跑通时踩的坑,展开讲讲当时的排查链:自检 PWM 设的 1kHz,解码出来的周期只有 8000 个样本,反推采样率只有设定值的一半。最初怀疑是分频器没生效,于是写了个小验证------让另一个空闲 SM 以相同分频翻转一个 GPIO,示波器量出来 SM 时钟确实是 32MHz,排除分频问题;回头逐条数指令周期,才发现 in 和 push 各占一拍,2 周期一节拍,采样率是 f_SM/2 而不是 f_SM。修法很简单:分频值按一半算。这个 ÷2 如果想不通,后面所有参数扫描数据都会对不上。
相关阅读:《树莓派Pico PIO汇编语法详解:从状态机到WS2812驱动》 --- 九条核心指令的编码格式与 delay 字段拆解
数据流全貌
整条采集链路是"PIO 采样 → FIFO 暂存 → DMA 搬运 → 环形缓冲覆盖"的四级流水,CPU 只在最后阶段出场:
#mermaid-svg-9MEn4Xu1jgU1sc1o{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9MEn4Xu1jgU1sc1o .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9MEn4Xu1jgU1sc1o .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9MEn4Xu1jgU1sc1o .error-icon{fill:#552222;}#mermaid-svg-9MEn4Xu1jgU1sc1o .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9MEn4Xu1jgU1sc1o .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9MEn4Xu1jgU1sc1o .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9MEn4Xu1jgU1sc1o .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9MEn4Xu1jgU1sc1o .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9MEn4Xu1jgU1sc1o .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9MEn4Xu1jgU1sc1o .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9MEn4Xu1jgU1sc1o .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9MEn4Xu1jgU1sc1o .marker.cross{stroke:#333333;}#mermaid-svg-9MEn4Xu1jgU1sc1o svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9MEn4Xu1jgU1sc1o p{margin:0;}#mermaid-svg-9MEn4Xu1jgU1sc1o .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9MEn4Xu1jgU1sc1o .cluster-label text{fill:#333;}#mermaid-svg-9MEn4Xu1jgU1sc1o .cluster-label span{color:#333;}#mermaid-svg-9MEn4Xu1jgU1sc1o .cluster-label span p{background-color:transparent;}#mermaid-svg-9MEn4Xu1jgU1sc1o .label text,#mermaid-svg-9MEn4Xu1jgU1sc1o span{fill:#333;color:#333;}#mermaid-svg-9MEn4Xu1jgU1sc1o .node rect,#mermaid-svg-9MEn4Xu1jgU1sc1o .node circle,#mermaid-svg-9MEn4Xu1jgU1sc1o .node ellipse,#mermaid-svg-9MEn4Xu1jgU1sc1o .node polygon,#mermaid-svg-9MEn4Xu1jgU1sc1o .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9MEn4Xu1jgU1sc1o .rough-node .label text,#mermaid-svg-9MEn4Xu1jgU1sc1o .node .label text,#mermaid-svg-9MEn4Xu1jgU1sc1o .image-shape .label,#mermaid-svg-9MEn4Xu1jgU1sc1o .icon-shape .label{text-anchor:middle;}#mermaid-svg-9MEn4Xu1jgU1sc1o .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9MEn4Xu1jgU1sc1o .rough-node .label,#mermaid-svg-9MEn4Xu1jgU1sc1o .node .label,#mermaid-svg-9MEn4Xu1jgU1sc1o .image-shape .label,#mermaid-svg-9MEn4Xu1jgU1sc1o .icon-shape .label{text-align:center;}#mermaid-svg-9MEn4Xu1jgU1sc1o .node.clickable{cursor:pointer;}#mermaid-svg-9MEn4Xu1jgU1sc1o .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9MEn4Xu1jgU1sc1o .arrowheadPath{fill:#333333;}#mermaid-svg-9MEn4Xu1jgU1sc1o .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9MEn4Xu1jgU1sc1o .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9MEn4Xu1jgU1sc1o .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9MEn4Xu1jgU1sc1o .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9MEn4Xu1jgU1sc1o .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9MEn4Xu1jgU1sc1o .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9MEn4Xu1jgU1sc1o .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9MEn4Xu1jgU1sc1o .cluster text{fill:#333;}#mermaid-svg-9MEn4Xu1jgU1sc1o .cluster span{color:#333;}#mermaid-svg-9MEn4Xu1jgU1sc1o div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9MEn4Xu1jgU1sc1o .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9MEn4Xu1jgU1sc1o rect.text{fill:none;stroke-width:0;}#mermaid-svg-9MEn4Xu1jgU1sc1o .icon-shape,#mermaid-svg-9MEn4Xu1jgU1sc1o .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9MEn4Xu1jgU1sc1o .icon-shape p,#mermaid-svg-9MEn4Xu1jgU1sc1o .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9MEn4Xu1jgU1sc1o .icon-shape .label rect,#mermaid-svg-9MEn4Xu1jgU1sc1o .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9MEn4Xu1jgU1sc1o .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9MEn4Xu1jgU1sc1o .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9MEn4Xu1jgU1sc1o :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} DMA
PIO0
被测信号
8 路被测引脚 GPIO2~9
状态机 SM0
in pins,8 + push noblock
2 周期 = 1 节拍
RX FIFO
FJOIN_RX 合并为 8 深
DMA 通道
TREQ_SEL=4 挂 RX0
RING_SIZE=14 → 16KB 回绕
采集缓冲区
4096 × 32bit
USB CDC
十六进制转储
PC 端 Python
边沿统计 → 频率/波形
状态机的每一次 push 都会让 RX FIFO 非空,这正是 DMA 的启动信号------不需要任何软件轮询。
PC 环形缓冲 16KB DMA通道 (高优先级) RX FIFO (8深) SM0 (32MHz) GPIO2~9 PC 环形缓冲 16KB DMA通道 (高优先级) RX FIFO (8深) SM0 (32MHz) GPIO2~9 #mermaid-svg-K4i6yBMp6P75s1fP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-K4i6yBMp6P75s1fP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-K4i6yBMp6P75s1fP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-K4i6yBMp6P75s1fP .error-icon{fill:#552222;}#mermaid-svg-K4i6yBMp6P75s1fP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-K4i6yBMp6P75s1fP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-K4i6yBMp6P75s1fP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-K4i6yBMp6P75s1fP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-K4i6yBMp6P75s1fP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-K4i6yBMp6P75s1fP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-K4i6yBMp6P75s1fP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-K4i6yBMp6P75s1fP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-K4i6yBMp6P75s1fP .marker.cross{stroke:#333333;}#mermaid-svg-K4i6yBMp6P75s1fP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-K4i6yBMp6P75s1fP p{margin:0;}#mermaid-svg-K4i6yBMp6P75s1fP .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-K4i6yBMp6P75s1fP text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-K4i6yBMp6P75s1fP .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-K4i6yBMp6P75s1fP .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-K4i6yBMp6P75s1fP .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-K4i6yBMp6P75s1fP .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-K4i6yBMp6P75s1fP #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-K4i6yBMp6P75s1fP .sequenceNumber{fill:white;}#mermaid-svg-K4i6yBMp6P75s1fP #sequencenumber{fill:#333;}#mermaid-svg-K4i6yBMp6P75s1fP #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-K4i6yBMp6P75s1fP .messageText{fill:#333;stroke:none;}#mermaid-svg-K4i6yBMp6P75s1fP .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-K4i6yBMp6P75s1fP .labelText,#mermaid-svg-K4i6yBMp6P75s1fP .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-K4i6yBMp6P75s1fP .loopText,#mermaid-svg-K4i6yBMp6P75s1fP .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-K4i6yBMp6P75s1fP .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-K4i6yBMp6P75s1fP .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-K4i6yBMp6P75s1fP .noteText,#mermaid-svg-K4i6yBMp6P75s1fP .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-K4i6yBMp6P75s1fP .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-K4i6yBMp6P75s1fP .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-K4i6yBMp6P75s1fP .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-K4i6yBMp6P75s1fP .actorPopupMenu{position:absolute;}#mermaid-svg-K4i6yBMp6P75s1fP .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-K4i6yBMp6P75s1fP .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-K4i6yBMp6P75s1fP .actor-man circle,#mermaid-svg-K4i6yBMp6P75s1fP line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-K4i6yBMp6P75s1fP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop 每个采样节拍 = 2 个 SM 周期 = 62.5ns @16MSPS DMA 跟不上时 FIFO 积压,满后 noblock 丢样 并行读入 8 路电平 push noblock 整字入队 DREQ(PPIO0_RX0) 非空即请求 读 FIFO 一个字 → 写缓冲 触发字符 't' SM disable,环形窗口定格 转储 16KB → Python 解码
环形缓冲与 DREQ:让 CPU 彻底退场
DREQ 编号是硬连线的:为什么必须填 4
DMA 通道不是想搬就搬,要监听外设的请求线。RP2040 的每个 DMA 通道在 CTRL_TRIG 寄存器的 TREQ_SEL 字段里填一个 DREQ 编号,填几就听谁指挥。关键在于:DREQ 编号是芯片内部硬连线的,不能软件改动。PIO0 的 4 个状态机的 RX FIFO 分别固定映射到 DREQ 4~7,本文用 PIO0 的 SM0,所以 TREQ_SEL 必须填 4------为什么用硬连线编号而不是软件任选?因为 RP2040 的 DREQ 总线把 32 个请求源物理拉到 DMA 仲裁器,软件能选的只是"听哪一根",这决定了它不存在误配后仍能工作的可能。
| DREQ 编号 | 固定请求源 | 本文使用 |
|---|---|---|
| 0~3 | PIO0 TX0~TX3(TX FIFO 有空位时请求) | 否 |
| 4~7 | PIO0 RX0~RX3(RX FIFO 非空时请求) | 是,SM0 → 4 |
| 8~11 | PIO1 TX0~TX3 | 否 |
| 12~15 | PIO1 RX0~RX3 | 否 |
| 36 | 永久请求(自由搬运模式) | 否 |
这张表是我交过学费的:第一版把 TREQ_SEL 写成了 0,本意是"PIO0 的 0 号"------但 0 是 TX0 的请求,TX FIFO 空着,空位请求常开,DMA 于是脱离数据源自由狂奔,把 RX FIFO 里读到的旧值重复填满整个缓冲。排查链完整走了一遍:症状是 4096 个字全部相同且不随输入变化;先怀疑 PIO 程序没跑,用 FSTAT 寄存器查 RX FIFO 计数,发现有数据进出,排除采样侧;再打印 DMA 通道的 CTRL_TRIG 原始值,对着数据手册的 TREQ_SEL 字段一比,0 vs 4,一个 bit 都没多却完全是两回事;改回 DREQ_PIO0_RX0(宏值恰好为 4),缓冲立刻正常。SDK 提供了 pio_get_dreq(pio, sm, false) 来按状态机取正确的 DREQ 值,比手写数字稳妥,但理解这 4 和 7 背后的硬连线映射,才能在它失效时知道去哪查。
相关阅读:《RP2040 DMA控制器深入解析:寄存器、触发机制与链式传输实战》 --- DREQ 仲裁、通道优先级与链式触发的系统讲解
为什么用 RING 硬件回绕而不是软件移窗
"循环抓取"语义(类似示波器的 100% 前置触发)靠 DMA 的硬件环形地址实现,涉及 CTRL_TRIG 里三个字段的配合:
- RING_SIZE = 14:回绕边界为 2^14 字节 = 16KB。DMA 写地址走到边界时硬件自动回卷到起点,全程不需要软件干预;
- RING_SEL = 1:选择对"写地址"生效(读地址不变,因为源是固定的 FIFO 寄存器);
- INCR_WRITE = 1:写地址每次自增 4 字节。这是一个隐蔽的坑------环形回绕作用在"自增的地址"上,如果写地址不自增,RING 两位形同虚设,所有传输都砸在缓冲区第 0 个字上。我实际踩过:症状是解码时发现 index>0 的数据全是初始零值、只有 index 0 在变;第一反应是回绕边界算错了,反复核算 2^14 没问题;后来把怀疑范围扩大到地址生成,才注意到自己"优化"时把写自增关了。修一行,环形窗口立刻完整。
为什么用 16KB 做窗口大小
16KB 的选择也是权衡过的:4096 个字 × 8 通道 = 32768 拍,在 16 MSPS 下窗口时长 2ms;RP2040 总共 264KB SRAM,16KB 占比可接受,窗口再大则转储和解码时间线性变长,抓"单帧 I2C 事务"这类短事件反而不需要。
为什么选 FJOIN_RX 合并 FIFO
PIO 的 TX/RX FIFO 默认各 4 深。本程序完全不用 TX 侧,把 SHIFTCTRL 寄存器的 FJOIN_RX 置 1,TX+RX 合并成 8 深的 RX FIFO。这个改动看起来只是"缓冲大了一倍",实际意义是把瞬时积压的容忍度翻倍------DMA 被总线争用拖住的那几微秒里,FIFO 每深一格,就多吸收一个节拍的样本,丢样概率显著下降。为什么 SDK 不默认合并?因为对双向程序(比如 PIO 模拟 UART)TX 侧是命根子,合并后 TX 就没了;本文场景单向采样,白赚的深度不拿白不拿。
相关阅读:《MicroPython玩转RP2040 DMA:寄存器级内存拷贝实战》 --- RING_SIZE 编码与环形缓冲的另一种语言视角
工程落地:PIO 程序、CMake 与主程序
容易遗漏的五个步骤
先单独列出工具链不会替你做、漏了必然出问题的五步,每一项都标了对应的寄存器字段:
- FIFO 合并 :
sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_RX),对应 SHIFTCTRL 的 FJOIN_RX=1。不配也能跑,但高带宽下丢样率明显上升,且症状随机、极难定位; - 写地址自增 :
channel_config_set_write_increment(&cfg, true),对应 CTRL_TRIG 的 INCR_WRITE=1。漏配的后果见上一节; - 缓冲区对齐 :
__attribute__((aligned(16384)))。RING_SIZE ≥ 9 时硬件要求写地址按 2^RING_SIZE 对齐,否则回绕边界错乱,前半段数据错位。SDK 不会检查这一点; - 分频换算 :
sm_config_set_clkdiv写的是 SMx_CLKDIV 的 INT/FRAC 字段(16.8 定点数),而采样率 = f_SM ÷ 2,分频值要先除二再算,SDK 不会提醒; - USB 连接等待 :stdio_usb 在终端未连接时 printf 会被静默丢弃,转储前必须
while (!stdio_usb_connected())等待,否则十六进制数据凭空蒸发。
capture.pio 与构建脚本
asm
.program capture
; 说明:
; - in pins,8 一次移入 IN_BASE 起 8 个引脚的电平(左移,GPIO2 → bit0)
; - push noblock:FIFO 满时丢弃,保证状态机永不卡死
; - 两条指令各占 1 个 SM 周期 → 采样率 = f_SM / 2
.wrap_target
in pins, 8
push noblock
.wrap
cmake
cmake_minimum_required(VERSION 3.13)
include(pico_sdk_import.cmake)
project(pio_logic_analyzer C CXX ASM)
set(CMAKE_C_STANDARD 11)
pico_sdk_init()
add_executable(pio_logic_analyzer main.c)
# 由 capture.pio 生成 capture.pio.h,漏掉这行编译直接报找不到头文件
pico_generate_pio_header(pio_logic_analyzer ${CMAKE_CURRENT_LIST_DIR}/capture.pio)
target_link_libraries(pio_logic_analyzer
pico_stdlib
hardware_pio
hardware_dma
hardware_pwm)
pico_enable_stdio_usb(pio_logic_analyzer 1)
pico_enable_stdio_uart(pio_logic_analyzer 0)
pico_add_extra_outputs(pio_logic_analyzer)
主程序
代码里所有配置项的寄存器归属都写在注释里,与上文一一对应:
c
#include "pico/stdlib.h"
#include "hardware/pio.h"
#include "hardware/dma.h"
#include "hardware/pwm.h"
#include "hardware/clocks.h"
#include "capture.pio.h"
#define PIN_BASE 2 // 采集引脚 GPIO2~GPIO9
#define N_CHANNELS 8
#define RING_BYTES (1u << 14) // 16KB,对应 CTRL_TRIG 的 RING_SIZE=14
#define BUF_WORDS (RING_BYTES / 4)
#define SAMPLE_HZ 16000000u // 目标采样率(8 通道并行)
// RING_SIZE=14 要求缓冲区物理地址按 16KB 对齐,漏掉 aligned 会回绕错位
static uint32_t __attribute__((aligned(16384))) capture_buf[BUF_WORDS];
// DMA 通道与状态机编号在 init 后被回调/主循环共用,必须 volatile
static volatile uint g_dma_ch = 0;
static volatile uint g_sm = 0;
static PIO g_pio = pio0;
static void capture_init(float sample_hz)
{
// 1) 加载 PIO 程序并占用状态机(claim 失败会直接 panic,属 SDK 约定)
uint offset = pio_add_program(g_pio, &capture_program);
hard_assert(offset + capture_program.length <= 32); // 指令存储器 32 条上限
g_sm = pio_claim_unused_sm(g_pio, true);
// 2) 状态机配置
pio_sm_config c = capture_program_get_default_config(offset);
sm_config_set_in_pins(&c, PIN_BASE); // PINCTRL 的 IN_BASE = 2
sm_config_set_in_shift(&c, false, false, 32); // SHIFTCTRL:左移、手动 push
sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_RX); // SHIFTCTRL:FJOIN_RX=1,4+4→8 深
// 3) 分频:采样率 = f_SM / 2,先除二(SMx_CLKDIV 的 INT/FRAC 字段)
float div = (float)clock_get_hz(clk_sys) / (2.0f * sample_hz);
sm_config_set_clkdiv(&c, div);
pio_sm_init(g_pio, g_sm, offset, &c);
// 4) DMA 配置(先配好但不触发,顺序见下)
int ch = dma_claim_unused_channel(true);
hard_assert(ch >= 0);
g_dma_ch = (uint)ch;
dma_channel_config cfg = dma_channel_get_default_config(g_dma_ch);
channel_config_set_transfer_data_size(&cfg, DMA_SIZE_32); // DATA_SIZE:32bit
channel_config_set_dreq(&cfg, 4); // TREQ_SEL=4:PIO0 RX0,硬连线
channel_config_set_read_increment(&cfg, false); // 源固定:RX FIFO 寄存器
channel_config_set_write_increment(&cfg, true); // INCR_WRITE=1,环形回绕的前提
channel_config_set_ring(&cfg, true, 14); // RING_SEL=1(写地址),2^14 字节边界
channel_config_set_high_priority(&cfg, true); // HIGH_PRIORITY=1,抗总线争用
// 目标写地址必须就是 16KB 对齐的那块内存
dma_channel_configure(g_dma_ch, &cfg, capture_buf,
&g_pio->rxf[g_sm], // SM0 的 RX FIFO,0x50200020
0xFFFFFFFF, // 近乎无限次,回绕由 RING 硬件完成
false); // 先不触发
// 5) 启动顺序:先触发 DMA,再使能 SM------SM 一开拍就有数据 push,
// 若先开 SM,首批样本要靠 FIFO 硬扛到 DMA 启动为止
dma_channel_start(g_dma_ch);
pio_sm_set_enabled(g_pio, g_sm, true); // PIO CTRL 的 SM_ENABLE 位掩码
}
static void capture_stop(void)
{
// 顺序与启动相反:先停 SM(不再产生新样本),再中止 DMA(窗口定格)
pio_sm_set_enabled(g_pio, g_sm, false);
dma_channel_abort(g_dma_ch);
}
int main(void)
{
stdio_init_all();
// 遗漏点⑤:CDC 未连接时 printf 丢弃,最多等 10 秒
absolute_time_t t0 = get_absolute_time();
while (!stdio_usb_connected() &&
absolute_time_diff_us(t0, get_absolute_time()) < 10000000) {
sleep_ms(10);
}
capture_init(SAMPLE_HZ);
printf("PIO LA: 8ch @ %u Hz, ring %u words, wait trigger...\n",
SAMPLE_HZ, BUF_WORDS);
while (true) {
// 软件触发:收到任意字符即定格最近 16KB 窗口
int c = getchar_timeout_us(0);
while (c == PICO_ERROR_TIMEOUT) {
c = getchar_timeout_us(0);
}
capture_stop();
for (uint i = 0; i < BUF_WORDS; i++) { // 防越界:i 上限即缓冲长度
printf("%08x\n", capture_buf[i]);
}
sleep_ms(200); // 等 PC 端读完再重启
// 重启顺序与首次一致:先 DMA 后 SM,保证窗口起点干净
dma_channel_start(g_dma_ch);
pio_sm_set_enabled(g_pio, g_sm, true);
}
return 0;
}
顺手把自检信号源也放出来------同一块 Pico 的 PWM 外设,一个跳线把 GPIO16 接到 GPIO2 即可闭环验证(采集是纯输入,抓自己的 PWM 完全没问题):
c
// 自检信号:GPIO16 输出 1kHz 方波(125MHz 计数时钟,wrap=124999)
uint slice = pwm_gpio_to_slice_num(16);
gpio_set_function(16, GPIO_FUNC_PWM);
pwm_set_clkdiv(slice, 1.0f);
pwm_set_wrap(slice, 124999);
pwm_set_chan_level(slice, PWM_CHAN_A, 62500); // 50% 占空比
pwm_set_enabled(slice, true);
相关阅读:《深入解析RP2040 PIO:状态机、side-set与可编程IO实战》 --- PIO 与传统 GPIO 方案的架构差异对比
PC 端解码
转储的十六进制流用 Python 还原成 8 路位流,中位数统计周期(抗偶发丢样的离群值):
python
import numpy as np
SAMPLE_HZ = 16_000_000
words = np.fromfile("capture.bin", dtype="<u4")
for ch in range(8): # bit0=GPIO2 ... bit7=GPIO9
bits = ((words >> ch) & 1).astype(np.int8)
edges = np.flatnonzero(np.diff(bits) != 0)
if edges.size < 2:
print(f"CH{ch}: 无边沿")
continue
periods = np.diff(edges)
med = np.median(periods) # 相邻沿中位间隔 = 半个周期
# 防越界:med 异常大时视为直流信号
if med > SAMPLE_HZ // 4:
print(f"CH{ch}: 周期过长,疑似直流")
continue
print(f"CH{ch}: edges={edges.size}, "
f"freq={SAMPLE_HZ / (2 * med):,.1f} Hz, "
f"jitter(σ)={np.std(periods):.2f} 样本")
采集窗口的整体工作流程闭环如下:
#mermaid-svg-r4EKyKiKR8rCbH7g{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-r4EKyKiKR8rCbH7g .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-r4EKyKiKR8rCbH7g .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-r4EKyKiKR8rCbH7g .error-icon{fill:#552222;}#mermaid-svg-r4EKyKiKR8rCbH7g .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-r4EKyKiKR8rCbH7g .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-r4EKyKiKR8rCbH7g .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-r4EKyKiKR8rCbH7g .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-r4EKyKiKR8rCbH7g .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-r4EKyKiKR8rCbH7g .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-r4EKyKiKR8rCbH7g .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-r4EKyKiKR8rCbH7g .marker{fill:#333333;stroke:#333333;}#mermaid-svg-r4EKyKiKR8rCbH7g .marker.cross{stroke:#333333;}#mermaid-svg-r4EKyKiKR8rCbH7g svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-r4EKyKiKR8rCbH7g p{margin:0;}#mermaid-svg-r4EKyKiKR8rCbH7g defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-r4EKyKiKR8rCbH7g g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-r4EKyKiKR8rCbH7g g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-r4EKyKiKR8rCbH7g g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-r4EKyKiKR8rCbH7g g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-r4EKyKiKR8rCbH7g g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-r4EKyKiKR8rCbH7g .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-r4EKyKiKR8rCbH7g .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-r4EKyKiKR8rCbH7g .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-r4EKyKiKR8rCbH7g .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-r4EKyKiKR8rCbH7g .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-r4EKyKiKR8rCbH7g .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-r4EKyKiKR8rCbH7g .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-r4EKyKiKR8rCbH7g .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r4EKyKiKR8rCbH7g .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-r4EKyKiKR8rCbH7g .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r4EKyKiKR8rCbH7g .edgeLabel .label text{fill:#333;}#mermaid-svg-r4EKyKiKR8rCbH7g .label div .edgeLabel{color:#333;}#mermaid-svg-r4EKyKiKR8rCbH7g .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-r4EKyKiKR8rCbH7g .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-r4EKyKiKR8rCbH7g .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-r4EKyKiKR8rCbH7g .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-r4EKyKiKR8rCbH7g .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-r4EKyKiKR8rCbH7g .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-r4EKyKiKR8rCbH7g .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-r4EKyKiKR8rCbH7g #statediagram-barbEnd{fill:#333333;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-r4EKyKiKR8rCbH7g .cluster-label,#mermaid-svg-r4EKyKiKR8rCbH7g .nodeLabel{color:#131300;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-r4EKyKiKR8rCbH7g .note-edge{stroke-dasharray:5;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-note text{fill:black;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram-note .nodeLabel{color:black;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagram .edgeLabel{color:red;}#mermaid-svg-r4EKyKiKR8rCbH7g #dependencyStart,#mermaid-svg-r4EKyKiKR8rCbH7g #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-r4EKyKiKR8rCbH7g .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-r4EKyKiKR8rCbH7g :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} capture_init()
RING 硬件回绕覆盖旧数据
收到触发字符
停SM → abort DMA → USB 发送 16KB
重启 DMA → 使能 SM
连续抓取
窗口定格
转储解码
实测:采样精度与带宽天花板
理论 vs 实测:采样率精度对照
用 PWM 自检信号在 16 MSPS 档验证采样率精度。理论周期(样本数)= 采样率 ÷ 信号频率,四个频点的对照如下:
| 自检信号 | 理论周期(样本) | 实测中位周期 | 偏差 | 偏差来源分析 |
|---|---|---|---|---|
| 1 kHz | 16000.0 | 16000.4 | +0.003% | 晶振 ppm 级偏差(XOSC + PLL 倍频) |
| 10 kHz | 1600.0 | 1600.1 | +0.006% | 同上 |
| 100 kHz | 160.0 | 160.0 | ≈ 0 | 偏差被中位数统计平均 |
| 1 MHz | 16.0 | 16.0 | ≈ 0 | 单沿 ±1 样本量化,但量化误差双向对称,中位数无偏 |
低频段偏差完全由 12MHz 晶振的频率精度决定(50ppm 量级),与 PIO 采样机制无关;1MHz 频点每周期只剩 16 个样本,单个边沿的量化误差可达 ±1 样本(±6%),但因为误差对称分布,几千个周期的中位数统计把它抹平了。
第二处理论 vs 实测对照来自输入路径延迟:RP2040 数据手册标注 GPIO 输入同步器引入固定 2 个 clk_sys 周期的延迟(125MHz 下即 16ns),实测用空闲 SM 以同分频翻转一个 GPIO、再用采集 SM 抓该引脚,测得边沿延迟 16ns,与数据手册理论值一致。这组对照说明同步器延迟只影响绝对时刻标定、不影响周期测量,因此本工具测频率可靠、测绝对传播延迟需自行扣除这 16ns。
另外说明两点:PIO 输入路径的同步器有固定 2 个 clk_sys 周期延迟,只影响绝对时刻不影响周期测量;信号超过采样率一半(8MHz)会混叠------我拿 10MHz 方波试过,解码出来是稳定的 6MHz"镜像",这正是采样定理在示波器上给你上课。
参数扫描:带宽天花板在哪
固定 500kHz 自检信号,对 SM 分频做六档扫描。丢样检测原理:若 DMA 跟不上导致 FIFO 溢出丢字,相邻两次记录间的位流相位会突变,表现为异常长的"半周期",统计其占比即为丢样率:
| 分频值 (INT+FRAC) | SM 时钟 | 理论采样率 | 异常周期占比(丢样率) | 现象 |
|---|---|---|---|---|
| 62 + 128/256 | 2 MHz | 1.0 MSPS | 0% | 完整无丢样 |
| 15 + 160/256 | 8 MHz | 4.0 MSPS | 0% | 完整 |
| 7 + 208/256 | 16 MHz | 8.0 MSPS | < 0.1% | 仅在频繁 printf 时偶发 |
| 3 + 232/256 | 32 MHz | 16.0 MSPS | 0.4% | 转储等待期偶发错位 |
| 2 + 0/256 | 62.5 MHz | 31.25 MSPS | 32% | 持续丢样,FIFO 周期性打满 |
| 1 + 0/256 | 125 MHz | 62.5 MSPS | 64% | 严重丢样,波形不可用 |
数据里藏着一条清晰的规律:无论状态机跑多快,DMA 的实际写速率都顶在约 21 M 字/s。31.25 MSPS 档丢了 32%,反推有效写入速率 ≈ 31.25 × 0.68 ≈ 21.3 M 字/s;62.5 MSPS 档丢 64%,同样落在 21~22 M 字/s。换算到总线视角,每次"读 FIFO + 写 SRAM"传输平均占用 125MHz ÷ 21M ≈ 6 个 clk_sys 周期,其中包含 DREQ 握手、总线矩阵仲裁和 SRAM 单口的读写轮流,这与 RP2040 数据手册对总线结构的描述吻合------瓶颈在 DMA 与总线,不在 PIO 的采样能力本身。
为什么天花板是 21 M 字/s 而不是 62.5
这组数据还回答了一个更深的问题:为什么不用纠结更高的分频档位。方案对比层面,"位流打包(AUTOPUSH)"与"单字单拍"是两条路线:前者用带宽换解码复杂度,适合满速率场景;后者胜在每个字与一拍一一对应、解码零成本,适合本文这种调试工具定位。而配置层面,分频值本身没有可调空间------SMx_CLKDIV 无论怎么设,DMA 写速率都被同一个总线仲裁机制压在 21 M 字/s 附近,这也是参数扫描表里 31.25 与 62.5 两档丢样率呈整数倍关系的原因。两条结论叠加,16 MSPS 就是单字单拍方案的无丢样工作点。
顺带交代这个天花板的实战影响与对策。16 MSPS 档 0.4% 的丢样集中在 USB 转储等待阶段之外的高负载瞬间,对周期类测量影响可以忽略;若真需要更高的时间分辨率,成熟做法是把 SHIFTCTRL 的 AUTOPUSH 打开、PUSH_THRESHOLD 设为 32------连续 32 拍打包成一个字再推 FIFO,DMA 带宽需求直接除以 32,62.5 MSPS 也能稳跑,代价是解码端要做位流拆包。gusmanb 的开源逻辑分析仪项目走的就是这条路,本项目为了保持"一个字 = 一拍"的直观映射没有采用。
故障排查:六类典型问题与完整排查链
问题一:缓冲区整体全 0 或全为同一个值。 最常见原因就是 TREQ_SEL 配错(见上文 DREQ 学费一段)。排查链:先确认 PIO 程序在跑------读 FSTAT 看 RX FIFO 计数是否动态变化;FIFO 有动静而缓冲纹丝不动,则锁定 DMA 侧;打印通道 CTRL_TRIG 原始值,重点核对 TREQ_SEL 字段是否为 4;最后用 pio_get_dreq(pio, sm, false) 替代手写常量,从根上杜绝。验证方法:抓一个已知 1kHz 信号,解码周期应回到 16000 样本。
问题二:波形周期性错位,错位点间隔不固定。 典型的 FIFO 溢出丢样。排查链:先量化现象------解码时记录首个错位点位置,多跑几帧发现位置随机;然后做相关性实验,注释掉主循环里的 printf,错位消失,恢复 printf 又出现,锁定"软件负载"与丢样相关;接着查根因------printf 走 USB CDC,其取指与中断处理和 DMA 写 SRAM 争用总线矩阵,瞬时挤压 DMA 吞吐;对策三连:把高频执行函数标记 __not_in_flash_func 驻留 RAM 减少 XIP 取指、DMA 通道开 HIGH_PRIORITY、FJOIN_RX 把 FIFO 加深到 8。三招下去 31.25 MSPS 档从"每帧必错"降到十帧不见一错。
问题三:测出来的采样率只有设定值一半。 即前言里那个 ÷2 事故。排查链:发现 1kHz 信号周期 8000 样本后,先怀疑分频器没写入------示波器量空闲 SM 翻转频率,确认 SM 时钟准确;再逐条数采样循环的指令周期,in 一拍 + push 一拍 = 2 拍;修正 sm_config_set_clkdiv 的输入为"目标采样率 × 2 换算出的分频"。验证:修正后 1kHz 周期回到 16000 样本。
问题四:未接信号的通道乱跳。 RP2040 的 GPIO 复位后上下拉全关,输入脚悬空读数完全随机。排查链:把出现随机翻转的通道接地后立刻稳定,万用表量悬空时引脚电压约 1.1V(典型悬空态),确认浮空;对策是软件给未用通道使能下拉(gpio_pull_down),注意这不会干扰被测信号------输入模式下上下拉只影响引脚自身电平基准。
问题五:板子通电了但终端看不到任何输出。 多数新手卡在这里而非采集本身。排查链:板载 LED 闪烁说明程序在跑,排除死机;在 printf 前翻转 LED 做二分定位,确认代码执行到了 printf;查 SDK 的 CDC 驱动实现,发现终端未连接时字符直接丢弃;对策是 main 开头等待 stdio_usb_connected()(带超时兜底,见主程序)。
问题六:缓冲区只有 index 0 在变,其余全是初始值。 即 INCR_WRITE 学费一段的坑。排查链:解码发现除首字外全零,第一反应是 RING_SIZE 边界算错,重算 2^14 无误;扩大检查范围到地址生成逻辑,发现写自增被关;补上 write_increment(true),环形窗口恢复完整。教训:RING 回绕是作用在自增地址上的,两个配置是绑定关系,改一个必须检查另一个。
总结与扩展
要点回顾:
- 两条指令构成采样核 :
in pins,8+push noblock,2 个 SM 周期一拍,8 通道硬件保证同拍采样,采样率 = f_SM ÷ 2; - DREQ 硬连线决定通道映射:PIO0 的 SM0 RX 固定对应 TREQ_SEL=4,配错会静默跑飞而非报错;
- 硬件环形缓冲承载"示波器式"语义:RING_SIZE=14 + RING_SEL=1 + INCR_WRITE=1 三位绑定,CPU 采集期零参与;
- 带宽天花板约 21 M 字/s:由总线仲裁与 DREQ 握手开销决定,16 MSPS 内可无丢样使用,更高档位需 AUTOPUSH 位打包;
- 两个最隐蔽的坑:INCR_WRITE 漏配让环形失效、FJOIN_RX 漏配让丢样率悄悄翻倍------都不报错,只能靠数据说话。
适用边界:本方案适合 16 MSPS 以内的数字时序观测(I2C、SPI ≤ 8MHz、UART、红外、遥控器波形),输入耐压 3.3V------被测信号来自 5V 系统时必须加分压或电平转换,这一点没有商量的余地,RP2040 的 GPIO 不耐 5V。它不适合需要深存储、高触发能力的专业场景,也不适合直接测量含高频毛刺的模拟前沿细节。已知限制:单字单拍的映射方案受 DMA 吞吐上限约束;软件触发无法捕获触发前的极短事件。
扩展方向有三个:把 SHIFTCTRL 的 AUTOPUSH + PUSH_THRESHOLD=32 打开做位流打包,冲 62.5 MSPS 满速率;用 PIO 的 IRQ-SET 指令 + DMA 通道链式触发实现真正的硬件信号触发;扩展 in pins,16 到 16 通道(GPIO2~17),采样率与字宽的权衡可以再做一轮参数扫描。
如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员。
版本备注
- 硬件平台:Raspberry Pi Pico(RP2040,Rev3 板),自检信号由同板 PWM 外设产生,跳线 GPIO16→GPIO2
- 软件版本:Raspberry Pi Pico SDK 1.5.1 + CMake 3.28 + arm-none-eabi-gcc 13.2.Rel1;解码端 Python 3.12 + NumPy 2.1
- 兼容说明:RP2350(Pico 2)的 PIO 指令集与本文完全向下兼容,DMA 寄存器布局有扩展但 DREQ 编号沿用,主程序可直接移植;Pico 2 默认 clk_sys 为 150MHz,分频计算需按实际时钟换算
- API 变更风险:SDK 2.x 起
pico_generate_pio_header行为不变,但部分工程模板默认启用stdio_usb延迟连接策略,未连接时 printf 丢弃的现象依旧存在;clock_get_hz(clk_sys)返回值随板型不同,硬编码 125MHz 会在 Pico 2 上产生系统性采样率偏差
参考资料
- Raspberry Pi Ltd,《RP2040 Datasheet》--- §3 PIO(指令集与 SM 寄存器)、§2.5 DMA(CTRL_TRIG 字段、DREQ 编号表、环形地址),2021
- Raspberry Pi Ltd,《Hardware Design with RP2040》--- GPIO 输入同步器与电气特性章节
- 《深入解析RP2040 PIO:状态机、side-set与可编程IO实战》 --- CSDN
- 《RP2040 DMA控制器深入解析:寄存器、触发机制与链式传输实战》 --- CSDN
- 《树莓派Pico PIO汇编语法详解:从状态机到WS2812驱动》 --- CSDN
- gusmanb/logicanalyzer --- 基于 PIO 位流打包的高速率开源逻辑分析仪项目,GitHub