ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从波形图到代码:EC11编码器AB相相位差原理与双平台解码

从波形图到代码:EC11编码器AB相相位差原理与双平台解码 从波形图到代码深度拆解EC11编码器的AB相相位差原理含Arduino/STM32双平台示例我第一次拿到EC11编码器的时候满心以为这玩意儿跟电位器差不多接上三个脚就能读角度。结果下载了个示例程序接好线一旋转数码管上的数字要么纹丝不动要么哗啦哗啦乱跳偶尔还反着走。折腾了一晚上最后才发现问题根本不是代码而是我没搞懂EC11的AB相相位差到底是怎么回事。后来吃透了波形图再看各类解码代码基本就是一眼洞穿。EC11编码器、AB相相位差原理这两件事一旦打通Arduino和STM32上的写法都只是形式问题。这篇文章我就从波形图开始把EC11的AB相相位差掰开揉碎讲清楚再给出两套平台可直接用的代码和避坑经验。这篇文章适合谁手头有EC11想做音量旋钮、仪表面板、数控手轮或者正在学正交解码、定时器编码器模式的人。我会尽量把原理讲得直白但也会把状态机查表这类工程上常用的写法讲透争取让不同基础的读者都能带着收获离开。1. 先看懂波形图EC11的AB相凭什么让单片机认识“方向”和“步数”1.1 EC11的内部结构一个金属触点的机械魔术EC11是机械式旋转编码器属于增量式编码器内部并没有光电码盘或磁性元件而是由一组金属触点和一片旋转的码盘或者说是带导电区的塑料/金属片构成。转动旋钮时内部的电刷会在导电区和非导电区之间滑动A、B两个引脚就和公共端C之间交替导通、断开。你可以把它简化成两个开关旋转时这两个开关以严格的先后顺序动作。我常用一个类比来解释假设两个人站在门口站岗顺时针旋转时A先举手示意、B再举手示意逆时针旋转时B先举手、A再举手。单片机需要回答的问题只有两个“有没有人动了”和“这次动是顺着转还是反着转”这两个开关先后举手的顺序就是这个问题的答案。EC11一般有6个引脚也有5脚的版本其中一侧的三个脚是编码器部分A、B、C另外两个脚是按键开关按下旋钮时导通用来做“按下确认”功能。不同厂家的引脚排序不一定相同拿到实物先查一下规格书比对着丝印瞎猜靠谱得多。这点我在后面Arduino接线部分还会再强调。1.2 相位差90°方向编码的关键先看两组方波。假设你用手慢慢旋转EC11用示波器同时抓A相和B相你会看到两路占空比接近50%的方波关键是这两路方波相位差约为90°也就是四分之一个周期。这就是“正交信号”——AB相相位差原理是整份代码的地基。为什么必须是90°而不是两个开关同时动作因为只有当相位差90°时在任意一个边沿到来的瞬间另一条信号线都处于稳定电平。比如A相上升沿到来时B相要么是高电平、要么是低电平一读便知方向如果旋转方向是A相超前B相90°那么A相上升沿对应的B相是低电平或某个确定电平反过来B相超前A相时同一位置读到的B相电平就是反的。这一个性质让单片机只要“看一个边沿读一根线”就能判断方向。但真正好用的还不止这个——因为一个完整周期内有四个边沿A升、A降、B升、B降而且每个边沿都能提供方向信息。如果程序把四个边沿都计数就能实现所谓“4倍频”也就是1倍频精度×4。对EC11来说物理上每一个脉冲一格棘爪定位是确定的常见EC11一圈有15个或20个脉冲也就是转一圈会有15或20个定位档位。“转一下发几个脉冲”就是由这个参数决定的不同型号不一样买的时候可以问清楚商家或者到手后用示波器转一圈数一下A相上升沿个数。1.3 机械编码器不是完美的抖动、毛刺与“奇怪计数”纯机械触点最大的麻烦就是抖动。电刷碰上导电区的那一瞬间金属弹片会反复弹跳典型持续时间从几微秒到几毫秒不等直接在波形边沿上炸出一串毛刺。你用示波器慢速旋转时能看到A相或B相在上升沿附近跳好几下这就是抖动。抖动对代码的影响非常直接如果程序只数上升沿一格本来该计1次结果可能计了2次甚至3次如果程序还在中断里做了别的判断抖动会导致方向误判表现为“明明正转数值却偶尔倒退一下”。所以一个成熟的EC11解码程序本质上做两件事第一判断当前边沿对应的另一个引脚电平确定方向第二想办法把抖动带来的非法跳变过滤掉。上面这两种思维分别对应后面的“简单中断法”和“状态机法”。状态机法的本质就是利用AB相合法状态只能按固定顺序转移的特性把抖动产生的“不合理跳变”直接忽略越过了单独做消抖的步骤。2. 把波形翻译成代码三种主流解码思路的取舍2.1 思路一只读电平旋转时按A/B电平组合判断最原始的方法主循环里不断读A、B引脚电平一旦发现A相变化就通过B相电平判断方向。代码很短适合低速、不追求精度的场景比如展示项目或者简单旋钮。int lastA digitalRead(ENC_A); int32_t pos 0; void loop() { int a digitalRead(ENC_A); if (a ! lastA) { int b digitalRead(ENC_B); if (a b) { pos--; // 方向正反取决于你的接线定义 } else { pos; } lastA a; Serial.println(pos); } }这个逻辑看着简单但要注意主循环必须不停地扫只要在两次采样之间漏掉了一次边沿变化脉冲就丢了。系统一旦有延时操作——比如驱动屏幕、串口打印、WiFi联网——漏步概率直线上升。实测中如果主循环里有超过几毫秒的阻塞操作快速旋转时基本必然丢步。所以此法只适用于简单演示不适合做产品。2.2 思路二边沿中断加方向判断Arduino里最常见的中断法A相接中断脚用CHANGE模式上升沿和下降沿都会触发在中断服务函数里读B相判断方向。void encoderISR() { int a digitalRead(ENC_A); int b digitalRead(ENC_B); if (a b) { encoderValue--; } else { encoderValue; } }这段代码比轮询实时性好中断一触发立刻读取不用管主循环在干什么。但它的抗抖能力依然有限——抖动会在极短时间内触发多次中断导致一个物理档位被计数多次。此外中断服务程序里不能有delay()、串口打印这类耗时操作否则中断阻塞期间新的脉冲会被漏掉。我开始用EC11时就是从这段代码起步的遇到的问题很典型慢速旋转一格数值跳2到3快速旋转又反过来丢步。本质原因就是既没有消抖中断服务又太慢。2.3 思路三正交状态机查表法最推荐AB相两个引脚组合起来只有四种合法状态00、01、11、10。机械旋转时这四种状态必须按固定顺序循环转移正转是00→01→11→10→00这样递增反转则倒过来。任何跳过一个状态的转移比如从00直接跳到11都属于非法转移只会是抖动或外部干扰。把这些合法转移和方向整理成一张表就是“状态机查表法”。表格生成方式取新状态和旧状态组合成一个4位索引旧状态左移2位或当前状态左移2位查表得到计数器增量。我推荐的查表数组如下// 索引 (旧状态 2) | 新状态 // 状态定义: bit1A, bit0B const int8_t stateTable[16] { 0, 1, -1, 0, -1, 0, 0, 1, 1, 0, 0, -1, 0, -1, 1, 0 };这张表是怎么来的我验证过关键项正转时候的合法转移是00→01、01→11、11→10、10→00对应索引1、7、14、8表中值都是1反转的合法转移是00→10、10→11、11→01、01→00对应索引2、11、13、4表中值都是-1。其余非法转移统统给0不计数。状态机法的好处是它不是简单数边沿而是只认“符合物理规律的状态迁移”。配合硬件RC滤波后慢转一格恰好计1步快转也不容易丢是我目前在Arduino和STM32上都在用的方案。三者对比总结如下方案实时性抗抖动能力代码量CPU开销适用场景电平轮询低差最少高需高频轮询低速演示、无阻塞任务中断方向判断中中少中一般旋钮、要求不高正交状态机高较强中低中断里查表工业手轮、精确计数、产品化3. Arduino实战接线、消抖与基于状态机的编码器驱动3.1 硬件接线公共端必须接对上拉电阻必须存在EC11的编码器部分有三个脚A、B、C公共端。在Arduino板上我的推荐接法是EC11引脚接ArduinoAD2BD3CGND按键1脚D4按键2脚GND这里有个关键细节EC11公共端C要接GND然后A、B两个引脚都启用Arduino的内部上拉INPUT_PULLUP。为什么这么做因为机械触点悬空时电平不确定直接读会有大量随机噪声。加上拉后触点断开时引脚被拉到高电平触点闭合时被拉到低电平波形的边沿才清晰可靠。反过来把公共端接VCC、A/B接下拉也可以工作但一来Arduino内部没有下拉电阻可供方便启用二来在某些供电不稳的场景下上拉到VCC会导致触点压差变小、更易受干扰。所以标准做法就是公共端接GNDA/B内部上拉。按键部分同理一个脚接GPIO、一个脚接GNDGPIO配置为INPUT_PULLUP按下时读到低电平。如果想让波形更干净可以在A、B两个引脚上各对GND接一个10nF到100nF的瓷片电容构成简单的RC低通滤波。取值需要斟酌电容越大消抖效果越好但会把边沿变缓高速旋转时可能翻转不及时造成丢步。我用47nF比较多慢转、快转都比较稳。原型阶段也可以用纯软件消抖先跑通。3.2 驱动代码状态机查表法完整示例下面这段代码是我目前最常用的Arduino实现挂在A、B两个引脚的中断上任何一个边沿变化都会进入中断服务函数查表更新计数// EC11 编码器驱动示例状态机查表法 // 接线A - D2, B - D3, C - GND // A、B均使用内部上拉 INPUT_PULLUP #define ENC_A 2 #define ENC_B 3 volatile int32_t encoderValue 0; volatile uint8_t oldState 0; // 状态转移表索引 (oldState 2) | newState // 状态定义bit1 A, bit0 B const int8_t stateTable[16] { 0, 1, -1, 0, -1, 0, 0, 1, 1, 0, 0, -1, 0, -1, 1, 0 }; void encoderISR() { uint8_t newState (digitalRead(ENC_A) 1) | digitalRead(ENC_B); int8_t key (oldState 2) | newState; encoderValue stateTable[key]; oldState newState; } void setup() { pinMode(ENC_A, INPUT_PULLUP); pinMode(ENC_B, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(ENC_A), encoderISR, CHANGE); attachInterrupt(digitalPinToInterrupt(ENC_B), encoderISR, CHANGE); Serial.begin(115200); } void loop() { Serial.println(encoderValue); delay(50); }代码逻辑不复杂但有两个点要特别说明第一为什么A、B两个引脚都要挂中断因为状态机需要在每个状态变化的边沿都及时更新。如果只挂一个引脚另一个引脚变化时状态不更新那么状态机就可能漏掉半步或无法判断方向。两个都挂CHANGE中断互相绑定状态更新才完整。第二中断服务函数里只用位运算和查表没有串口打印、没有delay()这是为了保证中断执行时间极短快速旋转时也能跟上脉冲频率。实测EC11手旋速度下即使每秒转好几圈这个中断也完全不会丢步。真正会丢步的场景是中断服务函数里有耗时操作记住这一点代码性能基本就稳了。3.3 实际踩过的三个坑坑一方向是反的。这是最常见也最容易解决的——A、B两根线接反了或者你定义的引脚顺序和我的相反。解决办法很简单把A、B对调接线或者在代码里对encoderValue取负。我一般选对调接线因为代码可读性更好。坑二旋转一格数字跳2个。多半是抖动被当成有效边沿。我实测发现如果只用中断方向判断不查状态表慢转一格就会出现这个问题换成状态机后抖动产生的非法状态转移会被过滤掉但如果是触点老化特别严重的编码器还是建议在硬件上加47nF电容双管齐下最稳。坑三快速旋转丢步。检查中断服务函数里有没有串口打印、delay、digitalWrite之类耗时操作另外还要确认是否用了一套真正的“边沿变化即触发”机制——CHANGE模式不能省略成RISING否则只认一半边沿丢步是必然的。软件层面没问题后再看看是不是电容取值太大导致边沿太缓适当减小电容。4. STM32实战外部中断与定时器编码器模式两条路线4.1 为什么STM32上优先考虑定时器编码器模式到了STM32上除了照搬Arduino的引脚中断状态机外还有一条更省心的路定时器编码器模式。STM32的通用定时器TIM2、TIM3、TIM4等内部自带正交编码器接口硬件会自动对A、B两相的边沿做状态判断和计数你直接读一个计数器寄存器就能拿到位置值还能通过方向标志位判断正反转。CPU全程不参与解码过程零占用。这是STM32比Arduino有优势的地方。Arduino没有硬件正交解码器只能靠引脚中断而STM32连中断都不用写把定时器配置好剩下的交给外设。我的建议是只要你的STM32型号有定时器编码器模式就优先用它别手动造轮子。如果用的芯片不支持或者你只是临时想快速验证再退回外部中断法。4.2 路线一外部中断法兼容任何STM32型号外部中断法本质和Arduino的状态机一致只是换成了HAL库写法。CubeMX里把A、B两个引脚配置为GPIO_EXTI触发模式选择Rising/Falling也就是双边沿同时启用内部上拉。生成代码后在中断回调里写查表状态机// EC11 编码器外部中断法STM32 HAL库 #define ENC_A_PIN GPIO_PIN_0 #define ENC_B_PIN GPIO_PIN_1 #define ENC_A_PORT GPIOA #define ENC_B_PORT GPIOA volatile int32_t enc_count 0; volatile uint8_t old_state 0; static const int8_t state_table[16] { 0, 1, -1, 0, -1, 0, 0, 1, 1, 0, 0, -1, 0, -1, 1, 0 }; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin ENC_A_PIN || GPIO_Pin ENC_B_PIN) { uint8_t new_state (HAL_GPIO_ReadPin(ENC_A_PORT, ENC_A_PIN) 1) | HAL_GPIO_ReadPin(ENC_B_PORT, ENC_B_PIN); enc_count state_table[(old_state 2) | new_state]; old_state new_state; } }注意CubeMX生成的HAL_GPIO_EXTI_Callback是弱函数weak你在用户代码里重新定义即可。多个外部中断共用同一个回调时函数里一定要先判断GPIO_Pin是不是编码器的引脚否则其他中断也会落到这个回调里。外部中断法适合芯片型号特殊、没有编码器模式或者你已经有现成状态机代码、不想改结构的场景。但中断频繁触发时CPU还是有开销的如果项目里同时有大量实时任务建议还是用下面这条路。4.3 路线二定时器编码器模式推荐CubeMX里配置步骤很简洁。以STM32F103的TIM3为例找到TIM3的Channel1和Channel2把Combined Channels选为Encoder Mode。在Parameter Settings里Encoder Mode选择Encoder Mode TI1 and TI2A、B两相都接。Counter Period填0xFFFF16位定时器的最大值或者填你需要的计数范围。Prescaler保持0因为编码器信号不需要分频。输入滤波Input Filter可以设为适当值比如取0x0F级别这相当于在硬件层面做了一定消抖。生成代码后在用户代码里启动编码器模式HAL_TIM_Encoder_Start(htim3, TIM_CHANNEL_ALL);读取位置值有两种方式。如果只是短距离计数直接读CNT因为计数器本身带符号可以用(int16_t)强转获得正确的增量值int16_t position (int16_t)__HAL_TIM_GET_COUNTER(htim3); uint8_t direction_down __HAL_TIM_IS_TIM_COUNTING_DOWN(htim3);这里有个工程经验值得说16位定时器CNT范围是0-0xFFFF连续正转超过32767就会溢出回卷。如果把计数当成uint16_t直接读会看到一个从65535跳到0的突变但如果用(int16_t)来读取由于补码溢出的特性正转到超过32767后变成负值再做增量计算时差值仍然是对的。也就是说你不需要特殊处理就能得到相对增量。但如果项目要求长时间记录绝对位置比如断电前保存位置值就需要在溢出中断里把高位累计起来。TIM3溢出中断思路如下volatile int32_t encoder_accumulator 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { // 判断溢出方向 if (__HAL_TIM_IS_TIM_COUNTING_DOWN(htim3)) { encoder_accumulator--; } else { encoder_accumulator; } } }然后绝对位置就是高位累加值乘以65536再加上当前CNT值。做到这一步定时器编码器模式就算彻底打通了。4.4 定时器编码器模式的补充说明硬件编码器模式虽然省CPU但有个容易被忽略的点它本身不做防抖处理。引脚上的抖动同样会被硬件当成有效边沿计数。所以产品化设计时我还是建议在A、B引脚对GND加10nF到100nF电容或者在CubeMX里把输入滤波设置为合适档位。另外EC11的A、B引脚接到STM32时注意电平问题——STM32的GPIO一般是3.3V逻辑电平EC11作为纯机械开关本身无所谓5V还是3.3V但如果你的EC11模块板上有指示灯、上拉到了5V就需要确认引脚是否容忍5V输入查看数据手册里的FT标记。3.3V系统上用内部上拉时引脚悬空状态下是高电平触点接GND闭合后是低电平电平摆幅只有3.3V抗干扰能力比5V稍弱所以更建议加个小电容。如果旋转方向反了可以不用重新接线直接在CubeMX里把A相或B相的极性Polarity取反效果等同于在软件里对计数取负非常方便。5. 从“计数”到“测速”EC11的进阶玩法与位置归零5.1 单位时间脉冲增量法M法测转速编器计数的直接产物是位置但很多时候我们需要转速比如数控手轮的倍率切换、音量旋钮的速度自适应。最简单的是M法每固定时间窗口比如100ms读一次计数器把两次读数之差除以时间窗口再除以每转脉冲数就得到每秒转数。// Arduino 上的 M法测速示例 // 依赖前面的状态机计数 encoderValue const uint32_t interval_ms 100; uint32_t lastTime 0; int32_t lastPos 0; void loop() { uint32_t now millis(); if (now - lastTime interval_ms) { int32_t pos encoderValue; int32_t delta pos - lastPos; float revs_per_sec (float)delta / interval_ms * 1000.0f / PULSES_PER_REV; lastPos pos; lastTime now; Serial.println(revs_per_sec, 3); } }这个方法简单但在低速时误差不小——如果时间窗口内只有少数几个脉冲速度值会跳来跳去。对旋钮类应用来说因为人手旋转速度一般不快不慢M法够用如果是精密转台或手轮就要考虑T法也就是测量相邻两个脉冲的时间间隔用“一个脉冲对应多少角度”换算速度。T法适合低速精确测速M法适合中高速各有适用边界。5.2 增量编码器的软肋断电丢位置需要“归零”EC11是增量式编码器它只知道“相对位置”——上电之后计数器从0开始旋转了多少增量计数器跟着变但它不记得断电前的位置。所以每次上电必然要从一个确定的基准点开始这就是“归零”。常见的归零方式有三种第一种是机械回零上电后程序控制电机或提示用户转到一个限位开关或光电开关所在的原点位置碰到原点后把计数器清零。这种用于数控手轮转台这类需要绝对基准的设备。第二种是按键清零旋钮类产品里最常见——按下编码器自带的按键把当前位置设为零点。很多屏幕菜单里设置的“归零”按钮本质上就是这个操作。第三种是掉电保存用EEPROM或Flash周期性地把计数值存起来上电时读回来。这里要注意写寿命问题不能每次转动都写Flash一般是定时存或者只在掉电前存。用STM32时可以用内部Flash模拟EEPROM用Arduino时就是EEPROM库但频繁写入一样有寿命问题。另外要提一句EC11是增量式编码器它永远给不出绝对角度如果需要掉电也不丢角度得选带绝对角度输出的磁性编码器比如AS5600、TLE5012价格高一个量级不是所有场景都需要。5.3 旋钮手感与代码的配合最后分享一个很实际的经验。EC11内部有机械棘爪定位每转一档有清晰的“咯噔”感。但如果你把状态机做到4倍频一个物理档位会产生4个计数而棘爪的定位在中间这样软件上就面临一个问题位置值和档位感不完全对应。解决方式也很简单在需要“一档一步”的交互场景里把计数结果做一次右移2位的操作找回物理档位粒度或者干脆只使用1倍频也就是只认一个方向的边沿比如只认A相上升沿而不是4倍频。用状态机做4倍频可以获得更平滑的位置值适合音量滑动、平滑调节这类场景用1倍频则跟棘爪手感完全对齐适合菜单选择这种需要精确档位的场景。我在做旋钮音量时通常保留4倍频但显示前除以4并做四舍五入这样既有平滑感又有明确档位手感最舒服。6. 最后提醒机械与软件要一起调示波器比什么都好用做了不少EC11的项目之后我最大的体会是编码器调试问题一半出在机械/硬件一半出在代码。很多时候你以为自己在改消抖算法改了好几个小时都没有效果拿逻辑分析仪一看才发现公共端根本没接对地或者两个引脚被杜邦线插反了甚至有的模块板自带上拉到了5V而MCU是3.3V。所以我强烈建议手上备一个几十块钱的八通道逻辑分析仪。调试步骤非常固定先接好线打开逻辑分析仪慢转旋钮抓A、B两相波形确认两路方波相位差确实是90度左右确认每转一档有一个完整脉冲。波形正常之后再看代码方向最后再讨论消抖。我见过太多人跳过波形检查直接调软件最后绕了一大圈才发现问题在硬件。如果波形上毛刺明显可以先在A、B引脚对GND加电容从47nF开始试看毛刺是否消失。注意如果加了电容之后快速旋转丢步明显就换小一点的电容比如10nF属于两害相权取其轻。软件层面再提一个细节多个中断同时存在时不要让编码器中断和其他高频率中断长时间抢CPU。STM32里如果使用了外部中断法可以在中断回调里只做状态机查表和计数累加绝不打印、不做逻辑判断以外的任何事。Arduino里更是如此中断服务函数保持极短这是所有编码器程序稳定运行的底线。关于EC11解码这件事走到这一步原理、代码、坑、硬件建议都有了。如果你正在调一个旋钮项目把波形图看懂把状态机表背下来或者存进工程模板里再决定用Arduino中断还是STM32编码器模式基本就能告别乱跳和丢步。我自己第一次调通状态机查表之后之后再碰到任何AB相编码器都是直接套用这套思路最多改改引脚和寄存器配置代码部分几乎没有返过工。
返回列表