首页 / 资讯中心 / 文章详情

STM32与EC11编码器驱动:从正交信号到状态机判向与消抖

STM32与EC11编码器驱动:从正交信号到状态机判向与消抖 ★ FEATURED ARTICLE
简介EC11编码器与STM32单片机结合的程序源码工程面向嵌入式初学者、电子竞赛参与者以及需要测量旋转角度、速度的项目开发者解决编码器脉冲采集、方向判断、数据处理与串口上传等典型应用问题。压缩包共120个文件以30个C源码和32个头文件为主体配合STM32F10x标准外设库、Keil工程配置uvprojx、编译生成的hex/axf/map及清理脚本bat等整体仅2.23MB结构清晰便于阅读和复用。已有4152人学习下载该工程在编码器信号处理教学与开发中具备良好示范性。源码涵盖了定时器编码器接口模式、A/B双通道相位差识别、脉冲计数与方向判断、中断服务函数、角度/速度计算以及USART串口向上位机发送数据的完整实现调试时可结合ST-Link、示波器与上位机解析程序验证功能。整套代码逻辑清楚、注释较完整能帮助读者快速掌握增量式编码器原理、STM32定时器与中断的工程化应用并迁移至其他嵌入式项目。1. EC11编码器配STM32这个组合为什么值得亲手写源码做旋钮类交互的设备想办法绕开EC11几乎不可能。音量、菜单、频率微调、工业参数设置一个EC11加一颗STM32就能稳稳定定干完。很多人第一反应是去网上找现成源码下载下来编译一看能转装上自己的板子就开始乱跳转一格跳俩数、方向时正时反、按下去还误触发旋转。问题不在EC11而在驱动代码的判向和消抖没吃透。这篇分享我自己的一个观点驱动EC11这件事没必要纠结“抄哪份源码”把状态机判向、采样消抖和硬件计数器这四件事写明白你自己写的代码比大多数网上流传的版本都可靠。文中所有代码都是常见做法基于HAL库普通F103级别的型号就能跑不一定适合所有衍生型号但思路通用。2. 看懂EC11的输出时序正交信号与判向逻辑2.1 EC11的A/B相时序与机械抖动EC11内部是金属刷在铜箔上滑动输出两路方波A和B相位差正好90度。正转时A领先B四分之一周期反转时B领先A四分之一周期。一个完整的四步状态变化对应编码器的一个格detent也就是你手指感觉到“咔哒”一下。这段时序关系是所有驱动代码的基础不理解它后面查表判向、定时器编码器模式全都无从下手。机械结构决定了抖动避不开。铜刷接触的瞬间会弹跳一个边沿往往要弹几次才稳定弹跳持续时间在几百微秒到几毫秒之间。快速旋转时弹跳更严重因为刷子滑过铜箔的速度变快接触时间变短毛刺密度反而升高。这就是为什么网上很多源码在低速时表现正常一快速旋转就“跳数像疯了一样”。抖动不是质量差的问题是机械编码器的物理特性软件必须主动消化它。2.2 状态机判向从电平组合还原旋转方向把A、B看作两位二进制数一组电平组合比如“A0B1”。旋转时这个组合按特定顺序循环00→01→11→10→00这是正转倒过来00→10→11→01→00是反转。注意看合法跳变永远只改变一位。这意味着只要记住上一次的完整电平组合再结合当前组合就能判断方向而不需要关心旋转速度。状态机判向的核心思想就是记录状态迁移。实现上最常用的写法是查表把“上一状态”和“当前状态”拼成4位索引查一张16项的表表里存的是这次迁移对应的方向值。这张表本质上就是把上面说的合法迁移编码成数据非法迁移比如两位同时变或者跳过了合法序列在表里对应0直接忽略。状态机的好处是对毛刺天然有抑制能力抖动让你在某个边界来回弹状态表会给一次1一次-1最终抵消不会累积错误。2.3 按键通道的独立处理EC11除了A/B两路还有一个独立的按键输出。按下中轴时按键引脚和公共端接通。这个开关和旋转刷共用机械外壳但电气上完全独立。很多驱动把旋转判向逻辑套用到按键上结果按下和松开都被当成旋转处理出现“转一格变两格”的离奇现象。按键通道必须单独走消抖逻辑。机械按键按下和弹起都有抖动通常需要10ms到20ms的延时确认比旋转通道的消抖时间要大一个量级。旋转通道状态机用微秒级的边沿捕获而按键通道用毫秒级的电平确认两者处理路径从一开始就应该分开写不要混在一个函数里。后面第5章的按键误触发案例基本都是通道没分开导致的。3. 从零搭驱动GPIO配置与定时轮询方案3.1 最小硬件连接上拉电阻与去抖电容EC11的A、B和按键引脚在未动作时是悬空或弱上拉状态必须依赖外部上拉电阻拉到3.3V。开发板上的编码器模块通常会自带上拉但裸编码器没有需要自己在A、B、按键引脚各接一个10kΩ电阻到3.3V。公共端接地这是最基础也最不会出错的接法。关于去抖电容A/B到地各并一个100nF可以显著减少高频毛刺。但电容会拉缓边沿导致软件读到的状态变化滞后对高速旋转时的采样精度有影响。我一般会先不加电容把软件消抖写好之后再用示波器看边沿是否干净。如果还有问题再在软件里增加采样次数而不是一味加大电容。按键引脚则建议并联一个100nF因为按键弹跳比旋转刷更剧烈而且按键不追求高速响应电容副作用可以忽略。3.2 轮询扫描代码1ms采一次状态表累加轮询方案适合对响应速度要求不高、MCU本身不忙的场景。用系统时钟或定时器产生1ms中断在中断里调用扫描函数。核心代码如下先看实现再解释。// ec11.h typedef struct { int16_t counter; uint8_t last_state; uint8_t last_confirm; } ec11_t; void ec11_init(ec11_t *enc); void ec11_scan(ec11_t *enc);// ec11.c #include ec11.h #include main.h #define EC11_PORT GPIOA #define EC11_A_PIN GPIO_PIN_6 #define EC11_B_PIN GPIO_PIN_7 // 状态表索引 (last_state 2) | current_state // 合法迁移返回 1 或 -1非法迁移返回 0 static const int8_t ec11_state_table[16] { 0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0 }; void ec11_init(ec11_t *enc) { enc-counter 0; enc-last_state 0; enc-last_confirm 0; } void ec11_scan(ec11_t *enc) { uint8_t a HAL_GPIO_ReadPin(EC11_PORT, EC11_A_PIN); uint8_t b HAL_GPIO_ReadPin(EC11_PORT, EC11_B_PIN); uint8_t current_state (a 1) | b; // 连续两次读到相同状态认为电平稳定 if (current_state enc-last_confirm) { uint8_t idx (enc-last_state 2) | current_state; enc-counter ec11_state_table[idx]; enc-last_state current_state; } else { // 第一次读到新电平可能是抖动先不更新 enc-last_confirm current_state; } }代码逻辑不复杂但关键点不少。current_state把A、B两路拼成一个2位数值last_confirm是上一次从GPIO读到的原始值用来做最简单的电平确认。连续两次读到的电平一致才认为这个状态稳定然后查表累加。idx由上一次确认稳定的状态和当前状态拼成4位索引取值范围0到15正好对应表格16项。合法迁移按表返回1或-1非法迁移返回0相当于把抖动产生的非法跳变直接丢掉。参数上最值得调的是扫描周期。1ms是经验值既能覆盖绝大多数旋转速度又不会给MCU带来明显负担。如果旋钮允许极高的旋转速率可以把周期缩到500μs但要注意中断占用。反过来如果设备是电池供电且旋钮用于低频调整10ms扫描也够用代价是快速旋转时明显丢步。counter用int16_t连续转65535步才溢出对一般交互足够如果要做无限旋转的编码器需要在外层对计数做有符号处理。3.3 采样周期、滤波深度与计数倍率的取舍轮询方案的三个参数互相牵制采样周期、滤波深度、计数倍率。采样周期决定你多久看一次电平滤波深度决定一个状态要被连续确认几次才接受计数倍率决定旋转一格最终累加多少。滤波深度和采样周期的乘积就是系统能容忍的最长抖动时间。比如1ms采样、连续2次确认意味着2ms以内的弹跳被自动过滤。超过这个时间的抖动会漏过去。机械编码器抖动一般不超过几毫秒所以这个配置够用。如果你把滤波深度改成4次系统更稳但每次状态更新延迟4ms快速旋转可能丢步。计数倍率在这里要特别说清楚。轮询方案按“状态迁移”计数一个完整周期也就是一格会经历4次状态变化表里会累加4次。所以转一格counter变化4。很多应用希望一格对应1这时在应用层除以4即可。不要试图在中断里只对“特定迁移方向”计数来凑1那样会丢失正交信号的信息方向判断反而变脆弱。倍率的取舍放在应用层驱动层保持最高分辨率这是更清晰的分层。4. 进阶外部中断与定时器编码器模式4.1 A相中断B相判向的轻量方案轮询方案在低功耗场景下有个问题为了捕捉旋转你得一直开着周期中断哪怕旋钮半天没人碰。外部中断方案可以只在有边沿时唤醒MCU。常见做法是只把A相接外部中断引脚B相当普通GPIO读方向。代码如下。void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin EC11_A_PIN) { // A相跳变说明旋转正在进行读B相判断方向 if (HAL_GPIO_ReadPin(EC11_PORT, EC11_B_PIN) GPIO_PIN_RESET) { encoder_counter; } else { encoder_counter--; } } }这段代码能工作但直接用在机械编码器上会翻车。它只在A相每个边沿触发一次中断B相是参考电平。因为每格有4个边沿所以一格计数变化4这个和轮询方案一致。然而机械抖动会让同一个边沿反复触发中断每次触发都按当前B相电平累加抖动期间B相如果也在弹跳方向就会乱。实际用这个方案必须叠加消抖。可以在中断里加一个简单的定时器延时等电平稳定后再读B相更通用的做法是中断里只置一个标志在主循环里延时几百微秒后重新读A/B做状态机判向。这样中断本身很轻消抖逻辑放在循环里思路和轮询方案归一了。轻量方案最大的价值是低功耗旋钮不动时MCU进睡眠A相边沿来唤醒。4.2 STM32定时器编码器模式硬件替你计数STM32的高级定时器和通用定时器自带编码器接口这是比外部中断优雅得多的做法。把A相接定时器的通道1B相接通道2配置编码器模式后硬件会在边沿自动增减计数寄存器完全不需要软件参与。代码更短可靠性更高。配置核心如下。TIM_HandleTypeDef htim3; htim3.Instance TIM3; htim3.Init.Period 0xFFFF; htim3.Init.Prescaler 0; htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(htim3); TIM_Encoder_InitTypeDef enc {0}; enc.EncoderMode TIM_ENCODERMODE_TI12; // 两相都计数4倍频 enc.IC1Polarity TIM_ICPOLARITY_RISING; enc.IC2Polarity TIM_ICPOLARITY_RISING; enc.IC1Selection TIM_ICSELECTION_DIRECTTI; enc.IC2Selection TIM_ICSELECTION_DIRECTTI; enc.IC1Prescaler TIM_ICPSC_DIV1; enc.IC2Prescaler TIM_ICPSC_DIV1; enc.IC1Filter 0x0F; // 硬件滤波取中等强度 enc.IC2Filter 0x0F; HAL_TIM_Encoder_Init(htim3, enc); HAL_TIM_Encoder_Start(htim3, TIM_CHANNEL_ALL);读计数就是一行代码int16_t encoder_value (int16_t)__HAL_TIM_GET_COUNTER(htim3);这里有两个参数要重点理解。EncoderMode用TIM_ENCODERMODE_TI12表示A、B两个通道的边沿都参与计数一格会产生4次计数即4倍频。如果用TIM_ENCODERMODE_TI1只在A相边沿计数一格2次分辨率减半。机械编码器追求手感细腻推荐4倍频。IC1Filter是输入滤波硬件会在采样边沿前做多周期确认0x0F是比较强的滤波级别能挡住大部分毛刺而不明显影响响应。这两个参数是硬件方案和软件方案差异最大的地方软件要靠状态机重复确认硬件直接采样滤波开销完全不在一个量级。4.3 两种方案怎么选中断频率、功耗与代码复杂度定时器编码器模式看起来完胜但它不是所有场景的最优解。它占用一个定时器A/B引脚必须绑定到特定通道PCB布线自由度受限。如果项目里定时器资源紧张或者引脚分配已经定死外部中断方案反而更灵活。功耗上两者差别明显。定时器编码器模式启动后计数工作在后台MCU可以睡觉事件由计数溢出或更新中断唤醒。外部中断方案的好处是可以在边沿唤醒但要注意边沿频率快速旋转时每秒可能产生几百次中断频繁唤醒会让低功耗名存实亡。我一般这样选如果设备以电池供电且旋钮长时间处于静止状态哪怕静止期间也需要保留旋钮响应能力选外部中断加睡眠如果设备插电运行旋钮操作频繁选定时器编码器模式软件更省心。代码维护性也要考虑。定时器编码器模式一旦跑起来软件只需要读计数器几乎不会出逻辑错误外部中断方案所有判向和消抖都堆在回调里出了问题不好排查。但如果你的项目需要“一格一个动作”而不是“一格四个计数”定时器模式的计数溢出中断反而要多一层除法处理外部中断方案可以在中断里直接判断是否到一格。没有绝对最优只有适合当前功耗和引脚预算的方案。5. 避坑EC11STM32最常见的5个翻车现场5.1 现象旋转一格跳多格转一个“咔哒”计数器跳了3、4个甚至更多。这不是EC11坏了十有八九是驱动把一次机械抖动的多次弹跳都当成有效迁移。特别是用外部中断方案时一个边沿触发多次中断每次中断读到不同的B相电平计数就乱了。解决方式回到状态机思路中断里不直接累加只记录发生跳变然后在主循环或定时器里延时几百微秒后重新读A/B用第3章的16项状态表判向。抖动产生的非法迁移在表里对应0多跳问题基本就消失了。5.2 现象方向偶尔反正转时大多数时候计数增加偶尔减少一两格或者同一个旋钮在不同批次板子上方向不一致。这个问题的根源几乎都是硬件接线A/B两相接反了。EC11的A、B引脚定义和丝印在不同型号上并不统一A相超前还是B相超前每批货都可能不一样。解决方式驱动里做成可以在初始化时配置默认方向调试时用一个dir变量正转计数异常就把它翻过来。不要改动硬件连线软件里把状态表换成反向表或对计数取负都行。我习惯在初始化结构体里加一个direction成员这样批量生产换料时不用改板子。5.3 现象按键误触发旋转按下旋钮没转计数器却动了。原因是按键弹跳产生的电平变化被旋转通道的状态机当成了A/B迁移。尤其是按键引脚和A/B引脚挨得很近按下瞬间内部刷子移位可能让A或B出现一次短暂的低电平脉冲。解决方式按键通道和旋转通道彻底隔离。按键单独配置一个GPIO用20ms消抖期间连续读到稳定电平才确认按下。旋转状态机的采样只读取A/B两相完全不读按键引脚。如果误触发仍然存在检查PCB上按键引脚和A/B之间有没有串扰必要时在按键引脚加一个100nF电容到地。5.4 现象低功耗模式功耗降不下去产品进睡眠后电流还是偏高仔细查发现是旋钮引脚在外部中断模式下反复触发。机械编码器静止时金属刷也会因为轻微震动产生毛刺外部中断被唤醒MCU不停进出中断服务函数根本没机会真正睡下去。解决方式有两种可行路径。一是干脆不用外部中断改用定时器周期性轮询旋钮静止时只有低频定时器在跑平均电流远低于高频中断唤醒二是保留外部中断但给A/B各加一个RC滤波网络把毛刺削平减少无谓唤醒。后者需要改硬件量产前建议验证RC参数对边沿速度的影响。5.5 现象快速旋转时系统卡死猛转旋钮界面卡顿甚至看门狗复位。原因是中断风暴外部中断在快速旋转时每秒触发几百上千次中断服务函数里还有GPIO读取、状态判断、计数累加、可能还有I2C或UI刷新主循环被饿死。解决方式中断服务函数只保留最轻量的操作比如置一个标志位所有耗时的处理放到主循环更彻底的是换用定时器编码器模式让硬件计数器独立处理高频边沿软件定时去读一次计数器。我遇到过一个案例外部中断方案在每秒20格的转速下CPU占用率达到40%换成定时器编码器模式后降到几乎为0UI卡顿彻底消失。6. 让驱动更顺手状态机接口抽象与三件调试手段EC11驱动写稳只是第一步把它变成可以复用的模块更重要。我习惯把编码器封装成一个结构体初始化时传入引脚配置、方向和回调函数上层只用三个接口初始化和读值。这样同一个驱动可以复用到菜单旋钮、音量旋钮、坐标微调旋钮不用每个项目重写一遍。typedef struct { int16_t value; int8_t direction; void (*on_change)(int16_t value); } ec11_handle_t;on_change回调设计成只在数值变化时触发上层逻辑不用轮询。对交互类设备来说这个回调里直接更新音量、移动光标代码结构清晰也不会在中断里做重活。调试EC11我常用三件手段。第一串口打印把计数器的变化实时打出来对比手转的角度立刻能发现是跳多格还是反向。第二逻辑分析仪同时抓A/B两路波形肉眼看相位关系和毛刺密度比盲目调软件参数高效得多。第三写一个持续旋转测试的小函数让电机带动旋钮转几分钟统计计数误差这个能暴露间歇性问题。这三个手段里逻辑分析仪最值得投资因为它能直接区分问题是出在硬件电气特性还是软件逻辑。我自己在项目里吃过亏一开始纯靠串口打印排查方向问题调了两天才发现是硬件接线有了逻辑分析仪十几分钟就定位了。EC11驱动这件事说难不难说简单也有一堆细节。但只要把状态机判向、消抖和硬件计数器的边界想清楚你的代码会比很多网上流传的版本更可靠。把这些思路沉淀成自己的模块下次做旋钮项目半天就能搞定。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站