ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 EC11旋转编码器驱动:硬件计数替代外部中断的稳定方案

STM32 EC11旋转编码器驱动:硬件计数替代外部中断的稳定方案

简介:STM32F407 EC11旋转编码器驱动函数,面向使用ARM Cortex-M4内核微控制器进行运动控制的嵌入式开发者,可广泛应用在工业控制、消费电子与物联网终端等场景。驱动代码围绕EC11编码器的A/B双相位脉冲检测、Z相零点定位及旋转方向判断展开,涵盖GPIO输入配置、外部中断响应、脉冲计数、速度计算与信号异常处理等完整逻辑,解决角度测量与速度反馈的实际工程需求,有助于开发者理解编码器状态机与脉冲时序机制。压缩包为rar格式,共两个文件,包含一个C源文件与一个头文件,包体仅3KB,结构紧凑,便于直接移植或按项目需求二次开发。头文件对外提供初始化、位置读取、速度获取等应用程序接口,源文件则实现定时器与中断处理,代码风格简洁易读。已有5275人学习下载,对于需要在STM32F407平台上实现精确旋转编码控制的开发者而言,是一份轻量且非常实用的参考代码。

1. EC11旋转编码器驱动:为什么硬件计数比外部中断稳得多

写嵌入式界面时最头疼的输入设备就是旋钮。电位器会漂,按键阵列占引脚,而EC11旋转编码器用一个旋钮同时搞定调节和按压确认,已经成为仪表、音响、菜单设置里的标配。这套基于STM32F407的EC11驱动函数,核心思路是让定时器硬件自动解码A/B两相的正交脉冲,CPU只负责读计数器,转快转慢都不丢步,也不占中断资源。整个驱动包含初始化、计数读取、按键消抖、速度累计四个模块,接好线就能跑,改两个参数就能适配到其他定时器或引脚。如果你正被乱跳的数值、反向的旋转、快速转动丢步折磨,这篇拆解就是为你准备的。

2. 硬件原理与定时器配置:正交解码、Filter滤波和上拉电阻的取舍

2.1 EC11的A/B相与正交信号

EC11内部是两个机械触点,旋转时A相和B相会输出相位差90度的方波。正转时A相超前B相90度,反转时B相超前A相90度。如果盯着电平组合看,正转的状态序列是00→01→11→10,反转是00→10→11→01。这四种状态合起来是一个完整脉冲周期,每个状态跳变都是一个有效边沿。

如果把这四个边沿全部交给GPIO外部中断处理,旋钮一转快CPU就被中断淹没。我见过一个项目用外部中断读EC11,慢速旋转还好,一加快就进入中断风暴,主循环直接卡死。定时器的编码器模式解决的正是这个问题:硬件逻辑自动识别这些状态跳变,每跳变一次计数器自动加1或减1,CPU完全不用参与。这就是“硬件计数比外部中断稳”的根本原因。

2.2 STM32F407定时器编码器模式配置

STM32F407的TIM1到TIM14基本都支持编码器模式。编码器模式分三种:只捕获TI1边沿、只捕获TI2边沿、同时捕获TI1和TI2双边沿。第三种也就是TI12模式,一个完整脉冲周期计数器会变化4次,分辨率最高,日常使用优先选它。

我用的是TIM4,PB6映射TIM4_CH1,PB7映射TIM4_CH2,对大多数开发板来说这两个引脚空闲且好接线。初始化代码基于STM32 HAL库编写:

