ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32f103编码器程序实战:正交解码、测速避坑与闭环控制

STM32f103编码器程序实战:正交解码、测速避坑与闭环控制

简介:这份STM32F103编码器程序面向嵌入式开发初学者与电机控制方向的工程师,聚焦增量式编码器与STM32定时器接口的完整实现,可用于电机测速、位置检测等场景的参考与移植。压缩包共58个文件,约1.36MB,以C语言源码、头文件、Keil工程文件(uvproj、uvopt)、编译中间文件(o、crf、d)及hex烧录文件为主,另含启动汇编文件与JLink调试配置,工程结构完整,可直接打开编译。程序围绕TIM模块捕获编码器脉冲展开,涵盖GPIO输入配置、定时器预分频与计数模式设置、中断优先级管理及中断服务函数中读取计数值t的流程,并涉及信号滤波抗干扰与PID控制思路。已有2030人学习下载,适合对照源码理解编码器信号采集与中断处理的完整链路,快速搭建自己的电机控制工程。

1. 从一次电机测速翻车说起:STM32f103 编码器程序到底能干什么

前阵子帮一个做云台的朋友调板子,他用的就是 STM32f103 接增量式编码器测电机转速。现象很典型:串口打出来的速度值要么是 0,要么突然跳到几千,PID 一上就抖成筛子。他以为是编码器坏了,换了三个型号,问题照旧。最后定位到根因——定时器编码器模式配置成了「只数一个方向」,另一路信号根本没参与计数,方向一变计数就乱。这个坑其实在 STM32f103 编码器程序里非常常见,也是很多人第一次接触正交解码时最容易翻车的地方。

这份 STM32f103 编码器程序,核心就是围绕 TIM 定时器的 Encoder Interface 模式,把 A/B 两相正交信号解成带方向的计数值,再换算成转速、位置,供上层做闭环控制。它适合三类人:正在做电机测速、云台、平衡车、CNC 进给的学生和工程师;想从 GPIO 中断计数升级到硬件正交解码的开发者;以及需要一套能直接移植到 F103C8T6 最小系统上的参考代码的人。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份程序拆开讲。

2. 架构与目录拆解:TIM 编码器模式是怎么把 A/B 相变成方向计数的

2.1 整体分层:从信号到转速的三段式

这份程序的结构不复杂,但分层很清楚,基本是「硬件抽象 → 编码器驱动 → 应用换算」三层。底层是 STM32f103 的 TIM 外设初始化,负责把 GPIO 配成浮空输入或上拉输入,再把对应通道映射到定时器的编码器接口;中间层是编码器读取与方向判断,负责在固定周期里取 CNT 值并做增量计算;上层是速度换算和溢出处理,把计数值转成 RPM 或 mm/s。

我一般会把这三层放在不同文件里,避免 main.c 变成一锅粥。常见做法是:

Project/ ├── Core/ │ ├── Inc/ │ │ ├── encoder.h // 编码器驱动接口 │ │ ├── motor.h // 电机控制接口 │ │ └── main.h │ └── Src/ │ ├── encoder.c // TIM 编码器模式初始化 + 读取 │ ├── motor.c // PWM 输出 + 方向控制 │ └── main.c // 主循环、定时采样、串口打印 ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ └── STM32F103C8TX_FLASH.ld

这个目录是 STM32CubeMX 生成工程后的典型形态,encoder.c 里放的是与 TIM 强相关的代码,motor.c 放 PWM,main.c 只做调度。这样分层的好处是:换定时器、换引脚,只动 encoder.c;换控制算法,只动 main.c 里的换算部分。

2.2 TIM 编码器模式的寄存器级理解

STM32f103 的通用定时器(TIM2/TIM3/TIM4)和高级定时器(TIM1/TIM8)都支持编码器接口模式。它的本质是:把 TIMx_CH1 和 TIMx_CH2 两路输入接到编码器的 A/B 相,硬件根据两路信号的边沿顺序自动加一或减一,CNT 寄存器就是位置计数。你不需要写任何中断去手动判断方向,这是它比 GPIO 中断计数优雅的地方。

