1. 从零到一:为什么你的第一个STM32项目应该是循迹小车?
如果你刚拿到一块STM32开发板,面对一堆外设和库函数感到无从下手,或者已经点了几遍LED灯、调通了串口,想找个综合性的项目把知识串起来,那循迹小车绝对是个完美的选择。这玩意儿听起来像玩具,但它几乎涵盖了嵌入式开发入门阶段你需要掌握的所有核心技能:GPIO控制、定时器PWM、外部中断、ADC采样,以及最关键的——如何将传感器数据转化为控制逻辑。更重要的是,它有一个明确、直观的反馈:小车能不能沿着黑线跑。成功与否,一目了然,这种即时正反馈对学习动力的提升是巨大的。
我见过太多初学者在裸机编程和RTOS之间反复横跳,或者沉迷于研究各种复杂的通信协议,结果越学越迷茫,最后板子吃灰。循迹小车这个项目,能帮你建立一个扎实的“手感”。你会真切地体会到,编程不是空中楼阁,每一行代码都对应着电机转速的变化和传感器的读数。当你调好PID参数,看着小车稳稳地压着黑线飞驰时,那种成就感是单纯点亮一个LED无法比拟的。接下来,我就以一个老电子爱好者的身份,带你完整地走一遍从元器件选型、电路搭建、代码编写到调试优化的全流程,避开那些我当年踩过的坑。
2. 硬件清单与核心模块选型:别在第一步就踩坑
做硬件项目,物料准备是基础。市面上循迹小车套件很多,但自己一件件采购、焊接,理解会更深刻。以下是核心清单和选型要点。
2.1 主控与电机驱动:动力与控制核心
1. STM32主控芯片对于循迹小车,STM32F103C8T6(俗称“蓝桥杯”或“最小系统板”)是性价比之王。它拥有足够的GPIO、定时器和ADC资源,价格亲民,社区资料海量。完全没必要一上来就追求F4、H7等高端系列,F103的性能对于循迹绰绰有余。
注意:购买开发板时,确认其引出了所有IO口,并且板载了ST-Link或留有SWD调试接口。自带Type-C供电和串口转换芯片的板子会方便很多。
2. 电机与驱动模块
- 电机:常用的是TT减速电机(直流减速电机)。参数关注工作电压(常用3-6V)和减速比。减速比越大,扭矩越大,速度越慢,对于循迹小车,1:48或1:120的都比较合适。
- 驱动芯片:最经典的是L298N或TB6612FNG。
- L298N:老牌劲旅,驱动能力强,但发热量大,需要散热片,逻辑电压和驱动电压需分开供电,电路稍复杂。
- TB6612FNG:更推荐。效率高、发热小,内置保护电路,支持PWM频率更高,逻辑电压与驱动电压可以相同,接线更简洁。它的一片芯片可以驱动两个电机。
选型建议:直接选择TB6612FNG电机驱动模块。它会让你的电源设计更简单,调试体验更好。
2.2 循迹传感器:小车的“眼睛”
这是循迹的核心。常见的有两种:
- TCRT5000红外反射传感器模块:最常用、最经济。它由一个红外发射管和一个接收管组成。发射管发出红外光,照射到不同颜色的地面(如白色反射强,黑色吸收强),接收管接收到的反射光强度不同,从而输出不同的电平(通常白线输出低电平,黑线输出高电平,具体看模块设计)。模块上通常有一个电位器,可以调节灵敏度。
- 灰度传感器:本质上也是红外反射原理,但有些型号输出的是模拟量(电压值),而非数字量(高低电平)。模拟量可以更精细地区分不同灰度,但需要主控的ADC来读取,增加了复杂度。
对于入门,强烈推荐使用4个或5个TCRT5000数字传感器模块一字排开。数字量直接用GPIO读取即可,简单可靠。5个传感器的布局可以提供更丰富的路径信息(如轻微偏左、严重偏右、十字路口等)。
2.3 电源系统:稳定大于一切
电源混乱是项目失败的一大元凶。你需要一个两路供电方案:
- 电机驱动供电:直接使用一块7.4V 2S锂电池。这是动力电源,接在TB6612FNG的VM引脚上。锂电池电量足、放电电流大,能保证电机瞬间提速时有劲。
- 控制系统供电:从锂电池引出电压,通过一个降压模块(如LM2596)降到5V。这个5V给STM32开发板、传感器模块和TB6612FNG的逻辑部分(VCC引脚)供电。
核心避坑点:绝对不要试图用USB线或者一堆碱性电池同时给电机和控制系统供电。电机启停的瞬间会产生巨大的电流波动和电压跌落,足以导致STM32复位或传感器误触发。物理上隔离动力电源和控制电源,是保证系统稳定的基石。
3. 电路连接与机械结构搭建:把想法变成实物
有了所有零件,就像有了乐高积木,现在需要把它们正确拼装起来。
3.1 电气连接图与接线表
下图清晰地展示了各模块间的连接关系:
flowchart TD subgraph P [电源部分] A[7.4V锂电池] --> B[DC降压模块 LM2596] B --> C[输出稳定5V] end subgraph Ctl [控制核心] D[STM32F103C8T6] end subgraph Sen [传感器阵列] E[TCRT5000 x5] end subgraph Drv [电机驱动] F[TB6612FNG模块] end subgraph Mot [执行机构] G[左轮电机] H[右轮电机] end C --> D C --> E C --> F[VCC逻辑供电] A --> F[VM电机供电] E -- GPIO输入 --> D D -- PWM & 方向控制GPIO --> F F --> G F --> H根据图示,具体接线可参考下表:
| 连接线 | 起点 | 终点 | 说明 |
|---|---|---|---|
| 电源正极(红) | 锂电池+ | TB6612 VM引脚、降压模块IN+ | 动力总输入 |
| 电源负极(黑) | 锂电池- | TB6612 GND、降压模块IN-、STM32 GND | 共地! |
| 5V输出(红) | 降压模块OUT+ | STM32 5V/VIN、TB6612 VCC、传感器VCC | 控制逻辑供电 |
| 5V地(黑) | 降压模块OUT- | STM32 GND、TB6612 GND、传感器GND | 控制逻辑共地 |
| 传感器信号线 | 传感器1 DO | STM32 PA0 | 根据你的布局定义引脚 |
| 传感器2 DO | STM32 PA1 | ||
| 传感器3 DO | STM32 PA2 | 中间传感器 | |
| 传感器4 DO | STM32 PA3 | ||
| 传感器5 DO | STM32 PA4 | ||
| 电机控制线 | STM32 PB6 (PWM) | TB6612 PWMA | 左电机PWM |
| STM32 PB7 (DIR) | TB6612 AIN1 | 左电机方向 | |
| STM32 PB8 (DIR) | TB6612 AIN2 | 左电机方向 | |
| STM32 PB9 (PWM) | TB6612 PWMB | 右电机PWM | |
| STM32 PB10 (DIR) | TB6612 BIN1 | 右电机方向 | |
| STM32 PB11 (DIR) | TB6612 BIN2 | 右电机方向 | |
| 电机动力线 | TB6612 A01/A02 | 左电机两极 | 极性决定正反转 |
| TB6612 B01/B02 | 右电机两极 |
接线关键提示:
- 共地!共地!共地!锂电池的GND、降压模块的GND、STM32的GND、驱动模块的GND、传感器的GND,必须全部连接在一起。这是电路正常工作的前提。
- 电机线先不接:在调试软件前,可以先不接电机线,避免程序错误导致电机乱转损坏设备。
- 传感器布局:将5个传感器模块紧密地一字排开,固定在小车底盘最前端。传感器间距应略小于黑线宽度,以确保任何时候至少有1个传感器能检测到黑线。
3.2 机械结构与装配心得
车架可以选择亚克力板、铝合金或者直接购买现成的智能小车底盘。装配时注意:
- 重心要低:电池等重物尽量放在底盘下方,降低重心,小车转弯时更稳,不易侧翻。
- 传感器高度可调:不要将传感器死死固定。最好能用铜柱、螺丝等让它的高度可调。传感器离地面太高,检测不到;太低,容易刮擦地面。最佳距离通常是1-2厘米,需要通过实验确定。
- 线材整理:用扎带或胶带将电线捆扎好,避免缠绕进车轮或齿轮。混乱的布线是后期调试的噩梦。
4. 软件开发环境与工程框架搭建
硬件准备就绪,我们进入软件世界。这里以Keil MDK(Keil5)和标准外设库为例。
4.1 创建工程与库文件管理
- 安装Keil MDK及STM32芯片包:确保你的Keil5安装了
Keil.STM32F1xx_DFP这样的设备支持包。 - 新建工程:选择芯片型号
STM32F103C8。 - 管理工程文件夹:不要把所有文件都堆在根目录。建议建立如下结构的文件夹:
Project/ ├── USER/ // 存放main.c, stm32f10x_it.c(中断文件), 等用户文件 ├── CORE/ // 存放CMSIS核心文件(如core_cm3.c, startup_stm32f10x_md.s) ├── FWLIB/ // 存放STM32标准外设库的所有.c和.h文件 ├── HARDWARE/ // 自己编写的模块驱动代码(如motor.c, sensor.c) └── SYSTEM/ // 一些公用代码(如sys.c, usart.c, delay.c) - 添加文件与头文件路径:在Keil的工程管理窗口中,将这些文件夹下的.c文件添加到对应的组里。在
Options for Target -> C/C++ -> Include Paths中,添加所有这些文件夹的路径。
4.2 基础驱动编写:GPIO、定时器与PWM
在HARDWARE文件夹下,我们创建两个基础驱动文件。
sensor.c- 红外传感器读取
#include "sensor.h" // 假设5个传感器接在PA0~PA4 #define SENSOR1_PIN GPIO_Pin_0 #define SENSOR2_PIN GPIO_Pin_1 #define SENSOR3_PIN GPIO_Pin_2 #define SENSOR4_PIN GPIO_Pin_3 #define SENSOR5_PIN GPIO_Pin_4 #define SENSOR_PORT GPIOA void Sensor_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = SENSOR1_PIN | SENSOR2_PIN | SENSOR3_PIN | SENSOR4_PIN | SENSOR5_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入模式 GPIO_Init(SENSOR_PORT, &GPIO_InitStructure); } // 读取传感器状态,返回一个5位的值,每一位代表一个传感器(1:检测到黑线,0:未检测到) u8 Sensor_Read(void) { u8 sensor_value = 0; if(GPIO_ReadInputDataBit(SENSOR_PORT, SENSOR1_PIN) == Bit_SET) sensor_value |= 0x01; if(GPIO_ReadInputDataBit(SENSOR_PORT, SENSOR2_PIN) == Bit_SET) sensor_value |= 0x02; if(GPIO_ReadInputDataBit(SENSOR_PORT, SENSOR3_PIN) == Bit_SET) sensor_value |= 0x04; if(GPIO_ReadInputDataBit(SENSOR_PORT, SENSOR4_PIN) == Bit_SET) sensor_value |= 0x08; if(GPIO_ReadInputDataBit(SENSOR_PORT, SENSOR5_PIN) == Bit_SET) sensor_value |= 0x10; return sensor_value; }这里使用上拉输入模式,当传感器未检测到黑线(白底)时,模块输出低电平,被上拉电阻拉高,读回Bit_RESET;检测到黑线时,模块输出高电平,读回Bit_SET。这样Sensor_Read()返回值的位为1就代表对应传感器在黑线上。
motor.c- TB6612电机PWM驱动
#include "motor.h" #include "stm32f10x_tim.h" // 引脚定义:左电机PWM-PB6, 方向-PB7/PB8;右电机PWM-PB9, 方向-PB10/PB11 void Motor_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; // 初始化方向控制引脚为推挽输出 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7 | GPIO_Pin_8 | GPIO_Pin_10 | GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); // 初始化PWM引脚为复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽输出 GPIO_Init(GPIOB, &GPIO_InitStructure); } void Motor_PWM_Init(u16 arr, u16 psc) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 开启TIM4时钟(PB6/PB7/PB8/PB9是TIM4的通道1/2) RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); // 定时器基础设置 TIM_TimeBaseStructure.TIM_Period = arr; // 自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler = psc; // 预分频系数 TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM4, &TIM_TimeBaseStructure); // PWM模式设置 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; // 通道1 (PB6 - 左电机) TIM_OC1Init(TIM4, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM4, TIM_OCPreload_Enable); // 通道4 (PB9 - 右电机) 注意:PB9是TIM4的通道4 TIM_OC4Init(TIM4, &TIM_OCInitStructure); TIM_OC4PreloadConfig(TIM4, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM4, ENABLE); TIM_Cmd(TIM4, ENABLE); } // 设置左电机速度和方向 void Motor_Left_Set(int speed) { if(speed >= 0) { // 正转 GPIO_SetBits(GPIOB, GPIO_Pin_7); GPIO_ResetBits(GPIOB, GPIO_Pin_8); TIM_SetCompare1(TIM4, speed); // 设置占空比 } else { // 反转 GPIO_ResetBits(GPIOB, GPIO_Pin_7); GPIO_SetBits(GPIOB, GPIO_Pin_8); TIM_SetCompare1(TIM4, -speed); } } // 设置右电机速度和方向 void Motor_Right_Set(int speed) { if(speed >= 0) { GPIO_SetBits(GPIOB, GPIO_Pin_10); GPIO_ResetBits(GPIOB, GPIO_Pin_11); TIM_SetCompare4(TIM4, speed); } else { GPIO_ResetBits(GPIOB, GPIO_Pin_10); GPIO_SetBits(GPIOB, GPIO_Pin_11); TIM_SetCompare4(TIM4, -speed); } }在Motor_PWM_Init函数中,arr和psc决定了PWM的频率。PWM频率 = 定时器时钟 / ( (arr+1) * (psc+1) )。对于直流电机,通常选择5kHz到20kHz的频率。频率太低电机会啸叫,太高则驱动芯片可能响应不了。假设系统时钟72MHz,设置arr=7199,psc=0,则PWM频率=72MHz / (7200 * 1) = 10kHz,这是一个常用值。
5. 循迹核心算法:从简单逻辑到比例控制
有了传感器数据和电机控制能力,现在需要编写“大脑”——循迹算法。
5.1 基础状态机与开关量控制
最直观的方法是根据5个传感器的状态,定义一个“位置误差”。
// 定义传感器位置权重,中间为0,两边对称 const int sensor_weight[5] = {-2, -1, 0, 1, 2}; int Calculate_Error(u8 sensor_val) { int error = 0; int active_sensor_count = 0; for(int i=0; i<5; i++) { if(sensor_val & (1<<i)) { // 如果第i个传感器检测到黑线 error += sensor_weight[i]; active_sensor_count++; } } if(active_sensor_count == 0) { // 所有传感器都没检测到黑线,可能是脱线了 return 100; // 返回一个很大的误差,触发特殊处理 } // 返回平均误差,使误差值归一化 return error / active_sensor_count; }这个Calculate_Error函数会根据哪几个传感器压线,计算出一个误差值。例如,只有最左边的传感器压线(二进制00001),误差为-2;只有中间传感器压线(00100),误差为0;右边两个传感器压线(11000),误差为(1+2)/2=1.5取整后为1。
有了误差,最简单的控制就是“bang-bang”控制(开关控制):
void Track_BangBang(int error) { int base_speed = 3000; // 基础速度,对应PWM比较寄存器的值 int turn_speed = 1000; // 转弯时的速度差 if(error == 100) { // 脱线处理:停车或原地旋转找线 Motor_Left_Set(0); Motor_Right_Set(0); return; } if(error < 0) { // 偏左,右转:左轮加速,右轮减速 Motor_Left_Set(base_speed + turn_speed); Motor_Right_Set(base_speed - turn_speed); } else if(error > 0) { // 偏右,左转:左轮减速,右轮加速 Motor_Left_Set(base_speed - turn_speed); Motor_Right_Set(base_speed + turn_speed); } else { // 居中,直行 Motor_Left_Set(base_speed); Motor_Right_Set(base_speed); } }这种方法实现简单,但小车运行起来会“画龙”,在黑白线边缘来回摆动,不流畅。
5.2 引入比例(P)控制实现平滑循迹
要让小车跑得稳,必须引入比例控制。核心思想是:控制量(左右轮速度差)与误差(偏离中心的程度)成正比。
void Track_P_Control(int error) { float Kp = 15.0; // 比例系数,需要调试 int base_speed = 3000; int turn_adjustment; // 转弯调整量 if(error == 100) { // 脱线处理策略可以更智能,比如记忆最后一次误差方向,向那个方向旋转 Motor_Left_Set(-1000); Motor_Right_Set(1000); delay_ms(100); // 旋转一下 return; } // 核心公式:调整量 = 误差 * 比例系数 turn_adjustment = (int)(Kp * error); // 限制调整量,防止过大 if(turn_adjustment > 2000) turn_adjustment = 2000; if(turn_adjustment < -2000) turn_adjustment = -2000; // 应用调整:左轮速度 = 基础速度 - 调整量;右轮速度 = 基础速度 + 调整量 // 当error为负(偏左),turn_adjustment为负,则左轮速度增加,右轮速度减少,实现右转 Motor_Left_Set(base_speed - turn_adjustment); Motor_Right_Set(base_speed + turn_adjustment); }比例系数Kp的调试技巧:
- 先将Kp设为0,让小车直行,观察它偏离黑线时,误差
error的变化范围(比如在-2到2之间)。 - 从小值开始(如Kp=5),逐渐增大。Kp太小,小车反应迟钝,拐弯时容易冲出去;Kp太大,小车会剧烈振荡,在直道上也左右摇摆。
- 理想的Kp是能让小车在直道上轻微、高频地微调,在弯道上能平滑地拐过去,没有明显的超调和振荡。
5.3 进阶:PID控制与误差处理优化
单纯的P控制可能存在静差(在弯道上无法完全回到中心线)或过冲。可以引入积分(I)和微分(D)项,构成完整的PID控制器。但对于很多场地,一个精心调校的P控制器或者PD控制器已经足够优秀。
更健壮的误差处理: 上面的简单平均法在传感器部分被遮挡或个别传感器损坏时不稳定。可以采用“加权重心法”:
int Calculate_Error_Robust(u8 sensor_val) { long weighted_sum = 0; int sensor_count = 0; // 传感器位置坐标,假设间距为10个单位 const int sensor_pos[5] = {-20, -10, 0, 10, 20}; for(int i=0; i<5; i++) { if(sensor_val & (1<<i)) { weighted_sum += sensor_pos[i]; sensor_count++; } } if(sensor_count == 0) { return 1000; // 脱线标志 } // 返回黑线位置的“重心” return (int)(weighted_sum / sensor_count); }这种方法计算出的误差是连续的坐标值,更精细。对应的Kp值也需要重新调整。
6. 系统集成、调试与性能优化
将各个模块组合起来,并在main.c中构建主循环。
6.1 主程序框架与状态管理
#include "stm32f10x.h" #include "sensor.h" #include "motor.h" #include "delay.h" #include "usart.h" // 用于调试打印 int main(void) { u8 sensor_state; int line_error; delay_init(); // 初始化延时函数 USART1_Init(115200); // 初始化串口,用于调试 Sensor_Init(); Motor_GPIO_Init(); Motor_PWM_Init(7199, 0); // 10kHz PWM printf("STM32 Tracking Car Start!\r\n"); while(1) { sensor_state = Sensor_Read(); line_error = Calculate_Error_Robust(sensor_state); // 打印调试信息(调试完成后可注释掉) printf("Sensor: 0x%02X, Error: %d\r\n", sensor_state, line_error); Track_P_Control(line_error); delay_ms(10); // 控制周期,10ms一次,即100Hz } }6.2 调试流程与常见问题排查
调试务必分步进行,不要一上来就期望小车完美运行。
第一步:传感器测试
- 将小车抬起,用手在传感器下方移动黑白纸片。
- 通过串口打印
Sensor_Read()的返回值。确认每个传感器都能正确输出0和1,并且灵敏度合适(电位器调节到在白纸上稳定输出0,在黑胶带上稳定输出1)。 - 常见问题:传感器输出不稳定,在白纸上也偶尔跳变。这通常是环境光干扰或传感器离地面太远/太近。可以尝试用热熔胶或遮光材料在传感器周围做一个“遮光罩”。
第二步:电机测试
- 将小车架空,轮子悬空。
- 编写一个测试函数,让两个电机分别正转、反转、停止。观察电机转向是否正确。特别注意:如果电机转向与预期相反,只需交换接到驱动模块A01/A02或B01/B02的两根线即可。
- 测试PWM调速是否平滑,从低速到高速。
第三步:开环调试(不循迹,只走固定路径)
- 编写一段程序,让小车直行3秒,左转1秒,右转1秒。
- 将小车放在地上,观察其行走路径是否笔直。如果不直,说明两个电机的机械性能或驱动有细微差异。可以通过在代码中给两个电机设置一个微小的“补偿值”来修正。
// 在设置速度时加入补偿 #define LEFT_COMPENSATE -50 // 左电机比右电机快,就给它减点速 Motor_Left_Set(target_speed + LEFT_COMPENSATE);第四步:闭环调试(正式循迹)
- 先让小车以很低的基础速度(如
base_speed=1000)运行,Kp设一个较小值(如5)。 - 观察小车在直道和弯道的行为。如果拐弯时冲出去,缓慢增大Kp;如果直道上左右摆动厉害,则减小Kp。
- 调整
base_speed。速度越快,对控制器的响应要求越高,需要更大的Kp或更快的控制周期。
6.3 性能优化与扩展思路
当基础循迹稳定后,可以尝试以下优化:
- 控制周期:尝试将
delay_ms(10)改为更短的时间(如5ms),可以提高系统响应速度。注意传感器读取和电机控制函数本身耗时很短,主要瓶颈在delay_ms。 - 动态速度:在直道上加速,进入弯道前减速。可以通过预测来实现:如果连续多次误差都很小(在直道上),则缓慢提高
base_speed;如果检测到误差突然变大(可能遇到急弯),则提前减速。 - 应对复杂路径:
- 十字路口:当检测到多个传感器(如中间三个)同时长时间压线,可以判断为十字路口,执行预设的直行或转弯逻辑。
- 断续线:如果黑线是断续的,需要在脱线状态(
error==1000)时,让小车按照脱线前的最后一个有效误差方向继续前进一小段距离,并加大找线的旋转幅度。
- 使用中断:可以将传感器检测放到外部中断中,或者使用定时器中断来严格定时执行控制算法,使系统时序更加精确。
循迹小车作为一个入门项目,其内涵可以非常深。从最基础的GPIO操作到闭环控制算法,从简单的状态机到可以应对复杂场面的决策逻辑,每一步的深入都能带来新的收获。当你调通第一版,看着它笨拙但坚定地跟着黑线前进时,恭喜你,你已经跨过了嵌入式开发实践的第一道重要门槛。接下来,你可以尝试给它加上蓝牙遥控、超声波避障、摄像头视觉,甚至移植到RTOS上,让它在更复杂的场景中游刃有余。