⚠️ 本文基于 STM32F103C8T6 + 标准库 V3.5.0,适用于江科大 STM32 入门教程体系。编码器接口属于定时器基础功能,原理跨版本通用,但 HAL 库函数名和配置方式会有差异。


一、前言

做过电机控制或者智能小车的朋友一定遇到过这个问题:怎么测电机的转速?

最原始的办法是接编码器的 A 相到外部中断引脚,每次来一个脉冲进一次中断,在中断里手动计数。但这样有个大问题——脉冲频率一高,CPU 就忙不过来了。编码器转一圈可能产生几百个脉冲,电机转速一上来,中断频率爆炸,主循环都跑不动了。

这时候 STM32 的 编码器接口(Encoder Interface) 就派上用场了。它是定时器内部的一个硬件模块,能自动接收编码器的正交信号,自动控制 CNT 计数器自增或自减,你只需要每隔一段时间去读一下 CNT 的值,就知道编码器转了多少、往哪个方向转的。CPU 完全不用干预!

今天这篇文章,我就把编码器接口的原理、三种工作模式、所有相关库函数、以及完整代码实战给大家讲清楚。


二、编码器接口是什么?

2.1 基本概念

编码器接口是 STM32 定时器内部的一个硬件模块,专门用来接 增量式(正交)编码器

它的核心能力就三个:

能力 说明
自动计数 编码器 A、B 相的脉冲直接作为 CNT 的计数时钟,不需要 CPU 干预
自动辨向 根据 A、B 相的相位关系,自动判断正转(CNT++)还是反转(CNT--)
测速测位 CNT 的值直接表示编码器的位置;定时取 CNT 值并清零,就能算出速度

💡 我的理解:你可以把编码器接口想象成一个"自动秘书"。以前你(CPU)要亲自数脉冲(进中断),现在秘书(硬件)帮你数,你每隔一段时间问一下"数到多少了"就行,轻松多了。

2.2 编码器接口"借"了谁的资源?

每个 高级定时器通用定时器 都有 1 个编码器接口。它占用的硬件资源很"克制":

  • 引脚:只占用 CH1 和 CH2 两个引脚(对应输入捕获的通道 1 和通道 2)

  • 通路:借用了输入捕获前端的 滤波器边沿检测 模块

  • 输出:直接控制时基单元的 CNT 计数器(控制计数时钟和计数方向)

CH3 和 CH4 与编码器接口完全无关,后面的预分频器、CCR 寄存器也不参与编码器接口的工作。

图中展示了编码器接口的数据通路:GPIO → 滤波器 → 边沿检测/极性选择 → 产生 TI1FP1 和 TI2FP2 → 编码器接口 → 控制 PSC 和 CNT。编码器接口相当于一个"从模式控制器",根据 A、B 相的边沿和相位关系,决定 CNT 是自增还是自减。


三、正交信号:编码器为什么能辨向?

3.1 什么是正交信号?

增量式编码器有两路输出,叫 A 相B 相。它们输出的是方波,但两路方波之间有一个固定的 90° 相位差

  • 正转时:A 相超前 B 相 90°

  • 反转时:A 相滞后 B 相 90°(或者说 B 相超前 A 相 90°)

上图展示了正转和反转时的 A、B 相波形。正转时 A 相上升沿对应 B 相低电平;反转时 A 相上升沿对应 B 相高电平。正是这个"另一相的电平状态"差异,让编码器接口能判断方向。

3.2 编码器接口怎么判断方向?

编码器接口的设计逻辑很巧妙:

把 A 相和 B 相的所有边沿(上升沿 + 下降沿)都作为计数时钟,但向上计数还是向下计数,取决于边沿发生时"另一相"的电平状态。

例如:

  • 正转时,A 相产生上升沿,此时 B 相是低电平 → CNT++

  • 反转时,A 相产生上升沿,此时 B 相是高电平 → CNT--

因为正转和反转时,同一相边沿对应的"另一相电平"正好是相反的,所以硬件能自动区分方向。


四、三种工作模式详解

编码器接口有三种计数模式,由 TIM_EncoderInterfaceConfig 的第一个参数决定:

模式 宏定义 计数边沿 适用场景
仅 TI1 计数 TIM_EncoderMode_TI1 只在 A 相(TI1)的边沿计数 只需要单倍精度
仅 TI2 计数 TIM_EncoderMode_TI2 只在 B 相(TI2)的边沿计数 只需要单倍精度
TI1 和 TI2 都计数 TIM_EncoderMode_TI12 A 相和 B 相的边沿都计数 四倍频,精度最高,最常用

