尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

学江协科技的编码器计数时遇到计数不准问题

学江协科技的编码器计数时遇到计数不准问题 前言在用 STM32 标准库复现旋转编码器计数例程时几乎人人都会遇到 “正转却减数、反转却加数” 的玄学问题。我最近也踩了这个坑代码逻辑完全正确但计数值 Num 就是会偶尔反向跳变。最终通过自建波形采集系统配合上位机软件 VOFA从信号层面定位并解决了问题。这篇文章将以教程式步骤为主线穿插我的调试经历带你从原理到实战彻底掌握编码器计数与波形诊断方法。硬件平台STM32F103C8T6、普通机械式旋转编码器、CH340 USB 转串口模块、0.96 寸 OLED可选软件Keil MDK 标准库 VOFA 上位机代码是基于江协科技stm32标准库教程中 5-2旋转编码器计次的章节。一、编码器计数原理与原始代码架构1.1 正交信号与电平映射旋转编码器的 A/B 两相输出相位差 90° 的方波。为便于软件判断我们将电平映射为A 相高电平 3低电平 2B 相高电平 1低电平 0这样正转时状态序列为(3,1) → (3,0) → (2,0) → (2,1) → 循环反转则相反。1.2 下降沿检测法1 倍频例程采用下降沿触发B 相下降沿触发中断此时若 A 为低电平判定正转计数值 1A 相下降沿触发中断此时若 B 为低电平判定反转计数值 -1这种每个周期只计一次的方案最简洁但抖动会放大误判概率。1.3 变量架构volatile int16_t Encoder_Count; // 中断中修改的增量 int16_t Num; // 主循环中的累计值 int16_t Encoder_Get(void) { // 取走增量并清零 int16_t temp Encoder_Count; Encoder_Count 0; return temp; } // 主循环中不断累加 Num Encoder_Get();这套分层很清晰但正是这种主循环只偶尔更新 Num 的机制导致后面波形中 Num 看起来 “不同步”。在后续问题一中解决二、波形采集方案设计要看见编码器的真实电平必须把 A/B 相和计数值高频发送到 PC。直接 printf 会严重卡顿因此采用 USART DMA 批量发送。2.1 DMA 通道与参数USART1_TX 固定映射到 DMA1 通道 4硬件绑定不可选错。关键参数选择表格参数取值理由波特率921600CH340 稳定支持有效速率 ≈ 92KB/s缓冲区 BUF_SIZE4096 字节足够存放一帧的文本数据每帧采样点500→ 50 初始 500后来为提升帧率改为 50平衡时间窗口与刷新率主循环延时100ms → 10ms 对应采样点减少帧率从 10Hz 提升到 100Hz高帧率便于观察跳变时刻2.2 数据发送时间线DMA_Cmd(ENABLE)是非阻塞的它只启动传输数据由硬件在后台搬运。50 个采样点每个约 7 字节如2,1,12\n共 350 字节921600 波特率下发送只需约 3.8ms远小于 10ms 的帧间隔确保 DMA 在下一次采样前已完成。2.3 采样点数、延时与波形精度的关系重要很多初学者误以为缓冲区越大、采样点数越多波形就越精细。事实恰恰相反。要理清这几个概念先看这套采集方案的实际工作模式。代码中CaptureAndSend()的 for 循环没有加任何延时CPU 全速读取引脚两次采样之间的间隔只有约 2μs。(为什么是 2μs因为GPIO_ReadInputDataBit 条件判断 sprintf在 72MHz 主频下大约消耗 150 个指令周期150 ÷ 72MHz ≈ 2μs。)50 个采样点采集完成仅耗时约 0.1ms。而主循环末尾的Delay_ms(10)让两次采集之间间隔 10ms。也就是说这套方案是“突发采样 长间隔等待”的模式每 10ms 里只有 0.1ms 在密集采样剩下 9.9ms 都在等待。帧内的数据点非常密集帧和帧之间却有很大的时间空白。VOFA 会按数据到达的顺序连续画线所以看起来是连续的波形但两帧之间有大量没采集到的时间。为什么这套方案对于调试编码器已经够用因为抖动只在转动编码器的瞬间发生。当你在转动旋钮时手指的动作持续几百毫秒而我们的帧间隔只有 10ms每秒 100 帧在一个完整的转动动作中会触发几十次采样。每一次采样的 0.1ms 时间窗口内CPU 以 2μs 的间隔密集抓取 50 个点 —— 这个密度足以捕捉到微秒级的机械弹跳毛刺。换句话说我们不需要看到每一毫秒的信号只需要在转动发生的时刻恰好有一帧 “抓拍” 到抖动就足够了。100Hz 的帧率保证了这一点。理解了这套方案的工作原理和适用范围后就能清楚地划分四个参数各自的职责采样间隔由 for 循环内代码执行速度决定本方案约 2μs。它决定波形的时间分辨率是唯一直接影响波形准确性的参数。间隔越短单位时间内的采样点越密能捕捉到的信号细节就越多。采样点数for 循环次数本方案 50 次。它决定当前帧的时间窗口长度 采样点数 × 采样间隔 ≈ 0.1ms。只影响一帧内能看到多长时间的信号不影响波形精度。缓冲区大小决定单次最多能装多少数据。采样点增加时格式化后的文本变多需要足够大的缓冲区来容纳。它只影响最大时间窗口的上限同样不影响精度。帧间隔由Delay_ms()决定本方案 10ms。决定上位机波形的刷新频率不影响单帧内波形的精度。盲目减小帧间隔并不能让波形更真实 —— 因为帧内仍然是 0.1ms 的突发采样帧外仍然是盲区只是盲区短了一点而已。用一个比喻来总结缓冲区是卡车的货箱容量采样点数是这趟装多少箱货采样间隔是每装一箱货花的时间。换一辆更大的卡车、装更多的货只是让这一趟能覆盖更长的路程但每箱货之间的时间间隔完全没变。真正决定波形能看清多细细节的永远是采样间隔。如果想更进一步从 “够用” 到 “完美”当前突发采样方案的局限在于帧与帧之间的 9.9ms 盲区内信号如何变化完全看不到。要彻底消除盲区需要换一种思路 —— 用硬件定时器驱动均匀采样。具体做法是配置一个定时器每 50μs 触发一次采样中断把结果存入数组存满一批后再用 DMA 发往电脑。这样所有采样点在时间轴上均匀分布没有任何盲区波形就是真实信号的忠实还原。这种方案实现更复杂但适用于需要精确时序分析的场景。本章末尾会给出具体的实现代码供有需要的读者参考。2.4 采样与发送核心代码uint8_t WaveBuf[4096]; void CaptureAndSend(void) { uint16_t len 0; for (int i 0; i 50; i) { uint8_t a GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) ? 3 : 2; uint8_t b GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) ? 1 : 0; int now Num Encoder_Count; // 实时总计数 len sprintf((char*)WaveBuf[len], %d,%d,%d\n, a, b, now); if (len 4096 - 20) break; } // 等待上一次 DMA 传输完成 if (DMA1_Channel4-CCR DMA_CCR1_EN) { while (!DMA_GetFlagStatus(DMA1_FLAG_TC4)); DMA_ClearFlag(DMA1_FLAG_TC4); } DMA_Cmd(DMA1_Channel4, DISABLE); while (DMA1_Channel4-CCR DMA_CCR1_EN); // 等待硬件真正关闭 DMA_SetCurrDataCounter(DMA1_Channel4, len); DMA_Cmd(DMA1_Channel4, ENABLE); }三、踩坑与修复两个关键问题问题 1A/B 相与计数值不同步现象VOFA 上 A/B 已经跳变几个周期Num 却纹丝不动。分析主循环虽然每 10ms 更新了Num Encoder_Get()但CaptureAndSend()内部的 50 次采样循环全部读的是同一个Num值。在这 50 次采样约 0.1ms期间如果编码器转动触发了中断Encoder_Count已经变了但Num没有跟着变。所以波形上看到的 Num 是这一帧开始时的旧值。修复采样时直接使用Num Encoder_Count作为实时计数。同时需要在 main.c 顶部添加外部声明extern volatile int16_t Encoder_Count;为什么必须用 volatileEncoder_Count这个变量很特殊 —— 它在中断服务函数里被修改在主循环的CaptureAndSend()里被读取。对于编译器来说主循环里看不到任何修改Encoder_Count的代码开启优化后编译器会认为这个变量值不变把它缓存到 CPU 寄存器里后续读取直接用寄存器里的旧值不会重新从内存加载。结果是中断已经把变量改了主循环却永远读不到新值。volatile就是告诉编译器这个变量可能在任何时刻被意外修改每次访问都必须老老实实从内存里重新读取不准优化、不准缓存。这是嵌入式开发中中断与主循环共享变量的基础常识少一个volatile低优化等级可能侥幸正常一开 O2 就出玄学 Bug。问题 2计数乱跳 —— 机械抖动现象正转时偶尔减数波形上 A/B 边沿存在密集毛刺。根因机械触点弹跳造成下降沿反复触发中断。修复软件消抖在中断服务函数中增加 1ms 延迟后二次确认电平。void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { for (volatile uint32_t i 0; i 72000; i); // 1ms 延时 if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) 0 GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) 0) Encoder_Count--; EXTI_ClearITPendingBit(EXTI_Line0); } }为什么选择 1ms机械编码器触点的弹跳持续时间通常在几十到几百微秒绝大多数在 1ms 以内就会完全稳定。1ms 的延时足够覆盖最恶劣的抖动情况同时又不会长到影响正常旋转的响应速度 —— 人手快速转动编码器时两个脉冲之间的间隔至少也有几个毫秒1ms 的延迟完全不会丢步。注意事项这种在中断里空等 1ms 的做法会阻塞 CPU仅适合学习调试或慢速旋钮的简单应用场景。如果用在高速旋转或实时性要求高的产品中建议改用 “关中断 硬件定时器确认” 的非阻塞消抖方案进入 EXTI 中断后立刻关闭该中断使能启动一个 1ms 定时器后立刻退出中断在定时器中断里做电平判断和计数更新最后重新开启 EXTI 中断。这样既能消抖又不阻塞主程序和其他中断。四、上位机 VOFA 配置要点串口参数与代码严格一致921600/8N1。协议选择FireWater因为代码发送的是数值,数值,数值\n格式的 CSV 文本JustFloat 不适用。波形通道设置 3 条曲线分别对应 A 相、B 相、计数值。Y 轴范围建议 A/B 设为 -0.5~3.5Num 单独调幅。五、最终代码与修改汇总修改点一览表格序号修改项作用1采样用Num Encoder_Count并 extern解决数据不同步2Encoder_Count加 volatile防止编译优化导致读取旧值3中断内增加 1ms 消抖 二次确认滤除触点弹跳4采样点 50、帧间隔 10ms提高刷新率观察时序5DMA 重配前等待 TC 标志和 EN 清零稳定高速 DMA 传输encoder.c 关键部分volatile int16_t Encoder_Count; void Encoder_Init(void) { RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); // GPIO PB0/PB1 上拉输入EXTI 下降沿触发NVIC 配置... (略) } // 消抖中断函数如前述main.c 主循环extern volatile int16_t Encoder_Count; int16_t Num; int main(void) { Encoder_Init(); OLED_Init(); // 显示 Num 用可选 UART1_Init(); // 包含 DMA 配置 while (1) { Num Encoder_Get(); OLED_ShowSignedNum(1, 5, Num, 5); CaptureAndSend(); Delay_ms(10); } }六、扩展一定时器编码器模式无中断、硬件消抖如果不想处理中断抖动也不满足于 1 倍频的低分辨率STM32 提供了一个更专业的硬件方案 —— 通用定时器的编码器接口模式。6.1 原理简介STM32 的 TIM2~TIM5 这些通用定时器都自带编码器接口。把编码器 A、B 两相分别接到定时器的 CH1 和 CH2 引脚例如 PA0/PA1 对应 TIM2配置成编码器模式后定时器会自动检测两相的边沿硬件完成计数和方向判断完全不需要 CPU 介入。它默认是 4 倍频计数A 相和 B 相的上升沿、下降沿都会触发计数一个完整周期会计 4 次分辨率是中断 1 倍频方案的 4 倍。计数方向由硬件根据相位差自动判断不会出现软件判断失误的问题。此外定时器还内置了数字滤波器ICFilter通过配置采样频率和滤波宽度可以在硬件层面直接滤除触点弹跳连软件消抖都省了。6.2 与中断方案的对比表格对比项中断法1 倍频定时器编码器模式4 倍频计数分辨率1 脉冲 / 周期4 脉冲 / 周期CPU 开销每次触发中断都占用 CPU零 CPU 开销硬件自动计数方向判断软件读取另一相电平判断硬件自动判断无误差消抖方式软件延时或定时器确认硬件数字滤波无需代码最高响应频率受中断响应时间限制受定时器时钟限制支持高频代码复杂度需写中断服务函数初始化后只需TIM_GetCounter()6.3 核心配置代码void TIM2_Encoder_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // PA0 TIM2_CH1, PA1 TIM2_CH2 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); // 时基ARR 设为最大值 65535预分频为 0 TIM_TimeBaseStructure.TIM_Period 65535; TIM_TimeBaseStructure.TIM_Prescaler 0; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); // 编码器模式 3TI1 和 TI2 同时计数4 倍频 TIM_EncoderInterfaceConfig(TIM2, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); // 输入滤波ICFilter 10滤除高频抖动 TIM_ICStructInit(TIM_ICInitStructure); TIM_ICInitStructure.TIM_ICFilter 10; TIM_ICInit(TIM2, TIM_ICInitStructure); // 计数器初值设为 32768方便正负方向计数 TIM_SetCounter(TIM2, 32768); TIM_Cmd(TIM2, ENABLE); } // 读取当前计数值 int16_t Encoder_Read(void) { return (int16_t)(TIM_GetCounter(TIM2) - 32768); }6.4 适用场景如果是做简单的旋钮调节中断法足够用也方便理解编码器原理如果是做电机测速、高精度位置检测这类对性能有要求的场景定时器编码器模式是毫无疑问的首选。七、扩展二定时器驱动均匀采样波形采集的终极方案前面 2.3 节详细分析了当前 “突发采样” 方案的局限性 —— 帧与帧之间存在大量时间盲区信号在盲区内的变化完全无法观测。如果需要对波形做精确的时序分析例如测量抖动宽度、验证严格的相位关系就需要换用定时器驱动的均匀采样方案。7.1 原理配置一个硬件定时器以固定频率如 50μs触发中断或 DMA 请求。每次触发时硬件自动将 A/B 相电平和计数值读入数组不需要 CPU 在 for 循环里空转。当数组填满后再通过 DMA 发送到电脑。7.2 为什么均匀采样能还原真实信号奈奎斯特采样定理指出要完整还原一个信号采样频率必须大于信号最高频率的 2 倍。机械编码器的抖动宽度通常在几十到几百微秒量级对应的最高频率成分约 10~20kHz。以 50μs 间隔20kHz 采样率均匀采样理论上能捕捉到所有宽度超过 100μs 的抖动事件。而且采样点在时间轴上均匀分布没有盲区VOFA 画出的波形就是真实信号的 “忠实慢放”。7.3 核心代码以下给出基于 TIM3 触发 DMA 读取 GPIO 输入数据寄存器IDR的示例读者可根据实际需求修改采样频率和数组大小。#define SAMPLE_COUNT 200 uint16_t SampleBuf[SAMPLE_COUNT]; // 每个采样点存 16 位 IDR 值 void TIM3_Sampling_Init(void) { // 1. 使能时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // 2. 配置 TIM3 为 50μs 触发周期 (72MHz / 3600 20kHz) TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period 3599; // 3600-1 TIM_TimeBaseStructure.TIM_Prescaler 0; TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); // 3. 配置 TIM3 更新事件触发 DMA TIM_DMACmd(TIM3, TIM_DMA_Update, ENABLE); // 4. 配置 DMA1 通道 3 (TIM3_UP 固定映射) DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel3); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(GPIOB-IDR); // 读取整个 PB 端口 DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)SampleBuf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; // 外设 → 内存 DMA_InitStructure.DMA_BufferSize SAMPLE_COUNT; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; // IDR 地址不变 DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel3, DMA_InitStructure); // 5. 启动定时器DMA 自动开始搬运 TIM_Cmd(TIM3, ENABLE); }7.4 与突发采样的对比表格对比项突发采样本文方案定时器均匀采样终极方案采样间隔不固定约 2μs固定如 50μs时间盲区大量99%无波形真实性高频细节可捕捉盲区信号丢失完整还原奈奎斯特保证CPU 开销低仅 for 循环瞬间占用极低DMA 自动搬运实现复杂度简单中等适用场景快速定位抖动问题精确时序分析、信号质量评估结语这次调试让我深刻体会到看不见的信号永远要靠数据说话。通过 USARTDMA 低开销波形采集把看不见的时序变成直观的曲线所有软件与硬件层面的问题都无处遁形。希望本教程能帮你在下次遇到编码器 “灵异” 计数时快速定位并根治问题。
返回列表