STM32定时器Encoder模式驱动EC11旋转编码器:从原理到工程实践
2026/9/24 1:24:50 网站建设 项目流程

做嵌入式这些年,凡是碰过旋钮、菜单、音量调节的,基本都在 EC11 这种机械旋转编码器上踩过几脚。以前我写驱动程序,第一反应就是开个定时器中断轮询两个 IO 口,判断正反转、消抖、累计脉冲,代码写下来少说四五十行,还得小心翼翼地调整采样周期。后来换用 STM32 定时器自带的 Encoder 模式,我才发现这东西根本不用软件去“猜”方向,硬件已经把所有活儿干完了,代码量直接砍半,CPU 占用也几乎降到了零。

这次就把我实际调 EC11 的过程、配置思路、代码模板和踩坑记录完整写出来。不管你是刚接触 STM32,还是想优化现有旋钮逻辑,这篇文章都值得你花十分钟看完。

1. EC11 编码器到底是什么?先弄懂它的“脾气”

EC11 是增量式机械旋转编码器,输出两路相位差 90° 的方波信号,通常标记为 A 相和 B 相。它本身不输出绝对位置,只输出“相对位置变化”,所以单片机这边需要自己去数脉冲、辨方向。

1.1 EC11 内部结构与输出信号

拆开一个 EC11,里面就是一圈金属触点和三个引脚:公共端 C(或者叫 COM/GND)、A 相输出、B 相输出。旋转的时候,内部触点会依次接通和断开,从而在 A、B 上产生高低电平变化。因为机械触点的接触顺序有先后,A、B 两个信号之间天然存在 90° 相位差。

这里有个特别重要的物理常识:机械编码器的信号不是干净的方波,旋转过程中触点会弹跳,产生大量毛刺。所以不管用轮询还是硬件编码器模式,信号质量直接决定后端的计数结果。后面我会专门讲消抖这件事。

A、B 两路信号一共有 4 种组合状态:00、01、11、10。正转的时候,状态按 00 → 01 → 11 → 10 → 00 循环;反转的时候,状态按 00 → 10 → 11 → 01 → 00 循环。这正是 STM32 编码器模式判断方向的底层依据。

1.2 方向判断与倍频问题:为什么转一格有多个脉冲

EC11 的规格书里一般会写“20 脉冲/圈”之类,指的是每转一圈 A 相或者 B 相上输出多少个完整脉冲。但注意,这是单相信号上的脉冲数。如果我们同时检测 A、B 两相的上升沿和下降沿,一个完整方波周期里能产生 4 个边沿跳变。

所以同样一个 EC11,在不同配置下转一圈得到的计数值完全不同:

检测方式一圈计数说明
只检测 A 相上升沿20最省资源,但无法在单相模式下判断方向(需额外 IO 读 B 相)
检测 A、B 两相上升沿402 倍频,两个脉冲判一次方向
检测 A、B 全部边沿804 倍频,分辨率最高,方向判断最准确

STM32 的定时器 Encoder 模式默认就是检测全部边沿,也就是 4 倍频。这意味着你的旋转分辨率直接翻了 4 倍,手感上会灵敏不少。很多网友抱怨“EC11 转一下发好几个脉冲”其实不是编码器坏了,而是因为没有考虑倍频,或者程序里又在 4 倍频基础上额外做了处理。

2. STM32 定时器 Encoder 模式是怎么“吃掉”这些脉冲的

STM32 的通用定时器(TIM2、TIM3、TIM4、TIM5 等)内部集成了一个编码器接口,可以直接把定时器的两个输入通道配置成编码器输入。硬件会自动检测 A、B 两相的边沿和电平状态,判断旋转方向,然后自动向上计数或者向下计数。

2.1 定时器内部结构:编码器接口背后的硬件逻辑

简单说,定时器的 CH1 和 CH2 通道经过内部复用,连接到编码器接口模块。编码器接口模块会根据两路信号的电平关系,生成计数方向信号 DIR,并输出计数时钟脉冲到计数器。

这个机制本质上就是一个硬件状态机。每一次 A 或 B 信号发生跳变,硬件就会触发一次计数操作,不用 CPU 介入。所以即使在软件完全没有轮询的情况下,计数器也会老老实实地记录你的每一次旋转。

这个过程其实很像日常生活中停车场入口的感应线圈:车一到,闸机自动抬起,而不是有人坐在岗亭里盯着摄像头数轮子。你做嵌入式,如果硬件已经把基础逻辑做完了,就别再用软件去模拟,效率低还容易出错。

2.2 四种计数模式与一倍的频率问题