void EC11_Init(TIM_HandleTypeDef *htim) { GPIO_InitTypeDef gpio = {0}; TIM_Encoder_InitTypeDef encoder = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_TIM4_CLK_ENABLE(); gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_VERY_HIGH; gpio.Alternate = GPIO_AF2_TIM4; HAL_GPIO_Init(GPIOB, &gpio); htim->Instance = TIM4; htim->Init.Period = 0xFFFF; htim->Init.Prescaler = 0; htim->Init.CounterMode = TIM_COUNTERMODE_UP; htim->Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(htim); encoder.EncoderMode = TIM_ENCODERMODE_TI12; encoder.IC1Polarity = TIM_ICPOLARITY_RISING; encoder.IC2Polarity = TIM_ICPOLARITY_RISING; encoder.IC1Selection = TIM_ICSELECTION_DIRECTTI; encoder.IC2Selection = TIM_ICSELECTION_DIRECTTI; encoder.IC1Prescaler = TIM_ICPSC_DIV1; encoder.IC2Prescaler = TIM_ICPSC_DIV1; encoder.IC1Filter = 0x0F; encoder.IC2Filter = 0x0F; HAL_TIM_Encoder_Init(htim, &encoder); HAL_TIM_Encoder_Start(htim, TIM_CHANNEL_ALL); __HAL_TIM_SET_COUNTER(htim, 0); }

IC1Filter和IC2Filter填0x0F不是随手写的。EC11是机械触点,旋钮转动时触点回弹会在示波器上出现一串毛刺。这个Filter是定时器输入捕获的数字滤波器,采样频率等于定时器时钟分频后的值,滤波窗口宽度由Filter参数决定。0x0F的滤波窗口能吞掉绝大多数机械抖动,同时不至于慢到丢脉冲。我测试过Filter填0的时候,旋钮一转数值就乱飘;填0x0F之后,即使快速猛转几十格数值都很干净。

Period填0xFFFF的关键原因在于,编码器模式下计数器在0到65535之间加减。代码里把上一次读到的计数值和当前值做差,就能得到这一段时间内的净变化步数,差值用有符号int16_t承接,正转得到正数,反转得到负数,不需要额外处理溢出。0xFFFF足够大,正常人手指一秒转几十圈产生的步数也远达不到溢出阈值。

CounterMode这个字段在编码器模式下是失效的。编码器模式由硬件根据A/B相位自动决定向上还是向下计数,跟TIM_COUNTERMODE_UP或DOWN配置无关。有同学在这里纠结配置成UP还是DOWN,实际测试发现都不影响结果,就是这个原因。

2.3 GPIO复用与外部电路

选引脚不能只看空闲,还得查数据手册的AF复用表。PB6的AF2是TIM4_CH1,PB7的AF2是TIM4_CH2,AF编号写错了初始化不会报错,但引脚功能就是不出来。TIM3、TIM5同样支持编码器模式,需要换引脚时优先看这几个定时器的通道映射。

硬件上我一般会在A相和B相各接一个10kΩ电阻上拉到3.3V。虽然GPIO内部上拉也能工作,但内部上拉阻值偏大,对长线和电磁干扰的抵抗能力弱。外加上拉后信号电平更陡峭,抗干扰能力明显提升。再各并联一个100pF电容到地,组成低通滤波,能吸收高频噪声。电容不能太大,超过1nF会把边沿拉缓,反而导致多次计数。

公共端C的接法要特别注意,它不是接上拉,而是直接接地。我见过有人把公共端接到3.3V,结果旋钮一动计数器乱跳,查了半天才意识到EC11的公共端方向接反了。按键那一脚同样需要上拉,GPIO内部上拉在按键场景下完全够用,不必额外加外部电阻。

3. 核心驱动代码拆解:从初始化到速度计算的完整链路

3.1 计数读取与方向判断

初始化完成后,主循环里第一步永远是读计数器。HAL库提供的宏__HAL_TIM_GET_COUNTER本质是读寄存器TIMx->CNT的值,底层就是一个16位内存读取,耗时极短。

typedef struct { TIM_HandleTypeDef *htim; int16_t lastCount; uint32_t lastTick; int16_t speed; int16_t accumulator; } EC11_Handle_t; int16_t EC11_GetStep(EC11_Handle_t *ec11) { int16_t current = __HAL_TIM_GET_COUNTER(ec11->htim); int16_t delta = current - ec11->lastCount; ec11->lastCount = current; return delta; }

