1. 先搞清楚旋转编码器的“脾气”:为什么必须消抖
旋转编码器在小成本交互设备里几乎是无处不在的元件,尤其是EC11这种机械式增量编码器,音量旋钮、3D打印机调参旋钮、示波器旋钮、各种仪表参数调节都靠它。它内部其实就是两个触点开关,转轴每转一格,A相和B相会依次接通和断开,输出两路相位相差90°的方波信号。通过判断A、B两相的相位先后关系,就能知道你是正转还是反转,配合计数,就能把“转了多少格”变成可用的数字。
听起来很简单,但真正用过的人都知道,这玩意儿输出信号远没有理想波形那么干净。
机械式编码器的触点接触方式,决定了它天生就会抖动。转轴转过的每一格,金属簧片与基板上的导电区域接触时,不会一次就稳定贴合,而是会在接触和断开之间来回弹跳几次。这个弹跳过程通常持续几毫秒到十几毫秒,表现出来的就是方波边缘上密密麻麻的毛刺。如果你直接拿这些信号去做边沿中断或者电平判断,计数器会像抽风一样乱跳。明明只转了一格,读数可能加了3、减了1,方向也经常判断错。
有些刚接触的朋友会想,我能不能直接在主循环里不停地读引脚,靠程序跑得快来弥补?在MicroPython环境下这条路基本走不通。MicoroPython本身是解释执行的,一条语句的开销比C语言大得多。主循环里再夹杂显示刷新、逻辑处理、通信任务,轮询周期可能从几毫秒漂到几十毫秒,读到的信号状态严重滞后不说,还会把整个流程卡得死死的。这时候定时器就派上用场了:让定时器以固定周期去采样编码器引脚,抖动的毛刺在多次采样中被过滤掉,主循环则完全不被阻塞,该干嘛干嘛。
先说结论:这种方案的核心不是写一个多高明的算法,而是三个环节的配合——定时器周期采样、多次采样确认状态稳定、状态机判定旋转方向。这篇文章我会把这套方案从原理到代码完整拆开讲。
2. 消抖方案选型剖析:定时器轮询为什么优于其他方法
2.1 四种常见消抖方案的对比
在动手写代码之前,最好先搞清楚一个问题:旋转编码器消抖到底有哪几条路可以走?各有什么代价?
第一类是硬件方案,最典型的就是RC低通滤波。在编码器A、B引脚对地各接一个电容,再串一个电阻,构成低通滤波器,把高频抖动脉冲削掉。这个方案的效果直接、不需要写代码,但在实际项目里并不受欢迎。电容的选值很讲究:太小了滤不掉高频抖动,太大了信号边沿变缓,快速旋转时会出现丢步。而且每个编码器型号的抖动特性不一样,换一个旋钮就得重新调一遍RC参数,研发阶段特别耽误时间。另外,加了电容之后,如果后续还想用中断方式检测,边沿变缓会导致中断触发不稳定,需要再加施密特触发器整形,电路复杂度直接上去了。
第二类是纯软件延时消抖,这也是新手最常写的方案。检测到A相电平变化之后,立刻delay一小段时间,等抖动过去再读一次B相确定方向。问题是这个delay是阻塞的。在MicroPython里,time.sleep_ms(10)意味着整个CPU在这10毫秒内什么都不能干。如果你的项目里还有OLED刷新、LED呼吸灯、串口通信、舵机控制这些任务,一个延时就把所有任务卡了一下,旋钮转动越频繁,系统卡顿越明显,体验非常糟糕。
第三类是边沿中断加延时确认,也就是在A相上升沿或下降沿触发中断,进入中断回调后启动定时器,过几毫秒再去读取电平做二次确认。这个思路比阻塞延时好很多,中断里只做标记,实际确认交给定时器。但在MicroPython里也有麻烦:旋转编码器的边沿中断频率可以很高,快速旋转时每秒可能触发几十上百次中断,每一次中断回调在MicroPython解释器里都要消耗不少指令周期,对系统其他实时任务会带来不小的压力。
第四类就是这篇要讲的定时器周期采样方案。它把采样行为变成一个固定频率的“例行公事”,中断回调也只负责采样和状态更新,不阻塞任何流程。只要采样周期和确认策略设计合理,既能滤除抖动,又能保持主循环的流畅度。
2.2 定时器采样的时间基准选择
定时器采样方案能否成立,完全取决于“采样频率”和“抖动特性”之间的匹配关系。
要理解这里面的逻辑,得先看机械抖动的时域特征。EC11这类编码器,触点在每格转动后产生的抖动一般持续2到10毫秒。抖动期间的电平变化是随机的、高速的,但在抖动结束后,信号会稳定在真实的状态上,直到下一次转动到来。
如果我们用一个固定的定时器去反复读取A、B相电平,那么在同一段真实状态下,连续多次采样得到的电平应当一致。如果某一次读到的电平与上一次不同,有两种可能:一是真的发生了新的机械转动,二是正处于抖动毛刺中。怎么区分这两种情况?看新状态能否稳定维持一段时间。如果连续N次采样都保持同一个新状态,那就判定这次变化有效,去做方向判断和计数;如果中间又跳回去了,那说明刚才那次只是抖动,直接忽略。
这里N取多少很关键。N太小过滤不了抖动,N太大会丢掉快速转动时的有效边沿。我实测的折中做法是:采样周期2毫秒,连续3次采样结果一致才确认状态变化。这样对抖动的判定延迟大概是4到6毫秒,正好与大多数EC11的抖动持续时间相当。如果你手里的是质量比较差的编码器,抖动时间可能到15毫秒以上,那就要把连续确认次数调到4次,或者把采样周期拉长到3毫秒。
2.3 为什么状态机判定比单纯电平判断更可靠
定时器解决了“什么时候读”的问题,但读完信号之后怎么判断方向,还有讲究。
最简单的方向判断方式是:检测到A相变化时,读一下B相电平,B为高就是正转,B为低就是反转。这种方法在信号理想时没问题,但有一个隐患——它只追踪了A相的边沿,如果某次采样恰好没有捕获到A相的完整边沿,只捕获到了半个,方向判断就会出错。比如B相抖动产生的毛刺被误认为A相边沿,方向判断瞬间失效。
更可靠的办法是使用状态机。编码器旋转时,A、B两相的电平组合会依次经过00、01、11、10这四个状态(或者反过来,取决于旋转方向)。每一次有效旋转,状态都按照固定的顺序转换。我们只要记住上一次的合法状态,再结合当前的状态,查一下状态转换表,就能同时知道“是否发生了有效步进”和“旋转方向是什么”。这种方式的好处是,即使采样过程中漏掉了一个状态,只要后续状态能回到合法路径上,它不会把抖动毛刺误判为一次完整的步进。
3. 基于MicroPython定时器的完整实现:从头到尾手工搭建
3.1 硬件连接与基础环境
先交代一下硬件环境。我用的是树莓派Pico开发板,主控芯片RP2040,固件是官方MicroPython版本,编码器是某宝最常见的EC11,带一个内置按键。A相接GPIO10,B相接GPIO11,公共端C接GND。EC11的连接方式与普通按键类似,不需要外部上拉,因为Pico的GPIO内部可以启用上拉电阻。
接线的时候有个细节值得注意:A、B两相尽量选相邻的GPIO,比如GPIO10和GPIO11,这样在内部资源分配上更方便排查问题。另外,如果你手头有示波器,接上之后先转一下旋钮,看看A、B相实际波形长什么样,抖动大概是几毫秒,这比任何经验值都管用。没有示波器也没关系,用最简单的LED闪灯法也能估算:写一个循环统计一段时间内引脚电平变化的次数,对比手动慢速转一格时变化的次数,用来大致判断抖动严重程度。
3.2 核心代码框架:从定时器初始化到状态机
下面直接上代码。这段代码是我在实际项目中跑过的版本,已经去掉了应用层的业务逻辑,只保留编码器读取与消抖核心部分,方便你直接拿来做底层模块。
from machine import Pin, Timer import time # 引脚初始化 PIN_A = 10 PIN_B = 11 pin_a = Pin(PIN_A, Pin.IN, Pin.PULL_UP) pin_b = Pin(PIN_B, Pin.IN, Pin.PULL_UP) # 全局状态变量 last_state = -1 # 上一次有效的AB状态,初始化为非法值 confirmed_a = 0 # 上一次确认稳定的A电平 confirmed_b = 0 # 上一次确认稳定的B电平 candidate_a = 0 # 当前采样的A电平 candidate_b = 0 # 当前采样的B电平 sample_count = 0 # 连续相同采样计数 stable_change = False # 是否有新的稳定变化 direction = 0 # 1=正转, -1=反转, 0=无 counter = 0 # 累计步数 # 状态机转换表 # 索引 = (old_state << 2) | new_state # 值含义:0无效, 1正转, -1反转 transition_table = [ 0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0 ] def read_ab(): """读取AB相电平,A为高位,B为低位""" return (pin_a.value() << 1) | pin_b.value() def timer_callback(timer): global last_state, confirmed_a, confirmed_b global candidate_a, candidate_b, sample_count global stable_change, direction, counter # 1. 读取当前采样值 current_a = pin_a.value() current_b = pin_b.value() # 2. 连续采样确认:只有连续多次采样与候选值一致,才确认为稳定状态 if current_a == candidate_a and current_b == candidate_b: sample_count += 1 else: candidate_a = current_a candidate_b = current_b sample_count = 1 # 新候选值刚出现,先不确认为稳定变化 # 3. 如果连续多轮采样一致,且与上一次确认状态不同,判定为有效变化 if sample_count >= 3: if candidate_a != confirmed_a or candidate_b != confirmed_b: confirmed_a = candidate_a confirmed_b = candidate_b stable_change = True # 4. 状态机计算方向和步进 new_state = (confirmed_a << 1) | confirmed_b if last_state == -1: # 第一次读到的状态,无法判断方向,只记录状态 last_state = new_state else: idx = (last_state << 2) | new_state step = transition_table[idx] if step == 1: direction = 1 counter += 1 elif step == -1: direction = -1 counter -= 1 # 非法跳转说明有漏采样或信号异常,忽略,不计数 last_state = new_state # 配置定时器:2ms周期采样 timer = Timer() timer.init(period=2, mode=Timer.PERIODIC, callback=timer_callback) # 主循环:业务逻辑不卡顿 while True: if stable_change: stable_change = False print("counter:", counter, "direction:", direction) time.sleep_ms(10)3.3 代码逐段拆解:定时器回调里的“确认”逻辑
这段代码看起来不长,但每一段都值得展开讲一下,因为里面的坑非常集中。
定时器的初始化。machine.Timer()在Pico上有硬件定时器支持,timer.init(period=2, mode=Timer.PERIODIC, callback=timer_callback)表示每2毫秒触发一次回调。period参数的单位是毫秒。这里不需要额外开一个线程或者主循环里做延时,CPU的大部分时间都留给了主循环。
引脚内部上拉。Pin(PIN_A, Pin.IN, Pin.PULL_UP)把内部上拉电阻打开了。机械编码器的公共端接地,A、B相在断开时被上拉到高电平,接通时被拉到低电平。上拉的作用不仅是确定默认电平,还能消除引脚悬空带来的不稳定杂波。忘了开启上拉是很多人第一步就踩的坑——引脚悬空时读到的值完全随机,消抖算法写得再好也是白搭。
连续采样确认。这是整套消抖逻辑的核心部分。每当定时器触发一次,回调函数先读取A、B电平,与候选值比较。如果一致,sample_count加1;不一致,说明电平正在发生跳变,立刻更新候选值并把计数重置为1。只有当计数达到3次以上,且新状态与已确认状态不同时,才认为出现了一次稳定的状态切换。
这个策略的精妙之处在于它天然过滤了抖动毛刺。抖动时电平会在高低之间来回变化,每次变化都会重置sample_count,因此永远不会达到确认阈值。只有当信号真正稳定在新电平后,连续3次采样才会形成有效确认。这其实和按键消抖的“延时确认”思想同源,只不过通过定时器把“延时”变成了“多次采样”。
状态机的查表判定。我用了一个16元素的一维数组作为状态转移表。数组索引是(旧状态 << 2) | 新状态,旧状态和新状态都是AB组合值,范围是0到3。比如旧状态是01(二进制01),新状态是11(二进制11),索引就是(1 << 2) | 3等于7,查表得到-1,意味着这一步是反转。这张表的推导过程不复杂,把四种状态的两两组合全都遍历一遍,对照编码器的物理运动方向标出有效转移即可。使用查表法的最大好处是运行时不需要写一堆if-else嵌套,逻辑清晰而且不易出错。
3.4 参数选择的工程依据:为什么是2ms和3次
我刚拿到这套代码的时候,第一反应是“2ms采样会不会太快?”后来实测转向了另一个极端,“2ms采样会不会太慢导致丢步?”
先说采样周期。EC11最常见的规格是一圈20格,人手快速转动一圈大概在0.1到0.2秒,也就是每秒50到100格。每输出一格,A相会产生一个完整方波周期,也就是有两个边沿(上升沿和下降沿),相当于每格对应两次有效状态变化。按每秒100格的极限转速算,每秒需要识别200次状态变化,每次变化的平均间隔是5毫秒。采样周期2毫秒意味着每个状态变化周期里至少能采样2到3次,刚好够确认,不多不少。
再说确认次数。我把确认次数设为3,意味着从“第一次发现新电平”到“确认稳定”需要约4到6毫秒。这个延迟对旋转编码器场景非常友好:不会有可感知的滞后,同时又能充分过滤EC11最常见的几毫秒抖动。我试过把确认次数降到2,结果在部分廉价编码器上出现了明显乱跳;升到4之后,快速旋转时读数明显出现了滞后和不跟手。所以如果你用的是品牌编码器,可以试试2次确认提升响应速度;如果是杂牌,老老实实3次以上。
3.5 扩展需求:同时处理内置按键的消抖
EC11编码器大多带一个按键,按下时公共端与按键输出端接通。如果你不想再用一套独立的按键消抖代码,完全可以复用它同一个定时器回调,在定时器中断里顺带做按键采样。
PIN_KEY = 12 pin_key = Pin(PIN_KEY, Pin.IN, Pin.PULL_UP) # 在timer_callback里增加按键处理 key_candidate = 1 key_sample_count = 0 key_confirmed = 1 key_pressed = False def timer_callback(timer): global key_candidate, key_sample_count, key_confirmed, key_pressed # ... 原有的编码器逻辑 ... # 按键消抖:同样用连续采样确认 cur_key = pin_key.value() if cur_key == key_candidate: key_sample_count += 1 else: key_candidate = cur_key key_sample_count = 1 if key_sample_count >= 3 and key_candidate != key_confirmed: key_confirmed = key_candidate if key_confirmed == 0: key_pressed = True按键与编码器共用一个2ms定时器,互不干扰。主循环里只要轮询key_pressed标志位即可。这种写法比单独用time.sleep消抖高效得多。
4. 实测调参记录:这些坑我替大家踩过了
4.1 定时器回调里的“隐形杀手”:浮动计算与打印
很多人在写完定时器回调后,发现系统偶尔卡顿,甚至出现采样周期明显变长的问题。我排查后发现,最常见的原因是回调里做了不该做的耗时操作。
MicroPython的定时器回调本质上是硬件中断触发的,但回调函数本身跑在MicroPython解释器里。如果在回调里做了打印、浮点运算、字符串拼接、甚至访问I2C总线等操作,中断处理时间会远超2毫秒。更糟糕的是,如果回调执行时间超过了采样周期,下一次中断会被挂起,采样时间就会抖动。
我的建议是,回调函数只做三件事:读取GPIO、比较数值、更新标志位。任何需要输出的内容(打印、屏幕显示、通信)全部丢到主循环里处理。上面的代码里用stable_change和direction两个标志变量来传递信息,就是为了把耗时操作留在主循环。
还有一个容易忽视的点:全局变量访问。MicroPython在函数内使用global声明的变量,每次访问都会经过字典查找,比局部变量慢不少。如果回调逻辑较复杂,可以把频繁使用的状态值缓存为局部变量,最后再写回。上面的代码为了可读性没有做这个优化,但如果你的项目对时序要求很高,值得试试。
4.2 旋转过快丢步的真相:不只是采样率的问题
测试中遇到过一个很奇怪的现象:慢速旋转时计数完全准确,一旦快速连续旋转,计数器经常会少几个数。一开始我以为是采样率不够,把定时器周期从2ms降到1ms,情况有所改善但依然存在。
后来用示波器看了波形才明白,问题出在另一层——机械编码器在快速旋转时,触点接触时间本身会变短,有些情况下甚至不足以形成完整的方波脉冲。也就是说,信号源本身就已经“丢步”了,消抖算法再快也不可能凭空补回缺失的脉冲。
这种情况下的对策有两个方向。一是从机械层面改善:选购质量更好的编码器,或者检查转轴带动机构的同心度,减少快速旋转时的振动。二是从算法层面补偿:如果旋转方向持续不变,可以在检测到连续同向步进时,对时间间隔做线性预测,推测是否漏掉了中间状态。这个方案比较复杂,实际项目里除非必须保证高精度计数,否则性价比不高。
4.3 状态机非法跳转:信号异常时的自我保护
用状态机查表法有一个隐患:万一采样的状态跳转不在合法路径上,查表会得到0。这个0代表了什么?可能是信号毛刺太严重,也可能是定时器延迟导致漏掉了一个状态。
我的处理策略是:遇到非法跳转时,不计数,也不回退,直接更新last_state为当前状态。这样做的好处是让状态机尽快回到合法路径上,避免连续漏判。代价是偶尔一次的错误跳转不会被纠正——但从工程角度看,编码器读数本身就不需要绝对精确,偶尔丢一步或者多一步远好过计数器彻底跑飞。
在工业级应用里,还有一种更保守的做法:非法跳转时把last_state恢复为-1,强制重新校准。这适合那些要求当前状态必须完全可信的场景,代价是每次异常后要重新转动一格才能恢复计数。我建议嵌入式场景用前者,上位机或者精密仪器用后者。
4.4 接线长度与电气噪声的干扰
如果你的编码器通过较长导线连接到Pico,比如超过20厘米,就有必要考虑电气噪声的干扰了。导线本身像一根天线,电机启停、继电器动作都会在导线上感应出毛刺脉冲。
针对这个问题,我试过几种方案。软件上,把采样周期从2ms提高到1ms,并增加确认次数到4次,可以在不改变硬件的前提下获得更好的噪声抗性,代价是响应速度稍微变慢。硬件上,在Pico的A、B引脚对地各接一个0.1uF陶瓷电容,滤波效果立竿见影。不过要注意,加电容之后信号边沿会变缓,如果将来你想从定时器轮询改成边沿中断,这个电容可能带来新的麻烦。
4.5 主循环被卡住导致没反应:检查是不是忘了处理标志位
还有一个很隐蔽的问题:主循环里如果有一个长时间阻塞的操作,比如一个time.sleep_ms(500)或者一个等待用户输入的阻塞调用,定时器回调依然在正常执行,counter也在正常累加,但主循环来不及处理,你会感觉旋钮“没反应”。
这不是定时器方案的问题,而是应用层与中断层之间同步方式的问题。上面代码中的stable_change标志位只能记录“有没有发生变化”,如果主循环处理不及时,多次变化会被合并成一次。如果你要求每一次步进都能被应用层精确感知,需要改用队列或者累加型计数器——也就是说,回调里不仅更新counter,还更新一个pending_events计数,主循环每次读取后清零。我在实际项目中用后者,彻底解决了快速旋转时事件丢失的问题。
5. 从这套方案延伸出去:还能解决哪些问题
定时器采样+连续确认这套逻辑,本质上是一个通用的“信号稳定化”框架,远不止能用在旋转编码器上。
最常见的一个延伸使用是矩阵键盘的扫描消抖。以前我写矩阵键盘,扫描频率高的时候总是出现按键抖动导致的重复触发。后来把这套定时器采样思路搬过去,每个按键用连续3次采样确认,彻底解决了问题。
另一个很实用的场景是读取霍尔传感器或者光电编码器的脉冲信号。这一类传感器输出的是方波,虽然比机械编码器干净,但在电机启停瞬间往往会有毛刺。把这套定时器采样框架直接套用,比单纯依赖边沿中断更稳健。
如果你手头还有PWM舵机控制的需求,也可以参考这个架构。用一个定时器负责PWM输出,另一个定时器负责编码器采样,再加上主循环负责逻辑,整个系统的时间基准就完全统一了。这样比用time.sleep拼凑出来的项目要顺畅得多。
我在实际使用中最深的一个体会是:消抖不是简单地“等一等”,而是要让系统在一个合理的时间粒度下对信号做多次观测,只有当观测结果一致时才采信。时间粒度选得好,系统既不会因为抖动而乱跳,也不会因为反应太慢而显得迟钝。这个思路,用在编码器上是这样,用在按键上、用在各种传感器信号处理上,逻辑都是相通的。
最后再分享一个调试小技巧:开发阶段,建议把定时器回调里的确认次数和采样周期做成全局变量,方便在运行中动态修改。这样你可以不用重新烧录固件,就能在串口交互界面里调参数,快速找出最适合你手里那个编码器的配置。实测下来,这个方法比一遍遍改代码烧录调试省太多时间了。