树莓派Pico旋转编码器消抖:定时器扫描与状态机实战
2026/9/6 9:59:29 网站建设 项目流程

1. 项目概述

1.1 为什么我一看到旋转编码器就先想消抖

旋转编码器这个东西,玩过树莓派 Pico、STM32、ESP32 的朋友应该都不陌生。不管是做音量旋钮、菜单选择、舵机微调,还是 3D 打印机的调平旋钮,它都能给你一种“物理世界和数字世界之间握着个旋钮”的手感。

但真正把它接到单片机上一测,你会发现一个特别尴尬的事实:不消抖的编码器,读数根本没法看

我最早拿 Pico 直接接 EC11 编码器,用轮询方式读 A、B 两相电平,结果旋一格有时候跳 3 个数,有时候反向还会先正向跳一下再跳回来,完全不是想象中“滴答一格”的清脆感。

这个问题的本质,不是编码器坏了,而是机械触点在旋转瞬间发生的物理抖动。触点闭合和断开不是一次到位的,而是高频弹跳几十到几百微秒。你没有做消抖处理的时候,单片机看到的不是干净的“01”跳变,而是一连串“0101010101”的毛刺。这不是某个特定品牌编码器的问题,而是所有机械式旋转编码器的通病。

也正因为如此,“消抖”就成了旋转编码器项目落地前的第一道必答题。这道题不解决,后面你接舵机也好、调参数也好、写菜单也好,全都会被误触发给搅乱。本来想着省去按键消抖的学习环节,结果旋转编码器的抖动比独立按键还让人头大。

1.2 这套方案到底解决什么问题

先说清楚,我在这篇博文里讲的消抖方案,是基于树莓派 Pico + MicroPython + 定时器这套组合实现的。它要解决的核心问题有四个:

  • 机械抖动导致的重读、漏读、跳数。这是最直观的问题,旋一格要能稳定识别成一格。
  • 主循环阻塞问题。很多初学教程里的消抖写法,是用time.sleep(0.005)死等抖动过去。这在只有编码器一个外设的时候勉强能用,但一旦你的主循环里还要刷 OLED、控制舵机 PWM、处理按键、跑 PID,这种死等基本等于把程序卡死。
  • 检测实时性。轮询间隔太长会漏掉快速旋转时的脉冲,间隔太短又会在主循环里占用过多 CPU。定时器扫一遍 A/B 相电平,比在主循环里轮询及时得多。
  • 资源开销要小。Pico 的 RP2040 虽然有双核 133MHz,但 MicroPython 毕竟是解释执行的,主频资源不能乱造。用定时器回调的方式,把检测逻辑从主循环里摘出去,是性价比最高的做法。

这套方案最后达到的效果是:代码量不大,不依赖任何外部硬件滤波电路,纯软件定时器扫描,实测下来旋一格稳定输出一个计数,正反转判断可靠,快速旋转也能跟手追得上。

1.3 适合谁看,需要什么基础

如果你属于下面这几类人,这篇博文对你应该有不少参考价值:

  • 刚入手树莓派 Pico,正在用 MicroPython 写外设驱动的入门玩家
  • 被旋转编码器抖动折磨,想找一个不阻塞主循环的消抖思路的嵌入式爱好者
  • 要在 Pico 上做带旋钮交互的完整项目(比如音量控制器、迷你调音台、菜单旋钮、怠速旋钮模拟器)的开发者
  • 之前用 Arduino 的rotary()库写过编码器,换到 MicroPython 之后发现没有现成库可用、想搞清楚原理的人

硬件基础方面,你只需要有最基础的电子常识:知道面包板怎么插、杜邦线怎么接、Pico 的 GP 引脚在哪即可。软件方面,只要会跑 MicroPython 的 hello world,看得懂while Trueif判断,那下面的代码就足够看得懂。

注意:这篇实现用的是软件消抖而非硬件 RC 滤波。并不是说硬件滤波不好,而是软件方案在成本、改版灵活度、参数可调性上都有优势,尤其适合快速原型验证。等项目成熟了、要量产了,再往 PCB 上加 RC 电路也不迟。

