1. 先搞清楚:旋转编码器的抖动从哪来
旋转编码器这东西,做菜单调节、音量旋钮、仪表参数设置,基本是嵌入式小项目里人手一个的标配。EC11 这类机械编码器不贵也好用,唯一的毛病就是触点会抖动。你要是把 A/B 两脚直接接到 GPIO 上,用轮询或者中断去累计数值,大概率会看到数字乱飞:正转一格可能跳三四个数,停在某个位置偶尔还会自己变一下。这文章我把自己在树莓派 Pico 上跑 MicroPython 时用的定时器采样消抖方案从头到尾写一遍,从接线、代码到踩过的坑都有,适合刚接触编码器的新手,也适合已经吃过大亏、想找可靠方案的人。
1.1 EC11 编码器的输出原理
EC11 机械旋转编码器看起来很简单,但它的输出不是普通的“转一下出一段高低电平”,而是两路方波信号:A(也叫 CLK)和 B(也叫 DT),再加上一个公共端 C。公共端通常会接到 GND,A/B 通过内部或者外部的上拉电阻保持高电平;旋转的时候,编码器内部的触点会依次把 A/B 拉低,形成一个固定的相位关系。
正常转动时,A 和 B 会按照固定顺序切换。拿一路典型信号来说,顺时针转一个完整的档位,状态序列大概是:
- 00 → 10 → 11 → 01 → 00
注意这里的“00”是指 (A, B) 两个引脚的电平组合,1 是高电平,0 是低电平。逆时针则是完全相反的顺序:00 → 01 → 11 → 10 → 00。这个序列也叫格雷码序,特点是每一步只有一个引脚的电平发生变化,A 和 B 的相位关系决定了旋转方向。
1.2 抖动是怎么产生的
机械编码器的触点闭合本身不是“啪”一下就干净利落的。金属触点接触的瞬间会有几十到几百微秒的弹跳,接触、断开、再接触,看起来就像在抖动。EC11 触点抖动持续时间通常不超过 1~2ms,但就是这短短几毫秒,会让 A/B 引脚在真实状态之间来回闪好几次。
如果不做处理,代码每检测到一个电平变化就当一次有效转动,那么真实逻辑上“转一格”的事件,会被误判成好几个事件。更头疼的是,抖动过程中 A/B 的先后关系也可能出现瞬间反向,导致方向判断也跟着反一下,最后表现出来就是:明明正转,计数器偶尔还会往负数方向跳。
1.3 消抖要解决的两类误动作
消抖听起来像是个小事,实际要解决的是两类问题:
- 第一类是“多计”:一个档位的转动被重复计算成好几个数值,这是最直观的乱跳现象。
- 第二类是“误判方向”:抖动导致 A/B 状态出现非法跳变,程序把方向算反了。
所以好的消抖方案,不光是“延时再读一次”,而是要同时解决“状态什么时候才算稳定”和“方向到底怎么判断”这两个问题。下面所有方案,我都会围绕这两点展开。
2. 消抖方案选型:为什么我用定时器而不是 delay
2.1 常见消抖方案横评
在做这个方案之前,我其实先把市面上常见的消抖做法都过了一遍,各有各的坑。
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 硬件 RC 滤波 | 在 A/B 引脚外加电阻电容,把抖动波形拉平 | 不占 CPU,实时性好 | 需要额外元件,参数要根据转速调,太慢会丢步,太快又没滤干净 |
| GPIO 中断边沿触发 | 用引脚中断检测 A 边沿,再读 B 判断方向 | 响应快,CPU 占用低 | 机械抖动会触发大量中断,MicroPython 中断开销又高,还是得加软件定时器复查 |
| 主循环阻塞式延时消抖 | 检测到变化后用sleep_ms等几毫秒再读 | 代码最简单 | 阻塞主流程,其他任务全部卡住,快速旋转时还容易漏事件 |
| 定时器周期采样 | 定时器每个几毫秒读一次 A/B,配合状态机累计 | 非阻塞,逻辑统一,代码可移植 | 需要对采样周期和稳定性判定做权衡 |
这里我要强调一句:硬件 RC 滤波不是不能用,我之前有一个项目也试过在编码器模块上加 104 电容,效果确实明显,但要跟具体的旋转速度匹配。你既然已经在用树莓派 Pico 做控制,加几个元件不如加几行软件消抖代码来得灵活。真正需要极高速旋转或者工业级可靠性时,再考虑硬件方案也不迟。
2.2 定时器采样消抖的核心思想
定时器采样消抖,说穿了就一句话:让采样频率快过正常转动,但慢过抖动本身。
机械抖动的时间尺度是微秒到毫秒级,正常人手拧编码器的速度却快不到哪去。假设你一秒钟能转 10 个档位,一个完整档位的状态序列有 4 个状态变化,也就是一秒最多 40 次状态变化。而定时器如果每 1ms 采样一次,一秒就是 1000 次采样,完全能跟上人手的速度。
反过来看,抖动持续时间也就是 1~2ms,如果采样周期本身就是 1~2ms,那么很多抖动脉冲要么被采不到,要么只能采到一个孤立的非法状态,根本来不及累计成有效转动。为了更保险,我还会在采样之后加一个“连续 N 次读到相同状态才算稳定”的判定,这等于把滤波窗口又放宽了一点点,效果上非常接近硬件滤波。
2.3 采样周期怎么定:一个估算例子
采样周期和稳定判定次数是这套方案的两个核心旋钮,具体选多少,我做过一个很简单的估算。
拿最常见的 EC11 举例:触点抖动最长按 2ms 算。我选period = 1ms,STABLE_N = 2,那么一个状态只有连续两次采样都相同才会被采纳,等效滤波窗口大约是 2ms,刚好能压住绝大多数抖动。这时候手动快速旋转,每个状态停留时间通常至少有 5~10ms,两个采样周期远远够用,不会丢步。
如果你发现自己的编码器触点老化比较严重,抖动时间偏长,可以把STABLE_N加到 3 甚至 4,缺点是快速旋转时可能跟不上,因为每个状态要多花几个采样周期才能确认。这个平衡没有标准答案,属于那种“你实测一次就懂”的参数。
3. 硬件接线与 MicroPython 环境准备
3.1 物料清单与引脚接线
这套方案用到的硬件很少:
- 树莓派 Pico 一块(MicroPython 官方固件)
- EC11 旋转编码器一个,裸的或者带模块的都行
- 杜邦线若干
- 如果裸编码器,可选的 10kΩ 电阻两个
接线方式如下表:
| EC11 引脚 | Pico GPIO | 说明 |
|---|---|---|
| A(CLK) | GP0 | 输入,启用内部上拉 |
| B(DT) | GP1 | 输入,启用内部上拉 |
| C(公共端) | GND | 公共端接地 |
| 按键两脚(可选) | GP2 + GND | 如果用编码器自带按键功能再接线 |
这里有一个特别容易踩的坑:EC11 的公共端一般是接 GND,不是接 3V3。你把 C 接到 GND,然后 A/B 靠 Pico 的内部上拉保持高电平,转起来才有正常的高低逻辑。如果接反了,整个逻辑电平会反过来,方向判断大概率也是反的。
3.2 固件准备与定时器 API 说明
Pico 刷好官方 MicroPython 固件后,machine模块里就带Timer,不需要额外安装任何库。默认用法是:
from machine import Timer def on_timer(t): # 定时器回调 pass timer = Timer(mode=Timer.PERIODIC, period=1, callback=on_timer)period的单位是毫秒,mode=Timer.PERIODIC表示周期模式,回调会被周期性地调用。如果之后想停掉定时器,直接timer.deinit()就行。
需要特别提醒:定时器回调是在中断上下文里执行的,不要在回调里做print、创建对象、跑耗时的业务逻辑。正确做法是回调里只更新几个全局变量,主循环再去消费这些变量。
3.3 一个最基础的定时器采样 demo
先看一个最简版本的定时器采样,逻辑很简单:每 1ms 读一次 A/B 状态,输出当前状态值。这个 demo 不是最终方案,但它能帮你验证接线和状态序列。
from machine import Pin, Timer a = Pin(0, Pin.IN, Pin.PULL_UP) b = Pin(1, Pin.IN, Pin.PULL_UP) def sample(t): state = (a.value() << 1) | b.value() print(state) # 演示用,实际项目别在回调里 print timer = Timer(mode=Timer.PERIODIC, period=1, callback=sample) while True: pass手动慢慢转动编码器,串口输出会像 0、2、3、1、0、2、3、1 这样循环变化。如果你的序列是 0、1、3、2、0,也不要紧张,方向反了而已,把 A/B 对调或者后面代码里把计数取反就行。
4. 带稳定判定的完整消抖实现
4.1 状态机设计思路
基础采样能看状态,但直接把状态变化当成计数还是不行,因为抖动产生的瞬时状态也会被采到。我的做法是分成两层:
- 第一层是“稳定判定”:连续
STABLE_N次采样,读到的状态都一样,才认为这是一个真实有效的新状态。之前读到的不同状态一律当作抖动丢弃。 - 第二层是“方向判定”:用一张查表,根据“上一个稳定状态”和“当前新状态”判断这一步是正转、反转还是非法跳变。非法跳变返回 0,不计入计数器。
完整的编码器转动周期包含 4 个状态变化,所以原始计数器position每转一档会累计 4。如果你只想按“档位”计数,就用position // 4。当然,原始值也有用,比如你想做更细的分辨率,或者做一个精密的测速工具,保留 4 倍数值反而更灵活。
4.2 完整代码与逐行解析
直接上代码,这个版本我在 Pico 上实测跑过,作为菜单旋钮非常稳。
from machine import Pin, Timer import time A_PIN = 0 B_PIN = 1 SAMPLE_PERIOD_MS = 1 # 采样周期 STABLE_N = 2 # 连续 N 次相同才认为状态稳定 pin_a = Pin(A_PIN, Pin.IN, Pin.PULL_UP) pin_b = Pin(B_PIN, Pin.IN, Pin.PULL_UP) # 状态查表:dir_table[上一个稳定状态][当前状态] # 状态编码 = (A << 1) | B, 即 0=00, 1=01, 2=10, 3=11 # 返回 1 表示正向一步,-1 表示反向一步,0 表示无变化或非法跳转 dir_table = [ [0, -1, 1, 0], # prev=00: cur=01 -> -1, cur=10 -> 1 [1, 0, 0, -1], # prev=01: cur=00 -> 1, cur=11 -> -1 [-1, 0, 0, 1], # prev=10: cur=00 -> -1, cur=11 -> 1 [0, 1, -1, 0], # prev=11: cur=01 -> 1, cur=10 -> -1 ] position = 0 last_stable = (pin_a.value() << 1) | pin_b.value() last_raw = last_stable raw_count = 0 def encoder_tick(t): global last_stable, last_raw, raw_count, position cur = (pin_a.value() << 1) | pin_b.value() if cur != last_raw: # 状态发生变化,重新累计 last_raw = cur raw_count = 1 return raw_count += 1 if raw_count >= STABLE_N and cur != last_stable: position += dir_table[last_stable][cur] last_stable = cur raw_count = 0 timer = Timer(mode=Timer.PERIODIC, period=SAMPLE_PERIOD_MS, callback=encoder_tick) while True: clicks = position // 4 print("pos:", position, "click:", clicks) time.sleep_ms(100)逐行看一下几个关键部分。
dir_table是方向判断的核心。它把上一个稳定状态和当前状态映射成一个偏移值。因为编码器的合法状态变化每次只改变一个位,所以只有相邻合法状态才会返回 1 或者 -1,非法跳返回 0。这样采样到抖动导致的非法状态时,计数器完全不受影响。
encoder_tick是定时器回调,整体非常轻,只做三件事:读取状态、累计稳定性、查表更新计数。我特意不在回调里做任何输出,print全部放主循环。
主循环每 100ms 打印一次。实际项目里,你完全可以把这个打印换成 OLED 显示、菜单切换、PWM 调整等任何业务逻辑,因为定时器采样本身不阻塞主循环。
4.3 参数调整:period 与 STABLE_N 怎么配合
参数这块我再展开说一下,因为我见过很多人把STABLE_N设得很大,结果拧快了疯狂丢步,回头骂代码有问题。
SAMPLE_PERIOD_MS = 1是采样节奏。太快了没必要,太慢了跟不上手速。STABLE_N = 2是最小稳定判断,滤波效果已经不错。STABLE_N = 3或4适合触点老化严重、抖动时间长的场景,但每个状态要多确认几个采样周期,快速旋转就更容易丢步。
实际调试建议是:先用STABLE_N = 2跑起来,手动快速拧几圈,观察pos是不是严格按 4 的倍数增长。如果偶尔出现非 4 倍数的残余跳动,再往上加STABLE_N,直到稳定为止。如果快速旋转出现丢步,就反过来降周期或降STABLE_N。
5. 踩坑实录:常见问题与排查方法
5.1 问题速查表
把我踩过的和跟朋友排查过的问题整理成一张表,遇到现象直接对着查:
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 计数器乱跳,停下来也变 | 采样太快没滤掉抖动 / 没接上拉 | 增大 STABLE_N 或 period;确认 C 接 GND、A/B 有上拉 |
| 方向反了 | A/B 接反,或者安装角度相反 | 对调 A/B 两线,最省事 |
| 快速旋转丢步 | 采样太慢,或 STABLE_N 太大 | period 调到 1ms,STABLE_N 降回 2 |
| 一档跳 4 个数,吓一跳 | 正常,因为按状态变化累计 | 用 clicks = position // 4 |
| 程序卡死/运行慢 | 回调里 print 或耗时操作 | 回调只更新变量,输出放主循环 |
| 转久了数值开始乱 | 触点氧化或接触不良 | 清理编码器触点或更换 |
5.2 调试技巧:先看状态序列再谈计数器
新手最容易犯的错,是上来就调计数器,却不看原始状态序列。我自己的调试顺序非常固定:先把 STABLE_N 临时设成 1,主循环里打印last_stable和cur,然后手动慢慢拧一格,观察状态序列是不是正常的 0→2→3→1→0 这种循环。
如果看到状态序列里频繁出现类似 0→3、1→2 这样的非法跳变,说明抖动确实很厉害,或者接线有虚接。这时候靠加大 STABLE_N 只能缓解,真正的解决方向是检查接线和上拉。如果没有逻辑分析仪,用这个笨办法一样能排查得很准。
5.3 方向反了和丢步是我遇到最多的两个问题
方向反了的处理最简单:把 A/B 接线对调,或者把position累计结果取负。我不建议去硬改查表,因为改了表以后逻辑会变得很难读,而且不同型号编码器的“正转”定义也不一样。
丢步的问题麻烦一点。有一次我在一个菜单项目里把 STABLE_N 调到了 3,结果用户快速连拧好几格时,计数器明显少算。原因是每个状态在快速旋转时停留时间本来就只有几毫秒,连续 3 次 1ms 采样都相同其实是个挺严苛的要求,稍微快一点就满足不了。后来把 STABLE_N 降回 2,问题立刻消失。所以 STABLE_N 不是越大越好,它是稳定性和响应速度之间的一个折中。
6. 扩展应用:用编码器数值控制舵机角度
6.1 如何把计数器映射成 PWM 占空比
消抖方案跑通了之后,最常见的需求就是把编码器数值用起来。拿控制舵机举例:把clicks映射成舵机角度,范围限制在 0 到 180 度之间。
SG90 这类模拟舵机的控制信号是 50Hz 的 PWM,脉宽大概在 0.5ms 到 2.5ms 之间。Pico 上用machine.PWM设定 50Hz,然后通过duty_u16控制占空比。65535 对应 20ms 的整个周期,所以:
- 0 度脉宽 0.5ms,占空比约为 0.5/20 = 2.5%,duty 约 1638
- 180 度脉宽 2.5ms,占空比约为 12.5%,duty 约 8192
6.2 完整示例代码
from machine import Pin, PWM servo = PWM(Pin(15)) servo.freq(50) def set_angle(angle): angle = max(0, min(180, angle)) # 限幅,防止舵机打死 pulse_ms = 0.5 + (angle / 180.0) * 2.0 # 0.5ms ~ 2.5ms duty = int(pulse_ms / 20.0 * 65535) servo.duty_u16(duty) while True: set_angle(clicks % 181) # clicks 来自编码器主循环的计算结果 time.sleep_ms(20)注意这里有个坑:舵机瞬间启动电流比较大,最好是单独给舵机供电,不要直接从 Pico 的 3V3 引脚拉大电流,不然编码器采样都可能被电源纹波干扰,到时候又会出现莫名其妙的乱跳。
6.3 移植到其他平台时要注意什么
这个思路不止 Pico 能用。MicroPython 在 ESP32、RP2040 上的Timer接口非常接近,把引脚定义改一改,逻辑直接搬。如果用 STM32 裸机或者 HAL 库,就把encoder_tick里的代码塞进一个定时器中断服务函数,注意中断里同样不能做耗时操作,其他流程基本一致。
移植的时候容易忽略一个点:不同开发板的采样周期能力不一样,有的板子定时器中断抖动比 Pico 明显,建议先用最简版采样 demo 验证状态序列稳定,再上完整消抖逻辑。
7. 最后说点我的实操心得
最后分享几个我在实际项目里总结的小习惯,算是给这套方案收个尾。
第一个习惯是编码器接线尽量短,尤其旁边有电机驱动板或者继电器的时候,长线很容易把干扰引进来,到时候软件消抖做得再好,数值照样跳。第二个是 EC11 用久了触点会氧化,如果你发现以前很稳定的代码突然开始乱跳,先别急着怀疑程序,换个新编码器试试往往就好了。第三个是产品里做菜单旋钮时,我会故意给显示界面加一点“迟滞”,比如数值变化后短时间内不刷新过于频繁,这样看起来手感更稳,用户也更容易接受。
旋转编码器消抖这件事,本质上不是“用最好的方案”,而是“用最合适的方案”。定时器采样这套组合在我看来是 Pico + MicroPython 场景下投入产出比最高的选择:不占额外硬件,不阻塞主循环,逻辑清晰,出了问题也容易排查。希望这份记录能让你少踩几个我踩过的坑。