早几年我刚开始碰STM32的时候第一个让我纠结的外设就是EC11旋转编码器。这个东西在音量旋钮、数控电源、示波器菜单上遍地都是但真到自己写驱动的时候一直轮询GPIO电平、自己用状态机去解正交信号代码又长又容易漏判一转快了数值就乱跳。后来换成了定时器的Encoder模式我才发现这东西本来就可以用硬件去解根本不需要软件死磕。这篇文章就把我实际工程里怎么用STM32定时器Encoder模式驱动EC11、代码怎么从几十行缩到几行的完整过程写出来。不管是刚学STM32的新手还是正在做毕业设计、做仪表项目的老哥这套思路都能直接用。1. 为什么放着轮询不用非要折腾Encoder模式1.1 轮询方案到底坑在哪先说轮询。EC11编码器本质是两路正交方波信号A相和B相相差90度。很多人第一反应就是“那我直接读电平不就行了”于是一开始我也这么干过在主循环里不断读GPIO然后根据A、B两相电平组合判断正反转。这种做法理论上没错但实际用起来全是坑。第一个问题是响应速度取决于主循环频率。如果你的主循环里还有屏幕刷新、按键扫描、PID运算编码器转快了信号根本读不过来一转快就丢步。第二个问题是代码量一点不小。你要维护上一次的状态两个引脚的电平状态组合有4种还要根据状态转移表判断是正转还是反转一套状态机写下来少说五六十行还要处理机械抖动、消抖延时看着头大。还有一个很多人没注意的问题轮询方式下编码器转动的瞬间CPU必须立刻响应否则就丢脉冲。但如果此时CPU正在处理一个耗时操作比如刷一块大屏、做浮点运算、写Flash那编码器的脉冲就白白丢了。实时性完全没保障。1.2 硬件正交解码才是正路STM32的定时器Encoder模式本质上就是硬件正交解码器。A相接定时器的CH1输入B相接CH2输入定时器会在硬件层面根据两路信号的相位关系自动加减计数。正转的时候计数器往上加反转的时候计数器往下减全程不需要CPU参与。你想想这意味着什么编码器转得再快只要信号频率不超过定时器时钟频率一般72MHz编码器信号也就几十kHz它一个脉冲都不会丢。CPU只需要在需要的时候去读一下CNT寄存器看一眼转了多少、方向是什么。这就是硬件解耦带来的最大好处。而且这个模式在STM32的中端型号里基本都有F1、F4、F0这些系列都支持不属于什么冷门功能。唯一要注意的是不是所有定时器都支持Encoder模式一般是通用定时器TIM2、TIM3、TIM4、TIM5高级定时器TIM1、TIM8也支持。实际项目里最常用的是TIM4因为它的CH1、CH2在PB6、PB7好走线也不和串口、JTAG冲突。1.3 什么时候值得用Encoder模式用Encoder模式并不复杂但要看你项目实际情况只用一个编码器调节音量、切换菜单这种简单交互其实用外部中断也行Encoder模式属于杀鸡用牛刀但也不算浪费。需要精确记录位移量比如控制云台角度、电机位置这种一定要用Encoder模式硬件计数不丢步精度有保障。系统主循环本来就很忙编码器仅作为人机交互输入这时候Encoder模式能让你的代码变得非常清爽主循环里随便什么时候读一下计数都能拿到正确值。我自己现在只要用到EC11无脑上Encoder模式。配置一次之后后面复制粘贴改个引脚就能用省心程度跟轮询完全不是一个级别。2. EC11编码器信号机制与定时器引脚分配2.1 一个刻度到底发几个脉冲很多人搜“EC11转一下发几个脉冲”答案其实很直接EC11一转一个刻度格A、B两相各输出一个完整的方波周期所以一个刻度对应一个脉冲。一圈一般是20个刻度也就是20个脉冲。当然也有15、24这种规格的但市面上最常见的EC11就是20脉冲一圈。这里要强调一个大家经常混淆的点一个脉冲不等于计数器只加1。如果你在Encoder模式里配置成“1倍频”只在A相上升沿计数那一个刻度计数值加1。如果你配置成“2倍频”A相上升沿和下降沿都计数那一个刻度加2。如果配置成“4倍频”A、B两相的所有边沿都计数那一个刻度加4。实际项目里如果你只是用来调参数用1倍频就够了一个刻度对应1逻辑清晰也好调试。如果你想要更高的分辨率比如后面要接电机做位置环那用4倍频能拿到4倍的反馈精度。注意EC11本身是机械触点输出信号质量一般太高倍频反而容易把抖动也算进去所以人机交互场景1倍频最稳。2.2 EC11硬件电路没那么简单EC11编码器除了A、B两相信号外还有一个按压开关引脚按下时输出低电平。所以硬件上要接的东西有A相、B相、按键引脚加上VCC和GND一共5个引脚。关键的电气问题是EC11内部是机械触点不是推挽输出低电平能拉稳高电平必须靠外部上拉。STM32的GPIO可以配置内部上拉所以理论上不接外部上拉也能工作。但实际经验是内部上拉的阻值一般在30~50kΩ左右对机械触点来说偏大抗干扰能力不够强。我一般都直接在PCB上放两个10kΩ上拉电阻接到3.3VA、B两相各一个。这样信号边沿陡、抗干扰强实测明显比只靠内部上拉要稳。还有一个高频踩坑点机械触点导通瞬间会有抖动时间大概在几毫秒到十几毫秒不等如果不处理Encoder模式会把抖动当成转动脉冲。软件上可以在读取到计数变化后加一个几毫秒的延时或者过滤硬件上最简单的办法是在A、B两相到地之间各并联一个0.1uF的电容把高频抖动滤掉。这两种方式可以结合效果最好。2.3 定时器引脚怎么选以STM32F103为例TIM4的CH1在PB6CH2在PB7这是最常用的编码器组合几乎每块开发板上都有引出。TIM3的CH1在PA6CH2在PA7也可以但PA6、PA7有时候会被SPI1或者ADC复用要看你板子上的资源占用情况。引脚选好后要把它们配置成输入模式。很多人这里会犯一个错以为接的是定时器通道就要配置成复用推挽输出。完全不是这么回事Encoder模式下这两个引脚是输入要配置成浮空输入或者上拉输入我一般选上拉输入省掉外部上拉当然外部上拉方案把内部上拉关了也行。如果你用的是带引脚重映射的型号比如STM32F103C8T6要注意部分定时器通道默认引脚和重映射引脚的配置区别。默认情况下用PB6/PB7就不用开AFIO重映射直接配GPIO就行。如果要用别的引脚一定要在CubeMX里看清楚是否勾选了Remap否则定时器输入根本引不到你接的那个引脚上。3. CubeMX配置与工程搭建完整流程3.1 用STM32CubeMX初始化定时器现在做STM32开发我基本都直接用CubeMX生成初始化代码省事也不容易漏配置。下面以STM32F103C8T6 TIM4为例把Encoder模式配置流程走一遍。新建工程后先把系统时钟配好我习惯用外部晶振配到72MHz主频。然后在左侧Categories里找到Timers点开TIM4把Slave Mode选成Encoder Mode把Combined Channels选成Encoder Mode TI1 and TI2这样就把TIM4的CH1和CH2配置成编码器输入了。接着配置Counter Period也就是自动重装载值ARR。这个值决定了计数范围F1的定时器是16位的最高65535。Encoder模式一般直接把ARR设成65535让计数器在这个范围内自由加减溢出后自动回绕后面代码里做差值就能自然处理回绕问题。在Parameter Settings里还有个Encoder Mode下拉框里面有几个选项TI1、TI2、TI1 and TI2它们对应的就是前面说的1倍频、2倍频、4倍频。我做人机交互的旋钮用的是TI1 and TI2这么选有点意思TI1 and TI2其实是可以任意倍频的但在F1的CubeMX里直接选TI1 and TI2就是4倍频。这里我建议选TI1一个刻度计数值加1最好用。如果你想要2倍频选TI1和TI2中的a也行大家自己试的时候可以留意一下。接下来配置GPIO。在Pinout视图里TIM4_CH1对应的PB6和TIM4_CH2对应的PB7会自动变成绿色。点开这两个引脚把GPIO mode选成Input modePull-up/Pull-down选Pull-up速度随便选就行输入模式速度不影响。如果要用定时器更新中断来做溢出计数后面会详细讲还需要在NVIC Settings里勾选TIM4 global interrupt的Enabled。这样初始化代码就生成好了CubeMX已经把TIM4的底层初始化、GPIO初始化、NVIC配置全部搞定你只需要在main函数里启动定时器就行。3.2 关键参数背后的原理为什么ARR要设65535很多新手不理解ARR为什么非要设成65535。其实原因很简单Encoder模式下计数器是在一个范围内来回加减的ARR设得越大计数器能表示的数值范围就越大越不容易溢出出问题。如果把ARR设成100那计数器加到100后会自动清零如果你读到的计数值是10但你不知道之前已经清过多少次零那你就无法知道编码器总共转了多少。而ARR设成65535后计数器在0~65535之间自由跑你只需要在代码里定期读取并做差值就能算出增量即使发生回绕差值计算也能自动修正。这也引出一个关键点如果你需要记录编码器的绝对位置而不是增量那ARR值就不应该设成65535而应该根据你的实际范围设。比如你要调一个0~100的参数可以把ARR设成100计数器会卡在0~100之间但这反而限制了溢出处理所以我个人还是建议ARR设最大然后用增量累加的方式去处理灵活度最高。3.3 初始化之后还必须做的两件事CubeMX生成代码后main函数里不会自动启动TIM4你需要手动添加HAL_TIM_Encoder_Start(htim4, TIM_CHANNEL_ALL);这行代码的作用是启动编码器接口让定时器开始采集A、B两相信号。忘了这行编码器数据永远是0很多新手卡在这里半天找不到问题。另外为了确保读取到的计数值是从0开始的启动之前最好清一下计数器__HAL_TIM_SET_COUNTER(htim4, 0);放在启动之前把CNT寄存器清零。这样整个工程的初始状态是确定的后面读到的第一个差值就是真实的转动量不会带一个随机偏置。4. 核心代码实现代码量减半的关键4.1 三个顶层API搞定全部功能代码量减半的核心就是把“读取编码器”这个操作抽象成三个简单的函数。第一个是读取当前计数值第二个是读取增量并清零第三个是判断旋转方向。这三个函数加起来不超过20行却能覆盖绝大部分应用场景。先看最简单的读取函数int16_t Encoder_GetCount(void) { return (int16_t)__HAL_TIM_GET_COUNTER(htim4); }这里用(int16_t)强制转换是为了把计数器的无符号数变成有符号数这样当计数值超过32767时读取出来就是一个负数方便后面做加减判断。因为EC11一个刻度对应1个计数一次操作顶多转几十个刻度完全不会超过int16_t的范围所以这样处理非常安全。再看增量读取。假设每次读取之间编码器转了N个刻度正转是正N反转是负Nint16_t Encoder_GetDelta(void) { static int16_t s_last_count 0; int16_t current_count (int16_t)__HAL_TIM_GET_COUNTER(htim4); int16_t delta current_count - s_last_count; s_last_count current_count; return delta; }这个函数的精髓在于静态变量s_last_count记录上一次的计数值每次调用都算差值。差值就是这段时间内编码器转了多少格正负号就是方向。不管计数器怎么回绕只要两次读取间隔内转动的刻度不超过32767这个差值永远是对的。这比判断正反转然后手动累加要简洁太多。最后一个方向判断函数int8_t Encoder_GetDirection(void) { int16_t delta Encoder_GetDelta(); if (delta 0) return 1; if (delta 0) return -1; return 0; }返回1表示正转-1表示反转0表示没转。实际项目里我一般直接用增量值做乘法很少单独调这个函数但它可以帮助刚接触的人理解方向是怎么判断出来的。4.2 在主循环里怎么优雅地使用有了这三个函数主循环里的逻辑就非常清爽了。比如你要实现一个“旋转旋钮调节音量按下旋钮确认”的功能while (1) { int16_t delta Encoder_GetDelta(); if (delta ! 0) { volume delta; if (volume 0) volume 0; if (volume 100) volume 100; Volume_Set(volume); // 更新音量输出 Display_ShowVolume(volume); // 刷新屏幕 } if (Key_Scan() KEY_PRESSED) { Settings_Save(volume); // 保存设置 } HAL_Delay(5); }看到了吧主循环里的编码器部分就这么几行。你不需要关心A、B两相的状态不需要判断正反转不需要写状态转移表只需要读一个增量然后按自己的业务逻辑处理就行。我做的一个数控电源项目里用同样的代码只改了变量名和范围限制就实现了电压调节、电流调节、菜单切换三个功能整体代码量比之前轮询方案少了一半不止。4.3 按键检测也一起解决了EC11内置的按键虽然和旋转信号是独立的但项目里经常要一起处理。这里给一个简单的按键扫描函数放在同一个文件里uint8_t Key_Scan(void) { static uint8_t s_key_state 0; static uint32_t s_last_debounce_time 0; uint32_t now HAL_GetTick(); if (s_key_state 0 HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) GPIO_PIN_RESET) { s_last_debounce_time now; s_key_state 1; return 0; } if (s_key_state 1) { if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) GPIO_PIN_RESET) { if (now - s_last_debounce_time 20) // 消抖20ms { s_key_state 2; return 1; // 检测到按下事件 } } else { s_key_state 0; } } return 0; }注意看这个按键扫描用的是“按下后持续20ms才算有效”的消抖方式不需要阻塞延时非常适合在主循环里用。按下事件只需要执行一次后续长按的话可以继续判断s_key_state的状态来扩展长按逻辑。5. 常见问题与排查技巧实录5.1 数值乱跳、一下加好几格这是EC11调试里最常见的问题我一共遇到过两次一次是没接上拉一次是机械抖动没处理。如果你用的是内部上拉先检查一下GPIO有没有配置成Pull-up。有些人CubeMX里默认没选用的是浮空输入那编码器高电平就是悬空的信号一抖动数值就乱飞。换成上拉输入或者外接10kΩ上拉问题基本能解决。如果上拉没问题就是抖动导致的。EC11内部是两个金属弹片在接触转动瞬间会反弹产生一连串的脉冲。软件上可以过滤检测到计数变化后在短时间内忽略后续变化。更简单有效的办法是硬件上在A、B两相对地各并一个0.1uF电容实测能干掉绝大多数抖动。还有一个容易被忽略的问题你的主循环读取频率太慢导致一次读取拿到了好几个刻度的差值。这不是故障只是你处理不及时。如果你的程序要处理耗时的操作建议把编码器读取放到定时器中断里保证每次中断间隔都能及时采集增量。5.2 只能加不能减或者反了这个问题的原因很简单A、B两相接反了或者方向判断逻辑反了。解决办法也最直接把A、B两相的连接互换一下问题就解决了。如果你不想动硬件在CubeMX里也可以把TIM4_CH1和TIM4_CH2的输入极性配置反过来或者在代码里把增量取反都是三秒钟的事。但我建议养成好习惯接线按固定的颜色规范来比如EC11的A相接黄色线B相接绿色线别用了一两次又忘了哪个是哪个。5.3 计数值卡在0或者65535附近不动如果你发现编码器转到某个位置后数值就不再变化了多半是计数器碰到了0或者ARR值而ARR设得太小了。解决方法是把ARR设成最大值65535然后按照前面说的差值法读取。这样计数器就是0到65535之间循环不会出现“卡边界”的现象。只要两次读取间隔内的转动量不超过32767差值法永远能算出正确的增量。如果用了溢出中断来做计数扩展还要检查溢出中断标志有没有正确处理。比如在中断回调里加了累计变量但忘了清除更新标志会导致中断反复进入计数器看起来像在乱跳。5.4 EC11转一下发几个脉冲的调试验证方法这里分享一个判断编码器实际规格的小技巧。在调试时用一个简单的测试程序不断读取计数器值并打印然后手动转一圈观察计数值变化。如果一圈变化了20说明你的EC11是20脉冲一圈当前是1倍频如果一圈变化了40说明是20脉冲一圈但用了2倍频如果一圈变化了80那就是4倍频。这个测试方法也适用于验证你的配置是否正确。比如你期望一个刻度一格的调节手感但转一圈变化了80那就说明你选了4倍频把Encoder Mode改回TI11倍频就可以了。5.5 编码器模式与PWM输出冲突热词里有人搜“STM32F103定时器PWM输出模式”和“STM32定时器中心对齐1和2有什么区别”这里顺带提一个关键点同一个定时器Encoder模式和PWM输出模式不能同时使用。如果你用TIM4做编码器就不能再用TIM4的某个通道做PWM输出了因为定时器的比较寄存器、捕获寄存器已经被编码器的输入捕获占用了。如果你既需要编码器输入又需要PWM输出有两个方案一是用不同的定时器比如TIM4做编码器TIM2做PWM二是检查该定时器是否还有未被编码器占用的通道可用但一般Encoder模式会把两个输入通道都用上所以最好还是换一个定时器。中心对齐模式这里也顺带说一句它主要用于PWM输出和编码器模式没有直接关系但如果你搜索时看到了这两个模式不要混淆Encoder模式下不需要关心中心对齐、边沿对齐这些PWM概念。6. 从代码到工程一些更深入的实战建议6.1 定时器溢出中断能做更多事前面演示的差值法其实已经不需要溢出中断了因为int16_t的差值计算天然容忍回绕。但如果你想做更复杂的事情比如记录编码器总共转了多少圈那溢出中断就有用了。实现方式很简单在定时器更新中断回调里判断计数方向是正向溢出还是反向溢出然后对圈数变量加1或减1extern int32_t g_total_count; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM4) { // 判断是向上溢出还是向下溢出 if (__HAL_TIM_IS_TIM_COUNTING_DOWN(htim4)) { g_total_count - 65536; } else { g_total_count 65536; } } }有了这个圈数扩展你就能精确知道编码器累计转动的总步数跟电机关联的场景会用到。注意一点STM32F103的定时器更新中断只告诉你计数器回绕了但不会告诉你方向所以要用__HAL_TIM_IS_TIM_COUNTING_DOWN去读方向寄存器。这个宏在HAL库里是有的如果你用的标准库可以对应读一下CR1的DIR位。6.2 长按、双击这样的复杂按键逻辑怎么做EC11的按键虽然简单但如果你要做长按、双击代码就得稍微设计一下。核心思路是用一个定时器比如SysTick或者TIM2提供时间基准按键扫描时记录按下和释放的时间戳然后根据时间差判断是短按、长按还是双击。这里给一个简单的状态机框架typedef enum { KEY_IDLE, KEY_DOWN, KEY_UP_WAIT, KEY_LONG_PRESSED } key_state_t; key_state_t key_state KEY_IDLE; uint32_t down_time 0; void Key_Task(void) { uint8_t key_read HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin); uint32_t now HAL_GetTick(); switch (key_state) { case KEY_IDLE: if (key_read GPIO_PIN_RESET) { down_time now; key_state KEY_DOWN; } break; case KEY_DOWN: if (key_read GPIO_PIN_RESET) { if (now - down_time 1000) { key_state KEY_LONG_PRESSED; Key_ActionLongPress(); } } else { key_state KEY_UP_WAIT; } break; case KEY_UP_WAIT: if (key_read GPIO_PIN_SET) { if (now - down_time 1000) { Key_ActionShortPress(); } key_state KEY_IDLE; } break; default: key_state KEY_IDLE; break; } }注意我对长按的定义是按下超过1000ms就算如果你要做双击还需要在“短按抬起后的一段时间内再检测一次按下”这个留给大家自己扩展。6.3 低功耗场景怎么处理编码器如果你做的是电池供电的便携设备就要考虑进入低功耗模式时编码器怎么处理。最简单的方案是进入Stop模式前把编码器所在定时器关闭把A、B、KEY引脚配置成外部中断输入让编码器转动时通过外部中断唤醒芯片。但要注意唤醒后你必须重新初始化编码器接口并且清理一下计数器因为你在休眠期间可能会错过一些脉冲。这个方案有个小坑EC11的机械抖动可能会在休眠期间反复触发外部中断导致频繁唤醒。解决办法是在外部中断回调里加一个消抖判断确认电平稳定后再唤醒。实测下来一档一档地转是没问题的但如果你快速连转好几档中间可能有脉冲丢失需要接受这个精度损失。6.4 这个编码器方案还能怎么扩展聊完了EC11其实这个思路可以扩展到很多地方。比如你用带ABZ输出的增量编码器常见于电机一样是接定时器的CH1、CH2Encoder模式直接就能解出位置和方向只不过要注意编码器输出的电平标准有些是5V输出需要做电平转换。再比如你用的是带霍尔传感器的无刷电机也可以利用定时器的Encoder模式来获取电角度信息虽然一般有专用的电机控制外设但做简单的转速测量足够了。还有一个很实用的扩展用Encoder模式配合Pulse计数可以测频率。这时候并不需要两路正交信号只要把一路信号接到CH1另一路接地配置成Encoder模式后计数器就能根据输入脉冲数计数配合时间基准就能算频率。热词里有人搜“STM32定时器捕获测频率”其实用Encoder模式反而更省事。总之定时器Encoder模式就是给你一个硬件计数的通道具体怎么用完全看你的想象力。我用这个东西做过音量旋钮、数控电源的电压调节、示波器菜单选择还有一个小型云台的角度反馈都是一套代码换个参数就能用。6.5 关于调试工具的一个小建议调试编码器的时候强烈建议用逻辑分析仪抓一下A、B两相的波形而不是只用调试器看变量。逻辑分析仪能直接看出信号有没有抖动、相位关系对不对、转动频率是否过高。我遇到过很多次“代码没问题但编码器乱跳”的情况最后都是靠逻辑分析仪发现是硬件接触不良或者上拉电阻虚焊。如果没有逻辑分析仪也可以用示波器看波形。关键看两点一是高电平是否接近3.3V如果只有2V左右说明上拉阻值可能太大二是A、B两相的相位差是否保持在90度左右偏离太远说明EC11的机械结构有问题。用调试器看变量当然也能发现问题但只能看到结果看不到信号质量排查起来效率低很多。真心建议做嵌入式的朋友备一个几十块钱的逻辑分析仪关键时候能省好几个小时。