STM32编码器闭环实现小车精确行驶距离:从PWM开环到PID位置控制
2026/9/9 1:33:41 网站建设 项目流程

简介:基于 STM32 与 PID 算法的电机行驶距离控制项目资源,面向嵌入式爱好者、电子竞赛参赛者及需要实现精准定位的开发者。内容围绕编码器反馈与 PID 闭环控制展开,涵盖比例、积分、微分环节的原理、参数整定思路、STM32 与编码器接口方式以及 PWM 电机驱动细节,适合用于课程设计或毕设参考。压缩包共 236 个文件,以 121 个 h 头文件和 107 个 c 源文件为主,包含 STM32F4 HAL 库底层驱动、PID 控制代码、Keil 工程配置、启动文件和可直接烧录的 hex 文件,整体仅 1.65MB,结构清晰便于查阅。目前已有 1020 人学习,内容还涉及调试中的阶跃响应测试与安全保护设计,既能帮助深入理解 PID 运动控制原理,也能快速移植到实际项目中验证指定距离行驶效果,性价比高。 去年我做了一个基于STM32的小车底盘,需求听起来特别简单:让车按设定距离直线行驶,误差控制在2厘米以内。结果第一版被现实狠狠教育了——固定PWM跑固定时间,十次里有八次超出误差范围,最夸张的一次跑了1.5米,实际停了1.72米,差了整整22厘米。

后来整套逻辑全部推翻,换成编码器闭环,才算把精度稳住。这篇就把整个思路、硬件选型、代码骨架和踩坑过程完整梳理一遍,给要做“STM32控制行驶指定距离”的朋友当个参考。如果你只是想让车子大致走个距离,不在乎那几厘米误差,那这篇文章对你也有用,至少能知道误差到底出在哪。

1. 为什么“给个固定PWM跑一会”根本行不通

很多人第一版方案都是这样:定时器输出一个固定占空比的PWM,让电机转,然后用delay或者定时中断,到时间就停。理论上只要转速稳定,时间乘以速度就是距离,但实际跑起来你会发现,这个方案从原理上就站不住脚。

1.1 开环控制的失败根源

开环控制下,你控制的是PWM占空比,而不是车轮转速,更不是行驶距离。占空比到转速之间隔着好几层不确定性:

  • 电池电压会随着放电逐渐下降,同样的PWM占空比,满电时转速高,亏电时转速低;
  • 电机负载变化,比如地面摩擦阻力、车身倾斜角度、轮胎气压,都会导致转速波动;
  • 启动瞬间存在死区电压,PWM占空比低于某个值时电机根本不动,高于某个值突然启动;
  • 减速箱齿轮间隙和轮胎打滑更是完全不可控的因素。

这里面最难处理的是电压波动。我用同一个PWM占空比做测试,4.2V锂电池满电时电机转速约320转/分,电压掉到3.7V时,同样占空比转速只有260转/分,差了接近20%。跑1米静止不动的距离,开环就会有大约20厘米的误差。

还有更隐蔽的问题:电机启动是一个加速过程,停止也有一个惯性滑行过程,这两个过程的时间占整个运动周期越短,误差占比就越大。跑1米时可能影响不大,跑20厘米时就非常明显。

1.2 闭环控制的核心逻辑

要真正控制行驶距离,核心思想就一句话:把“车轮实际转了多少圈”测出来,和目标值对比,有偏差就加大PWM,接近了就减小PWM,到了就停止。

这里有个关键传感器概念——增量式编码器。它装在电机尾部,电机每转一圈会输出固定数量的脉冲,通过脉冲计数可以精确知道电机转了多少圈。配合减速箱的减速比和轮子直径,就能换算出车实际走了多少毫米。

闭环的另外一个好处是自动补偿。地面阻力变大?编码器发现转速慢了,控制器自动加大PWM。电压下降?编码器发现距离还没到,控制器自动延长运行时间。这些在开环方案里根本做不到。

2. 硬件层的地基:编码器电机选型与接线要点

做距离控制,硬件选型直接决定系统精度上限。脉冲数太少,控制再精细也白搭。

