STM32四路循迹小车从传感器到PID控制的完整实现指南
简介针对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设置为999PWM占空比寄存器范围就是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对应-3S2对应-1S3对应1S4对应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为400correction的最大值约为KP*6KD*12所以KP20、KD50时最大修正为120600720显然超出了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); }这里预分频7172MHz主频除以72得到1MHz计数频率ARR为999PWM频率为1MHz/10001kHz。电机驱动通常工作在10kHz到20kHz的PWM频率下更安静1kHz会产生可听的啸叫声。如果要提高到20kHz把TIM_Period改为49TIM_Prescaler改为71即可72MHz/72/5020kHz。但这种方式会降低PWM分辨率到50级对速度控制精度有影响。另一个路线是提高定时器时钟到更高的APB频率这涉及RCC时钟树配置改动更大新手建议接受1kHz的轻微啸叫不失为一种实用取向。方向控制通过两个方向引脚输出高低电平组合来控制电机正反转。TB6612的IN1、IN2控制一路电机IN11且IN20时正转IN10且IN21时反转IN1IN2时刹车。在循迹转向中用到反转减速时你需要同时设置方向引脚和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); // IN11 GPIO_ResetBits(GPIOB, GPIO_Pin_1); // IN20 TIM_SetCompare1(TIM2, speed); } else { GPIO_ResetBits(GPIOB, GPIO_Pin_0); // IN10 GPIO_SetBits(GPIOB, GPIO_Pin_1); // IN21 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。如果系统时钟是72MHzAPB1预分频系数为2那么TIM2的时钟是72MHz。但如果有人把APB1预分频设为1TIM2反而只能拿到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输出引脚确认频率在预期范围内且启动瞬间没有长时间的高电平毛刺。这个检查能排除定时器初始化顺序错误导致的启动异常。最后把串口调试宏打开以固定速度推着车沿赛道走比对各状态切换是否符合预期——这一步能发现传感器顺序接反、左右方向定义颠倒这类令人崩溃的低级错误。四路循迹的程序写到这个程度已经不是“能动就行”的玩具代码而是有明确采样频率、有滤波策略、有状态机映射、有丢线恢复的完整控制方案。按这个思路去调试即使你的硬件布局和代码细节与这里不完全一致排查问题的路径也是可复用的。本文还有配套的精品资源点击获取