☰
STM32CubeMX配置正交编码器:从原理到代码的电机测速完整指南
2026/10/5 5:02:19 网站建设 项目流程

很多人第一次在STM32上做电机测速、云台角度回读、或者精确定位的时候,都会被“正交编码器”这四个字劝退。其实用STM32CubeMX配置正交编码器,也就是十几分钟的事。我们不需要自己写边沿捕获、不需要开外部中断一个个数脉冲,只要把定时器切到Encoder Mode,剩下的硬件全帮你干完了。这篇文章我把从原理到CubeMX实操再到代码读取的完整流程都整理出来,几个容易踩的坑也标注在最前面,希望能帮想用编码器测速或者定位的朋友少走弯路。

1. 项目背景与整体设计思路

1.1 为什么选择STM32CubeMX来配置

做嵌入式开发的老手都知道,早期用标准外设库配编码器模式是个繁琐的活:要手动计算定时器的时钟树、要配置GPIO的复用功能、要设置TI1和TI2的映射关系、还要处理极性反相、滤波器那一堆寄存器位。光是搞清楚TIM的SMCR寄存器里SMS位写100还是101,就能耗掉半天时间。

STM32CubeMX把这个过程变成了图形化点选。你只需要在定时器的配置页里把Combined Channels改成Encoder Mode,勾上TI1和TI2两个通道,软件会自动帮你把GPIO复用、时钟使能、寄存器初始化代码全部生成好。这带来的好处不只是省时间,更重要的是减少了人为配置出错的概率。我之前在项目里手动配过一次复用功能,把PA8当成通道输入用,结果引脚电平死活不变化,排查半天才发现复用映射选错了。用CubeMX后这类低级错误基本可以杜绝。

另外很多团队现在用STM32CubeMX管理整个工程的时钟、外设、中间件,如果你要在同一个项目里同时用ADC采集、串口调试、PWM输出和编码器输入,用CubeMX统一生成代码,后续维护起来也方便很多。特别是换芯片型号的时候,直接修改.ioc文件重新生成即可,不用手动移植外设初始化代码。

1.2 正交编码器的基本工作原理

正交编码器,也叫增量式编码器,输出的是A、B两路相位相差90度的方波信号。电机转动时,A、B两路信号会产生规律的相位关系:正转时A相超前B相90度,反转时B相超前A相90度。芯片正是通过检测这个相位关系来判断方向,再通过累计边沿数量来计算位移。

常见的编码器每转一圈会输出固定的脉冲数,比如1000线编码器,一圈就是1000个脉冲,但这只是A相或者B相的脉冲数。如果我们把A、B两路的上升沿和下降沿全部利用起来,一个脉冲周期内就能记录到4个状态变化,也就是大家常说的4倍频。这样1000线的编码器,实际分辨率可以达到每圈4000个计数值。

除了A、B两路,很多编码器还有Z相,也叫零位信号,每转一圈输出一个脉冲,通常用来做回零定位。Z相一般会接到另外一个定时器通道上,或者用普通IO配合外部中断,这个我们在后面专门讲。

1.3 方案选型:硬件定时器模式还是外部中断计数

很多初学者看到编码器信号,第一反应是用外部中断来数脉冲。外部中断的方式确实也能实现计数,A相触发中断,在中断里判断B相的电平高低,进而决定计数加一还是减一。但这个方案有几个硬伤:第一,编码器在高速旋转时脉冲频率会很高,假设3000转的电机配合500线的编码器,4倍频之后每秒就有10万个计数事件,外部中断根本忙不过来,还会拖垮主程序;第二,中断响应本身有延迟和抖动,容易漏脉冲或者误判方向,导致计数不准。

定时器的Encoder Mode是纯硬件逻辑实现的,不需要CPU参与。定时器内部持续不断地对TI1和TI2进行采样,然后根据两路信号的边沿组合来自动更新计数器的值。无论电机转到多快,只要脉冲频率不超过定时器时钟上限,计数就不会丢。这就把CPU解放出来了,主循环该干什么干什么,读取计数值的时候直接拿计数器寄存器就行。

所以在电机控制、自动化定位这类场景下,硬件的编码器模式几乎是唯一合理的选择,这也是我推荐你使用STM32CubeMX来配置的根本原因。

2. 核心原理:定时器编码器模式到底在做什么

2.1 编码器模式的采样逻辑

STM32的通用定时器(TIM2、TIM3、TIM4、TIM5等)都支持编码器接口模式。开启这个模式后,定时器的CH1和CH2引脚自动变成编码器输入,内部逻辑会同时监测这两个引脚的电平变化。

