基于STM32与RT-Thread的EC11编码器定时器扫描与去抖实现
1. 项目缘起为什么需要为EC11编码器写一个“定时扫描”的APP在嵌入式开发中旋转编码器Rotary Encoder是一种非常经典的人机交互输入设备尤其是在没有触摸屏或需要快速、精确调节参数的场合比如音量旋钮、菜单选择、参数微调等。EC11是其中最常见的一款机械式增量编码器价格低廉结构简单但用起来却有不少门道。很多新手开发者拿到EC11第一反应可能就是直接去读它的A、B两个相位引脚的电平然后根据相位差判断旋转方向。这听起来很直接但在实际项目中尤其是在像RT-Thread这样的实时操作系统中这种做法往往会带来两个棘手的问题抖动Bouncing和CPU占用率。机械触点式的EC11在旋转时内部的簧片接触会产生毫秒级的电平抖动。如果你在主线程里用一个while循环去轮询引脚状态大概率会读到一连串混乱的跳变导致一次旋转被误判成多次。更麻烦的是这种忙等待Busy Waiting的轮询方式会毫无意义地霸占CPU让其他任务比如网络通信、屏幕刷新得不到及时执行。所以一个更优雅、更“RTOS范儿”的做法是利用硬件定时器TIM产生一个固定频率的中断在中断服务函数里以“扫描”的方式去采样和去抖处理EC11的状态。这就是标题中“定时器TIM6扫描方式”的核心思想。它把对时间精度要求高的采样工作交给硬件定时器保证了采样的均匀性和实时性同时中断处理函数执行时间极短不会长时间阻塞系统最后通过软件算法滤除抖动得到干净、可靠的旋转事件。本文将基于STM32平台和RT-Thread Nano手把手带你实现一个名为app_encoder的独立应用程序APP。这个APP会封装所有与EC11相关的硬件初始化、定时器扫描、去抖算法和事件回调机制最终向上层应用提供一个清晰、稳定的“顺时针/逆时针旋转”事件接口。你会发现经过这样一番设计你的主程序逻辑会变得异常清爽。2. 硬件连接与原理理解EC11的工作机制在写代码之前我们必须先搞清楚EC11是怎么“说话”的。EC11有三个引脚VCC供电通常3.3V、GND接地、以及关键的A相CLK和B相DT。有些型号会把中间引脚作为带按键功能的SWSwitch引脚我们这里主要讨论旋转功能。2.1 旋转信号的产生原理EC11内部可以看作是两个联动的机械开关分别对应A相和B相。当旋钮转动时这两个开关会以特定的顺序闭合和断开。这个顺序就编码了方向信息。假设旋钮静止时A、B相都处于高电平通过上拉电阻。当开始旋转时顺时针CW旋转A相引脚会先产生一个下降沿从高到低紧接着B相也产生一个下降沿。然后A相先上升B相再上升。在一个完整的“咔哒”感即一个定位点周期内A、B相会输出一个90度相位差的方波且A相领先B相。逆时针CCW旋转顺序相反B相引脚先产生下降沿然后才是A相。即B相领先A相。这个“谁先谁后”的相位关系就是我们判断方向的唯一依据。下图是一个理想状态下的波形示意此处以文字描述实际开发中可用逻辑分析仪抓取顺时针 (CW): A相: 高 _||___||___ (A领先) B相: 高 __||___||___ 逆时针 (CCW): A相: 高 __||___||___ B相: 高 _||___||___ (B领先)每一个“凹槽”||代表一个跳变沿。我们的任务就是在定时器中断里捕捉到这些跳变沿并根据A、B相的当前状态和历史状态还原出旋转动作。2.2 硬件电路设计与连接为了稳定读取EC11的信号硬件电路设计同样重要。一个推荐的连接方式如下电源EC11的VCC接MCU的3.3VGND接系统地。上拉电阻A相和B相引脚必须连接上拉电阻通常10kΩ到3.3V。非常重要很多MCU的GPIO内部有可配置的上拉电阻但驱动能力较弱在长导线或干扰环境下可能不稳定。我强烈建议使用外部上拉电阻这是保证信号干净的第一道防线。滤波电容在A、B相引脚到地之间并联一个10nF~100nF的瓷片电容可以很好地吸收高频毛刺是硬件去抖的有效手段。MCU引脚选择将A相和B相连接到STM32的任意两个具有外部中断或普通GPIO输入功能的引脚上。因为我们采用定时器扫描所以不需要配置为外部中断模式配置为浮空输入或上拉输入如果不用外部上拉即可。例如我们选择PA6作为A相PA7作为B相。按键引脚可选如果EC11带按键SW引脚同样需要接上拉电阻可以连接到另一个GPIO用于检测按下事件。这部分逻辑相对简单本文重点讨论旋转。连接好后你的原理图部分应该就清晰了。接下来我们进入软件设计的核心。3. 软件架构设计构建一个高内聚、低耦合的Encoder APP我们的目标是创建一个易于移植和复用的app_encoder模块。它应该隐藏所有硬件和定时器的细节只对外暴露初始化和事件回调接口。这样的设计即使以后更换编码器型号或MCU平台也只需要修改这个模块的内部实现应用层代码无需变动。3.1 模块接口定义首先我们定义这个APP对外的头文件app_encoder.h。#ifndef __APP_ENCODER_H__ #define __APP_ENCODER_H__ #include rtthread.h /* 编码器旋转方向定义 */ typedef enum { ENCODER_DIR_NONE 0, /* 无转动 */ ENCODER_DIR_CW, /* 顺时针 (Clockwise) */ ENCODER_DIR_CCW /* 逆时针 (Counter-Clockwise) */ } encoder_dir_t; /* 编码器事件回调函数类型 */ typedef void (*encoder_event_cb_t)(encoder_dir_t dir); /* 编码器初始化 * param cb 旋转事件发生时的回调函数指针。当检测到一次有效的旋转时会调用此函数并传入方向。 * return rt_err_t RT_EOK表示成功其他表示失败。 */ rt_err_t app_encoder_init(encoder_event_cb_t cb); /* 编码器去初始化可选用于资源回收 */ void app_encoder_deinit(void); #endif /* __APP_ENCODER_H__ */这个头文件非常简洁。应用层比如你的主线程或另一个APP只需要调用app_encoder_init()并传入一个回调函数。之后每当用户旋转编码器你的回调函数就会被调用并得知是顺时针还是逆时针。所有复杂的逻辑都被封装在.c文件里。3.2 核心数据结构与状态机在app_encoder.c内部我们需要一些静态变量来保存状态。最关键的是一个状态机用于实现去抖算法。/* 私有全局变量 */ static encoder_event_cb_t user_callback RT_NULL; static rt_timer_t scan_timer RT_NULL; // RT-Thread 软件定时器对象 static GPIO_TypeDef* GPIO_A_PORT; static uint16_t GPIO_A_PIN; static GPIO_TypeDef* GPIO_B_PORT; static uint16_t GPIO_B_PIN; /* 编码器状态机状态 */ static uint8_t last_state 0; // 上一次扫描的AB状态 (bit1:A, bit0:B) static int8_t encoder_count 0; // 积分计数器用于去抖和确认这里解释一下encoder_count的作用这是软件去抖的核心。我们不会在检测到一次A相变化后就立刻报告事件那样太容易受抖动干扰。而是采用“积分法”每次定时器中断采样到新的A、B相状态new_state。将last_state和new_state组合成一个4位的历史-当前状态码。根据一个预定义的“有效跳转表”判断这次状态变化是否是一个有效的旋转步骤比如从00-10对于CW是有效的。如果是有效步骤encoder_count加1CW或减1CCW。如果encoder_count累加到某个正阈值如3则判定发生了一次有效的顺时针旋转触发回调并重置计数器。同理累减到某个负阈值如-3判定为逆时针旋转。如果状态变化是无效的比如抖动产生的非法跳变则encoder_count清零。这个方法的妙处在于它要求连续、多次、符合顺序的有效采样才能确认一次旋转能极好地抵抗随机抖动。阈值的大小这里是3决定了去抖的严格程度和旋钮的“手感”你可以根据EC11的机械特性和定时器扫描频率来调整。4. 定时器TIM6的配置与扫描中断实现我们选择STM32的TIM6作为扫描定时器。TIM6是一个基本定时器功能简单正好适合做这种周期性触发的工作。为什么不直接用RT-Thread的软件定时器因为软件定时器的精度依赖于系统时钟节拍tick通常为1ms或10ms对于需要几百微秒间隔的硬件去抖来说精度和及时性不够。硬件定时器中断的优先级更高时序更精确。4.1 TIM6的CubeMX配置与HAL库初始化如果你使用STM32CubeMX进行初始化配置步骤如下在Pinout Configuration标签页中找到TIM6。将Clock Source设置为Internal Clock。在Parameter Settings选项卡中Prescaler (PSC - 16 bits value): 定时器预分频器。根据你的系统时钟HCLK计算。假设HCLK72MHz我们希望定时器计数频率为1MHz即每微秒计数一次则PSC 72 - 1 71。Counter Mode:Up。Counter Period (AutoReload Register - 16 bits value): 自动重装载值。这决定了中断频率。如果我们希望每250微秒中断一次扫描频率4kHz因为计数频率是1MHz所以ARR 250 - 1 249。auto-reload preload:Enable。在NVIC Settings选项卡中勾选TIM6 global interrupt使能中断并设置一个合适的优先级比如比系统调度器优先级高但比其他紧急硬件中断低。生成代码后CubeMX会在tim.c中生成MX_TIM6_Init()函数。我们需要在自己的APP中调用它并启动定时器。然而在RT-Thread中我们通常希望更精细地控制中断和与RTOS的交互。更常见的做法是不直接使用HAL库的中断回调而是在中断服务函数ISR中发送一个信号量或事件给一个专门的处理线程。但为了极致简单和减少任务切换开销我们这个APP采用一种更轻量的方式直接在TIM6中断服务函数中执行扫描和状态判断算法。因为算法非常简单执行时间极短几个微秒完全可行。我们需要重写TIM6的中断服务函数// 在 app_encoder.c 文件中 #include “stm32f1xx_hal.h” // 根据你的芯片系列修改 extern TIM_HandleTypeDef htim6; // 声明CubeMX生成的句柄 void TIM6_IRQHandler(void) { /* 进入中断 */ if (__HAL_TIM_GET_FLAG(htim6, TIM_FLAG_UPDATE) ! RESET) { if (__HAL_TIM_GET_IT_SOURCE(htim6, TIM_IT_UPDATE) ! RESET) { __HAL_TIM_CLEAR_IT(htim6, TIM_IT_UPDATE); /* 调用我们的编码器扫描函数 */ encoder_timer_scan_isr(); } } }4.2 核心扫描函数encoder_timer_scan_isr()这个函数是整个APP的心脏在定时器中断中执行。/* 编码器状态查询表 (Finite State Machine Table) * 索引规则: (last_state 2) | new_state * last_state: bit1A, bit0B (上一次状态) * new_state: bit1A, bit0B (当前状态) * 值: 0无效/无变化 1有效CW步 -1有效CCW步 */ static const int8_t encoder_fsm_table[16] { 0, // 00-00 -1, // 00-01 (CCW) 1, // 00-10 (CW) 0, // 00-11 (非法) 1, // 01-00 (CW) 0, // 01-01 0, // 01-10 (非法) -1, // 01-11 (CCW) -1, // 10-00 (CCW) 0, // 10-01 (非法) 0, // 10-10 1, // 10-11 (CW) 0, // 11-00 (非法) 1, // 11-01 (CW) -1, // 11-10 (CCW) 0 // 11-11 }; static void encoder_timer_scan_isr(void) { uint8_t new_state 0; int8_t step; // 1. 采样当前A、B相电平 if (HAL_GPIO_ReadPin(GPIO_A_PORT, GPIO_A_PIN) GPIO_PIN_SET) { new_state | 0x02; // A相为高设置bit1 } if (HAL_GPIO_ReadPin(GPIO_B_PORT, GPIO_B_PIN) GPIO_PIN_SET) { new_state | 0x01; // B相为高设置bit0 } // 2. 查表判断本次状态变化是否有效及方向 step encoder_fsm_table[(last_state 2) | new_state]; // 3. 更新状态历史 last_state new_state; // 4. 无效变化或未变化清零计数器并返回 if (step 0) { encoder_count 0; return; } // 5. 有效变化更新积分计数器 encoder_count step; // 6. 判断是否达到触发阈值 if (encoder_count ENCODER_CW_THRESHOLD) { // 顺时针阈值例如3 encoder_count 0; // 重置计数器 if (user_callback ! RT_NULL) { // 注意在中断中调用回调要求回调函数必须非常短小。 user_callback(ENCODER_DIR_CW); } } else if (encoder_count ENCODER_CCW_THRESHOLD) { // 逆时针阈值例如-3 encoder_count 0; if (user_callback ! RT_NULL) { user_callback(ENCODER_DIR_CCW); } } // 如果未达到阈值计数器保持等待下一次中断继续累加/累减。 }这个函数的设计有几个关键点查表法使用状态转移表encoder_fsm_table是嵌入式系统常见的优化手段。它将复杂的if-else逻辑判断转化为一次数组索引操作效率极高且不易出错。表的内容根据EC11的真值表推导而来。积分去抖encoder_count的累加/累减机制要求连续、正确的状态序列才能触发事件完美过滤了单次或双次的抖动毛刺。中断内回调为了极致的实时性我们在中断里直接调用用户回调。这就要求用户提供的回调函数必须尽可能短小绝不能进行rt_thread_delay()、rt_mutex_take()等可能引起阻塞的操作。通常回调函数里只是设置一个标志位或发送一个邮件/事件给应用线程。5. APP初始化与资源管理有了核心的中断处理函数我们需要一个初始化函数来组装所有部件。#define ENCODER_SCAN_FREQ_HZ 4000 // 扫描频率4kHz #define ENCODER_TIM_CLOCK_MHZ 72 // TIM6时钟源频率单位MHz #define ENCODER_CW_THRESHOLD 3 #define ENCODER_CCW_THRESHOLD -3 rt_err_t app_encoder_init(encoder_event_cb_t cb) { rt_err_t result RT_EOK; uint32_t prescaler, period; // 1. 参数检查 if (cb RT_NULL) { rt_kprintf(“[ENCODER] Error: Callback function is NULL.\n”); return -RT_ERROR; } user_callback cb; // 2. 初始化GPIO (这里以PA6, PA7为例实际应根据你的电路修改) GPIO_A_PORT GPIOA; GPIO_A_PIN GPIO_PIN_6; GPIO_B_PORT GPIOA; GPIO_B_PIN GPIO_PIN_7; __HAL_RCC_GPIOA_CLK_ENABLE(); // 使能GPIOA时钟 GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin GPIO_A_PIN | GPIO_B_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_NOPULL; // 使用外部上拉所以这里不拉 // GPIO_InitStruct.Pull GPIO_PULLUP; // 如果使用内部上拉则用这行 HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // 3. 初始化TIM6定时器 __HAL_RCC_TIM6_CLK_ENABLE(); // 计算定时器参数 // 定时器计数频率 TIM_CLOCK / (PSC 1) // 中断频率 计数频率 / (ARR 1) prescaler (ENCODER_TIM_CLOCK_MHZ * 1000000) / 1000000 - 1; // 目标1MHz计数频率 period 1000000 / ENCODER_SCAN_FREQ_HZ - 1; // 目标扫描频率 htim6.Instance TIM6; htim6.Init.Prescaler prescaler; htim6.Init.CounterMode TIM_COUNTERMODE_UP; htim6.Init.Period period; htim6.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; if (HAL_TIM_Base_Init(htim6) ! HAL_OK) { rt_kprintf(“[ENCODER] Error: TIM6 init failed.\n”); return -RT_ERROR; } // 4. 使能定时器更新中断并启动 HAL_TIM_Base_Start_IT(htim6); // 5. 初始化状态变量 last_state 0; encoder_count 0; // 上电后第一次读取初始状态 encoder_timer_scan_isr(); // 手动调用一次获取初始状态 rt_kprintf(“[ENCODER] Initialized. Scan Freq: %dHz.\n”, ENCODER_SCAN_FREQ_HZ); return RT_EOK; }在初始化函数中我们完成了硬件GPIO和TIM6的配置并启动了定时器中断。注意最后手动调用了一次encoder_timer_scan_isr()这是为了获取A、B相的初始状态填充last_state变量避免第一次中断时因历史状态未知而产生误判。6. 应用层集成与实战演示现在我们的app_encoder模块已经完成了。如何在应用中使用它呢让我们创建一个示例应用线程。// 在 application.c 或 main.c 中 #include “app_encoder.h” #include rtthread.h static rt_thread_t encoder_thread RT_NULL; static struct rt_event encoder_evt; // 用于传递编码器事件的事件集 /* 编码器事件回调函数 */ static void my_encoder_callback(encoder_dir_t dir) { // 在中断中调用仅发送事件不做耗时操作 rt_event_send(encoder_evt, (rt_uint32_t)dir); } /* 编码器处理线程入口函数 */ static void encoder_thread_entry(void *parameter) { rt_uint32_t recv_evt 0; // 初始化编码器APP if (app_encoder_init(my_encoder_callback) ! RT_EOK) { rt_kprintf(“Failed to init encoder app.\n”); return; } // 初始化事件集 rt_event_init(encoder_evt, “enc_evt”, RT_IPC_FLAG_FIFO); while (1) { // 等待编码器事件永久等待 if (rt_event_recv(encoder_evt, ENCODER_DIR_CW | ENCODER_DIR_CCW, // 接收这两种事件 RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR, // 任一事件到来并清除事件 RT_WAITING_FOREVER, recv_evt) RT_EOK) { // 根据接收到的事件进行处理 switch (recv_evt) { case ENCODER_DIR_CW: rt_kprintf(“[APP] Encoder CW rotated.\n”); // 这里可以执行实际功能如增加音量、翻页等 // volume_up(); break; case ENCODER_DIR_CCW: rt_kprintf(“[APP] Encoder CCW rotated.\n”); // volume_down(); break; default: break; } } } } /* 线程创建 */ int encoder_app_sample_start(void) { encoder_thread rt_thread_create(“enc_thd”, encoder_thread_entry, RT_NULL, 512, // 栈大小 25, // 优先级比一般任务高一些 10); // 时间片 if (encoder_thread ! RT_NULL) { rt_thread_startup(encoder_thread); return RT_EOK; } return -RT_ERROR; } /* 导出到 msh 命令方便测试 */ MSH_CMD_EXPORT(encoder_app_sample_start, start encoder sample app);在这个示例中我们建立了一个经典的中断-线程通信模型中断服务函数ISRencoder_timer_scan_isr()执行于TIM6中断上下文负责采样、去抖、判断方向。一旦确认旋转它调用my_encoder_callback。回调函数Callbackmy_encoder_callback虽然被ISR调用但它只做一件事向一个RT-Thread事件集encoder_evt发送一个事件事件值就是方向。这个操作是原子的速度极快不会导致中断阻塞。应用线程Threadencoder_thread在后台等待事件集。当收到事件后它从等待中唤醒执行实际的应用逻辑如打印信息、调节音量。因为逻辑在线程中执行所以你可以安全地调用任何RT-Thread的API如延时、申请信号量等。这种设计完美平衡了实时性和系统安全性是RTOS下处理外部事件的黄金法则。7. 调试技巧与常见问题排查即使代码逻辑正确在实际硬件调试中也可能遇到问题。以下是一些实用的调试技巧和常见坑点7.1 问题旋转编码器无反应或反应混乱排查步骤检查硬件连接这是最常见的问题。用万用表测量VCC和GND是否接通电压是否正确。测量A、B相在静止时的电压应该是稳定的高电平接近3.3V。如果电压不稳或为低检查上拉电阻是否虚焊或阻值过大。检查软件配置GPIO模式确认配置为输入模式。如果使用了内部上拉代码中GPIO_InitStruct.Pull应设为GPIO_PULLUP如果使用了外部上拉则必须设为GPIO_NOPULL。这里极易出错如果外部上拉硬件存在软件又配置了内部上拉可能会造成电平冲突或读取异常。定时器频率计算你的定时器中断实际频率。在TIM6_IRQHandler入口点翻转一个测试引脚用示波器测量方波频率确认是否是预期的4kHz。频率太高可能CPU忙不过来太低则可能丢失快速旋转的脉冲。逻辑分析仪抓波形这是最强大的调试工具。将逻辑分析仪的通道连接到EC11的A、B相旋转旋钮观察波形。看是否有清晰的、相位差90度的方波抖动是否严重通常不应超过1ms波形是否符合第2.1节描述的规律通过波形可以直观判断是硬件问题还是软件解码问题。打印状态信息在encoder_timer_scan_isr()函数中不要直接调用rt_kprintf中断中严禁使用阻塞式打印但可以将last_state、new_state、step、encoder_count等变量存入一个循环缓冲区。然后在应用线程中定期打印这个缓冲区的内容观察状态机是否正确跳转。7.2 问题旋转一次回调函数被触发多次原因与解决去抖阈值过低ENCODER_CW_THRESHOLD设置太小比如1或2。机械抖动可能产生2-3个连续的跳变脉冲如果阈值是1一次旋转就会触发多次。建议阈值至少设为3或4。扫描频率与机械特性不匹配扫描频率如4kHz太高而你的EC11旋转一个定位点产生的脉冲周期可能较长。导致在一个定位点内定时器中断采样到了太多次“稳定状态”使得encoder_count累加超过了阈值多次。可以尝试降低扫描频率如1kHz或者修改算法在触发一次事件后加入一个短暂的“死区时间”例如清零计数器后接下来几次扫描忽略输入。硬件去抖不足回顾第2.2节检查是否在A、B相上并联了滤波电容如100nF。这个电容能显著平滑信号。7.3 优化如何实现“加速”功能很多编码器应用需要“加速”功能慢速旋转时一次“咔哒”触发一次事件快速旋转时一次“咔哒”可能触发多次事件比如快速滚动列表。 这可以在应用线程中实现。思路是记录两次事件的时间间隔。如果间隔很短比如小于100ms则认为是在快速旋转除了执行本次操作还可以额外再触发一次或多次操作。你可以利用RT-Thread的rt_tick_get()函数来获取系统时间戳。static rt_tick_t last_tick 0; // 在 encoder_thread_entry 的 switch 语句中 case ENCODER_DIR_CW: { rt_tick_t current_tick rt_tick_get(); rt_tick_t diff current_tick - last_tick; last_tick current_tick; int steps 1; // 默认步进为1 if (diff RT_TICK_PER_SECOND / 10) { // 如果间隔小于100ms steps 3; // 加速步进变为3 } for (int i 0; i steps; i) { // 执行你的操作例如 volume_up(); rt_kprintf(“CW (fast)\n”); } break; }8. 进阶思考从TIM6到通用定时器与软件定时器本文以TIM6为例因为它简单。但在资源紧张的项目中TIM6可能被用于其他用途如DAC触发。你可以轻松地将本方案移植到其他通用定时器如TIM2, TIM3等上只需修改相应的初始化代码和中断向量函数名即可。另一个更“RT-Thread”的思路是使用硬件定时器设备驱动框架。RT-Thread提供了rt_device_find(“timerX”)和rt_device_control等API来操作定时器这样代码与硬件耦合度更低移植性更好。不过直接操作寄存器在简单项目中通常更直接高效。最后虽然不推荐用于高精度扫描但如果你确实想用RT-Thread的软件定时器也是可以的。你需要创建一个周期为1ms或2ms的软件定时器在其超时回调函数中执行encoder_timer_scan_isr()里的逻辑。需要注意的是软件定时器的精度受系统tick和线程调度影响在系统负载高时可能会有较大抖动可能导致编码器检测不灵敏或误触发。因此对于手感要求高的场合硬件定时器中断仍是首选。通过这个完整的app_encoder实现你将获得一个稳定、可靠、易于集成的编码器输入模块。它把硬件细节、中断处理和去抖算法这些脏活累活都封装好了让你的应用开发可以更专注于业务逻辑本身。下次当你需要为一个设备增加一个精致的旋钮时不妨试试这个方案。