读取逻辑只有三行,但有两个容易翻车的细节。第一,current和delta必须用int16_t,因为计数器的值范围是-32768到32767。如果用uint16_t承接,反转时的负步数会被截断成一个大正数,方向判断直接废掉。第二,这个函数在任何时刻调用都安全,因为它只做差值运算,不关心计数器的绝对值在什么位置。

调用频率取决于你的需求。我习惯在主循环里每10ms调用一次,这样每次拿到的delta是10ms窗口内的净步数。如果需要更细粒度的事件响应,可以塞进定时器中断里,每1ms读一次。两种方式读到的delta含义不同,但函数本身不需要改动。

3.2 带时间戳的速度计算

只拿到步数还不够。做音量渐变或菜单加速滚动时,还需要知道旋钮转得多快。我的做法是让每次读取记录一个毫秒时间戳,算出单位时间内的步数变化:

int16_t EC11_ReadSpeed(EC11_Handle_t *ec11) { int16_t current = __HAL_TIM_GET_COUNTER(ec11->htim); int16_t delta = current - ec11->lastCount; uint32_t now = HAL_GetTick(); uint32_t dt = now - ec11->lastTick; ec11->lastCount = current; ec11->lastTick = now; if (dt > 0) { ec11->speed = (int16_t)((int32_t)delta * 1000 / dt); } return ec11->speed; }

speed的单位是“步每秒”。delta乘以1000再除以dt毫秒,就把时间单位从毫秒换算成秒。这里dt用uint32_t承接,防止时间差值被8位或16位变量截断。HAL_GetTick()返回的是系统上电以来的毫秒数,在32位平台上大约49天才会归零一次,正常使用的dt值在几毫秒到几十毫秒之间,完全不会溢出。

速度值的用途很灵活。转速大于每秒100步时,菜单列表每格跳3项;转速小于每秒30步时,一格一项精细调节。这种自适应逻辑手感远好于固定步长,后面二次开发章节会给出具体实现。

3.3 按键消抖与长按短按

EC11除了旋转还有按压功能,按下时按键脚会从高电平变成低电平。机械按键不处理消抖的话,一次按压可能被识别成两三次,所以我把消抖写成了积分式的状态机:

uint8_t EC11_KeyScan(EC11_Handle_t *ec11, uint8_t *keyEvent) { uint8_t level = HAL_GPIO_ReadPin(EC11_KEY_PORT, EC11_KEY_PIN); static uint8_t keyState = KEY_RELEASE; static uint8_t debounceCnt = 0; static uint32_t pressTick = 0; if (level == GPIO_PIN_RESET) { if (debounceCnt < 5) { debounceCnt++; } else if (keyState == KEY_RELEASE) { keyState = KEY_PRESS; pressTick = HAL_GetTick(); *keyEvent = KEY_EVENT_SHORT_CLICK; return 1; } } else { if (debounceCnt > 0) { debounceCnt--; } else if (keyState == KEY_PRESS) { keyState = KEY_RELEASE; if (HAL_GetTick() - pressTick > 1000) { *keyEvent = KEY_EVENT_LONG_CLICK; } return 1; } } return 0; }

这个消抖逻辑的精髓是“连续5次确认”而不是“延时等待”。按下和抬起各需要连续5次采样一致才切换状态,任何一次读到的抖动电平都会让计数值回退。这种写法不阻塞主循环,每10ms调用一次就能稳定消抖。长按判定放在松开那一刻处理,HAL_GetTick()减去pressTick大于1000ms就标记为长按事件。短按和长按不会同时触发,按下时发的短按事件只是预判,真正区分在松开时才落地。

3.4 累计器与位置记忆

在需要记住位置的场合,比如频率设置、音量等级、亮度参数,编码器需要一个累计值。这个累计值保存在结构体成员accumulator中,每次拿到step后累加即可。断电前需要保存位置时,调用EC11_SetPosition把累计值回写到计数器:

void EC11_SetPosition(EC11_Handle_t *ec11, int16_t position) { __HAL_TIM_SET_COUNTER(ec11->htim, position); ec11->lastCount = position; ec11->accumulator = position; }

