STM32平衡车开发日记 — 编码器T法测速(附3个隐蔽Bug排查实录) —基于铁头山羊教程
一、前言
做平衡车离不开电机测速,常见的编码器测速方法有两种:
| 方法 | 原理 | 适用场景 |
|---|---|---|
| M法 | 固定时间间隔内统计脉冲个数 | 高速 |
| T法 | 测量相邻脉冲的时间间隔 | 低速 |
平衡车在自稳状态下电机转速通常很低,M法一个采样周期可能只来一两个脉冲,分辨率极差,在上篇文章也已经证实。T法测速在低速下精度更高,所以本文选择 T 法。
硬件平台:STM32F103C8T6,编码器为增量式霍尔编码器(22线,1:20.4减速比)。
二、T法测速原理
T 法的核心思想是:测量编码器相邻两个脉冲之间的时间间隔。
一个脉冲对应电机轴转过多少角度是可以算出来的,所以:
角速度(度/秒) = 方向(±1) / 脉冲间隔(秒) / 编码器线数 / 减速比 × 360°
具体实现上,用 STM32 的 EXTI 外部中断捕获编码器 A 相的上升沿和下降沿,在中断里:
- 记录当前时刻(微秒)
- 和上一次中断的时刻做差,得到
dt - 同时读取 B 相电平判断旋转方向
再用 1 / dt 换算出角速度。