STM32 编码器模式有三种计数映射方式:

  • 模式 1:只在 TI1(即 CH1)的边沿计数
  • 模式 2:只在 TI2(即 CH2)的边沿计数
  • 模式 3:在 TI1 和 TI2 的全部边沿计数

通常我们都用模式 3,获得最高的 4 倍频分辨率和最可靠的方向判断。但这里有个容易踩坑的点:模式 3 下,转速很快时,计数器更新频率是单路脉冲的 4 倍。如果你的定时器时钟设置不当,或者重装载值太小,可能导致溢出太频繁,计数不准。

再说“一倍频”的问题。网上有人问:“EC11 编码器转一下发几个脉冲?”这个问题没有标准答案,取决于你用的是几倍频。我实测的 EC11 标称 20 脉冲/圈,在模式 3 下转一圈计数器变化 80。如果你的界面逻辑只需要 10 步一格,那在软件里做个简单除法就行:计数值 / 8 就是实际格数。

2.3 为什么能省一半以上代码?对比轮询处理流程

我最早用轮询写 EC11 驱动时,默认流程是:

  1. 定时器每 1ms 中断一次
  2. 读取 A、B 电平值
  3. 用状态机记录上一状态和当前状态
  4. 查表判断是正转还是反转
  5. 做软件消抖,连续几次一致才认一次有效变化
  6. 更新全局计数值变量

这套流程写下来,代码量轻易超过 50 行,而且采样周期、消抖次数、状态表每个都要反复调试。用定时器 Encoder 模式之后,初始化配置完,主循环里只需要两行代码:

int16_t enc_count = (int16_t)__HAL_TIM_GET_COUNTER(&htim4); __HAL_TIM_SET_COUNTER(&htim4, 0);

正转、反转、脉冲累计全由硬件完成,唯一要做的就是“取走当前计数并清零”。这不是代码量减半,严格来说是减到了原来的十分之一。

3. 环境准备与 CubeMX 配置

我用的是 STM32F103RCT6,开发环境是 STM32CubeMX + Keil MDK。以下配置过程同样适用于 STM32F4、G0、L4 系列,寄存器名和库函数名有细微差异,但思路完全一致。

3.1 硬件连接:别把 A、B 接反

EC11 引脚一般标有 A、B、C(或 GND),有些还带定位脚。接线并不复杂:

  • EC11 的 A 相接到定时器通道 CH1
  • EC11 的 B 相接到同一个定时器的通道 CH2
  • EC11 的公共端 C 接 GND

这里要特别强调配套电阻。EC11 内部是机械触点,没有上拉或者下拉,必须外部加上拉电阻,否则引脚电平会悬空,计数会乱跳。常规做法是给 A、B 各接一个 10kΩ 上拉到 3.3V。别小看这个电阻,我见过不下十个项目,换了编码器依旧乱跳,最后发现是上拉电阻忘焊或者阻值太大。

如果 A、B 接反了,现象就是旋转方向全部反过来。有两种解决办法:要么在 CubeMX 里把 TI1 和 TI2 对调映射,要么在初始化后调用如下函数交换极性:

TIM_Encoder_InitTypeDef encoderInit = {0}; encoderInit.EncoderMode = TIM_ENCODERMODE_TI12; encoderInit.IC1Polarity = TIM_INPUTCHANNELPOLARITY_RISING; encoderInit.IC2Polarity = TIM_INPUTCHANNELPOLARITY_RISING; // 如果发现方向反了,把两个 Polarity 交换即可

3.2 CubeMX 参数设置:从时钟树到 Encoder Mode

CubeMX 里配置步骤不算复杂,关键是有几个隐藏项容易忽略。先说时钟树:如果你的主频是 72MHz,APB1 总线时钟默认 36MHz,但定时器时钟是 APB1 的 2 倍,也就是 72MHz。在 CubeMX 里确认 TIM4 的时钟源为内部时钟即可,编码器接口模式下它依然由内部时钟驱动计数器,只是计数触发来自外部输入。

接着在 TIM4 的 Mode 配置里,把 Combined Channels 选为 Encoder Mode。第一项 Channel1 和 Channel2 的 Polarity 如果不是特殊需求,保持 Rising Edge 就行。Prescaler(预分频)决定的是计数时钟分频,一般设为 0,因为我们要的是全分辨率计数。Counter Period(自动重装载值)是关键参数,后面单独说。

配置完这步,CubeMX 会自动生成MX_TIM4_Init()函数,内部会自动调用HAL_TIM_Encoder_Init()。注意,这个函数在旧版 HAL 库里可能会提示编码器模式的 IC 极性配置不能同时为下降沿,因为硬件上有些定时器不允许两路都是下降沿。

3.3 重装载值与计数范围:一个关键选择