关键配置项有三个:计数模式、输入捕获滤波、极性。计数模式决定在 A/B 的哪些边沿计数,常见的是「Encoder Mode TI1 and TI2」,也就是四倍频,A 和 B 的上升沿下降沿都计数,分辨率最高。滤波用来抑制编码器抖动和电机换向噪声,值越大滤波越强,但会限制最高响应频率。极性则用来纠正 A/B 接反导致的方向反转。

// encoder.c —— TIM3 编码器模式初始化(四倍频) void Encoder_Init(void) { TIM_Encoder_InitTypeDef sConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 0; // 编码器模式不需要分频 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 65535; // 16 位满量程,靠溢出计数扩展 htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; sConfig.EncoderMode = TIM_ENCODERMODE_TI12; // A/B 双边沿都计数 sConfig.IC1Polarity = TIM_ICPOLARITY_RISING; sConfig.IC1Selection = TIM_ICSELECTION_DIRECTTI; sConfig.IC1Prescaler = TIM_ICPSC_DIV1; sConfig.IC1Filter = 6; // 滤波,抗抖动 sConfig.IC2Polarity = TIM_ICPOLARITY_RISING; sConfig.IC2Selection = TIM_ICSELECTION_DIRECTTI; sConfig.IC2Prescaler = TIM_ICPSC_DIV1; sConfig.IC2Filter = 6; HAL_TIM_Encoder_Init(&htim3, &sConfig); sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig); HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL); // 启动编码器接口 }

这段代码里,EncoderMode = TIM_ENCODERMODE_TI12是核心,它让 TIM3 同时对 TI1 和 TI2 的边沿敏感,实现四倍频。IC1Filter = 6是经验值,电机碳刷噪声大时可以调到 8 到 10,但如果你编码器线数高、转速快,滤波太强会丢脉冲,这时候要降下来。Period = 65535是因为 F103 的 TIM3 是 16 位,计满会溢出,所以上层必须处理溢出,否则位置会突然跳变。

2.3 读取与方向判断:CNT 差值的正确算法

很多人读编码器就是直接读 CNT,然后两次相减。这在低速下没问题,但高速或者采样周期长的时候,16 位计数器会溢出,差值算出来是错的。正确做法是用有符号的 16 位差值,让编译器自动处理回绕。

// encoder.c —— 读取增量,返回带符号的脉冲数 int16_t Encoder_GetDelta(void) { static uint16_t last_cnt = 0; uint16_t now_cnt = __HAL_TIM_GET_COUNTER(&htim3); int16_t delta = (int16_t)(now_cnt - last_cnt); // 利用补码回绕 last_cnt = now_cnt; return delta; }

(int16_t)(now_cnt - last_cnt)这行是关键。假设 last_cnt 是 65530,now_cnt 是 5,无符号相减得到 11,强转成 int16_t 还是 11,表示正向走了 11 个计数。反过来 last_cnt 是 5,now_cnt 是 65530,无符号相减得到 65525,强转 int16_t 变成 -11,表示反向。这就是利用补码回绕做溢出处理,比手动判断溢出标志位简洁得多。注意采样周期不能太长,否则一个周期内增量超过 32767 就会误判方向,一般 1ms 到 10ms 采样一次足够。

3. 跑起来:从 CubeMX 配置到串口看到转速的完整步骤

3.1 硬件接线与引脚分配

先确认你的板子和编码器。F103C8T6 最小系统上,TIM3 的 CH1 是 PA6,CH2 是 PA7;TIM2 的 CH1 是 PA0,CH2 是 PA1;TIM4 的 CH1 是 PB6,CH2 是 PB7。编码器的 A 相接 CH1,B 相接 CH2,VCC 和 GND 按编码器规格接,常见的是 5V 或 3.3V 供电。如果是差分输出的编码器(比如伺服电机上的),中间要加差分接收芯片转成单端,不能直接接。

提示:接线前先用万用表确认编码器供电电压,5V 编码器输出直接进 F103 的 3.3V 引脚长期可能损伤 IO,稳妥做法是加电平转换或串 1k 电阻。

3.2 CubeMX 关键配置项

用 CubeMX 建工程时,选好芯片型号后,在 Timers 里选一个支持编码器模式的定时器,比如 TIM3。Combined Channels 选 Encoder Mode,然后 Channel1 和 Channel2 会自动变成编码器输入。Parameter Settings 里,Encoder Mode 选 Encoder Mode TI1 and TI2,Counter Period 填 65535,IC1 Filter 和 IC2 Filter 填 6 到 10。GPIO 那边会自动把 PA6、PA7 配成输入,Pull-up/Pull-down 建议选 Pull-up,因为很多编码器是开漏输出。