💡 我的理解:什么叫"四倍频"?编码器转一圈,A 相和 B 相各输出一圈脉冲(比如各 100 个)。如果只在 A 相计数,一圈计 100 次;如果在 A、B 两相的上升沿和下降沿都计数,一圈就能计 400 次!分辨率直接翻 4 倍,所以叫四倍频。测速测位都用这个模式。


五、配置六步法

编码器接口的配置流程非常固定,记住这六步:

表格

步骤 操作内容 对应代码
第 1 步 RCC 开启时钟(GPIO + TIM) RCC_APB1PeriphClockCmdRCC_APB2PeriphClockCmd
第 2 步 GPIO 初始化,配置为输入模式 GPIO_Init
第 3 步 配置时基单元(PSC 不分频,ARR 给最大) TIM_TimeBaseInit
第 4 步 配置输入捕获单元(只配滤波器和极性) TIM_ICInit
第 5 步 配置编码器接口模式 TIM_EncoderInterfaceConfig
第 6 步 开启定时器 TIM_Cmd

六、所有相关库函数详解

这一节我把编码器接口会用到的 全部库函数 列出来,一个一个讲清楚。

6.1 编码器接口配置函数(核心中的核心)

① 编码器接口模式配置
void TIM_EncoderInterfaceConfig(TIM_TypeDef* TIMx, 
                                uint16_t TIM_EncoderMode,
                                uint16_t TIM_IC1Polarity, 
                                uint16_t TIM_IC2Polarity);
// 解析:TIMx             —— 指定定时器,如 TIM3
//       TIM_EncoderMode  —— 编码器模式选择
//           TIM_EncoderMode_TI1   :仅在 TI1(A相)边沿计数
//           TIM_EncoderMode_TI2   :仅在 TI2(B相)边沿计数
//           TIM_EncoderMode_TI12  :TI1 和 TI2 边沿都计数(四倍频,最常用)
//       TIM_IC1Polarity  —— 通道 1(TI1)的极性/边沿选择
//           TIM_ICPolarity_Rising  :上升沿有效(不反相)
//           TIM_ICPolarity_Falling :下降沿有效(反相)
//       TIM_IC2Polarity  —— 通道 2(TI2)的极性/边沿选择,同上
// 注意:这个函数是编码器接口的"一键配置",调用后硬件自动接管 CNT 的计数

💡 我的理解:这个函数就是编码器接口的"总开关"。一旦调用,定时器就不再走内部时钟了,而是把 TI1FP1 和 TI2FP2 当成外部时钟源,并且根据两相信号的相位关系自动决定 CNT 是加还是减。极性参数一般两个都写 Rising,表示不反相,正常计数。


6.2 时基单元相关函数

② 选择内部时钟源
void TIM_InternalClockConfig(TIM_TypeDef* TIMx);
// 解析:TIMx —— 指定定时器
// 功能:配置定时器使用内部时钟(CK_INT),编码器接口模式下其实用不到内部时钟,
//       但时基单元初始化前显式调用一下是好习惯
③ 时基单元初始化
void TIM_TimeBaseInit(TIM_TypeDef* TIMx, TIM_TimeBaseInitTypeDef* TIM_TimeBaseInitStruct);
// 解析:TIMx                  —— 指定定时器
//       TIM_TimeBaseInitStruct —— 时基结构体指针

TIM_TimeBaseInitTypeDef 结构体成员(编码器接口模式下的特殊配置):

成员 编码器接口下的配置 说明
TIM_ClockDivision TIM_CKD_DIV1 时钟不分频
TIM_CounterMode TIM_CounterMode_Up 向上计数(编码器接口会覆盖方向)
TIM_Period 65536 - 1 ARR 给最大,防止计数溢出
TIM_Prescaler 1 - 1(即 0) PSC 不分频! 编码器脉冲直接进 CNT
TIM_RepetitionCounter 0 仅高级定时器有效

⚠️ 踩坑预警:编码器接口模式下,PSC 一定要设为 0(1-1)!因为编码器脉冲就是 CNT 的计数时钟,如果再分频,脉冲就被吃了,数出来的值就不对了。ARR 一定要给最大(65535),不然编码器转得快一点就溢出了。


6.3 输入捕获配置函数

④ 输入捕获单元初始化
void TIM_ICInit(TIM_TypeDef* TIMx, TIM_ICInitTypeDef* TIM_ICInitStruct);
// 解析:TIMx             —— 指定定时器
//       TIM_ICInitStruct —— 输入捕获结构体指针
// 注意:编码器接口只借用输入捕获的"前端"(滤波器和边沿检测),
//       所以只需要配置滤波器和极性,通道选择等参数不影响编码器接口

