3、GPIO模拟AB相解码:使用GPIO中断捕获边沿、软件状态机实现正交解码、抖动消除与毛刺滤波
上一章我们聊了编码器的硬件接口和信号特性。这一章,咱们要动真格的了——用GPIO中断配合软件状态机,把AB相的脉冲翻译成位置和方向。
说实话,我刚开始做这个的时候,觉得不就是数脉冲嘛,有什么难的?结果第一次上机测试,电机一转,数据全乱套了。后来才明白,正交解码的坑,全藏在边沿抖动和状态转换的细节里。
3.1 正交解码的核心逻辑
AB相编码器输出两路方波,相位差90度。A相领先B相90度,表示正转;B相领先A相90度,表示反转。解码的关键,就是捕捉每一组边沿组合,判断方向并累加计数值。
一个完整的正交周期包含4个边沿:A上升、B上升、A下降、B下降。每个边沿都对应一个唯一的状态组合。你想想看,如果我们能捕获每一个边沿,就能实现4倍频的精度。
| 边沿事件 | A电平 | B电平 | 正转方向 | 反转方向 |
|---|---|---|---|---|
| A上升沿 | 1 | 0→1 或 1→0 | +1 | -1 |
| A下降沿 | 0 | 0→1 或 1→0 | +1 | -1 |
| B上升沿 | 0→1 或 1→0 | 1 | +1 | -1 |
| B下降沿 | 0→1 或 1→0 | 0 | +1 | -1 |
这张表看着简单,但实际写代码的时候,很多人会搞混。我个人的习惯是画一张状态转移图,把4个状态和8种跳转都列出来,再对着写代码,基本不会出错。
3.2 软件状态机实现
状态机是正交解码的骨架。我们用两个GPIO引脚同时捕获A相和B相的电平,组成一个2位的状态值。每次中断触发时,读取当前状态,查表得到方向增量。
// 状态定义:bit1 = A, bit0 = B
#define STATE_00 0
#define STATE_01 1
#define STATE_11 3
#define STATE_10 2
// 方向查找表:old_state << 2 | new_state
// 正转 +1,反转 -1,无效 0
const int8_t dir_table[16] = {
// 00 01 11 10
0, -1, 0, 1, // 从 00 出发
1, 0, -1, 0, // 从 01 出发
0, 1, 0, -1, // 从 11 出发
-1, 0, 1, 0 // 从 10 出发
};
int32_t encoder_count = 0;
uint8_t last_state;
void encoder_isr(void) {
uint8_t a = gpio_read(A_PIN);
uint8_t b = gpio_read(B_PIN);
uint8_t new_state = (a << 1) | b;
uint8_t index = (last_state << 2) | new_state;
int8_t delta = dir_table[index];
if (delta != 0) {
encoder_count += delta;
last_state = new_state;
}
}
这段代码看起来简单,但有几个关键点要注意。第一,查表索引必须用旧状态左移2位再或上新状态,顺序不能反。第二,delta为0的情况要跳过,否则会重复计数。第三,中断里只做最少的操作,别在里面打印日志或者调用延时函数。
性能要点:查表法比if-else链快3-5倍。在高速编码器场景下,这个差距可能就是能不能跑满100kHz中断频率的分水岭。
3.3 抖动消除与毛刺滤波
理想很丰满,现实很骨感。实际电机运行时,编码器信号上全是毛刺。机械振动、电磁干扰、线缆耦合,都会在边沿附近产生多次跳变。如果不做处理,一个边沿可能触发十几次中断,计数值直接飞掉。
我曾经在一个伺服项目上吃过这个亏。电机低速运行时,编码器每转只产生几百个脉冲,但毛刺让计数值在1秒内跳了几千。后来花了整整两天才定位到问题——就是缺了毛刺滤波。
3.3.1 硬件滤波方案
最简单的办法是在GPIO引脚上加RC低通滤波器。R取1kΩ~10kΩ,C取10nF~100nF,截止频率大概在几百Hz到几kHz。这个方案成本低,但会引入相位延迟,影响高速响应。
另一个选择是用施密特触发器,比如74HC14。它能消除边沿附近的微小抖动,而且不引入延迟。我个人的习惯是,如果编码器线缆超过1米,一定会在接收端加施密特触发器。
3.3.2 软件去抖策略
软件去抖更灵活,也更容易调整。常用的方法有三种:
- 时间窗口法:中断触发后,延迟一段时间再读取电平。如果电平稳定,才认为是有效边沿。
- 连续采样法:在中断里连续采样3-5次,如果结果一致,才更新状态。
- 边沿锁定法:检测到边沿后,锁定该通道,忽略后续中断,直到另一个通道触发。
我比较推荐时间窗口法,实现简单,效果也够用。下面是一个示例:
#define DEBOUNCE_US 50 // 50微秒去抖窗口
volatile uint32_t last_tick = 0;
void encoder_isr(void) {
uint32_t now = get_tick_us();
if ((now - last_tick) < DEBOUNCE_US) {
return; // 在去抖窗口内,忽略
}
last_tick = now;
// 正常的解码逻辑
// ...
}
注意:去抖时间不能设得太长。50微秒对于100kHz的编码器信号来说,已经占用了5个脉冲周期。如果设成200微秒,高速时就会丢脉冲。我一般会根据编码器最大频率来算:去抖时间 < 1/(4 × 最大频率)。
3.4 完整的状态机与滤波融合
把状态机和去抖逻辑结合起来,就得到了一个完整的解码驱动。下面是我在实际项目中用过的结构:
typedef struct {
int32_t count;
uint8_t last_state;
uint32_t last_tick;
uint32_t debounce_us;
} encoder_t;
void encoder_init(encoder_t *enc, uint32_t debounce_us) {
enc->count = 0;
enc->last_state = 0;
enc->last_tick = 0;
enc->debounce_us = debounce_us;
}
void encoder_isr_handler(encoder_t *enc) {
uint32_t now = get_tick_us();
if ((now - enc->last_tick) < enc->debounce_us) {
return;
}
enc->last_tick = now;
uint8_t a = gpio_read(A_PIN);
uint8_t b = gpio_read(B_PIN);
uint8_t new_state = (a << 1) | b;
uint8_t index = (enc->last_state << 2) | new_state;
int8_t delta = dir_table[index];
if (delta != 0) {
enc->count += delta;
enc->last_state = new_state;
}
}
这个结构体封装了所有状态,可以同时管理多个编码器。我在四轮机器人底盘上用过,四个轮子各一个编码器,每个都跑这个驱动,效果很稳定。
3.5 性能分析与调优
GPIO中断解码的性能瓶颈主要在中断响应时间和处理时间上。我整理了一个对比表,方便你评估:
| 指标 | STM32 F4 @168MHz | ESP32 @240MHz | Linux GPIO (树莓派) |
|---|---|---|---|
| 中断延迟 | ~50ns | ~100ns | ~2-5μs (有调度抖动) |
| 单次中断处理 | ~200ns | ~400ns | ~5-10μs |
| 最大安全频率 | ~500kHz | ~250kHz | ~50kHz |
| 去抖窗口 | 10-50μs | 20-100μs | 50-200μs |
你看,Linux下的GPIO中断延迟比MCU大一个数量级,主要原因是内核调度和中断上下文切换。如果你在Linux上做编码器驱动,建议用硬件定时器轮询代替中断,或者用RT内核。
小技巧:在MCU上,可以把GPIO中断优先级设到最高,避免被其他中断打断。我习惯把编码器中断放在NVIC的0或1优先级,和系统滴答定时器同级。
3.6 本章知识结构图
下面这张图总结了GPIO模拟AB相解码的完整流程,从硬件信号到软件处理,再到最终的位置输出: