简介:针对STM32四路循迹小车开发场景,这份源代码工程包面向电子竞赛、课程设计与入门进阶开发者,解决基于定时器PWM和L298N驱动的循迹控制实现问题。工程采用标准外设库编写,包含主控逻辑、电机驱动、四路循迹采样及中断服务等模块,可直接移植到常见STM32F103开发板。包体共199个文件、5.06MB,以.c源文件、.h头文件、Keil编译中间文件(.crf/.o/.axf)及工程配置(.uvprojx/.uvoptx)为主,并附带hex烧录文件与调试映射文件,便于查看构建结果与硬件调试。当前已有6043人浏览学习,适合正在搭建小车项目或希望快速理解PWM调速与循迹算法结合的读者。资源价值体现在完整工程脉络清晰:从定时器配置、L298N逻辑控制到四路传感器数据判定均有对应代码;生成的hex文件可烧录验证,sct/lnp等文件辅助分析链接布局,帮助读者结合硬件实际调参,节省从零搭建的时间。
1. 四路循迹不是多两个传感器那么简单
手上有过两路循迹小车的人,大概率都遇到过同一个问题:车跑快了就冲线,弯道一急就丢线,丢线之后到处乱转,像没头苍蝇一样。四路循迹在硬件上只是从两个探头变成四个,但它在程序结构上改变的是一件事——把“有无偏差”的判断,升级成“偏差多大、往哪偏”的量化感知。这直接决定了你能不能用PID去调转向,也决定了小车在高速和急弯下还能不能稳住线。
这篇内容面向的是已经在用STM32标准库或HAL库做过基本GPIO、定时器操作,想把手上的四路循迹小车跑顺的开发者。我会把传感器选型、阈值标定、主循环逻辑、中断式采样这几个环节串起来,给出可以直接抄进工程的核心代码,并解释每个参数为什么这么设、改大了会怎样、改小了又会出现什么现象。那些网上流传的“四路循迹源代码”大多只有main.c里的一个while(1)死循环,真正决定循迹上限的标定和滤波,反而被忽略了。
这里先给出一个反直觉的结论:四路循迹小车的程序质量,不取决于你写了多少行控制代码,而取决于你给控制代码喂进去的数据干不干净。传感器读到的不是线,是反射回来的红外光强度,而强度是随环境光、地面材质、电池电压一起漂移的。所以下面第一件事,先把四路传感器的信号调理和阈值标定讲透,再谈控制。
2. 四路循迹传感器选型和输出信号处理
2.1 数字量输出与模拟量输出的取舍
目前市面上常见的四路循迹模块,按照输出形态分为数字量输出和模拟量输出两大类。数字量模块(例如TCRT5000加LM393比较器)在模块上就把红外对管的输出和可调电位器的阈值做了比较,直接输出0或1。这种模块的好处是STM32端只需要配置GPIO输入模式,读电平即可,节省ADC资源,也不需要滤波——比较器本身就完成了一次二值化。
模拟量输出模块则是把光敏三极管的电压直接引出,需要STM32的ADC去采样。模拟量方案有一个数字量方案难以替代的优势:你可以拿到连续的灰度值,从而在阈值附近做出“这条线距离中心偏移了多少”的连续估计。这在做舵机转向或PID闭环时非常有帮助。但代价是代码复杂度和调试工作量都会上升,你需要处理参考电压、ADC采样滤波、阈值漂移等一系列问题。
我给一个适合绝大多数人的选择标准:如果你做的是教学演示、课程设计、竞赛选拔车,优先选数字量四路模块,原因很简单——稳定性和可解释性最好,出问题排查容易。如果你做的是需要高速过弯的竞赛车,且赛道线的对比度较差,这时模拟量模块配合ADC连续采样才能压榨出极限过弯性能,但这条路需要配合示波器或串口绘图逐项调,不是改几行代码就能见效的。
2.2 GPIO输入模式配置与上下拉电阻策略
四路循迹模块接到STM32上,通常占用四个GPIO引脚。常见的接法是PA0~PA3或PB0~PB3,具体看你的PCB设计或杜邦线方便程度。配置代码要特别注意一点:模块在检测到黑线时输出的电平逻辑并不统一,有的模块是“检测到黑线输出低电平”,有的是“检测到黑线输出高电平”,完全取决于模块上的比较器接法。所以代码里不要想当然,先把模块放在白底上读一次电平,再放在黑线上读一次电平,确认逻辑极性后再写判断条件。
GPIO模式推荐配置为输入模式,并启用内部上拉或下拉。以标准库代码为例:
GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure);这里选择上拉输入,原因是TCRT5000这类红外对管模块在未检测到反射(黑色表面)时,光敏三极管截止,输出引脚处于高阻态,上拉电阻会把电平拉高。而在白色表面反射强,三极管导通,输出被拉低。这样配置后,读取到低电平代表白底、高电平代表黑线,逻辑更自然适配对黑线检测。
如果你用的是开漏输出的模块,则必须启用内部上拉,否则引脚电平悬浮,读到的数据会随机跳变。确认模块是否开漏,最简单的办法是看原理图,或者用万用表量一下模块在检测状态下的输出电压——如果高电平只有零点几伏,说明没有内部上拉,必须依赖MCU的内部上拉电阻。
2.3 去抖滤波与采样时序
GPIO直接读到的二值信号看似干净,实际上在阈值边界附近会出现抖动。物理原因是红外对管的模拟输出在黑白交界处是渐变过渡的,比较器在过渡区域会反复翻转。尤其是在小车高速行驶时,抖动更剧烈,主循环读到的一瞬间可能正好落在翻转毛刺上。处理方式有两种:软件延时去抖和连续采样投票。
软件延时去抖最简单,读电平后延时1~5毫秒再读一次,两次一致才采信。这种做法的代价是增加主循环阻塞时间,对高速循迹不可取。连续采样投票则更适合:连续读取8次,超过5次为高才算高,否则视为低。投票窗口在时间上天然等效于一个低通滤波器,可以有效消除毛刺,且不会产生额外延时阻塞。
代码实现如下:
uint8_t Sensor_ReadFiltered(GPIO_TypeDef* port, uint16_t pin) { uint8_t high_count = 0; for (uint8_t i = 0; i < 8; i++) { if (GPIO_ReadInputDataBit(port, pin)) high_count++; } return (high_count >= 5) ? 1 : 0; // 超过5次为高才判定为高 }这个函数的采样过程虽然有8次连续读,但GPIO_ReadInputDataBit的指令周期只有几十个时钟周期,8次累加起来是微秒级别,对控制循环的影响可以忽略。参数5是半数阈值,如果你想更灵敏,改成3;想更稳定,改成6或7。注意灵敏度与抗干扰是矛盾的,不要同时追求两者。
2.4 阈值标定的正确姿势:先采集、后设定
数字量模块上的电位器就是阈值调节旋钮,用螺丝刀调节可以改变比较器的参考电压,以适应不同地面。网上很多教程让你“边调边试”,实际上这个方法效率极低。正确的做法是:把模块固定在小车上,放在赛道白底上方,用串口把GPIO原始电平打印出来,同时调节电位器直到模块输出稳定在某个逻辑值。然后把模块移到黑线上方,再次调节,直到输出稳定到相反逻辑。这样反复几次,找到黑白两种状态下都最稳定的旋钮位置。
串口打印调试代码可以用一个定时器循环发送四个传感器状态:
uint8_t s1 = Sensor_ReadFiltered(GPIOA, GPIO_Pin_0); uint8_t s2 = Sensor_ReadFiltered(GPIOA, GPIO_Pin_1); uint8_t s3 = Sensor_ReadFiltered(GPIOA, GPIO_Pin_2); uint8_t s4 = Sensor_ReadFiltered(GPIOA, GPIO_Pin_3); char buf[32]; sprintf(buf, "S1=%d S2=%d S3=%d S4=%d\r\n", s1, s2, s3, s4); UART_SendString(buf);把波特率设为115200,用串口助手观察打印内容。注意,串口打印本身会消耗时间,如果在主循环里每轮都打印,会严重影响循迹频率。建议调试阶段用一个空闲定时器,例如每100毫秒打印一次,等调好之后再把串口打印关掉或启用宏控制。
标定完毕的另一个重要输出是“阈值记录”。在你写文章、交设计报告或后续复现时,把黑白两态下的电平值、电位器大致角度、地面颜色材质记录下来。因为红外反射率与地面颜色强相关,深色木地板与白色瓷砖上的最优阈值完全不同,换场地就得重新标定。这是很多人忽略了上赛场就翻车的核心原因。
3. 四路循迹的程序架构与核心控制逻辑
3.1 状态量定义:四种传感器组合映射到九种状态
四路传感器从左到右排列,依次记为S1、S2、S3、S4。将每个传感器读到的二进制值编码成一个4位整数,可以定义一个查表结构,把二进制组合映射到语义状态。这里约定:读到黑线为1,白底为0。
四路传感器可能产生的组合有16种,但实际有效的只有约9种典型状态:
#define S1 GPIO_Pin_0 #define S2 GPIO_Pin_1 #define S3 GPIO_Pin_2 #define S4 GPIO_Pin_3 uint8_t GetLineState(void) { uint8_t s1 = Sensor_ReadFiltered(GPIOA, S1); uint8_t s2 = Sensor_ReadFiltered(GPIOA, S2); uint8_t s3 = Sensor_ReadFiltered(GPIOA, S3); uint8_t s4 = Sensor_ReadFiltered(GPIOA, S4); uint8_t code = (s1 << 3) | (s2 << 2) | (s3 << 1) | s4; switch (code) { case 0b0000: return STATE_LOST_ALL; // 全白,丢线 case 0b0001: return STATE_FAR_RIGHT; // 仅S4压线,右偏严重 case 0b0011: return STATE_RIGHT; // S3、S4压线,右偏 case 0b0110: return STATE_CENTER; // S2、S3压线,正中 case 0b0100: return STATE_CENTER_LEFT; // 仅S2压线,微偏左 case 0b0010: return STATE_CENTER_RIGHT; // 仅S3压线,微偏右 case 0b1100: return STATE_LEFT; // S1、S2压线,左偏 case 0b1000: return STATE_FAR_LEFT; // 仅S1压线,左偏严重 case 0b1111: return STATE_LOST_ALL; // 全黑,压在线宽过大的位置 case 0b0111: return STATE_LEFT; // S1白其余黑,按左偏处理 case 0b1110: return STATE_RIGHT; // S4白其余黑,按右偏处理 default: return STATE_UNKNOWN; } }这段代码的逻辑本质是把传感器的二进制组合映射成一个语义枚举。case 0b0111和0b1110这两个组合需要特别解释一下——它们表示三个传感器同时压线,通常出现在小车完全骑在粗线上或者过十字交叉点的时候,此时按单侧偏转处理比按丢线处理更不容易引起振荡。
用switch-case而不是if-else链,一方面是因为状态映射逻辑更清晰,另一方面是编译后的跳转表效率更高。在8MHz主频下,这种差异微乎其微,但从代码可维护性角度看,switch-case明显更优。
3.2 状态机设计:从状态到PWM输出
识别到状态之后,下一步是把状态转化为电机PWM输出。常用的做法是“状态直接映射”,也就是每个状态对应一组固定的左右轮占空比值。这种方式实现简单、调参直观,适合大多数教学项目和入门竞赛。
typedef struct { uint8_t state; uint16_t left_pwm; uint16_t right_pwm; } LineAction; LineAction action_table[] = { {STATE_FAR_LEFT, 180, 400}, // 左偏严重:左轮反转减速,右轮加速 {STATE_LEFT, 250, 400}, // 左偏:左轮降低,右轮保持 {STATE_CENTER_LEFT, 300, 400}, // 微偏左:左轮小幅降低 {STATE_CENTER, 400, 400}, // 正中:全速直行 {STATE_CENTER_RIGHT, 400, 300}, // 微偏右:右轮小幅降低 {STATE_RIGHT, 400, 250}, // 右偏:右轮降低,左轮保持 {STATE_FAR_RIGHT, 400, 180}, // 右偏严重:右轮反转减速,左轮加速 {STATE_LOST_ALL, 340, 340}, // 丢线:降速直行,等待重新找线 };PWM值范围取决于你的定时器配置。以STM32F103C8T6为例,如果TIM2的ARR设置为999,PWM占空比寄存器范围就是0~999,这里的400大约相当于40%占空比。250约25%,180约18%。具体的绝对数值不重要,重要的是相对关系——左右轮的差值决定转向力度,差值偏大则转向猛但容易摇摆,差值偏小则转向柔但弯道过不去。
表驱动的好处在于:你不会在主循环里写一长串if-else来判断状态,调参时只需要修改action_table中的数字即可,不同赛道特性调起来效率差很多。
3.3 PID版控制与直接映射的适用边界
状态直接映射的局限在于:转向是离散的。从STATE_CENTER_LEFT切到STATE_CENTER时,左轮占空比从300跳回400,这个阶跃会让小车产生抖动。如果要让转向过渡更平滑,就要引入PD控制器,用“偏差量”作为输入,输出一个连续的PWM修正量。
偏差量的定义方式:把四个传感器位置映射到一条数轴上。例如S1对应-3,S2对应-1,S3对应+1,S4对应+3,将压线的传感器位置的加权和作为当前偏差值。
int16_t GetLineError(void) { uint8_t s1 = Sensor_ReadFiltered(GPIOA, S1); uint8_t s2 = Sensor_ReadFiltered(GPIOA, S2); uint8_t s3 = Sensor_ReadFiltered(GPIOA, S3); uint8_t s4 = Sensor_ReadFiltered(GPIOA, S4); int16_t error = 0; uint8_t count = 0; if (s1) { error -= 3; count++; } if (s2) { error -= 1; count++; } if (s3) { error += 1; count++; } if (s4) { error += 3; count++; } if (count == 0) return 0; // 全丢线时认为偏差为0,靠其他逻辑处理 return error; // 偏差范围:-6 ~ +6 }这里偏差范围是-6到+6,绝对值大小反映偏离程度,符号反映方向。这组数据可以直接喂给PD控制器:
int16_t error = GetLineError(); static int16_t last_error = 0; int16_t p_term = KP * error; int16_t d_term = KD * (error - last_error); int16_t correction = p_term + d_term; last_error = error; left_pwm = BASE_PWM + correction; right_pwm = BASE_PWM - correction;KP和KD的典型起步值分别是20和50左右,具体取决于PWM量程。若基底PWM为400,correction的最大值约为KP*6+KD*12,所以KP=20、KD=50时,最大修正为120+600=720,显然超出了PWM量程——这个算法必须加限幅:
if (left_pwm > MAX_PWM) left_pwm = MAX_PWM; if (left_pwm < MIN_PWM) left_pwm = MIN_PWM; if (right_pwm > MAX_PWM) right_pwm = MAX_PWM; if (right_pwm < MIN_PWM) right_pwm = MIN_PWM;MIN_PWM不能设为0,因为电机存在启动死区。无刷或有刷电机的启动电压不同,但一般来说MIN_PWM应至少为总PWM的20%左右,否则会出现电机停转再启动困难的问题。关于PID是否一定比状态表好,经验是:直线多弯道缓的赛道,状态表完全够用;连续S弯和直角弯密集的赛道,PD控制的连续输出明显更顺滑。但PD调参耗时远大于状态表,初学时先把状态表跑通,再迭代到PD更靠谱。
3.4 主循环架构与执行频率控制
循迹主程序最常见的问题是while(1)里塞了太多事:传感器读取、状态判断、PWM赋值、串口打印、OLED刷新、按键扫描,全堆一起,导致传感器采样频率不稳定。传感器采样频率不稳定意味着每次进入控制算法时小车前进的距离不同,相当于控制器的时间基准在漂移,PID在这种条件下很难调好。
推荐的主循环架构是定时器驱动:
volatile uint8_t control_flag = 0; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); control_flag = 1; } } int main(void) { // 初始化部分省略 TIM_BaseInitTypeDef TIM_InitStructure; TIM_InitStructure.TIM_Period = 999; // 1kHz @72MHz, 1ms周期 TIM_InitStructure.TIM_Prescaler = 71; // 72MHz/72 = 1MHz TIM_TimeBaseInit(TIM3, &TIM_InitStructure); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); TIM_Cmd(TIM3, ENABLE); while (1) { if (control_flag) { control_flag = 0; // 传感器读取与状态判断 uint8_t state = GetLineState(); // 查表或PID输出 if (use_pid) { // PID处理部分 } else { // 状态表处理部分 } } } }定时器中断周期设为1毫秒,意味着控制频率是1kHz。注意TIM_Period = 999意味着计数0~999共1000次,配合预分频71得到1MHz的计数频率,中断周期正好1毫秒。这个1kHz频率对于循迹小车来说是合理的——实际上200Hz到500Hz也够用,频率太高反而会让传感器判别在黑白交界处过于灵敏,频率太低则转向响应迟钝。
3.5 丢线处理的两种策略:记忆与搜索
丢线是四路循迹无法完全避免的情况,常见的触发场景是:速度过快冲出弯道、十字交叉点、线宽突然变窄、传感器被强光干扰。丢线后的处理策略直接决定小车会不会直接冲出赛道。
第一种策略是“记忆转向”,适用于刚刚丢线的情况——小车在丢线前的瞬间还知道线在哪,利用惯性继续按原方向转向一小段时间,往往能重新捕捉到线。实现方式是:
// 丢线前最后一个有意义的转向方向,保存在last_direction中 if (state == STATE_LOST_ALL) { if (last_direction > 0) { left_pwm = BASE_PWM - 100; right_pwm = BASE_PWM + 100; // 继续向右转 } else if (last_direction < 0) { left_pwm = BASE_PWM + 100; right_pwm = BASE_PWM - 100; // 继续向左转 } else { left_pwm = BASE_PWM * 0.8; right_pwm = BASE_PWM * 0.8; // 完全无信息:降速直行 } }记忆转向的本质是利用上一帧的可靠信息做短时外推。如果记忆转向持续超过预设的时长(比如300毫秒)仍未找回线,就必须切换到“搜索模式”——通常是以一个较大的固定占空比向一个方向画弧,绕回来找线。搜索模式加一个时间上限,超过2秒则停车,避免小车毫无意义地打转。
第二种策略是“全黑即直行”,适用于赛道线特别粗、传感器间距较窄的情况。当四个传感器同时压到黑线上时,说明小车正骑在粗线的中央,此时继续直行往往是对的,而不是判定为丢线。这也是代码中case 0b1111被归为STATE_LOST_ALL的原因——你必须根据自己赛道的线宽来调整这个case的归组,如果线宽接近或超过传感器排布宽度,0b1111应该单独处理为STATE_CENTER。
4. 可复现的四路循迹完整程序骨架
4.1 项目结构建议与文件划分
工程文件划分这个细节,很多人不在意,但等到要加功能、要移植代码、要给别人讲解时才发现文件一团糟。我建议顺序是:main.c只放初始化调用和主循环,sensor.c放传感器读取与标定,motor.c放电机PWM控制,control.c放状态判断与控制策略,uart.c放调试打印。这样每个文件职责清晰,调一个部分不用翻整个工程。
以下是一个main.c的最小骨架,省去具体外设初始化函数实现,因为这些代码与你的PCB接线强相关,只需要知道在调用前完成GPIO和TIM初始化即可。
#include "stm32f10x.h" #include "sensor.h" #include "motor.h" #include "control.h" #include "uart.h" int main(void) { NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); Sensor_Init(); // 初始化四路循迹GPIO Motor_Init(); // 初始化PWM定时器与GPIO UART_Init(115200); Control_Init(); // 初始化控制状态和PID参数 while (1) { Control_Loop(); // 包含传感器读取、状态判断、PWM输出 } }这种清晰的调用层级对后续调试帮助很大。Control_Loop内部实现控制频率的节拍控制,可以直接复用上一章定时器标志位的思路。注意Motor_Init中要配置两个通道为PWM输出模式,通常用TIM2的CH1和CH2产生左右电机PWM,另外还需要两个GPIO控制电机方向。
4.2 电机PWM初始化与反向刹车配置
电机驱动常用L298N或TB6612,前者便宜但有1.2V左右压降,后者效率更好。PWM初始化以TIM2为例:
void Motor_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_InitStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); // PA0、PA1配置为TIM2的CH1、CH2复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 电机方向控制引脚,普通推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); TIM_InitStructure.TIM_Period = 999; TIM_InitStructure.TIM_Prescaler = 71; TIM_InitStructure.TIM_ClockDivision = 0; TIM_InitStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_InitStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &TIM_OCInitStructure); TIM_OC2Init(TIM2, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM2, ENABLE); TIM_Cmd(TIM2, ENABLE); }这里预分频71,72MHz主频除以72得到1MHz计数频率,ARR为999,PWM频率为1MHz/1000=1kHz。电机驱动通常工作在10kHz到20kHz的PWM频率下更安静,1kHz会产生可听的啸叫声。如果要提高到20kHz,把TIM_Period改为49,TIM_Prescaler改为71即可(72MHz/72/50=20kHz)。但这种方式会降低PWM分辨率到50级,对速度控制精度有影响。另一个路线是提高定时器时钟到更高的APB频率,这涉及RCC时钟树配置,改动更大,新手建议接受1kHz的轻微啸叫,不失为一种实用取向。
方向控制通过两个方向引脚输出高低电平组合来控制电机正反转。TB6612的IN1、IN2控制一路电机:IN1=1且IN2=0时正转,IN1=0且IN2=1时反转,IN1=IN2时刹车。在循迹转向中用到反转减速时,你需要同时设置方向引脚和PWM占空比:
void Motor_SetSpeed(uint8_t motor_id, int16_t speed) { // speed范围:-1000 ~ +1000,正数正转,负数反转 if (motor_id == MOTOR_LEFT) { if (speed >= 0) { GPIO_SetBits(GPIOB, GPIO_Pin_0); // IN1=1 GPIO_ResetBits(GPIOB, GPIO_Pin_1); // IN2=0 TIM_SetCompare1(TIM2, speed); } else { GPIO_ResetBits(GPIOB, GPIO_Pin_0); // IN1=0 GPIO_SetBits(GPIOB, GPIO_Pin_1); // IN2=1 TIM_SetCompare1(TIM2, -speed); } } // motor_id == MOTOR_RIGHT的部分同理,用TIM2 CH2和PB2/PB3 }注意TIM_SetCompare1传入的数值不能超过ARR。如果speed传入-1000,-speed即为1000,正好等于ARR,相当于占空比100%。如果传入超过量程的值,有些库函数不会做保护,需要自己调用之前先做饱和处理。
4.3 把传感器读取、状态判断、速度输出串成闭环
这里给出一个完整的Control_Loop实现,涵盖传感器读取、状态判断、查表输出、串口调试开关的全部逻辑。
void Control_Loop(void) { static uint8_t last_state = STATE_CENTER; uint8_t current_state; // 读取并滤波后的四个传感器状态 current_state = GetLineState(); // 丢线时使用记忆转向 if (current_state == STATE_LOST_ALL) { // 用丢线前最后一次有效状态决定转向方向 switch (last_state) { case STATE_LEFT: case STATE_FAR_LEFT: Motor_SetSpeed(MOTOR_LEFT, 350); Motor_SetSpeed(MOTOR_RIGHT, 450); break; case STATE_RIGHT: case STATE_FAR_RIGHT: Motor_SetSpeed(MOTOR_LEFT, 450); Motor_SetSpeed(MOTOR_RIGHT, 350); break; default: Motor_SetSpeed(MOTOR_LEFT, 350); Motor_SetSpeed(MOTOR_RIGHT, 350); break; } } else { // 正常状态下查表输出 for (uint8_t i = 0; i < ACTION_COUNT; i++) { if (action_table[i].state == current_state) { Motor_SetSpeed(MOTOR_LEFT, action_table[i].left_pwm); Motor_SetSpeed(MOTOR_RIGHT, action_table[i].right_pwm); break; } } last_state = current_state; } // 调试输出,用宏开关避免生产运行时消耗资源 #if DEBUG_ENABLE char buf[32]; sprintf(buf, "state=%d L=%d R=%d\r\n", current_state, action_table[current_state].left_pwm, action_table[current_state].right_pwm); UART_SendString(buf); #endif }这段代码的逻辑分为三个层次:第一层是状态获取,第二层是丢线分支处理,第三层是正常状态查表。丢线分支里的last_state记录了上一次有效状态,这正是记忆转向的核心数据。注意正常分支里last_state的更新位置不能放在丢线分支里,否则丢线发生后last_state仍然保留丢线前的正确状态,这才能实现有效记忆。
时序控制如何在主循环里实现?我推荐在主循环中用阻塞式延时来控频,因为控制循环本身执行时间在微秒级,一次性延时到设定周期即可。这样实现最简单且时序抖动小。如果你的控制代码加入了复杂的浮点PID运算,再用定时器标志法,两者不冲突。
4.4 时钟树与定时器起始的一个常见坑
STM32F103的TIM2挂载在APB1总线上,APB1的默认最大频率是36MHz。如果系统时钟是72MHz,APB1预分频系数为2,那么TIM2的时钟是72MHz。但如果有人把APB1预分频设为1,TIM2反而只能拿到36MHz。定时器的PWM频率计算会整体翻倍,所有占空比参数含义随之变化。遇到“定时器配置一模一样,但别人频率是1kHz我的是2kHz”,先查RCC_APB1PeriphClockCmd之外是否配置了RCC_PCLK1Config。
热词里也提到过“stm32芯片包安装”“error: no stm32 target found”这类问题。芯片包安装属于开发环境问题,IDE正常识别型号才能编译和烧录。No target found则多半是ST-Link驱动或接线问题,检查SWDIO和SWCLK两根线是否接反、板子是否独立供电。这些属于环境排障,跟程序逻辑无关,但项目调试中遇到的概率极高,建议准备好万用表和示波器再开工。
5. 四路循迹的调参与验证技巧
5.1 串口绘图代替肉眼观察
调试循迹时最容易犯的错误是推着车跑,一边跑一边看传感器指示灯判断好坏。这种做法只能判断“有没有反应”,不能判断“反应快不快”。更快的方法是借助串口绘图功能——把四个传感器的实时电平或偏差值通过串口发送到上位机,用波形方式观察。你会在波形图上直接看到信号边沿的陡峭程度和抖动毛刺。
串口绘图的关键是数据格式要稳定。推荐每帧发送一个换行结尾的数值序列,例如发送偏差值单值,绘图就能看到一条随时间变化的曲线。当推着车经过黑线时,曲线应该是一个干净的脉冲,脉宽对应黑线的宽度除以车移动速度。如果脉冲边缘有明显的来回震荡,说明传感器在阈值附近抖动,先去检查电位器位置和滤波强度,而不是去调PID参数。
5.2 起步参数口诀:先低速直线、再中速弯道、最后高速调PD
参数调整要分步,不要一上来就全速跑。第一步是纯直线测试:把赛道铺成一条直线,从200的基底PWM起步,观察小车是否走直。如果不直,先用左右轮PWM补偿修正机械偏差(左右电机性能差异造成的固定偏移),通过修改直行时的左右PWM差值来补偿。这个差值通常是固定值,写在代码里作为直行修正量。
第二步是过弯测试,速度保持在300左右,观察入弯和出弯的响应。如果入弯太晚才转,说明传感器状态切换后转向输出不够强,调大状态表中转向差值的比例。如果出弯后左右摆动,说明转向过度,减小差值或增加丢线记忆的恢复时间。
第三步才是提高速度到500以上,配合PD控制减小动态超调。此时观察的不是能不能过弯,而是过弯时的稳定性——车身有没有明显横向摆动。有摆动则减小KP,出弯后冲外线则减小KD或增大BASE_PWM。这里的调参逻辑是:先增基速,再升KD抑制过冲,失控就同时降KP和速度。
5.3 验证清单:确认你的循迹程序真的跑稳了
结束调试前,按这个清单过一遍,能提前发现大量隐藏问题:
用黑电工胶带在浅色地板上贴出一条约2厘米宽的闭合赛道,包含至少两个半径小于30厘米的急弯和一段约50厘米的直线加速段。让小车连续跑五圈,统计丢线次数。五圈内丢线不超过一次,且丢线后能在1秒内恢复,这是基本合格线。再换到不同光照条件(拉窗帘关灯、打开手机闪光灯照赛道)下测试,如果检测状态翻转,说明阈值裕量不够,需要调整电位器位置。
用示波器或逻辑分析仪测一下电机PWM输出引脚,确认频率在预期范围内,且启动瞬间没有长时间的高电平毛刺。这个检查能排除定时器初始化顺序错误导致的启动异常。最后,把串口调试宏打开,以固定速度推着车沿赛道走,比对各状态切换是否符合预期——这一步能发现传感器顺序接反、左右方向定义颠倒这类令人崩溃的低级错误。
四路循迹的程序写到这个程度,已经不是“能动就行”的玩具代码,而是有明确采样频率、有滤波策略、有状态机映射、有丢线恢复的完整控制方案。按这个思路去调试,即使你的硬件布局和代码细节与这里不完全一致,排查问题的路径也是可复用的。
本文还有配套的精品资源,点击获取