旋转编码器这东西,单看原理觉得简单,两个引脚输出正交方波,无非就是谁先谁后的问题。可真把它焊到板子上、接上树莓派 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 引脚,得到当前状态码
current(A 为 bit1,B 为 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(period=1, mode=machine.Timer.PERIODIC, callback=encoder_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 微秒之间。如果定时周期设为 1ms,CPU 占用率不到 20%,完全够用。
如果你把周期改到 100us(0.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(f"Angle: {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_state=0直接变成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 有一个很强大的外设叫 PIO(Programmable 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, period=1, threshold=3): # 初始化引脚、定时器 ... 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 的定时器调度机制这三块拼图对上号,方案自然就浮出水面了。如果只是照抄代码,今天能用,明天换个编码器型号可能就拉胯。希望这篇文章能帮你把"为什么这么写"彻底想明白,下次遇到类似问题,你就能自己设计出更合适的消抖逻辑。