
做嵌入式这些年凡是碰过旋钮、菜单、音量调节的基本都在 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 驱动时默认流程是定时器每 1ms 中断一次读取 A、B 电平值用状态机记录上一状态和当前状态查表判断是正转还是反转做软件消抖连续几次一致才认一次有效变化更新全局计数值变量这套流程写下来代码量轻易超过 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 相接到定时器通道 CH1EC11 的 B 相接到同一个定时器的通道 CH2EC11 的公共端 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 ModeCubeMX 里配置步骤不算复杂关键是有几个隐藏项容易忽略。先说时钟树如果你的主频是 72MHzAPB1 总线时钟默认 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 引脚对地并联 104100nF陶瓷电容构成低通滤波常规代码里对计数器变化做死区判断小于某个阈值的瞬间变化忽略高级选用带施密特触发器输入的 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 信号完整抓一段波形比盲调代码高效十倍。波形里你能直观看到相位关系、毛刺宽度、上升沿质量然后决定在硬件上滤波还是修改软件策略。这个习惯帮我省下的调试时间比任何代码优化都值。