时钟树别忘了,F103 一般跑 72MHz,APB1 定时器时钟也是 72MHz。串口配一个 USART1,PA9/PA10,波特率 115200,用来打印转速。生成工程时选 HAL 库,工具链按你用的 IDE 选。

3.3 主循环采样与转速换算

生成工程后,在 main.c 里加采样逻辑。核心是固定周期读增量,换算成转速。假设编码器线数是 500 线,四倍频后每转 2000 个计数,采样周期 10ms。

// main.c —— 10ms 定时采样,换算 RPM #define ENCODER_PPR 500 // 编码器线数 #define GEAR_RATIO 1 // 减速比 #define SAMPLE_PERIOD_MS 10 // 采样周期 int32_t total_count = 0; float rpm = 0.0f; while (1) { int16_t delta = Encoder_GetDelta(); total_count += delta; // RPM = (增量 / 每转计数) / 采样时间(秒) * 60 float counts_per_rev = ENCODER_PPR * 4 * GEAR_RATIO; rpm = (delta / counts_per_rev) / (SAMPLE_PERIOD_MS / 1000.0f) * 60.0f; printf("delta=%d, total=%ld, rpm=%.2f\r\n", delta, total_count, rpm); HAL_Delay(SAMPLE_PERIOD_MS); }

ENCODER_PPR * 4是四倍频后的每转计数,GEAR_RATIO是电机到输出轴的减速比,直连就填 1。delta / counts_per_rev得到这一周期转了多少圈,除以采样时间得到每秒转数,乘 60 就是 RPM。total_count是累计位置,可以用来做位置闭环。注意HAL_Delay在中断里不能用,这里放主循环没问题,但如果你的控制周期要求严格,建议用定时器中断触发采样,避免主循环里其他任务拖慢节奏。

烧录后打开串口,手动转一下电机轴,应该能看到 delta 正负交替、rpm 跟着变。如果方向反了,把 A/B 相接线对调,或者在 CubeMX 里把 IC1Polarity 和 IC2Polarity 其中一个改成 FALLING。

4. 避坑与排查:编码器读数不对时先查这五条

4.1 读数一直是 0 或者固定值

现象:串口打出来的 delta 恒为 0,或者一直是个固定数不随转动变化。原因通常是编码器没供电、A/B 相接错引脚、或者定时器没启动。先量编码器 VCC 有没有电压,再用示波器看 A/B 相有没有方波。如果信号有但 CNT 不动,检查HAL_TIM_Encoder_Start有没有调用,以及 GPIO 有没有被别的外设占用。F103 的 PA6/PA7 同时也是 SPI1 的引脚,如果工程里开了 SPI1 复用,会冲突。

4.2 方向反了或者正反转都加

现象:正转时 CNT 增加,反转时也增加,或者方向跟预期相反。原因一是 A/B 相接反,二是编码器模式选成了单通道。解决:先对调 A/B 接线看方向是否正常;如果正反转都加,检查EncoderMode是不是误设成了TIM_ENCODERMODE_TI1或TI2,这两个只对单通道边沿计数,无法判断方向,必须用TI12。

4.3 高速时转速跳变或丢脉冲

现象:低速正常,转速一高 rpm 就乱跳,甚至出现负值。原因一是采样周期太长,一个周期内增量超过 32767,补码回绕判断失效;二是输入滤波太强,高频脉冲被滤掉。解决:缩短采样周期到 1ms 到 5ms,或者把IC1Filter降到 2 到 4。如果编码器线数很高(比如 2500 线),四倍频后每转 10000 计数,高速时增量很大,这时候要么提高采样频率,要么换 32 位定时器(TIM2/TIM5)并扩大 Period。

4.4 电机一动读数就抖

现象:电机不转时读数稳定,一上电转动就出现大量随机正负跳变。原因是电机换向噪声和电源纹波耦合到编码器信号线上。解决:编码器信号线用屏蔽线并单端接地,A/B 相各加 100nF 到 GND 的滤波电容,软件上把IC1Filter和IC2Filter调到 8 到 10。如果还抖,检查电机和编码器的地是不是共地,不共地会引入共模干扰。