2. 编码器工作原理与抖动成因拆解

2.1 旋转编码器是靠什么“感觉”到你在转

在动手写代码之前,我强烈建议先把旋转编码器的工作原理弄明白。很多人写消抖代码时参数怎么调都不对,本质原因就是没搞懂编码器输出的到底是什么波形。

以最常见的机械增量式编码器 EC11 为例,它内部有两个触点开关,对应输出两个信号,我们一般称它们为A 相和 B 相。当你旋转旋钮的时候,这两个触点会按照旋转方向产生相位差 90 度的方波信号。也就是说:

  • 顺时针旋转时,A 相跳变领先B 相约 90 度。
  • 逆时针旋转时,B 相跳变领先A 相约 90 度。

单片机正是通过检测“谁先跳变”,来判断你是往哪边转的。而一格完整的转动(从某个定位档位转到下一个定位档位),A、B 两相各会产生一次完整的“高→低→高”跳变,也就是一个周期。

这里有个非常重要的细节:编码器并不是时刻都输出稳定的高或低电平。在没有任何外力旋转的时候,A、B 两相的引脚状态由你外接的上拉或下拉电阻决定——EC11 这类机械编码器通常内部两对触点分别接公共端(COMMON),公共端接 GND 时,A、B 需要外接上拉电阻,静态为高电平;公共端接 VCC 时,则需要下拉电阻。我们在 Pico 上通常用内部上拉,所以静态读到的是1, 1

当你转动旋钮、经过定位点时,会听到清脆的“咔哒”一声,这个“咔哒”对应的就是内部弹簧片在定位凹槽上弹跳滑动。而在弹跳的过程里,触点的闭合状态是不稳定的。

2.2 抖动到底长什么样

我直接用逻辑分析仪抓过 EC11 旋转一格时 A 相的实际波形,看完你就明白为什么简单的电平判断会出错。

理想情况下,A 相应该是一段干净的电平跳变:高→低→高,中间没有毛刺。但实际抓到的是这样:从高跳到低的过程中,不是一步到位,而是在高和低之间来回反弹好几次,持续几十微秒到一两百微秒,然后才稳定在低电平。松开、转下一格的时候,也会有类似的弹跳。

而 B 相的抖动和 A 相往往是不同步的。这就导致一个非常头疼的情况:单片机在主循环里读 A 相和 B 相的时候,可能刚好读到了 A 相已经稳定、B 相还在弹跳的状态,或者 A 相还在弹跳、B 相已经稳定的状态。于是程序误判方向、计数翻转、一次旋转被当成多次旋转,全都来了。

想起我之前用 STM32 做类似项目时,有人在论坛里讨论过“旋转编码器要不要硬件消抖”的问题,有工程师直接说“STM32 的定时器输入捕获配一个 10ms 的消抖时间,软件滤波就够了”。我当时将信将疑,直到自己在 Pico 上用 MicroPython 试过一轮才发现:这个说法在逻辑上是对的,但具体到代码怎么写,坑还不少。

2.3 标准消抖思路有哪些,我为什么放弃了两三种

聊到消抖方案,行业内常用的有大致几种,我筛掉了一部分,最后才定下定时器扫描,下面说说理由。

第一种,硬件 RC 滤波。在 A、B 两相上各加一个低通滤波器(典型参数 10kΩ 电阻 + 100nF 电容),把高频毛刺滤掉。这个方案在工业设备上很常见,效果很好,但它需要额外的电子元件、焊接或面包板接线,而且参数一旦定死,就不好改了。对快速原型验证来说,不够灵活。

第二种,纯延时消抖。检测到某相跳变后,立刻time.sleep_ms(5)time.sleep_ms(10),等抖动结束后再稳定读一次。代码只有三五行,特别简单。这个方案最大的问题我在开头说过:time.sleep_ms会阻塞整个程序,你的 OLED 不刷新了、舵机不动了、按键不响应了。项目只有一个旋钮的时候还好,一旦有多个任务并行,直接崩掉。

