树莓派Pico ADC实战:用C语言面向对象封装PS2摇杆模块
发布时间:2026/9/6 10:36:31 锦皓数字建站

做嵌入式这段时间我最常被问到的一个问题就是ADC 到底怎么学直接啃手册太枯燥做项目又不知道从哪里上手。如果你也有这种感觉我强烈建议你从这个小项目开始用树莓派 Pico 读取一个 PS2 摇杆模块的数据。它不复杂却能把 ADC 原理、硬件接线、采样滤波和代码架构全部串起来。更关键的是这个项目天然适合用 C 语言里的面向对象思路来组织代码写出来不会变成一坨面条代码。我最初是直接拿adc_read()循环读结果发现数据抖动、按键悬空、摇杆回中不准一个都没少。后来重新梳理了硬件结构又用结构体加函数指针把摇杆封装成一个独立模块代码一下子清爽了很多后面还想接舵机、做遥控手柄都变得很容易。这篇文章就把整个过程的硬件结构、接线方式、代码封装思路和调试踩坑全部记录下来适合刚学完 GPIO 和基础 C 语言、准备进阶 ADC 的嵌入式学习者。1. 为什么做这个实验以及整体设计思路1.1 摇杆模块真的是“模拟”输入吗很多人第一次拿到摇杆模块会以为它像游戏手柄一样输出数字信号直接读高低电平就行。实际完全不是。市面上最常见的 PS2 摇杆模块两个轴向 VRx、VRy 输出的是连续变化的模拟电压本质上是两个电位器在分压只有中间的 SW 按键是数字量。这个区分很重要因为一旦接错思路后面整个代码方向就偏了。摇杆的 X 轴和 Y 轴不能用gpio_get()去读必须走 ADC 采集而 SW 按键又不能用 ADC 通道去读它是个按钮开关。所以这个实验看起来简单实际上同时包含模拟量采集和数字量输入两类应用非常适合当成一个完整的嵌入式入门练手项目。我在给初学者讲这块的时候常说一个摇杆模块等于“两个电位器加一个按键”。你在玩飞行游戏时推杆的手感本质上就是手指在改变电位器滑臂的位置进而改变分压比例。理解这句话硬件原理就通了一半。1.2 为什么选树莓派 Pico 来跑这个项目选 Pico 而不是 STM32、ESP32 或者 Arduino有几个原因。首先是 RP2040 这颗芯片的 ADC 资料相对完整引脚也不复杂GP26、GP27、GP28 三个 ADC 通道直接引出到排针不用查复用表就能接线对新手非常友好。STM32 的 ADC 功能更强但引脚映射、ADC 时钟、采样周期配置一大堆很多人在RCC那里就先迷路了。其次是 Pico 可以用 C/C SDK也可以用 MicroPython还能用 Arduino 框架。同一个硬件你可以先用 MicroPython 验证硬件再用 C SDK 做严谨的工程化实现学习梯度非常平滑。这个项目里我用的是 C 和 Pico SDK因为要聊面向对象封装C 才是最能体现“结构体加函数指针”这门手艺的地方。当然如果你手头已经有一块 STM32F103 或者 ESP32也完全可以照着同样的思路移植。ADC 的核心原理是通用的摇杆模块的接口也是一样的只是引脚宏和库函数名字不同。理解 Pico 这一套换成 STM32F ADC 获取的数据逻辑完全不变。1.3 从硬件到软件的“三层”设计做这个项目之前我给自己定了一个整体框架避免想到哪写到哪。整个系统分三层底层是摇杆模块的硬件结构也就是电位器分压和按键回路中间层是 Pico 的 ADC 初始化、采样和 GPIO 读取上层是基于 ADC 原始值做的滤波、校准、死区判断和事件回调。这样分层最大的好处是硬件有问题不会赖到软件头上软件要扩展也不用推翻硬件接线。比如后来我想加舵机控制只需要在应用层新写一个servo_map()函数从摇杆对象里拿处理过的 x、y 值再映射成 PWM 占空比摇杆底层一行都不用改。这就是嵌入式架构设计里很实用的一点点心得。2. 硬件结构拆解摇杆电位器和 Pico ADC 通道2.1 摇杆模块内部结构拆开一个 PS2 摇杆模块能看到两个 10kΩ 电位器一个控制 X 轴一个控制 Y 轴两个电位器的滑臂分别接到模块的 VRx 和 VRy 引脚。摇杆靠在中心位置时两个电位器都处于中间抽头输出电压大约是供电电压的一半向某个方向推到底对应轴的输出电压就会接近 0V 或供电电压。模块上的 VCC 和 GND 就是电位器的两端供电正常接 3.3V 和 GND。这里有个非常容易踩的坑很多摇杆模块兼容 5V但 Pico 的 ADC 参考电压是 3.3V而且 ADC 引脚绝不是 5V 容忍引脚。如果你把 VCC 接到 5VVRx 在高位时输出的电压会超过 3.3V轻则读数长时间卡在 4095重则直接烧坏 RP2040 的 ADC 输入级。实验级应用我还是建议统一用 3.3V 供电。除了两个电位器模块上还有一个 SW 引脚对应摇杆垂直按下时的微动开关。按下时内部机械触点闭合把 SW 引脚拉到 GND松开时是悬空状态。所以这个按键需要外接上拉电阻Pico 内部也有可编程上拉电阻直接在初始化时用gpio_pull_up()打开即可。否则你会发现按键读数随机跳一会儿高一会儿低。2.2 Pico 的 ADC 硬件基础12 位 SAR ADC 与采样限制RP2040 内部集成的 ADC 是 12 位逐次逼近型 ADC也就是面试八股里常说的 SAR ADC。它通过一个比较器逐位逼近输入电压分辨率是 4096 个量化等级。Pico 的adc_read()返回 0 到 40950 对应 0V4095 对应参考电压而参考电压默认就是芯片的 3.3V 供电。SAR ADC 的结构决定了它有几个实践上必须注意的细节。第一采样过程会被“串扰”如果输入源阻抗太高采样保持电容来不及充满读数就会偏低且不稳定。摇杆电位器的输出阻抗典型值是 10kΩ并不算很低所以在高精度场景下建议在 ADC 引脚对地并联一个 0.1μF 电容给采样电容提供一个瞬时电荷池。第二采样不是瞬间完成的adc_read()内部要做采样和逐次逼近连续读多个通道时中间要留一点时间Pico SDK 本身已经处理了但你自己在裸机编程时要注意采样周期不要给得太短。之前看贺老师讲嵌入式那套 ADC 基础课时提到一个说法ADC 读出来的值不是“真值”而是“输入电压在参考电压下的量化投影”。这句话我特别认同。摇杆模块输出的是分压后的模拟电压这个电压本身会随供电波动、随机械磨损漂移所以后续必须做滤波和校准不能把单次采样值当成真理。2.3 接线示意图与供电注意事项完整的接线特别简单我用的是 Pico 的排针直插没有额外转接板。接线对照关系如下摇杆模块引脚接 Pico 引脚说明VCC3V3(OUT)供电必须用 3.3VGNDGND共地不能省VRxGP26 / ADC0X 轴模拟输出VRyGP27 / ADC1Y 轴模拟输出SWGP22按键输出启用内部上拉上电前最好拿万用表量一下 VCC 和 GND 之间有没有短路再量一下 VRx 对 GND 的电阻摇杆居中时应该在 5kΩ 左右推到底会接近 0 或 10kΩ。这个动作看似多余却能救你一次我遇到过一盒模块里有两个是虚焊的VRx 引脚碰一下就断不量根本发现不了。还有一个容易忽略的点Pico 的 ADC 参考电压是芯片供电电压不是独立的精密基准。如果你用 USB 供电且同时带舵机或者电机负载变化会导致 3.3V 波动ADC 读数也会跟着抖。做这个摇杆实验没问题但后续如果要驱动大电流外设建议把模拟部分和数字部分分开供电或者在软件里做参考电压补偿。3. 先用最原始的方式读 ADC再谈面向对象封装3.1 裸读取能跑但问题很多刚拿到模块时我其实没写任何面向对象的逻辑直接就是几行初始化代码加一个循环#include pico/stdlib.h #include hardware/adc.h #include hardware/gpio.h int main() { stdio_init_all(); adc_init(); adc_gpio_init(26); adc_gpio_init(27); gpio_init(22); gpio_pull_up(22); while (true) { adc_select_input(0); uint16_t x_raw adc_read(); adc_select_input(1); uint16_t y_raw adc_read(); uint16_t btn !gpio_get(22); printf(x%u y%u btn%u\n, x_raw, y_raw, btn); sleep_ms(10); } }这段代码能在串口里看到数据但它的问题很快暴露出来。首先是读数跳动明显摇杆静止时 x 数值也能在十几二十个数之间来回跳其次是不能区分“轻微偏离中心”和“用户真的有操作意图”我又不想在应用层每次都用一堆if (abs(x - center) threshold)去处理按键更是没有消抖按下一次经常会打印两次甚至三次。更重要的是这段代码只在这个 main 文件里能用如果我新开一个舵机控制文件又要重新复制一堆变量和逻辑完全不可维护。这时候就轮到面向对象上场了。3.2 C 语言的面向对象编程思路很多人在嵌入式 C 里谈面向对象会本能地说“C 不支持 class”但实际上 C 语言完全可以实现面向对象的三大核心概念封装、继承、多态。封装就是结构体把数据和操作这个数据的函数放一起多态就是函数指针调用方不需要关心具体实现继承则可以通过结构体嵌套实现虽然不如 C 优雅但足够用。网上有一份流传很广的资料叫《C 语言面向对象编程嵌入式实战》里面讲的正是这个思路。它没有玄学核心套路就是“定义一个结构体作为对象的属性再定义一组函数作为对象的方法操作时把结构体指针传过去”。这样写出来的代码每个外设都像一个小盒子盒子有自己的状态也有对外提供的接口应用层只跟接口打交道。用摇杆举例我不需要担心应用层是舵机还是屏幕它只要调用joystick_read(joy)然后从joy.x、joy.y里拿值就行了。将来如果要把摇杆换成触摸板模拟输出只要保证Joystick结构体对外接口不变内部实现随便改这就是封装带来的好处。3.3 定义 Joystick 对象结构体、操作函数、事件回调下面这个结构体就是摇杆对象的雏形#define JOY_FILTER_N 16 typedef struct { uint8_t adc_ch_x; uint8_t adc_ch_y; uint8_t btn_gpio; uint16_t center_x; uint16_t center_y; uint16_t deadzone; uint16_t filter_buf[JOY_FILTER_N]; uint8_t filter_cnt; uint8_t filter_idx; int16_t x; int16_t y; uint8_t btn_state; uint8_t btn_event; void (*on_change)(int16_t x, int16_t y); void (*on_btn_press)(void); } Joystick;字段分成三类硬件配置、运行状态、事件回调。adc_ch_x、adc_ch_y、btn_gpio是初始化时填的硬件映射center_x和center_y是校准后的中心值filter_buf是滑动平均滤波的环形缓冲区最后的on_change和on_btn_press是函数指针相当于对象的事件接口。你可能觉得函数指针很玄其实它就是“把函数当作变量传进结构体”。比如在 main 里给摇杆绑定一个回调joy.on_change process_joystick_change;以后每次内部数据更新完会自动调用process_joystick_change()不需要在 while 循环里额外判断。这个模式在大型嵌入式项目里很常见比如协议栈收到完整帧后触发一个回调按键驱动检测到长按后触发另一个回调。4. 核心代码实现采样滤波、中心校准、按键消抖4.1 为什么必须做滤波滑动平均窗口怎么取ADC 单次采样值抖动是正常现象。摇杆电位器本身有接触噪声供电有纹波电磁环境也会耦合进来。如果直接用原始值映射成舵机角度舵机就会高频抖动听起来像在“打嗝”。最常见的滤波方式是滑动平均维护一个缓冲区每来一个新样本就把它放进队尾同时丢掉队头输出取整个缓冲区的平均值。这个办法简单、实时性好特别适合 ADC 这种周期性采样场景。代码实现如下static uint16_t moving_average(Joystick *js, uint16_t new_sample) { js-filter_buf[js-filter_idx] new_sample; js-filter_idx (js-filter_idx 1) % JOY_FILTER_N; if (js-filter_cnt JOY_FILTER_N) { js-filter_cnt; } uint32_t sum 0; for (uint8_t i 0; i js-filter_cnt; i) { sum js-filter_buf[i]; } return (uint16_t)(sum / js-filter_cnt); }这里的窗口长度JOY_FILTER_N选择有讲究。窗口太大平滑效果好但滞后明显推杆后要很久才能反映到输出窗口太小滤波等于没做。我实测 8 到 16 之间比较合适摇杆是人工操作几十毫秒的响应延迟完全感知不到。我用 16 以后静止时的抖动幅度从 ±15 压到了 ±3 以内效果已经很好。4.2 中心校准与死区处理摇杆模块回中时理论上 X、Y 都应该是 2048实际上因为电位器制造误差和安装应力中心值往往偏离几十甚至上百。所以需要在系统启动时让用户保持摇杆居中读取 N 次取平均作为中心值。校准函数可以这样设计void joystick_calibrate_center(Joystick *js) { uint32_t sum_x 0; uint32_t sum_y 0; uint8_t count 20; for (uint8_t i 0; i count; i) { adc_select_input(js-adc_ch_x); sum_x adc_read(); adc_select_input(js-adc_ch_y); sum_y adc_read(); sleep_ms(10); } js-center_x sum_x / count; js-center_y sum_y / count; js-filter_cnt 0; js-filter_idx 0; }有了中心值还要做死区处理。任何时候摇杆都不可能完美停在中心手稍微抖一下经过滤波后的值也会偏离中心几个数。如果不加死区屏幕上的光标会轻微飘动舵机也会小幅抖动。我在更新函数里做了这样的归一化int16_t dx (int16_t)x_raw - (int16_t)js-center_x; if (dx -js-deadzone dx js-deadzone) { dx 0; } else { if (dx 0) dx - js-deadzone; else dx js-deadzone; }死区大小我是默认 20也就是大约 20 / 2048 ≈ 1% 的行程。太小了没效果太大了会让摇杆在小角度操作时失去线性手感很差。你也可以把死区值暴露成可配置参数在调试阶段先用串口打印观察实际数据再现场调整。4.3 按键消抖与事件输出SW 按键是数字输入但机械按键必然存在抖动。按下的一瞬间触点会因弹性机械振荡产生几十毫秒的连续高低电平跳变如果用gpio_get()直接读很容易把一个“按下”误判成好几次。最轻量的消抖方案是“时间窗口确认”检测到电平变化后延时 5 到 20 毫秒再读一次如果电平稳定再认为按键状态真的变了。我在摇杆对象里维护了一个上一状态字段uint8_t cur_btn (gpio_get(js-btn_gpio) 0) ? 1 : 0; if (cur_btn ! js-btn_state) { sleep_ms(10); cur_btn (gpio_get(js-btn_gpio) 0) ? 1 : 0; if (cur_btn ! js-btn_state) { js-btn_state cur_btn; if (cur_btn 1 js-on_btn_press) { js-on_btn_press(); } } }这里用了最朴素的延时函数消抖。因为更新循环本身是 10ms 一次10ms 的延时不会造成明显卡顿。如果是在中断或实时性要求比较高的场景建议改成状态机加时间戳的方式不阻塞当前线程。对于这个摇杆实验朴素的延时方案完全够用。4.4 完整代码整合其实上面几个功能拼起来完整的更新函数大概长这样void joystick_update(Joystick *js) { adc_select_input(js-adc_ch_x); uint16_t raw_x moving_average(js, adc_read()); adc_select_input(js-adc_ch_y); uint16_t raw_y moving_average(js, adc_read()); // 去中心 死区处理 js-x normalize(raw_x, js-center_x, js-deadzone); js-y normalize(raw_y, js-center_y, js-deadzone); // 按键消抖与事件 uint8_t cur_btn (gpio_get(js-btn_gpio) 0) ? 1 : 0; if (cur_btn ! js-btn_state) { sleep_ms(10); cur_btn (gpio_get(js-btn_gpio) 0) ? 1 : 0; if (cur_btn ! js-btn_state) { js-btn_state cur_btn; if (cur_btn 1 js-on_btn_press) { js-on_btn_press(); } } } if (js-on_change) { js-on_change(js-x, js-y); } }main 函数就清爽了初始化摇杆对象绑定回调然后在一个 10ms 循环里不停调用joystick_update(joy)。中间那一堆滤波、校准、消抖细节全部被封装在模块内部后面就算换到 STM32也就是把adc_read()换成 HAL 库的HAL_ADC_GetValue()外部的游戏逻辑不用动。5. 接线、编译、烧录与调试实录5.1 环境准备Pico SDK 还是 Arduino我在这个项目里用的是 Pico C SDK因为要展示结构体和函数指针C 是最合适的语言。环境搭建不复杂核心步骤是拉取 SDK配置PICO_SDK_PATH然后写一个CMakeLists.txtcmake_minimum_required(VERSION 3.13) include(pico_sdk_init.cmake) project(joystick_demo C CXX ASM) pico_sdk_init() add_executable(joystick_demo main.c joystick.c) target_link_libraries(joystick_demo pico_stdlib hardware_adc hardware_gpio) pico_add_extra_outputs(joystick_demo)编译生成joystick_demo.uf2后按住 Pico 上的 BOOTSEL 按钮插入 USB松开按钮把 uf2 文件拖进弹出的 U 盘就烧录完成了。整个过程非常“傻瓜化”这也是我推荐新手用 Pico 做 ADC 实验的原因之一省去各种下载器接线。如果你习惯 Arduino 环境也可以把代码移植成 Arduino 框架。Pico 的 Arduino 核心已经把analogRead()封装好了但要注意返回值范围。不同核心可能不一样官方 arduino-pico 默认是 12 位返回 0 到 4095你需要先跑一个打印例程确认而不是直接套用以前 AVR 板子的 0 到 1023。5.2 实测数据中心值、极限值、抖动幅度我把编译好的固件烧进去用串口助手观察了一组数据。摇杆自然回中时X 轴大约在 1987 到 2011 之间跳Y 轴大约在 2042 到 2059 之间跳中心值并不在 2048 附近。这说明每个模块的机械回中精度都不一样不校准直接用一定会出现方向偏移。把摇杆分别推到上下左右四个极限位置读数分别接近 0 和 4095。X 轴推到底时接近 4095Y 轴推到底时也接近 4095但两端不是完全对称有的轴最大值是 4088最小值是 12。这是因为电位器行程的物理边界和电阻体不完全覆盖整个 ADC 量程。实际应用里不需要纠结这一点反正映射到舵机角度或游戏坐标时你会做归一化。开启 16 点滑动平均滤波后静止读数抖动从 ±15 左右降到了 ±3效果非常明显。再开启 20 的死区处理后静止时输出就是 0不再有漂浮感。这个实验让我真正体会到了“硬件有噪声不可怕软件滤波没思路才可怕”这句话。5.3 我踩过的三个坑共地、上拉、GPIO 复用第一个坑就是供电不共地。最开始我图省事用一个小面包板同时给摇杆和屏幕供电结果摇杆模块的 GND 和 Pico 的 GND 之间没有完全连通之间有几十欧姆的接触电阻。ADC 读到的数值会随着屏幕刷新而跳动查了半天才发现是地线问题。记住模拟电路里“共地”不是口号是硬底线。第二个坑是 SW 按键悬空。我一开始没有在软件里开内部上拉SW 引脚直接浮空GPIO 读到的值像抽奖一样。后来在gpio_init后立刻调用了gpio_pull_up(22)问题迎刃而解。如果你的摇杆模块比较特殊板上已经带了上拉电阻那开不开都行但一般模块没有所以建议统一在软件里打开内部上拉成本最低。第三个坑和 GPIO 复用有关。Pico 的 GP26、GP27 既是 ADC 引脚也能当普通 GPIO 用。如果你在代码里先调用了gpio_set_function(26, GPIO_FUNC_SIO)后面再用adc_gpio_init(26)有时候不会报错但确确实实会把引脚切回 ADC 功能。问题在于顺序不对会导致初始化失效。正确做法是用adc_gpio_init()统一设置不要自己手动混用GPIO_FUNC_SIO。6. 常见问题速查与排查思路6.1 常见问题表格把我在实际调试中遇到的高频问题整理成了一张表方便你遇到类似情况时快速定位。现象可能原因排查办法摇杆读数固定为 0摇杆 VCC 没接或 GND 断开用万用表量 VRx 对地电压摇杆读数固定为 4095VCC 接到 5V 或 ADC 引脚短路确认 VCC 为 3.3V断电检查接线读数乱跳和摇杆操作无关供电共地不良或滤波不足检查 GND 连通增加滑动平均窗口摇杆回中后输出不为 0没有做中心校准和死区处理调用中心校准并将死区设为 20 左右按键按一下触发多次缺少消抖增加 10ms 电平确认按键状态完全不变SW 引脚悬空打开gpio_pull_up()接上舵机后摇杆读数不稳舵机电流拉低 3.3V 参考电压摇杆和舵机分开供电共地这张表不是标准答案但排查思路是通用的。ADC 类问题先查硬件通路再查参考电压最后才查软件逻辑顺序不要反。6.2 扩展把摇杆数据映射到舵机控制这个项目做完之后最自然的扩展就是接一个舵机做一个“摇杆控制云台”。舵机控制需要 50Hz 的 PWM脉宽通常从 0.5ms 到 2.5ms 对应 0 到 180 度。Pico 的 PWM 模块可以直接产生这个信号。映射思路很直观经过死区处理后的 x 值范围大约在-2048到2048把它线性映射到舵机脉宽即可uint16_t servo_pulse_from_x(int16_t x) { int32_t mapped (int32_t)(x 2048) * 2000 / 4096 500; return (uint16_t)mapped; }这里mapped的单位是微秒输出范围约 500us 到 2500us。你可以在实际调试时根据舵机品牌调整上下限。注意舵机是功率器件尽量不要从 Pico 的 3.3V 引脚直接供电否则会把 ADC 基准电压拉得乱七八糟。这也是我在 2.3 里强调供电分离的后续延续。写到最后再分享一个我个人的建议这类实验不要只看代码跑通就结束。你可以在串口里盯着原始值把手放在摇杆上慢慢推感受数值连续变化的过程也可以故意把滤波关掉听一听舵机发出的抖动声。只有亲手对比过“滤波前”和“滤波后”的差别才能真正理解为什么要做这些处理。嵌入式说到底是一门实践的功夫ADC 尤其如此多花时间在观察数据和修正参数上会比背一百个面试题更有用。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。