
旋转编码器这玩意儿用好了是手感利器用不好就是调试地狱。前阵子我在做一个手持参数调节面板主控选的就是树莓派 PicoMicroPython 开发环境。功能不复杂一个旋转编码器调数值一个 OLED 显示一个舵机跟着动。听起来挺简单吧结果我第一天就被编码器“抖”到怀疑人生——手一拧数值乱跳明明顺时针转三格它给你往回跳两格偶尔还多跳一格。后来我把整套消抖逻辑从“轮询延时”换成了“定时器状态机”问题才算彻底根治。今天就把这个完整方案拆开聊从为什么会有抖动、为什么用定时器、到 Pico 上的 MicroPython 代码怎么一条条写全讲清楚。正在折腾旋转编码器、被乱跳数值折磨的朋友这篇应该能帮你少走不少弯路。1. 先看本质编码器为什么“抖”消抖到底在消什么想写好消抖程序就绕不开编码器的工作原理。不把这层磨透后面出了问题只会对着代码瞎猜。1.1 机械结构决定了信号天生“脏”市面上最常见的旋转编码器是增量式机械编码器比如你淘宝搜“EC11”出来那一堆。它内部结构很简单一个带金属簧片的旋转轴踩在 PCB 上的一圈固定触点上。你每旋转一个角度簧片就会依次碰通几个触点从而输出电平变化。这里的关键在于簧片是金属的触点是金属的两者接触瞬间不可能像理想电路那样瞬间导通。在一次旋转过程中簧片会经历“刚碰上一开始弹开一再碰上”的机械振动过程。反映在电平上就是一个状态切换中间夹了若干次快速抖动。这种毛刺宽度的典型量级是几个微秒到几个毫秒取决于编码器质量、旋转速度、以及你的手稳不稳。提示这不是代码问题是物理问题。机械旋转编码器只要存在抖动就一定存在消抖程序要做的不是“消灭抖动”而是“识别并忽略抖动”。1.2 A/B 两相正交信号的读取逻辑编码器输出两路信号叫 A 相和 B 相。它们之间的相位差恒为 90 度所以叫“正交信号”。旋转方向不同两路信号谁先跳变、谁后跳变也会不同。剥掉细节后检测方向的通用做法是每次检测到 A 或 B 的电平跳变时去读另一相的电平。举个例子如果是在 A 相上升沿读 BB 为高说明 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 (A0,B0) [1, 0, 0, -1], # 上一次状态为 1 (A0,B1) [-1, 0, 0, 1], # 上一次状态为 2 (A1,B0) [0, 1, -1, 0] # 上一次状态为 3 (A1,B1) ] 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(modeTimer.PERIODIC, period1, callbackencoder_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.2ms200 微秒结果问题更严重了抖动毛刺本身就包含快速电平翻转采样越快越容易捕捉到这些毛刺的中间状态状态机看到的现象是“同一个位置反复多跳几格”。后来我把周期调到 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。方向是好的期待你也试试。