简介:STM32F103编码器程序是一份面向嵌入式开发者的完整参考工程,针对增量型编码器信号读取与运动控制场景,解决电机测速、位置反馈和方向判断等常见需求。压缩包共934个文件,以C源码和H头文件为主,包含汇编启动文件、链接脚本、IAR/Keil工程配置、说明文档等,整体约9.99MB,可直接导入IDE编译调试。工程基于标准外设库实现,覆盖TIM定时器编码器模式配置、A/B相输入捕获、中断服务程序、方向判定逻辑,并给出滤波与倍频处理思路,便于理解编码器接口底层机制。已有2330人下载学习,目录结构清晰,代码注释较完整,适合作为电机控制或机器人定位项目的直接参考模板。
1. STM32F103编码器程序:从正交解码到可靠测速,一篇讲透
第一次做电机闭环时,很多人想当然地认为编码器就是两根脉冲线,随便开两个外部中断数一下边沿就行。真把电机转起来才发现,中断满天飞,方向判断一团乱,速度一高就开始丢步。折腾一圈后回到正解:STM32F103编码器程序最可靠的做法,是让内部定时器进入编码器模式,用硬件自动捕获A/B相的两个边沿,自动判断方向,自动四倍频。CPU只在需要时读一下TIMx->CNT,既不抢中断,也不会漏脉冲。
这篇文章从编码器模式的原理讲起,给出可直接抄走的TIM3初始化代码、位置和速度换算公式,然后把方向反了、Z相清零失效、高速丢步、计数值一直是0这几个高频翻车点逐个拆开。最后补上输入滤波、16位溢出扩展成32位、测速清零这三种进阶配置,适合第一次接触编码器的开发者照着走,也适合被丢步问题困住的老手对照排查。
2. 编码器程序的设计起点:定时器为何比外部中断更值得用
2.1 正交编码器的信号本质:A相、B相与Z相
增量式编码器输出三路信号:A相、B相、Z相。A/B两路波形频率相同,相位差正好90度。正转时A相超前B相,反转时B相超前A相,这个超前关系就是方向信息。Z相每圈只输出一个脉冲,并且在码盘的固定机械位置上出现,通常用来做零点校准。很多国产编码器还带差分输出,也就是A+、A-、B+、B-,抗干扰更强,但STM32F103的定时器输入是单端的,差分信号要先经过一个接收芯片转成单端电平再接进单片机。
这里有一个让新手绕不开的误区:编码器的“线数”PPR指的是码盘一圈有多少个物理栅格,对应A/B波一圈有多少个完整周期,并不是直接输出多少个脉冲。200线编码器一圈有200个正交周期,如果只在A相上升沿计数,一圈只能数到200;如果把A/B两路的上升沿和下降沿全部用上,一圈能数到800。这个“四倍频”就是STM32F103编码器模式帮你做的事。
实际上编码器内部输出元器件类型也会影响接线。推挽输出可以直接接单片机,开漏输出必须加上拉电阻。如果你在电路上看到A/B信号对地只有一个几百欧下拉、没有上拉,那多半是编码器型号和接口板不匹配,信号会浮在中间电平,导致定时器在边沿附近反复判断,计数结果乱跳。所以写STM32F103编码器程序的第一步不是敲代码,而是确认A/B/Z三根线上的静态电平是稳定高电平,并且边沿干净。
2.2 定时器编码器模式的计数原理与四倍频
STM32F103的通用定时器和高级定时器都内置编码器接口。开启后,定时器不再使用内部时钟计数,而是由TI1和TI2两路输入信号的边沿驱动计数器。A相或B相每个边沿到来时,硬件会采样另一路信号的电平,从而得出计数方向:正转加一,反转减一。这个方向判断是纯硬件完成的,CPU完全不需要参与。
参考手册把这个模式称为编码器模式,寄存器SMCR的SMS位决定计数方式。SMS=001时只在TI1的边沿计数,相当于二倍频;SMS=010时只在TI2的边沿计数,也是二倍频;SMS=011时TI1和TI2的每个边沿都计数,这就是四倍频。标准外设库用一行TIM_EncoderInterfaceConfig配置,实际上就是设置SMCR的SMS位,以及CCER里的CC1P、CC2P极性。如果你希望降低分辨率换取更高的输入频率上限,可以把四倍频改成二倍频,方向判断仍然有效,只是每圈计数变为2×PPR。
四倍频不是把信号频率乘四,而是把每个正交周期里的4个边沿都变成计数事件。这要求A/B两路的占空比尽量接近50%,相位差尽量接近90度。有些低成本编码器输出波形畸变严重,A相高电平只有四分之一周期,这种情况下四倍频的精度会下降,但方向判断一般还能保住。真正让计数失准的是信号上的毛刺,一个毛刺被当成一个边沿,计数器就会多走一步。所以硬件滤波和软件滤波在编码器模式里不是可选项,而是必选项。后面第5章会单独说滤波参数怎么设。
2.3 选型:TIM1/TIM2/TIM3/TIM4的资源差异
STM32F103里几乎每个通用定时器和高级定时器都能做编码器模式,但A/B相脚位是固定的,选型时先看原理图上编码器接在哪。
| 定时器 | CH1引脚 | CH2引脚 |
|---|---|---|
| TIM1 | PA8 | PA9 |
| TIM2 | PA0 | PA1 |
| TIM3 | PA6 | PA7 |
| TIM4 | PB6 | PB7 |
TIM1是高级定时器,TIM2、TIM3、TIM4是通用定时器,编码器模式下功能没有本质区别。但TIM1往往要留给电机PWM互补输出和刹车,建议优先用通用定时器。我一般默认选TIM3,因为PA6/PA7很少被其他外设占用,也不在JTAG默认引脚上,调试最方便。如果PA6/PA7被占,就选TIM4的PB6/PB7,注意PB口要开启AFIO时钟。TIM2的PA0/PA1也可能被DAC、ADC等占用,但编码器只是输入复用,冲突要看具体项目。
还有一个容易忽略的引脚复用:部分封装支持重映射,比如TIM3可以重映射到PB4/PB5,TIM2可以重映射到PA15/PB3。但PB4、PB3、PA15在默认情况下是JTAG口,重映射前必须关闭JTAG复用,否则引脚功能不生效。我做开发板验证时吃过这个亏,以为重映射后就能用,结果调试器也连不上了。因此在STM32F103编码器程序里,只要原引脚没被复用,就不要随便开重映射,省得把调试口一起关掉。
选完定时器后,还有一个基础配置要注意:编码器模式下预分频器PSC必须保持为0,自动重装载值ARR通常设成0xFFFF。PSC一旦不为0,外部边沿会被分频,直接丢脉冲;ARR一旦设成其他值,计数值会提前回绕,位置信息就乱了。这两条是所有编码器模式程序的共同地基,改不得。
3. 用STM32F103编码器程序把位置和速度读出来:初始化、读取与换算
3.1 最小初始化代码:TIM3编码器模式
这套初始化针对的是最常见的接线:编码器A相接PA6,B相接PA7,对应TIM3_CH1和TIM3_CH2。代码用标准外设库风格写,因为能清楚看到每一步设置的寄存器行为。用HAL库的人也能对照,底层原理是一样的。
void Encoder_TIM3_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // A相PA6,B相PA7,配置为带上拉输入 // 编码器输出无论是推挽还是开漏,上拉输入都能稳定读电平 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 编码器模式下PSC必须为0,否则外部边沿被分频,造成丢计数 TIM_TimeBaseStructure.TIM_Prescaler = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 0xFFFF; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); // 先配置CH1和CH2为直连输入,滤波器放到后面再调 // CH1对应A相,CH2对应B相 TIM_ICInitStructure.TIM_Channel = TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x0F; TIM_ICInit(TIM3, &TIM_ICInitStructure); TIM_ICInitStructure.TIM_Channel = TIM_Channel_2; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x0F; TIM_ICInit(TIM3, &TIM_ICInitStructure); // 关键配置:TI1和TI2双沿计数,即硬件四倍频 // 两个通道极性都设为Rising,正反方向由硬件根据相位差判断 TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); // 清空计数器,然后启动编码器模式 TIM_SetCounter(TIM3, 0); TIM_Cmd(TIM3, ENABLE); }这段代码的逻辑很直接:先开时钟,再配GPIO输入模式,然后配置时基和输入捕获,最后调用TIM_EncoderInterfaceConfig进入编码器模式。有一个细节值得多说一句:TIM_ICInitStructure先配置了输入捕获,随后TIM_EncoderInterfaceConfig会覆盖部分极性位。如果你不小心把顺序颠倒,先编码器模式后ICInit,输入捕获配置会把编码器模式的计数路径打乱,计数器可能一动不动。这是我实际调试时遇到过的坑,后面第4章再展开。
使用HAL库时,对应操作是初始化定时器句柄、配置EncoderMode为TIM_ENCODERMODE_TI12,然后调用HAL_TIM_Encoder_Start。HAL把很多事情包起来了,查寄存器不如标准库方便。如果HAL初始化后计数异常,我通常不看HAL代码,直接读TIM3->SMCR、TIM3->CCER、TIM3->CNT三个寄存器,马上就能定位问题。这也是我在STM32F103编码器程序上推荐标准库的一个理由。
3.2 读取计数值并换算成角度/位移
编码器模式启动后,A/B相每来一个有效边沿,TIM3->CNT就会加一或减一。读取时不要用int去接,因为计数器是16位有符号循环,要直接保存为int16_t,后续做差分才不会被强制转换坑到。换算角度也很直接,一圈总计数是4×PPR,360度除以总计数就是每个脉冲代表的角度。
#define ENCODER_PPR 200 int16_t Encoder_GetCount(void) { return (int16_t)TIM_GetCounter(TIM3); } float Encoder_GetAngleDeg(void) { int16_t raw = (int16_t)TIM_GetCounter(TIM3); return (float)raw * 360.0f / (4.0f * ENCODER_PPR); } void Encoder_SetZero(void) { TIM_SetCounter(TIM3, 0); }这段代码里,ENCODER_PPR是编码器线数,也就是一圈的物理栅格数。200线编码器四倍频后一圈800个计数,每个计数正好0.45度。如果你用的是13线编码器,那就不能套用这个公式,应改成4×13=52个计数一圈,角度步进接近6.92度,这种低线数编码器只适合测速,不适合做高精度定位。
实际项目里还要考虑减速比。如果编码器装在电机输出轴上,而负载是减速箱输出轴,那么机械角度的换算需要乘以减速比。比如减速比是30,输出轴转一圈,电机轴转30圈,编码器计数就是30×4×PPR。这个换算不要在中断里做,浮点运算放在主循环或者速度任务里做就好,中断里只读取整数计数。
清零位置也有讲究。TIM_SetCounter(TIM3, 0)看起来简单,但如果在电机高速旋转时调用,清零瞬间正在到来的边沿可能刚好落在写CNT的过程中。硬件上写CNT不会把边沿吃掉,但计数器的绝对参考点会模糊。我的习惯是:外部绝对零点用Z相中断清零,软件复位时只清自己的位置变量,不直接清硬件计数器。这样即使清零时序有偏差,后续差分测速也不会受影响。
3.3 速度怎么算:M法、T法与单位时间采样
速度测量有两种主流方法:M法和T法。M法在固定采样时间内统计脉冲增量,脉冲多则速度快;T法测量两个相邻脉冲的时间间隔,间隔短则速度快。STM32F103编码器程序里,M法实现最简单,因为计数器已经把脉冲数累加好了,CPU只需要每隔固定时间读一次差分。T法更适用于极低速,因为低速时M法在10ms采样周期里可能一个脉冲都没有,速度直接算成0。
下面这段是M法测速的典型写法,我用系统节拍tick做时基,每10ms调用一次。
static int16_t last_cnt = 0; uint32_t Encoder_GetSpeedRpm(uint32_t period_ms) { int16_t curr_cnt = (int16_t)TIM_GetCounter(TIM3); int16_t delta = curr_cnt - last_cnt; float rpm; last_cnt = curr_cnt; // delta是period_ms时间内的脉冲增量 // 一圈计数=4*PPR,一分钟=60000ms rpm = (float)delta * 60000.0f / (4.0f * ENCODER_PPR * period_ms); return (uint32_t)(rpm + 0.5f); }这里最关键的是对delta的理解。curr_cnt和last_cnt都是int16_t,两者相减时即使计数器发生了从0x7FFF到0x8000的回绕,减法结果在有符号数语义下依然等于真实的脉冲差。比如上一周期计数是32700,这一周期因为正向加了200变成32900,但32900在16位里是-32336,两者相减得到的仍然是200。这就是为什么必须用int16_t,不要强行转成uint16_t。
采样周期选多少,直接影响速度曲线平滑度。10ms适合大多数电机速度环,1秒有100个采样点,转速变化能及时反映。如果你做的是高响应伺服,可以把周期压到5ms,但要注意主循环负载和串口打印的阻塞。反过来,如果只是看转速显示,20ms更稳,低速时的波动会小很多。
T法在STM32F103上通常用输入捕获中断实现:把A相或B相接到另一个定时器的捕获通道,每次捕获时读取CCR寄存器,两个相邻CCR之差就是单个脉冲的间隔。因为F103的主频不高,T法对中断时延很敏感,我建议只在电机转速低于几十转每分钟,并且确实需要平滑速度反馈时才引入。大部分项目用M法已经足够,不要为了炫技把系统复杂度拉上去。
4. STM32F103编码器程序常见问题与排查:四个高频坑
4.1 计数方向总是不对
现象是电机正转,计数值反而递减;电机反转,计数值递增。又或者手动转一圈,计数值从0变成负数,看起来完全没有规律。
原因来自两个层面:硬件上A相和B相接反,A相接到了CH2,B相接到了CH1;软件上编码器模式的极性配置和实际电平不匹配。STM32F103编码器模式的方向由CCER里的CC1P、CC2P极性以及接入通道共同决定。如果A/B接线正确,两个极性都设为Rising时方向为正;把两个极性都改为Falling,方向就会反过来。但如果你只改了一路的极性,计数方向会乱掉,甚至前后抖动。
解决时先不要动代码,先检查原理图,确认A相确实接PA6,B相确实接PA7。然后把电机手动正转半圈,观察计数值变化。如果方向反了,最简单的是把A/B两根线互换,或者把TIM_EncoderInterfaceConfig里的两个极性同时从Rising改成Falling。我不建议在应用层把返回值取负来临时修正,因为那会让溢出扩展、Z相清零里的方向判断与真实位置出现双重取反,后续很难排查。
这类问题的排查步骤通常是:先看方向,再看计数是否四倍频。用手转一圈,如果一圈计数等于4×PPR,说明硬件和软件配置正确。如果等于2×PPR,说明SMS被设成了TI1或TI2模式,也就是只在一路信号边沿计数。如果计数完全不动,再回去查GPIO和复用配置。
4.2 Z相清零失效或丢脉冲
现象是Z相接到了外部中断,程序里也确实在中断中清零计数器,但实际运行中偶尔没有清零,有时候一个机械周期清两次,位置零点漂移。
原因是Z相脉冲宽度很窄,通常只有A相周期的几分之一,如果中断优先级不够高,或者主循环里长时间关中断,脉冲边沿会被错过。F103的外部中断检测依赖电平边沿,但Z相在高转速时脉宽可能只有几微秒,如果刚好落在系统扛负载的瞬间,丢失一个脉冲就要再等一整圈。
解决时我一般把Z相接到EXTI,并配置为双边沿触发。在中断里不直接TIM_SetCounter,而是置一个“零点到达”标志,主循环看到标志后重新同步位置变量。这样做的好处是中断处理时间极短,不容易丢事件;坏处是清零动作有延迟,但位置同步对几十微秒的延迟不敏感。
如果Z相引脚周围干扰比较大,EXTI会反复触发,导致一遍清零后再次清零。此时最简单的办法是在中断里记录一次时间戳,两次Z相中断间隔小于2ms的直接忽略。还有一点,如果Z相连接了上拉电阻,必须确保Z相空闲是高电平、脉冲是低电平,或者反过来,要和你配置的触发边沿一致。用示波器挂Z相看一眼,比在代码里猜要快得多。
4.3 高速时丢步,低速却正常
现象是电机低速转到某一速度以下,计数精度没问题;一旦转到3000转以上,计数值明显小于实际位置,速度反馈也偏低。用示波器看A/B波形,波形没问题。
原因有几种可能。首先是输入滤波设得太大,TIM_ICFilter为0x0F时,硬件要求输入连续稳定多个内部时钟周期才认作有效沿,高频窄脉冲直接被滤掉。其次是F103定时器的编码器模式下输入频率有上限,内部计数采样速度跟不上外部信号时,丢脉冲是必然的。最后是线材太长,信号上升沿变缓,在门限附近反复抖动,也会让硬件计数错乱。
排查时第一步把TIM_ICFilter从0x0F改成0x03或0x00。第二步确认定时器时钟确实到了72MHz。F103的APB1时钟最大36MHz,但通用定时器在APB1分频不等于1时会自动乘2,所以TIM3时钟通常就是72MHz。如果初始化代码里误把APB1设成了不分频,TIM3时钟就是36MHz,编码器模式的最大输入频率直接减半,高速丢步就会提前出现。
第三步是实实在在降频。如果四倍频后的脉冲频率已经逼近内部时钟的四分之一,就应考虑把四倍频改成二倍频,也就是用SMS=001只对TI1计数。分辨率降一半,但最高输入频率也降一半,丢步问题立刻缓解。电机控制中,速度和位置的精度并不完全依赖编码器分辨率,控制周期内只要不丢脉冲,零点校准后的角度依然可用。
4.4 调试器里看计数值一直是0
现象是程序烧进去后,手动转编码器,在调试器变量的窗口里看TIM_GetCounter,数值始终是0。主循环里的串口打印也显示0。换一句:程序压根没进入编码器状态。
原因按照概率排序:GPIO没配成输入,定时器没开时钟,TIM_Cmd没调用,AFIO重映射把引脚占掉,以及调试器暂停时计数器被冻结。还有一种比较隐蔽:初始化顺序错了,先调了编码器模式,后调了TIM_ICInit,导致计数路径被打断,CNT根本不更新。
解决时先做一个最小检查清单。第一,确认RCC里已经开了TIM3和GPIOA时钟;第二,确认GPIO_Pin_6|GPIO_Pin_7配置的是输入模式而不是复用功能;第三,确认TIM_Cmd(TIM3, ENABLE)被调用;第四,直接在调试器里读TIM3->SMCR,SMS位应该是011。第五,读TIM3->CCER,CC1E和CC2E都应该为1。
如果SMCR和CCER都对,CNT还是0,就用示波器探PA6/PA7有没有脉冲。如果引脚悬空或电平恒定,那多半是编码器没供电,或者A/B信号压根没到MCU。这里要特别注意,编码器是集电极开路输出时,没有上拉电阻的话,信号会被拉低,MCU读不到任何边沿,CNT自然不动。
最后检查调试器配置。有些调试工具在CPU暂停时会冻结定时器,导致你在暂停状态下看不到计数器变化。这不是程序问题,把暂停冻结外设的选项关掉,或者用串口打印代替调试器观察,就能验证。
5. 让编码器程序更稳的进阶配置:输入滤波、溢出扩展与测速清零
5.1 输入滤波参数怎么定:CKD位与ETF位
编码器模式下,硬件输入滤波在每次边沿进入计数逻辑之前,先要求信号电平连续保持若干个内部时钟周期。这个“连续保持多少个周期”由TIMx_CCMR1里的IC1F和IC2F字段控制。在标准库里,对应TIM_ICInitStructure.TIM_ICFilter。取值越大,抗抖动能力越强,但能通过的最高输入频率越低。
| IC1F值 | 等效滤波窗口 | 适用场景 |
|---|---|---|
| 0x00 | 无滤波 | 短引线、信号干净、高转速 |
| 0x03 | 约2个内部时钟周期 | 常规电机线,中等转速 |
| 0x07 | 约4个内部时钟周期 | 线材较长、有干扰 |
| 0x0F | 约8个内部时钟周期 | 强干扰环境,但高速易丢步 |
我一般从0x00或0x03起步,先跑起来再根据丢步和抖动情况调整。如果你一上来就设置0x0F,也许低速计数很稳,但一到高速就丢脉冲,反而让人误以为定时器配置有问题。另一个参数是TIM_TimeBaseStructure.TIM_ClockDivision,对应CKD位。这个位影响死区时间以及输入滤波的采样时钟分频,编码器模式下通常设DIV1。如果外部信号沿很慢,调整CKD不会比直接换一个施密特触发器整形更有效。
硬件滤波和软件滤波要配合起来看。我在某图像处理Demo上曾经给A/B信号并了104电容,滤得很干净,但电机一转起来,高速时计数值少了三分之一。原因是RC低通让信号沿变缓,边沿到达门限的时间不确定,定时器在没有滤波的情况下也会在门限附近误判。后来把RC的值调小,同时把定时器的ICFilter调到0x03,高速和低速才都能兼顾。经验是总延迟不能超过A相最小脉冲宽度的三分之一,否则会吃掉有效边沿。
5.2 16位溢出扩展成32位:中断里只做加减法
TIM3的计数器只有16位,一圈800个计数时,转41圈左右就会超过32767的符号范围。虽然用int16_t做速度差分没问题,但位置累加不能只靠16位寄存器,否则长时间运行后位置值会回绕。常见做法是开启TIM3的更新中断,在溢出或下溢时维护一个高16位变量,把位置扩展成32位。
volatile int16_t high_cnt = 0; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); // DIR位为1表示向下计数,即下溢,位置应向负方向移动 if (TIM3->CR1 & TIM_CR1_DIR) { high_cnt--; } else { high_cnt++; } } } int32_t Encoder_GetCount32(void) { uint16_t low; int32_t value; // 先关中断,避免读取高16位和低16位之间插入更新中断 __disable_irq(); low = (uint16_t)TIM_GetCounter(TIM3); value = ((int32_t)high_cnt << 16) | low; __enable_irq(); return value; }这段代码里的中断只做加减法,不做任何PID计算或浮点运算,所以执行时间极短。每次计数器从0xFFFF回绕到0时,high_cnt加一;从0下溢到0xFFFF时,high_cnt减一。读取32位值时,关中断是为了保证高16位和低16位来自同一个瞬间。如果不关,中断可能在高16位读取之后、低16位读取之前发生,组合出来的值会比真实位置偏差65536。
很多人会问,为什么不直接在读取时读两次高16位来验证?那也是一种办法,但在F103上关中断最直接。注意__disable_irq会关掉所有可屏蔽中断,如果你的编码器更新中断优先级非常低,而另一个更高优先级的中断频繁触发,关中断时间可能过长。所以在F103上,建议把所有和编码器组合读取相关的中断放在同一个抢占优先级,或者至少保证更新中断的优先级不高于读取临界区所在上下文,否则临界区保护会失效。
还有一个容易翻车的地方:更新中断里不要尝试读取TIM_GetCounter来计算方向,因为在更新中断触发时,计数器已经完成回绕,CNT值可能已经变成了0或0xFFFF,但方向判断要用CR1的DIR位。DIR位在编码器模式下会随正反转实时更新,是正确的方向来源。如果只看CNT在事件前后的变化,高速时很容易误判,导致high_cnt振荡。
5.3 测速清零模式:位置和速度用同一个计数器
编码器模式下的计数器天然适合“差分测速”:定期读取当前值,减去上一次读取的值,差值就是该周期内的脉冲增量。还有一种写法是每次采样后直接把计数器清零,这样下一次采样读到的值就是增量,省去减法。
int16_t Encoder_GetSpeedByClear(void) { int16_t cur = (int16_t)TIM_GetCounter(TIM3); // 清掉计数器,下一次读取直接作为增量 TIM_SetCounter(TIM3, 0); return cur; }这段代码在10ms周期中断里调用,返回的cur直接就是周期内的脉冲增量,反转时为负。这种测速清零方式在只关心速度、不关心绝对位置的场景里非常简洁,中断里不用保存last_cnt,也不需要溢出扩展。但它的前提是“每次采样后必须成功清零”,如果某次周期任务卡顿,导致清零推迟了几百微秒,那几百微秒内的脉冲就会混入下一次采样周期,速度曲线出现毛刺。
我自己的习惯是优先用“只读不清零”的差分法。因为读CNT不写CNT,和硬件计数器之间没有竞争,配合32位扩展后,位置和速度两个量可以同时从同一个数据链路得到。清零法虽然代码少,但可观测性差一点,出了问题不好判断是清零晚了还是采样晚了。
如果确实用清零法,有一个小技巧:不要在速度任务里清零,而是在采样点先读,再清零,清零后立刻把当前计数器值赋给last_cnt,这样下次差值天然等于间隔内的增量。这其实和差分法最终等价,但多了一次写寄存器操作。两种方法选一个就好,不要在同一套代码里混用,否则位置变量会越跑越乱。
6. 最后一个技巧:用编码器程序把系统时钟和中断优先级理顺
很多STM32F103编码器程序初始化看起来没问题,但跑到高速就丢步,问题往往不在编码器模式本身,而在系统时钟。F103的APB1最大是36MHz,但通用定时器时钟在APB1分频不等于1时会自动乘2。很多人默认TIM3时钟就是72MHz,可如果初始化代码把APB1设成了不分频,TIM3实际只有36MHz,编码器模式最大输入频率直接减半,高速丢步就来了。我的建议是初始化时钟后先调用RCC_GetClocksFrequencies,把TIM3的时钟打印出来确认是72MHz还是36MHz,再往下调编码器。这个动作只需要一分钟,却能把“玄学丢步”变成“参数错误”。
另一个经验是中断优先级的分配。编码器更新中断负责维护32位高位计数,采样时基负责差分测速。如果把采样时基设为最高优先级,而更新中断优先级比它低,那么当采样中断正在读取高16位时,更新中断无法抢占,组合读数可能跨越两次更新事件,偶尔位置值跳变一大块。我在一次电机闭环调试中就被这种跳变坑过,第一次遇到还以为是位置清零逻辑错了,后来才意识到是中断抢占顺序惹的祸。解决办法是把更新中断的优先级设得比采样时基更高,或者在读取32位组合值时用临界区保护。两条路选一条就够,别同时依赖多个手段,不然代码变得很难维护。
验证编码器程序是否真正写对了,我有一个很简单的流程:手转电机,而不是电转。把编码器接好后,程序只在串口打印计数值和方向。静置时计数值必须纹丝不动,如果手指轻轻碰一下编码器线计数就跳,说明输入滤波太弱或者上拉配置不合理。然后慢速转一圈,如果一圈计数值接近4×PPR,说明四倍频和方向都对了。最后快速转一下,观察速度值不能出现瞬间跳到几千的突变,出现突变就回到滤波和时钟上查。这三步能滤掉绝大多数初始化错误,之后再接入PID闭环就不会被编码器数据本身误导。
这套程序我从第一次接触编码器到现在用了很多年,踩过的坑无外乎时钟没查、滤波乱设、优先级颠倒。现在每到一个新平台,第一件事不是抄初始化代码,而是先把时钟、GPIO、中断优先级三者确认完,再动编码器模式配置。希望帮到你。
本文还有配套的精品资源,点击获取