2.1 编码器电机的基础参数

我用的方案是常见的带霍尔编码器的直流减速电机,型号是JGB37-520,几个关键参数值得回头看:

  • 额定电压6V,空载转速约320转/分;
  • 减速比1:30,输出轴转速约10.7转/分;
  • 霍尔编码器分辨率11线/转,因为是双路输出且带倍频,STM32用定时器编码器模式可以做到4倍频,也就是电机轴每转44个脉冲;
  • 轮子直径65mm,周长约204.2mm。

换算下来,每个编码器脉冲对应的行驶距离约0.116mm(204.2mm ÷ 30 ÷ 44 ≈ 0.155mm,考虑实际减速比)。这个分辨率下,2厘米误差对应大约129个脉冲,控制裕量很充足。

2.2 电机驱动接线与供电

驱动板我用的是TB6612FNG,比L298N轻巧且压降小,内置稳压可以给STM32供电但我不推荐共用。接线参考如下:

  • STM32的PB6、PB7接到驱动板AIN1、AIN2(A电机方向控制);
  • STM32的PA8输出PWM到驱动板PWMA;
  • STM32的PB8、PB9(TIM4的CH3、CH4)接到编码器A相、B相;
  • 6V电源接VM,逻辑电压接3.3V(注意TB6612逻辑电平支持3.3V);
  • 电机线A01、A02接电机。

供电是最大的坑。驱动板VM和STM32供电必须分开,共地即可。STM32用稳压的3.3V,电机单独用一节锂电池。我曾经图省事用同一块电源,电机启动瞬间电压跌落直接导致STM32复位,车直接原地重启。

2.3 定时器编码器模式的初始化配置

编码器模式是STM32定时器的特殊功能,不需要外部中断,硬件自动对A、B两相的上升沿和下降沿进行计数,等于单片机零开销获取脉冲数。这是STM32做这个项目最顺手的地方。

初始化配置核心代码如下:

void Encoder_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 0xFFFF; TIM_TimeBaseStructure.TIM_Prescaler = 0; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM4, &TIM_TimeBaseStructure); TIM_ICInitStructure.TIM_Channel = TIM_Channel_3; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x0F; TIM_ICInit(TIM4, &TIM_ICInitStructure); TIM_ICInitStructure.TIM_Channel = TIM_Channel_4; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_IndirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x0F; TIM_ICInit(TIM4, &TIM_ICInitStructure); TIM_EncoderInterfaceConfig(TIM4, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_SetCounter(TIM4, 0); TIM_Cmd(TIM4, ENABLE); }

一个必须注意的细节:TIM_Period设为0xFFFF,计数器满65535会溢出。如果电机转速快、运行时间长,必须在溢出中断里做处理,否则距离会突然跳变。我实际用下来,44个脉冲/轴转,电机轴最高约10.7转/分,一秒最多470个脉冲,65535大约能撑139秒,短距离运行问题不大。要是做成持续运行的小车,就需要维护一个32位累计变量。

3. 从编码器脉冲到距离的换算逻辑

编码器返回的是脉冲数值,距离控制需要的是毫米,中间必须有一个可靠的换算层。

3.1 脉冲到毫米的换算公式

整个换算链路如下来:

  • 电机齿轮箱输出轴每转一圈,车轮转一圈(直驱轮子);
  • 电机轴每转一圈,编码器输出44个脉冲(11线4倍频);
  • 电机轴转30圈,输出轴转1圈,对应脉冲数30 × 44 = 1320个;
  • 轮子直径65mm,周长65 × π ≈ 204.2mm;
  • 每脉冲行程 = 204.2 / 1320 ≈ 0.155mm/脉冲。

所以如果你要行驶500mm,目标脉冲数 = 500 / 0.155 ≈ 3226个。这个目标值我会在代码里直接用浮点乘法得到,避免手工取整损失精度。

但实际工程的坑在于:这个理论周长建立在轮胎标准气压、无负载形变的前提下。真实运行时,轮胎压在地面上会有形变,实际滚动半径比几何半径小,轮径越软误差越大。处理方式后面说,这里先记住一个概念——理论值只能当初始值,最终要用实测标定校准。