第三种,定时器中断 + 状态机。这是本博文推荐方案的核心。用 Pico 的硬件定时器,每隔一小段时间(比如 1ms~2ms)自动触发一次回调,在回调里读一次 A 相和 B 相电平。因为每次读取都隔开了抖动持续的时间窗口,弹跳毛刺会被天然滤除掉——你在一个稳定采样周期里读到的,是经过时间累积后趋于稳定的电平值。再加上状态机记录上一次的 A/B 组合和当前组合,就能准确判断方向、累加计数。

我在动手实现时,还有一个小插曲:一开始我也想过用 Pico 的PIO 状态机配合轮询去实现精准的编码器解码,RP2040 的 PIO 做正交解码是硬件级的,精度极高。但考虑到 MicroPython 环境下 PIO 的编程复杂度偏高,而且这个博文面向的读者大部分是刚入门 MicroPython 的人,用machine.Timer是更好的平衡——代码短、逻辑直观、性能足够。

实操心得:做这类信号处理,什么时候“够用就好”比“性能拉满”更重要。定时器扫描虽然不如 PIO 正交解码那样能扛住极高的转速,但人手指旋转编码器的速度,用 1ms 采样周期追绰绰有余。

3. 定时器扫描消抖方案的总体设计

3.1 为什么定时器扫描能“顺手”消掉抖动

你可能会有疑问:定时器扫描不过就是每隔 1ms 去读一次电平而已,它不会把抖动读进来吗?

答案是:它确实还可能读到抖动,但抖动概率被大幅降低了。原因有两层:

  • 机械抖动本质上是小时间尺度内的高频弹跳。触点在几十到上百微秒内来回变化。如果你的采样间隔是 1ms,那么很有可能会错过抖动持续期间内的那些快速翻转。换句话说,采样间隔天然起到了低通滤波的效果。
  • 你可以结合边沿变化做二次确认。第一次检测到 A 相或 B 相电平变化时,先记录这个变化,然后在下一个采样周期(1ms 后)再读一次。如果第二次读到的电平跟第一次记录的一致,说明已经不是瞬时的弹跳毛刺,而是稳定的状态变化,这时候才确定“真的转了一格”。

这就好比你要确认对面那个人是在向你招手,还是只是风把袖子吹起来了——你不会看一眼就下结论,而是隔半秒再看一眼,确认动作延续了,才会回应他。定时器扫描在消抖中的角色,就是那个“隔半秒再看一眼”的机制。

3.2 总体结构:主循环和定时器各干各的

我们的程序整体上分成两层:

第一层是定时器回调层machine.Timer设置为周期触发模式,每 1ms 自动调用一次read_encoder()函数。这个函数负责:读 A 相、读 B 相、组合成一个 2 位的状态值(比如 A 作为 bit1,B 作为 bit0)、跟上一次状态做比较、如果发生了变化就执行消抖判定和方向判定、最后更新全局的计数器encoder_pos

第二层是主循环层。主循环里不需要再去管 A 相 B 相的电平变化,它只干一件事:定期读取encoder_pos这个全局变量的变化,然后去执行真正的业务逻辑。比如显示在 OLED 上、控制舵机角度、调整一个参数值、打印串口日志等等。

这种分层的好处非常明显:主循环永远不会被编码器读取逻辑阻塞,你可以在主循环里放心地做刷屏、延时、状态机切换,编码器照样“后台”默默计数,等你想起来看它的时候,它已经把结果准备好了。

3.3 选用 Micropython 的 machine.Timer 还是自己写死循环

在 MicroPython 里实现“每隔一小段时间干一件事”,方案不止一种:

  • while True + time.sleep_ms(1)做轮询。这个实现最简单,但问题跟time.sleep_ms(5)一样,主循环被占住了。而且 sleep 间隔抖动大,不精准。
  • uasyncio异步任务。思路对,但 Pico 的 MicroPython 固件里 uasyncio 对定时器精度和回调的兼容性做过调整,新手用它还需要理解事件循环,学习成本变高了。
  • machine.Timer。这是最贴合我们需求的方案。它的回调在后台运行,不占用主循环,精度在毫秒级完全够用,代码还短。

