☰
用C语言中断方式实现旋转编码器方向识别与状态机设计
2026/10/17 5:50:30 网站建设 项目流程

做嵌入式开发的朋友对旋转编码器应该都不陌生。音量旋钮、仪器调节、菜单翻页,很多时候我们面对的就是这么一个小小的旋钮。这个项目,做的是用C语言,以中断方式实现旋转编码器的方向识别,并且编码器本身存在空闲状态。说直白一点:旋钮平时安静地停在某档位上,A、B两路信号都没有边沿变化,用户突然拧了一下,系统要在第一下脉冲时立刻判断是顺时针还是逆时针,整个识别过程还不能把MCU的CPU时间吃掉太多。这个需求听起来简单,实际上翻车点不少——方向错、乱计数、高速丢步、上电第一圈就错,都是常见问题。这篇文章就从一套能跑的完整实现出发,把技术点拆开讲清楚,适合需要在自己项目里移植编码器驱动的嵌入式开发者和电子爱好者参考。

1. 项目需求与核心思路

1.1 旋转编码器输出的本质:两路脉冲的相位关系

机械旋转编码器一般输出两路矩形波信号,习惯上叫A相和B相。轴旋转时,两个触点会先后接通或断开,从MCU的视角看就是两路相互错开90°电角度的方波,也叫正交信号。拧得越快,脉冲频率越高;拧到某一档位停下来时,两路都保持当前电平不再变化,进入空闲状态。这里要建立的最重要概念就是:方向信息不在单个引脚里,而在两路信号的先后顺序里。举个生活类比:就像两个人轮流举手,先举手的是A、后举手的是B,那么这一轮就代表正转;下一次反过来,先B后A,就代表反转。编码器方向识别实际上就是判断这种先后关系,而不是单纯看哪一路出现了脉冲。

1.2 为什么这个项目坚持用中断方式

一些人会想,我定时读引脚状态,再对比前后状态不也能判断方向吗?确实可行,但轮询方式有两个天然劣势。一是实时性差:编码器转得快时,两路脉冲间隔可能只有一两百微秒。如果系统里还有其他任务,轮询周期一变大,就会漏掉某个中间状态,方向自然就判断不出来。二是费CPU:为了不漏状态,只能把轮询周期压得很短,哪怕旋钮一直静止,MCU也要一直空转去读引脚。中断方式则完全不同,引脚电平一旦变化,硬件会自动触发中断,软件立即读取状态并处理;旋钮静止时没有任何中断,CPU几乎零负担。这个优势在很多产品场景里都非常关键,尤其是功耗敏感和任务多的系统。

1.3 “有空闲状态”这个条件,到底难在哪

标题里专门点出来“编码器有空闲状态”,很容易被忽略,但它才是很多实现会翻车的根源。编码器静止时,A、B引脚可能是任意组合,比如都处于高电平。很多初学者的代码会假设系统上电时A、B一定是什么固定的值,或者在第一次进入中断时才初始化上一次状态。这就会出问题:如果编码器在开机前已经拧到了某个位置,第一次转动时,进入中断的当前状态和默认上一次状态之间没有必然联系,查表后方向就可能反。正确的做法是在初始化阶段就读一次A、B电平,把组合保存下来,作为空闲状态的基线;之后每来一次中断,都拿新的组合和上一次组合比较,查方向增量表。这样不管编码器停在哪个档位、从哪个方向开始转,第一下就可以得到正确方向。这是状态机方案的优势,也是本项目正确性的基础。

2. 硬件电路与信号调理

2.1 机械编码器的典型接法

这个项目里的硬件结构很简单,但要接对。常见的带定位手感机械编码器有三个功能引脚,同时还有一个公共端引脚。以公共端接GND的接法来说,A、B两路通过内部金属触点开关连接到地;平时触点断开,MCU读到的是上拉后的高电平,转动时触点闭合,引脚被拉到低电平。所以硬件设计的要点是给A、B做好上拉。很多独立模块自带上拉电阻,但有些散装的编码器没有,不能想当然。我的建议是初始化代码里开启MCU内部上拉,同时外部再加一个10kΩ上拉到VCC,双保险。电位上拉不稳会导致引脚在高低电平之间漂移,表现为不转动也频繁触发中断。