3.2 方向判断与脉冲累计

编码器的两路输出相位差90度,正转时A超前B,反转时B超前A。TIM_EncoderMode_TI12模式下,计数器可以自动完成加减计数,正转计数增加,反转计数减少,方向判断完全交给硬件。

不过在距离控制的场景下,我会限制小车只前进或者只后退,单一方向运行让逻辑更简单。如果需要回程,只需要在接近目标时把PWM反向,计数器从当前位置反向递减,到达零即为回程终点。

这里强烈建议用32位变量包装累计值:

volatile int32_t g_encoder_count = 0; void TIM4_IRQHandler(void) { if (TIM_GetITStatus(TIM4, TIM_IT_Update) != RESET) { // 根据计数器的变化方向更新累计值 uint16_t current = TIM4->CNT; static uint16_t last; int16_t delta = (int16_t)(current - last); g_encoder_count += delta; last = current; TIM_ClearITPendingBit(TIM4, TIM_IT_Update); } }

这个中断函数写得好不好,直接决定整个系统的可靠性。

3.3 距离换算中的标定陷阱

理论换算值0.155mm/脉冲一开始看着很精确,实测时发现实际跑500mm,编码器只数到约3070个脉冲,反推真实每个脉冲对应0.163mm。差别来自三个地方:

  • 轮胎形变,软橡胶轮胎受压后滚动半径缩小;
  • 电机减速比实际值和标称值有偏差(JGB37-520标称1:30,实测约1:29.7);
  • 编码器安装公差导致霍尔元件和磁环间距偏大,偶尔丢脉冲。

我的解决办法是跑一次已知距离标定。贴一张卷尺在地上,让车满载正常路面行驶1米,记录实际脉冲数。标定公式:

实际每脉冲距离 = 1000mm / 实测脉冲数

这个值替代理论值写入代码,精度立刻从5%提升到1%以内。不同路面、不同胎压的系统,标定结果完全不同,换轮胎后也必须重新标定。

4. 位置闭环的控制核心:PID整定与限幅

硬件和反馈链路就绪后,剩下就是让控制器聪明起来。我用的是位置式PID中最简单的P(比例)控制加一点D(微分),效果已经很理想,I项在这种一次到位的小车里反而容易惹麻烦。

4.1 比例控制为什么能用

控制逻辑不复杂:

  • 当前脉冲数cur和距离目标脉冲数target作差,得偏差err = target - cur;
  • 让PWM输出 = Kp × err;
  • 偏差越大,电机转速越快;偏差越小,转速越慢;
  • 偏差为零,电机停转。

生活化理解:你开车快到目的地时,会提前松油门滑行而不是一直全油门等到线前急刹。比例控制模拟的就是这个动作。

Kp取值有个直观范围。如果设定目标距离500mm,对应脉冲3226个。启动时偏差最大3226,PWM调速范围0到1000(对应约10kHz、0~100%占空比),那么Kp = 1000 / 3226 ≈ 0.31。这样起步就是全速,随着接近目标,PWM逐渐下降,到偏差100个脉冲时PWM降低到100左右,车子明显减速。

4.2 位置式PID代码骨架

下面是我实际跑通的精简版本,基于标准库函数,逻辑清晰可移植:

#define TARGET_DISTANCE_MM 500.0f #define PULSE_PER_MM 6.13f // 标定值,0.163mm/脉冲的倒数 #define KP 0.25f #define KD 0.01f #define PWM_MAX 1000 float target_pulse = TARGET_DISTANCE_MM * PULSE_PER_MM; float last_err = 0; void Move_Distance(void) { while (1) { float cur = (float)g_encoder_count; float err = target_pulse - cur; if (err <= 0.2f) { set_motor(0); break; } float diff = err - last_err; int pwm = (int)(KP * err + KD * diff); if (pwm > PWM_MAX) pwm = PWM_MAX; if (pwm < 0) pwm = 0; set_motor(pwm); last_err = err; delay_ms(5); } }

几点说明:

  • err <= 0.2f作为停机阈值。PWM死区导致车子在低速时可能停不下来,必须设置一个极小的静差区间。0.2个脉冲几乎不影响精度,但能避免电机在静止前反复抖动;
  • 微分项对偏差变化速率起作用,防止车子冲过头。实测KD设0.01时,接近目标时PWM下降更平滑,停车位置更稳定;
  • delay_ms(5)控制调节周期。周期太长反应迟钝,太短微分非常敏感容易抖动。5ms是我试过比较稳定的值。

4.3 参数整定的实操经验

整定Kp有个快速方法论:

  1. 先从很小的Kp开始,比如0.05,观察车子是否到达目标附近,如果停下时偏差很大,就需要加大Kp;
  2. 逐步增加Kp,直到车子在接近目标时出现明显的来回震荡,此时记下临界值;
  3. 最终Kp取临界值的60% ~ 70%,留出稳定裕量;
  4. 再加上微小的KD值来抑制超调。

我实际整定过程中,KP从0.1增加到0.3时,500mm测试出现明显过冲,最终定格在0.25。这个值跑20cm到150cm都有不错表现,但如果你要覆盖很宽的距离范围,建议做分段Kp或者加积分项处理。

5. 完整代码骨架与运行注意

上面的Move_Distance函数是整个控制的中枢,但实际工程还包含PWM初始化、电机方向控制、编码器读取等外围代码。

5.1 电机方向控制与PWM初始化

方向控制用GPIO输出高低电平即可:

void set_motor_dir(uint8_t dir) { if (dir == DIR_FORWARD) { GPIO_SetBits(GPIOB, GPIO_Pin_6); // AIN1 GPIO_ResetBits(GPIOB, GPIO_Pin_7); // AIN2 } else { GPIO_ResetBits(GPIOB, GPIO_Pin_6); GPIO_SetBits(GPIOB, GPIO_Pin_7); } } void set_motor(int pwm) { if (pwm > 0) { set_motor_dir(DIR_FORWARD); TIM_SetCompare1(TIM1, pwm); // PA8的PWM输出 } else { set_motor_dir(DIR_BACKWARD); TIM_SetCompare1(TIM1, -pwm); } }

PWM频率我用的是10kHz。低于1kHz能听到明显的电机线圈啸叫,高于20kHz时驱动板开关损耗上升。10kHz是平衡点。

5.2 主循环中的控制节奏

主程序框架大致这样:

int main(void) { Motor_Init(); Encoder_Init(); delay_init(); while (1) { if (KEY_Pressed) { g_encoder_count = 0; // 清零脉冲 TIM_SetCounter(TIM4, 0); Move_Distance(); // 走指定距离 } } }

一个关键心得是:每次运行前必须同时清零定时器计数器和软件累计值,两层清零缺一不可。只清TIM4->CNT而忘了清g_encoder_count,会在中断里叠加一个巨大突变,直接导致控制异常。

另一个容易出问题的点是中断优先级。编码器计数中断必须设为最高优先级,因为它是距离反馈的唯一来源。PWM或者定时中断如果抢占了它,脉冲就会丢失,距离数据不准,闭环就失效了。

6. 实测中的意外情况与调参经验

纸上谈兵结束,说说真实调试中我踩过的几个坑,每一个都花了不少时间。

6.1 机械间隙带来的停车误差

第一次完成闭环测试时,设定500mm,实测493mm,误差7mm,我觉得挺满意。但连续跑十次,误差波动在486mm到498mm之间,规律很不稳定。查了一晚上,发现根源在减速箱齿轮间隙。

齿轮啮合存在空行程,电机正转时,扭矩通过齿轮逐级传递,每一级之间都有一点点可转动但没咬合的空隙。当PID把PWM逐渐减小到很低时,最后几个齿的咬合状态不稳定,电机轴微小的来回晃动被编码器视为真实位移,导致计数跳动。

解决办法:一是增加停机阈值,把err <= 0.2f改成err <= 1.0f,避免在微小偏差区间反复切换电机方向;二是在停车后加一个50ms的等待,让电机彻底静止,再读取一次编码器值做最终记录和校正。

修改后十次测试,极差压缩到4mm以内。

6.2 电池电压下降导致的间接影响

前面说过电压对开环的直接影响很大,闭环后系统能自动补偿,但电压下降改变了另一个东西:PWM饱和区间。TB6612的VM从4.2V降到3.7V时,同样PWM占空比下的电机转速明显下降。启动时err很大,PID计算出1000的PWM,看似是最大占空比,但实际电压降低导致最大可达到的转速也降低。

这造成的结果是:低压时车在大部分路程上处于“全速但不够快”的状态,接近目标时减速特性也不一样,最终停车位置和高压时会有约1%的偏差。

解决方案是在代码中根据电源电压做前馈补偿。

6.3 打滑和碰撞时的识别与保护

普通平地打滑不严重,但如果地面有油渍,或者车被障碍物挡住,编码器计数还在增加但车子实际没动,系统会一直输出PWM直到超时。这种情况下一个简单的保护逻辑是:如果持续N个控制周期内,编码器计数变化量小于某个阈值,判定为堵转,强制停车。

int static_count = 0; float last_pulse = 0; float cur_pulse = g_encoder_count; if (fabs(cur_pulse - last_pulse) < 0.5f) { static_count++; } else { static_count = 0; } if (static_count > 100) // 连续500ms没有有效位移 { set_motor(0); break; } last_pulse = cur_pulse;

这个防护在调试初期尤其有用,它能防止你测试时车撞墙推着跑,电机堵转烧驱动板。

7. 后续还能往哪些方向扩展

基础版做到位之后,扩展方向非常多,我按推荐顺序整理了几个:

7.1 速度环与位置环的双环控制

单位置环在距离较长时,启停加速度波动大。改成内环速度环、外环位置环的双闭环结构后,距离控制会平顺很多。思想是:位置环输出一个目标速度,速度环通过实时编码器测速,计算出PWM让实际速度跟随目标速度。

速度环用PI控制,位置环用P控制就足够了。

7.2 S曲线加减速算法

如果不做双环,也可以把目标距离细分,按梯形速度曲线或者S曲线规划目标位置曲线,让PID去跟踪这个动态目标。比如前10%距离加速,中间80%匀速,最后10%减速。好处是机械冲击小,车走起来不会点头,适合拿来拍视频展示或者做些精密位置控制。

7.3 多传感器协同

纯编码器在打滑严重的场景依然不可靠。可以加一个MPU6050惯性传感器做辅助推算,或者加一个红外/超声避障感应在前方有障碍时减速停车。

我的建议是:编码器作为主传感器,其他传感器做安全保护和鲁棒性增强,不要试图用IMU替代编码器,因为积分漂移比编码器误差更可怕。

7.4 双轮差速与转向控制

两轮差速底盘需要控制左右轮分别行驶指定距离才能直线跑偏。常见的做法是加一个航向角传感器,左轮右轮分别PID,同时根据方向偏差微调两侧速度,才能实现长距离直线行驶。如果只是短距离开环跑,左右轮机械差异会导致走不了直线。

我在做双轮控制时踩过另一个坑:左右轮单独标定后的PWM参数并不通用,因为两侧的机械阻力不一样。即使标定了静止距离控制,动态加速阶段偏差也会放大,最后我是在较短距离(2米以内)用位置PID加微调系数,效果已经很够用。

最后的一点实践经验

这套闭环方案从立项到稳定运行,差不多花了我两个周末,大部分时间都耗在标定和参调上。回头看整个项目,最核心的不是STM32代码写得多漂亮,而是三件事:编码器数据可靠、PD参数合理、机械传动的误差心中有数。

如果你是自己做着玩,预算有限,用38块钱的JGB37-520电机加20块钱的TB6612加一块最小开发板,就能搭出完整原型。如果想用在项目交付场景,记得预留标定接口和堵转保护,这两点会让你的设备在客户现场少挨很多骂。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询