machine.Timer在 RP2040 上是通过硬件定时器触发的。MicroPython 固件会为它创建一个回调环境,中断上下文里执行的操作要尽量短,否则会影响其他中断的响应。我们在回调里只做“读两脚电平 + 比较 + 改一个整数变量”,这个开销极小,实测下来完全不影响系统整体运行。

注意:MicroPython 的 Timer 回调里不能做浮点运算、不能调用print这种带 I/O 的操作,否则轻则影响实时性,重则导致定时器回调延迟累积。把数据存到变量里,到主循环里再统一打印和转发,这是一个非常重要的习惯。

3.4 状态机判断正反转的底层逻辑

编码器方向判断的原理,我用表格给你展示出来。假设 A 相对应二进制高位(bit1),B 对应低位(bit0),那么编码器在一个静止状态下,A、B 读数为11(二进制就是 0b11)。

当你顺时针旋转一格时,电平变化顺序可能是:

11 -> 01 -> 00 -> 10 -> 11

当你逆时针旋转一格时,顺序是:

11 -> 10 -> 00 -> 01 -> 11

每一次状态变化,都可以根据“上一个状态”和“当前状态”的组合,查表判断它是正转还是反转。这就是经典的正交解码状态表。

我们可以用一张表来表示部分状态跃迁:

上一个状态 (A,B)当前状态 (A,B)旋转方向
1101顺时针
0100顺时针
0010顺时针
1011顺时针
1110逆时针
1000逆时针
0001逆时针
0111逆时针

其余所有“跳变不连续”的状态组合,都视为抖动或非法跃迁,直接忽略不计。

这种查表法不仅判断准确,还能天然忽略抖动——因为抖动产生的小幅快速跳变,通常不会按照上表那样有序次第出现,而会突然从11跳到00或者01 -> 10之类的不相邻状态,这些都会被我们直接丢进“无效状态”的分支里。

4. 硬件连接与基础代码实现

4.1 硬件清单与接线图说明

本次项目的硬件清单特别简单:

  • 树莓派 Pico 一块(RP2040 芯片)。
  • EC11 旋转编码器一个(带定位感、带按键的那种常见型号即可)。
  • 面包板一块 + 杜邦线若干。
  • 可选:0.96 寸 OLED 显示屏一块(I2C 接口),用来可视化计数结果。

接线关系如下,照做即可跑通:

EC11 编码器引脚说明:EC11 通常有 5 个引脚,排成一排。一般是GNDABSWVCC(不同厂家针脚顺序略有差异,最好拿万用表蜂鸣档确认公共端位置)。

接线方式:

EC11 引脚接到 Pico
A 相GP10
B 相GP11
公共端(COM)GND
SW(按键)GP12(可选)
上拉电阻Pico 内部上拉(代码里开启)

注意:EC11 的 A 相和 B 相在旋转时,公共端会在 A 或 B 之间切换闭合。我们这里公共端接 GND,A/B 通过 Pico 内部上拉默认读高。当内部触点闭合时,对应引脚被拉到 GND,读到低电平。如果你把公共端接到了 VCC,则要改用下拉模式,否则逻辑反了。

这里有个小经验:EC11 的引脚顺序不同批次真的不一样,第一次用之前,最好用万用表测试一下哪两个引脚是 A/B,哪两个是内部触点。如果接线反了,程序读出来方向也是反的——不用慌,最后把代码里的方向判断反过来即可。

4.2 消抖定时器的核心代码

下面给出可以直接运行的完整 MicroPython 代码。我在关键行都写了注释,方便你照着敲。

