定时器采样消抖:树莓派Pico旋转编码器稳定计数方案
2026/9/8 16:46:42 网站建设 项目流程

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 = 1msSTABLE_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 = 34适合触点老化严重、抖动时间长的场景,但每个状态要多确认几个采样周期,快速旋转就更容易丢步。

实际调试建议是:先用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_stablecur,然后手动慢慢拧一格,观察状态序列是不是正常的 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 场景下投入产出比最高的选择:不占额外硬件,不阻塞主循环,逻辑清晰,出了问题也容易排查。希望这份记录能让你少踩几个我踩过的坑。

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

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

立即咨询