ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32四路循迹小车源码解析:PWM控制与差速转向实现

2026/9/16 17:45:53 拓冰建站 浏览量
STM32四路循迹小车源码解析:PWM控制与差速转向实现 简介面向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 左右、占空比可调的 PWMvoid 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 使能脚为高时才有输出IN1IN2左电机状态01正转10反转00停止11急停/刹车代码里切换方向就是一次 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拿到四路状态后常用做法是给每个传感器一个加权位置然后算加权误差。假设黑线为 1S1 在最左侧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推荐动作0x01S1压线-3大右转0x02S2压线-1小右转0x04S3压线1小左转0x08S4压线3大左转0x03S1/S2压线-4右转回中0x06S2/S3压线0直行0x0CS3/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。这种情况通常发生在直角弯、回形弯车速过快时小车冲出赛道四个探头全部离开黑线。这时直接按 error0 处理会直线冲出更远正确做法是保持上一次有效误差方向继续转弯一段时间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 DefineSTM32F10X_MD, USE_STDPERIPH_DRIVER外设函数“未定义”Include Path标准库 Inc/src 路径找不到头文件Debug 选择ST-LinkSW 模式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和实际过弯是否稳定的关系记在调试表里下一次改机械结构或换电机时可以快速确定是机械瓶颈还是控制参数问题。本文还有配套的精品资源点击获取