Counter Period 决定计数器向上计数到什么值回绕到 0。以 TIM4 为例,它是一个 16 位定时器,计数器范围最大是 0~65535。如果你设成 65535,计数器会在这个范围内自由走动,当旋转到超过这个范围时会发生溢出回绕。

我实际项目中通常把 Period 设为 65535,并且用有符号数去读计数器,这样正反转都能正常表示。读取时强制转换为int16_t,就能得到 -32768~32767 的范围。如果你只想要一个不会溢出的绝对位置,可以设一个较小的 Period,比如 999,并通过中断回调更新位置变量。

这里有个容易糊涂的地方:自动重装载值在编码器模式下不是“旋转圈数”,而是计数器的上限。它决定的是计数器的工作范围,不是编码器一圈对应多少计数。一圈对应多少计数是物理电气特性和倍频决定的,和你在配置里写的最大值没有直接关系。

4. 代码实现与实测效果

CubeMX 生成初始化代码后,编码器模式要真正跑起来,还需要手动启动编码器接口。只调MX_TIM4_Init()是不够的,必须在初始化之后启动:

HAL_TIM_Encoder_Start(&htim4, TIM_CHANNEL_ALL);

这行代码完成后,TIM4 就开始默默数脉冲了。之后无论主循环写不写代码,计数器都一直在变化。

4.1 HAL 初始化与读取计数

下面是一段我很常用的编码器读取函数,可以直接复制到工程里用:

#define ENC_TIMER &htim4 void Encoder_Init(void) { HAL_TIM_Encoder_Start(ENC_TIMER, TIM_CHANNEL_ALL); } int16_t Encoder_GetCount(void) { int16_t value = (int16_t)__HAL_TIM_GET_COUNTER(ENC_TIMER); __HAL_TIM_SET_COUNTER(ENC_TIMER, 0); return value; } int8_t Encoder_GetDirection(void) { int16_t value = Encoder_GetCount(); if (value > 0) return 1; if (value < 0) return -1; return 0; }

注意函数里的__HAL_TIM_SET_COUNTER(ENC_TIMER, 0),这段代码是需要每次读取后立刻清零,还是让它继续累加,完全取决于你的业务场景。

如果只是做“旋转一格菜单加一”,建议读取后清零,用返回值作为单次增量。如果要做一个绝对位置记录,比如音量从 0 到 100,那就不要清零,让计数器自行累积,再在主逻辑里做边界判断。

void Volume_Update(int16_t *volume, int16_t min, int16_t max) { int16_t delta = Encoder_GetCount(); *volume += delta; if (*volume < min) *volume = min; if (*volume > max) *volume = max; }

既有方向信息,又有增量信息,界面逻辑会变得异常干净。

4.2 用定时器溢出实现按键长按与旋钮复用

EC11 通常还带一个按键开关,按下时第三组触点导通。这个开关没有硬件去抖,单纯用轮询会碰到按键抖动导致误触发。我的做法是把按键扫描也并入定时器周期中断里,因为编码器核心计数已经不再占用中断资源,剩下的 CPU 时间足够处理按键逻辑。

我当时是在一个 1ms 的定时器中断里做按键扫描,消抖计数器累计到 20ms 再确认电平状态。这样写出来的效果是:旋转编码器完全不依赖定时器中断,按键扫描只占用很少的定时器中断时间。整个系统的实时性和可靠性都比轮询方案好很多。

4.3 实测记录与代码量对比

下面是我在同一个 EC11、同一块板子上,分别用轮询方案和硬件 Encoder 模式方案做的实测对比:

对比项轮询方案Encoder 模式方案
驱动代码量约 60 行约 15 行
CPU 占用每 1ms 中断处理 5~10μs几乎为 0
最高可靠转速约可承受 10 转/秒远高于人手操作极限
抖动处理依赖软件消抖依赖硬件滤波,软件无需处理
调参难度状态表、采样周期、消抖次数只需确认周期和极性

实测最高的旋转速度下,轮询方案已经开始掉脉冲,而硬件 Encoder 模式依然计数正确。对于人手旋转操作,这个余量已经非常充沛。

5. 常见问题与排查技巧

配置和代码都抄对了,仍然可能遇到各种奇怪现象。我把这几年调试 EC11 看到的典型问题统一整理出来,每一条都是实际踩过的坑。

5.1 抖动误计数:为什么硬件模式也救不了你

机械编码器旋转时触点会产生抖动,虽然 STM32 的编码器接口能检测到边沿,但硬件并不会帮你“过滤”机械弹跳。编码器模式只是把计数逻辑变成硬件,可它计数的仍然是真实的边沿事件。如果触点抖动产生了额外边沿,计数器照样会把这些毛刺数进去。

