ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ESP32从零驱动SIQ-02FVS3旋转编码器:消抖与状态机实现

ESP32从零驱动SIQ-02FVS3旋转编码器:消抖与状态机实现 刚把手头一个项目的交互旋钮换成 SIQ-02FVS3用 ESP-IDF 从零开始撸驱动整个过程比想象中有意思。这颗编码器看着小但半边是旋转编码、半边是按压开关引脚复用加机械抖动一套折腾下来踩了不少坑。这篇笔记就把我实测通过、能直接跑的完整流程写出来包括硬件接线、软件消抖、状态机判向、按键复用处理以及最后调出来的工程代码。打算用 ESP32 做交互旋钮、想做音量控制、菜单翻页或者小型仪器面板的朋友这篇应该能帮你省下至少一个下午的调试时间。1. 为什么选 SIQ-02FVS3 这种机械编码器SIQ-02FVS3 本质是一只带开关的增量式机械编码器旋转时内部两组金属触点交替闭合断开产生正交信号。相比光电编码器或者磁编码器比如 MT6701 那类它最大的优势就是便宜、体积小、不需要额外的解码芯片直接焊在 PCB 上三根线加两个 GPIO 就能用非常适合消费级交互设备。很多调音台、对讲机侧边的音量旋钮、示波器面板上的多功能旋钮用的都是类似结构。不过便宜是有代价的。机械触点闭合的那一瞬间会反复弹跳示波器上一看全是毛刺直接接中断读的话拧一格可能给你触发七八次。另外正交信号的方向判断也不是简单看某一根线的高低电平就行的必须同时跟踪两根线的状态变化。这些都是在写驱动之前需要想清楚的。再说按键。SIQ-02FVS3 的按键部分和旋转部分是独立的两个触点组引脚不共用逻辑上可以完全分开处理。但注意按键按下时如果刚好还在拧旋钮机械上可能会出现短暂的信号毛刺所以软件上两者都要做防抖不能偷懒。选型上如果手头没有这颗也不用纠结大部分 EC11 类的增量编码器驱动思路完全一样代码改一下引脚定义就能适配后面我会提一下这个兼容性。2. 正交信号与旋转方向判断的核心原理要写对驱动必须先搞清楚两路信号到底是什么关系。SIQ-02FVS3 旋钮顺转时A 相和 B 相输出的方波相位关系是 A 领先 B 90 度逆转时反过来B 领先 A 90 度。你说的“领先”在电平上表现为两路信号每次变化组合起来的状态会沿着一个固定序列循环。把 A、B 两路电平看成一个两比特的数顺转时状态变化顺序是00 → 01 → 11 → 10 → 00或者反相后的另一组序列逆转时严格反序。这是所有增量式正交编码器统一的状态规律。所以判断方向根本不需要每次都去计算谁上升沿谁下降沿只需要盯着状态表状态切换方向是顺时针序列还是逆时针序列一目了然。这带来的一个非常实用的工程结论中断触发设计可以简化为“任意一路 GPIO 变化就触发”因为只要两路都在变化任意一次边沿跳变都意味着状态号前进或后退一格。写驱动时对 A、B 两路 GPIO 都注册中断一次状态变化记录一次对照状态表更新计数器即可。网上很多老代码只对 A 相做上升沿中断进中断再去读 B 相电平判断方向。这种写法在低速、触点接触良好的场景下能用但高速旋转或者机械抖动明显时错误率会上升因为单边沿判断等于主动丢掉了一半的状态信息。所以我直接推荐用完整状态机方案代码量也没多多少但鲁棒性提升显著。按键就简单了一路 GPIO 输入平时高电平内部上拉按下接地变低。只需要做消抖处理具体策略下一步讲。3. 机械抖动与消抖方案取舍机械编码器最大的敌人是抖动。查 SIQ-02FVS3 数据手册的话官方可能不会给你太详细的抖动时间指标实际用示波器测常见的这类编码器触点抖动一般在 0.5ms 到 5ms 之间旋钮转速越快抖动越没规律。所以消抖必须做而且要做好。消抖有几条路可以走硬件 RC 滤波在 A、B 两路线对地各加一个 0.1uF 电容配合 MCU 内部上拉电阻构成低通滤波能削掉大部分毛刺。优点是 MCU 端几乎不用额外操作缺点是电容会改变波形边沿陡峭度对高速旋转有影响而且客单 BOM 上多两颗料。软件延时候再读进中断后延时 1ms 再采样一次。思路简单但延时期间 CPU 被占住如果还有别的实时任务就不太合适而且 1ms 延时对高速旋转来说是实打实的精度损失。定时器交替采样每次 GPIO 中断先不急着处理而是重置一个软件定时器或者用 ESP-IDF 的 esp_timer 起一次性回调等 5ms 后去读一次稳定电平再做状态机判断。这种方式把消抖和状态判断拆开了逻辑清晰延迟可接受也方便调节。我这里用的是第三种。esp_timer 是硬件定时器驱动的回调精度微秒级足够用。代码上就是中断里只做一件事调用esp_timer_start_once启动一个 5ms 的一次性定时器。定时器回调函数里再去读两路 GPIO 电平跑状态机更新计数。这样中断回调极其轻量里面没有任何耗时操作也不会卡别的中断。注意ESP-IDF 的中断回调函数里禁止调用阻塞函数和无锁的日志输出GPIO 读取是可以的esp_timer 的 one-shot 接口也可以在中断里调用。但printf、ESP_LOGI这类尽量不要出现在 ISR 里否则可能触发看门狗。调试信息放到定时器回调后面那个任务上下文打就行。按键的消抖也是同样套路不过不需要定时器重复触发进中断后开启一个一次性定时器10ms 后读电平确认是低就认为按键有效。4. ESP-IDF 工程搭建与环境准备我这次用的是 Ubuntu 24.04 ESP-IDF v5.2.2。关于装哪个版本这里多说一句如果只是做常规外设驱动、MQTT、Wi-Fi 这类应用直接装 release 分支最新稳定版就行那时候装 v5.2.2 是当时稳定线。不需要追 mastermaster 有时候 API 变动比较大照着网上的老教程会踩坑。Linux 下安装没什么好说的官方脚本一条龙mkdir -p ~/esp cd ~/esp git clone --recursive https://github.com/espressif/esp-idf.git cd esp-idf ./install.sh esp32 source export.sh记得--recursive拉子模块缺了后面编译会失败这是新手最容易漏的一步。装完之后source export.sh只是当前终端生效为了避免每次都要手动 source建议直接写进~/.bashrc一行搞定。Windows 用户我更推荐 VS Code Espressif IDF 插件图形化界面里直接选 ESP-IDF 版本装完插件它会自动帮你配好工具链和路径省心不少。不过这篇笔记的代码是平台无关的编译烧录命令在 Windows 的 ESP-IDF PowerShell 里也一样跑。工程我直接用idf.py create-project生成idf.py create-project siq_encoder_demo cd siq_encoder_demo默认生成的是一个 hello_world 骨架我会把 main 文件夹里的内容整个替换成下面的驱动代码。5. 驱动代码实现与逐段解析我的工程目录结构建议长这样后续加功能也好扩展siq_encoder_demo/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ ├── idf_component.yml │ ├── siq_encoder.c │ ├── siq_encoder.h │ └── app_main.cmain/CMakeLists.txt里不需要额外加什么依赖因为只用到了 esp-idf 自带的驱动库默认注册就可以。idf_component.yml暂时也可以留空如果后面要用外部的组件仓库管理第三方库再往里加依赖。接线引脚我选的是 GPIO 0 和 GPIO 1。GPIO 0 板载按键一般复用实际项目里建议换掉这里为了演示方便。你把 A、B、KEY 分别接这三个引脚公共端接 GND。SIQ-02FVS3 内部是用机械开关把引脚拉低的所以 MCU 端要开内部上拉接线端不用再外加电阻。完整驱动代码分成三部分先看头文件// main/siq_encoder.h #ifndef SIQ_ENCODER_H #define SIQ_ENCODER_H #include stdint.h #include esp_err.h // 编码器引脚配置 #define ENC_GPIO_A GPIO_NUM_0 #define ENC_GPIO_B GPIO_NUM_1 #define ENC_GPIO_KEY GPIO_NUM_2 // 外部可见的接口 esp_err_t siq_encoder_init(void); int32_t siq_encoder_get_count(void); void siq_encoder_reset_count(void); bool siq_encoder_is_key_pressed(void); #endif这个头文件对外只暴露了初始化、读计数、清零计数、读按键状态四个接口内部实现细节全部隐藏。之所以这么设计是因为编码器状态机相关的全局变量放在 .c 文件里更安全避免被外部无意修改。再看核心实现siq_encoder.c// main/siq_encoder.c #include string.h #include siq_encoder.h #include driver/gpio.h #include esp_log.h #include esp_timer.h #include freertos/FreeRTOS.h #include freertos/task.h #define ENC_DEBOUNCE_US 5000 // 编码器消抖时间 5ms #define KEY_DEBOUNCE_US 10000 // 按键消抖时间 10ms static const char* TAG siq_enc; // 状态机当前状态初始为 0b00 0 static volatile uint8_t s_enc_state 0; // 旋转计数正值表示顺转次数负值逆转 static volatile int32_t s_enc_count 0; // 按键按下标志非阻塞交互可读 static volatile bool s_key_pressed false; // 定时器句柄 static esp_timer_handle_t s_debounce_timer; // 编码器正交状态表索引为当前状态值为下一个合法状态 // 这里定义的是顺转方向的状态转移表 static const uint8_t s_quadrature_table[4] {1, 3, 0, 2}; // 读取当前 A/B 组合状态 static uint8_t encoder_read_state(void) { uint8_t a gpio_get_level(ENC_GPIO_A) ? 1 : 0; uint8_t b gpio_get_level(ENC_GPIO_B) ? 1 : 0; return (a 1) | b; } // 定时器回调执行真正的状态机判断 static void debounce_timer_cb(void* arg) { uint8_t new_state encoder_read_state(); uint8_t old_state s_enc_state; if (new_state old_state) { return; // 状态没变判定为抖动忽略 } // 判断方向如果新状态等于旧状态顺转表里的下一个则是顺转 if (s_quadrature_table[old_state] new_state) { s_enc_count; } else { // 逆转时新状态是上一个按状态转移表反方向推 // 这里用二分判断顺转目标不是新状态那就是逆转 if (s_quadrature_table[new_state] old_state) { s_enc_count--; } // 其他情况属于跳变非法忽略 } s_enc_state new_state; } // GPIO 中断处理只启动定时器实际逻辑交给定时器回调 static void IRAM_ATTR encoder_isr_handler(void* arg) { esp_timer_start_once(s_debounce_timer, ENC_DEBOUNCE_US); } // 按键 GPIO 中断 static void IRAM_ATTR key_isr_handler(void* arg) { // 按键中断同样走定时器消抖这里直接复用同一个定时器不行 // 因为会和编码器定时冲突。所以单独建一个按键专用一次性定时器。 // 为简化代码此处直接置一个标志让主循环轮询消抖见 siq_encoder_is_key_pressed } esp_err_t siq_encoder_init(void) { gpio_config_t io_conf {}; io_conf.pin_bit_mask (1ULL ENC_GPIO_A) | (1ULL ENC_GPIO_B) | (1ULL ENC_GPIO_KEY); io_conf.mode GPIO_MODE_INPUT; io_conf.pull_up_en GPIO_PULLUP_ENABLE; io_conf.pull_down_en GPIO_PULLDOWN_DISABLE; io_conf.intr_type GPIO_INTR_ANYEDGE; gpio_config(io_conf); esp_timer_create_args_t timer_args { .callback debounce_timer_cb, .arg NULL, .name enc_debounce }; esp_timer_create(timer_args, s_debounce_timer); s_enc_state encoder_read_state(); gpio_install_isr_service(0); gpio_isr_handler_add(ENC_GPIO_A, encoder_isr_handler, NULL); gpio_isr_handler_add(ENC_GPIO_B, encoder_isr_handler, NULL); ESP_LOGI(TAG, SIQ-02FVS3 initialized, state%u, s_enc_state); return ESP_OK; } int32_t siq_encoder_get_count(void) { return (int32_t)s_enc_count; } void siq_encoder_reset_count(void) { s_enc_count 0; } bool siq_encoder_is_key_pressed(void) { // 简化版按键处理直接读电平外层配合轮询 软件延时 static uint32_t last_call_time 0; uint32_t now (uint32_t)(esp_timer_get_time() / 1000); bool level gpio_get_level(ENC_GPIO_KEY) 0; if (level (now - last_call_time KEY_DEBOUNCE_US / 1000)) { last_call_time now; return true; } return false; }这段代码有几个细节值得说明。方向判断用的状态表是新版本里比较优雅的写法。传统做法是定义一张 4x4 的查询表把所有可能的状态跳变都列出来代码长但直观。我这版定义一个“顺转下一状态表”s_quadrature_tables_quadrature_table[old_state]表示 old_state 顺转后应该进入的新状态。如果实际读取到的 new_state 恰好等于它就是顺转否则再看反过来是否成立成立就是逆转两边都不对说明中间丢了状态可能是拧太快了直接忽略不做计数。这种写法代码量最小而且判断速度是 O(1)。关于esp_timer_start_once在 ISR 里调用ESP-IDF 文档里明确说明这个接口是中断安全的可以放心用。但注意不能因为图省事在 ISR 里直接vTaskDelay或者ets_delay_us消抖一旦中断阻塞时间太长系统 tick 和 Wi-Fi 都可能出问题。gpio_install_isr_service(0)是 ESP-IDF 的 GPIO 中断统一管理机制参数 0 表示使用默认的中断优先级配置。第一次调用会把底层中断服务统一挂上后续所有 GPIO 中断都通过gpio_isr_handler_add注册。这个服务只能安装一次如果工程里别的模块已经装过比如按键扫描库再调用会返回 ESP_ERR_INVALID_STATE所以要考虑用返回值做容错。按键接口这里严格来说偷了个懒。真正产品级的按键消抖应该在子任务或者定时器回调里做完整的状态机区分短按、长按、双击。我这个siq_encoder_is_key_pressed是用“调用时低电平 距上次有效触发超过 10ms”的简化逻辑实现基础消抖对大多数场景的单击、双击都够用但你要做长按事件的话还是得扩展成带时间戳的完整状态机。6. 主程序怎么调用这套驱动写个最简单的 demo把旋钮转动的数值打印出来按键按下打印一行按键事件。放在app_main.c里// main/app_main.c #include stdio.h #include freertos/FreeRTOS.h #include freertos/task.h #include esp_log.h #include siq_encoder.h static const char* TAG app; void app_main(void) { ESP_ERROR_CHECK(siq_encoder_init()); int32_t last_count 0; while (1) { int32_t count siq_encoder_get_count(); if (count ! last_count) { ESP_LOGI(TAG, Encoder count: %ld, (long)count); last_count count; } if (siq_encoder_is_key_pressed()) { ESP_LOGI(TAG, Key pressed!); } vTaskDelay(pdMS_TO_TICKS(10)); } }这个主循环的轮询周期 10ms配合 5ms 消抖定时器整体响应手感不错。注意日志用了ESP_LOGI如果觉得输出太频繁可以把打印条件改成每变化 5 格输出一次体验更接近真实产品。为什么要 10ms 轮询一次而不是用事件标志组因为按键和计数器都是原子变量主循环读一下也就几微秒的事完全占用不了多少 CPU。而且轮询天然规避了共享变量的互斥问题只要保证读操作是原子的32 位变量在 ESP32 上读是原子的就不会出现撕裂数据。7. 中断里不加延时消抖定时器到底怎么选调试过程中我试过一个极端方案中断里直接esp_rom_delay_us(5000)来实现消抖结果在idf.py monitor里眼睁睁看着系统报Task watchdog got triggered直接重启。原因很简单中断服务函数里读电平延时等于是把整个 CPU 的优先级最高的执行路径卡住 5msWi-Fi 协议栈和其他实时任务全部饿死看门狗当然要咬人。所以消抖绝不能放进 ISR 本体。我用esp_timer_start_once的思路本质是把“消抖后判断”这件事推迟到一个 ISR 退出之后的上下文定时器回调执行这样等效于把延时挪到了后台中断只负责打标记实际耗时不到 100ns。调试中还发现一个点就是 5ms 这个值不能拍脑袋定死。如果是手拧旋钮5ms 够用如果是打算用电机带动编码器做自动化测试转速上来之后 5ms 可能就不够了会出现旋转丢步、方向误判。建议项目中预留一个宏定义实测后按需调整。想查实际效果可以直接用逻辑分析仪抓 A/B 两路波形看抖动长度和状态跳变密度。我用的是便宜的 Saleae 16 通道逻辑分析仪抓 5 秒波形做正交解码分析能看到每次抖动引起的脉冲簇长度。这个方式排查起来非常直观强烈建议手头备一个。8. 实测数据旋钮转速、计数稳定性与按键手感工程跑起来之后我做了三组快速测试。第一组是慢速手动旋转一秒转一两格计数稳定无抖动误增。第二组是快速来回甩旋钮连续来回 10 次最终计数基本回到原点偶尔会有 ±1 的偏差属于机械编码器的通病因为快速换向的时候触点状态可能来不及完全稳定。第三组测试按键连续快速按 100 下没有一次漏检或者重复触发。贴一下我测试中记录的实际数据场景旋转格数实际显示计数抖动误触发次数慢速旋转 10 格10100快速旋转 50 格5048~500~2来回快速正反 10 次0-1~10~2数据说明这套消抖方案在实际使用中完全够用但精度要求和转速极高的话建议直接换磁编码器方案本质上机械触点就决定了它的上限。按键手感方面因为消抖用了 10ms快速按下松开时偶尔会出现一个按键事件被吞掉的情况这在人机交互上影响不大但如果要记录每一次按下建议把消抖时间适当调短到 5ms或者用 GPIO 上升沿下降沿分别记录再做事件合并。9. 常见问题排查速查表把调试中遇到的典型问题整理成表格遇到类似情况可以直接对着查。现象可能原因解决思路旋转方向反了A/B 接反交换 A/B 两路 GPIO或者在状态表中反转顺逆方向定义旋转一格计数值跳好几次消抖时间太短把 ENC_DEBOUNCE_US 从 5000 调到 10000 试试快速旋转丢步、计数变少消抖时间过长或中断负载过高适当调小消抖时间检查是否其他高优先级中断抢占按键按下没反应按键 GPIO 内部上拉未使能检查 gpio_config_t 的 pull_up_en 字段按键按下反应多次按键消抖时间不足确认 KEY_DEBOUNCE_US 是否大于实际按键抖动时间编译报 gpio_isr_handler_add 未定义没安装 GPIO ISR 服务确认在主函数里调用过 gpio_install_isr_service程序运行后反复重启中断回调里做了耗时操作检查 ISR 里是否有延时、日志、阻塞调用两个编码器共用中断时互相干扰中断处理函数里没有区分来源在 isr_handler 的 arg 参数里传不同的 encoder 实例按需处理旋转计数偶发反向一下状态机非法跳变被错误判定为逆转检查非法跳变分支是否被正确处理应忽略非法状态10. 从 demo 到实际项目还能怎么扩展写完这个 demo 后我顺手把它接了三个真实场景屏幕菜单的翻页与确认、音频音量精细调节、步进电机的微调旋钮控制。每个场景的扩展方式都不一样。屏幕菜单场景在siq_encoder_get_count基础上增加一个差值接口每次读取返回自上次读取以来的增量顺便清零内部累计。这样主逻辑只要拿到增量就知道该往哪个方向翻页不需要关心绝对计数。音量调节场景需要给计数增加阈值和指数曲线刚转第一格加 1连续快速转时按步长倍增。检测转速可以靠相邻两次状态变化的时间间隔实现这个间隔在定时器回调里用esp_timer_get_time()记录即可。人手能转出的最快速度大约一拳 200ms 转 10 格也就是每格 20ms把这个值当作快速和慢速的分界线很合理。步进电机微调场景编码器计数直接映射到电机的脉冲数正转加脉冲、反转减脉冲。但因为机械编码器的抖动特性建议在关键路径上再加一层“防反跳确认”比如连续两次确认同一方向后才开始输出脉冲否则高速正反转时电机会跟着抖机架都会有共振声。如果你需要同时挂两个编码器比如数控电源的电压和电流调节直接在一个.c文件里定义两个实例每个实例维护自己的状态和计数。把公共的状态机判断提成一个静态函数传入一个实例指针即可。中断注册时arg参数传对应实例的指针这样回调里就能区分来源。EC11 类编码器的兼容性也验证过。SIQ-02FVS3 和常见的 EC11 虽然引脚间距不一样但电气特性和正交输出规律完全一致我直接把代码里的 GPIO 定义改成 EC11 对应引脚编译烧录跑起来完全正常。所以这份代码的适用范围其实要比标题宽得多。11. 最后想说的几个实操体会驱动写完后我回头复盘有几点体会值得分享。第一机械编码器驱动的核心不是怎么读电平而是怎么处理“电平变化的那一瞬间”产生的不确定性。把消抖和方向判断拆成两个独立的阶段比在中断里硬扛要稳定得多。第二ESP-IDF 的esp_timer在中断安全方面的设计确实好用遇到这类对时序敏感的外设充分利用定时器回调可以让代码既有实时性又不干扰主任务。第三选型如果允许尽量选带定位钢珠的编码器“咔哒”手感清晰的型号在用户体验上会比没定位的强很多而且定位感实际上能帮你减少用户快速旋转时的误操作。如果你照着上面的代码跑通了不妨把消抖时间试着调小再调大观察不同的手感差异。这个实验做完你对机械编码器的理解会比只看代码深得多。
返回列表