from machine import Pin, Timer import time # 编码器 A、B 相位引脚,开启内部上拉 pin_a = Pin(10, Pin.IN, Pin.PULL_UP) pin_b = Pin(11, Pin.IN, Pin.PULL_UP) # 全局计数器 encoder_pos = 0 last_state = 0 last_valid_state = 0 # 状态表法方向判定 # 用 (A<<1) | B 编码当前状态,查表得到方向:1 顺时针,-1 逆时针,0 无效 DIR_TABLE = { (0b11, 0b01): 1, (0b01, 0b00): 1, (0b00, 0b10): 1, (0b10, 0b11): 1, (0b11, 0b10): -1, (0b10, 0b00): -1, (0b00, 0b01): -1, (0b01, 0b11): -1, } def read_encoder(timer): global encoder_pos, last_state # 读取 A、B 并合成状态值 a = pin_a.value() b = pin_b.value() state = (a << 1) | b # 如果状态没有变化,直接返回 if state == last_state: return # 查表法判断方向 direction = DIR_TABLE.get((last_state, state), 0) if direction != 0: encoder_pos += direction # 更新上一次状态 last_state = state # 初始化定时器,1ms 周期扫描 timer = Timer() timer.init(period=1, mode=Timer.PERIODIC, callback=read_encoder) # 主循环:读取并打印当前计数 while True: print("encoder_pos:", encoder_pos) time.sleep_ms(100)

这段代码的逻辑非常直接:

  • read_encoder每 1ms 被调用一次。
  • 它读到的 A、B 电平组合成状态值(a<<1)|b
  • 如果statelast_state一致,说明没有变化,忽略。
  • 如果变化了,就在DIR_TABLE里查表,看这次变化属于顺时针还是逆时针。
  • encoder_pos全局变量实时累加,主循环只管读取。

4.3 为什么DIR_TABLE能过滤掉抖动

你把抖动波形的状态变化列出来就明白了。一次真实的旋转,状态有序跳变:11 -> 01 -> 00 -> 10 -> 11。而抖动的表现往往是:11 -> 01 -> 11 -> 01 -> 00。后面的11 -> 01 -> 11 -> 01这些来回跳,在查表里对应(0b01, 0b11)这个组合吗?查表发现不在表中,或者在某些抖动场景下它确实在表中但方向不一致。

问题来了,如果抖动恰好沿着合法的查表路径跳呢?比如11 -> 01 -> 11这个来回,(0b11, 0b01)是顺时针,(0b01, 0b11)不在表中,所以只计了 +1 又忽略了一次。但最终机械触点稳定到了最终状态,方向判断基本不受影响。

为了更彻底地抑制抖动,你可以在查表通过后再加上“连续两次确认”逻辑:即在检测到状态变化后,等下一次扫描(1ms 后)重新读一次同一状态,如果两次一致,才真正执行计数累加。这样能进一步降低抖动造成的误计数。

我这里再给一个“连续确认”版本的代码片段,供你对比:

def read_encoder_with_confirm(timer): global encoder_pos, last_state, last_valid_state a = pin_a.value() b = pin_b.value() state = (a << 1) | b if state == last_state: return # 第一次记录变化 last_state = state # 等待下一次扫描再确认一次(其实定时器已经隔了1ms,这里主要是逻辑上的确认) # 在MicroPython的定时器回调里,我们无法直接“等”, # 所以这里用的是一个轻量级标志位。 confirm_state = last_valid_state if state == confirm_state: direction = DIR_TABLE.get((last_valid_state, state), 0) if direction != 0: encoder_pos += direction last_valid_state = state

这个写法实际是把“确认”和“计数”分开到了不同周期执行。不过坦白说,对于 EC11 接 Pico 这种场景,纯DIR_TABLE方案已经够用了,连续确认代码更多是作为一种思路补充给你,方便你在更恶劣的电磁环境或者转速更慢、更精密的场景下做增强。

4.4 主循环里如何安全读取计数

主循环读取encoder_pos时有一个“坑”:MicroPython 里虽然全局变量的整型赋值在 GIL 保护下是原子的,但print(encoder_pos)这类操作和定时器回调同时发生时,你打印出来的值可能不是最新的。不过对大多数项目来说这不是问题,反而让你看到的效果更“干净”——统计完的最终结果才是真实的。

但有一种情况必须注意:如果你要基于encoder_pos的变化去控制舵机或播放声音,千万别直接读一次就完事,应该记录上一次的值,用差值去触发动作。比如:

last_display_pos = 0 while True: if encoder_pos != last_display_pos: delta = encoder_pos - last_display_pos print("转动了", delta, "格") # 这里根据 delta 调整舵机角度或菜单项索引 last_display_pos = encoder_pos time.sleep_ms(20)