2.2 消抖电容与RC时间常数的估算

机械编码器的触点本来就是机械结构,转动时接触点会在极短时间内反复通断,产生抖动毛刺。这些毛刺反映在电平上就是非常窄的脉冲,软件层可以过滤一部分,但硬件上做简单处理会更省心。常见的做法是在A、B引脚对地各加一个10nF到100nF的电容,配合上拉电阻构成RC低通滤波。以10kΩ配10nF为例,时间常数τ=RC=100微秒,可以滤掉绝大多数抖动毛刺。这个数值对百赫兹级别的旋转脉冲完全够用,但要注意电容不要加太大。如果换成1μF,时间常数就变成10ms,脉冲边沿会被拉得很缓,MCU可能在中间阈值附近反复触发,反而更容易误判。经验值来讲,普通旋钮场景选10nF比较合适,能跑较快的手速也不会被毛刺折磨。

2.3 示波器确认相位关系与毛刺

接好线之后,不要急着写代码。建议先拿示波器两个通道同时测量A、B引脚,手拧几次旋钮,观察波形。正常时能看到两路错开90°的方波;顺时针时一路先沿,逆时针时另一路先沿。同时看沿附近有没有毛刺,如果有,就需要调整RC参数。这里有个简单经验:如果毛刺出现在触点上,波形连续跳动,说明抖动严重;如果只是偶尔一个沿上有小幅反弹,软件状态机基本能过滤。示波器还能帮你确认编码器的输出逻辑,比如是上拉高有效还是下拉低有效,不然代码方向表和实际硬件对不上,调试半天发现只是接线逻辑反了。

2.4 接线要点速查

编码器引脚推荐接法备注
A相MCU外部中断输入,开启内部上拉外接10kΩ上拉,对地10nF滤波电容
B相MCU普通输入,开启内部上拉同样外接上拉和滤波电容
公共端接GND若模块公共端是VCC,则上下拉关系要反过来
上拉电源3.3V或5V与MCU电平匹配

3. 方向识别算法:为什么用状态机

3.1 只在一个中断里读另一路电平,为什么不够可靠

最直观的方向识别法,是让A相触发中断,在A相中断里读一下B相的电平。A上升沿时B是高还是低,决定正转还是反转;A下降沿时B的状态又可以再判断一次。代码只有几行,看起来很简单。这种方法在信号非常理想、转速稳定时确实能用,但有两个缺陷。第一,如果旋钮停在正好让A相边沿来回抖动的档位附近,一个机械步进去可能会产生多个虚假边沿,程序每进来一次都按同样的规则累加,计数就会乱跳。第二,它只利用了A相的边沿信息,B相如果在某个短暂时刻里发生了抖动,程序根本不知道,状态就错了。旋转编码器的方向信息分布在两路信号上,只盯一路信号,本质上就丢了一半信息。

3.2 四状态环形路线的直觉理解

更好的是把A、B两个引脚看作一个2位二进制状态。电平组合只有四种:00、01、10、11。编码器转动的过程,就是这四种状态在相邻状态之间连续迁移的过程。把这四种状态排成一个环形路线,合法的转动只会沿相邻状态移动一格,且同一方向的每一次移动都对应同一个方向。比如可以定义一组正交序列 00→01→11→10→00 为正方向,那么反过来 00→10→11→01→00 就是反方向;具体哪个方向叫正,完全由你的定义决定,代码里调整符号即可。这是“格雷码”的规律:相邻状态之间只有一个位发生变化。如果突然从00直接跳到11,中间跳过一个状态,说明大概率碰上了抖动毛刺,应该忽略这次变化。这个环形路线的概念非常重要,理解了它,后面的查表代码其实就是把这张路线图翻译成数字。

3.3 方向增量表的查表原理