TIM_ICInitTypeDef 结构体成员(编码器接口模式下):

成员 常用配置 说明
TIM_Channel TIM_Channel_1 / TIM_Channel_2 分别配置 CH1 和 CH2
TIM_ICFilter 0xF 输入滤波器,最大滤波消除抖动
TIM_ICPolarity TIM_ICPolarity_Rising 极性选择,实际由 EncoderInterfaceConfig 覆盖
TIM_ICPrescaler TIM_ICPSC_DIV1 不分频
TIM_ICSelection TIM_ICSelection_DirectTI 直连模式

💡 我的理解:编码器接口模式下,TIM_ICInit 主要目的是配置滤波器。因为编码器信号可能会有机械抖动,滤波器能把毛刺滤掉。极性其实最终由 TIM_EncoderInterfaceConfig 决定,但这里也写 Rising 保持一致。

⑤ 给输入捕获结构体赋默认值
void TIM_ICStructInit(TIM_ICInitTypeDef* TIM_ICInitStruct);
// 解析:TIM_ICInitStruct —— 输入捕获结构体指针
// 功能:把结构体所有成员填充为默认值,防止未初始化成员导致奇怪问题
// 建议:定义结构体后先调用这个函数,再修改需要的成员

6.4 计数器读写函数

⑥ 获取当前计数器值
uint16_t TIM_GetCounter(TIM_TypeDef* TIMx);
// 解析:TIMx —— 指定定时器
// 返回值:当前 CNT 计数器的值(0~65535)
// 注意:编码器正转时 CNT 增加,反转时 CNT 减小,但 CNT 本身是 uint16_t
//       如果要判断方向,需要结合前后两次读数的差值符号(用 int16_t 强制转换)
⑦ 设置计数器值
void TIM_SetCounter(TIM_TypeDef* TIMx, uint16_t Counter);
// 解析:TIMx    —— 指定定时器
//       Counter —— 要写入的计数值
// 功能:直接给 CNT 赋一个值,常用于清零操作(写 0)

💡 我的理解:测速的标准套路是——定时(比如每 100ms)调用 TIM_GetCounter 读出 CNT,然后用 TIM_SetCounter(TIM3, 0) 清零。两次读数的差值就是这段时间内的脉冲数,除以时间就能得到转速。


6.5 定时器开关

⑧ 使能/失能定时器
void TIM_Cmd(TIM_TypeDef* TIMx, FunctionalState NewState);
// 解析:TIMx     —— 指定定时器
//       NewState —— ENABLE(开启)或 DISABLE(关闭)
// 功能:定时器的总开关,所有配置完成后最后调用

七、完整代码实战:编码器接口测速

下面是基于 TIM3 编码器接口(PA6-A相,PA7-B相) 的完整代码。我把每一行都加上了详细解析,方便大家对照理解。

7.1 编码器接口初始化函数

#include "stm32f10x.h"

/**
  * @brief  编码器接口初始化(TIM3,PA6-CH1/A相,PA7-CH2/B相)
  * @param  无
  * @retval 无
  * @note   适用 STM32F103C8T6,标准库 V3.5.0
  *         使用四倍频模式(TI1+TI2),最大量程
  */
