简介:面向嵌入式初学者与电机控制方向学习者的STM32F103编码器程序实例,聚焦增量式编码器与定时器TIM的接口实现,可用于电机测速、位置检测与闭环控制的入门实践。压缩包共58个文件、约1.36MB,涵盖Keil工程与工作区文件、C源文件及启动文件、编译中间产物、HEX烧录文件,并附JLink调试配置、map映射与lst列表等,另含ili9320液晶驱动代码,工程结构完整、便于对照编译。该资源已有2030人学习下载。代码围绕GPIO输入配置、TIM编码器模式选择、预分频与计数模式设定、中断优先级设置,以及中断服务函数中读取计数值并换算转速与位移展开;同时涉及信号滤波抗干扰与PID调节的扩展思路,有助于理解定时器捕获脉冲、在中断上下文更新位置速度的完整流程,为高精度电机控制打下基础。
1. 从脉冲乱跳到稳定读数:STM32F103 编码器程序的落地边界
电机一上电,串口里的计数从 0 跳到 3000 又掉回 17,这种“玄学”读数多半不是编码器坏了,而是把 AB 相丢给了外部中断做软件解码。STM32F103 编码器程序的核心,是把增量式正交编码器的 A/B 相直接送进 TIM3 或 TIM4 的编码器接口,让硬件完成四倍频计数和方向识别,CPU 只负责按固定周期取差值。它适合手里有 F103 最小系统板、一台 5V 或 3.3V 增量编码器、需要做电机测速或位置闭环的场合;不适合把它当成绝对值编码器用,也不适合一上来就追求高线数高速响应。先把引脚、电平、计数溢出这三件事对齐,再谈 RPM 才稳。
2. 选定时器还是外部中断:TIM3 正交解码的硬件路径与引脚分配
编码器接到 F103 上,第一件事不是写代码,是决定用硬件编码器接口还是外部中断。这个选择直接决定高转速时会不会丢脉冲,也决定后面测速代码是简单取差值,还是要在中断里做软件相位判断。我一般会先看编码器铭牌上的 PPR 和输出类型,再决定把 A/B 相接到哪组定时器,而不是先把线焊上去再改代码。
2.1 外部中断方案的计数上限与适用边界
增量式正交编码器每转输出 A/B 两路方波,两路相差 90 度。常见的软件解码做法是把 A、B 都配成外部中断,在两边沿中断里读另一相电平,判断方向并做四倍频。这个方法在低速、低线数、只做简单验证时能用,但一旦线数和转速上去,中断频率会很快压满 CPU。
按 1000 线编码器算,四倍频后每转 4000 个计数。电机 3000 RPM 时是 50 转每秒,计数率达到 200k 次每秒。每次边沿都进一次中断,72MHz 主频下平均每 360 个时钟周期就要进一次中断,中断进出、现场保护、相位判断、全局变量更新都要占时间。如果编码器是 2500 线,计数率到 500k 次每秒,基本没有余量给串口、控制和别的任务。更麻烦的是抖动:编码器输出边沿不干净时,外部中断会把一个边沿拆成多个,软件四倍频跟着误判,现象就是正转时计数偶尔回退,或者静止时计数自己往上爬。
硬件编码器接口的价值就在这里。TIM3/TIM4 的编码器模式内部做正交解码,A/B 两相的上升沿和下降沿都参与计数,等于硬件完成四倍频,不需要 CPU 在每次边沿进出中断。方向位由定时器根据两相相位自动更新,计数器只负责加一或减一。对 1000 线、3000 RPM 的场合,CPU 只需要每 10ms 读一次计数器,压力从 200k 次中断降到 100 次采样。这个差别在电机闭环里很实际:中断省下来的时间可以留给 PWM、电流采样和通信。
所以选型结论很直接:外部中断方案适合先验证编码器好坏、或者线数很低且转速不高的场景;要落地到测速和位置闭环,优先用 TIM3/TIM4 的编码器接口。F103 的 TIM3/TIM4 都是 16 位计数器,做位置累计时要注意溢出,这个后面单独讲。硬件解码不是万能的,它只解决“计数”这件事,电平、上拉、共地、滤波没做好,硬件接口照样读出错数。
2.2 TIM3/TIM4 的引脚映射与输入电平
F103 上常用两组定时器做编码器接口,引脚分配如下:
| 定时器 | CH1 | CH2 | 计数位宽 | 适合场景 |
|---|---|---|---|---|
| TIM3 | PA6 | PA7 | 16 位 | 单轴测速、位置计数 |
| TIM4 | PB6 | PB7 | 16 位 | 第二轴或与 TIM3 分开使用 |
接线时编码器 A 相接 CH1,B 相接 CH2,GND 必须和 F103 共地。编码器 VCC 按铭牌给,常见有 5V 和 3.3V 两种。输出类型要看清楚:推挽输出可以直接进引脚;开漏输出必须加上拉电阻,否则引脚悬空,读出来就是随机跳变。F103 内部上拉阻值较大,短线临时验证可以开内部上拉,超过十几厘米的线我一般会在编码器侧加 4.7kΩ 到 10kΩ 外部上拉,边沿会干净很多。
电平边界要单独确认。5V 供电的编码器如果输出 5V 方波,进 F103 前要确认对应引脚是否 5V 容忍,不确定就加电平转换,不要拿“别人说能直接接”当依据。PA6/PA7 是否容忍 5V 要看具体型号的数据手册,不同封装和引脚定义有差异。更稳的做法是让编码器按 3.3V 供电,或者用光耦、电平转换芯片把信号降到 3.3V。Z 相归零信号如果不用,可以悬空并关掉对应中断;要用的话单独接一个 GPIO 中断,在 Z 相边沿把计数器清零,别把 Z 相和 A/B 相混在同一个定时器通道里。
线缆也有讲究。编码器线如果和电机动力线捆在一起走,电机 PWM 的 dv/dt 会耦合进 A/B 相,现象是电机一转计数就多。常见做法是 A/B 用双绞线,屏蔽层单端接地,编码器电源和电机电源分开走,至少不要平行长距离捆扎。这些是硬件层的“后悔药”,后面软件滤波只能补一部分,补不了地环路和电平不匹配。
2.3 上电前的接线检查和第一轮手动验证
接线完成后不要急着上电机,先做一轮静态检查。步骤不复杂,但能省掉后面大量排查时间。
- 断电状态下确认编码器供电电压、输出类型、A/B/Z 线色定义。铭牌不清楚就用万用表测一下输出是开漏还是推挽,别猜。
- A 相接 PA6,B 相接 PA7,GND 共地。开漏输出加上拉电阻,推挽输出可以不加,但长线仍建议留上拉位置。
- 上电后不转电机,串口读 TIM3 计数器。正常应稳定在 0 附近,允许 ±1 的末位抖动;如果持续往一个方向爬,先查上拉、共地和 A/B 是否悬空。
- 手动慢慢转一圈,看计数是否单调增加或减少。正转增加、反转减少,或者反过来,都正常;中间出现跳变、回退、丢数,先查线长、滤波和电源。
- 交换 A/B 两相,确认计数方向反转。这个动作用来验证相位关系,后面代码里也可以不改线,直接改 IC1/IC2 极性。
| 检查项 | 正常现象 | 异常处理 |
|---|---|---|
| 静止计数 | 0 附近 ±1 | 查上拉、共地、A/B 是否悬空 |
| 手动慢转 | 计数单调变化 | 查线序、滤波、编码器供电 |
| 方向 | 正转增、反转减 | 交换 A/B 或改极性配置 |
| 快速转 | 计数不丢段 | 查采样周期和计数器溢出 |
提示:静止时计数漂移,先查上拉和地,不要一上来把 IC 滤波器拉满。滤波过强会引入相位延迟,高速时反而容易丢数。
3. 把 AB 相接进 PA6/PA7:TIM3 编码器初始化代码与方向验证
硬件接好之后,代码要按固定顺序走:开时钟、配 GPIO、配定时器编码器模式、启动、清零。顺序错了常见现象是计数器不动,或者一启动就涨到一个奇怪的值。下面这套初始化按 HAL 库写,标准库的差别主要在TIM_EncoderInterfaceConfig和寄存器操作,思路一样。
3.1 GPIO 与 TIM3 编码器模式的初始化顺序
#include "stm32f1xx_hal.h" TIM_HandleTypeDef htim3; static void Encoder_TIM3_Init(void) { TIM_Encoder_InitTypeDef sConfig = {0}; GPIO_InitTypeDef GPIO_InitStruct = {0}; /* 1. 开时钟:TIM3 和 GPIOA 都要开 */ __HAL_RCC_TIM3_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); /* 2. PA6 -> TIM3_CH1,PA7 -> TIM3_CH2 编码器开漏输出时内部上拉只能临时用,长线建议外部上拉 */ GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /* 3. 定时器基础参数:编码器模式不需要预分频,ARR 给满 */ htim3.Instance = TIM3; htim3.Init.Prescaler = 0; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 0xFFFF; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; /* 4. 编码器接口:TI12 模式,A/B 双边沿都计数,等于四倍频 */ sConfig.EncoderMode = TIM_ENCODERMODE_TI12; 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; if (HAL_TIM_Encoder_Init(&htim3, &sConfig) != HAL_OK) { Error_Handler(); /* 工程里的错误处理函数 */ } /* 5. 启动编码器接口,并把计数器清到 0 */ HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL); __HAL_TIM_SET_COUNTER(&htim3, 0); } /* 读当前 16 位计数,按有符号解释,便于看正反转 */ int16_t Encoder_GetCount(void) { return (int16_t)__HAL_TIM_GET_COUNTER(&htim3); } /* 读定时器方向位:1 表示向上计数,-1 表示向下计数 */ int8_t Encoder_GetDir(void) { return __HAL_TIM_IS_TIM_COUNTING_DOWN(&htim3) ? -1 : 1; }这段代码的关键参数不多,但每个都影响读数:
| 参数 | 取值 | 说明 |
|---|---|---|
| Prescaler | 0 | 编码器模式不要分频,分频会直接丢计数 |
| Period | 0xFFFF | 16 位计数器满量程,溢出后回绕 |
| EncoderMode | TIM_ENCODERMODE_TI12 | A/B 两相都参与计数,四倍频 |
| IC1Filter / IC2Filter | 6 | 0 到 15,越大滤波越强,延迟也越大 |
| IC1Polarity / IC2Polarity | RISING | 两相都按上升沿捕获,配合 TI12 做双边沿 |
EncoderMode选TI12是四倍频的关键。如果只选TI1,只有 A 相参与计数,分辨率会降下来,方向判断也不如 TI12 稳。滤波器取 6 是线长和响应之间的折中;编码器线很短、信号干净时可以降到 2 到 4,电机干扰大、线长超过半米时可以往 8 到 10 调,但不要直接拉满 15,高速时相位延迟会让你看到计数滞后。
Encoder_GetCount返回int16_t,是把 0 到 65535 的计数器按有符号解释。正转超过 32767 会变成负数,反转低于 0 会回到 65535 附近。这个特性不是 bug,后面做差值累计时正好用它处理回绕。Encoder_GetDir读的是定时器 CR1 的 DIR 位,电机停止时这个位可能保持上一次方向,所以方向判断更适合用两次计数的差值符号,DIR 位只做辅助。
3.2 用差值读方向,别直接拿绝对计数做位置
直接读Encoder_GetCount()适合看瞬时计数,但做位置累计和方向判断时,我一般用两次读数的差值:
static int16_t last_count = 0; static int32_t total_count = 0; void Encoder_Update(void) { int16_t now = (int16_t)__HAL_TIM_GET_COUNTER(&htim3); int16_t delta = (int16_t)(now - last_count); /* 利用 int16_t 回绕处理溢出 */ last_count = now; total_count += delta; } int32_t Encoder_GetTotal(void) { return total_count; } int8_t Encoder_GetDeltaDir(void) { int16_t now = (int16_t)__HAL_TIM_GET_COUNTER(&htim3); int16_t delta = (int16_t)(now - last_count); if (delta > 0) return 1; if (delta < 0) return -1; return 0; /* 本次采样没动,方向保持上一次的值由上层决定 */ }delta用int16_t相减,是因为 16 位计数器从 65535 回 0 时,直接做无符号减法会得到很大的正数;转成有符号差值后,回绕会变成 -1 或 +1,累计不会跳变。total_count用int32_t,1000 线四倍频下约 4000 计数每转,int32_t能记一百万转左右,短时间够用;如果长时间运行,需要定期清零或换成 64 位累计。Encoder_GetDeltaDir里 delta 为 0 时返回 0,是为了让上层知道“这次没动”,不要用 0 去覆盖上一次方向。
3.3 串口打印第一轮验证
初始化完成后,先用串口把计数和方向打出来,确认硬件通路再往下做测速。主循环里每 200ms 打印一次,手动转编码器看变化:
#include <stdio.h> int main(void) { HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); /* 串口按你的工程配置,115200 8N1 即可 */ Encoder_TIM3_Init(); int16_t last_print = 0; while (1) { HAL_Delay(200); int16_t now = Encoder_GetCount(); int16_t delta = (int16_t)(now - last_print); last_print = now; printf("count=%d delta=%d dir=%d\r\n", now, delta, Encoder_GetDir()); } }打印结果里,手动正转时count应该持续增加,反转持续减少,delta的符号跟着方向变。如果count不动,先查HAL_TIM_Encoder_Start有没有调用、GPIO 时钟和定时器时钟有没有开、A/B 是不是接在 PA6/PA7。如果count跳得厉害,回到第 2 章查上拉和共地。如果方向反了但计数稳定,交换 A/B 或者把IC1Polarity和IC2Polarity其中一个改成FALLING,不要改线改到一半又改代码,最后自己都记不清。
注意:
printf重定向和串口初始化因工程而异,上面只保留验证逻辑。串口波特率太高、打印太频繁会占用 CPU,第一轮验证用 200ms 一次足够。
4. 从计数值到 RPM:10ms 采样、16 位溢出补偿与测速公式
计数稳定之后,下一步是把计数值变成转速。这里最容易出的问题不是公式写错,而是采样周期和计数器溢出没对齐:低速时 10ms 内没有计数,显示一直是 0;高速时一个采样周期跨过 65535,差值突然变成很大的负数。先把公式和溢出处理写清楚,再谈滤波。
4.1 M 法测速公式与四倍频系数
编码器铭牌上的 PPR 是每转脉冲数,通常指单相每转脉冲数。TIM3 用 TI12 模式后,A/B 的上升沿和下降沿都计数,实际每转计数是4 * PPR。如果在固定时间Δt内读到差值Δcount,转速换算成 RPM 是:
RPM = Δcount * 60 / (4 * PPR * Δt)其中Δt单位是秒。若采样周期用毫秒Tms,公式变成:
RPM = Δcount * 60000 / (4 * PPR * Tms)举个例子,PPR=1000,Tms=10,Δcount=1 时,RPM = 1 * 60000 / (4 * 1000 * 10) = 1.5。也就是说 10ms 采样下,1000 线编码器的测速分辨率是 1.5 RPM。这个分辨率在低速段很粗:电机转 1 RPM 时,10ms 内可能一个计数都没有,读数会在 0 和 1.5 之间跳。要改善低速分辨率,可以加长采样周期,或者低速段改用 T 法测脉冲周期;高速段再用 M 法。实际工程里常见做法是 M 法和 T 法结合,低速用 T 法保证分辨率,高速用 M 法保证实时性。
| 参数 | 含义 | 示例 |
|---|---|---|
| PPR | 编码器单相每转脉冲数 | 1000 |
| 四倍频系数 | TI12 模式下每转计数倍数 | 4 |
| Tms | 采样周期,单位 ms | 10 |
| Δcount | 一个采样周期内的计数差 | 由定时器读出 |
| RPM | 每分钟转数 | 按公式计算 |
4.2 10ms 定时采样与溢出补偿代码
用一个 100Hz 的定时器中断做采样,TIM1 配 10ms 更新中断即可。72MHz 下分频和重装值可以这样算:72MHz / (PSC+1) / (ARR+1) = 100Hz,例如 PSC=7199、ARR=99,得到 100Hz。采样中断里读 TIM3 计数,用int16_t差值处理回绕,再累计到int32_t:
#define ENCODER_PPR 1000.0f #define SPEED_SAMPLE_MS 10.0f volatile int32_t g_encoder_total = 0; volatile int16_t g_encoder_last = 0; volatile float g_speed_rpm = 0.0f; volatile int8_t g_speed_dir = 0; /* TIM1 10ms 更新中断回调,HAL 里重写这个弱函数 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { int16_t now = (int16_t)__HAL_TIM_GET_COUNTER(&htim3); int16_t delta = (int16_t)(now - g_encoder_last); g_encoder_last = now; /* 累计位置,int32_t 不够时再换 int64_t 或定期清零 */ g_encoder_total += delta; /* M 法测速:Δcount * 60000 / (4 * PPR * Tms) */ g_speed_rpm = (float)delta * 60000.0f / (4.0f * ENCODER_PPR * SPEED_SAMPLE_MS); /* 只有本次有计数才更新方向,静止时保持上一次方向 */ if (delta > 0) { g_speed_dir = 1; } else if (delta < 0) { g_speed_dir = -1; } } }这段代码里,delta是本次 10ms 的计数差,g_encoder_total是累计位置。g_speed_rpm的符号由 delta 决定,负值表示反转。g_speed_dir在 delta 为 0 时不更新,避免电机静止时方向在 0 和上一次方向之间乱跳。ENCODER_PPR要按你手里编码器的铭牌改,不要拿一个“大概是 1000”的值直接跑;PPR 差一倍,转速就差一倍。
g_encoder_total用int32_t在 1000 线、4000 计数每转下能记约 53 万转,短时够用。如果设备连续运行几天,累计会溢出,常见做法是上位机按圈数取模,或者在 Z 相归零时清零。不要用int16_t做总位置,16 位在高速下几秒就回绕一次。
4.3 低速抖动和高速丢数的处理边界
10ms 采样下,低速抖动是 M 法的固有缺陷,不是代码写错了。1000 线编码器 1.5 RPM 的分辨率意味着 1 RPM 附近读数会在 0 和 1.5 之间跳。要压住显示抖动,可以加滑动平均,但平均会带来滞后,电机闭环里要谨慎。简单的 5 点平均可以这样写:
#define SPEED_FILTER_N 5 static float speed_buf[SPEED_FILTER_N]; static uint8_t speed_idx = 0; float Speed_Filter(float new_speed) { speed_buf[speed_idx] = new_speed; speed_idx = (speed_idx + 1) % SPEED_FILTER_N; float sum = 0.0f; for (uint8_t i = 0; i < SPEED_FILTER_N; i++) { sum += speed_buf[i]; } return sum / SPEED_FILTER_N; }滑动平均适合显示和慢速监控,不适合高动态闭环。高速段如果发现计数丢段,先看采样周期是不是太长:3000 RPM、1000 线时 10ms 内约 2000 个计数,离 32767 还很远,一般不会因 int16_t 差值溢出;但如果你把采样周期放到 100ms,高速时差值可能超过 32767,int16_t差值会翻符号,转速突然变负。这种“高速变负”不是编码器问题,是采样周期和数据类型没对齐。采样周期越短,实时性越好,但低速分辨率越差;采样周期越长,低速分辨率越好,但高速容易溢出。10ms 是 1000 线、几千 RPM 场合的常用折中,实际项目按最高转速和最低可测转速两头算一遍再定。
5. 避坑排查:编码器读数跳变、丢脉冲、溢出翻车的 5 个现场
编码器程序调不通,很多时候不是定时器配置错,而是硬件边界和数据类型没对齐。下面这五类现场是按“先电源地、再电平、再接线、再滤波、最后软件溢出”的顺序整理的,照着查能少走弯路。
5.1 先看现象再拆原因:五类故障的定位顺序
拿到一个读数异常的现象,我一般先分三类:静止时是否稳定、手动转是否单调、电机转是否丢数。静止不稳定,优先查上拉、共地、电源;手动转不单调,查 A/B 线序、滤波、编码器本身;电机一转就出错,查动力线干扰、电平匹配、采样周期。不要一上来改代码,代码改多了最后不知道是哪一版好的。
| 现象 | 第一怀疑点 | 快速验证 |
|---|---|---|
| 静止计数漂移 | 上拉、共地、A/B 悬空 | 断电测通断,上电测 A/B 静态电平 |
| 手动转跳变 | 线序、滤波过强、供电不稳 | 慢转看 delta 是否连续 |
| 电机一转就多计数 | 动力线干扰、屏蔽、地环路 | 电机不转只通 PWM 看计数 |
| 计数突然变负 | 16 位溢出、采样周期太长 | 缩短采样周期看是否复现 |
| 方向偶尔反 | A/B 接反、极性配置、抖动 | 交换 A/B 对比 |
5.2 五条现场记录:从现象到解决
现象:静止时计数在 0 和 1 之间跳,电机一转就多出几百。原因:编码器是开漏输出,只靠 F103 内部上拉,线长后边沿变缓,干扰被当成脉冲。解决:在编码器侧加 4.7kΩ 到 10kΩ 外部上拉,A/B 用双绞线,屏蔽层单端接地,先别动滤波器。
现象:手动慢转计数单调,但快速转时偶尔丢一段,换向时尤其明显。原因:IC 滤波器设得太大,相位延迟把高速边沿吃掉了;也可能是编码器供电不稳。解决:把 IC1Filter/IC2Filter 从 10 降到 4 到 6,示波器看 A/B 边沿和电源纹波,先保证边沿干净再调滤波。
现象:正转时计数偶尔回退,delta 一正一负。原因:A/B 相序接反或者其中一相接触不良,硬件解码在相位临界点判断方向出错。解决:交换 A/B 看方向是否稳定;用万用表测线序和插头压接,确认没有虚接。线序问题不要靠软件取绝对值掩盖。
现象:电机运行几秒后计数突然从 60000 多跳到负数,位置累计跟着翻车。原因:TIM3 是 16 位计数器,直接拿无符号绝对计数做位置,回绕时没处理。解决:用
int16_t差值累计,像第 4 章那样每次读now - last再转有符号;总位置用int32_t,长时间运行加 Z 相归零或定期清零。现象:电机一启动,单片机复位或者串口乱码,编码器计数也跟着乱。原因:5V 编码器信号直接进 3.3V 引脚,或者电机动力线和编码器线捆在一起,地弹把 IO 打穿。解决:确认引脚 5V 容忍再决定是否直连,不确定就加电平转换;编码器电源和电机电源分开,必要时加光耦隔离,电机地和控制地单点连接。
这五条里,前三条
本文还有配套的精品资源,点击获取