既然合法状态迁移只发生在相邻状态之间,那就可以预先把所有“旧状态→新状态”的组合可以带来的增量列成一张表。旧状态4种,新状态4种,一共16种组合;合法组合对应+1或-1,非法组合和相同状态对应0。程序每次进入中断,只需要用旧状态和当前状态做一次查表,就得到本次旋转的方向增量。查表法的优点是判断时间固定,代码执行路径短,非常适合中断服务函数;而且增量表的数据可以设计成符合正交信号规律,不管旋钮从空闲什么位置开始转动,只要上一次状态保存正确,下一次查表结果就是对的。相比if-else判断,查表没有分支风暴,也不容易漏方向,这一点在高速旋转时优势明显。

3.4 我实际使用的方向增量表

状态编码采用code = (A << 1) | B,所以10代表A引脚为高、B引脚为低,其他以此类推。横向是当前状态,纵向是上一次状态,表格里的数值就是本次方向增量:

旧状态 \ 新状态00011011
000-1+10
01+100-1
10-100+1
110+1-10

这张表对应的正方向序列是00→10→11→01→00,反方向序列是00→01→11→10→00。如果你希望符号反过来,直接把表格里的 +1 和 -1 整体对调,或者把A、B两根线对调,效果是一样的。这个表的最大特点是非法跳变全部返回0,天然过滤了一部分抖动。

4. 完整C语言实现与移植说明

4.1 初始化流程

不管用哪款MCU,初始化的逻辑都是一样的:

  1. 使能GPIO时钟,把A、B改成输入模式。
  2. 开启内部上拉电阻。
  3. 把A、B两路外部中断配置成双边沿触发。这里有一个关键点:两路都建议配成中断,而不是只配A一路。只配A也能转起来,但编码器从某个状态变到相邻状态时,如果只有B相发生了边沿,A不变化,那么A的边沿中断就不会触发,状态就推不动,慢速旋转时容易出现漏步。
  4. 延时几毫秒,等引脚电平稳定,读取当前状态保存为上一次状态。这个“稳定后采样”就是专门应对“有空闲状态”编码器的初始化方法。
  5. 开启全局中断。

4.2 中断服务函数的时间要求

在ISR里只做三件事:读引脚生成当前状态、查增量表、保存当前状态。最多再往一个volatile变量里累加计数。不要在ISR里调用打印函数,不要做界面刷新,不要用delay。很多人调试时习惯在中断里printf,结果拧一次旋钮串口卡半天,然后把锅甩给编码器方向识别。实际上机械编码器在低速旋转时中断频率不算高,但一旦手速快,每秒几十上百个中断,printf就会让系统彻底卡死。正确做法是在ISR里只累计delta,主循环通过函数读取并清空计数后,再去做界面、串口等业务。

4.3 平台无关的参考代码

下面这段代码是平台无关风格,只需要把引脚读取宏替换成你自己的库函数即可。

// encoder_drv.h #ifndef ENCODER_DRV_H #define ENCODER_DRV_H #include <stdint.h> void Encoder_Init(void); void Encoder_ISR(void); int16_t Encoder_GetAndResetDelta(void); #endif
// encoder_drv.c #include "encoder_drv.h" // 把下面这两个宏替换成你的MCU引脚读取函数 #define ENCODER_A_READ() GPIO_ReadPin(ENCODER_A_PORT, ENCODER_A_PIN) #define ENCODER_B_READ() GPIO_ReadPin(ENCODER_B_PORT, ENCODER_B_PIN) static volatile int16_t s_encoder_delta = 0; static uint8_t s_prev_state = 0; // 方向增量表: index = (旧状态 << 2) | 新状态 static const int8_t s_dir_table[16] = { 0, -1, 1, 0, // 旧状态 00 1, 0, 0, -1, // 旧状态 01 -1, 0, 0, 1, // 旧状态 10 0, 1, -1, 0 // 旧状态 11 }; void Encoder_Init(void) { // 1. 配置A、B引脚为输入模式,打开内部上拉 // 2. 配置A、B引脚外部中断为双边沿触发 // 3. 延时等待电平稳定,采样一次作为空闲基线 volatile uint32_t i; for (i = 0; i < 2000; i++) { // 空循环延时,实际工程建议用延时函数 } s_prev_state = ((ENCODER_A_READ() ? 1U : 0U) << 1) | (ENCODER_B_READ() ? 1U : 0U); } void Encoder_ISR(void) { uint8_t curr_state = ((ENCODER_A_READ() ? 1U : 0U) << 1) | (ENCODER_B_READ() ? 1U : 0U); uint8_t index = (uint8_t)((s_prev_state << 2) | curr_state); int8_t delta = s_dir_table[index]; if (delta != 0) { s_encoder_delta += delta; } s_prev_state = curr_state; } int16_t Encoder_GetAndResetDelta(void) { int16_t val; // 临界区保护,防止主循环读取时刚好被中断打断 __disable_irq(); val = s_encoder_delta; s_encoder_delta = 0; __enable_irq(); return val; }