三、硬件连接
左电机编码器:
| 编码器引脚 | STM32引脚 | 说明 |
|---|---|---|
| A相 | PB14 | 编码器A相,接EXTI_Line14 |
| B相 | PB15 | 编码器B相,用于判断方向 |
右电机编码器:
| 编码器引脚 | STM32引脚 | 说明 |
|---|---|---|
| A相 | PB3 | 编码器A相,接EXTI_Line3 |
| B相 | PB4 | 编码器B相,用于判断方向 |
⚠️ PB3/PB4 默认是 JTAG 调试接口(JTDO/JNTRST),使用前必须关闭 JTAG 并开启重映射,只保留 SWD 下载口。
四、代码实现
4.1 编码器初始化 — app_encoder.c
#include "app_encoder.h"
#include "delay.h"
// 编码器计数 — 必须用 int64_t,int16_t 跑久了会溢出
static volatile int64_t Encoder_L = 0; // 左编码器
static volatile int64_t Encoder_R = 0; // 右编码器
// 旋转方向:1为正转,-1为反转
static volatile int8_t Encoder_L_Dir = 1;
static volatile int8_t Encoder_R_Dir = 1;
// 时间间隔(微秒)— GetUs() 返回 uint64_t,必须用 64 位存储!
static volatile uint64_t t0_l = 0; // 上次中断时刻
static volatile uint64_t t1_l = 0; // 当前中断时刻
static volatile uint64_t dt_l = 0; // 脉冲间隔
static volatile uint64_t t0_r = 0;
static volatile uint64_t t1_r = 0;
static volatile uint64_t dt_r = 0;
// 左编码器初始化:PB14(A相) + PB15(B相),EXTI_Line14 双沿触发
static void Encoder_L_Init(void)
{
// 1. GPIO 初始化 — 内部上拉输入
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure = {0};
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_14 | GPIO_Pin_15;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOB, &GPIO_InitStructure);
// 2. 映射 EXTI 线路 — A相接 PB14 → EXTI_Line14
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource14);
// 3. EXTI 配置 — 双沿触发
EXTI_InitTypeDef EXTI_InitStructure = {0};
EXTI_InitStructure.EXTI_Line = EXTI_Line14;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising_Falling;
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);
// 4. NVIC 中断配置 — EXTI15_10 共享中断通道
NVIC_InitTypeDef NVIC_InitStructure = {0};
NVIC_InitStructure.NVIC_IRQChannel = EXTI15_10_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
// 右编码器初始化:PB3(A相) + PB4(B相),EXTI_Line3 双沿触发
static void Encoder_R_Init(void)
{
// 关闭 JTAG,保留 SWD(PB3/PB4 默认被 JTAG 占用)
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure = {0};
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3 | GPIO_Pin_4;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOB, &GPIO_InitStructure);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource3);
EXTI_InitTypeDef EXTI_InitStructure = {0};
EXTI_InitStructure.EXTI_Line = EXTI_Line3;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising_Falling;
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);
NVIC_InitTypeDef NVIC_InitStructure = {0};
NVIC_InitStructure.NVIC_IRQChannel = EXTI3_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
// 统一初始化入口
void App_Encoder_Init(void)
{
Encoder_L_Init();
Encoder_R_Init();
}
4.2 中断服务函数 — 核心测速逻辑
// 左编码器中断 (EXTI_Line14 → EXTI15_10_IRQHandler)
void EXTI15_10_IRQHandler(void)
{
if (EXTI_GetITStatus(EXTI_Line14) == SET)
{
EXTI_ClearITPendingBit(EXTI_Line14);
// 记录脉冲间隔时间
t1_l = GetUs();//记录当前时间
dt_l = t1_l - t0_l;//记录时间间隔
t0_l = t1_l;//更新上次中断时间
// 读取 A/B 相电平,判断方向并计数
uint8_t a = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_14); // A相
uint8_t b = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_15); // B相
if (a == Bit_SET) // A相上升沿
{
if (b == Bit_RESET) { Encoder_L--; Encoder_L_Dir = -1; }
else { Encoder_L++; Encoder_L_Dir = 1; }
}
else // A相下降沿
{
if (b == Bit_RESET) { Encoder_L++; Encoder_L_Dir = 1; }
else { Encoder_L--; Encoder_L_Dir = -1; }
}
}
}
// 右编码器中断 (EXTI_Line3 → EXTI3_IRQHandler)
void EXTI3_IRQHandler(void)
{
EXTI_ClearITPendingBit(EXTI_Line3);
t1_r = GetUs();//记录当前时间
dt_r = t1_r - t0_r;//记录时间间隔
t0_r = t1_r;//更新上次中断时间
uint8_t a = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_3); // A相
uint8_t b = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_4); // B相
if (a == Bit_SET)
{
if (b == Bit_RESET) { Encoder_R++; Encoder_R_Dir = 1; }
else { Encoder_R--; Encoder_R_Dir = -1; }
}
else
{
if (b == Bit_RESET) { Encoder_R--; Encoder_R_Dir = -1; }
else { Encoder_R++; Encoder_R_Dir = 1; }
}
}
💡 左右电机在平衡车两侧对称安装,正转方向恰好相反,所以左编码器的方向逻辑和右编码器是镜像的。
4.3 速度计算
// 获取左轮角速度,单位:度/秒
float App_Encoder_GetSpeed_L(void)
{
if (dt_l == 0) return 0.0f; // 防止首次调用时除零
return (float)Encoder_L_Dir / ((float)dt_l / 1000000.0f)
/ 22.0f / 20.4f * 360.0f;
}
// 获取右轮角速度
float App_Encoder_GetSpeed_R(void)
{
if (dt_r == 0) return 0.0f;
return (float)Encoder_R_Dir / ((float)dt_r / 1000000.0f)
/ 22.0f / 20.4f * 360.0f;
}
公式拆解:
Encoder_L_Dir — 方向 ±1
dt_l / 1000000.0f — 脉冲间隔:微秒 → 秒
1 / (秒) — 脉冲频率 Hz(个/秒)
/ 22.0f — 除以编码器线数(22线/转)
/ 20.4f — 除以减速比
× 360.0f — 转换为度/秒
4.4 VOFA 调试输出
// encoder_test.c — T法测速测试
void Encoder_T_Method_Test(void)
{
App_USART2_Init(); // 串口2初始化,波特率 921600
App_Encoder_Init(); // 编码器初始化
while (1)
{
Delay(1);
float speed_L = App_Encoder_GetSpeed_L();
float speed_R = App_Encoder_GetSpeed_R();
My_USART_Printf(USART2, "%f,%f\n", speed_L, speed_R);
}
}
VOFA+ 配置:串口波特率 921600,协议选择 JustFloat 或 FireWater 均可。
五、调试踩坑记录(重要!)
代码写完下载到板子上,VOFA 显示的转速一直是 0。排查后找到 3 个 Bug,记录如下。
🐛 Bug 1(致命):整数除法截断 — 导致速度恒为 0
现象:VOFA 显示的 speed_L 和 speed_R 始终是 0.000000。
最初的错误代码:
return Encoder_L_Dir / dt_l * 1e-6 * 360.0f / 22.0f / 20.4f;
问题分析:
C 语言中 / 和 * 优先级相同,从左到右结合。上面的代码实际等价于:
((Encoder_L_Dir / dt_l) * 1e-6) * 360.0f / 22.0f / 20.4f
Encoder_L_Dir 是 int8_t(值为 ±1),dt_l 是 uint64_t(通常几万到几百万):
±1 / 1000000 = 0 ← 整数除法!商为 0
0 * 1e-6 = 0.0 ← 结果恒为 0
修复:将分母用括号包起来,并在括号内先做浮点运算,保证整个运算链在浮点域中进行:
return (float)Encoder_L_Dir / ((float)dt_l / 1000000.0f) / 22.0f / 20.4f * 360.0f;
核心原则:参与除法之前,至少让其中一个操作数是浮点类型。
🐛 Bug 2(隐蔽):时间变量类型溢出 — 65ms 后数据崩溃
现象:Bug 1 修复后,前几秒数据正常,随后速度值乱跳。
错误代码:
static volatile uint16_t t0_l = 0; // 最大 65535
static volatile uint16_t t1_l = 0;
static volatile uint16_t dt_l = 0;
问题分析:
GetUs() 返回 uint64_t,表示系统启动以来的微秒数。赋值给 uint16_t 时高位被截断:
| 系统运行时间 | GetUs() 真实值 | uint16_t 截断值 |
|---|---|---|
| 0ms | 0 | 0 |
| 65ms | 65535 | 65535 ✅ |
| 66ms | 65536 | 0 ❌ 溢出! |
系统运行 65 毫秒后时间变量就溢出了。dt = t1 - t0 的计算结果完全不可信。
修复:
static volatile uint64_t t0_l = 0; // 与 GetUs() 返回类型一致
static volatile uint64_t t1_l = 0;
static volatile uint64_t dt_l = 0;
教训:变量类型要和赋给它的函数返回值类型匹配,而不是"觉得够用就行"。
🐛 Bug 3(隐患):编码器计数值溢出
错误代码:
static volatile int16_t Encoder_L = 0; // 最大 ±32767
问题分析:
电机转一圈编码器脉冲数 = 22线 × 4倍频 = 88 个脉冲。转 373 圈就溢出了。平衡车长时间运行后计数必然会溢出,导致位置计算跳变。
修复:
static volatile int64_t Encoder_L = 0; // 没必要省这点内存
六、完整调试时间线
| 阶段 | 现象 | 原因 |
|---|---|---|
| 第一版 | 转速小数点后 3 位,非常小 | 整数除法导致数量级错误 |
| 第二版 | 转速恒为 0 | 修改后仍然是整数除法 |
| 第三版 | VOFA 正常显示 | 三个 Bug 全部修复 ✅ |
七、经验总结
-
C 语言整数除法的坑:
int / int结果还是int,很多初学者在这栽跟头。写完公式回头检查一下每个/的左右操作数类型。 -
变量类型要匹配函数返回值:
uint64_t的函数赋给uint16_t的变量,编译器不会报错(只会有隐式截断),但运行时必然出问题。 -
用 volatile 修饰中断共享变量:ISR 和主循环都可能访问的变量必须加
volatile,否则编译器优化可能导致主循环读到过期值。 -
VOFA 是个好工具:串口打印 + VOFA 波形显示,比盲调高效太多。建议每个模块开发时都单独写个 Test 函数用 VOFA 验证,确认没问题再集成到主程序。
八、完整工程代码
本文涉及的完整代码文件:
app_encoder.c/app_encoder.h— T法测速核心实现encoder_test.c/encoder_test.h— VOFA 调试测试delay.c/delay.h— 微秒级时间戳GetUs()
配套硬件:STM32F103C8T6 + 带霍尔编码器的直流减速电机。
更多推荐


所有评论(0)