☰
树莓派Pico旋转编码器消抖:定时器+状态机实战指南
2026/11/5 13:13:31 网站建设 项目流程

旋转编码器这玩意儿,用好了是手感利器,用不好就是调试地狱。前阵子我在做一个手持参数调节面板,主控选的就是树莓派 Pico,MicroPython 开发环境。功能不复杂:一个旋转编码器调数值,一个 OLED 显示,一个舵机跟着动。听起来挺简单吧?结果我第一天就被编码器“抖”到怀疑人生——手一拧,数值乱跳,明明顺时针转三格,它给你往回跳两格,偶尔还多跳一格。

后来我把整套消抖逻辑从“轮询+延时”换成了“定时器+状态机”,问题才算彻底根治。今天就把这个完整方案拆开聊,从为什么会有抖动、为什么用定时器、到 Pico 上的 MicroPython 代码怎么一条条写,全讲清楚。正在折腾旋转编码器、被乱跳数值折磨的朋友,这篇应该能帮你少走不少弯路。

1. 先看本质:编码器为什么“抖”,消抖到底在消什么

想写好消抖程序,就绕不开编码器的工作原理。不把这层磨透,后面出了问题只会对着代码瞎猜。

1.1 机械结构决定了信号天生“脏”

市面上最常见的旋转编码器是增量式机械编码器,比如你淘宝搜“EC11”出来那一堆。它内部结构很简单:一个带金属簧片的旋转轴,踩在 PCB 上的一圈固定触点上。你每旋转一个角度,簧片就会依次碰通几个触点,从而输出电平变化。

这里的关键在于:簧片是金属的,触点是金属的,两者接触瞬间不可能像理想电路那样瞬间导通。在一次旋转过程中,簧片会经历“刚碰上一开始弹开一再碰上”的机械振动过程。反映在电平上,就是一个状态切换中间夹了若干次快速抖动。这种毛刺宽度的典型量级是几个微秒到几个毫秒,取决于编码器质量、旋转速度、以及你的手稳不稳。

提示:这不是代码问题,是物理问题。机械旋转编码器只要存在,抖动就一定存在,消抖程序要做的不是“消灭抖动”,而是“识别并忽略抖动”。

1.2 A/B 两相正交信号的读取逻辑

编码器输出两路信号,叫 A 相和 B 相。它们之间的相位差恒为 90 度,所以叫“正交信号”。旋转方向不同,两路信号谁先跳变、谁后跳变也会不同。

剥掉细节后,检测方向的通用做法是:每次检测到 A 或 B 的电平跳变时,去读另一相的电平。举个例子,如果是在 A 相上升沿读 B:

  • B 为高,说明 A 相超前于 B 相,顺时针旋转;
  • B 为低,说明 B 相超前于 A 相,逆时针旋转。

另一种更稳妥的办法是状态机法:把 A、B 两相组合成一个 2 位状态值(比如 A 接高两位,B 接低两位),旋转一格,状态按 00→01→11→10 的顺序循环;反向则完全反过来。程序只需要维护一个“上一次状态”,每次读到新状态后查表判断方向。

状态机法的好处很明显:它不依赖某一路是上升沿还是下降沿,只要采样频率足够快,任何一次真实变化都会让状态进入合法序列,而抖动有可能让状态在相邻状态间快速反复,但查表结果会互相抵消,不会累计错误计数。这就是消抖在逻辑层的核心思路。

2. 消抖方案取舍:为什么最终选了“定时器”

旋转编码器消抖方案在网络上随手一搜能出来一大堆:最简单的有延时等待,好一点的有边沿检测加延时确认,再高级的用状态机+轮询,工业级则直接用专用芯片或定时器捕获。给你看看我当时是怎么逐步排除的。

2.1 几种主流方案的横向对比

方案原理优点缺点适用场景
RC 硬件滤波在 A/B 输出端并联电阻电容组成低通滤波,压制毛刺几乎不占 CPU,响应快需要改硬件,电容取值不合适会衰减边缘导致丢步量产固件、对实时性要求极高的场合
延时消抖检测到跳变后延时 5~10ms 再读一次,确认电平稳定代码最简单,几行就够阻塞主流程,转快时会漏事件;Pico 主频虽高但 MicroPython 延时很浪费只处理一两个按键、不追求手感时
边沿中断+状态机GPIO 上升沿/下降沿触发中断,在回调里跑完整状态机响应及时,CPU 占用低中断回调里不能做耗时操作;MicroPython 中断响应有延迟,极端情况下边缘毛刺可能导致重复触发配合 C/C++ SDK 或单片机裸机开发时优选
定时器周期采样建立固定周期定时器,每次回调读一遍 A/B 状态,跑状态机去抖不阻塞主循环,实现稳定,时序可预测采样周期要选好,太快浪费资源、太慢响应迟钝MicroPython 应用、多任务轮询场景、想省心上手