这段代码的核心就是查表那一行。没有复杂的if嵌套,没有分支预测的担忧,执行时间非常固定。原项目采用的就是类似实现,直接换到三款不同MCU上,只改了引脚读取宏和中断入口函数名,算法部分一行没动。

4.4 主循环使用示例

主循环里不需要关心边沿,只需要周期性取走增量,再刷新界面。下面的代码演示了限定范围菜单选项的写法:

#include "encoder_drv.h" static int16_t s_menu_index = 0; static int16_t s_menu_min = 0; static int16_t s_menu_max = 9; void Menu_Task(void) { int16_t delta = Encoder_GetAndResetDelta(); if (delta == 0) { return; } s_menu_index += delta; if (s_menu_index < s_menu_min) { s_menu_index = s_menu_min; } if (s_menu_index > s_menu_max) { s_menu_index = s_menu_max; } Menu_Display(s_menu_index); }

这里有个习惯建议:界面只在delta不为0时刷一次,而不是每轮任务都刷。这样既减少闪烁,也方便以后扩展出“空闲N秒”之类的逻辑。

4.5 没有硬件时怎么验证算法

如果你手头暂时没有编码器,完全可以用信号发生器或者另一个MCU的GPIO模拟A/B方波。把两个GPIO按正交时序轮流输出高低电平,每次间隔几毫秒到几十毫秒,看主程序计数是否按要求累加。这个办法比手拧旋钮稳定得多,能快速验证状态表方向定义是否正确。我在调试这个项目时就是先用软件模拟跑通了表,再接实物,省掉了很多硬件排查时间。

5. 常见问题与排查记录

5.1 方向反了

这是最常遇到也最好解决的问题。原因基本只有两种:A/B接线对调了,或者方向表正方向定义跟预期不符。解决办法有两种,一是硬件上把A、B两根信号线交换位置,二是软件里把增量的符号取反。如果只是方向反、计数本身正常,说明算法逻辑没有毛病,不用怀疑状态表,放心改符号就行。

5.2 旋钮静止时计数乱跳

这个问题的排查顺序很固定。先量静态电压:正常时A、B引脚应该稳定在高电平或低电平,如果电压在中间飘,说明上拉或下拉没配置对。再确认是否真的开了内部上拉,有没有其他外部下拉电阻把电平拉成中间态。接着看RC电容:太小滤波效果差,太大会让边沿变圆。最后用示波器抓波形,看是不是机械触点本身抖动严重。

这里有一个容易被忽视的深坑:清中断标志的位置。有些MCU要求在进入中断后立刻清标志,有些要求在读取引脚之后清。如果清得太早,刚好边沿再次到来,中断标志又置上,退出ISR后再次进入,看起来就像静止时不断乱跳。我遇到过类似问题,排查了很久才发现是清标志顺序不对,顺序调整后计数立刻安静了。

5.3 快速旋转时丢方向

