用Arduino玩转EC11编码器:3种典型应用场景+按键复用技巧
如果你玩过Arduino,大概率接触过按钮、电位器这些基础输入设备。但当你想要一个既能旋转选择、又能按下确认,还能快速调节数值的交互元件时,EC11旋转编码器就成了一个绝佳的选择。它不像电位器那样有物理极限,可以无限旋转,同时自带一个独立的按压开关,这种“旋转+按压”的二合一设计,让它在有限的硬件空间里实现了丰富的交互可能。
我在好几个智能家居和可穿戴设备的项目里都用到了EC11,从简单的音量旋钮到复杂的多级菜单控制器,它几乎成了我交互设计的“瑞士军刀”。但说实话,刚开始用的时候也踩过不少坑:旋转方向判断错误、按键抖动、菜单逻辑混乱……这些问题都曾让我头疼不已。后来经过反复调试和优化,才总结出一套稳定可靠的驱动方法和应用模式。
这篇文章,我就把自己在Arduino平台上折腾EC11的经验和技巧分享给你。我们不仅会深入解析EC11的工作原理和驱动代码,更会聚焦于三种极具代表性的实际应用场景,并重点探讨如何通过状态机设计和按键复用技巧,让一个小小的EC11发挥出远超你想象的控制能力。无论你是想做一个精致的桌面控制面板,还是一个可编程的智能旋钮,抑或是一个充满创意的交互装置,这里都有你需要的思路和代码。
1. 理解EC11:从硬件连接到核心驱动
EC11本质上是一个增量式旋转编码器。它内部有两组机械触点(通常标记为A相和B相),当你旋转旋钮时,这两组触点会以特定的相位差顺序通断。通过检测这个相位差的变化顺序,我们就能判断出旋转的方向。同时,它通常还集成了一个独立的按压式开关(SW引脚),相当于一个轻触按键。
1.1 硬件连接与引脚定义
市面上常见的EC11模块通常有5个引脚:VCC、GND、SW(按键)、DT(B相)、CLK(A相)。有些模块为了简化使用,已经集成了上拉电阻和滤波电容。如果你的模块是“裸”的EC11元件,那么就需要自己添加外部上拉电阻。
一个典型的Arduino连接方案如下表所示:
| EC11模块引脚 | Arduino引脚 | 功能说明 | 备注 |
|---|---|---|---|
| CLK (或A) | D2 | A相信号输出 | 强烈建议连接到支持外部中断的引脚(如UNO的D2, D3) |
| DT (或B) | D3 | B相信号输出 | 普通数字输入引脚即可 |
| SW | D4 | 按键开关 | 普通数字输入引脚,内部或外部上拉 |
| VCC | 5V 或 3.3V | 电源正极 | 根据模块规格选择 |
| GND | GND | 电源地 |
提示:将CLK引脚连接到支持外部中断的引脚,可以极大地提高旋转检测的响应速度和可靠性,避免因主循环扫描延迟而丢失脉冲。
对于没有内置上拉电阻的模块,你需要在CLK、DT和SW引脚上各连接一个10kΩ的上拉电阻到VCC。SW引脚还需要一个0.1µF的电容到GND进行硬件消抖。接线图可以想象成一个简单的分压电路:信号线通过上拉电阻接到高电平,当编码器触点闭合时,该线被拉低到GND。
1.2 核心驱动:中断法与状态机解码
读取EC11旋转方向最经典的方法是基于状态机的四步解码法。我们不需要频繁地轮询引脚状态,而是利用外部中断,在CLK引脚的电平发生变化时(上升沿或下降沿),去读取此刻DT引脚的状态,从而判断方向。
其核心逻辑基于A、B两相输出的正交编码波形。当顺时针旋转时,A相(CLK)的跳变沿领先于B相(DT);逆时针时则相反。我们可以用一个变量lastState来记录上一次A、B相的组合状态(2位二进制数),当状态发生变化时,根据状态转移表来判断方向。
下面是一个经过实战检验的、带消抖处理的EC11驱动库核心代码示例。我把它封装成了一个易于使用的Encoder类:
// EC11_Encoder.h #ifndef EC11_ENCODER_H #define EC11_ENCODER_H #include <Arduino.h> class EC11_Encoder { public: // 构造函数:传入CLK, DT, SW引脚编号 EC11_Encoder(uint8_t pinCLK, uint8_t pinDT, uint8_t pinSW); void begin(); // 初始化引脚和中断 void update(); // 在主循环中调用,处理按键和更新状态 // 获取旋转增量值,正数为顺时针,负数为逆时针,读取后自动清零 int getIncrement(); // 获取自上次调用以来的绝对旋转次数(带方向) long getRotation(); // 按键状态查询 bool wasPressed(); // 是否刚被按下(边缘检测) bool wasReleased(); // 是否刚被释放 bool isPressed(); // 当前是否被按住 bool isHeld(); // 是否被长按(默认>1000ms) // 可配置的长按时间(毫秒) void setHoldTime(unsigned long holdTimeMs); private: uint8_t _pinCLK, _pinDT, _pinSW; volatile long _position; // 旋转计数值,中断内修改 long _lastPosition; // 用于计算增量 int _increment; // 临时增量存储 // 按键状态机相关变量 bool _lastSWState; bool _currentSWState; bool _pressedFlag; bool _releasedFlag; unsigned long _pressStartTime; bool _holdFlag; unsigned long _holdTimeThreshold; // 静态中断服务例程(ISR)挂钩 static void _isrWrapper(); static EC11_Encoder* _instanceForISR; // 指向当前实例,供ISR使用 void _handleInterrupt(); // 实际的中断处理函数 // 内部状态变量,用于方向判断 volatile uint8_t _state; }; #endif对应的.cpp文件实现了状态机解码和消抖逻辑:
// EC11_Encoder.cpp #include "EC11_Encoder.h" // 静态成员初始化 EC11_Encoder* EC11_Encoder::_instanceForISR = nullptr; EC11_Encoder::EC11_Encoder(uint8_t pinCLK, uint8_t pinDT, uint8_t pinSW) : _pinCLK(pinCLK), _pinDT(pinDT), _pinSW(pinSW), _position(0), _lastPosition(0), _increment(0), _lastSWState(HIGH), _currentSWState(HIGH), _pressedFlag(false), _releasedFlag(false), _pressStartTime(0), _holdFlag(false), _holdTimeThreshold(1000), _state(0) { } void EC11_Encoder::begin() { pinMode(_pinCLK, INPUT_PULLUP); pinMode(_pinDT, INPUT_PULLUP); pinMode(_pinSW, INPUT_PULLUP); // 保存当前实例指针,供静态ISR调用 _instanceForISR = this; // 读取初始状态 _state = (digitalRead(_pinCLK) << 1) | digitalRead(_pinDT); // 附加中断,在CLK引脚变化时触发 attachInterrupt(digitalPinToInterrupt(_pinCLK), _isrWrapper, CHANGE); } // 静态ISR包装函数,调用实例的成员函数 void EC11_Encoder::_isrWrapper() { if (_instanceForISR) { _instanceForISR->_handleInterrupt(); } } void EC11_Encoder::_handleInterrupt() { // 读取当前A、B相状态,组合成一个2位二进制数 uint8_t newState = (digitalRead(_pinCLK) << 1) | digitalRead(_pinDT); uint8_t stateChange = (_state << 2) | newState; // 将旧状态和新状态组合成4位 // 状态转移表:根据旧状态->新状态的转移判断方向 // 常见有效序列:0b0010, 0b1011, 0b1101, 0b0100 为顺时针 // 0b0001, 0b0111, 0b1110, 0b1000 为逆时针 switch (stateChange) { case 0b0010: case 0b1011: case 0b1101: case 0b0100: _position++; // 顺时针 break; case 0b0001: case 0b0111: case 0b1110: case 0b1000: _position--; // 逆时针 break; // 其他序列为无效抖动,忽略 default: break; } _state = newState; // 更新状态 } void EC11_Encoder::update() { // 1. 处理旋转增量 long currentPos = _position; _increment = currentPos - _lastPosition; _lastPosition = currentPos; // 2. 处理按键(软件消抖) bool reading = digitalRead(_pinSW); if (reading != _lastSWState) { _pressStartTime = millis(); // 重置消抖计时器 } // 消抖延迟(通常20-50ms) if ((millis() - _pressStartTime) > 50) { if (reading != _currentSWState) { _currentSWState = reading; if (_currentSWState == LOW) { // 按下(假设低电平有效) _pressedFlag = true; _holdFlag = false; _pressStartTime = millis(); // 开始长按计时 } else { // 释放 _releasedFlag = true; // 如果释放时还没达到长按时间,则清除长按标志 if (!_holdFlag) { // 这是一个短按 } } } } _lastSWState = reading; // 3. 检测长按 if (_currentSWState == LOW && !_holdFlag) { if ((millis() - _pressStartTime) > _holdTimeThreshold) { _holdFlag = true; // 长按事件触发 } } } int EC11_Encoder::getIncrement() { int inc = _increment; _increment = 0; // 读取后清零 return inc; } long EC11_Encoder::getRotation() { return _position; } bool EC11_Encoder::wasPressed() { if (_pressedFlag) { _pressedFlag = false; return true; } return false; } // ... 其他成员函数实现(wasReleased, isPressed, isHeld, setHoldTime)这个驱动类的好处是封装了所有底层细节。在你的主程序中,只需要创建对象、调用begin()初始化,然后在loop()里定期调用update(),就可以通过getIncrement()、wasPressed()等函数轻松获取用户输入,完全不用担心中断冲突和抖动问题。
2. 场景一:智能家居控制面板——多级菜单与参数调节
智能家居控制面板是EC11的“主场”。一个旋钮加一个按键,就能优雅地控制灯光亮度、空调温度、音乐音量等多个参数。关键在于如何设计菜单导航逻辑。
2.1 状态机:菜单系统的灵魂
面对多层级的菜单,最清晰的设计模式就是有限状态机(FSM)。每个菜单界面都是一个状态,旋转操作在不同状态下触发不同的行为。
假设我们有一个简单的三层菜单系统:
- 主菜单:选择要控制的设备(灯光、空调、音响)。
- 设备菜单:选择该设备的具体功能(如灯光:开关、亮度、色温)。
- 参数调节:旋转调节选中的参数值。
我们可以用枚举定义所有状态,并用一个变量currentState来跟踪当前状态:
enum MenuState { STATE_MAIN_MENU, STATE_LIGHT_CONTROL, STATE_AC_CONTROL, STATE_MUSIC_CONTROL, STATE_LIGHT_BRIGHTNESS, STATE_LIGHT_COLOR_TEMP, STATE_AC_TEMPERATURE, STATE_MUSIC_VOLUME }; MenuState currentState = STATE_MAIN_MENU; int menuIndex = 0; // 当前选中的菜单项索引 const char* mainMenuItems[] = {"Lights", "AC", "Music"}; const int mainMenuCount = 3;2.2 交互逻辑与代码实现
在不同的状态下,我们需要定义EC11旋转和按键按下所对应的操作。下面是一个简化的逻辑框架:
// 假设 enc 是之前定义的 EC11_Encoder 对象 void handleEncoderInput() { enc.update(); // 处理旋转 int inc = enc.getIncrement(); if (inc != 0) { switch (currentState) { case STATE_MAIN_MENU: // 在主菜单中上下选择项目 menuIndex = (menuIndex + inc + mainMenuCount) % mainMenuCount; updateDisplay(); // 刷新显示 break; case STATE_LIGHT_BRIGHTNESS: // 调节亮度值,限制在0-255范围内 lightBrightness = constrain(lightBrightness + inc * 5, 0, 255); analogWrite(LED_PIN, lightBrightness); // 实际控制LED updateDisplay(); break; // ... 其他状态的处理 } } // 处理按键按下(短按) if (enc.wasPressed()) { switch (currentState) { case STATE_MAIN_MENU: if (menuIndex == 0) { currentState = STATE_LIGHT_CONTROL; menuIndex = 0; // 进入子菜单后重置索引 } else if (menuIndex == 1) { currentState = STATE_AC_CONTROL; } // ... updateDisplay(); break; case STATE_LIGHT_CONTROL: if (menuIndex == 0) { // 切换灯光开关 lightOn = !lightOn; digitalWrite(LIGHT_PIN, lightOn); } else if (menuIndex == 1) { // 进入亮度调节 currentState = STATE_LIGHT_BRIGHTNESS; } // ... break; case STATE_LIGHT_BRIGHTNESS: // 在参数调节界面,短按可能意味着“确认并返回” currentState = STATE_LIGHT_CONTROL; updateDisplay(); break; } } // 处理长按(作为“返回”或“退出”功能) if (enc.isHeld()) { // 长按通常用于返回上一级菜单 switch (currentState) { case STATE_LIGHT_CONTROL: case STATE_AC_CONTROL: case STATE_MUSIC_CONTROL: currentState = STATE_MAIN_MENU; break; case STATE_LIGHT_BRIGHTNESS: case STATE_LIGHT_COLOR_TEMP: currentState = STATE_LIGHT_CONTROL; break; // ... } updateDisplay(); // 注意:需要在某个地方清除长按标志,避免持续触发 // 可以在状态切换后,或者在update函数中检测到按键释放时清除 } }这种状态机的设计让代码结构非常清晰,添加新的菜单或功能就像搭积木一样简单。你只需要定义新的状态,并在switch语句中补充对应的处理逻辑即可。
2.3 显示反馈与用户体验
光有逻辑还不够,用户需要清晰的视觉反馈。通常我们会搭配一个OLED或LCD屏幕来显示当前菜单和参数。在updateDisplay()函数中,根据currentState和menuIndex来渲染不同的界面。
例如,在主菜单下,可以用一个“>”符号指向当前选中的项;在参数调节界面,可以绘制一个进度条或直接显示数值。视觉反馈的即时性对于提升操控手感至关重要。
3. 场景二:可编程智能旋钮——宏命令与模式切换
可编程旋钮的魅力在于其灵活性。你可以为EC11定义多种“模式”,在不同的模式下,旋转和按键触发完全不同的功能。比如,在“剪辑软件”模式下,旋转是时间轴缩放,按键是标记入点;切换到“音乐软件”模式,旋转变成音量调节,按键是播放/暂停。
3.1 实现模式存储与切换
首先,我们需要定义几种模式,并可能将每种模式下的行为映射存储起来。这里可以用一个结构体数组来实现:
struct KnobMode { const char* name; void (*onClockwise)(); // 顺时针旋转时执行的函数指针 void (*onCounterCW)(); // 逆时针旋转时执行的函数指针 void (*onClick)(); // 单击时执行的函数指针 void (*onLongPress)(); // 长按时执行的函数指针 }; KnobMode modes[] = { {"Volume", adjustVolumeUp, adjustVolumeDown, toggleMute, openVolumeMixer}, {"Brush Size", increaseBrushSize, decreaseBrushSize, switchBrushType, resetBrushSize}, {"Zoom", zoomIn, zoomOut, fitToScreen, resetZoom}, {"Scroll", scrollUp, scrollDown, middleClick, openLink} }; int currentModeIndex = 0; KnobMode* currentMode = &modes[currentModeIndex];模式切换可以通过组合键(如快速双击)或额外的模式切换按钮来实现。更酷的做法是,利用EC11按键的长按进入一个“模式选择菜单”,然后旋转选择模式,再次按下确认。
3.2 与PC软件通信:模拟键盘与串口指令
要让Arduino上的智能旋钮控制电脑软件,通常有两种方式:
模拟USB HID设备(如键盘、鼠标):使用Arduino Leonardo、Micro或支持USB HID的板子(如RP2040)。你可以让EC11的旋转模拟键盘的上下键或组合键(如Ctrl+滚轮),按键模拟回车或空格。
// 在Leonardo上使用Keyboard库 #include <Keyboard.h> void adjustVolumeUp() { Keyboard.press(KEY_UP_ARROW); delay(50); Keyboard.release(KEY_UP_ARROW); } void toggleMute() { Keyboard.press(KEY_LEFT_CTRL); Keyboard.press('m'); delay(50); Keyboard.release(KEY_LEFT_CTRL); Keyboard.release('m'); }通过串口发送自定义指令:Arduino通过USB串口向PC发送特定的字符串指令,PC端用一个后台程序(如用Python、AutoHotkey或Node.js编写)监听串口,解析指令并触发相应的系统动作或软件宏。
void zoomIn() { Serial.println("ZOOM_IN"); // PC端程序监听此字符串 }
哪种方式更好?模拟HID的优点是无需在PC端安装任何软件,兼容性极广。串口方式的优点是灵活,可以定义非常复杂的指令,且PC端程序可以做更强大的逻辑处理。在实际项目中,我经常两者结合:基础控制用HID,高级功能用串口指令。
3.3 旋钮加速算法:快速滚动与精细调节
当你需要调节一个范围很大的参数(比如从1到10000)时,慢吞吞地一格一格转就太痛苦了。这时就需要旋转加速算法:根据旋转的速度来动态改变每次的增量。
基本思路是记录最近几次旋转事件的时间间隔。如果间隔很短(转得快),就增加步进值;如果间隔变长或停止,就重置为基本步进。
class AcceleratedEncoder { private: EC11_Encoder& _encoder; unsigned long _lastTurnTime = 0; int _speedLevel = 0; // 速度等级 const unsigned long _speedThresholds[3] = {150, 100, 50}; // 毫秒,定义速度区间 const int _multipliers[4] = {1, 5, 20, 50}; // 对应速度等级的乘数 public: AcceleratedEncoder(EC11_Encoder& enc) : _encoder(enc) {} int getAcceleratedIncrement() { int baseInc = _encoder.getIncrement(); if (baseInc == 0) return 0; unsigned long now = millis(); unsigned long interval = now - _lastTurnTime; _lastTurnTime = now; // 判断速度等级 if (interval < _speedThresholds[2]) _speedLevel = 3; else if (interval < _speedThresholds[1]) _speedLevel = 2; else if (interval < _speedThresholds[0]) _speedLevel = 1; else _speedLevel = 0; // 如果旋转方向改变,重置加速 static int lastDir = 0; int currentDir = (baseInc > 0) ? 1 : -1; if (lastDir != 0 && currentDir != lastDir) { _speedLevel = 0; // 方向反转,从头加速 } lastDir = currentDir; return baseInc * _multipliers[_speedLevel]; } };将这个加速器应用到之前的亮度调节中,你会发现慢转时亮度细腻变化,快速旋转时亮度飞速改变,体验瞬间提升了好几个档次。
4. 场景三:交互式艺术装置——创意输入与视觉反馈
在艺术装置中,EC11不再是一个冰冷的控制器,而是观众与作品对话的媒介。它的旋转可以映射为声音的频率、图形的旋转速度、灯光的颜色渐变等等。
4.1 映射抽象参数:从物理旋转到数字艺术
关键在于建立一个映射函数,将旋转的“量”和“方向”转化为有表现力的参数。例如:
- 控制LED灯带颜色:将旋转位置映射到HSV色彩空间的Hue(色调)值上,实现无极的色彩循环。
// 假设 position 是编码器的累计位置 int hue = map(position % 1024, 0, 1024, 0, 255); // 映射到0-255的色相 // 使用FastLED库设置灯带颜色 fill_rainbow(leds, NUM_LEDS, hue, 7); - 控制伺服电机角度:将旋转直接映射到舵机的角度(0-180度),制作一个物理指针或旋转雕塑。
#include <Servo.h> Servo myservo; int servoPos = map(encoder.getRotation(), -500, 500, 0, 180); servoPos = constrain(servoPos, 0, 180); myservo.write(servoPos); - 生成交互式音效:用旋转控制一个软件合成器的频率或滤波器截止频率。通过串口或MIDI库将参数发送给音乐软件(如Pure Data, Max/MSP)。
4.2 利用按键触发模式或效果
装置中的按键可以设计得更具戏剧性:
- 单击:切换模式(如从“颜色控制”切换到“图案控制”)。
- 双击:触发一个特殊的视觉效果或声音片段。
- 长按:重置装置或进入“设置模式”。
- 旋转中按下:在调节参数时按下,可以锁定当前参数或进入“微调”模式。
实现双击检测需要更精细的计时逻辑:
unsigned long lastPressTime = 0; const unsigned long doubleClickTimeout = 300; // 毫秒 if (enc.wasPressed()) { unsigned long now = millis(); if ((now - lastPressTime) < doubleClickTimeout) { // 双击事件 triggerDoubleClickAction(); lastPressTime = 0; // 防止误判为三击 } else { lastPressTime = now; } } // 还需要一个定时器,在超时后清除lastPressTime,以区分单击和双击4.3 多设备同步与网络化控制
在大型装置中,你可能需要多个EC11控制器,或者让一个EC11控制多个输出设备。这时可以考虑使用无线通信模块(如ESP-NOW, Bluetooth, WiFi)或有线总线(如I2C)。
例如,用一个ESP32作为主控制器,连接EC11和显示屏,再通过ESP-NOW将控制指令发送给多个从属的ESP32,每个从机负责控制一部分LED灯带或电机。这样,一个旋钮就能同步控制整个装置的变化,创造出震撼的协同效果。
5. 高级技巧与避坑指南
掌握了基础应用后,下面这些技巧能让你的项目更加稳定和优雅。
5.1 极致优化:降低功耗与提高响应速度
对于电池供电的项目,功耗是关键。EC11本身功耗极低,但你的代码可能让MCU一直忙碌。优化策略包括:
- 中断唤醒:将MCU设置为睡眠模式,将EC11的CLK引脚连接到支持外部中断唤醒的引脚。任何旋转动作都会立即唤醒MCU进行处理,处理完毕后再进入睡眠。
- 降低扫描频率:如果不是绝对需要实时响应,可以在主循环中使用
millis()进行非阻塞延时,每隔10-20ms检查一次编码器状态,而不是疯狂轮询。 - 按键内部上拉:务必启用Arduino引脚的内置上拉电阻(
INPUT_PULLUP),省去外部电阻,简化电路。
5.2 软件消抖的权衡
前面代码使用了简单的延时消抖。对于要求极高的应用,可以考虑更高级的算法:
- 积分法:连续采样多次,只有当一定次数(如3/5)的采样值一致时,才认为状态稳定。
- 状态机消抖:将按键状态分为
IDLE、PRESS_DETECTED、DEBOUNCING、PRESSED、RELEASE_DETECTED等状态,用时间阈值进行转移。这是最可靠的方法。
enum ButtonState { BTN_IDLE, BTN_PRESS_DETECTED, BTN_PRESSED, BTN_RELEASE_DETECTED }; ButtonState btnState = BTN_IDLE; unsigned long debounceStartTime; void updateButtonStateMachine(bool currentReading) { unsigned long now = millis(); switch (btnState) { case BTN_IDLE: if (currentReading == LOW) { // 假设按下为低电平 btnState = BTN_PRESS_DETECTED; debounceStartTime = now; } break; case BTN_PRESS_DETECTED: if (now - debounceStartTime > DEBOUNCE_MS) { if (currentReading == LOW) { btnState = BTN_PRESSED; // 触发按下事件 onButtonPressed(); } else { btnState = BTN_IDLE; // 抖动,忽略 } } break; case BTN_PRESSED: if (currentReading == HIGH) { btnState = BTN_RELEASE_DETECTED; debounceStartTime = now; } // 这里可以检测长按 break; case BTN_RELEASE_DETECTED: if (now - debounceStartTime > DEBOUNCE_MS) { if (currentReading == HIGH) { btnState = BTN_IDLE; // 触发释放事件 onButtonReleased(); } else { btnState = BTN_PRESSED; // 抖动,还是按下状态 } } break; } }5.3 常见问题与解决方案
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 旋转方向相反 | A、B相引脚接反 | 交换CLK和DT的接线。或者在代码中反转_position++和_position--的逻辑。 |
| 偶尔跳变或漏计数 | 1. 机械抖动 2. 中断处理时间过长,丢失脉冲 3. 上拉电阻阻值过大或过小 | 1. 确保软件消抖有效,或增加硬件RC滤波(10kΩ + 100pF)。 2. 优化中断服务程序(ISR),使其尽可能短,只做最必要的操作(如更新变量)。避免在ISR内调用 delay()或进行复杂计算。3. 使用4.7kΩ - 10kΩ的上拉电阻。 |
| 按键反应迟钝或不灵 | 1. 消抖延时过长 2. 主循环 update()调用频率太低 | 1. 将消抖时间从50ms调整到20ms试试。 2. 确保 loop()中调用enc.update()的间隔足够短(<50ms),或者将按键检测也放到中断中(注意中断冲突)。 |
| 同时旋转和按下时行为错乱 | 中断和主循环资源竞争 | 对于_position这类在ISR和主循环中都可能访问的变量,声明为volatile。如果主循环中需要读取并清零,可以考虑暂时关闭中断进行原子操作:noInterrupts(); int val = _position; _position = 0; interrupts(); |
最后,分享一个我最近项目中的小发现:有些便宜的EC11模块,其内部触点的定位感(Detent)和电气脉冲可能不完全对应。也就是你感觉“咔哒”转了一格,但可能输出了两个脉冲。如果你需要非常精确的“一格一动作”,最好在购买时选择脉冲数与定位数一致的型号(如20脉冲20定位),并在代码中加入过滤,忽略在极短时间内(如2ms内)发生的第二次中断。
EC11这个小元件,其潜力远不止一个简单的旋钮。通过巧妙的编程和设计,它完全可以成为你项目中最核心、最富交互感的输入设备。希望这篇文章提供的思路和代码,能帮你打开一扇新的大门。