void Encoder_Init(void)
{
    /* ========== 第 1 步:RCC 开启时钟 ========== */
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);   // 解析:开启 TIM3 时钟,TIM3 在 APB1 总线上
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);  // 解析:开启 GPIOA 时钟,PA6、PA7 需要它
    
    /* ========== 第 2 步:GPIO 初始化 ========== */
    GPIO_InitTypeDef GPIO_InitStructure;                   // 解析:定义 GPIO 初始化结构体
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;        // 解析:配置上拉输入,防止引脚浮空导致误触发
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; // 解析:选择 PA6 和 PA7,分别对应 TIM3_CH1 和 TIM3_CH2
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;    // 解析:输入模式下此参数不影响,但习惯性写上
    GPIO_Init(GPIOA, &GPIO_InitStructure);               // 解析:调用初始化函数,将配置写入 PA6 和 PA7
    
    /* ========== 第 3 步:配置时基单元 ========== */
    TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;      // 解析:定义时基初始化结构体
    TIM_TimeBaseInitStructure.TIM_ClockDivision = TIM_CKD_DIV1;   // 解析:时钟不分频,滤波器采样频率最高
    TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; // 解析:向上计数模式,编码器接口会自动覆盖方向
    TIM_TimeBaseInitStructure.TIM_Period = 65536 - 1;     // 解析:ARR = 65535,最大量程,防止编码器转多了溢出
    TIM_TimeBaseInitStructure.TIM_Prescaler = 1 - 1;    // 解析:【关键】PSC = 0,编码器脉冲直接作为 CNT 时钟,不分频!
    TIM_TimeBaseInitStructure.TIM_RepetitionCounter = 0;  // 解析:重复计数器,仅高级定时器有效,通用定时器写 0
    TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure);   // 解析:将时基参数写入 TIM3 寄存器
    
    /* ========== 第 4 步:配置输入捕获单元(滤波器) ========== */
    TIM_ICInitTypeDef TIM_ICInitStructure;                 // 解析:定义输入捕获结构体
    TIM_ICStructInit(&TIM_ICInitStructure);               // 解析:先给结构体赋默认值,防止未定义成员捣乱
    
    TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;      // 解析:配置通道 1(对应 PA6 / A相)
    TIM_ICInitStructure.TIM_ICFilter = 0xF;               // 解析:输入滤波器设为最大(0xF),滤除机械抖动产生的毛刺
    TIM_ICInit(TIM3, &TIM_ICInitStructure);               // 解析:将通道 1 配置写入 TIM3
    
    TIM_ICInitStructure.TIM_Channel = TIM_Channel_2;      // 解析:配置通道 2(对应 PA7 / B相)
    TIM_ICInitStructure.TIM_ICFilter = 0xF;               // 解析:同样配置最大滤波
    TIM_ICInit(TIM3, &TIM_ICInitStructure);               // 解析:将通道 2 配置写入 TIM3
    
    /* ========== 第 5 步:配置编码器接口模式 ========== */
    TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, // 解析:【核心】选择 TI1+TI2 模式,A相和B相的边沿都计数(四倍频)
                               TIM_ICPolarity_Rising,     // 解析:通道 1 极性不反相,上升沿有效
                               TIM_ICPolarity_Rising);    // 解析:通道 2 极性不反相,上升沿有效
    
    /* ========== 第 6 步:开启定时器 ========== */
    TIM_Cmd(TIM3, ENABLE);                                // 解析:启动 TIM3,编码器接口开始工作,硬件自动计数
}

7.2 编码器数值获取函数

/**
  * @brief  获取编码器当前计数值并清零
  * @param  无
  * @retval int16_t 编码器变化量,正数表示正转,负数表示反转
  * @note   建议在固定时间间隔(如 10ms/100ms)调用此函数来测速
  */
int16_t Encoder_Get(void)
{
    int16_t Temp;                                         // 解析:定义有符号变量,用来保存带方向的结果
    
    Temp = TIM_GetCounter(TIM3);                          // 解析:读取当前 CNT 值(0~65535)
                                                          //       正转时 CNT 增加,反转时 CNT 减小(向下计数)
    
    TIM_SetCounter(TIM3, 0);                              // 解析:将 CNT 清零,为下一次采样做准备
    
    return Temp;                                          // 解析:返回本次采样到的脉冲数(带符号)
                                                          //       注意:uint16_t 转 int16_t,0~32767 为正,32768~65535 为负
}

💡 我的理解:这里返回 int16_t 非常关键。因为 TIM_GetCounter 返回的是 uint16_t,但编码器反转时 CNT 是向下减的。如果当前 CNT 值从 0 减到了 65535(借位),直接当无符号数读就是 65535,显然不对。强制转成 int16_t 后,65535 就变成了 -1,正好表示反转了 1 个脉冲。这个类型转换的细节江科大在视频里强调过。

7.3 主函数调用示例(测速框架)

#include "stm32f10x.h"
#include "OLED.h"

int main(void)
{
    OLED_Init();        // 解析:初始化 OLED,用于显示转速
    Encoder_Init();     // 解析:初始化编码器接口
    
    int16_t Speed;      // 解析:定义速度变量,有符号表示方向
    
    while (1)
    {
        Delay_ms(100);                  // 解析:延时 100ms,作为"闸门时间"
        Speed = Encoder_Get();          // 解析:获取这 100ms 内编码器的脉冲变化量
        
        // 解析:在 OLED 上显示速度,正数为正转,负数为反转
        OLED_ShowSignedNum(1, 1, Speed, 5);
    }
}

💡 我的理解:这个 Delay_ms(100) + Encoder_Get() 的组合就是最简单的测速法——测频法。每 100ms 读一次 CNT,读出来的值就是这段时间内的脉冲数。

更详细代码:STM32_Project: 基于江科大的STM32的教程编写的程序


八、我的理解