需要注意,硬件滤波和软件逻辑并非只能二选一。好一点的设计都是软硬结合:板子上留 RC 滤波焊盘,程序里再包一层软件状态机。我这次用的是纯软件方案,因为手里板子已经焊好了,不想再飞线改电容,而且 Pico 的处理能力对这种 10ms 级别的逻辑绰绰有余。

2.2 定时器方案的核心优势:不阻塞 + 时间基准稳定

真正让我放弃“延时消抖”的元凶,是 MicroPython 里的延时操作。Pico 主频默认 125MHz,看起来挺快,但 MicroPython 解释执行一条time.sleep_ms(10)涉及解释器、调度器,实测 IRQ 期间卡顿非常明显。如果我一边读编码器一边刷新 OLED,延时消抖会让整个界面掉帧,手快时漏计数。

定时器方案的思路完全不同:我开一个周期 1ms 的machine.Timer,每到时间点,系统自动打断当前任务进入回调函数,在回调里读一次 GPIO 状态并更新状态机。主循环该干嘛干嘛,显示、控制舵机、响蜂鸣器,互不干扰。因为采样周期固定,每次采样之间都有一个明确的观察窗口,比“边沿来临时再判断”更容易滤掉短毛刺。

这里也提一句其他平台的参考:STM32、GD32、8051 这些单片机在 C 环境下做消抖,普遍也是走定时器中断或硬件捕获通道,原理完全一样,只是写法从 MicroPython 换成了寄存器/HAL 库操作,思路完全可以平移。

3. 树莓派 Pico 上的定时器消抖实践

理论聊完了,直接上干货。下面是我在 Pico 上验证过的完整方案,从接线到代码逐段解释。

3.1 硬件准备与引脚连接

我用的编码器是常见的 EC11 带开关款,五针脚,左右两排是 A、B 输出,中间一路是公共端,另外两脚是按下时的开关信号。项目里我先只处理旋转,按下开关另外接了一个 GPIO 去处理长按/短按逻辑,跟旋转消抖互不干扰。

接线我按这样接的:

编码器引脚Pico GPIO说明
A 相输出GP10用内部上拉,初始为高电平
B 相输出GP11用内部上拉,初始为高电平
公共端GND编码器内部开关结构不同,需要确认公共端接 GND 还是 VCC
按键输出GP12按键按下时拉低,作为额外输入

关于公共端接法,这里有个容易踩的坑:EC11 的公共端一般标为 C 或 K 端,有的编码器内部结构要求公共端接地,有的要求接 VCC。如果信号反了,你得到的是“高电平有效的脉冲序列”,配合 Pico 的内部上拉/下拉逻辑会乱套。稳妥做法是先查你手里编码器的数据手册,或者用万用表量一下 A/B 对公共端的通断变化规律。

提示:GPIO 内部上拉是必须的。EC11 是机械开关,开路状态下引脚悬空,悬空电平是浮动的,会被空间电磁干扰带跑,看起来就跟疯了一样乱跳。开了内部上拉,静止时引脚稳定在高电平,只有旋转时才会产生清晰的低/高切换。

3.2 定时器回调函数与状态机核心代码

完整代码如下,我加了详细注释,把关键逻辑都标出来了。你把它复制到 Pico 板子上,接好线,串口监视器里应该能看到清晰的步进计数。

from machine import Pin, Timer import time # 引脚初始化:编码器A/B相,开启内部上拉 pin_a = Pin(10, Pin.IN, Pin.PULL_UP) pin_b = Pin(11, Pin.IN, Pin.PULL_UP) # 全局计数器和上一次A/B组合状态 counter = 0 last_state = 0 # 状态到方向映射表 # 我们约定状态值 = (A << 1) | B,即高两位是A,低两位是B # 旋转一格时,合法状态序列为:0->1->3->2->0(顺时针) # 逆时针时状态序列完全相反:0->2->3->1->0 # 表格索引是上一次状态,存储了与当前状态比较时应增加/减少的步数 # 用 -1 表示非法跳变(抖动导致的无效状态),忽略不计 direction_table = [ [0, -1, 1, 0], # 上一次状态为 0 (A=0,B=0) [1, 0, 0, -1], # 上一次状态为 1 (A=0,B=1) [-1, 0, 0, 1], # 上一次状态为 2 (A=1,B=0) [0, 1, -1, 0] # 上一次状态为 3 (A=1,B=1) ] def read_encoder_state(): """读取编码器当前A/B组合状态""" a_val = pin_a.value() b_val = pin_b.value() return (a_val << 1) | b_val def encoder_tick(timer): """定时器回调:状态机消抖核心逻辑""" global counter, last_state current_state = read_encoder_state() if current_state != last_state: # 查表获得方向步进值:顺时针 +1,逆时针 -1,非法跳变 0 step = direction_table[last_state][current_state] if step != 0: counter += step # 这里可以加一个标志位,告诉主循环“编码器有变化了” # 实际项目里最好不要在回调里直接 print,避免串口阻塞 last_state = current_state # 创建定时器:周期1ms,也就是每1ms采样一次编码器状态 timer = Timer(mode=Timer.PERIODIC, period=1, callback=encoder_tick) # 主循环:显示计数即可 while True: # 注意:counter是全局变量,需要在修改它的回调里声明global # 主循环里直接读即可 print("counter:", counter) time.sleep_ms(100)