我在第一次用硬件模式调试时,就遇到过“轻轻地转半格,计数值乱跳 10 多个”的现象。用示波器一看,A、B 信号在切换瞬间有几十个毫伏的振荡,同时伴随宽度只有几微秒的毛刺。解决办法有三个层次:

  • 最简单:在 A、B 引脚对地并联 104(100nF)陶瓷电容,构成低通滤波
  • 常规:代码里对计数器变化做死区判断,小于某个阈值的瞬间变化忽略
  • 高级:选用带施密特触发器输入的 MCU 引脚,或者外接施密特缓冲芯片

对于绝大多数单片机,我建议至少并联一个 10nF~100nF 的电容。这个电容能有效吸收高频毛刺,代价是会让信号边沿变缓,但 EC11 转速本来就低,完全不影响使用。

5.2 速度翻转与临界抖动

另外一个让我一度怀疑板子损坏的现象是:旋转速度很快时,计数器方向偶尔会反转一下,然后又恢复正常。研究后发现,这是机械触点在高速旋转下接触不稳定,A、B 相位关系出现短暂混乱。编码器硬件严格按相位状态判断方向,状态一旦乱掉,它就会认为旋转方向改变了。

这种情况在工业旋钮上特别烦人,因为用户往往快速连转好几圈,希望得到平滑连续的结果。我对此的经验是:不要在软件里对单次方向变化做过度响应。比较合理的做法是累计一小段时间内的总增量,比如每 50ms 采样一次计数值,再对两次采样差值做方向判断。

int16_t enc_prev = 0; int16_t enc_speed = 0; void Speed_Task_50ms(void) { int16_t enc_now = (int16_t)__HAL_TIM_GET_COUNTER(&htim4); enc_speed = enc_now - enc_prev; enc_prev = enc_now; // enc_speed 就是这一段时间内的净变化量 }

这样做的好处是既保留方向信息,又不会被瞬间的相位跳跃干扰。

5.3 上拉电阻阻值的玄学

很多人以为 EC11 上拉电阻用 10kΩ 就行,但不同板子的走线电容、引脚输入阻抗不一样,阻值过大会导致信号上升沿变缓,接近翻转阈值时反复触发,计数乱跳。

我在一块布局紧凑的四层板上遇到过类似问题,10kΩ 上拉配合 100nF 电容时,上升沿慢到了将近 300ns。后来把上拉换成 4.7kΩ,情况立刻改善。经验固化下来就是:

  • 3.3V 系统优先选 4.7kΩ ~ 10kΩ
  • 5V 系统优先选 10kΩ
  • 如果引脚自带上拉,可以先不开,避免双重上拉影响电平判断

5.4 硬件滤波与软件滤波思路

遇到抖动严重的 EC11,硬件滤波永远比软件滤波省心。我的优先级建议是:外部 RC 滤波 > 引脚内部施密特触发 > 软件防抖。

软件防抖在硬件 Encoder 模式下的思路和轮询时期完全不同。硬件模式不需要反复读电平,只需要定期读取计数值,检测是否有“异常跳变”。比如理论上人手最快旋转不可能在 1ms 内产生超过 10 个计数,如果单次采样出现 100 个计数,大概率是信号毛刺。这时候可以加一个简单的滑动平均或者限幅滤波。

注意不要为了防抖把重装载周期和中断频率调得太激进,编码器模式的精髓是“让硬件干活”。软件只做业务判断,不做电平判断。

6. 一些个人体会

从我第一次用轮询状态机调 EC11,到现在用定时器 Encoder 模式做旋钮,最大的感受是:硬件方案往往比软件方案更可靠,也更容易维护。很多所谓“经验丰富的工程师”喜欢在软件里堆状态机,但真正省心的做法是先吃透数据手册里的硬件外设,用硬件本身的功能去化解问题。

我也见过很多人说“反正轮询也能用,没必要学 Encoder 模式”,但一个产品的稳定性评估,不应该只看“功能能跑通”。轮询方案里那些微妙的采样周期、消抖阈值、状态表,在批量生产和长时间运行后都会成为隐患。而硬件编码器模式给出的是一套确定性逻辑:计数由硬件完成,方向由硬件判定,唯一需要关注的是外设初始化参数和信号质量。

最后再分享一个小技巧:如果你手头有逻辑分析仪,调试 EC11 之前先把 A、B 信号完整抓一段波形,比盲调代码高效十倍。波形里你能直观看到相位关系、毛刺宽度、上升沿质量,然后决定在硬件上滤波还是修改软件策略。这个习惯帮我省下的调试时间,比任何代码优化都值。

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

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

立即咨询