第一次学编码器接口的时候,我最大的困惑是:它到底"借"了输入捕获的哪些资源? 看了江科大的视频和参考手册才搞清楚——编码器接口只借用了输入捕获前端的 滤波器边沿检测,后面的预分频器、CCR 寄存器它一概不用。所以配置输入捕获的时候,你不需要关心通道选择、预分频这些,只要配好滤波器就行。

还有一个让我恍然大悟的点:编码器接口相当于"带有方向控制的外部时钟"。普通的外部时钟模式(External Clock Mode 1)只能让 CNT 单向递增,但编码器接口能根据 A、B 相的相位关系自动决定是递增还是递减。这意味着你不需要软件判断方向,硬件全帮你做了!

关于四倍频,我之前一直以为是软件做的,后来才明白是硬件天然支持的。因为编码器接口在 TI12 模式下,A 相的上升沿、下降沿和 B 相的上升沿、下降沿都会触发计数,一圈 100 线的编码器就能计 400 次,分辨率直接翻倍再翻倍。对于需要精确控制的场景(比如 PID 调速),四倍频太重要了。


九、踩坑记录

踩坑点 现象 原因 解决方法
1. PSC 设了分频 编码器转很多圈,CNT 只增加一点点 编码器脉冲被 PSC 分频了,大部分脉冲被"吞"了 PSC 必须设为 0(1-1),编码器脉冲直接进 CNT
2. ARR 设得太小 编码器转快了之后数值突然跳变 CNT 溢出,从 65535 回到 0 ARR 设为 65536 - 1(最大),并定期清零
3. 只用了一相计数 测速精度不够,低速时抖动大 用了 TIM_EncoderMode_TI1,只在一相边沿计数 改用 TIM_EncoderMode_TI12,四倍频提高分辨率
4. GPIO 模式选错 CNT 乱跳,数值不稳定 用了浮空输入,引脚受干扰 改成 上拉输入(IPU) 或 下拉输入(IPD
5. 返回类型用 uint16_t 反转时显示超大数值(如 65530) 反转时 CNT 向下减,借位后无符号数解读错误 Encoder_Get 返回 int16_t,强制转换带符号

最惨痛的一次:我把编码器的 A、B 相接反了,结果电机正转时 OLED 显示负数,反转显示正数。排查了半天代码,以为是极性配错了,最后发现是物理线接反了……从那以后,我接编码器之前一定会先确认 A 相和 B 相的定义。


十、总结表格

项目 说明
编码器接口本质 定时器内部硬件模块,自动接收正交信号并控制 CNT 计数
占用资源 仅占用 CH1(PA6)和 CH2(PA7),借用输入捕获前端滤波器和边沿检测
辨向原理 A、B 相 90° 相位差,边沿触发时根据"另一相电平"判断方向
四倍频 TIM_EncoderMode_TI12 模式,A、B 相所有边沿都计数,分辨率 ×4
核心函数 TIM_EncoderInterfaceConfig(一键配置)、TIM_GetCounter(读数)、TIM_SetCounter(清零)
PSC 配置 必须设为 0(不分频),否则脉冲被吞
ARR 配置 设为 65535(最大量程),防止溢出
测速方法 固定闸门时间读 CNT 并清零,脉冲数 / 时间 = 速度
返回类型 int16_t 保存 CNT 值,才能正确表示正反转方向
适用边界 标准库 V3.5.0 + STM32F103C8T6;增量式正交编码器
注意事项 必须共地!PSC 必须为 0!ARR 必须最大!返回必须用有符号数!

十一、结语

写这篇文章的时候,我把江科大的视频又回顾了一遍,重新梳理了编码器接口在定时器框图中的位置和它的数据通路。编码器接口真的是 STM32 定时器里一个非常贴心的设计——它把"数脉冲"这种简单但频繁的任务完全交给硬件,让 CPU 去干更重要的事情。

从外部中断手动计数,到编码器接口硬件自动计数,这就是用硬件资源换软件资源的典型例子。理解了这一点,你就能明白为什么 STM32 要设计这么多专用硬件模块——它们不是为了炫技,而是为了让你写更少的代码、跑更稳的程序。

希望这篇文章能帮你理清编码器接口的思路。如果你在接编码器或者测速的过程中遇到了其他问题,欢迎在评论区交流,我们一起踩坑、一起进步!

📌 开发环境:Keil5 + STM32F103C8T6最小系统板 + ST-Link
📌 参考资料:江科大STM32入门教程、《STM32F10xxx参考手册》
📌 前置文章江科大STM32入门:TIM输入捕获之PWMI模式——用一根线同时测频率和占空比

如果本文对你有帮助,欢迎点赞 👍 收藏 ⭐ !有任何问题欢迎在评论区交流,一起学习进步!

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