1. QDEC库概述:基于状态机的高效正交解码器
QDEC(Quadrature Decoder)是一个轻量级、高效率的正交编码器解码库,采用纯状态机(State Machine)架构实现,MIT开源许可。其核心设计目标是在无硬件QDEC外设的MCU平台上(如Arduino Uno、ATmega328P、ESP32 GPIO输入模式、STM32通用IO等),以极低资源开销完成可靠、抗抖动的正交信号解析。该库不依赖中断服务程序(ISR)轮询或复杂滤波算法,而是通过精确建模AB相正交信号的有限状态转移逻辑,从根本上规避机械抖动(bounce)引发的误计数问题。
与传统“边沿计数+软件消抖”方案不同,QDEC将解码过程完全抽象为确定性状态跳转:每个采样周期读取A/B两路输入电平(0/1),依据当前状态和新输入组合,查表决定下一状态及是否触发有效事件(CW顺时针/CCW逆时针)。这种设计使解码逻辑具备数学可验证性,且代码体积极小——完整状态转移表仅占用32字节(支持半步模式)或64字节(全步+半步双模式),适合Flash资源紧张的8位MCU。
工程实践中,QDEC特别适用于以下场景:
- 使用通用GPIO模拟QDEC功能的低成本主控(如ATmega328P、RP2040 PIO未启用时)
- 需要多路独立编码器解码但硬件QDEC通道不足的系统(如STM32F103仅有1个QDEC外设,而项目需4路旋钮)
- 对实时性要求严苛、需避免中断延迟影响解码精度的运动控制系统
- 教学演示中清晰展示正交编码原理与状态机建模方法
2. 正交编码原理与QDEC设计哲学
2.1 AB相信号的本质特征
旋转式或线性式增量编码器输出两路方波信号A和B,相位差恒为90°(π/2)。当轴正向旋转时,A相领先B相;反向旋转时,B相领先A相。标准四状态循环如下(按时间顺序):
| 状态序号 | A电平 | B电平 | 物理含义 |
|---|---|---|---|
| 0 | 0 | 0 | 起始参考点 |
| 1 | 1 | 0 | A上升沿(正向第一步) |
| 2 | 1 | 1 | B上升沿(正向第二步) |
| 3 | 0 | 1 | A下降沿(正向第三步) |
一个完整机械“刻度”(detent)对应一次四状态循环。若编码器每刻度产生完整四状态,则称全步模式(Full-step);若电路设计或器件特性导致每刻度仅经历两状态(如仅检测A/B上升沿),则称半步模式(Half-step)。QDEC库同时支持两种模式,由用户根据编码器电气特性选择。
2.2 状态机设计的核心工程考量
QDEC的状态机设计直指嵌入式开发三大痛点:
- 抗抖动(Debouncing):机械触点抖动通常持续数毫秒,表现为单路信号在高低电平间快速振荡(如A从0→1→0→1多次)。传统计数法对此极为敏感。QDEC通过强制要求连续2次(半步)或4次(全步)同向状态转移才触发事件,使单路抖动仅在中间状态间反复横跳,无法形成有效循环,从而天然免疫抖动。
- 确定性响应:所有状态转移均通过查表实现,执行时间恒定(典型值<1μs on AVR @16MHz),无分支预测失败风险,满足硬实时需求。
- 内存效率:状态转移表采用紧凑编码。以半步模式为例,4个有效状态(0b00, 0b01, 0b11, 0b10)映射为索引0~3,每个表项存储2字节:低字节为下一状态ID,高字节为事件标志(0x00=无事件,0x01=CW,0xFF=CCW)。整个表仅32字节。
关键洞察:QDEC不“消除”抖动,而是重构解码逻辑使其对抖动不敏感。这是状态机方法论在嵌入式信号处理中的典范应用。
3. 状态机架构详解:半步与全步模式
3.1 半步模式(Half-step)状态图
半步模式要求两次同向状态转移即触发事件,符合多数硬件QDEC外设行为及主流开源库惯例。其状态图包含4个有效状态(Start, CW A, CW B, CCW A, CCW B —— 实际为5状态,但Start与Mid合并优化):
+----+ A=0,B=0 +----+ |Start|<---------------|Mid | +----+ +----+ | ^ | A=1,B=0| |A=0,B=1 A=1,B=1|A=0,B=0 v | v +----+ A=1,B=0 +----+ |CW A|---------------->|CW B| +----+ +----+ ^ | |A=0,B=1 |A=1,B=1 | v +----+ A=0,B=0 +----+ |CCW B|<----------------|CCW A| +----+ +----+- 有效转移:Start→CW A(A↑)→CW B(B↑)→触发CW事件;Start→CCW A(B↑)→CCW B(A↓)→触发CCW事件
- 无效转移(防抖关键):任何状态遇到“反向”输入(如CW A状态下A=0,B=0)均跳回Start/Mid,清空计数上下文
3.2 全步模式(Full-step)状态图
全步模式需四次同向转移才触发事件,更适合带明确刻度的旋转编码器(每刻度严格对应一完整四状态循环):
+----+ A=0,B=0 +----+ |Start|<---------------|Mid | +----+ +----+ | ^ | A=1,B=0| |A=0,B=1 A=1,B=1|A=0,B=0 v | v +----+ A=1,B=0 +----+ A=1,B=1 +----+ |S1 |---------------->|S2 |---------------->|S3 | +----+ +----+ +----+ ^ | | |A=0,B=1 |A=0,B=0 |A=1,B=0 | v v +----+ A=0,B=0 +----+ A=0,B=1 +----+ |S3' |<----------------|S2' |<----------------|S1' | +----+ +----+ +----+- 有效转移链:Start→S1→S2→S3→触发CW;Start→S1'→S2'→S3'→触发CCW
- 抖动抑制更强:单路抖动最多造成S1↔S1'振荡,无法进入S2,彻底杜绝误触发
3.3 状态转移表实现
QDEC将状态图编译为静态查找表。以半步模式为例,state_table_halfstep[]定义如下(伪代码):
// 索引 = (current_state << 2) | (new_input_pattern) // new_input_pattern: 0b00=0, 0b01=1, 0b11=2, 0b10=3 const uint8_t state_table_halfstep[16] = { // 当前状态0 (Start), 输入00->保持0, 无事件 0x00, 0x00, 0x00, 0x00, // 当前状态0, 输入01->转态1(CW A), 无事件 0x01, 0x00, 0x00, 0x00, // 当前状态0, 输入11->转态2(CW B), 无事件 0x02, 0x00, 0x00, 0x00, // 当前状态0, 输入10->转态3(CCWA), 无事件 0x03, 0x00, 0x00, 0x00, // ... 后续12项定义其他状态转移 };实际库中采用更紧凑的16字节表(每个表项1字节),通过位域编码:低2位=下一状态,高6位=事件类型。此设计使AVR平台下Update()函数执行时间稳定在1.2μs以内。
4. API接口与使用范式
QDEC提供极简API,仅两个静态函数,体现“零成本抽象”设计哲学:
4.1 核心API函数
| 函数名 | 原型 | 功能说明 |
|---|---|---|
Init() | void QDEC::Init(uint8_t pin_a, uint8_t pin_b) | 初始化GPIO:配置pin_a/pin_b为INPUT_PULLUP模式,确保无外部上拉时仍能读取确定电平。内部重置状态机至Start态。 |
Update() | int8_t QDEC::Update() | 读取A/B引脚当前电平,查表更新状态机,返回事件码: • +1:检测到顺时针(CW)事件• -1:检测到逆时针(CCW)事件• 0:无有效事件 |
注意:
Update()必须被周期性调用(推荐10kHz~50kHz采样率),频率需高于编码器最大输出频率(通常≥5×机械最大转速×PPR)。过低采样率会导致状态丢失。
4.2 典型使用示例(Arduino平台)
#include <QDEC.h> #define ROTARY_PIN_A 2 #define ROTARY_PIN_B 3 QDEC encoder; volatile int32_t position = 0; // 全局位置计数器 void setup() { Serial.begin(115200); // 初始化QDEC,指定A/B引脚 encoder.Init(ROTARY_PIN_A, ROTARY_PIN_B); } void loop() { int8_t event = encoder.Update(); if (event != 0) { position += event; // 累加事件:+1或-1 Serial.print("Position: "); Serial.println(position); // 实际应用中可触发LED、更新LCD、发送CAN帧等 if (event > 0) { digitalWrite(LED_BUILTIN, HIGH); // CW时点亮LED delay(50); digitalWrite(LED_BUILTIN, LOW); } } delayMicroseconds(100); // 保证采样率≈10kHz }4.3 STM32 HAL库集成示例
在STM32CubeIDE中,需先配置GPIO为输入模式(无需开启外部中断):
#include "qdec.h" // 假设已移植为C风格 QDEC_HandleTypeDef hqdec; int32_t g_position = 0; void MX_GPIO_Init(void) { __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = GPIO_PIN_0 | GPIO_PIN_1; // PA0=A, PA1=B GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; // 关键:必须上拉 HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); // 初始化QDEC句柄 hqdec.pin_a = GPIO_PIN_0; hqdec.pin_b = GPIO_PIN_1; hqdec.port = GPIOA; QDEC_Init(&hqdec); while (1) { int8_t evt = QDEC_Update(&hqdec); if (evt) { g_position += evt; // 通过HAL_UART_Transmit发送位置数据 char buf[32]; sprintf(buf, "POS:%ld\r\n", g_position); HAL_UART_Transmit(&huart2, (uint8_t*)buf, strlen(buf), HAL_MAX_DELAY); } HAL_Delay(100); // 10Hz采样,适合低速旋钮 } }5. 工程实践指南:调试、配置与性能优化
5.1 常见问题诊断(Troubleshooting)
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 方向相反:顺时针旋转报告CCW事件 | A/B信号物理接反或逻辑定义颠倒 | 三选一: 1. 交换 ROTARY_PIN_A与ROTARY_PIN_B宏定义2. 调换 QDEC::Init()参数顺序3. 硬件上交换编码器A/B线至MCU引脚 |
| 事件丢失:快速旋转时计数不准 | 采样率不足或Update()调用被阻塞 | 提高loop()中delayMicroseconds()参数(降低延时);检查是否有delay()长延时阻塞;改用FreeRTOS任务以固定周期调用 |
| 持续触发:静止时不断报告CW/CCW | 编码器接触不良或GPIO浮空 | 检查Init()中是否启用内部上拉(INPUT_PULLUP);测量A/B引脚静止电压是否稳定在VCC/GND;更换编码器 |
5.2 模式选择决策树
选择半步还是全步模式,取决于编码器的电气特性和应用需求:
graph TD A[编码器类型] --> B{是否带明确机械刻度 detent?} B -->|是| C{每刻度是否严格经历4状态?} B -->|否| D[选用半步模式] C -->|是| E[选用全步模式] C -->|否| F[实测验证:缓慢旋转1刻度,用逻辑分析仪捕获AB波形] F --> G{波形是否呈现标准四状态循环?} G -->|是| E G -->|否| D经验法则:对于Bourns PEC11系列等常见带刻度旋钮,全步模式可确保每刻度仅1次事件;对于无刻度线性编码器或高速电机反馈,半步模式提供更高分辨率。
5.3 FreeRTOS任务化封装
在FreeRTOS环境中,推荐将QDEC集成到独立任务中,避免阻塞其他任务:
QueueHandle_t qdec_queue; void qdec_task(void *pvParameters) { QDEC encoder; encoder.Init(ROTARY_PIN_A, ROTARY_PIN_B); for(;;) { int8_t evt = encoder.Update(); if (evt != 0) { // 发送事件到队列,由主任务处理 xQueueSend(qdec_queue, &evt, portMAX_DELAY); } vTaskDelay(10); // 100Hz采样率 } } // 主任务中创建队列和QDEC任务 void app_main() { qdec_queue = xQueueCreate(10, sizeof(int8_t)); xTaskCreate(qdec_task, "QDEC", 256, NULL, 5, NULL); for(;;) { int8_t evt; if (xQueueReceive(qdec_queue, &evt, portMAX_DELAY) == pdTRUE) { // 处理事件:更新GUI、控制电机等 handle_encoder_event(evt); } } }6. 源码级实现剖析:从状态图到机器码
6.1 状态转移表生成逻辑
QDEC库的state_table.h中,半步模式表由Python脚本自动生成,确保数学正确性。核心算法如下:
# 生成半步状态转移表(简化版) states = ['START', 'CW_A', 'CW_B', 'CCW_A', 'CCW_B'] # 定义每个状态对4种输入(00,01,11,10)的转移 transitions = { 'START': {'00':'START','01':'CW_A','11':'CW_B','10':'CCW_A'}, 'CW_A': {'00':'START','01':'CW_B','11':'CW_B','10':'START'}, # 无效转移回START 'CW_B': {'00':'START','01':'CW_B','11':'CW_B','10':'CW_A'}, # 有效转移至CW_A触发事件 # ... 其他状态 }脚本遍历所有状态-输入组合,依据状态图规则填入下一状态ID及事件标志,最终输出C数组。此流程杜绝人工查表错误。
6.2 Update()函数汇编级优化
在AVR-GCC -O2优化下,Update()核心循环编译为12条指令(含函数调用开销),关键路径仅7条:
; 读取A/B引脚(假设PIN_A=PORTD.2, PIN_B=PORTD.3) in r16, PIND ; 读取PORTD寄存器 andi r16, 0x0C ; 屏蔽其他位,只留PD2/PD3 swap r16 ; 交换半字节,使A/B位对齐 andi r16, 0x03 ; 得到2位输入模式 ; 查表:r16为索引,从state_table中取值 ld r17, Z+ ; 加载下一状态 ld r18, Z ; 加载事件码 ; 更新全局状态变量 st X+, r17 ; 存储新状态 ; 返回事件码 mov r24, r18此实现证明:状态机解码可在8位MCU上达成亚微秒级确定性响应,远超机械编码器的物理响应极限(典型抖动时间>5ms)。
7. 扩展应用场景与系统集成
7.1 多编码器并行解码
QDEC库支持实例化多个对象,实现N路独立解码:
QDEC encoder1, encoder2, encoder3; void setup() { encoder1.Init(2,3); // D2/D3 encoder2.Init(4,5); // D4/D5 encoder3.Init(6,7); // D6/D7 } void loop() { static uint32_t last_ms = 0; if (millis() - last_ms > 1) { // 1kHz采样 last_ms = millis(); int8_t e1 = encoder1.Update(); int8_t e2 = encoder2.Update(); int8_t e3 = encoder3.Update(); // 分别处理三路事件... } }7.2 与显示驱动协同(LVGL示例)
在ESP32+ST7789显示屏上,QDEC事件可直接驱动LVGL滚轮:
lv_obj_t *roller; void qdec_event_handler(lv_event_t *e) { lv_event_code_t code = lv_event_get_code(e); if(code == LV_EVENT_KEY) { uint32_t key = *(uint32_t*)lv_event_get_param(e); if(key == LV_KEY_RIGHT) lv_roller_add_option(roller, "New", LV_ROLLER_MODE_INFINITE); else if(key == LV_KEY_LEFT) lv_roller_del_option(roller, 0); } } // 在QDEC Update后触发LVGL事件 if (evt == 1) lv_event_send(roller, LV_EVENT_KEY, &LV_KEY_RIGHT); else if (evt == -1) lv_event_send(roller, LV_EVENT_KEY, &LV_KEY_LEFT);7.3 硬件加速建议
对超高性能需求场景(>100kHz编码器),可结合硬件外设:
- STM32 QEI模式:将QDEC引脚接入TIMx_CH1/TIMx_CH2,启用编码器接口,QDEC库退化为状态校验器
- ESP32 RMT外设:用RMT接收AB相信号,DMA传输至内存,QDEC在DMA完成中断中解析
- FPGA协处理器:将状态机烧录至FPGA,MCU仅读取FIFO中的事件流
此类混合架构在工业伺服驱动中已被验证可实现2MHz脉冲处理能力。
QDEC库的价值不仅在于其代码本身,更在于它提供了一种以状态机思维重构嵌入式信号处理问题的方法论。当面对触摸按键、霍尔传感器阵列、多路开关矩阵等具有确定性时序特征的输入设备时,工程师可依循相同范式:绘制状态图→定义有效/无效转移→生成查表→实现零抖动解析。这种从物理现象到数学模型再到机器实现的完整链条,正是嵌入式底层开发的核心能力。