这个函数的本质相当于把旋钮的当前位置“拨”到指定刻度。下次调用EC11_GetStep时,返回的步数就是从新位置的相对增量。这对于实现“开机恢复上次参数”非常有用:掉电前把accumulator存进Flash,上电后调用EC11_SetPosition恢复,用户看到的就是断电前的数值。

accumulator的符号问题要提前想清楚。如果参数区间是0到100,那么每次获得step后要做边界钳位,否则用户转多了会跑到负数或超过最大值。钳位逻辑我一般放在调用侧,而不是驱动内部,因为不同场景对边界处理的需求不一样。

4. 运行验证:接线、CubeMX工程配置和串口观察

4.1 硬件接线

拿到驱动后第一步是接线。以下是我在STM32F407开发板上验证通过的接线表:

信号连接到说明
EC11 A相PB6TIM4_CH1
EC11 B相PB7TIM4_CH2
EC11 C公共端GND不是接3.3V
EC11 按键脚PB0内部上拉
A相上拉电阻10kΩ到3.3V推荐
B相上拉电阻10kΩ到3.3V推荐
A相对地电容100pF可选
B相对地电容100pF可选

接好线后先不要急于烧代码,用万用表测一下A相和B相对GND的电压。旋钮静止时应该是高电平,旋转时两个引脚会交替出现高低电平。如果万用表显示一直是0V,多半是公共端接错或者上拉电阻没有生效。

4.2 工程配置

用STM32CubeMX生成基础工程时,TIM4的配置项按下面的参数设置:

  • Combined Channels选择Encoder Mode
  • Encoder Mode选择TI1 and TI2
  • Input Filter分别填15(对应0x0F)
  • Prescaler保持0
  • Period填65535

CubeMX生成的MX_TIM4_Init函数里,编码器相关的初始化代码会被自动填充。生成工程后把驱动文件加入编译,然后在main函数中调用:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM4_Init(); TIM_HandleTypeDef htim4; EC11_Handle_t ec11; ec11.htim = &htim4; EC11_Init(&htim4); uint8_t keyEvent = 0; while (1) { int16_t step = EC11_GetStep(&ec11); if (step != 0) { printf("step=%d\r\n", step); } if (EC11_KeyScan(&ec11, &keyEvent)) { printf("key=%d\r\n", keyEvent); } HAL_Delay(10); } }

这里EC11_Init内部的GPIO初始化会和CubeMX生成的MX_GPIO_Init产生重复初始化,因为MX_GPIO_Init已经把PB6和PB7初始化为高阻输入了。我的建议是:CubeMX里把PB6和PB7保留为默认输入模式,实际复用配置交给EC11_Init去做,避免两段配置互相覆盖。

4.3 串口观察验证

把USART1配置成115200波特率,接上USB转串口模块,打开串口助手,然后手动旋转旋钮。预期输出如下:

  • 正转一格,输出step=4
  • 反转一格,输出step=-4
  • 按键按下松开,输出key=1(短按)
  • 按住超过1秒松开,输出key=2(长按)

如果方向反了,说明A/B接反,把两根线对调即可。如果输出的是step=1而不是4,说明EncoderMode没有被配置成TI12模式,检查CubeMX里Encoder Mode的选择项。如果数值乱跳,先看Filter是否是0x0F,再看A/B信号线有没有接上拉电阻。这套验证流程能一次性确认硬件接线、定时器配置、驱动代码三个环节,是排查问题的起点。

5. 避坑指南:五个最容易让EC11翻车的信号完整性问题

5.1 旋钮静止时数值也在跳

现象:手没碰旋钮,串口每隔几百毫秒冒出一个1或-1。

原因:A/B信号受电磁干扰,或者引脚悬空导致电平不稳定。EC11内部触点在不旋转时处于悬浮状态,如果GPIO没有上拉,附近电机、继电器、电源模块产生的电磁干扰会让电平随机翻转。

解决:GPIO配置里把Pull从GPIO_NOPULL改成GPIO_PULLUP,同时在外部加10kΩ上拉电阻。杜邦线超过20cm就相当于天线,信号会被环境噪声调制,尽量缩短A/B到MCU的走线距离。我踩过这个坑,最后是把两根杜邦线换成双绞线才彻底解决。

5.2 快速旋转时丢步严重

现象:慢速转一格输出4步,快速转一圈只输出十几个数,明显少于实际步数。

原因:Filter滤波窗口太大。0x0F的滤波窗口大约能覆盖几十微秒的抖动时间,转速越快,相邻脉冲间隔越短,脉冲落在滤波窗口内就被吞掉了。

解决:把Filter从0x0F降到0x07或0x00,同时把读取周期从10ms降到5ms。经验数据是:Filter=0x0F时可靠识别上限大约每秒300到400格,超过这个速度就会丢步。如果应用场景确定不会高速旋转,保留大滤波值没有坏处;如果可能高速猛转,滤波值越小越好。

5.3 方向反了

现象:正转输出负数,反转输出正数。

原因:A/B两相接反。

解决:硬件上交换A/B接线,或者在初始化代码里把IC1Polarity和IC2Polarity都改成TIM_ICPOLARITY_FALLING。两个极性必须同时改,只改其中一个会导致计数逻辑完全混乱。改极性相当于在硬件层面做了一次“反相”,实际效果和交换A/B线一致。

5.4 一格偶尔输出两次

现象:轻轻转一格,大部分时候输出4次,偶尔输出8次或12次。

原因:机械触点弹跳。EC11的簧片在转动到位时会发生多次回弹,Filter已经滤掉了高频部分,但某些触点的回弹速度慢,滤波窗口覆盖不住。

解决:在EC11_GetStep外面做一层软件滤波——记录上次方向和本次方向,如果两次同方向之间间隔小于2ms,就认为是抖动并丢弃。这个“二次消抖”作为补救手段,能有效解决硬件滤波覆盖不到的慢速回弹。

5.5 按键一直处于按下状态

现象:按键代码逻辑没问题,但读到的电平永远是低电平。

原因:按键引脚被复用功能占用,或者该引脚在开发板上已经接了其他外部设备。部分开发板的PB0默认连接了LED、触摸芯片或调试接口,初始化成GPIO输入后会互相拉扯电平。

解决:换个空闲引脚,比如PE4或PD12,重新配置GPIO和端口宏。换引脚后同步修改EC11_KEY_PORT和EC11_KEY_PIN两个宏定义即可,驱动逻辑不用动。

6. 二次开发:参数映射、菜单加速和断电记忆的实现思路

拿到这套驱动后,真正让它发挥价值的是上层应用逻辑。最常见的改造方向有三个。

参数映射的思路是维护一个int16_t位置值,把步数变化映射到0到100这样的目标区间。我一般写一个通用的map函数,输入累计位置值,输出目标参数值,边界用钳位处理防止越界。映射时注意乘法溢出,在32位平台上int16_t会提升到int32_t再计算,不会出问题。

菜单加速滚动的实现依赖前面EC11_ReadSpeed返回的速度值:

int16_t step = EC11_GetStep(&ec11); int16_t speed = EC11_ReadSpeed(&ec11); if (speed > 100) { menuIndex += step * 3; } else { menuIndex += step; } menuIndex = (menuIndex + MENU_ITEM_COUNT) % MENU_ITEM_COUNT;

转速小于每秒100步时一格一项,大于时一格三项。菜单项多也不会让人烦躁,手感接近消费级设备。

断电记忆的做法是把accumulator写进内部Flash,但要注意Flash擦写寿命。我的习惯是只在位置变化超过阈值时才写一次,比如改动了5步以上才触发写入,正常使用时一天也写不了几次,能跑很多年。

从那以后,我每次拿到新的旋钮硬件,都强制走一遍“慢速转、快速转、轻点按键、长按按键”四个动作的验证流程,一步不落。这套驱动函数本身不复杂,但信号完整性处理和上层调用习惯才是决定手感的关键,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进