4.5 累计位置 total_count 越走越偏

现象:来回转动后 total_count 回不到原点,有累积误差。原因是四倍频模式下每个边沿都计数,如果编码器信号有毛刺,毛刺会被当成有效边沿。解决:加强硬件滤波,软件上可以在采样时对 delta 做限幅,比如单周期增量超过理论最大值就丢弃。另外注意total_count用 int32_t,别用 int16_t,否则转几圈就溢出了。

5. 二次开发:把这份程序改成带速度环和位置环的控制底座

5.1 从测速到闭环:加一个定时器中断做固定周期采样

主循环HAL_Delay采样最大的问题是周期不准,串口打印、其他任务都会拖慢它。要做闭环,第一步是把采样搬到定时器中断里。我一般会再开一个基本定时器(比如 TIM6),配成 1ms 或 5ms 中断,在中断里读编码器增量、算速度、跑 PID。

// tim.c —— TIM6 基础定时器,1ms 中断 void MX_TIM6_Init(void) { TIM_MasterConfigTypeDef sMasterConfig = {0}; htim6.Instance = TIM6; htim6.Init.Prescaler = 7200 - 1; // 72MHz / 7200 = 10kHz htim6.Init.CounterMode = TIM_COUNTERMODE_UP; htim6.Init.Period = 10 - 1; // 10kHz / 10 = 1kHz,即 1ms HAL_TIM_Base_Init(&htim6); HAL_TIM_Base_Start_IT(&htim6); } // stm32f1xx_it.c —— 中断回调 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { int16_t delta = Encoder_GetDelta(); g_total_count += delta; g_speed_rpm = (delta / (ENCODER_PPR * 4.0f)) / 0.001f * 60.0f; // 在这里调用 PID 计算,输出 PWM } }

Prescaler = 7200 - 1把 72MHz 分频到 10kHz,Period = 10 - 1再分频到 1kHz,也就是 1ms 一次中断。中断里只做读数和 PID,串口打印这种耗时操作放主循环,用标志位传递数据。这样速度环的采样周期是硬的,PID 参数才好整定。

5.2 位置环与多圈计数:把 16 位 CNT 扩成 32 位位置

F103 的 TIM3 是 16 位,单圈计数有限,做多圈位置控制必须扩展。上面的g_total_count就是扩展后的 32 位位置。要注意的是,如果你在中断里读 CNT,而 CNT 在两次中断之间溢出了多次,就会丢圈。1ms 中断下,只要电机转速不超过「32768 计数/ms」,就不会丢,换算成 RPM 是极高的值,一般应用碰不到。但如果你的采样周期是 10ms,高速时就要小心。

位置环的改造思路是:把g_total_count作为反馈,目标位置作为输入,误差送 PID,输出限幅后给 PWM。这里有个细节,位置环和速度环的采样周期最好一致,或者位置环用速度环的输出做前馈,否则容易振荡。

5.3 移植到其他定时器或芯片的注意事项

这份程序基于 TIM3,换成 TIM2 或 TIM4 时,除了改htim3为对应句柄,还要注意引脚映射不同。TIM2 是 32 位定时器,Period 可以设成 0xFFFFFFFF,这样几乎不用考虑溢出,做位置计数更省心。如果换到 F4 或 G4 系列,HAL 库接口基本一致,但时钟树和定时器位宽不同,EncoderMode的宏定义可能略有差异,移植时对着参考手册确认一下。

另外,如果你用的是带霍尔传感器的 BLDC,编码器模式同样适用,但霍尔是三路信号,需要换成 Hall 接口模式或者用三个 GPIO 中断做换向,这跟正交编码器是两套逻辑,别混用。

5.4 一个我踩过的坑:中断里读 CNT 的原子性

最后说个血泪经验。我早期在 1ms 中断里读 CNT,同时主循环里也读 CNT 做显示,两边都调Encoder_GetDelta,结果last_cnt这个静态变量被两个上下文同时改,读数偶尔跳变。后来改成只在中断里更新编码器数据,主循环只读全局变量,问题消失。从那以后我每次把编码器读取放进中断,都强制走一遍「谁写谁读」的检查,确保last_cnt只有一个写入者。如果你也在做速度环,这个习惯能帮你省下不少调试时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进