这样即使两次主循环间隔期间编码器被转动了多格,你也能感知到完整的差值,而不是只感知到“变化了”这个布尔事件。

5. 定时器参数选择与性能调优

5.1 定时器扫描周期应该设多少毫秒

这是新手问得最多的问题:“period=1period=2有什么区别?我是不是设得越小越好?”

答案是否定的。定时器回调当然越频繁,采样越密,漏检的概率越低。但回调函数本身是有执行开销的。MicroPython 是解释执行的,每次回调都要执行 Python 级别的函数调用、位运算、字典查找,开销远比 C 语言大。如果period=0.1ms,你的 Pico 会花大量 CPU 时间在编码器读取上,反而拖累其他任务。

根据我实测的经验:

  • 浏览器、菜单导航这类普通旋钮操作:1ms 完全够用,手感顺滑,不丢步。
  • 快速连旋、测试极限转速:可以尝试 0.5ms(500 微秒),观察是否漏步。
  • 低速精调、需要严格控制误触发的场景:可以放宽到 2ms,但再宽就有明显反应迟钝感了。

我默认推荐 1ms。这个数值是“性能”和“响应”都能兼顾的经验值。

实操心得:MicroPython 的Timer.init(period=1)代表的是 1ms 吗?在 RP2040 的 MicroPython 固件中,period参数单位确实是毫秒,但底层定时器精度受系统 tick 影响,不是绝对精确的 1ms。好在我们的消抖逻辑对微小的周期抖动并不敏感,所以这个问题可以忽略。

5.2 快速旋转时会不会丢步

这是很多人担心的问题,我直接给结论:在人手旋转编码器的正常速度范围内,1ms 采样不会丢步

我们可以做个简单的数学估算。EC11 编码器一般转动一圈有 20 个定位格,每格对应 4 次状态变化(11->01->00->10->11)。也就是说,快速转动一整圈,A、B 两相约产生 40 次状态变化。

如果你 1 秒钟转 5 圈(这已经是相当快的速度了),那一秒内需要捕捉的状态变化大约是 200 次。而我们在 1ms 内采样一次,一秒能采样 1000 次。采样能力远大于实际需求,至少还有 5 倍的余量。

但要注意一点:一次状态变化后,如果 1ms 内状态又变了一次,中间那次变化可能被漏掉。不过这种情况只会在极高速旋转或者抖动特别剧烈时出现,普通的手感操作完全不受影响。

5.3 定时器回调代码的“轻量化”建议

在 MicroPython 的定时器回调里,尽量避免以下几种操作:

  • 避免print:串口输出是阻塞式的,一个print可能耗时几十毫秒,如果它出现在定时器回调里,整个定时周期会被拉飞。
  • 避免浮点运算:MicroPython 的浮点运算在 RP2040 上不慢,但比整型运算仍然慢一个量级。方向判定和计数累加完全可以用整数实现。
  • 避免列表/字典动态分配:我们在初始化时就把DIR_TABLE定义成了模块级别的字典常量,回调里只做.get()查表,不会产生新的对象分配。
  • 避免在回调里驱动 OLED 等慢速外设:I2C 通信一个字节都要几十微秒,一个屏幕刷新要几毫秒。这些事情全放到主循环。

只要你遵守以上规则,定时器回调的实际耗时通常在 50 微秒以内,占一个 1ms 周期的 5%,剩下的时间都能留给主循环去干正经事。

5.4 如果转速极高,可以考虑“边缘触发中断 + 定时器判定”混合模式

刚才说过 PIO 正交解码是终极方案,但如果你暂时不想上 PIO,又想比纯定时器采样更快地捕捉跳变,可以试试“外部中断 + 定时器消抖窗口”的混合模式:让 A 相和 B 相都配置成上升沿/下降沿触发Pin.IRQ,中断到来时先记录时间戳,然后设置一个标志位;定时器每 0.5ms 检查一次标志位,若标志位置位且当前电平稳定后再更新计数,中断函数本身不执行耗时逻辑。

这个方案的优势是实时性比纯定时器扫描更高,缺点是代码复杂度上去了,而且要处理好“同一个物理事件触发了多次中断”的去重。对 Pico + MicroPython 来说,除非你做的是高速测量设备,否则没有必要上这个方案。

