1. 从零开始:为什么你的编码器读数总在“跳变”?
很多刚开始玩STM32电机控制或者位置检测的朋友,可能都遇到过这样一个让人头疼的问题:我明明接好了旋转编码器,代码也照着例程写了,可读出来的数值怎么一会儿正、一会儿负,有时候还会突然从正数跳到很大的负数,或者从0直接蹦到65535?电机转得挺顺滑,可这数据却像在“蹦极”,完全没法用来做精确的速度计算或者位置闭环。
我自己在第一次用STM32F103做一个小车底盘时,就栽在这个坑里。当时为了读取电机编码器的脉冲数来计算轮子转了多少圈,我直接用了定时器的计数寄存器值。电机正转时,数字噌噌往上涨,一切正常。可当我让电机反转时,期待看到数字慢慢减小,结果它却在计数到0之后,“唰”一下变成了65535,然后继续减小为65534、65533……这直接导致我计算出的速度值在反转时出现一个巨大的正跳变,整个控制系统瞬间紊乱。后来我才明白,这就是典型的正负值处理没做好,或者说,没有真正理解STM32正交解码的“补码”特性。
简单来说,STM32定时器的编码器接口(Encoder Interface)是个硬件外设,它帮你省去了用外部中断手动捕捉A、B相脉冲上升下降沿的麻烦。它内部有一个16位的计数器(Counter Register),我们通常通过__HAL_TIM_GET_COUNTER(&htimx)这个宏来读取它。这个计数器在正交解码模式下,会根据编码器A、B两相的相位关系自动进行加减计数。核心痛点在于:这个计数器是无符号的16位整数(uint16_t),它的计数范围是0到65535。当正转时,它从0加到65535;继续正转,它会溢出,重新从0开始。反转时,它从0减1,按照无符号数的规则,0减1就变成了65535(即0xFFFF),然后继续减为65534、65533……
所以,如果你直接把这个无符号数当成“位置”来用,就会看到上面说的“跳变”。解决问题的钥匙,就在于有符号数与无符号数的转换,以及计算机中补码的巧妙运用。我们需要的,是一个能正确反映“正转增加、反转减少”的有符号整数,而STM32的硬件已经为我们准备好了数据,只是需要我们以正确的“姿势”去读取它。接下来,我们就以最常用的TIM3为例,一步步拆解如何配置和编写代码,彻底搞定这个正负值问题。
2. 手把手配置:让TIM3变身编码器“读数机”
首先,我们得把TIM3这个定时器,配置成编码器模式。我习惯使用STM32CubeMX这个图形化工具来初始化,这能避免很多低级错误,特别是时钟和引脚配置。这里我以STM32F103C8T6(蓝色pill开发板常用芯片)为例,其他系列如F0、F4、G0等操作几乎完全一样。
第一步,打开CubeMX,选择你的芯片型号。找到TIM3,在左侧外设列表里点击它。工作模式选择“Encoder Mode”。这时,你会看到CH1和CH2两个通道(对应TIM3的通道1和通道2)变成了绿色,并且自动映射到了某两个GPIO引脚上(比如PA6和PA7)。这两个引脚就是用来连接编码器的A相和B相的。这里有个关键点:编码器模式会占用一个定时器的两个输入通道,所以当你选择编码器模式后,这两个通道就不能再用作PWM输出或者普通输入捕获了,这在CubeMX里会显示为灰色不可用状态,这是正常的。
第二步,配置编码器参数。在下方参数设置栏,有几个重要选项:
- Encoder Mode:这里选择“Encoder Mode TI1 and TI2”。这是最常用的模式,同时使用TI1(通道1)和TI2(通道2)进行正交解码,精度最高,能识别正反转和4倍频计数。
- Polarity:极性。通常保持默认的“Rising Edge”上升沿即可。如果你的编码器信号需要反向,可以在这里修改。
- Counter Period (AutoReload Register):这个值通常设置为最大值65535(对于16位定时器)。这个寄存器决定了计数器的溢出上限。在编码器模式下,我们通常希望计数范围尽可能大,所以填65535。但请注意,这个值不影响我们处理正负值,后面会讲到。
- Prescaler:分频器,设为0(不分频)。编码器计数一般不需要分频。
配置好后,在GPIO设置里,你可以检查一下这两个引脚的配置,通常无需改动。然后就是生成代码了。点击“Project Manager”,设置好项目名称、路径和IDE(比如STM32CubeIDE),在“Code Generator”里勾选“Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral”,这样代码结构更清晰。最后点击“GENERATE CODE”。
生成的代码里,在tim.c文件中,你会找到MX_TIM3_Init函数,里面已经把TIM3初始化为编码器模式。而在main.c中,这个初始化函数会被自动调用。硬件配置部分到此就完成了,是不是很简单?接下来,我们要在软件里启动它并读取数据。
3. 核心代码三行搞定:启动、读取与“魔法”转换
硬件配置好后,软件操作就三件事:启动编码器接口、定期读取计数器值、进行有符号转换。我们直接在main.c的main函数里,在初始化代码之后(while(1)循环之前)添加启动代码。
/* 启动TIM3的编码器接口 */ if (HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL) != HAL_OK) { /* 启动错误处理,可以点亮一个错误LED */ Error_Handler(); }这行代码HAL_TIM_Encoder_Start告诉STM32:TIM3的编码器硬件可以开始工作了,它会自动根据A、B相的跳变来更新内部计数器。
然后,在你需要获取编码器位置的地方(比如在一个定时中断里,或者主循环中),读取计数器值:
int16_t encoder_raw = (int16_t)(__HAL_TIM_GET_COUNTER(&htim3));看,关键就在这里!我们不是直接用uint16_t类型去接这个值,而是把它强制转换(cast)为int16_t(也就是short型)。这短短的一个强制类型转换,就是处理正负值的“魔法”。
我们来剖析一下这个“魔法”是怎么生效的。假设编码器反转,硬件计数器从0减到-1。对于16位无符号寄存器,-1的补码表示是0xFFFF(二进制全1)。当我们用__HAL_TIM_GET_COUNTER宏读取时,得到的是无符号的0xFFFF,其十进制值是65535。如果我们将这个值赋给一个uint16_t变量,它就是65535。但是,当我们把它强制转换为int16_t时,编译器会把这16位数据0xFFFF解释为一个有符号的短整数。在补码表示法中,0xFFFF正好就对应着**-1**。同理,0xFFFE(65534)被解释为-2,0x8000(32768)被解释为-32768,而0x7FFF(32767)还是32767。
这样一来,无论硬件计数器如何溢出、反转,我们通过int16_t类型看到的,都是一个连续变化的、符合直觉的有符号数:正转时从0增加到32767,然后增加到32768?不对,这里有个细节。当正转超过32767后,无符号数变为32768(0x8000),强制转换为int16_t后,它会变成-32768。这意味着,如果我们期望的计数范围超过了-32768到+32767,这种简单的强制转换在跨越这个边界时依然会有个跳变。但这通常不是问题,因为对于很多增量式编码器应用,我们关心的是相对变化量,而不是绝对位置。如果需要绝对位置,我们就需要处理“溢出”和“下溢”,这我们放在下一节详细讲。
所以,对于大多数速度计算、短距离相对位置检测,int16_t encoder_raw = (int16_t)(__HAL_TIM_GET_COUNTER(&htim3));这一行代码,已经完美解决了正反转的正负值显示问题。读取到的encoder_raw就是一个实时反映编码器“净增量”的有符号数。
4. 进阶处理:应对长距离与溢出,让计数“绵绵无绝期”
上面那招“强制转换”大法对于很多场景已经够用,比如用M法测速(一定时间内的脉冲增量)。但如果你要做的是一个长行程的绝对位置记录,比如一个滑块在丝杠上从一端移动到另一端,编码器旋转了成千上万圈,你希望有一个连续的、不会跳变的32位甚至64位的绝对位置值,那该怎么办?这时候,我们就需要手动处理16位计数器的溢出和下溢事件。
思路是这样的:我们仍然定期(比如每1ms)读取经过强制转换后的有符号16位数值current_count。然后,我们维护一个全局的32位有符号整数total_ticks作为绝对位置。每次读取时,计算本次读数与上次读数last_count的差值delta。由于current_count的范围是-32768到32767,所以相邻两次读取的差值delta在正常情况下(采样频率足够高,编码器速度不太快)应该落在**-32768到32767之间**。但是,如果发生了溢出或下溢,这个差值就会异常大。
判断溢出的逻辑(正转越过+32767边界):假设上次读数last_count是+30000,本次读数current_count是-30000。单纯相减,delta = -30000 - 30000 = -60000。这个绝对值远大于32768/2。实际上,这是因为计数器从+30000正转,经过+32767后变成-32768,再转到-30000。真实的物理增量应该是:从+30000到+32767(增加了+2767),然后从-32768到-30000(增加了+2768),总增量是+5535。但由于补码的跳变,我们直接算出了-60000。所以,当delta小于 -32768(一个经验阈值,比如-30000)时,我们推断发生了正向溢出,实际的delta应该修正为delta + 65536。
判断下溢的逻辑(反转越过-32768边界):反之,如果last_count是-30000,current_count是+30000,直接delta = 30000 - (-30000) = +60000,远大于32768/2。这是因为计数器从-30000反转,经过-32768后变成+32767,再转到+30000。真实的物理增量是负的。所以,当delta大于 +32768(比如+30000)时,我们推断发生了负向下溢,实际的delta应该修正为delta - 65536。
下面是一个经过实战检验的示例函数,它放在定时中断里调用,可以稳定地累积出32位的绝对位置:
int32_t total_encoder_ticks = 0; // 全局变量,32位绝对位置 int16_t last_encoder_count = 0; // 全局变量,保存上一次的读数 void Encoder_Update(void) // 假设这个函数每1ms被调用一次 { int16_t current_count = (int16_t)(__HAL_TIM_GET_COUNTER(&htim3)); int16_t delta = current_count - last_encoder_count; // 溢出/下溢判断与修正 if(delta > 32768) { // 可能发生负向下溢(反转越过-32768) delta -= 65536; } else if(delta < -32768) { // 可能发生正向溢出(正转越过+32767) delta += 65536; } // 如果delta在[-32768, 32767]合理范围内,则直接使用 total_encoder_ticks += delta; // 更新32位绝对位置 last_encoder_count = current_count; // 更新上次读数 }这段代码的精髓在于经验阈值32768。为什么是32768?因为对于16位有符号数,其变化量的理论最大绝对值不会超过32767(如果采样间隔内计数变化超过32767,说明采样太慢或编码器转速太快,这种算法也会失效,需要提高采样频率)。当差值delta的绝对值超过32768时,几乎可以肯定是发生了跨边界跳变,而不是真实的物理变化。通过加上或减去65536(16位计数器的模),我们就把这个跳变补偿了回来,得到了真实的、连续的增量。
在实际项目中,我用这个方法成功实现了对一台3D打印机喷头位置的长时间、高精度记录,即使电机来回高速运动数小时,位置积分也从未出错。这里有个小技巧,你可以把total_encoder_ticks除以编码器线数(比如每转400个脉冲)和机械减速比,直接转换成以“圈”或“毫米”为单位的实际位置,非常方便。
5. 避坑指南:硬件连接、抖动与速度计算的实战经验
理论通了,代码写了,是不是就高枕无忧了?别急,在实际焊接电路和调试中,还有几个坑等着你。我踩过,希望你别再踩。
第一个坑:硬件连接与上拉电阻。很多光电编码器是开集电极输出,这意味着它本身不能输出稳定的高电平。你必须给编码器的A、B相信号线加上拉电阻到3.3V,通常用4.7kΩ到10kΩ的电阻。如果不加上拉,STM32的GPIO引脚可能会读到不确定的浮空电平,导致计数器乱跳。我一开始就忘了加,结果电机没转,计数器自己就在疯狂加减,排查了半天。另外,编码器的电源和地线一定要接好,最好电源端并联一个0.1uF的瓷片电容滤波,防止开关电源的噪声干扰。
第二个坑:机械抖动与软件滤波。尤其是廉价的增量式编码器,或者在电机启停的瞬间,由于机械振动或接触不良,A、B相可能会产生非常短暂的毛刺(glitch)。这些毛刺会被TIM3的硬件捕获,导致计数器偶尔多计或少计一两个脉冲。对于高精度应用,这很致命。解决办法有两个:一是选用质量好的编码器,并确保机械安装牢固同心;二是在软件里做数字滤波。STM32的定时器输入通道通常支持输入滤波器(Input Filter),你可以在CubeMX中TIM3的通道配置里找到“Input Filter”选项,设置一个时钟分频数,比如设置成4个时钟周期,那么短于这个时间的脉冲会被滤掉。这能有效消除高频毛刺。但注意,滤波器设置得太高,可能会滤掉正常的高速脉冲。
第三个坑:速度计算中的“数值陷阱”。我们获取了位置,最常用的就是计算速度:速度 = (本次位置 - 上次位置) / 时间间隔。这里有两个细节:一是数据类型,位置差delta是int16_t,时间间隔dt可能是float(单位秒),直接除可能会因为整数除法丢失精度。最好先把delta转换成float再除。二是采样时间,dt不能太长,否则会丢失高速运动的细节;也不能太短,否则delta可能大部分时间为0,速度计算噪声大。根据我的经验,对于每转几百线的电机,采样周期在1ms到10ms之间比较合适。计算出的速度值,最好再经过一个一阶低通滤波,让曲线更平滑,便于后续的PID控制。
// 一个简单的速度计算与滤波示例 float speed_rpm = 0; // 滤波后的速度,单位RPM float speed_alpha = 0.1; // 低通滤波系数,越小越平滑,响应越慢 void Calculate_Speed(void) { static int32_t last_total_ticks = 0; const float dt = 0.001f; // 假设此函数每1ms调用一次 const int pulses_per_rev = 400; // 编码器每转脉冲数 int32_t delta_ticks = total_encoder_ticks - last_total_ticks; float instant_speed = (float)delta_ticks / pulses_per_rev / dt * 60.0f; // 计算瞬时RPM // 一阶低通滤波 speed_rpm = speed_alpha * instant_speed + (1 - speed_alpha) * speed_rpm; last_total_ticks = total_encoder_ticks; }第四个坑:多圈与断电保存。如果你的系统断电后需要记住绝对位置,那么上面32位的total_encoder_ticks存在RAM里是不够的,需要定期写入Flash或EEPROM。但频繁写Flash会损坏它。一个折中方案是,在系统正常关机或收到停车指令时,将当前位置写入非易失存储器。上电后读出来,作为total_encoder_ticks的初始值。更复杂的系统会使用带电池的绝对式编码器,但那成本就高多了。
把这些坑都填上,你的STM32正交解码应用才算真正稳健可靠。从简单的正负值处理,到复杂的绝对位置跟踪和速度计算,这套方法在机器人、CNC、3D打印机等需要精密运动控制的领域里,经过了无数项目的验证。下次当你看到编码器的数据流平稳而准确地跟随电机转动时,你会感谢当初在这些细节上花费的时间。