1. 从零到一:为什么选择开环控制作为单片机小车的第一步
如果你刚拿到一块STM32开发板和一套小车底盘,看着一堆电机、轮子和杜邦线,脑子里第一个冒出来的想法,多半是“怎么让它动起来?”。很多教程会直接上PID闭环控制,讲得天花乱坠,但对于一个初学者来说,这就像还没学会走路就想跑马拉松。我的建议是,别急,我们先从“开环控制”开始。这不仅是让小车动起来的起点,更是你理解整个嵌入式控制系统底层逻辑的基石。
开环控制,说白了,就是“我说什么,你就做什么,至于你做没做到位,我不关心”。比如,我让电机以50%的占空比转3秒,它就转3秒,至于这3秒里小车是不是因为地面不平、电池电压下降而跑偏了,控制器不管。听起来很“傻”,对吧?但正是这种“傻”,让它成为了入门的最佳选择。它剥离了复杂的反馈和纠错环节,让你能集中精力解决几个核心问题:如何用STM32的定时器产生PWM波驱动电机?如何通过程序逻辑控制小车的“前进”、“后退”、“左转”、“右转”?如何搭建一个最基础的硬件系统?
这个过程,你会亲手打通从代码到物理运动的整个链路。当你第一次通过几行代码,让轮子按照你的意愿转动时,那种“掌控感”是无与伦比的。这比一上来就面对误差、积分、微分这些抽象概念要直观得多。开环控制是“骨架”,先把骨架搭起来,后续的PID闭环控制、传感器融合才是往上添加的“肌肉”和“神经”。跳过骨架,直接堆肌肉,系统是立不起来的。所以,咱们今天就踏踏实实,从给STM32小车搭建这个最基础的“开环骨架”开始。
2. 硬件清单与核心电路连接:别在第一步就“翻车”
在写代码之前,硬件连接是重中之重。很多新手在这里栽跟头,不是电机不转,就是单片机冒烟。我结合自己焊板子、调车的经验,给你捋一个清晰且避坑的连接清单。
2.1 核心部件清单
- 主控:STM32F103C8T6(俗称“蓝桥杯”或“最小系统板”)。它性价比高,资源足够,社区资料海量,是学习的不二之选。
- 电机驱动模块:L298N或TB6612FNG。强烈推荐TB6612FNG,原因有三:效率高、发热小、外围电路简单。L298N是老将,但需要外接散热片,逻辑电压和驱动电压要处理好,对新手不友好。TB6612是MOSFET桥,更现代。
- 直流减速电机:带编码器的那种。虽然开环用不到编码器反馈,但买带编码器的电机是为未来升级闭环预留了硬件接口,避免重复投资。通常工作电压在3-6V,注意与驱动模块匹配。
- 电源:这是最大的坑点。务必准备两路独立电源:
- 一路给STM32开发板供电:5V Micro USB或3.3V引脚供电。
- 一路给电机驱动模块供电:根据电机额定电压选择,比如4节5号电池(约6V)或2节18650锂电池(约7.4V)。严禁只用USB给整个系统供电!USB的500mA电流带不动两个电机,瞬间就会导致电压被拉低,单片机不断复位,现象就是程序跑飞、电机抽搐。
- 小车底盘:四轮或两轮差分驱动底盘。四轮的后两轮通常是万向轮,靠前两轮(差分驱动)决定方向和速度。
- 杜邦线:公对公、母对母、公对母都需要,用于连接。
2.2 电路连接详解与避坑指南
以TB6612FNG驱动模块为例,连接STM32F103C8T6。一个TB6612可以驱动两个电机,我们假设控制小车的左轮电机(MOTOR A)和右轮电机(MOTOR B)。
注意:接线前,确保所有电源处于断开状态!养成好习惯。
电源连接(关键!):
VM引脚:接电机驱动电源正极(如电池盒的6V)。这是电机的“力气”来源。VCC引脚:接逻辑电源正极(接STM32的3.3V或5V输出引脚)。这是给驱动芯片内部逻辑电路供电的,必须接。GND引脚:共地!共地!共地!将电池的负极、STM32的GND、TB6612的GND,三者用杜邦线全部连接在一起。这是保证信号基准一致的基础,否则控制信号会乱。
电机输出:
A01和A02引脚:分别接左轮电机的两根线。B01和B02引脚:分别接右轮电机的两根线。如果电机转向与预期相反,交换这两根线即可。
STM32控制信号连接(核心): TB6612每个电机需要3个控制信号:
AIN1/AIN2(方向)、PWMA(速度PWM)。我们将其连接到STM32的通用定时器(如TIM2)的通道上,以实现硬件PWM输出。- 左轮电机:
AIN1-> STM32PA0(TIM2_CH1)AIN2-> STM32PA1(TIM2_CH2)PWMA-> STM32PA2(TIM2_CH3)
- 右轮电机:
BIN1-> STM32PA6(TIM3_CH1)BIN2-> STM32PA7(TIM3_CH2)PWMB-> STM32PB0(TIM3_CH3)
STBY(待机)引脚:直接接VCC(逻辑电源),让驱动模块始终处于工作状态。
- 左轮电机:
这里为什么选择TIM2和TIM3?因为STM32F103C8T6的通用定时器TIM2、TIM3、TIM4都支持4路PWM输出,且引脚分布方便布线。你也可以根据自己板子的引脚定义灵活选择,但原则是:一个电机的方向和PWM信号,尽量来自同一个定时器,方便程序管理。
2.3 上电前最后检查
- 电压确认:用万用表量一下
VM和GND之间的电压,是否在电机额定电压附近(比如6V左右)?VCC和GND之间是否是3.3V或5V? - 短路检查:快速目视检查所有杜邦线头,有没有金属部分裸露接触导致短路的风险。
- 连接牢固度:轻轻拉扯一下杜邦线,确保都插紧了。接触不良是调试中最诡异的问题来源。
硬件连接就像盖房子的地基,这里扎实了,后面的软件调试才能顺风顺水。
3. STM32定时器与PWM配置:让电机“听令行事”的核心
硬件通了,现在要让STM32发出正确的控制信号。开环控制的核心,就是通过PWM(脉冲宽度调制)波来控制电机的平均电压,从而控制转速。PWM的频率和占空比是两个关键参数。
3.1 PWM频率的选择:不是越高越好
对于普通的直流减速电机,PWM频率通常在1kHz到20kHz之间。
- 频率太低(如几十Hz):你会听到电机发出刺耳的啸叫声,这是脉冲间隔太长,电机线圈在不断地“启动-停止”造成的,对电机寿命和控制平滑度都不好。
- 频率太高(如上百kHz):虽然电机声音会变得很安静(人耳听不到),但会对电机驱动模块的开关损耗提出更高要求,有些低端驱动芯片可能响应不过来,导致实际效果变差。
- 经验值:我通常选择5kHz到10kHz。这个区间对于TB6612和L298N都工作良好,电机运行平稳,噪音在可接受范围内。我们以10kHz为例进行配置。
3.2 使用CubeMX进行图形化配置(HAL库)
对于初学者,我强烈推荐使用STM32CubeMX工具配合HAL库。它通过图形界面配置时钟和引脚,自动生成初始化代码,能避免大量底层寄存器操作的错误。
- 时钟树配置:确保系统时钟(SYSCLK)被正确设置为72MHz(STM32F103的最高主频)。这是计算定时器计数值的基准。
- 定时器配置(以TIM2为例):
- 在Pinout视图找到
PA0、PA1、PA2,分别设置为TIM2_CH1、TIM2_CH2、TIM2_CH3。 - 在左侧配置栏找到
TIM2。 Clock Source选择Internal Clock。Parameter Settings选项卡:Prescaler(预分频器PSC):设置为71。定时器时钟 = 72MHz / (71+1) = 1MHz。Counter Mode:Up(向上计数)。Counter Period(自动重装载值ARR):设置为99。PWM频率 = 定时器时钟 / ((PSC+1) * (ARR+1)) = 1MHz / (100) = 10kHz。公式一定要理解:Fpwm = Fclk / ((PSC+1) * (ARR+1))。auto-reload preload:Enable(使能自动重装载)。
- 切换到
PWM Generation CHx(对应通道)子选项卡:Mode:PWM mode 1。Pulse(初始脉冲值):先设为0。这个值就是后续控制占空比的关键,范围是0到ARR(即99)。占空比 =Pulse / (ARR+1)。Fast Mode:Disable。CH Polarity:High。这意味着当计数器小于Pulse值时,输出高电平。这个极性要和你的驱动模块逻辑匹配。TB6612一般是高电平有效。
- 在Pinout视图找到
- 生成代码:在Project Manager里设置好工程名、路径、IDE(如MDK-ARM V5),然后生成代码。
3.3 在代码中操控PWM与方向
CubeMX生成的代码已经完成了定时器和GPIO的初始化。我们只需要在main.c的用户代码区调用HAL库函数即可。
// 在main函数初始化部分之后,启动PWM输出 HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); // 启动TIM2 CH1 (PA0, AIN1的PWM? 不,这里注意!) HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_2); // 启动TIM2 CH2 (PA1, AIN2的PWM? 不,这里注意!) HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_3); // 启动TIM2 CH3 (PA2, PWMA) // 同理启动TIM3的各个通道 HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3);这里有一个非常重要的细节:我们连接时,AIN1/AIN2、BIN1/BIN2接的是普通GPIO口(虽然我们用了定时器通道引脚,但可以配置为纯输出模式),而PWMA/PWMB接的才是真正的PWM输出引脚。在CubeMX里,PA0和PA1应该配置为GPIO_Output,而不是TIM2_CH1/2。只有PA2(PWMA)需要配置为TIM2_CH3的PWM模式。
所以,正确的做法是:
PA0(AIN1),PA1(AIN2) 配置为GPIO Output。PA2(PWMA) 配置为TIM2_CH3的PWM Generation。- 在代码中,通过
HAL_GPIO_WritePin来控制PA0和PA1的高低电平组合,决定电机方向;通过__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_3, pulse)来改变PA2的PWM占空比,控制电机速度。
方向控制逻辑(以左轮为例):
| AIN1 (PA0) | AIN2 (PA1) | 电机状态 |
|---|---|---|
| 1 | 0 | 正转 |
| 0 | 1 | 反转 |
| 0 | 0 | 刹车(停止) |
| 1 | 1 | 刹车(停止) |
我们可以封装一个函数来控制一个电机:
// 控制左轮电机 void Left_Motor_Control(int16_t speed) // speed范围:-100 ~ 100 { if(speed > 0) { HAL_GPIO_WritePin(AIN1_GPIO_Port, AIN1_Pin, GPIO_PIN_SET); // 正转 HAL_GPIO_WritePin(AIN2_GPIO_Port, AIN2_Pin, GPIO_PIN_RESET); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_3, speed); // 假设ARR=99, speed就是占空比 } else if(speed < 0) { HAL_GPIO_WritePin(AIN1_GPIO_Port, AIN1_Pin, GPIO_PIN_RESET); // 反转 HAL_GPIO_WritePin(AIN2_GPIO_Port, AIN2_Pin, GPIO_PIN_SET); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_3, -speed); } else { HAL_GPIO_WritePin(AIN1_GPIO_Port, AIN1_Pin, GPIO_PIN_RESET); // 刹车 HAL_GPIO_WritePin(AIN2_GPIO_Port, AIN2_Pin, GPIO_PIN_RESET); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_3, 0); } }对于右轮电机,同理封装Right_Motor_Control函数,操作BIN1、BIN2和TIM3_CH3。
4. 基础运动函数封装与调试:让小车“走”起来
有了单个电机的控制函数,我们就可以组合出小车的基本动作了。封装这些动作函数,能让主程序逻辑变得非常清晰。
4.1 运动函数封装
// 小车运动函数封装 void Car_Stop(void) { Left_Motor_Control(0); Right_Motor_Control(0); } void Car_Forward(uint8_t speed) { Left_Motor_Control(speed); Right_Motor_Control(speed); } void Car_Backward(uint8_t speed) { Left_Motor_Control(-speed); Right_Motor_Control(-speed); } void Car_TurnLeft(uint8_t speed) // 原地左转:左轮后退,右轮前进 { Left_Motor_Control(-speed); Right_Motor_Control(speed); } void Car_TurnRight(uint8_t speed) // 原地右转 { Left_Motor_Control(speed); Right_Motor_Control(-speed); } void Car_SpinLeft(uint8_t speed) // 左轮不动或慢速,右轮快速前进,实现弧线左转 { Left_Motor_Control(speed / 3); // 比例可调 Right_Motor_Control(speed); } void Car_SpinRight(uint8_t speed) // 弧线右转 { Left_Motor_Control(speed); Right_Motor_Control(speed / 3); }4.2 上电调试与常见问题排查
将代码编译下载到STM32后,先别急着让小车跑。按步骤调试:
静态测试(车轮悬空):
- 在
main函数的while(1)循环里,写一个简单的测试序列。
while (1) { Car_Forward(50); // 50%速度前进 HAL_Delay(2000); Car_Stop(); HAL_Delay(500); Car_TurnLeft(60); // 原地左转 HAL_Delay(1000); Car_Stop(); HAL_Delay(2000); // ... 其他动作测试 }- 观察车轮是否按预期转动。如果不动,按以下顺序排查:
- 电源指示灯:STM32板和驱动板上的电源LED亮了吗?
- 信号测量:用万用表电压档或示波器(如果有)测量
PWMA引脚对地电压。在电机转动时,你应该能看到一个平均电压(比如3.3V * 50% ≈ 1.65V)。用万用表频率档可以粗略看PWM频率。 - 方向信号:测量
AIN1和AIN2的电压,是否根据代码变化(0V或3.3V)。 - 电机驱动输出:测量
AO1和AO2之间的电压。当电机转动时,这里应该有接近VM电源电压的PWM输出。注意安全,避免短路。 - 代码检查:确认
Left_Motor_Control函数里的引脚名和CubeMX配置的一致。确认__HAL_TIM_SET_COMPARE函数的第一个参数(定时器句柄)正确。
- 在
动态测试(小车落地):
- 静态测试通过后,将小车放在空旷平整的地面上(远离桌子边缘!)。
- 再次运行测试程序。观察小车运动轨迹。
- 典型问题:小车不走直线,而是偏向一边。这是100%会出现的现象,也是开环控制的固有缺陷。原因有两个:
- 电机差异性:即使是同一批次的电机,其空载转速、负载特性也有微小差异。
- 车轮安装与摩擦:车轮安装的同心度、与地面的摩擦力不可能完全一致。
- 开环阶段的“土办法”校准:我们无法感知误差,但可以“预补偿”。通过实验,找到一个偏差值。比如,你发现小车总是向右偏,那么可以让左轮的速度略高于右轮。
void Car_Forward_Adjusted(uint8_t speed) { uint8_t left_adj = speed + 5; // 左轮增加5个“单位”的补偿 uint8_t right_adj = speed; if(left_adj > 100) left_adj = 100; // 防止溢出 Left_Motor_Control(left_adj); Right_Motor_Control(right_adj); }这个补偿值需要你在实际场地上反复测试获得。这就是开环控制:基于事先的“经验”或“校准”来发指令,无法应对运行中的新变化。
5. 构建开环运动序列:设计你的第一条“巡线”
开环控制并非一无是处。在已知的、结构化的环境中,我们可以通过精心设计的时序控制,让小车完成一些固定任务。最经典的例子就是:让小车走一个正方形轨迹。
5.1 “走正方形”程序设计与陷阱
思路很简单:前进一段距离 -> 右转90度 -> 前进 -> 右转... 循环4次。但这里的关键是:“一段距离”和“90度”如何用时间和速度来定义?
void Car_Run_Square(void) { uint8_t side_speed = 60; uint16_t forward_time = 1000; // 前进时间,单位ms uint16_t turn_time = 500; // 旋转90度时间,单位ms for(int i = 0; i < 4; i++) { Car_Forward(side_speed); HAL_Delay(forward_time); // 前进 Car_Stop(); HAL_Delay(100); // 停顿一下,让动作更清晰 Car_TurnRight(side_speed); HAL_Delay(turn_time); // 右转 Car_Stop(); HAL_Delay(100); } Car_Stop(); }你会发现,这个小车画出来的绝对不是一个标准的正方形,可能是一个歪歪扭扭的平行四边形,甚至是个螺旋形。原因如下:
- 速度-时间与距离的非线性:
Car_Forward(side_speed)指令发出后,小车从静止加速到稳定速度需要时间,停止时也有减速过程。HAL_Delay(forward_time)延迟的这1秒钟里,包含了加速和减速段,真正以稳定速度前进的距离小于速度 * 时间。 - 地面摩擦影响:不同地面的摩擦系数不同,加速和减速时间也不同。
- 转弯角度的不确定性:
Car_TurnRight是差速转弯,其角速度受电机性能、电池电压、地面摩擦等多重影响。HAL_Delay(turn_time)延迟0.5秒,转过的角度不一定是90度。
5.2 从“开环”到“开环+”的改进思路
纯粹的延时开环控制鲁棒性极差。为了让它更可靠一点,我们可以引入一些非常简单的“准反馈”或更精细的控制模型,这可以看作是从纯开环到闭环的一个过渡练习。
思路一:分段速度控制。模仿工业上的“S曲线加减速”,让启动和停止更平滑,减少打滑,提高时间-距离关系的可重复性。
void Car_Forward_Smooth(uint8_t target_speed, uint16_t distance_time) { // 加速段 for(int s = 30; s < target_speed; s+=5) { Car_Forward(s); HAL_Delay(20); // 每20ms增加一点速度 } // 匀速段 Car_Forward(target_speed); HAL_Delay(distance_time); // 减速段 for(int s = target_speed; s > 30; s-=5) { Car_Forward(s); HAL_Delay(20); } Car_Stop(); }通过匀加速/减速,电机负载变化更平缓,电池电压波动更小,两次执行的一致性会稍好一些。
思路二:利用简单的传感器进行事件触发。比如,在小车底盘前端安装一个微动开关作为“碰撞传感器”。我们可以写一个程序:“一直前进,直到碰到障碍物(开关被压下),然后后退一点,右转90度,继续前进”。这就构成了一个非常初级的“随机避障”行为。虽然它没有测量距离,只是感知了“碰撞”这个事件,但已经引入了外部反馈,系统的智能性上了一个台阶。这需要你学习STM32的外部中断(EXTI)编程。
5.3 开环控制的价值总结
通过以上实践,你应该深刻体会到开环控制的局限性:它无法应对扰动,精度差。但它的价值同样巨大:
- 验证了硬件系统:电机、驱动、电源、单片机连接全部正确。
- 掌握了最底层的控制API:你知道了如何让轮子正转、反转、变速。
- 建立了系统性的调试思维:从电源、信号、到软件逻辑的逐层排查。
- 直观理解了“为什么需要闭环”:你亲眼看到了小车跑不直、转弯不准,这为你后续学习编码器测速、PID控制提供了最强烈的学习动机——你知道了你要解决的问题到底是什么。
开环控制是起点,而不是终点。它就像学骑自行车时的辅助轮,帮你建立起平衡感和操控感,但最终你要拆掉它,去面对更真实、更复杂的路况。当你成功实现小车的开环控制后,下一步自然就是为电机装上编码器,开始探索“闭环控制”的世界,那时,你的小车才能真正做到“指哪打哪”,抵抗各种外部干扰。