ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

MicroPython定时器实现旋转编码器消抖:状态机与采样策略详解

2026/9/7 14:22:00 拓冰建站 浏览量
MicroPython定时器实现旋转编码器消抖:状态机与采样策略详解 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相接GPIO10B相接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(period2, modeTimer.PERIODIC, callbacktimer_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(period2, modeTimer.PERIODIC, callbacktimer_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拼凑出来的项目要顺畅得多。我在实际使用中最深的一个体会是消抖不是简单地“等一等”而是要让系统在一个合理的时间粒度下对信号做多次观测只有当观测结果一致时才采信。时间粒度选得好系统既不会因为抖动而乱跳也不会因为反应太慢而显得迟钝。这个思路用在编码器上是这样用在按键上、用在各种传感器信号处理上逻辑都是相通的。最后再分享一个调试小技巧开发阶段建议把定时器回调里的确认次数和采样周期做成全局变量方便在运行中动态修改。这样你可以不用重新烧录固件就能在串口交互界面里调参数快速找出最适合你手里那个编码器的配置。实测下来这个方法比一遍遍改代码烧录调试省太多时间了。