多半原因是ISR执行时间太长、RC滤波时间常数太大或者中断被更高优先级的中断频繁抢占。先把ISR精简到最短,再确认电容不能超过100nF,然后用示波器看高速旋转时波形是否依然方整。还要确认编译优化等级,调试版不开优化时,查表代码可能比想象中慢不少。如果这些都没问题,并且MCU主频确实有限,那就需要考虑硬件定时器正交解码模式,后面我会展开说。另外,机械编码器本身也有最高响应频率,便宜型号在高转速下触点根本跟不上,硬件品质问题别指望软件全兜住。

5.4 上电后第一次转动方向就是错的

典型原因是上一次状态没有初始化,或者初始化时引脚还在抖动,采样值不对。解决办法是初始化时先延时,等引脚稳定后再采样;如果还不放心,可以连续采样两次,两次结果一致才把它当作基线。这个坑特别符合标题里“编码器有空闲状态”的条件:旋钮在开机前可能已经被拧到任意角度,不能默认上一次状态等于0,必须实测。

5.5 多个编码器同时接入

系统里接多个编码器时,每个编码器都要有独立的s_prev_state和s_encoder_delta变量,方向表可以共用,但状态变量不能共用。有些MCU同一组端口的上拉配置和复用功能会互相影响,原理图阶段就要规划好引脚分配,最好每个编码器A相都选在不同的外部中断线端口,避免中断入口冲突。

5.6 排查速查表

现象排查方向常见根因解决手段
方向反A/B接线、方向表符号接线对调或正方向定义不符接线对调或软件取反
静止乱跳静态电压、上拉、RC、清中断标志上拉没开、电容过大、清标志顺序错检查GPIO配置、调整RC、调整清理顺序
高速丢步ISR长度、RC、优先级、编码器品质ISR内有打印、电容过大、机械跟不上精简ISR、减小电容、换硬件解码
上电首转错初始化采样没有采样基线或采样过早延时稳定后再采样
多编码器冲突状态变量、中断资源共用了状态变量每路独立状态变量

6. 扩展玩法与个人经验

6.1 用硬件定时器正交解码模式

不少32位MCU的定时器外设自带编码器接口模式,把A、B信号接到定时器的两个输入通道,硬件自动对边沿解码,计数寄存器自动加减,软件只需要读寄存器。这个方案CPU占用更低,速度上限也更高,在电机闭环和高速手轮场景下很香。但要注意,它通常固定占用定时器资源,引脚复用也有限制,可能和现有PCB布局冲突。标题这个项目要求的是C语言软件实现,所以前面都以状态机方案为主,硬件解码我就点到为止,给你留着一条优化路线。

6.2 转速计算与加速度判断

在状态机方案的基础上,还能扩展出转速计算。做法是在ISR里每次状态变化都记录一次自由运行定时器的计数值,主循环用两次跳变之间的时间差换算转速。注意时间戳变量用32位,读取高16位和低16位时要关注MCU的“半字”读取问题,否则偶尔会算出离谱的时间差。如果只是做方向识别,这部分砍掉即可,别给自己加负担。

6.3 把驱动封装成独立模块

建议把方向增量和状态表做成一个独立的C文件,业务代码只调用Encoder_GetAndResetDelta()。我当时这么封装之后,后面再做类似项目时,直接把整个C文件拖过去,改一下引脚读取宏,编译就能跑。界面刷新、串口打印、掉电保存这些功能全部留在主循环里,驱动层保持干净,也方便测试。

6.4 最后的经验

如果状态机、消抖、上拉这些条件都做对了,还有个别随机误计数,大概率是机械编码器本身触点氧化或者模块供电不稳。换一颗品质好点的编码器,或者用稳压芯片给编码器单独供电,往往比改半天软件更有效。我个人做下来还有个体会:把旋转编码器的数值当成方向增量而不是绝对值来用,界面只在变化时响应,静止时不做任何操作,即使偶尔有一两次毛刺没滤掉,用户也不容易感知到错误。这个理念比单纯追求计数绝对准确更贴合真实产品体验。

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

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

立即咨询