一、前言

做平衡车离不开电机测速,常见的编码器测速方法有两种:

方法原理适用场景
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 截断值
0ms00
65ms6553565535 ✅
66ms655360 ❌ 溢出!

系统运行 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 全部修复 ✅

七、经验总结

  1. C 语言整数除法的坑:int / int 结果还是 int,很多初学者在这栽跟头。写完公式回头检查一下每个 / 的左右操作数类型。

  2. 变量类型要匹配函数返回值:uint64_t 的函数赋给 uint16_t 的变量,编译器不会报错(只会有隐式截断),但运行时必然出问题。

  3. 用 volatile 修饰中断共享变量:ISR 和主循环都可能访问的变量必须加 volatile,否则编译器优化可能导致主循环读到过期值。

  4. VOFA 是个好工具:串口打印 + VOFA 波形显示,比盲调高效太多。建议每个模块开发时都单独写个 Test 函数用 VOFA 验证,确认没问题再集成到主程序。


八、完整工程代码

本文涉及的完整代码文件:

  • app_encoder.c / app_encoder.h — T法测速核心实现
  • encoder_test.c / encoder_test.h — VOFA 调试测试
  • delay.c / delay.h — 微秒级时间戳 GetUs()

配套硬件:STM32F103C8T6 + 带霍尔编码器的直流减速电机。

Logo

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

更多推荐