这段代码的核心就是direction_table这张表。它把上一次状态和当前状态组合后,直接查表得出“应该步进多少”,而不需要写一堆if嵌套。非法跳变返回 0,直接忽略;合法跳变返回 1 或 -1。因为在旋转过程中会在 00→01→11→10 这几个合法状态间连续变化,它会把整个旋转过程拆成多个步进事件,累计结果与真实旋转格数完全一致。

3.3 让结果“动起来”:数值变化驱动舵机联动示例

上面的代码只是干巴巴打印计数,实际项目里肯定要把计数映射到输出设备上。我这次是用它控制一个舵机角度,效果是旋转编码器时舵机能跟着转、且在两端有限位。

舵机控制本质是通过 PWM 改变脉冲宽度。Pico 的 PWM 输出频率设为 50Hz(周期 20ms),占空比决定舵机角度。MicroPython 里用machine.PWM很方便,这里我直接给出联动代码的核心片段:

from machine import Pin, PWM servo = PWM(Pin(15)) # 舵机信号线接GP15 servo.freq(50) # 50Hz,标准舵机周期 # 将0~180角度映射到0.5ms~2.5ms脉宽 def set_angle(angle): if angle < 0: angle = 0 if angle > 180: angle = 180 pulse_ms = 0.5 + (angle / 180.0) * 2.0 # 0.5ms~2.5ms duty = int(pulse_ms / 20.0 * 65535) # 换算到16位占空比 servo.duty_u16(duty) # 主循环里,把counter映射成角度 last_display_counter = 0 while True: current_counter = counter if current_counter != last_display_counter: # 假设步数范围映射到 0~180 度 target_angle = current_counter % 180 set_angle(target_angle) print("angle:", target_angle) last_display_counter = current_counter time.sleep_ms(50)

这里提个细节:set_angle里的脉宽计算用的是理想值。实际舵机个体差异很大,有的 0.5ms 脉宽是 0 度,有的却是 -10 度;建议拿到舵机后先测一下极限脉宽,再反向映射,不然容易顶到机械限位发出“嗡嗡”声。我的舵机实测最小脉宽是 0.6ms 左右,最大在 2.4ms 左右,所以我把映射做了微调,让角度输出更线性。

4. 把消抖做稳的几条实战经验

代码看似简单,但真实项目里把它调稳了需要一些细节经验。每一条都是我踩坑踩出来的,希望你不要再踩一遍。

4.1 采样周期选择:太快反而容易“抖不可除”

定时器周期是关键参数。我一开始贪图“高采样率”,直接把period设成了 0.2ms(200 微秒),结果问题更严重了:抖动毛刺本身就包含快速电平翻转,采样越快,越容易捕捉到这些毛刺的中间状态,状态机看到的现象是“同一个位置反复多跳几格”。

后来我把周期调到 1ms,效果就完全正常了。原因在于:

  • 机械编码器的真实抖动毛刺宽度,绝大多数集中在 0.1ms ~ 2ms 区间;
  • 如果采样周期远小于毛刺宽度,你会把毛刺当成真实转动去计数;
  • 如果采样周期远大于毛刺宽度,你会丢失真实的转动事件,快速旋转时漏步;
  • 1ms 是两者之间的折中点,既能滤掉大部分毛刺,又能保证快速手速下不丢步。

提示:这只是我手里 EC11 的体会。你手里的编码器质量和手感不同,最稳的做法是先用示波器看一遍 A/B 波形,测出毛刺宽度分布,再回来调period。没有示波器的话,按 1ms 起步,步进异常就翻倍往上调成 2ms、5ms,步进丢失就往下调。

4.2 硬件与软件配合的几个避坑点

