简介:这是一份STM32智能小车PID闭环速度控制程序源代码,基于标准库函数开发,主要面向嵌入式入门学习者、电子设计竞赛队伍以及智能小车爱好者,帮助解决直流减速电机的测速反馈与闭环调速问题。工程基于KEIL环境,配套Keil.STM32F1xx_DFP.2.3.0.pack库文件,适配STM32F103C8T6芯片,也较易移植到其他STM32F1系列;源码涵盖L293D电机驱动、1602液晶显示、测速模块信号采集,并通过K1按键启动闭环控制,K3/K4按键降低或提高目标速度,整体逻辑清晰,作者已在智能小车上实测通过。压缩包共包含172个文件,除37个.h头文件和36个.c源文件外,还有Keil工程配置(.uvprojx)、编译中间文件(.o/.crf)、最终烧录用的.hex/.axf以及map映射、调试脚本等,完整还原工程构建过程,包体仅3.44MB。目前已有4145人学习使用,适合想快速理解PID比例积分微分运算、PWM调速与编码器测速配合,或用此工程作为课程设计、竞赛作品基础的读者。
1. 为什么PID闭环速度控制是智能小车的必修课
很多朋友拿到“STM32智能小车PID闭环速度控制基于库函数程序源代码.rar”这个压缩包时,第一反应是赶紧解压、下载、烧录,看看小车能不能转。这个思路没错,但如果你只是把它当成一个“能跑的程序”来用,那真的太浪费了。我建议你先花十分钟想清楚一件事:这个项目到底解决了什么问题,以及它和你之前玩过的普通小车有什么本质区别。
1.1 从“能走”到“走得稳”的跨越
先说说很多入门玩家踩过的坑。你之前做的小车,大概率是开环控制——给电机一个固定PWM占空比,比如70%,然后小车就往前冲。但实际跑起来你会发现:电池电压从8.4V降到7.2V,同样70%的PWM,车速明显变慢;地面从瓷砖换成地毯,摩擦力变大,速度又开始掉;转弯的时候负载变化,左右轮转速不一致,小车跑的路线歪得没法看。
这是因为直流电机的转速受负载和供电电压影响非常大,开环控制根本没法保证“我想要多少速度,它就真的有多少速度”。而PID闭环控制做的事,就是实时测量电机实际转速,和目标速度做比较,根据误差动态调整PWM输出,让电机转速始终贴在目标值附近。这就是“能走”和“走得稳”的本质区别。
1.2 为什么选择STM32 + 库函数方案
这个项目选型很有代表性:主控用STM32F103系列,开发方式用标准外设库(也就是常说的“库函数”),速度反馈用编码器,控制算法用增量式PID。这个组合在2024年仍然值得学,原因有三点。
第一,STM32F103是市面上资料最全、最经典的单片机,不管是江科大(原野火)的教程,还是正点原子的视频,覆盖量都极其庞大,遇到问题几乎一定能搜到答案。第二,标准外设库虽然是“老技术”,但它的代码可读性比HAL库更直观,适合理解底层寄存器操作逻辑,对初学者来说反而比HAL库更好上手。第三,增量式PID代码量小、无积分累积过深的问题、执行效率高,非常适合8位/32位MCU上的电机控制场景。
提示:如果你的开发环境比较新,可能标准外设库在最新版Keil里会报一些兼容性警告,这不是代码问题,是编译器版本导致的,后面我会讲怎么处理。
1.3 这个源码包能帮你解决什么问题
从实际价值来看,这个项目至少覆盖了几个高频需求:毕业设计(STM32小车是电类专业经典选题)、竞赛前期验证(智能车/电子设计竞赛的基础闭环控制)、以及想从“点亮LED”进阶到“真正控制一个物理对象”的自学者。
拿到源码后,你应该重点看五个模块:定时器编码器模式的初始化与读取、PWM输出配置、PID计算函数、电机驱动逻辑、以及主循环或中断里的速度控制调度。这五个模块理解了,整个项目就等于拿下了80%。接下来的内容,我会按这几个模块逐个拆解,把代码背后为什么这么写讲清楚。
2. 核心硬件方案与选型思路
很多初学者容易忽视硬件选型和代码之间的因果关系。其实代码怎么写,很大程度上是由硬件方案决定的。这个项目里的关键硬件选型如下:主控STM32F103C8T6(蓝丸板)、电机驱动TB6612或L298N、直流减速电机带霍尔编码器(如常见的MG310或JGA25-370)、电源方案(两节18650或7.4V锂电池组)。
2.1 编码器与测速原理
编码器是这个项目中最关键的传感器。常见的光电霍尔编码器装在电机尾部,电机每转一圈,编码器会输出一定数量的脉冲。比如常见的减速电机减速比1:30,电机本体每转一圈输出13个脉冲(霍尔编码器常见13PPR),那么输出轴转一圈的脉冲数就是13×30=390个。
你在代码里看到的TIM_GetCounter或者编码器计数读取,实际上就是读取定时器在编码器模式下自动累计的脉冲数。STM32的定时器编码器模式非常方便,它通过TIMx_CH1和TIMx_CH2两路输入捕获,自动判断正反转,并向上/向下计数。这意味着硬件上就把方向判断和脉冲计数一起解决了,不用你在中断里手动判断A相B相的先后顺序。
有一点必须提醒:编码器脉冲计数读取后,要转化为实际速度。常见的做法是在一个固定时间窗口内读取计数器增量,然后换算成“每秒多少转”或“每秒多少厘米”。公式是:
速度(转/秒) = (脉冲增量 / 编码器总分辨率) / 时间间隔(秒)其中编码器总分辨率 = 编码器线数(PPR) × 减速比,如果开启定时器4倍频模式,还需要再乘以4。这就是为什么这类代码里你总能看到一个类似Encoder_Get_Speed()的函数,内部做了一堆除法运算——它不是为了炫技,是真的要换算成物理单位。
2.2 电机驱动与PWM的配合
有了速度反馈,还得有执行机构。电机驱动芯片负责将单片机的PWM信号放大成功率信号驱动电机。TB6612和L298N的区别在于:TB6612功耗低、体积小、PWM频率响应更好,适合智能小车这种电池供电的场景;L298N压降大、发热大,但胜在便宜、皮实。
在代码层面,PWM配置有几个关键参数:频率、占空比分辨率、以及方向引脚的控制逻辑。对于常见的直流减速电机,PWM频率建议设置在10kHz到20kHz之间。频率太低电机会发出刺耳的啸叫声,人也听得见;频率太高MOS管开关损耗加大,驱动芯片发热。选择15kHz左右基本不会错。占空比分辨率则取决于定时器重装值(ARR),一般设置为999或9999,对应PWM分辨率为0.1%或0.01%。
电机正反转控制靠两个方向引脚(如AIN1、AIN2)的电平组合。注意方向切换时最好先让PWM输出为0,再切换方向引脚,否则容易烧驱动芯片或产生大的冲击电流。这个细节在很多源码里都不会专门写,但却是实际调试中最容易烧硬件的坑。
2.3 电源系统的隐藏细节
这个项目里还有一个很多人容易忽视的地方——电源。STM32需要3.3V,编码器需要5V(或3.3V),电机需要7.4V左右。靠谱的做法是:电池直接给电机驱动板供电,驱动板上带5V稳压输出给单片机系统;或者用独立的降压模块给逻辑电路供电。绝对不要用单片机的3.3V去驱动电机,也不要把电机电源和逻辑电源直接并在同一根线上,否则电机启停瞬间的电压跌落会把单片机搞复位。
我见过太多“小车跑着跑着就重启”“速度一快就死机”的案例,查到最后都是电源问题。代码写得再对,电源不干净,什么都白搭。
3. PID算法实现细节与关键代码解析
PID是整个项目的灵魂。这个源码包里用的是增量式PID,相比位置式PID,它的优势是输出的是控制量的增量,没有误差累积项,而且天然带有抗积分饱和的特性,非常适合嵌入式环境。
3.1 位置式与增量式的选择逻辑
简单回顾一下两个公式。
位置式PID输出的是绝对的PWM占空比:
u(k) = Kp*e(k) + Ki*Σe(i) + Kd*(e(k)-e(k-1))增量式PID输出的是PWM的增量:
Δu(k) = Kp*(e(k)-e(k-1)) + Ki*e(k) + Kd*(e(k)-2*e(k-1)+e(k-2))两者的差异在实际工程中很明显。位置式的积分项需要累加所有历史误差,一旦长时间运行,计算量增大且可能出现积分饱和——误差一直存在,积分项疯狂累积,最终导致输出一直在最大值,调不回来。增量式只计算最近三次误差,输出的是增量,天然规避了这个麻烦。
在智能小车这个场景,控制频率(速度环)通常在50到200Hz之间。增量式PID的计算量小,占用资源少,而且不需要对积分项做特殊的限幅保护,代码实现简单可靠。所以这个项目选增量式是合理的,建议你不要轻易改成位置式,除非有特殊需求。
3.2 核心代码结构逐行解析
抛开具体文件名,整个项目的核心代码逻辑基本可以抽象为以下几个模块:
编码器读取模块
int16_t Encoder_Get_Count(void) { int16_t count; count = (int16_t)TIM2->CNT; // 读取计数器值 TIM2->CNT = 0; // 清零,准备下一次计数 return count; }这里需要注意两个细节。第一,TIM2->CNT一定要清零。不清零的话,下一次读到的就是累计值而不是增量,速度计算直接就错了。第二,计数器的类型是有符号的int16_t,而不是无符号uint16_t,因为编码器模式会向上或向下计数,需要能表达负数。这两个细节是这个模块最容易出错的地方。
PID控制模块
typedef struct { float Kp; float Ki; float Kd; float target; // 目标值 float actual; // 实际值 float err; // 当前误差 float err_last; // 上一次误差 float err_prev; // 上上次误差 float output; // PID输出增量 } PID_TypeDef; void PID_Calc(PID_TypeDef *pid) { pid->err = pid->target - pid->actual; pid->output = pid->Kp * (pid->err - pid->err_last) + pid->Ki * pid->err + pid->Kd * (pid->err - 2*pid->err_last + pid->err_prev); pid->err_prev = pid->err_last; pid->err_last = pid->err; }这段代码很典型。每次调用PID_Calc就是做一次增量式计算,输出output是PWM的增量,最终PWM占空比是current_pwm += output。注意需要手动判断current_pwm是否超限,例如限制在0到1000之间(对应ARR=999),这个限幅操作一般放在调用PID的函数里,而不是放在PID计算函数里。
速度控制调度
速度环的定时器中断里,大概每5到10毫秒执行一次:读取编码器计数,换算成实际速度,调用PID_Calc,更新PWM占空比。中断频率太低,控制滞后明显;中断频率太高,PID输出变化过于频繁,电机反而抖动。5ms到20ms(即50Hz到200Hz)是一个比较稳妥的范围,我一般先用100Hz起步。
3.3 调参方法论:从P到I再到D
很多人拿到源码后最关心的问题就是“PID参数调多少合适?”这个真没有标准答案,但有一套方法论可以参考。我推荐用“临界比例度法”配合“试凑法”组合操作。
先把Ki和Kd设为0,只保留Kp。从小到大慢慢加Kp,观察小车速度响应:如果实际速度有稳态误差、响应慢,继续加Kp;如果出现等幅振荡,说明Kp已经偏大,回退到振荡前值的60%-80%,作为基础Kp。然后在基础Kp上把Ki从0开始往上加,用来消除稳态误差。注意Ki过大同样会引发振荡,而且振荡周期往往比Kp引起的更长。最后加Kd,用来抑制超调和振荡,但Kd对噪声敏感,如果编码器信号有干扰,Kd太大会导致输出抖动。
实测下来,常见的速度环参数范围大致是:Kp在0.1到2.0之间,Ki在0.01到0.5之间,Kd在0.001到0.1之间(具体和PWM分辨率、速度单位、编码器分辨率有关)。但这个范围只能作为起点,真正合适的值必须针对自己的小车去调。
注意:调参时一定要让目标速度发生变化来测试。比如先给一个中速目标,稳定后再突然给一个高速目标,观察响应曲线。如果从低速切到高速时出现过大的超调,说明Kp或Ki偏大;如果响应太慢、半天爬不到目标速度,说明Kp或Ki偏小。
4. 实操过程与核心环节实现
前面讲了原理和代码逻辑,这一节我按实际操作顺序,把从解压源码到小车跑起来的过程完整走一遍,标出哪些坑值得特别注意。
4.1 环境准备与工程导入
首先,准备开发环境。建议使用Keil MDK 5.36以上版本,再安装STM32F1系列的器件支持包(DFP),也就是你之前可能搜过的“STM32芯片包安装”。标准外设库(StdPeriph Library)在Keil老版本如5.23里体验最好,新版本会有几个警告,不影响下载运行,但如果你有强迫症,可以把编译器版本切换为AC5(ARM Compiler 5),警告就基本消除了。
解压源码包后,不要直接打开,先看文件夹结构。一般会分为:USER(包含main.c、stm32f10x_it.c、系统文件)、HARDWARE(各外设驱动的.c和.h)、SYSTEM(延时、串口等公共模块)、CORE(启动文件)、OBJ(编译输出)。双击USER目录下的.uvprojx或.uvproj文件即可打开工程。
打开后做一次“清理-编译”。按下编译快捷键F7前,先检查三件事:魔术棒(Options for Target)里的Device是否选的是你实际用的芯片型号(C8T6还是C6T6);C/C++选项卡里的Define是否包含USE_STDPERIPH_DRIVER,STM32F10X_MD;Output选项卡里的Create HEX File是否勾选。这三个配置错了,编译必报错或者烧录后没反应。
4.2 硬件接线与烧录验证
硬件接线以最常见的C8T6蓝丸板为例。电机驱动模块和MCU的IO对应关系需要和源码里的宏定义保持一致,比如:
| 信号 | 端口 | 说明 |
|---|---|---|
| PWM_L | PA0/TIM2_CH1 | 左电机PWM |
| PWM_R | PA1/TIM2_CH2 | 右电机PWM |
| AIN1/DIR_L | PB0 | 左电机方向 |
| AIN2 | PB1 | 左电机反向 |
| BIN1/DIR_R | PB10 | 右电机方向 |
| BIN2 | PB11 | 右电机反向 |
| ENC_L_A | PA6/TIM3_CH1 | 左编码器A相 |
| ENC_L_B | PA7/TIM3_CH2 | 左编码器B相 |
| ENC_R_A | PB4/TIM3_CH1(或PB6) | 右编码器A相 |
| ENC_R_B | PB5/TIM3_CH2(或PB7) | 右编码器B相 |
提示:不同开发板的丝印不一样,主控引脚复用也不一样。务必对照原理图确认,不要盲信网上的接线表。接错引脚轻则没反应,重则烧IO口。
烧录方式推荐用ST-Link或J-Link,用SWD接口只需要接SWDIO、SWCLK、GND、3V3四根线。如果你用的是串口ISP下载,记得BOOT0要拉高,下载完再拉低复位。烧录成功后,如果程序里有LED闪烁或者OLED显示初始化,应该能立刻看到效果。
4.3 关键指标实测与效果评估
程序烧录成功后,还不能高兴得太早,要验证闭环是否真的生效。我建议做一个简单但效果明显的测试:把小车架起来,让轮子悬空,通过串口上位机(或板载按键)设定一个目标速度。如果PID生效,你会听到电机声音快速变化,轮子转速稳定在目标值附近;如果PID没调好,你会看到轮子要么忽快忽慢,要么震荡到极限。
另一个建议是给小车设一个突然变化的目标速度,观察是否能快速跟随。短时间内如果你看到轮子像“抽搐”一样抖动,多半是Kp过大;如果轮子过很久才达到目标速度,可能是Ki太小或Kp太小。这些表现比你用示波器看波形更直观,对新手也更友好。
我试过的调试顺序是先只调Kp,目标速度恒定,观察稳态误差和稳定性;稳定后再加Ki,消除稳态误差;最后加Kd,抑制超调。整个调参过程大概需要一两个小时,要有耐心。调完一轮参数,手感上你就对PID“身体记忆”了。
4.4 常见问题速查表
我把实际调试中经常遇到的问题整理成一个速查表,遇到问题可以快速定位。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 编译报错“core_cm3.h: No such file” | 工程缺少CMSIS路径 | 在C/C++ Include Paths中添加CORE目录 |
| 电机不转 | PWM通道或方向引脚接线错误 | 检查IO映射和驱动芯片供电 |
| 轮子能转,但速度显示为0 | 编码器接线错误或定时器配置不对 | 检查编码器A/B相是否接对,模式是否配置为编码器模式 |
| 速度波动大,噪声严重 | Kd过大,或编码器信号受干扰 | 降低Kd,给编码器线加滤波电容 |
| 电机啸叫 | PWM频率太低 | 增大PWM频率至10k-15kHz |
| 小车跑偏 | 左右轮PID参数不一致或编码器分辨率不同 | 左右轮分别单独标定和调参 |
| 一键下载后运行不了 | BOOT0跳线没归位 | 下载后复位或把BOOT0拉低 |
还有一个非常典型的坑:使用标准库的延时函数Delay_ms()时,如果定时器配置和默认SysTick冲突,程序会卡死在延时里。这个往往是SYSTEM文件夹里的SysTick_Config配置与你自己的定时器初始化有冲突导致的。排查方法是单步调试,看卡在哪一行。
4.5 我的一些独家调试技巧
分享三个能直接提升效率的小经验。
第一,善用串口打印。把目标速度、实际速度、PID输出值通过串口输出,用上位机虚拟示波器(比如VOFA+、匿名上位机)画曲线,比看逻辑分析仪直观得多。串口波特率建议115200,输出频率和控制频率保持一致(比如100Hz),这样画的曲线才有参考价值。
第二,用静态测试代替动态测试。调参时不要总在跑动中观察,把车架起来调好空载参数,再放到地面做负载修正。空载下小车的响应特性和地面满载差别很大,直接地面调你会分不清到底哪个参数有问题。
第三,如果左右轮驱动结构完全对称,但跑起来还是有微小跑偏,可以用软件补偿。例如在直行时给某一侧电机加一个小的占空比偏置,比如2%到5%。这个叫做“死区补偿”或“零位校准”,在很多工业控制里也是这样做的。
5. 从速度环到更复杂的控制结构
很多同学做完速度闭环后,会觉得“就这样了?”其实不是。速度环只是第一步,在这个基础上还能扩展出很多更有意思的控制架构。
5.1 双环控制:位置外环 + 速度内环
最常见的是在速度环外面再加一个位置环,构成串级PID(也叫级联PID,也就是你搜索时看到的“pid双环控制”、“级联pid控制”)。位置环的输出作为速度环的目标值,速度环的输出控制PWM。这样做的好处是能实现“精确走直线多少厘米”“自动停到某个位置”这类功能,是循迹小车、智能车库、机械臂控制的基础结构。
实现双环时的关键原则是:外环(位置环)的响应频率要低于内环(速度环)。通常速度环100Hz到200Hz,位置环50Hz左右,否则两个环互相抢资源,系统容易不稳定。
5.2 模糊PID和自适应控制
如果你觉得定参数的PID调着费劲,可以了解模糊PID。模糊PID的本质是用模糊规则表,根据当前误差和误差变化率,在线调整Kp、Ki、Kd。在小车高速跑直线、突然转弯、爬坡等工况变化很大的场景,模糊PID有更好的适应性。但学习成本也更高,需要建立模糊规则、隶属度函数等,建议先把定参数PID吃透再考虑,别一上来就搞复杂结构。
5.3 多机通信与上位机联调
STM32小车做到后期,经常要和K210、OpenMV这类视觉模块通信。这也解释了热搜词里为什么有“k210与stm32通讯”。串口通信是首选方案,STM32的USART接收视觉模块发来的坐标或循迹数据,再把位置信息转换成速度环的目标速度,这样就组成了一套完整的视觉伺服系统。
通信协议建议使用帧头+数据+校验的简单格式,比如0xAA 0x55 len data checksum。串口中断接收时要用环形缓冲或者状态机解析,防止数据粘包和丢帧。这个能力在后续做比赛和毕业设计里都是高频考点。
关于“error: no stm32 target found”这个报错,如果你遇到的是ST-Link连接失败,基本是接线松了或者驱动没装好;但如果是在CubeMX生成工程后编译板子不识别,注意检查调试接口的引脚是否被复用成了GPIO。复用后ST-Link的SWD引脚没法用,就回不去下载模式了,这时你需要按住复位键再点下载,同时连按复位放掉,多试几次就能连上。
我在实际做项目时发现,把速度环调试好之后,整个代码的可扩展性会变得非常强。很多看起来复杂的功能(比如自动循迹、避障、定位停车)本质上都是在目标速度的生成上做文章,而底层的执行机制还是那套PID闭环。这大概就是这类项目最值得学习的地方——它不是孤立的知识点,而是一个打通“传感器-算法-执行器”全流程的经典案例。
最后再分享一个我自己的小习惯:每次调完一组参数,我都会把当时的波形截图或者把参数记在代码注释里,附带调参日期、小车状态(空载/满载)、电源电压。这样下次不管是自己调优还是帮别人复现,都有数据可查,不用从头再来。这个习惯看起来简单,但坚持下来你会发现它帮你省下的时间绝对超出预期。
本文还有配套的精品资源,点击获取