☰
STM32与EC11编码器驱动:从正交信号到状态机判向与消抖
2026/10/15 14:02:41 网站建设 项目流程

简介: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看作两位二进制数,一组电平组合比如“A=0,B=1”。旋转时这个组合按特定顺序循环: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相接定时器的通道1,B相接通道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. 避坑:EC11+STM32最常见的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%,换成定时器编码器模式后降到几乎为0,UI卡顿彻底消失。

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驱动这件事,说难不难,说简单也有一堆细节。但只要把状态机判向、消抖和硬件计数器的边界想清楚,你的代码会比很多网上流传的版本更可靠。把这些思路沉淀成自己的模块,下次做旋钮项目,半天就能搞定。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询