行业资讯
📅 2026/9/6 7:19:48
树莓派Pico定时器消抖:旋转编码器稳定读数方案
用树莓派 Pico 读旋转编码器很多人第一反应是“两根 GPIO 的事”但真正把旋钮焊上去跑起来才发现事情远没有这么简单。机械旋转编码器在转动的瞬间A、B 两相会产生一大串不干净的边沿——不是稳定的 0 和 1而是持续几十微秒甚至几毫秒的来回抖动。直接拿外部中断去读一次旋转可能触发几十次中断计数结果忽上忽下拿主循环轮询去读碰到循环里有耗时任务时又容易丢脉冲。这篇文章把我最近在 Pico 上用 MicroPython 定时器做旋转编码器消抖的完整方案整理出来包括为什么选定时器、状态机怎么设计、完整代码怎么写以及实测中踩过的几个坑。整个方案只占用一个硬件定时器和两个 GPIO非常适合做菜单旋钮、音量调节、3D 打印机手轮这类场景工程量不大但能省下非常多调抖动的时间。1. 旋转编码器的抖动问题看着在读电平其实在读一团噪声1.1 机械编码器的真实输出波形先明确一个概念我们平时用的带定位感的旋转编码器比如常见的 EC11内部就是两组机械触点旋转时触点依次接通和断开产生正交的方波信号。理想波形是干净的 0/1 跳变但实际用示波器看每次触点接触的瞬间都会有大量的回弹和毛刺——这就是抖动。具体表现是在 A 相或 B 相本该只跳变一次的位置实际会出现连续多次快速翻转。这些翻转发生在毫秒级甚至更短的时间内单个脉冲宽度可能只有几十微秒。如果你的单片机直接响应这些边沿就会把一次机械旋转误判成几十次旋转。抖动不是靠换一个好一点的编码器就能彻底避免的。即使是全新正品 EC11冷启动第一次旋转时的抖动也明显比连续旋转时更严重。这是因为触点表面氧化、接触压力变化都会影响回弹特性同一颗编码器不同位置的抖动时长都不一样。1.2 抖动在什么时候真的会搞出事故抖动带来的问题不只是“计数不准”在某些应用里它是会直接变成事故的。最常见的场景是 3D 打印机的手轮调机。操作人员快速旋转编码器来移动喷头如果抖动被当成有效旋转喷头会突然多走一段距离轻则打印件报废重则撞到热床。另一个典型场景是音频设备的音量旋钮转动音量时如果伴随抖动输出音量会来回跳变甚至出现“拧一格跳三格”的体感问题。还有一类容易被忽略的问题编码器转动过程中如果检测程序引入了方向判断错误一次正向旋转里夹着几个反向计数最终表现为“明明往右转数值却先增后减”。这种问题比单纯的计数偏多更隐蔽因为最后的总数可能看起来差不多但中间过程完全不对对依赖方向连续性的控制算法影响很大。所以消抖不能只是“延迟一下再读”这么简单还要保证方向判断的稳定性。这也是我最终采用定时器 状态机方案的根本原因。2. 主流消抖方案对比为什么定时器方案更省心2.1 硬件 RC 滤波好用但有条件最经典的消抖方式是硬件 RC 低通滤波加施密特触发器。A、B 两相各接一个电阻和电容把高频抖动滤掉让信号变成缓变的模拟电平再用施密特触发器整形成干净的数字信号。这个方案在不带单片机的纯逻辑电路里非常好用响应快、无软件开销。但是在 Pico 上用这个方案有几个实际问题。首先RC 滤波器会引入延迟RC 时间常数越大消抖效果越好但信号延迟也越大。高频旋转时窄脉冲经过 RC 滤波后幅度可能衰减到无法触发整形电路导致丢脉冲。其次Pico 这类开发板上通常没有预留滤波电路的位置焊飞线加电阻电容很麻烦。第三MicroPython 开发者的普遍诉求是“改个代码就能调”硬件方案每次调消抖窗口都要换电阻电容调试效率太低。硬件滤波不是不行只是它更适合做产品定型后的方案不适合做原型验证和快速迭代。软件消抖在这个场景下是更务实的选择。2.2 中断 延时确认灵活但回调压力大很多人一开始会写这样的逻辑外部中断检测到边沿后进入回调先延时几毫秒再读取当前电平确认是否真的是有效跳变。这就是常说的“延时确认消抖”在普通按键上非常好用。但旋转编码器比按键麻烦得多。第一编码器一次旋转会产生多个脉冲每个脉冲都有抖动如果都进中断回调并延时主程序会被阻塞得比较严重。第二MicroPython 的中断回调本身运行在底层上下文里回调中执行 time.sleep 会阻塞其他中断和底层调度快一点旋转时很可能直接卡死。第三中断回调里不适合做复杂处理一旦在回调里加方向判断、状态机更新代码就会变得脆弱。Pico 的 MicroPython 环境里GPIO 中断回调是细粒度的但因为 Python 解释执行回调本身并不算快频繁触发时开销非常高。实测中快速旋转编码器时中断频率能轻松超过每秒几千次这种情况下回调里的任何多余操作都会被放大。2.3 定时器扫描把消抖变成“投票确认”定时器方案的做法完全不同不用中断去抓每个边沿而是用一个固定周期的定时器不断采样 A、B 两相的状态然后用状态机和“连续 N 次同态确认”来判定有效的状态变化。这个思路的本质是让抖动在时间轴上“摊开”而我们只相信持续了一段时间的信号。你可以把它理解成投票——一个状态只有连续拿到 N 票才被认为是真实的单次或间歇的抖动票一律作废。定时器方案有几个天然优势。一是主循环完全不被阻塞编码器检测在后台自动进行适合需要同时处理屏幕刷新、网络请求、舵机控制的应用。二是逻辑确定性高只要扫描周期和确认阈值选得合适不管抖动多快只要它在确认窗口内不稳定就会被过滤。三是代码结构统一消抖和方向判断都在同一个回调里完成不需要协调中断和主程序之间的状态同步。所以我的选择很明确Pico MicroPython 的平台特性决定了定时器扫描是综合成本最低的方案。3. Pico 上的定时器消抖实现状态机 投票确认3.1 正交状态表是怎么来的旋转编码器的 A、B 两相在旋转时是正交信号也就是说两者有 90 度相位差。任意时刻 A、B 组合起来有四种状态我按 (CLK, DT) 编码0b00 00b01 10b11 30b10 2正转时状态按相邻关系循环变化例如 3 - 2 - 0 - 1 - 3反转时顺序反过来。关键规律是合法的旋转一定发生在相邻状态之间从 00 直接跳到 11 这种跳变要么是抖动要么是一次采样周期内漏掉了中间状态。要判断方向最直接的实现就是用查表法。有一个经典的 4x4 方向表行是旧状态列是新状态值表示这次状态变化对应的方向增量。旧状态 \ 新状态0001111000010-101-1010110-1011010-10表中为 0 的情况有两种状态没变对角线的 0或者发生了非法跳变从 00 到 11、从 01 到 10 这类。非法跳变在消抖逻辑里直接忽略不做计数这样即使抖动残留也不会造成方向反转。如果你的接线方向和我的假设相反导致正转显示负数不需要改方向表直接把 CLK 和 DT 两个引脚对调一下就解决了。3.2 连续 N 次同态确认消抖的核心逻辑定时器扫描消抖的核心不是“采样”而是“确认”。我的实现里维护两个关键变量_state当前已确认的稳定状态。_candidate当前正在投票中的候选状态。_consecutive候选状态连续出现的次数。每次定时器触发时读一次 AB 状态然后执行投票规则如果本次采样等于已确认状态_state说明信号仍然稳定清空候选状态和连续计数。如果本次采样不等于_state则看它是否等于上一次的候选状态_candidate。如果相同说明该候选状态连续出现了计数加一如果不同说明刚才的候选只是抖动更新候选状态并从 1 开始重新计数。当_consecutive达到设定的阈值debounce_count说明新状态已经连续稳定出现了足够多次这时才把它确认为新的_state并通过正交状态表计算方向增量。这个逻辑的关键在于每次采样都要比较“本次采样”和“上一次采样是不是同一个候选状态”。很多定时器消抖代码只统计“非稳定状态的累计次数”结果快速抖动时不同抖动状态轮流出现也会凑够次数导致误判。只有要求同一个候选状态连续出现才能真正过滤抖动。3.3 完整代码与接线说明接线方面EC11 这类编码器通常有五个引脚两个是定位弹片接地另外三个是 A、B、公共端 C。公共端 C 接 Pico 的 GNDA 和 B 分别接两个 GPIO同时开启内部上拉。接好后旋转时引脚会在 0 和 1 之间变化Pico 内部上拉配合编码器触点完全够用。下面是完整的类实现可以直接保存成rotary.py在main.py里引用from machine import Pin, Timer class RotaryEncoder: def __init__(self, clk_pin, dt_pin, scan_ms1, debounce_count3): self.clk Pin(clk_pin, Pin.IN, Pin.PULL_UP) self.dt Pin(dt_pin, Pin.IN, Pin.PULL_UP) self.scan_ms scan_ms self.debounce_count debounce_count # 正交解码表: table[旧状态][新状态] # 方向由接线决定如果反了就把 CLK/DT 对调 self.table [ [0, 1, 0, -1], # 旧状态 00 [-1, 0, 1, 0], # 旧状态 01 [0, -1, 0, 1], # 旧状态 11 [1, 0, -1, 0], # 旧状态 10 ] self._state self._read_state() self._candidate -1 self._consecutive 0 self.callback None self.position 0 self._timer Timer() self._timer.init(periodself.scan_ms, modeTimer.PERIODIC, callbackself._scan) def _read_state(self): return (self.clk.value() 1) | self.dt.value() def _scan(self, timer): s self._read_state() if s self._state: self._candidate -1 self._consecutive 0 return if s self._candidate: self._consecutive 1 else: self._candidate s self._consecutive 1 if self._consecutive self.debounce_count: delta self.table[self._state][s] if delta ! 0: self.position delta if self.callback: self.callback(delta, self.position) self._state s self._candidate -1 self._consecutive 0使用方式非常简单from rotary import RotaryEncoder enc RotaryEncoder(clk_pin10, dt_pin11, scan_ms1, debounce_count3) def on_rotate(delta, pos): print(delta:, delta, position:, pos) enc.callback on_rotate while True: pass顺序要特别注意先定义callback再开始旋转否则初始化阶段的多次采样可能会在回调为空时安全跳过。把while True: pass放最后让定时器在后台持续工作主循环就可以做别的事了。4. 参数调试与实测中踩过的坑4.1 scan_ms 和 debounce_count 怎么配这个方案里两个核心参数是采样周期scan_ms和确认次数debounce_count。它们的乘积基本决定了消抖窗口时间。比如scan_ms1、debounce_count3意味着新状态必须连续稳定 3 毫秒才会被确认。这个窗口不是越大越好。窗口太大快速旋转时一个状态还没确认完就已经旋转到下一个状态导致丢脉冲窗口太小又过滤不掉持续时间较长的抖动。我实测的结论是对于普通 EC11 编码器配合 1ms 采样周期debounce_count取 2 到 5 之间都比较安全。如果应用场景是音频音量旋钮可以稍微激进一点debounce_count2响应更跟手。如果是 3D 打印机这种对方向稳定性要求极高的场景建议取 3 到 4宁可牺牲一点点响应速度也要保证不误判。对于廉价的、手感比较松的编码器抖动会更久可以把debounce_count拉到 5。4.2 高速旋转丢脉冲的边界问题定时器扫描方案有一个物理性的短板采样周期决定了它能检测到的最高旋转速度。如果两个相邻状态变化发生在一次采样周期内定时器就可能“跳过”中间状态直接看到状态跳变。我的状态表对非法跳变做了忽略处理所以快速旋转时会表现为偶尔丢计数但不会方向错乱。这要看你应用对计数的要求。对于菜单旋钮这种场景丢一帧计数根本不明显对于需要精确测量角度的场景就要考虑提高采样频率。把scan_ms调到 0.5ms 或 0.2ms 在 Pico 上是可行的MicroPython 的硬件定时器能稳定支持亚毫秒周期。但要注意采样周期越短抖动越容易被“看见”反而需要更大的debounce_count来平衡。我建议先从scan_ms1起步根据实际旋转手感微调。4.3 MicroPython 定时器回调的几个隐藏限制我在调试时踩过一个很隐蔽的坑在定时器回调里调用print会严重影响时序。MicroPython 的print走 USB 串口输出时一条输出可能耗时数毫秒如果每次状态变化都打印定时器回调会严重超时后续扫描全部乱掉。解决方法是回调里绝对不打印只做状态更新。需要观察计数变化时应该在主循环里读取enc.position再打印。类似的回调里也不要执行文件写入、网络请求、内存分配这样的耗时操作它们会导致定时器周期漂移甚至回调重叠。另外Pico 的machine.Timer底层对应硬件定时器资源数量有限。一个项目如果同时还要 PWM 输出、其他定时任务要注意别创建太多 Timer 实例。我这个方案只用一个定时器资源占用很低也建议你把所有周期性任务尽量合并到一个定时器里而不是为每个功能单独建定时器。还有一个细节Pico 内部上拉的阻值偏高如果编码器到 Pico 的连线比较长或者旁边有电机、电源模块等强干扰源建议在 A、B 引脚外接 10kΩ 上拉电阻到 3.3V能明显提升信号稳定性。我在一个环境嘈杂的 CNC 控制盒里遇到过偶发乱跳补上外部上拉后问题消失。5. 把消抖方案变成能直接用的模块5.1 事件回调让编码器自己上报转动前面代码里的callback参数就是事件通知机制。每次状态被确认为有效变化时会自动调用回调函数并传入两个参数本次变化的方向增量以及当前的累计位置值。有了回调主程序就不需要反复轮询编码器状态了而是被动接收转动事件。这种模式特别适合做菜单系统——转动编码器时界面马上响应不需要主循环去“惦记”编码器有没有动。回调函数的执行仍然发生在定时器中断上下文里所以回调本身也要保持轻量。如果要在回调里更新 OLED 显示建议回调只修改一个标志位或缓存数据真正的 I2C 写入放到主循环里做否则 I2C 通信的耗时可能拖垮定时器。5.2 简单应用编码器控制舵机角度把编码器和 PWM 结合起来就是一个非常典型的手轮控制场景。我把它接在舵机上做了测试转动编码器实时调整舵机角度手感很直接。from machine import Pin, PWM from rotary import RotaryEncoder enc RotaryEncoder(clk_pin10, dt_pin11, scan_ms1, debounce_count3) servo PWM(Pin(15)) servo.freq(50) # 舵机角度对应的占空比范围0~180度 MIN_DUTY 1638 # 约 0.5ms 脉宽 MAX_DUTY 8192 # 约 2.5ms 脉宽 angle 90 def set_servo(angle_deg): angle_deg max(0, min(180, angle_deg)) duty int(MIN_DUTY (MAX_DUTY - MIN_DUTY) * angle_deg / 180) servo.duty_u16(duty) def on_rotate(delta, pos): global angle angle delta set_servo(angle) enc.callback on_rotate set_servo(angle) while True: pass这段代码里每次转动都在回调里直接更新 PWM 占空比PWM 的写入操作非常快不会影响定时器节奏。如果有按键引脚也可以在编码器的 SW 按键上接一个 GPIO用于“按下确认”之类的菜单交互。5.3 进一步优化方向如果后续需要更复杂的处理可以考虑几个方向。第一把position限制在指定范围内实现“音量 0 到 100”这类带上下限的计数逻辑防止数值溢出或跑到负值。第二增加旋转速度检测根据相邻两次确认之间的时间间隔估算转动速度用于快速滚动列表时加速翻页。第三把多圈累积变成相对增量模式每次读取后自动清零适合“转动一次触发一次动作”的场合。还有一个我自己常用的调优技巧把确认后的状态变化累计起来再做一次“小窗口方向平滑”。比如一次旋转动作中如果反方向增量小于某个小阈值就忽略掉专门用来对付那种手感很涩、会在定位点前后轻微回弹的编码器。旋转编码器消抖这件事本质上就是和机械触点的“不可靠性”做斗争。定时器方案的优点在于把不可靠的信号交给时间和投票规则去过滤代码逻辑一旦想清楚几乎不用再为各种千奇百怪的抖动波形头疼。我用这套方案在 Pico 上跑了好几个项目从菜单旋钮到手轮控制都没再出过抖动相关的故障希望你也能直接用起来。