STM32旋转编码器实战:从硬件消抖到状态机解码与HAL库实现

📅 2026/8/1 20:13:07
STM32旋转编码器实战:从硬件消抖到状态机解码与HAL库实现
1. 项目概述从“旋转”到“计数”的桥梁最近在调试一个需要精确控制旋钮位置的项目用到了旋转编码器。这东西看着简单就是个能转的旋钮但真把它接到STM32上让它稳定、准确地读出旋转方向和步数里面门道还真不少。网上资料要么太浅只讲接线要么直接上库原理一笔带过。踩了几个坑之后我决定把从硬件原理到软件防抖的完整过程梳理一遍尤其是那种最常用、成本最低的机械式增量编码器。如果你正准备在STM32项目里用它来做菜单选择、速度调节或者位置反馈这篇笔记应该能帮你避开我走过的弯路。旋转编码器本质上是一个将角位移转换成数字脉冲信号的传感器。我们最常见的是增量式编码器它输出两路相位差90度的方波通常称为A相和B相。你的STM32不需要关心它绝对转到了多少度只需要通过检测这两路信号的跳变顺序就能判断是顺时针还是逆时针旋转并通过计数脉冲数知道转了多少“格”。这比普通的电位器模拟量更耐用、精度更高且没有旋转角度限制非常适合人机交互界面和慢速运动控制。2. 核心硬件原理与信号特征解析2.1 机械式增量编码器是如何工作的你手里那个带“咔哒”手感的小编码器内部结构其实不复杂。核心是一个和轴联动的码盘以及两对光电或机械触点对应A、B相。当轴旋转时码盘上的栅格会依次触发这两对触点产生电信号。关键在于这两对触点在空间上被错开一定角度通常是1/4个栅格周期这就导致了A、B两相信号在时间上会产生90度的相位差。这个相位差是判断方向的关键。假设初始状态A、B都为低电平。顺时针旋转时A相会先产生上升沿然后B相才产生上升沿逆时针旋转时则相反B相先跳变A相后跳变。你的单片机程序就是要牢牢抓住这个“谁先谁后”的时序关系。注意市面上还有一种“正交编码器接口”是STM32定时器的硬件功能能自动解码这类信号效率极高。但本篇我们先从最基础的GPIO中断软件解码讲起这能让你彻底理解原理并且在没有富余定时器资源时也能解决问题。2.2 读懂编码器的输出波形理想情况下A、B相的波形是干净、标准的方波。但实际使用尤其是廉价的机械编码器会带来两个主要问题抖动和毛刺。抖动这是机械触点闭合/断开时不可避免的物理现象。在电平跳变的边缘触点可能会在极短时间内微秒级反复弹跳多次导致信号线上出现一连串快速的脉冲而不是一次干净的跳变。如果你直接在跳变沿触发中断并计数一次旋转可能会被误判成几十次。毛刺与噪声长导线、不稳定的电源或附近的大功率设备都可能引入随机干扰脉冲。因此直接读取GPIO电平是不可靠的。我们必须从硬件和软件两个层面进行“消抖”。硬件上最简单的办法是在A、B相引脚各接一个0.1uF的电容到地构成一个低通滤波器可以吸收掉高频的抖动噪声。软件上则需要采用“延时采样”或“状态机”的方法来过滤掉不可靠的跳变。3. 软件解码策略与状态机实现3.1 为什么简单的边沿中断会失败很多新手会尝试这样的方法将A相连接到外部中断引脚设置为上升沿和下降沿触发。在中断服务函数里读取B相的电平如果B为低则是顺时针如果B为高则是逆时针。看起来利用了相位差对吗这个方法在理想无抖动的世界里是成立的。但在现实中它会灾难性地失败。原因正是上文提到的抖动。一次有效的旋转A相可能因为抖动产生多次中断你的程序会错误地多次进入中断并且每次读取的B相电平可能处于抖动过程中的不确定状态导致方向判断完全随机。结果就是旋钮动一下计数值乱跳根本没法用。3.2 基于状态机的四倍频解码法可靠的方法是使用状态机对A、B两相进行联合判断。我们不仅关心跳变更关心跳变后的稳定状态。将A、B两个比特组成一个2位二进制数共有4种状态00, 01, 10, 11。一次完整的步进从一个“咔哒”位到下一个编码器的输出会按顺序经过这4个状态。顺时针和逆时针旋转时状态变化的顺序是相反的。我们可以建立一个状态转移表。假设当前状态是AB00A低B低顺时针旋转的下一个稳定状态是AB10A高B低。逆时针旋转的下一个稳定状态是AB01A低B高。我们的程序逻辑是定期例如每1ms采样A、B相的电平得到当前状态。将当前状态与上一次保存的状态进行比较。只有当变化符合我们状态表中定义的合法转移如00-10或00-01时我们才认为发生了一次有效的步进并根据转移方向增加或减少计数值。这种方法的好处是抗抖动因为我们是定期采样而不是在跳变沿立即响应。抖动产生的高频变化很可能在我们两次采样之间就完成了我们采到的是抖动结束后的稳定电平。可实现四倍频一次物理步进对应4个状态变化如果我们对每个合法状态转移都计数就能将分辨率提高4倍。这对于需要精细控制的场合很有用。逻辑清晰用状态转移表来定义合法操作程序结构非常规整。下面是一个简化的状态机解码示例代码框架// 定义编码器状态 typedef enum { ENC_STATE_00 0, // A0, B0 ENC_STATE_01 1, // A0, B1 ENC_STATE_10 2, // A1, B0 ENC_STATE_11 3 // A1, B1 } EncoderState; // 状态转移表-1表示非法转移1表示顺时针步进-1表示逆时针步进 // 行索引是旧状态列索引是新状态 const int8_t state_transition_table[4][4] { /*从 00 到 (00,01,10,11) */ {0, -1, 1, 0}, /*从 01 到 (00,01,10,11) */ {1, 0, 0, -1}, /*从 10 到 (00,01,10,11) */ {-1, 0, 0, 1}, /*从 11 到 (00,01,10,11) */ {0, 1, -1, 0} }; volatile int32_t encoder_count 0; // 编码器计数值 static EncoderState last_state ENC_STATE_00; // 需要在定时器中断或主循环中定期调用此函数例如每1ms void Encoder_Polling(void) { // 1. 读取当前A、B相电平 uint8_t a_val HAL_GPIO_ReadPin(ENC_A_GPIO_Port, ENC_A_Pin); uint8_t b_val HAL_GPIO_ReadPin(ENC_B_GPIO_Port, ENC_B_Pin); // 2. 组合成当前状态 EncoderState current_state (a_val 1) | b_val; // 3. 查表判断是否发生有效步进及方向 int8_t direction state_transition_table[last_state][current_state]; if (direction ! 0) { // 发生有效步进 encoder_count direction; // 这里可以触发回调函数通知应用层计数值已变 } // 4. 更新旧状态 last_state current_state; }这个Encoder_Polling函数需要被周期性调用。你可以把它放在一个1ms的定时器中断里或者放在主循环中但确保执行间隔稳定。定时中断的方式更可靠因为它不受主循环其他任务执行时间的影响。4. 基于STM32 HAL库的完整工程实现4.1 硬件连接与CubeMX配置我们以STM32F103C8T6蓝桥杯常用板为例使用GPIOA的PA0和PA1作为编码器的A、B相输入。硬件连接编码器A相 → PA0编码器B相 → PA1编码器COM端 → GND编码器VCC端 → 3.3V务必确认编码器工作电压常见有5V和3.3V在PA0和PA1引脚上各并联一个0.1uF的电容到地作为硬件消抖。STM32CubeMX配置打开CubeMX选择你的芯片。将PA0和PA1配置为GPIO_Input 上拉模式Pull-up。因为大多数编码器是开集电极输出需要上拉电阻才能产生高电平。STM32内部的上拉电阻约40kΩ通常足够。配置一个基本定时器如TIM2用于产生1ms的中断。时钟源Internal ClockPrescaler (PSC): 7200 - 1 如果系统时钟是72MHz则72MHz / 7200 10kHzCounter Period (ARR): 10 - 1 10kHz / 10 1kHz即1ms中断开启定时器中断。生成代码。4.2 代码集成与功能封装在生成的工程中我们需要添加编码器解码模块。首先在main.c的/* USER CODE BEGIN PV */区域定义全局变量/* USER CODE BEGIN PV */ volatile int32_t g_encoder_val 0; // 全局编码器值 /* USER CODE END PV */然后创建一个encoder.c和encoder.h文件。encoder.h内容#ifndef __ENCODER_H #define __ENCODER_H #include main.h void Encoder_Init(void); int32_t Encoder_GetCount(void); void Encoder_ClearCount(void); #endifencoder.c内容#include encoder.h // 假设A相接PA0 B相接PA1 #define ENC_A_PIN GPIO_PIN_0 #define ENC_A_PORT GPIOA #define ENC_B_PIN GPIO_PIN_1 #define ENC_B_PORT GPIOA static EncoderState last_state ENC_STATE_00; // 初始化函数主要读取初始状态 void Encoder_Init(void) { uint8_t a_val HAL_GPIO_ReadPin(ENC_A_PORT, ENC_A_PIN); uint8_t b_val HAL_GPIO_ReadPin(ENC_B_PORT, ENC_B_PIN); last_state (a_val 1) | b_val; g_encoder_val 0; } // 需要在1ms定时器中断中调用的轮询函数 void Encoder_Update(void) { uint8_t a_val HAL_GPIO_ReadPin(ENC_A_PORT, ENC_A_PIN); uint8_t b_val HAL_GPIO_ReadPin(ENC_B_PORT, ENC_B_PIN); EncoderState current_state (a_val 1) | b_val; int8_t dir state_transition_table[last_state][current_state]; if(dir ! 0) { g_encoder_val dir; // 可选添加计数范围限制 // if(g_encoder_val MAX_COUNT) g_encoder_val MAX_COUNT; // if(g_encoder_val MIN_COUNT) g_encoder_val MIN_COUNT; } last_state current_state; } int32_t Encoder_GetCount(void) { return g_encoder_val; } void Encoder_ClearCount(void) { g_encoder_val 0; }接下来在1ms定时器中断回调函数中调用Encoder_Update。找到stm32f1xx_it.c文件中的TIM2_IRQHandler函数或者更规范的做法在main.c中重写HAL库的定时器溢出回调函数在main.c的/* USER CODE BEGIN 4 */区域添加void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if(htim-Instance TIM2) { // 确认是TIM2触发的中断 Encoder_Update(); // 更新编码器状态 } }最后在main函数的初始化部分调用Encoder_Init()然后在主循环中你就可以随时使用Encoder_GetCount()来获取当前的计数值了。这个值的变化就对应着编码器的旋转。4.3 应用层处理将计数值转化为实用功能获取到不断变化的g_encoder_val后我们通常不会直接使用这个原始值。常见的应用模式有两种模式一相对值模式用于菜单浏览、音量调节在这种模式下我们只关心编码器相对于上次读取时变化了多少。我们定期比如每20ms读取一次编码器值并与上次的值做差这个差值delta就是在这段时间内旋转的“步数”。然后根据delta的正负和大小来执行相应的操作如菜单项上移/下移数值增加/减少。int32_t last_encoder_val 0; while(1) { int32_t current_val Encoder_GetCount(); int32_t delta current_val - last_encoder_val; last_encoder_val current_val; if(delta ! 0) { // delta 0: 顺时针旋转了 |delta| 步 // delta 0: 逆时针旋转了 |delta| 步 // 在这里执行你的应用逻辑例如调整菜单索引 menu_index delta; // 别忘了处理菜单索引的边界 menu_index (menu_index 0) ? 0 : menu_index; menu_index (menu_index MAX_MENU_ITEMS) ? (MAX_MENU_ITEMS - 1) : menu_index; // 刷新显示 Update_Display(menu_index); } HAL_Delay(20); // 降低检测频率使操作更平滑 }模式二按键编码器组合用于确认选择很多旋转编码器中间还带一个按键SW引脚。我们可以将这个按键配置为外部中断按下时表示确认当前选项。这样一个编码器就能完成“浏览”和“确认”全部操作非常节省IO口。5. 进阶话题与深度优化5.1 使用硬件正交编码器接口如果你的STM32定时器资源充足强烈建议使用硬件正交编码器模式。这是最专业、最可靠、CPU占用率最低的方案。以TIM2为例在CubeMX中将TIM2的通道1和通道2通常是PA0和PA1配置为 “Encoder Mode”。在参数设置中编码器模式通常选择 “Encoder Mode TI1 and TI2”。生成代码后HAL库会自动配置好定时器。使用时你只需要启动定时器然后直接读取定时器的计数器寄存器htim2.Instance-CNT即可。这个计数值会自动根据A、B相的相位关系进行增减并且硬件自动处理了防抖和四倍频。你甚至可以通过设置定时器的自动重装载值ARR来模拟一个圆形编码器计数值到达边界后会自动翻转。HAL_TIM_Encoder_Start(htim2, TIM_CHANNEL_ALL); // 启动编码器接口 ... int32_t hw_encoder_val (int32_t)(__HAL_TIM_GET_COUNTER(htim2)); // 获取硬件计数值 // 如果需要清零使用 __HAL_TIM_SET_COUNTER(htim2, 0);硬件方案几乎不需要CPU干预计数值精准无误是产品级项目的首选。5.2 高转速下的处理与频率考量我们上面讲的软件轮询法1ms采样有其速度上限。1ms采样一次意味着理论最高能检测到500Hz的状态变化因为一个完整步进的4个状态每个状态至少要被采样到一次。对于机械编码器这通常足够了人手快速旋转也很难超过每秒几十转。但对于高速电机上的光电编码器则远远不够。对策提高采样频率将轮询函数放到更高频率的定时中断中如100us。使用输入捕获将A、B相连接到定时器的输入捕获通道利用定时器测量脉冲间隔可以计算出转速但方向判断稍复杂。终极方案使用硬件正交编码器接口。这是为高速场景设计的其计数频率可以高达定时器时钟频率的1/4对于72MHz的时钟理论计数频率可达18MHz足以应对绝大多数高速场合。5.3 消抖算法的权衡软件滤波的代价我们采用了状态机定期采样的方式消抖这是一种“时间窗”滤波。它的代价是引入了检测延迟最大为一个采样周期即1ms。对于人机交互来说这个延迟完全无法感知。但对于需要极高实时性的控制回路可能需要权衡。另一种更复杂的软件消抖方法是“积分法”在跳变沿中断中启动一个短定时器如2ms定时器到期后再采样引脚电平如果电平稳定且符合预期则确认是一次有效跳变。这种方法响应更快但需要为每个引脚配备一个定时器资源实现也更复杂。实操心得对于绝大多数STM32的嵌入式UI应用1ms状态机轮询法在简单性、可靠性和资源消耗上取得了最佳平衡。不要过早优化先用起来遇到性能瓶颈再考虑硬件方案或更复杂的算法。6. 调试技巧与常见问题排查调试编码器时逻辑分析仪或示波器是神器。没有的话串口打印也是很好的帮手。问题一计数值偶尔反向或乱跳。排查首先用printf在Encoder_Update函数里打印出每次采样到的current_state和计算出的dir。观察在静止时current_state是否稳定。如果不稳定说明硬件消抖不足尝试加大并联的电容如增加到1uF或者在软件上增加“状态必须稳定连续N个周期才确认”的判断。检查状态转移表state_transition_table是否定义正确。对照编码器的真值表反复核对。问题二旋转很慢时计数正常快速旋转时丢步。排查采样频率不足。1ms采样一次理论最大跟踪速度是每秒250个完整步进假设四倍频。如果你旋转编码器感觉它“跟不上”尝试将定时器中断周期改为500us2kHz采样率。检查Encoder_Update函数是否放在了高优先级的中断里并且执行时间过长确保这个函数本身执行效率很高避免在中断内进行浮点运算或复杂的函数调用。问题三编码器感觉“不跟手”有迟滞感。排查这是应用层处理的问题不是底层驱动问题。检查你的主循环中读取编码器值的频率是否太低。如果你每100ms才去处理一次编码器变化那用户当然会觉得反应慢。确保主循环周期在20-50ms以内并对编码器值进行“差值”处理而不是处理绝对值。问题四使用硬件编码器模式但计数值不变化。排查检查CubeMX中定时器是否确实配置为Encoder Mode并且两个通道的GPIO模式是否正确应为浮空输入或上拉输入与编码器输出类型匹配。检查是否调用了HAL_TIM_Encoder_Start()启动定时器。检查编码器的A、B相是否接反了。接反会导致方向相反但至少会计数。如果不计数用万用表测量引脚在旋转时是否有电平变化确认硬件连接正常。检查定时器的时钟是否使能。有些低功耗模式下定时器时钟可能被关闭。一个实用的调试表格现象可能原因排查步骤数值乱跳方向随机机械抖动严重1. 并联104电容到地。2. 软件增加状态稳定判断。快速旋转时丢步软件采样率过低1. 提高Encoder_Update调用频率。2. 考虑改用硬件编码器模式。只有一个方向有效状态转移表错误或引脚接反1. 打印状态序列检查转移逻辑。2. 交换A、B相线序测试。完全无反应硬件连接问题或供电错误1. 用万用表测VCC、GND电压。2. 测A、B相对GND电压旋转时观察变化。3. 检查代码中GPIO引脚定义是否正确。硬件模式不计数定时器未启动或模式配置错误1. 确认HAL_TIM_Encoder_Start被调用。2. 在CubeMX中复查定时器配置。最后关于编码器的选择如果你的项目是全新的我建议优先选择“光电式”增量编码器它内部没有机械触点依靠光栅和光电传感器从根本上消除了抖动问题寿命极长当然价格也稍高。而“机械式”的优点是成本极低在消费类电子产品中无处不在通过合理的软硬件消抖完全能满足要求。理解并解决了机械编码器的问题你再使用其他类型的编码器都会觉得轻而易举。