ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

树莓派Pico ADC深度解析:硬件原理、寄存器操作与实战应用

树莓派Pico ADC深度解析:硬件原理、寄存器操作与实战应用 1. 硬件架构与信号链路从引脚到数值的完整旅程1.1 Pico ADC 基础规格与典型应用场景树莓派 Pico 搭载的 RP2040 芯片内置了一个 12 位逐次逼近型SARADC核心采样率标称 500 kSPS但实测在 48 MHz 的 ADC 时钟下可以跑到更高的采样速度这一点后面会详细计算。很多刚接触 Pico 的朋友第一次用machine.ADC这个 MicroPython 类的时候觉得这不就是个读模拟值的玩意吗但实际上 ADC 外设的底层细节直接影响采集精度和系统稳定性尤其是你要拿它做舵机控制、电池电压监测、传感器采样这类真实项目时踩坑的概率相当高。1.2 引脚与通道映射别把物理引脚序号和 ADC 通道号搞混RP2040 的 ADC 外围有 5 个输入通道其中 3 个是真正引出到引脚的GPIO26 对应 ADC0GPIO27 对应 ADC1GPIO28 对应 ADC2。第 4 个通道接的是芯片内部的温度传感器通过 TS 位使能后读取第 5 个通道是 VREF 电源电压的一半可以用来做供电电压监测。注意没有任何引脚可以把这第 4、第 5 路信号引出来——它们是片上测试通道只能用寄存器访问。这里有一个非常经典的混淆点GPIO26 这个编号是芯片的物理引脚编号ADC0 是 ADC 外设的通道编号。在 MicroPython 里你写machine.ADC(26)实际上是传入 GPIO 编号底层再去映射到对应的 ADC 通道。但在 C 语言寄存器操作或者裸机编程里你必须明确在 ADC_CS 寄存器的 AINSEL 字段写入的到底是通道编号而不是 GPIO 编号写错了读到的一定是错误数据。1.3 SAR ADC 的核心原理与精度限制SAR ADC 的工作方式你可以理解成一个逐位猜数字的过程先假设输入电压在满量程的一半比较器判断实际电压比这个假设高还是低然后二分法逐步逼近。RP2040 用的是 12 位 SAR意味着一个转换周期内有 12 次比较动作。这个结构决定了它有几个天生的限制输入阻抗是动态的不像积分型 ADC 那样恒定采样瞬间会有一个抽流过程采样保持电容需要充电时间如果源阻抗过大电压还没稳定就被拍快照了比较器的精度决定了有效位宽RP2040 的 datasheet 上写的是 12 位分辨率但有效位数ENOB受噪声影响只有 9 到 10 位实测下来用 Pico 读一个干净的 3.3V 电源分压信号跳动幅度通常在 2 到 3 个 LSB 左右也就是最末尾两位基本是噪声。这不是你代码的问题而是片上 ADC 的固有噪声水平。如果项目要求有效位数高于 10 位或者需要高精度采样通常的做法是外挂独立 ADC 芯片比如 ADS1115 或者 MCP3561Pico 片上 ADC 更适合对绝对精度要求不高的场景。2. 工作模式与触发源ADC 不是只能读一下2.1 单次转换模式 vs 连续采样模式RP2040 的 ADC 支持两种基本工作模式单次转换Single-shot和连续采样Free-running。单次转换模式下你往 ADC_CS 寄存器的 START 位写 1ADC 开始转换转换完成后结果锁存到 FIFO 或 ADC_RESULT 寄存器同时 READY 位被置 1。这个模式适合低频传感器采样比如每 100 ms 读一次温度或者按键检测。连续采样模式下START 位保持为 1 时 ADC 会自动启动下一轮转换转换完成后再次自动启动循环往复直到你向 START 位写 0 才停止。这个模式适合需要连续波形采集的场景尤其是配合 DMA 使用时能实现无 CPU 干预的批量采样。Free-running 模式下 FIFO 可能会溢出需要注意及时读取或者配置 FIFO 水印中断否则数据会被新数据覆盖掉。2.2 采样时钟与采样周期的计算RP2040 的 ADC 时钟源是系统时钟 48 MHzUSB 模式或者 12 MHzPLL 模式分频通过 ADC_CLKDIV 寄存器也叫 ADC_DIV分频得到实际 ADC 时钟。这个分频寄存器是 16 位的8 位整数加 8 位小数所以分频精度相当高。ADC 的转换过程分为两个阶段采样阶段和转换阶段。采样阶段需要 96 个 ADC 时钟周期转换阶段需要 12 个比较周期加 1 个量化周期总共约 109 个 ADC 时钟周期。换句话说ADC 时钟频率决定了单次转换的总时间。实际采样频率的公式是f_sample f_adc / (96 12)如果直接用 48 MHz 作为 ADC 时钟f_sample 48 MHz / 108 ≈ 444 kSPS和标称的 500 kSPS 有一定的差距。数据手册上写的 500 kSPS 是约数实际要看你设置的 ADC 时钟分频值。如果你用默认配置不设置 ADC_DIV实际采样率就在 44 万到 45 万次每秒这个量级。2.3 定时器触发与 PWM 触发的配置思路RP2040 的 ADC 本身没有独立的硬件触发引脚但它可以通过 DMA 请求和定时器/PWM 配合使用。常用的做法是配置 PWM 在特定时刻产生一个 DMA 请求DMA 把 ADC 的 FIFO 数据搬运到内存——本质上是用 PWM 的周期来精确控制采样间隔。这个方式在控制类项目中特别实用比如电机电流采样。你需要在一个 PWM 周期内的特定相位点采集电流值如果只是用while循环不停轮询采样时刻和 PWM 边沿的相位关系是不确定的控制环路的效果会打折扣。我自己在做舵机控制的时候就遇到了这个问题后面实操部分会详细展开。3. 寄存器级操作全解直接操作硬件脱离库的束缚3.1 核心寄存器基址与偏移速查表RP2040 的 ADC 寄存器基地址是 0x4004C000以下是完整寄存器速查表寄存器名称偏移地址功能描述ADC_CS0x00控制与状态寄存器包含使能位、启动位、通道选择、READY 标志ADC_RESULT0x04最近一次转换结果12 位有效只读ADC_FIFO0x08FIFO 数据寄存器读取时弹出数据ADC_DIV0x0C时钟分频寄存器8 位整数 8 位小数ADC_INTR0x10中断状态寄存器ADC_INTE0x14中断使能寄存器ADC_INTF0x18中断强制寄存器ADC_INTS0x1C中断状态读后清除版本3.2 ADC_CS 寄存器逐位拆解ADC_CS 是整个 ADC 外设的核心控制寄存器32 位宽度但只用了低 12 位。各位的含义如下Bit 0ENADC 总使能位写 1 开启 ADC 电源必须置 1 才能进行转换Bit 1START启动转换位单次模式下写 1 启动一次转换free-running 模式下保持 1 连续转换Bit 3TS温度传感器使能写 1 时采样通道切换到内部温度传感器Bits 6-8AINSEL输入通道选择0b000 是 ADC0GPIO260b001 是 ADC1GPIO270b010 是 ADC2GPIO280b011 是 ADC3内部温度传感器0b100 是 ADC4VREF/2Bit 9ERR_STICKY错误锁存位只要发生过一次采样错误就会保持为 1Bit 10ERR当前采样错误标志电平方式不复位Bit 11READYADC 就绪标志1 表示当前无转换进行一个常见的坑是TS 位和 AINSEL 是协同工作的。即使你把 AINSEL 设置成 0b011温度传感器通道TS 位没置 1 的话读到的是不确定值。反过来TS 位置 1 后 AINSEL 被强制指向通道 3温度传感器。所以正确的温度采样寄存器操作顺序是先置 TS 位再等待 READY 为 1然后连续读两次结果取第二次第一次是管线内的旧数据最后清除 TS 位恢复普通通道。3.3 FIFO 工作机制与 DMA 交互ADC_FIFO 寄存器是一个 4 字深的硬件 FIFO读操作会弹出最老的数据。写操作不会直接发生——只有当 ADC 转换完成后硬件自动写入。这个 FIFO 的深度是 4这意味着如果中段处理不及时新数据会把旧数据覆盖掉造成数据丢失。FIFO 有水位线watermark配置位在 ADC_CS 寄存器的高 16 位FCS 字段里默认配置下 FIFO 中数据量达到 1 时就可以触发中断或者 DMA 请求。如果你要开启 DMA 传输在 DMA 控制块里把读取地址指向0x4004C008ADC_FIFO传输宽度设置为半字16 bit传输完成后触发中断释放信号量这样就能实现ADC 采样 → DMA 搬运 → CPU 处理的完整流水线。4. 实操从零手写 ADC 驱动完成舵机控制4.1 硬件接线与基础电路设计实操场景用 Pico 读取一个电位器或角度传感器的输出电压根据电压映射出舵机角度驱动舵机转到指定位置。这个场景非常典型融合了 ADC 采样、数值滤波、PWM 控制、电气隔离等多个知识点。硬件接线清单电位器10 kΩ三个引脚分别接 3.3V、GND、GPIO26ADC0SG90 舵机信号线接 GPIO15PWM 输出SG90 电源正极接 5VGND 和 Pico 共地我建议在电位器滑片到 GPIO26 之间串联一个 1 kΩ 电阻在 GPIO26 对地并联一个 0.1 μF 电容组成一个低通滤波器。这样做有两个作用一是限制高频噪声进入采样电路二是防止电位器滑片接触不良时产生的瞬态高压直接冲击 ADC 引脚。4.2 寄存器版 ADC 读取代码C 语言 硬件寄存器方式下面是用 C 语言直接操作寄存器读取 ADC 通道 0 的完整代码完全脱离 SDK 的adc.h封装#include pico/stdlib.h #include hardware/regs/adc.h #include hardware/regs/io_bank0.h #include hardware/regs/pads_bank0.h // ADC 寄存器基地址 #define ADC_BASE 0x4004C000u // 读取 12 位 ADC 结果通道 0 为例 uint16_t adc_read_channel0(void) { // 1. 使能 ADC volatile uint32_t *cs (volatile uint32_t *)(ADC_BASE ADC_CS_OFFSET); *cs | (1u 0); // EN 1 // 2. 选择通道 0清除 TS 位 *cs ~(0x7u 6); // 清 AINSEL *cs ~(1u 3); // 清 TS *cs | (0u 6); // AINSEL 0b000, 通道 0 // 3. 清除错误标志 *cs | (1u 9); // 4. 启动单次转换 *cs | (1u 1); // START 1 // 5. 等待 READY 位变为 1转换完成 while (!( *cs (1u 11) )) { tight_loop_contents(); } // 6. 读取结果 volatile uint32_t *result_reg (volatile uint32_t *)(ADC_BASE ADC_RESULT_OFFSET); return (uint16_t)((*result_reg) 0xFFFu); }注意几个细节步骤 2 中清 AINSEL 和清 TS 的位操作顺序不能颠倒因为 TS 位和 AINSEL 存在硬件互锁关系——TS 为 1 时 AINSEL 只能读通道 3即使你写了别的值也会被硬件忽略。步骤 3 的清除错误标志也要做否则 ERR_STICKY 为 1 时内部的仲裁逻辑可能异常。如果你用的是 MicroPython 又想了解底层可以这样实现寄存器访问from machine import mem32 ADC_BASE 0x4004C000 ADC_CS ADC_BASE 0x00 ADC_RESULT ADC_BASE 0x04 def read_adc0(): # 使能 ADC mem32[ADC_CS] | (1 0) # 选择通道 0 mem32[ADC_CS] ~(0x7 6) ~(1 3) mem32[ADC_CS] | (0 6) # 启动转换 mem32[ADC_CS] | (1 1) # 等待完成 while not (mem32[ADC_CS] (1 11)): pass return mem32[ADC_RESULT] 0xFFF4.3 采样数据的软件滤波为什么平均值法不够用原始 ADC 数据跳动 2~3 个 LSB在舵机控制场景中2~3 LSB 对应大约 0.3° 的角度误差视觉上不明显但如果你把采样值直接乘上比例系数去驱动舵机会听到舵机不断嗡嗡响——这是典型的高频抖动舵机在不停地做微小修正。我实测下来有效的组合滤波策略是先做中值滤波去掉脉冲噪声取 5 次采样的中间值然后做一阶低通滤波EMA指数移动平均平滑信号。中值滤波负责剔除偶发的毛刺EMA 负责把轻微的随机噪声压住。代码实现如下volatile uint16_t ema_value 0; uint8_t ema_init 0; uint16_t low_pass_filter(uint16_t new_sample) { if (!ema_init) { ema_value new_sample; ema_init 1; return ema_value; } // 一阶低通alpha 取 0.3 ema_value (uint16_t)((0.3f * new_sample) (0.7f * ema_value)); return ema_value; }alpha 系数要根据采样率和信号变化速度调整。对于舵机控制场景采样率 1 kHz信号是手动转动电位器产生的缓变信号alpha 取 0.3 比较合适既能跟上手动操作的速度又能把噪声压到 1 LSB 以内。如果 alpha 太小电位器转快一点输出会明显滞后如果 alpha 太大滤波效果接近于无。4.4 舵机角度映射与 PWM 驱动舵机控制的核心是脉宽调制PWM。SG90 舵机的控制信号是 50 Hz周期 20 ms的 PWM 波脉宽 0.5 ms ~ 2.5 ms 对应 0° ~ 180°。Pico 的 PWM 外设可以输出精确的脉宽。GPIO15 对应的 PWM 通道是 PWM_CH6_A时钟源是系统时钟 125 MHz或者 48 MHz取决于 PLL 配置通过 CSPLIT 分频和 WRAP 计数器值配合可以计算得到精确的脉宽值。计算公式PWM 频率 时钟频率 / (周期寄存器值 WRAP 1) / 分频器值 脉宽 (比较寄存器值 CCR) / (WRAP 1) / 频率假设系统时钟 125 MHz分频器设为 125那么 PWM 时钟就是 1 MHzWRAP 设为 19999PWM 频率就是 50 Hz每一周期脉宽 0.5 ms 对应 CCR5002.5 ms 对应 CCR2500。角度到脉宽的线性映射uint16_t angle_to_ccr(float angle) { // angle: 0.0 ~ 180.0 float pulse_us 0.5f (angle / 180.0f) * 2.0f; // 0.5ms ~ 2.5ms return (uint16_t)(pulse_us * 1000.0f); // 1MHz 时钟下1us 1个计数 }注意一点并不是所有舵机都严格遵循 0.5~2.5 ms 的脉宽范围便宜舵机的实际有效范围可能是 0.6~2.4 ms超出范围会顶死到物理限位产生嗡嗡声甚至烧驱动。安全做法是在前 50 次更新里做限幅让舵机在 1 ms ~ 2 ms 范围内先跑确认方向之后再把范围放宽。4.5 完整控制流程ADC → 滤波 → 映射 → PWM主循环逻辑while (true) { uint16_t raw adc_read_channel0(); uint16_t filtered low_pass_filter(raw); // 12 位 ADC 满量程 4095 对应 3.3V float voltage (filtered / 4095.0f) * 3.3f; // 假设电位器 0V ~ 3.3V 映射到 0° ~ 180° float angle (voltage / 3.3f) * 180.0f; uint16_t ccr angle_to_ccr(angle); pwm_set_chan_level(pwm_slice_num, PWM_CHAN_A, ccr); sleep_us(500); // 采样周期 2kHz }这个主循环没有用中断直接轮询。对于单个舵机控制足够了但如果要做多路舵机同步控制建议把采样放在定时器中断里主循环只负责控制逻辑。5. 应用避坑实录那些不起眼但致命的细节5.1 采样保持电容与源阻抗的匹配问题这是新手最容易踩的坑。RP2040 的 ADC 前端有一个采样电容典型值在 1~2 pF 量级充电时间常数取决于外部源阻抗。如果你用一个大阻值传感器比如 100 kΩ 的光敏电阻直接接到 GPIO26采样瞬间电容充电速度跟不上采样保持电容取到的电压会比实际值低。按照数据手册的建议外部源阻抗不要超过 10 kΩ。如果你必须使用高阻源需要在引脚到地之间并联一个大电容比如 1 μF充当电荷池。这个电容在采样间隙不断充电采样瞬间为采样电容快速补充电荷有效降低源阻抗的影响。但是要注意并联大电容会降低信号带宽快速变化的信号会失真。5.2 参考电压 VREF 的实际表现与校准RP2040 的 ADC 参考电压是芯片内部的 3.3V LDO 输出不是外部精密参考源。这个电压在不同芯片之间有 ±5% 左右的差异温度漂移也明显。换句话说同一个传感器接在不同 Pico 板子上ADC 读数换算出的电压值会有偏差。如果你的应用需要跨板一致性比如产品量产时的校准一致性必须在出厂时做一次单点校准。我的做法是将 GPIO26 接到一个精密 2.048V 兹纳二极管TL431 电路上电后测量 ADC 读数然后计算出实际增益系数存到 flash 的 OTP 区。后续所有读值都乘以这个系数修正。单点校准可以消除增益误差但不能消除偏移误差要求更高时还需要用两个精密电压点做两点校准。5.3 GPIO 复用模式对 ADC 信号的影响ADC 通道引脚默认是模拟功能但在 Pico 板上GPIO26/27/28 同时也被用于一些板载资源比如 Pico 的板载 LED 在 GPIO25不会冲突但有些扩展板会用 GPIO26 做数字 I/O。如果你之前用 GPIO26 做过数字输出切换到 ADC 之前必须把引脚的 Pad 配置恢复为模拟模式。在 C 语言 SDK 里adc_gpio_init()函数做的事情就是配置引脚的 IO 功能为 ADC 输入。如果把这个步骤漏掉ADC 读到的值可能始终为 0 或者跟测一条乱跳的曲线排查半天也找不到原因。寄存器层面你需要将 PADS_BANK0 中对应引脚的 IE输入使能位置 1SOC输出禁用位置 1并且设置 PUE 和 PDE上下拉电阻位都为 0避免上下拉影响外部信号。5.4 温度传感器的采样技巧读取 RP2040 内部温度传感器是很多低功耗项目的标配功能。但温度传感器的精确度其实不高——数据手册给出的误差范围是 ±2°C 到 ±5°C取决于校准条件。如果你的项目宣称温度精度要达到 ±1°C用内部温度传感器是不可能的必须外挂数字温度芯片。温度读数的处理流程float read_temperature_celsius(void) { // 1. 配置 ADC 为温度传感器模式 volatile uint32_t *cs (volatile uint32_t *)(ADC_BASE ADC_CS_OFFSET); *cs | (1u 0); // EN *cs | (1u 3); // TS 1 sleep_us(100); // 等待稳定 // 2. 连续读两次取第二次第一次丢弃 uint16_t v1 read_adc(); sleep_us(10); uint16_t v2 read_adc(); // 3. 温度计算公式来自 RP2040 datasheet float temp 27.0f - ((v2 * 3.3f / 4095.0f) - 0.706f) / 0.001721f; // 4. 恢复普通模式 *cs ~(1u 3); // TS 0 return temp; }公式中的 0.706 V 是温度传感器在 27°C 时的典型输出电压0.001721 V/°C 是温度系数。每个芯片的具体参数略有不同误差范围在 ±5°C 以内做个粗略的环境温度监测够用但要精确到小数点后一位就别指望它了。5.5 高阻环境下测量烟雾信号的坑用 ADC 读取烟雾传感器的输出信号时源阻抗经常达到几十千欧到几百千欧。除了 5.1 节提到的采样电容问题还有另一个隐蔽的坑烟雾传感器的输出本身带有直流偏置如果直接用 ADC 读取偏置会把有效信号淹没。正确做法是加一级运放做隔直和放大把传感器输出的有效交流分量放大到 ADC 的满量程范围内再采样。没有运放就直接接 ADC大概率读出来的数值只在小数点后几位跳动而你期望的有用信号变化根本看不出来。6. 问题排查与调试技巧实录6.1 典型故障对照表现象可能原因解决办法ADC 读值始终为 0EN 位未置 1或者引脚被配置为数字输出检查 ADC_CS 的 EN 位和引脚的 PADS 配置ADC 读值固定为 4095满量程引脚悬空或外部信号超出参考电压检查接线确认信号电压加下拉电阻读值严重抖动10 LSB电源噪声大或者采样电路源阻抗过高加去耦电容检查输入滤波电路温度传感器读数异常负值或超 100°CTS 位和 AINSEL 配置错误或者丢弃第一次读值严格按 5.4 节的流程操作通道切换后第一个数据异常切换通道后的转换结果包含残留电荷切换后丢弃第一个转换结果实际项目中至少丢弃 3 个DMA 批量采样时数据错位FIFO 溢出导致旧数据被覆盖增大 FIFO 读取频率或检测 ERR_STICKY 清错6.2 在线调试方法用串口输出原始采样波形调试 ADC 采集系统的时候光看最终控制效果是不够的。我的习惯是先在串口上输出原始 ADC 值和滤波后的值以 CSV 格式发送在 PC 端用 Python 画波形图。这样一眼就能看出噪声的模式、滤波器的效果、电位器转动是否线性等问题。# PC 端 Python 脚本读取串口数据画图 import serial import matplotlib.pyplot as plt ser serial.Serial(COM3, 115200) raw_values [] filtered_values [] for _ in range(1000): line ser.readline().decode().strip() if line: raw, filtered map(int, line.split(,)) raw_values.append(raw) filtered_values.append(filtered) plt.plot(raw_values, labelraw) plt.plot(filtered_values, labelfiltered) plt.legend() plt.show()Pico 端的发送代码就是把adc_read_channel0()的返回值用 printf 打印注意加换行符\n否则 PC 端串口解析会乱掉。6.3 DMA 采样中断优先级与数据完整性的经验做高速采样超过 200 kSPS的时候DMA 中断的优先级设置会影响数据完整性。我在实际项目中发现如果 DMA 中断优先级低于定时器中断而定时器中断的处理时间过长DMA 的 FIFO 溢出标志会置位但数据已经丢失了程序没有及时发现。正确处理方式是DMA 完成中断中优先检查 ADC_CS 的 ERR_STICKY 位如果发现置位立即停止当前采集流程把错误信息上报而不是埋头继续处理一批不完整的数据。这个检查动作只需要几条指令但能避免整批数据分析结果全部作废。7. 扩展思路ADC 底层操作还能用在哪其实搞懂了 Pico 的 ADC 寄存器操作整个 RP2040 的寄存器直接操作思路就一通百通了——RP2040 外设的寄存器布局、中断系统的设计、DMA 的数据通路都是类似的套路。后续你可以尝试用 DMA ADC 实现简易示波器利用 Pico 的 PIO 状态机配合 ADC 实现多通道分时采样或者把 ADC 数据通过 USB 虚拟串口以 1 Mbit/s 的速率实时上传到 PC 绘制波形。我个人的体会是直接用寄存器操作外部设备虽然前期代码量比用 SDK 封装多一点但对硬件工作机制的理解提升是 SDK 封装永远无法替代的。踩几次坑之后你对采样频谱、信号调理、噪声抑制这些概念的认知会从背公式变成有手感——看到一个新传感器扫一眼数据手册上的阻抗和带宽参数基本就能判断直连 Pico 的 ADC 行不行需不需要加缓冲器。这是做嵌入式底层开发最值钱的能力。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进