6. 常见问题与坑位实录

6.1 方向反了怎么办

这是最简单的问题。如果你接线时把 A、B 两相反了——或者 EC11 引脚定义和你手里的实际排布不同——程序会把顺时针识别成逆时针。

解决方法有两种:

  • pin_apin_b的赋值交换:
    pin_a = Pin(11, Pin.IN, Pin.PULL_UP) pin_b = Pin(10, Pin.IN, Pin.PULL_UP)
  • DIR_TABLE中所有 1 改成 -1、-1 改成 1。

个人更推荐交换pin_apin_b,因为改代码语义更直接,不容易引出一堆方向相关的 bug。

6.2 计数乱跳、抖动过滤不掉

如果你发现即使用了定时器方案,偶尔还是出现“一次旋转 +2/+3”的情况,先不要急着怀疑代码,按优先级排查以下项目:

  1. 接线接触不良。面包板插孔氧化、杜邦线松动,都会造成电平随机跳变。先重新插拔一次杜邦线,或者用万用表测 A/B 和 GND 之间的通断。
  2. 上拉电阻缺失或过弱。Pico 内部上拉是大约 50kΩ 左右,阻抗偏高。噪声环境较差时,建议在 A、B 到 VCC 之间各外接一个 10kΩ 电阻,能显著提高抗干扰能力。
  3. 引脚悬空。EC11 的公共端如果没接 GND,A、B 无法被拉到低电平,逻辑会乱。确认公共端确实接的是 GND。
  4. 供电不稳。Pico 如果用 USB 供电还拖了一堆外设,3.3V 可能波动较大。给外设单独供电或加个 100nF 去耦电容会有帮助。

6.3Timer回调没走、计数一直为 0

出现这种情况,九成是定时器没正确初始化。检查以下几处:

  • 确认代码里调用了timer.init(period=1, mode=Timer.PERIODIC, callback=read_encoder)
  • 确认read_encoder函数名拼写正确,且是全局函数,不是嵌套在其他函数内部的内层函数(有的 MicroPython 固件对闭包回调支持有点奇怪,保险起见都用模块级函数)。
  • 确认last_state初始化和实际状态一致。如果上电后引脚没有稳定,第一次回调里可能直接发生一次“状态跳变”,但不影响后续计数。

6.4 接 OLED 之后,编码器反应变迟钝了

如果主循环里跑着 OLED 刷新,Encoder_pos要过一两秒才更新一次,问题不在定时器,而在主循环刷新频率太低。OLED 的 I2C 刷新一屏可能要 20~50ms,如果你每次刷新都重新画一帧,主循环自然很慢。

解决方案也很简单:把“读编码器”和“显示”解耦。主循环里只做轻量判断,比如每 20ms 读一次encoder_pos,如果变了再刷 OLED。没变化的时候,OLED 不需要重画。

6.5 MicroPython 定时器回调与主循环的“线程安全”

MicroPython 的定时器回调运行在软件定时器上下文,和主循环共享同一个解释器锁。严格来说,如果你在回调里修改了一个列表、字典之类的可变对象,主循环同时在读取,可能出现不一致。但我们的方案里只改encoder_pos这个整数,主循环里也只是读取整数,不会有任何问题。

一旦你以后要在这个基础上扩展,比如把编码器计数存成一个数组或字典,就要注意加锁或者用一个标志位来协调读写,避免出现数据竞争。

7. 扩展实战:编码器控制舵机角度

7.1 思路:把旋转角度映射到舵机 PWM 占空比

写到这里,我不建议你停留在“打印计数”这一步。旋转编码器最有价值的落地场景,就是作为人机交互输入去控制某个执行器。最常见也最容易出成就感的就是控制舵机角度。

舵机的控制原理我们简单回顾一下:Pico 通过 PWM 输出一个周期为 20ms(50Hz)的方波,方波的占空比决定舵机轴的角度。常规舵机的脉冲宽度大概在 0.5ms~2.5ms 之间,对应 0 度到 180 度。MicroPython 里配置 PWM 非常简单:

