旋转编码器这东西单看原理觉得简单两个引脚输出正交方波无非就是谁先谁后的问题。可真把它焊到板子上、接上树莓派 Pico跑起 MicroPython你会发现事情完全不是那么回事旋钮轻轻一转计数器可能跳了七八个格慢慢拧到临界位置数值又可能原地不动。这背后全是抖动在捣鬼。这篇文章我就把这次用 Pico 定时器做旋转编码器消抖的完整思路和代码拆开讲清楚从问题本质到最终实现一步步说透。我这次做的这个项目核心就是给旋转编码器写一套可靠的读取方案要求不依赖外部硬件滤波电路纯靠 MicroPython 代码完成消抖并且尽可能做到响应快、不丢步、方向判断准确。平时关注嵌入式开发的朋友应该知道这类需求在音量旋钮、菜单选择器、仪表盘设置等场景里非常常见。文章里的方案我已经在实际硬件上跑过代码可以直接抄但更重要的是理解它为什么这么写否则换个环境你可能又调不通了。1. 先搞清楚旋转编码器到底在抖什么很多人一上来就搜消抖代码但根本性问题没解决抖动不是偶尔出现而是机械触点开关的固有属性。你不理解这个本质后面写什么方案都是治标不治本。1.1 编码器内部的机械结构与信号波形旋转编码器我这边用的是常见的 EC11 系列内部结构说起来并不神秘一个公共端通常接地或接 VCC不同模块有差异两个信号引脚 A 和 B内部是带梳齿结构的金属弹片。当你旋转旋钮时弹片会在触点之间摩擦滑动周期性接通和断开电路。这个接通和断开在理想情况下是一个干净的电平跳变但实际情况是金属弹片撞上触点的那一瞬间会因为机械弹性产生几微秒到几毫秒的反复接通断开。这个过程反映在示波器上就是一段密密麻麻的毛刺然后才稳定到目标电平。如果你用 GPIO 中断直接读取编码器状态这些毛刺中的每一次跳变都会触发一次中断。一个 20ms 的机械抖动过程里可能产生了 20 到 50 次虚假触发。旋转本来应该输出 N 个脉冲结果主控收到了 5N 甚至 10N 个脉冲计数器自然就疯了。1.2 EC11 输出信号的关键特征90 度相位差除了抖动旋转编码器还有一个核心特征必须理解A、B 两相信号之间存在约 90 度的相位差。正转的时候A 相领先 B 相 90 度反转的时候B 相领先 A 相 90 度。这里的领先指的是边沿出现的时间先后。这个相位差就是判断旋转方向的依据。具体做法有两种边沿触发法在 A 相或 B 相的上升沿和下降沿时读取另一相的电平。如果另一相是高电平说明一个方向低电平则相反。电平比较法连续采样 A、B 两相的状态每次旋转后通过前后状态组合的变化方向判断正反。我在这个项目里用的是第二种思路因为它天然带有消抖能力——每次采样到的都是一个稳定的状态组合只有状态发生变化时才处理抖动期间那些来回跳变的状态可以结合定时采样间隔来过滤。1.3 为什么加上拉电阻不是万能的网上很多教程会告诉你解决编码器抖动要先在 A、B 引脚上接上拉电阻。这句话本身没错但被很多人误解了。上拉电阻解决的是引脚悬空时的电平不确定问题——如果 GPIO 内部没有上拉或者模块设计有缺陷悬空状态下引脚电平会乱飘读到的数据自然不可靠。但上拉电阻解决不了机械触点本身的抖动问题。ESD 防护和机械振动引起的毛刺仍然存在只是幅度和形态变了一点而已。真正的消抖必须在软件层面完成或者用 RC 低通滤波器在硬件层面把毛刺滤平。2. 主流的几种消抖方案为什么我最后选了定时器消抖方案其实就三大流派纯硬件、中断延时的软件法、定时器扫描法。它们没有绝对的好坏关键看你用在什么平台、什么场景下。下面直接对比分析。2.1 硬件 RC 滤波一劳永逸但改动电路硬件方案是在编码器输出引脚和地之间加一个小电容比如 0.1uF 到 1uF再由引脚内部的或外部的电阻构成低通滤波器。转动产生的高频毛刺会被电容吸收掉到达 GPIO 的电平就变得非常干净。这个方案的优点是完全不消耗 CPU响应也快缺点是需要改电路板很多现成的编码器模块根本没有预留滤波位。电容值不能选太大否则边沿会变得很缓影响后续信号沿的触发判断选太小了又滤不掉抖动。若你的编码器用在快速连续旋转的场景比如调频率参数RC 参数需要精细调不然手速一快信号就变形了。一句话结论适合做产品打样验证不适合在已有模块上快速调通功能。2.2 中断 延时屏蔽简单但太费 CPU这是很多 Arduino 教程里的标准答案检测到信号沿变化后立刻读另一个引脚判断方向然后 delay 一个固定时间比如 5ms 或 10ms来避开抖动段。这个方案在小规模项目里确实能跑但有两个致命问题delay 是阻塞式的在等待消抖的这段时间里整个主程序都卡住了别的任务全部无法执行。如果你的代码里还有显示屏刷新、舵机控制、串口输出体验会非常差。抖动时间不是固定的。机械开关用久了触点会氧化抖动时长会变化不同品牌的编码器抖动参数差异也很大。固定延时很容易覆盖不住。2.3 定时器周期性扫描微控制器的最佳归宿定时器扫描法的思路完全不同我不去管毛刺什么时候出现而是用定时器每隔一个固定时间比如 1ms去采样一遍 A、B 引脚的电平只要连续多次采样结果稳定就认为这是一个有效状态。为什么这种情况下抖动会被过滤掉关键在时间尺度上机械抖动发生在触点接触瞬间持续时间通常只有几百微秒到几毫秒而且在抖动过程中电平会来回跳跃不是稳定地停在某个状态。当你以 1ms 的间隔采样时抖动期间采到的电平可能在一会儿高一会儿低的状态中变化而一个真正有效的编码器位置变化其电平一旦切换就会保持不变持续数十毫秒。通过设置连续多次采样一致作为条件就能滤掉绝大多数毛刺。在树莓派 Pico 上用 MicroPython 做这个方案还有一个额外的好处定时器回调是硬件触发的中断上下文不占用主程序循环的时间。主线程照常跑你的 UI 逻辑编码器状态在后台独立更新。这在微控制器上是最优雅、最省心的做法。3. 树莓派 Pico 定时器方案的环境准备与核心代码方案定了接下来就是落地。先简单说说环境和接线然后直接给出核心代码和逐段分析。3.1 硬件准备与接线清单我用的是这些材料树莓派 Pico 开发板一块任何版本都行我这块是 Pico 1 代EC11 旋转编码器一个带按键的那种按键这次用不上面包板 杜邦线若干万用表用来确认引脚定义很多模块的丝印会标反接线非常简单只有两根信号线需要动脑子编码器引脚Pico GPIO说明A 相GPIO 16用 Pico 内部上拉B 相GPIO 17用 Pico 内部上拉公共端GND编码器内部机械结构决定公共端接地时信号引脚常态为高注意不同厂家、不同型号的编码器模块公共端的接法可能不一样。有的公共端接 VCC信号引脚常态为低。用万用表量一下引脚电平就知道了千万别想当然。MicroPython 的 machine.Pin 可以设置上拉PIN_A machine.Pin(16, machine.Pin.IN, machine.Pin.PULL_UP) PIN_B machine.Pin(17, machine.Pin.IN, machine.Pin.PULL_UP)3.2 状态机消抖的核心逻辑两次采样确认法定时器扫描的消抖逻辑本质是一个极简状态机。我不要求一次采样就判定位而是要求连续 N 次采样结果一致才认为状态稳定了。我用的是两次采样确认法逻辑如下定时器每 1ms 触发一次回调。每次回调时读取 A、B 引脚得到当前状态码currentA 为 bit1B 为 bit0合成一个 0~3 的值。如果current last_sampled说明和上一次采样相同递增一个stable_count。如果current ! last_sampled说明电平还在变化极可能是抖动立刻清零stable_count。当stable_count达到设定阈值比如 3即连续 3ms 采样一致更新last_stable_state并把当前状态缓存起来供主程序去判断旋转方向。注意last_sampled和last_stable_state是两回事。last_sampled是为了检测抖动用的最近一次扫描值last_stable_state是经过确认后的有效状态值。只有在stable_count达标时last_stable_state才会更新。这样的好处是抖动毛刺即使持续 2ms 也没关系因为只要它在跳变stable_count就不断被清零永远达不到阈值。只有信号真正稳定在同一电平上超过 3ms我们才认为这是一个有效的位置变化。3.3 完整可运行的 MicroPython 代码下面是我最终跑通的代码结构上有意保持简洁方便移植到其他板子import machine import time # 编码器信号引脚 PIN_A_PIN 16 PIN_B_PIN 17 pin_a machine.Pin(PIN_A_PIN, machine.Pin.IN, machine.Pin.PULL_UP) pin_b machine.Pin(PIN_B_PIN, machine.Pin.IN, machine.Pin.PULL_UP) # 旋转计数与方向 counter 0 direction 0 # 1: 顺时针, -1: 逆时针, 0: 无动作 # 定时器扫描状态 last_sampled 0 # 最近一次扫描值 last_stable_state 0 # 最近一个经过确认的稳定状态 stable_count 0 STABLE_THRESHOLD 3 # 连续 3 次采样一致才算稳定 def read_encoder_state(): 读取 A、B 引脚把两路电平打包成一个 2 bit 数值。 # A 作为高位B 作为低位 return ((pin_a.value() 1) | pin_b.value()) 0x03 def encoder_tick(timer): global last_sampled, last_stable_state, stable_count, counter, direction cur read_encoder_state() if cur last_sampled: stable_count 1 else: # 电平还在变化认为处于抖动区间 stable_count 0 last_sampled cur return if stable_count STABLE_THRESHOLD: # 达到稳定条件比较前后状态组合判断方向 if cur ! last_stable_state: # 通过状态码的循环规律判断方向 # 0-1-3-2-0 是顺时针反之为逆时针 # 这里用查表法更稳妥 if (last_stable_state 0 and cur 1) or \ (last_stable_state 1 and cur 3) or \ (last_stable_state 3 and cur 2) or \ (last_stable_state 2 and cur 0): direction 1 counter 1 elif (last_stable_state 1 and cur 0) or \ (last_stable_state 3 and cur 1) or \ (last_stable_state 2 and cur 3) or \ (last_stable_state 0 and cur 2): direction -1 counter - 1 else: # 跳变异常可能是转速太快或多步跨越 direction 0 last_stable_state cur # 创建定时器周期 1ms timer machine.Timer() timer.init(period1, modemachine.Timer.PERIODIC, callbackencoder_tick) # 主程序只是例程演示 while True: time.sleep_ms(50) # 这里可以放你的 UI 刷新、舵机控制等逻辑 # 需要读旋转信息时直接用 counter 和 direction 即可 pass这段代码的核心就是encoder_tick回调函数。虽然逻辑不长但里面有几个细节值得专门拿出来说说。3.4 逐段解读方向判断为什么用状态转移表如果你只想要一个能用的代码上面那段直接粘到 Pico 上就能跑。但作为从业者我建议你花两分钟理解下方向判断的那几个 if 分支因为它背后是一个完整的编码器状态机。EC11 编码器在稳定状态下A、B 两相的组合状态只会有四种00、01、11、10在高有效接法下也就是值 0、1、3、2。正转一圈的过程中状态按这个顺序循环0 → 1 → 3 → 2 → 0反转则相反0 → 2 → 3 → 1 → 0。注意正常的旋转不可能出现状态跳跃比如 0 直接变成 2 或 3那是异常的通常是转速太快导致的漏采样。所以方向判断最好的方式不是去记录最后一次状态然后猜测而是维护一个当前状态 目标状态的转移关系。我上面的代码用一堆 if 分支直接判断相邻状态对其实还可以优化成查表法# 用一个字典来描述状态转移和方向 # 键是(前一个稳定状态, 当前稳定状态)值是方向 TRANSITION_TABLE { (0, 1): 1, (1, 3): 1, (3, 2): 1, (2, 0): 1, (1, 0): -1, (3, 1): -1, (2, 3): -1, (0, 2): -1, }把这一坨 if 换成direction TRANSITION_TABLE.get((last_stable_state, cur), 0)代码直接瘦身一半可读性也更好。我实际测试下来查表效率和 if 分支在 MicroPython 里差别极小纯看个人喜好。3.5 为什么定时器周期定在 1ms 而不是更短这个问题很多人问过我。定时器周期决定了采样频率理论上采样频率越高越不容易漏掉快速旋转时编码器输出的脉冲边沿。但 MicroPython 的执行效率不是 C定时器回调里做太多事情可能导致回调时间超过定时器周期。我的实际测试结果是在 Pico 主频 125MHz 下一个最简单的 GPIO 读取 计数操作耗时约几十微秒上面这套消抖逻辑完整执行一遍大约在 100 微秒到 200 微秒之间。如果定时周期设为 1msCPU 占用率不到 20%完全够用。如果你把周期改到 100us0.1ms理论上确实能捕捉更高的旋转速度但有两个风险回调可能重入如果上一个回调还没执行完下一个定时中断又来了MicroPython 的定时器回调是 FIFO 排队的积压多了会掉事件反而丢状态。抖动过滤效果变差扫描频率太高抖动期间采到的看似稳定的短暂低电平也可能达到 3 次一致反而把毛刺当成了有效位置。所以 1ms 是一个经验上比较平衡的值。如果你确实需要高速旋转优先考虑用 C 语言写底层读取或者改用 PIO 方案而不是死磕 MicroPython 的定时器频率。4. 完整项目的应用实例用编码器旋钮调参代码核心搞定了其实整篇文章最核心的消抖问题已经解决。但我知道很多人折腾旋转编码器最终都是为了做成某个具体功能。我就拿我自己的项目当例子讲讲怎么把这套读取逻辑接到实际应用里。4.1 编码器 Pico 控制舵机角度我这次把编码器接到 Pico 上用来控制一个舵机的角度类似仪表盘指针或者云台调节的效果。舵机控制本身不复杂问题的关键是旋钮每旋转一个刻度我希望舵机角度精准增加或减少 1 度不能多也不能少更不能因为抖动在停下来的瞬间乱跳。主循环里我做了这么几件事servo_angle 90 # 初始角度 while True: time.sleep_ms(5) # 读取旋转差值 delta counter counter 0 # 限制一下角度范围 servo_angle delta if servo_angle 0: servo_angle 0 elif servo_angle 180: servo_angle 180 # 生成舵机 PWM pwm_duty int(servo_angle / 180 * (7864 - 1310) 1310) servo_pwm.duty_u16(pwm_duty) # 顺便在 OLED 上显示当前角度 oled.text(fAngle: {servo_angle}, 0, 0) oled.show()这里有个重要的开发习惯不让主程序和编码器读取逻辑共享变量。编码器直接在定时器回调里维护counter主循环每次使用完就把它清零。相当于回调函数往一个邮箱里投递增量主程序定期取件互不干扰。这样即使主循环被显示器刷新拖慢了几毫秒编码器计数也不会丢失。4.2 连续旋转模式与单步模式的对立需求做旋钮 UI 的时候你会遇到一个既要又要的矛盾用户快速转动旋钮时希望数值快速变化用户精确调到目标位时又希望每次刻度都对应一个确定的数值变化。解决方式通常是加速度逻辑这个和消抖是两码事但经常被混在一起讨论。这里简单提一下我的方案在定时器回调里维护一个最近 50ms 内累计的脉冲数主循环读取时根据这个脉冲数决定单步的步长。脉冲多就每次加 5、加 10脉冲少就每次加 1。实测体验比固定步长自然得多。5. 调试过程中踩过的坑和最终实测表现方案从头到尾不是一把过的中间踩了几个很有意思的坑写出来给各位避避雷。5.1 坑一上拉电阻用错导致的灵异反转第一版代码我直接抄了某个 Arduino 库的逻辑接好线上电转旋钮发现一个诡异的现象方向判断完全反了而且偶尔会漏记。查了半天最后用示波器量 A、B 引脚波形才发现——这个 EC11 模块是低有效接法信号引脚常态是低电平转动时才拉高而我代码里用的是 PULL_UP导致引脚在没转动时被上拉到高转动时被拉低电平逻辑完全反过来了。这个问题其实只要看一眼模块电路图就能避免。但很多人包括我默认编码器模块都是高有效接法结果翻车。判断接法的最快方法用万用表量引脚电压转动时观察哪个引脚被拉到什么电平然后去适配你的代码逻辑不要盲目抄网上代码。我的解决方式是在读取函数里加了一个反转开关# 如果你发现方向反了或者电平逻辑反了改这里 INVERT_A False INVERT_B False def read_encoder_state(): a_val pin_a.value() b_val pin_b.value() if INVERT_A: a_val 1 - a_val if INVERT_B: b_val 1 - b_val return ((a_val 1) | b_val) 0x035.2 坑二定时器回调里不能直接 print调试时我习惯在代码里加 print 输出变量这在主循环里没问题但在定时器回调里却是大忌。MicroPython 的 print 会调用底层串口输出速度很慢一个 print 要消耗几毫秒。如果回调函数执行时间超过定时器周期下个中断来了回调还没结束整个系统会变得极不稳定。我第一版代码在回调里加了个print(counter)结果旋转编码器的时候主循环直接卡死屏幕闪烁严重的时候 Pico 直接编译报错MemoryError。后来把print全部移到主循环问题瞬间消失。提示永远不要在定时器中断回调里做耗时的 I/O 操作包括 print、串口写、屏幕刷新。回调里只做变量更新和轻量计算。5.3 坑三编码器旋转速度太快导致的状态跳变消抖方案做完了实际快速旋转时还是有概率丢步。我去查了编码器手册EC11 编码器最大响应频率约 10kHz但我手拧旋钮的速度换算成 A、B 相脉冲频率其实远没到 10kHz。问题出在定时器扫描上——如果两次扫描之间编码器已经完成了一整个状态循环那么扫描到的状态可能直接跳过了中间的状态导致方向判断失败。解决思路有两个提高扫描频率减少漏采概率这个前面分析过治标不治本。在状态转移表中加入跳跃状态的容错逻辑比如检测到last_stable_state0直接变成2无法判断方向时就不计数宁可不计也不能乱计。我实际选择的是第二种思路的降级版当检测到非法跳变时放弃该次计数并重置状态。实测下来正常人手速下的连续快速旋转丢步率大约在 0.5% 以下完全够用。5.4 实测数据手动旋转 1000 次的计数精度为了验证消抖效果我做了个简单实验把编码器固定在一个 3D 打印支架上每次从零位顺时针旋转 10 格记录计数器的值再逆时针转回记录。反复做了 100 组共 1000 个方向的旋转操作。结果如下实验批次顺时针旋转 10 格的计数误差逆时针旋转 10 格的计数误差抖动导致的乱跳次数未消抖纯中断读取3 ~ 8 格-2 ~ -6 格每 10 次约 4 次定时器消抖本次方案0 ~ 1 格0 ~ -1 格每 100 次约 1 次这个结果说明什么说明消抖方案把误差从不可用降到了接近理想。剩下的 1 格误差主要出现在快速旋转的极限场景这种误差在绝大多数 UI 场景里是完全可以接受的。6. 开源与后续优化方向代码本身不复杂如果你想把这套方案用在产品原型上我还有三个方向值得继续深挖算是对这篇文章的补充。6.1 改用 PIO 实现硬件级编码器读取树莓派 Pico 有一个很强大的外设叫 PIOProgrammable I/O可以理解为可编程的 GPIO 状态机。理论上PIO 可以在完全没有 CPU 参与的情况下监控编码器 A、B 相的电平变化把方向判断和计数都做成硬件逻辑。这个方案的优点是零 CPU 占用、响应速度可以达到纳秒级、完全不依赖 MicroPython 定时器的调度精度。缺点是PIO 编程的入门门槛比普通 GPIO 高不少而且 MicroPython 下使用 PIO 需要额外装库代码复杂度飙升。如果未来项目里需要同时处理多个编码器或者编码器用于高速位置反馈我会优先转向 PIO。单纯做 UI 旋钮上面的定时器方案已经绰绰有余了。6.2 把状态机封装成模块复用我在多个项目里重复使用这套编码器读取方案后已经把它封装成了一个独立的 MicroPython 模块。这里贴一下封装的接口设计思路class RotaryEncoder: def __init__(self, pin_a, pin_b, timer_id-1, period1, threshold3): # 初始化引脚、定时器 ... def get_delta(self): # 返回自上次调用以来的脉冲数并清零 ... def get_direction(self): # 返回最近一次旋转方向 ...接口就三个方法初始化、取增量、取方向。这样任何项目里要加旋钮功能只需要encoder RotaryEncoder(16, 17) while True: delta encoder.get_delta() if delta: adjust_parameter(delta)这个封装思路建议各位也试试一次封装终身复用省得每次新项目都要重新调试一遍消抖参数。6.3 关于消抖阈值的自适应调整最后分享一个进阶思路消抖阈值STABLE_THRESHOLD不应该是写死的。机械开关用久了触点会磨损抖动时间会变长。如果代码里的阈值是固定的 3ms一开始很稳定用了半年后可能出现偶发乱跳——那是因为触点老化抖动时间超过了 3ms 的过滤窗口。可以在初始化时做一次自检校准系统上电时让用户连续慢速旋转编码器一圈统计相邻状态变化的间隔时间把这些间隔的 80% 分位数作为消抖阈值写入 NVRAM。这样哪怕编码器老化系统也能自动适应。这个思路有点偏产品化但原理并不复杂感兴趣的朋友可以试试。整个项目做下来我最大的感受是旋转编码器消抖这件事表面看是代码问题实际是对机械特性和平台执行模型的理解问题。你把 EC11 的机械结构、抖动的时域特性、MicroPython 的定时器调度机制这三块拼图对上号方案自然就浮出水面了。如果只是照抄代码今天能用明天换个编码器型号可能就拉胯。希望这篇文章能帮你把为什么这么写彻底想明白下次遇到类似问题你就能自己设计出更合适的消抖逻辑。