我要强调一个容易误解的点:编码器模式不是在两个引脚的上升沿计数这么简单,它内部有一个状态机。TI1和TI2一共可以构成4种电平组合(00、01、10、11),电机转动时这4种状态按固定顺序循环变化。正转时状态的顺序是00→01→11→10(或者反过来),反转时顺序完全颠倒。定时器检测到状态变化后,根据变化的方向决定计数器CNT是加一还是减一。这就是为什么编码器模式天然就能区分正反转,不需要你去写逻辑判断。

在CubeMX里,Encoder Mode有三个选项:Encoder Mode TI1、Encoder Mode TI2、Encoder Mode TI1 and TI2。前两个模式只在单路信号的边沿计数,相当于2倍频或者1倍频。最后一个是同时利用两路信号的所有边沿,也就是4倍频。绝大多数场景我都建议选TI1 and TI2,把分辨率拉到最高,这样测速和定位的精度都好一些。

2.2 计数方向与倍频的关系

编码器模式下的倍频,取决于你配置的是单通道还是双通道。打个比方:TI1模式,只在A相的上升沿计数,B相只用来判断方向,这样每个完整脉冲周期只计1次;TI2模式同理,在B相边沿计数,也是每个脉冲周期计1次;TI1 and TI2模式,A、B两相的上升沿和下降沿几乎都会触发计数,每个脉冲周期计4次。

这里有个细节值得注意:在双通道模式下,也不是每个状态跳变都计数。硬件会在特定的边沿组合下更新计数器,确保一个完整循环内恰好增加4个计数。我实际测试过,在TI1 and TI2模式下,让电机正转一圈,计数值增加的数量正好等于编码器线数乘以4,误差为零。

计数方向方面,ARR和CNT配合使用。默认配置下,正转CNT向上计数,反转CNT向下计数。这个方向可以通过交换A、B两相接线来反转,也可以在CubeMX里直接改通道的极性来实现。我建议前期调试的时候保持默认方向,等确定电机正反转的定义之后再决定要不要在设计上反转。

2.3 溢出与计数量程设计

编码器模式使用的还是定时器的16位或者32位计数器。以16位定时器为例,CNT的范围是0到65535。编码器正转时CNT递增,超过65535就会溢出;反转时CNT递减,低于0会下溢。如果不对溢出做处理,你拿到的计数值就是一个模65536的循环量,单圈以内没问题,多圈累计就会出错。

解决多圈计数有两个思路。第一个思路,把ARR设置为编码器每圈的4倍频值,这样每转一圈CNT刚好从0走到ARR,然后触发更新事件,在更新中断里用一个软件变量记录圈数。这个方案的好处是CNT直接对应当前圈内的绝对位置,定位控制特别方便。第二个思路,把ARR设置为最大值65535,CNT纯粹当作一个相对量来读,每次读取后用上一次的值做差值,得到增量,再配合溢出方向来修正圈数。

我个人做速度测量更喜欢第二种方式,因为速度只关心增量,圈数修正逻辑本身也不复杂。做绝对位置定位则更适合第一种方式,代码更直观。具体的代码实现我放在后面的章节里详细展开。

3. STM32CubeMX实操配置步骤

3.1 新建工程与引脚规划

打开STM32CubeMX,新建工程,选择你手头的芯片型号。这里以常见的STM32F103C8T6为例,它的TIM2、TIM3、TIM4都支持编码器模式,引脚资源也比较充裕。

我建议把编码器的A相接TIMx_CH1,B相接TIMx_CH2。以TIM3为例,A相接PA6(TIM3_CH1),B相接PA7(TIM3_CH2)。如果你要用Z相做回零,那Z相可以接到PB0这类普通IO,配合外部中断或者定时器输入捕获来处理。

确定引脚后,在CubeMX里把PA6和PA7两个引脚点击一下,在弹出的菜单中选择TIM3_CH1和TIM3_CH2。CubeMX会自动推导出引脚需要用到的复用功能,非常方便。这里要注意,编码器输入不需要配置上拉或者下拉,定时器模式会自动处理引脚的电气特性。如果编码器输出类型是开漏,那外部需要加合适的上拉电阻,这个跟CubeMX配置无关,是硬件设计层面的问题。

3.2 定时器参数配置详解

配置好引脚之后,在左侧Categories列表里找到Timers,点击TIM3,进入配置界面。首先要确认两个关键参数:

Clock Source选择Internal Clock,这个不用动,它是定时器的时基时钟,编码器计数不依赖它产生PWM,但时基时钟决定了计数频率上限。

然后在Combined Channels下拉框里选择Encoder Mode,这时界面会出现TI1和TI2相关选项,把两个通道都勾选成Input capture direct mode。Encoder Mode下拉框选择TI1 and TI2。

接着往下看参数配置,这里有几个参数需要仔细设置:

  • Prescaler(预分频):编码器模式下预分频器默认设为0,也就是说每个计数事件对应一个边沿,不做分频。改成非零值可以达到一个类似于软件降采样的效果,但一般没这个必要。
  • Counter Period(自动重载值ARR):根据你的量程需求来填。如果做单圈定位,填每圈的4倍频数值,比如500线编码器填2000;如果做增量测量,填65535。
  • Input Filter(输入滤波器):这个参数很关键,后面讲抗干扰的时候我会详细展开。调试初期可以填0,不滤波,等确认基本功能正常后再根据现场情况调整。
  • Polarity(极性):默认Rising Edge就行。如果发现方向反了,我建议先交换A/B接线来验证,而不是急着改极性。

配置完参数之后,记得在Project Manager里设置好工程名、路径、IDE类型(MDK-ARM或者IAR),选好HAL固件库版本,然后点击Generate Code生成工程。

3.3 生成代码与基础验证

生成完工程后,打开工程,在main.c的main函数里找到MX_TIM3_Init()函数,里面就是CubeMX自动生成的配置代码。你会看到类似这样的结构:

static void MX_TIM3_Init(void) { TIM_Encoder_InitTypeDef sConfig = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 0; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 65535; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; sConfig.EncoderMode = TIM_ENCODERMODE_TI12; sConfig.IC1Polarity = TIM_ICPOLARITY_RISING; sConfig.IC1Selection = TIM_ICSELECTION_DIRECTTI; sConfig.IC1Prescaler = TIM_ICPSC_DIV1; sConfig.IC1Filter = 0; sConfig.IC2Polarity = TIM_ICPOLARITY_RISING; sConfig.IC2Selection = TIM_ICSELECTION_DIRECTTI; sConfig.IC2Prescaler = TIM_ICPSC_DIV1; sConfig.IC2Filter = 0; HAL_TIM_Encoder_Init(&htim3, &sConfig); }

这个初始化函数把定时器配置成了编码器接口模式。看一下EncoderMode那行是TIM_ENCODERMODE_TI12,说明两个通道双沿都在计数,这就是我们想要的4倍频模式。

基础验证的方法很简单:接上编码器,手动慢慢旋转电机轴,然后在调试器里观察TIM3的CNT寄存器值。如果正转时数值增加、反转时数值减小,说明配置正确。如果方向反了,交换A/B线再试。如果数值乱跳,那就要检查接线、滤波和供电了。

4. 代码实现与数据处理

4.1 读取计数值

使用HAL库读取编码器计数值非常简单,一行代码就行:

int16_t count = (int16_t)__HAL_TIM_GET_COUNTER(&htim3);

为什么这里用一个强制类型转换?因为CNT寄存器是一个16位的无符号数,范围0到65535。但我们知道编码器的计数是有方向的,正转加、反转减。如果直接把寄存器值赋给一个int16_t类型变量,硬件上的二进制补码转换会帮我们把这个循环区间映射到-32768到32767,这样读取出来的值就是一个带正负的“相对位置”。

这个技巧在很多编码器应用里非常实用。以500线编码器4倍频为例,一圈2000个计数。选ARR为65535时,只要单圈内正反两个方向从零点出发的累计变化量不超过32767个计数(相当于16圈以上),读取出来的int16_t值就能直接当作带符号的位置量使用,不需要额外判断溢出。当然,如果你要做多圈累计或者长时间运行,单靠这个技巧是不够的,必须处理溢出事件。

4.2 增量转速计算

测速的本质就是计算单位时间内计数值的变化量。我常用的方法是在一个固定时间间隔(比如10ms)内读取两次计数值,做差得到增量,再换算成转速。伪代码逻辑如下:

uint16_t last_count = __HAL_TIM_GET_COUNTER(&htim3); uint32_t last_time = HAL_GetTick(); float speed_rpm = 0.0f; while (1) { delay_ms(10); uint16_t now_count = __HAL_TIM_GET_COUNTER(&htim3); uint32_t now_time = HAL_GetTick(); int16_t delta_count = (int16_t)(now_count - last_count); uint32_t delta_time_ms = now_time - last_time; // 每秒计数增量 = delta_count * 1000 / delta_time_ms // 转速RPM = 每秒计数增量 / (每圈计数量) * 60 float delta_per_sec = (float)delta_count * 1000.0f / (float)delta_time_ms; speed_rpm = delta_per_sec / (encoder_ppr * 4.0f) * 60.0f; last_count = now_count; last_time = now_time; }

这里需要注意一个细节:delta_count的计算用int16_t类型,是因为两次采样之间电机可能正转也可能反转,直接用uint16_t做差再转换会出现方向丢失的问题。先用int16_t强制转换,再利用二进制补码的特性,就能正确得到带符号的增量值。这个技巧我在项目里用了很多次,非常可靠。

还有一个设计取舍:采样周期越短,速度响应越快,但低速时的量化误差也越大。比如10ms采样周期下,低速时每个周期可能只增加1到2个计数,测出来的速度是一跳一跳的。如果应用对低速平稳性有要求,可以加大采样周期,比如用50ms,代价是动态响应变慢。具体选多大,要根据你电机的额定转速和控制周期来权衡。

4.3 多圈计数与溢出处理

如果你的应用需要连续多圈累计,就必须处理溢出问题。我推荐的方案是开启定时器更新中断,在中断里维护一个软件计数器。以ARR=65535为例,正转溢出时CNT从65535跳到0,方向向上;反转下溢时CNT从0跳到65535,方向向下。在更新中断里判断方向,把软件计数器加一或减一。

代码上,先在CubeMX里使能TIM3的全局中断NVIC,然后在回调函数里写逻辑:

volatile int32_t encoder_overflow_count = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { // 通过DIR位判断计数方向 if ((TIM3->CR1 & TIM_CR1_DIR) != 0) { encoder_overflow_count--; // 向下计数产生更新事件 } else { encoder_overflow_count++; // 向上计数产生更新事件 } } }

完整的位置值可以这样计算:

// 读取当前CNT和溢出计数值 uint16_t cnt = __HAL_TIM_GET_COUNTER(&htim3); int32_t total_count = (int32_t)encoder_overflow_count * 65536 + (int32_t)(uint16_t)cnt;

这个方案的关键在于,定时器必须配置成更新事件由溢出或者下溢触发,要确认CubeMX里Update interrupt是使能状态,同时NVIC里对应中断线打开。否则中断不触发,软件计数就永远不更新。

另一种更精细的做法是:不同时读取CNT和overflow_count可能导致数据不同步,因为中断可能在读取过程中触发。严谨的代码应该用临界区保护一下,比如先关中断读CNT,再开中断。不过在实际应用里,如果你的采样频率远低于中断频率,不同步造成的误差通常可以忽略,我一般只在要求极高的场合才加临界区保护。

4.4 Z相零位与回零逻辑

增量式编码器的特点是没有绝对位置,上电之后你不知道当前角度在哪。如果需要回零,就要用到Z相信号。Z相每转一圈输出一个脉冲,这个脉冲的位置与编码器的机械位置严格对应。

处理Z相有两种常用方式。第一种是把Z相接在另一个定时器的输入捕获通道上,开启输入捕获中断,捕获到Z相脉冲时记录当前位置并清零计数器。第二种是直接把Z相接普通IO口,用外部中断来处理。

我比较推荐第二种方式,因为回零逻辑往往不只是清零计数器这么简单。实际应用中,通常需要先低速反转找Z相,找到之后还要让电机停在某个特定位置,比如Z相之后的第N个计数处,这个过程中你可能还要控制电机启停,放普通IO中断里处理更灵活。

一个典型的回零流程:

  1. 设置一个低速目标速度,让电机朝一个固定方向旋转。
  2. 等待Z相外部中断触发,表示已经找到零位标记。
  3. 在Z相中断里把编码器计数值清零,同时记录当前圈数。
  4. 如果需要停在精确的机械零位,则继续以极低速度运动,直到计数值等于某一预设偏移量后停止。

这个流程看起来简单,实际调试时还是有不少坑。比如Z相信号宽度一般只有几微秒,外部中断配置为双边沿触发时容易产生毛刺;再比如回零过程中电机过冲导致Z相脉冲被漏掉。所以我之前在代码里都会加一个超时保护:如果10秒内没有找到Z相,就认为回零失败,重新开始。这些细节在博文后面的问题排查部分也会提到。

5. 常见问题与排查技巧实录

5.1 计数异常问题速查表

我把实际调试中遇到的高频问题整理成了表格,每个问题都标注了排查思路。这省去了不少重复排查的时间:

现象可能原因排查方法
计数值一直为0引脚复用未生效确认PA6/PA7是否选成TIM3_CH1/CH2,重新生成代码
计数值不增加,只跳变编码器供电或接线松动用示波器/万用表量A/B相电平,确认有方波信号
计数方向反了A/B接反交换编码器A/B输出线,或在CubeMX里改极性
低速计数正常,高速丢步输入滤波过大减小IC1Filter/IC2Filter,或检查硬件上拉电阻
计数值来回抖动编码器信号噪声大开启输入滤波、使用屏蔽线、检查共地
上电后第一次计数异常定时器未启动在初始化后调用HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL)
多圈累计数值不对溢出中断未处理检查NVIC是否使能,验证溢出回调是否触发

特别强调一下启动定时器这步。CubeMX生成代码后,很多人忘了启动编码器模式。如果你在main里没调用HAL_TIM_Encoder_Start,计数器是静止的,读数永远不变。HAL库不会自动启动编码器模式,必须手动调用。这个是我见过最多新手踩的坑。

还有一点,编码器模式的输入频率上限和定时器时钟有关。以F103的72MHz定时器时钟为例,理论上可以处理的编码器频率远超实际电机能产生的脉冲频率,所以基本不用考虑超限问题。但如果你用低速时钟跑高频编码器,确实可能出现采样漏边沿的情况,这个可以通过提高定时器时钟或者降低编码器线数来解决。

5.2 抗干扰与布线经验

编码器信号在工业现场最容易受到干扰。电机驱动线、电源线都是干扰源,如果编码器线跟这些线走在一起,很容易把毛刺耦合进A/B信号里。

CubeMX里提供的输入滤波功能是抗干扰的第一道防线。在编码器模式下,IC1Filter和IC2Filter的单位是定时器时钟周期,可以设置一个范围。以72MHz时钟为例,Filter值设为比如8,大约能滤掉110ns以的窄脉冲。这个参数并不是越大越好,因为滤波会引入信号延迟,过高时在高速编码器应用中会造成计数偏差。我的经验是先从比较小的值开始试,比如4到8,现场有示波器的话直接观察滤波前后的波形。

硬件层面的措施也不能缺少。我给几个在实际产品上验证过的建议:

  • 编码器线使用双绞屏蔽线,屏蔽层单点接地。
  • 编码器供电独立走线,不要和电机电源共用一根线。
  • 在单片机端的A/B引脚各加一个100Ω串联电阻和10nF对地电容,做简单RC滤波。
  • 编码器与单片机之间确保共地,地线压差过大时信号电平可能出现不确定性。

软件上还有一个技巧:读计数值时连续读两次,如果两次结果一致再使用。因为计数器可能在读取过程中发生变化,这种读取竞争问题在高频计数时可能出现。用HAL库的__HAL_TIM_GET_COUNTER读取时,本质上就是一个16位读取操作,在部分硬件上可能触发锁存机制,但保险起见连续两次读取加校验的逻辑开销很小,值得加上。

5.3 调试心得与经验总结

最后分享一点我的个人体会。先说调试工具,我强烈建议手头常备一个带逻辑分析仪或者双通道示波器。编码器调试中八成的问题,看一眼A/B相波形就能定位,比盲改代码快得多。如果没有示波器,也可以用两个LED分别接A/B相,手动慢慢转电机,观察LED点亮的顺序来判断相序是否正常,这个方法在野外或者实验室没有仪器的时候特别好用。

另外,在写测速代码时,我习惯先用固定占空比开环驱动电机,然后在串口打印计数值,验证方向、量程、滤波都正确之后,再上闭环控制。这种分步验证的思路能最大程度上避免“开环都转不动,闭环更是一团糟”的局面。

还有一个容易忽略的点:编码器的机械安装精度会直接影响读数稳定性。联轴器松动、电机轴径向跳动、编码器码盘污染,这些机械问题在软件上无论怎么调都救不回来。如果排除了所有电气和软件问题但计数还是不稳定,不妨检查一下编码器的机械固定情况。

做嵌入式就是这样,软件上看起来简单的一个外设,真正稳定可靠地用起来,需要从原理到接线再到调试方法全面考虑。希望这篇文章能把STM32CubeMX配置正交编码器这条路帮你蹚平,你接下来要做的就是拿一块板子、一个电机,按照步骤操作一遍,有问题随时对照排查表来定位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询