**内部上拉不是万能药。**我最初直接依赖 Pico 内部上拉,接线很长的情况下照样乱跳。后来发现是面包板跳线太长,线束之间引入耦合干扰。如果你发现编码器在“没人碰它”的时候偶尔自己跳一步,先怀疑接线质量问题:缩短跳线、绞合线对、或者给 A/B 到地之间并一个 100nF 电容,基本能解决。

**回调里不要 print。**MicroPython 里print会触发串口字符输出,速度远慢于回调周期。如果你在encoder_tick回调里直接 print,回调耗时被拉长,定时器下一次回调会发生积压或丢帧,计数反而错乱。正确姿势是回调里只更新计数和标志位,主循环里空闲时统一打印或刷新显示。

**不要试图“滤掉所有抖动”。**抖动是物理世界的正常现象。状态机消抖的目标是让非法状态不产生累计误差,而不是让状态序列保持干净。要接受一个事实:图表显示上可能还有个别毛刺状态,但因为它们被仲裁为 0 步进,最终的 counter 不会变。只要计数结果稳定,中间状态脏一点没关系。

4.3 与其他平台方案的移植思路

你如果从树莓派 Pico 换到 STM32、ESP32、GD32 上,这套逻辑几乎原样能搬。

  • STM32 用 HAL 库时,定时器中断回调里跑同样的状态机查表即可;
  • 用 cubemx 配置时把时基定时器设成 1ms 中断,中断里只做状态读取和查表;
  • GD32 需要在rcu_timer_clock_prescaler_config里配置合适的时钟分频,然后设置自动重载值;
  • 如果是 STM32 用定时器输入捕获来测频率,它解决的是“测量脉冲频率”问题,跟“消抖判断方向”是两码事,别混在一起。

说白了,状态机消抖是平台无关的逻辑,只是每个平台的定时器配置方式不同。把核心逻辑和硬件配置抽象成两个模块,移植时会非常省事。

5. 常见问题与排查技巧速查表

整理一份我实际调试中遇到的高频问题,方便你直接对照排查。

现象可能原因排查方法解决方案
旋转方向反了A/B 接反,或公共端接法不同打印 A/B 电平变化序列看看规律交换 A/B 两根线,或修改状态映射表顺序
慢转正常,快转丢步采样周期太长,无法完整跟踪状态变化串口输出采样到的状态序列,看是否有跳变把定时器周期从 1ms 调到 0.5ms
静止时偶尔自动跳一步接线上拉不足,或电磁干扰用手触碰导线观察状态;用短导线替换长跳线开内部上拉;在主板上加 100nF 滤波电容
旋转一格却跳两格步数插座接触不良导致重复触发,或状态机表配错增加手感和旋转角度观察状态序列检查接线;重新验证状态机映射表
主循环卡顿,界面刷新慢回调里做了耗时操作,如 print、字符串拼接注释掉回调里的 print 再试把打印移到主循环
计数器值不稳定,时正时负抖动毛刺被当成有效状态累计用示波器看毛刺宽度,或者调大采样周期调整定时器周期,或引入连续确认机制
方向盘两端到底后异响舵机限位角度与实际脉宽不匹配单测舵机最小/最大脉宽对应的 PWM 值重新映射角度范围,留出安全余量

这里特别提示连续确认机制:如果你在非常强烈的震动环境下使用编码器,单一状态查表可能还不够,可以在查表的基础上加一个“连续读到相同方向 N 次才计数”的逻辑。这个逻辑用一次性延时不现实,但结合定时器方案很自然:加一个“候选方向值”和“候选计数累加器”,连续 N 次采样都给出相同方向时才真正更新计数。代价是响应会延迟 N 个采样周期,但抗干扰能力会更强。

提示:如果是工业现场的强震动环境,建议还是硬件 RC 滤波加软件状态机双保险,纯软件方案再怎么做,也比不上硬件层面把毛刺能量吸收掉来得直接。

6. 一个可以继续优化的方向

基础消抖能跑通之后,我后来还做了个小改进,让手感好了不少:把“单次旋转反馈”改成了“脉冲数按速度动态缩放”。思路是:旋转越快,每个脉冲在物理上对应的角度间隔应该按比例衰减,否则高速旋转时 encoder 输出脉冲密度高、而舵机响应不过来,就会出现“手动快了舵机跟不上”的滞后感。

这个在 Pico 的 MicroPython 里实现起来也不复杂,在定时器回调里记录“相邻两次有效步进之间的时间间隔”,间隔越短说明转速越快,然后给累计步数乘一个低于 1 的速度系数。不过这个对响应实时性要求高,MicroPython 的效率做轻量UI还行,复杂实时控制建议直接上 C 语言 SDK。方向是好的,期待你也试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询