from machine import Pin, PWM servo = PWM(Pin(15)) servo.freq(50) def set_servo_angle(angle): # 角度转换为占空比 us = 500 + (angle / 180) * 2000 # 500us ~ 2500us servo.duty_ns(int(us * 1000))

7.2 把旋转编码器和舵机联动起来

我们只要在上面的编码器方案基础上,修改主循环逻辑:每次encoder_pos发生变化时,把计数值映射到一个 0~180 的角度值,然后调用set_servo_angle()即可。

这里有两个细节值得注意:

  • 给角度限幅。编码器转到超出 0~180 的范围时,应该把目标角度夹紧在边界,防止 PWM 计算出超范围的占空比。
  • 不要把计数值直接当角度值。如果一个步进对应一度,那旋转一圈变化 20,你可能觉得太慢了。可以设一个比例系数,比如每格 3 度,旋转一圈变化 60 度,手感更畅快。这个系数完全看你项目需要。

来看看改完之后的完整主循环逻辑:

servo_angle = 90 # 初始角度 ANGLE_STEP = 3 while True: if encoder_pos != last_display_pos: delta = encoder_pos - last_display_pos last_display_pos = encoder_pos # 调整角度并限幅 servo_angle += delta * ANGLE_STEP if servo_angle > 180: servo_angle = 180 if servo_angle < 0: servo_angle = 0 set_servo_angle(servo_angle) print("当前角度:", servo_angle) time.sleep_ms(20)

这段代码跑起来,你会获得一个非常直觉的体验:拧旋钮,舵机跟着转;右拧增加角度,左拧减小角度。整个交互的实时性非常自然,没有任何明显的延迟感。

实操心得:舵机启动瞬间电流可能较大,用 Pico 的 3.3V 引脚直接给舵机供电,转速高的时候会导致电压跌落,Pico 直接重启不是没可能。建议给舵机用独立 5V 电源供电,Pico 和舵机共地即可。这个坑我踩过一次,之后凡是带舵机的项目,电源方案永远优先考虑。

7.3 再接一个 OLED:可视化菜单选择器

编码器另一个高频应用场景是菜单导航。你可以在 OLED 上显示一个菜单列表,编码器负责上下移动高亮项,内置按键(SW 引脚)负责确认。

这里不放完整代码,只说思路:把encoder_pos对一个菜单项总数取模,就能得到当前高亮项的索引;每次编码器计数变化时刷新高亮行;按 SW 键时进入对应的子菜单。这个交互模式在小型仪器、迷你调音台、自制 SDR 收音机里都非常常见。

8. 我对这套方案的一些复盘

这个项目做到最后,说实话,代码本身反而是最简单的一部分。真正值钱的是理清楚“为什么这么做”的那条思路线。

我第一次调编码器的时候,还在网上翻各类 STM32、Arduino 的消抖方案,买 RC 滤波器、灰度光电、专用编码器解码芯片,折腾一圈后回到纯粹的软件定时器扫描,发现十分钟就搞定了。回头想,不是硬件方案不好,而是大多数时候我们做的原型项目,“够用”比“极致”重要。Pico 的定时器 + 状态机查表,就是典型的高性价比方案。

在写这篇博文的过程中,我又翻出了当年的逻辑分析仪,重新抓了一次 EC11 的波形,确认了 1ms 采样对机械抖动的抑制效果。看着屏幕上干净的状态跳变序列,再回头看最初“乱七八糟”的毛刺波形,那个对比本身就是“消抖”二字最好的注脚。

最后再分享一个实用的小技巧:调试编码器程序的时候,别在回调里 print,也别在主循环里 print 得太频繁。串口打印会拖慢一切,让手感变得粘滞。正确做法是接一块 OLED,或者只在计数变化超过一定阈值时才打印一行。等你真正把编码器接进完整项目里,会感谢这个习惯。

如果你已经用上了这个定时器方案,建议试试把扫描周期从 1ms 改成 2ms,感受下手感差异,再决定哪个更适合你的项目。不同人旋旋钮的习惯不同,这种微调没有绝对标准,实测才是王道。

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

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

立即咨询