简介:面向STM32初学者的四路循迹小车完整程序源代码,适合正在做课程设计或准备智能车竞赛的开发者。方案以STM32为主控芯片,利用片内定时器产生PWM信号驱动L298N电机模块,并通过四路循迹传感器实时检测黑白赛道偏差,实现自动循迹功能,思路清晰、注释明确,便于二次修改。压缩包共199个文件,文件中既包含大量.c与.h源码、外设库文件,也有.uvprojx工程文件、.hex烧录文件、.axf链接映射等编译产物,以及htm网页说明、url快捷方式等辅助内容,整体约5.06MB,结构紧凑。已有6043人学习下载,属于社区中较热门的STM32小车入门方案。入手后可直接用Keil打开工程查看各模块代码,重点学习PWM调速、GPIO输入检测、循迹逻辑判断等关键部分,并能据此快速搭建自己的四路循迹小车,为后续增加蓝牙遥控、超声波避障、速度闭环等扩展功能奠定基础。
1. 四路循迹小车为什么不只是“绕线走”
很多第一次做循迹小车的人认为四路循迹只是比两路多了两个传感器,跑起来更“宽心”。实际跑一场会发现:四路循迹真正的问题是数据变多了、判定也变复杂了,它不再回答“偏没偏”,而是回答“偏了多少、弯有多急、现在是进了十字还是丢了线”。这份 STM32 循迹小车程序源代码基于标准外设库,电机由定时器 PWM 控制、驱动级用 L298N,传感器端是四路循迹输入。适合正在做课设或想从 HAL 库切回标准库、自己掌控寄存器细节的开发者。下面按“电机控制 → 传感器策略 → 工程编译 → 调参”四层拆解,每一层都能在源码里找到对应文件。
2. 定时器 PWM 与 L298N:先让电机转起来
2.1 从源码文件看电机控制在哪一层
源码包里能看到stm32f10x_tim.c、system_stm32f10x.c、stm32f10x_rcc.c这类文件,它们并不是随手灌进来的模板。stm32f10x_rcc.c负责把 APB1/APB2 外设时钟打开,stm32f10x_tim.c提供定时器初始化与 PWM 输出接口,system_stm32f10x.c在进入 main 前完成系统时钟设置。也就是说,一个可用的电机控制链路至少包含三件事:时钟、定时器、GPIO 复用。这三者缺一个,电机表现通常不是完全不转,而是“忽转忽停”或“上电转一下就没反应”。
常见做法是用 TIM2/TIM3/TIM4 的某一组通道输出两路 PWM,分别接到 L298N 的 ENA 和 ENB 使能引脚。这样占空比直接决定左右电机的平均电压,也就是速度。还要注意标准库和 HAL 库的命名差异:标准库里设置占空比用TIM_SetCompare1,不是__HAL_TIM_SET_COMPARE;如果代码是从 HAL 工程搬过来的,编译会首先在这一层报错。
2.2 定时器 PWM 初始化代码与参数计算
下面这段是标准外设库的初始化方式,适用于 STM32F103 系列,核心目的是输出 1kHz 左右、占空比可调的 PWM:
void Motor_Timer_Init(void) { TIM_TimeBaseInitTypeDef tim; TIM_OCInitTypeDef oc; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); tim.TIM_Prescaler = 71; // 72MHz / 72 = 1MHz 计数频率 tim.TIM_CounterMode = TIM_CounterMode_Up; tim.TIM_Period = 999; // 1MHz / 1000 = 1kHz PWM tim.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &tim); oc.TIM_OCMode = TIM_OCMode_PWM1; // 计数值小于 Pulse 时输出有效电平 oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_Pulse = 0; // 初始占空比 0% oc.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &oc); // TIM3_CH1 输出左电机PWM TIM_OC2Init(TIM3, &oc); // TIM3_CH2 输出右电机PWM TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE); // 使能TIM3 }逻辑说明:预分频器写 71,实际分频值是寄存器值加 1,也就是 72 分频。72MHz 除以 72 后得到 1MHz 计数频率,自动重载值设 999,所以 PWM 频率是 1kHz。这个频率对直流减速电机比较合适,低于 100Hz 会有明显“嗡嗡”声且扭矩波动大,高于 10kHz 反而会让 L298N 的开关损耗变大。占空比通过修改TIM3->CCR1或调用TIM_SetCompare1实现,数值范围从 0 到 999。
用 PWM 调制后,车速并不是线性对应占空比的。减速电机内部有减速箱摩擦和电枢电阻,占空比低于某个值(常见是 20% 到 30%)时电机根本不会转。这个值通常写入程序里的MOTOR_DEAD_ZONE,初始化时就限制最小输出。源代码里虽然没有单独列出来,但凡是能在赛道稳定跑起来的小车,一定在速度赋值处做过这种限幅。
2.3 L298N 方向引脚与共地问题
L298N 的 IN1~IN4 决定 H 桥导通方向,ENA/ENB 控制使能。一个常见接法是:IN1/IN2 接 PB0/PB1 控制左电机,IN3/IN4 接 PB10/PB11 控制右电机;ENA/ENB 接定时器 PWM 输出。方向引脚不需要模拟量,普通推挽 GPIO 即可:
void Motor_GPIO_Init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); gpio.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_10 | GPIO_Pin_11; gpio.GPIO_Mode = GPIO_Mode_Out_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &gpio); // TIM3_CH1/CH2 若使用默认引脚 PB4/PB5,需要单独复用配置 gpio.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5; gpio.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOB, &gpio); }这里要特别说明:AF_PP是给定时器通道复用输出用的,方向引脚用Out_PP。如果把 PWM 引脚也设成Out_PP,在标准库下也能看到电平变化,但输出极性、复用关系和引脚映射不受定时器控制,实际会乱。另外,L298N 的逻辑电源和单片机必须共地,否则 IN 引脚高电平没有参考地,可能出现“PWM 有值但电机纹丝不动”。实践里我会先用万用表量 ENA 对 GND 的电压,如果测不到稳定方波,再反查通道配置。
L298N 方向逻辑可以用下面这张表快速核对,IN1/IN2 控制左电机,IN3/IN4 控制右电机,PWM 使能脚为高时才有输出:
| IN1 | IN2 | 左电机状态 |
|---|---|---|
| 0 | 1 | 正转 |
| 1 | 0 | 反转 |
| 0 | 0 | 停止 |
| 1 | 1 | 急停/刹车 |
代码里切换方向就是一次 GPIO 赋值,比如GPIO_SetBits(GPIOB, GPIO_Pin_0)配合GPIO_ResetBits(GPIOB, GPIO_Pin_1)。建议不要直接在 main 里重复写寄存器,封装成Motor_SetDirection(left_dir, right_dir)后,后续循迹策略只要关心“左转、右转、前进”三个动作即可。
3. 四路循迹传感器的阈值判定与转向策略
3.1 四个传感器如何排列、如何定义线内线外
四路循迹通常用四路红外反射模块,最常见的是 TCRT5000 类型的对射式光电头配合 LM393 比较器输出数字电平。传感器从左到右记为 S1、S2、S3、S4,间距要和小车车轮轮距匹配。间距太小,弯道内沿容易同时压线;间距太大,细线状态下小车会左右摆头。我的经验是传感器中心距取 1.5 到 2.5 厘米之间,具体看导轨线宽和车宽。
数字输出电平不同模块定义不一样,有的黑线输出高电平,有的白线输出高电平。程序里不能把电平写死,应该先定义一个“线值”宏,例如黑线为 1、白线为 0:
#define LINE_ON 1 // 传感器检测到黑线时的电平四路循迹比两路更能分辨“偏心程度”:两路只能得到偏左、偏右、在中间三种结果,四路可以把偏移量划分成五档。偏差档位并不是越多越好,反正最终都要映射到左右电机的占空比差值。这里先把四路输入读成一个 4 位状态值,再做策略切换。
3.2 读取状态的代码与消抖处理
读取四路 GPIO 并打包成状态字节:
uint8_t Track_Read(void) { uint8_t val = 0; // 四个传感器按 S1-S4 接到 PA0-PA3 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == LINE_ON) val |= 0x01; if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_1) == LINE_ON) val |= 0x02; if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_2) == LINE_ON) val |= 0x04; if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_3) == LINE_ON) val |= 0x08; return val; }逻辑说明:val的 bit0 到 bit3 对应 S1 到 S4。这样后续判断只要做位运算,比如if (val == 0x01)表示最左边的传感器压线。实际赛道环境有环境光干扰和传感器迟滞,我会连续读两次,两次结果一致才采用:
uint8_t track_val; do { track_val = Track_Read(); } while (track_val != Track_Read()); // 直到两次一致注意这个do while在高速循迹时可能阻塞主循环,所以真实代码里可以改成“记录上次结果,连续第二次与上次相同则更新”。消抖时间本质上是给传感器和比较器一个稳定输出的时间,一般几十微秒就够,不用靠delay_ms硬等。
3.3 加权误差与差速转向:从状态值到 PWM
拿到四路状态后,常用做法是给每个传感器一个加权位置,然后算加权误差。假设黑线为 1,S1 在最左侧,S4 在最右侧:
int error = 0; if (val & 0x01) error += -3; // S1 压线,说明车偏右,需要右转 if (val & 0x02) error += -1; // S2 压线,轻微偏右 if (val & 0x04) error += 1; // S3 压线,轻微偏左 if (val & 0x08) error += 3; // S4 压线,说明车偏左,需要左转这个error的范围是 -7 到 7,越接近 0 表示轨迹越居中。四路循迹的优势在这里体现:不仅知道偏了,还知道偏的幅度。两路方案只能输出 -1、0、1 三个值,做 PID 时反馈精度不够,速度稍快就会振荡。
| val | 线位 | error | 推荐动作 |
|---|---|---|---|
| 0x01 | S1压线 | -3 | 大右转 |
| 0x02 | S2压线 | -1 | 小右转 |
| 0x04 | S3压线 | +1 | 小左转 |
| 0x08 | S4压线 | +3 | 大左转 |
| 0x03 | S1/S2压线 | -4 | 右转回中 |
| 0x06 | S2/S3压线 | 0 | 直行 |
| 0x0C | S3/S4压线 | +4 | 左转回中 |
| 0x00 | 全离线 | 保持上次 | 记忆过弯 |
| 0x0F | 全压线 | 0 | 横线直行 |
有了误差,接着做差速转向。典型代码是:
int speed_diff = (int)(kp * error + kd * (error - last_error)); int left_speed = base_speed - speed_diff; int right_speed = base_speed + speed_diff; // 限幅,避免直接写 0 或超过 ARR if (left_speed < 0) left_speed = 0; if (right_speed < 0) right_speed = 0; if (left_speed > 900) left_speed = 900; if (right_speed > 900) right_speed = 900; TIM_SetCompare1(TIM3, left_speed); TIM_SetCompare2(TIM3, right_speed); last_error = error;参数说明:base_speed是直道目标速度,speed_diff是左右轮差速量。kp控制转向强度,kd控制误差变化率,防止在黑白线交界处来回切换造成车头摆动。对于四路循迹,我一般先把kd设为 0,只调kp;等小车出现持续振荡再逐步加kd。left_speed和right_speed用的是占空比寄存器值,不是百分比。公式里把speed_diff直接加到两个电机上,符合两轮差速小车的控制模型:转向时外侧轮加速、内侧轮减速,差速越大转弯半径越小。
3.4 全白、全黑与十字路口的处理
四路循迹最常见的故障状态是四个传感器全无信号,也就是val == 0x00。这种情况通常发生在直角弯、回形弯车速过快时,小车冲出赛道,四个探头全部离开黑线。这时直接按 error=0 处理会直线冲出更远,正确做法是保持上一次有效误差方向继续转弯一段时间:
if (val == 0x00) { if (last_error > 0) { left_speed = base_speed * 0.5; right_speed = base_speed; } if (last_error < 0) { left_speed = base_speed; right_speed = base_speed * 0.5; } TIM_SetCompare1(TIM3, left_speed); TIM_SetCompare2(TIM3, right_speed); return; }另一个状态是val == 0x0F,四路全亮或全黑,通常是压过竖直交叉线。如果小车需要通过十字路口,这个状态应该继续直行,而不是按“严重偏移”处理。部分竞赛会把十字路口当作禁停区,这时可以设定一旦进入 0x0F 状态就固定直行 200ms。用定时器计时或状态机实现都比在循环里delay更可靠。可以额外加一个“重复感应”判断:同一段 0x0F 如果短时间内出现两次,多半是进入圆环或绕圈,此时要触发计数而不是直行。
这里的策略已经接近比赛级调法。再往下,就需要把四路状态、error 和 PWM 占空比通过串口或显示屏观察,才能判断是传感器装歪了还是 PID 系数不合适。
4. 把源码包做成可编译、可烧录的 STM32 工程
4.1 标准外设库源码包里哪些文件是必须的
这个工程源码里能看到stm32f10x_tim.c、stm32f10x_flash.c、stm32f10x_rcc.c、stm32f10x_adc.c、stm32f10x_i2c.c、stm32f10x_can.c、stm32f10x_usart.c和system_stm32f10x.c。这不是随便一堆同名文件,而是标准外设库的组成部分。stm32f10x_rcc.c用来打开外设时钟,stm32f10x_tim.c提供 PWM 和定时器接口,stm32f10x_usart.c负责调试串口,stm32f10x_adc.c可以扩展电池电压检测或传感器模拟量采样。stm32f10x_can.c和stm32f10x_i2c.c在这个项目里不一定会被调用,但如果工程文件已经包含,链接时也不会报错。
启动流程上,system_stm32f10x.c里的SystemInit()会在main之前被启动文件调用,默认把系统时钟配置到 72MHz。很多移植出问题的人把 main 里的外设初始化删得只剩自己的代码,结果 RCC 没配好,定时器 PWM 输出频率完全不对。标准库工程要能跑,至少保留启动文件、system_stm32f10x.c、stm32f10x_rcc.c、stm32f10x_gpio.c、stm32f10x_tim.c以及misc.c。misc.c里是 NVIC 配置函数,用中断时必须有。
提示:
stm32f10x_can.c和stm32f10x_i2c.c在这个项目里即使不调用也不会影响编译,但如果自己新建工程,建议只添加用到的外设文件,避免链接时间变长。
4.2 Keil 工程配置与常见编译错误
Keil 工程要正确编译,有两个预定义宏不能少:STM32F10X_MD和USE_STDPERIPH_DRIVER。STM32F10X_MD对应中等容量 STM32F103 芯片;如果是大容量芯片要改成STM32F10X_HD。USE_STDPERIPH_DRIVER让标准外设库真正启用函数接口,不定义这个宏,.c文件里很多外设函数会直接被条件编译跳过。芯片选型错误最常见的现象是:代码编译通过,但下载时报No STM32 target found!,或能识别芯片但写入后程序不运行。
如果用 CubeMX 自动生成工程后想更换单片机型号,建议重新配置时钟树,而不是在标准库里直接改芯片宏。下面是我检查 Keil 工程时常用的核对表:
| 检查项 | 推荐配置 | 出错表现 |
|---|---|---|
| Device 型号 | STM32F103C8/C6 | 烧录后外设寄存器地址错乱 |
| C/C++ Define | STM32F10X_MD, USE_STDPERIPH_DRIVER | 外设函数“未定义” |
| Include Path | 标准库 Inc/src 路径 | 找不到头文件 |
| Debug 选择 | ST-Link,SW 模式,1MHz | 找不到 Cortex-M 设备 |
| Flash 下载算法 | 对应 F103 的 64KB/128KB 算法 | 写入校验失败 |
如果用的是 Keil5,安装 STM32 芯片包是第一步,否则 Device 列表里根本没有 STM32F103C8。芯片包装好后,工程里不需要再添加虚拟外设库文件,直接在 Include Path 指到标准库目录就行。还有一个坑:stm32f10x.h里默认没有定义STM32F10X_MD,如果在 C/C++ 宏里漏写,编译时会提示类似assert_param未定义,或者外设时钟函数不生效。遇到这种问题先看头部的条件编译。
4.3 烧录与串口调试输出
调试阶段最好把传感器状态和 PWM 值通过串口打印出来。标准库里用 USART1 发送一个字节可以写得很直接:
void Usart1_SendByte(uint8_t b) { USART_SendData(USART1, b); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); } void Usart1_Report(uint8_t track_val, int left, int right) { uint8_t buf[4]; buf[0] = track_val; buf[1] = (uint8_t)(left >> 2); // 压缩到 8bit 方便查看 buf[2] = (uint8_t)(right >> 2); buf[3] = '\n'; for (int i = 0; i < 4; i++) Usart1_SendByte(buf[i]); }逻辑说明:把传感器状态和左右占空比打包成 4 字节,用串口助手按 HEX 查看。只看 ASCII 数字容易因为刷新太快刷屏,反而抓不到瞬间状态。如果用 ST-Link 调试器,可以直接在 Keil 的 UART 窗口看 printf 输出,但需要工程里实现fputc重定向,否则 printf 看不到东西。
遇到stm32 st-link utility能擦除但 Keil 下载失败时,优先检查 SWDIO/SWCLK 是否被代码占用。很多 STM32 循迹小车把 USART1 和 SWD 引脚混在一起,或者把复用功能开到了 PA13/PA14,导致连接不稳定。必要时可以在工程初始化代码里加一段 GPIO 解锁 SWD,但更好的做法是一开始就不把 JTCK-SWCLK/JTMS-SWDIO 配置成普通 IO。这条排错顺序要比反复换调试器更有效。
5. 掉线、振荡与速度匹配:四路循迹的收尾调参技巧
5.1 用串口先校准阈值,再调 PD 参数
把四个传感器分别放在黑线和白纸上,记录Track_Read()输出的状态字。理想情况是黑线上只有对应位为 1,白纸上为 0;如果某一路始终是 1 或始终是 0,先检查模块上的电位器,而不是先改代码。数字量模块的阈值电位器顺时针调大不意味灵敏度变高,它调整的是比较器翻转点,调到黑线输出稳定为 1、白线输出稳定为 0 即可。校准后把每一路的翻转时间记录下来,用高速时追传感器跟不上作为换线的信号。
5.2 高速直角弯掉线的两个兜底方案
小车进入直角弯时经常出现“四个探头全部离线”,这时除了保持上次误差外,还可以按时间分档:离开黑线 50ms 内保持原差速,超过 100ms 还没有恢复线就降速并反向搜索。把这段逻辑以状态机方式放进主循环,比在主循环里写死if更稳定。具体实现是记录进入全白时刻t_lost,每次循环判断t_lost与当前SysTick的差;超过阈值就降低base_speed。这个方案不会让小车在 S 形连续弯道里因短暂离线而彻底冲出赛道。
5.3 用相邻传感器触发间隔推算弯道曲率
最后给一个提升一档速度的技巧:记录相邻两次中线传感器状态变化的时间差,用来估算弯道曲率。直道时 S2/S3 连续被触发,时间差很小;大弯道时有一侧传感器提前压线,时间差变大。程序里维护一个last_change_tick,在每次检测到val从0x02跳到0x04或反向时更新:
uint32_t now = get_tick_ms(); uint32_t dt = now - last_change_tick; last_change_tick = now; if (dt < 120) base_speed = 620; // 直道 else if (dt < 250) base_speed = 480; // 中弯 else if (dt < 400) base_speed = 350; // 大回头弯 else base_speed = 260; // 发卡弯get_tick_ms()可以用 SysTick 实现,每次进入循环读一次。这个方法比固定速度跑完一整圈更有用,因为它不需要地图信息,纯粹根据当前车的运动状态动态调整基准速度。这套误差计算与差速更新逻辑,后续换摄像头或 K210 视觉循迹时也能复用,只需把传感器状态改成视觉偏移量。调完后把dt和实际过弯是否稳定的关系记在调试表里,下一次改机械结构或换电机时,可以快速确定是机械瓶颈还是控制参数问题。
本文还有配套的精品资源,点击获取