STM32智能小车黑线循迹与红外避障协同控制实战
简介面向嵌入式入门学习者的 STM32 智能小车综合实验工程围绕黑线循迹与红外避障两项基础功能展开适合单片机课程设计、电子竞赛入门与自学练手。工程以定时器输出 PWM 配置电机把运动控制封装成前进、刹车、左转、右转、原地左旋右旋与后退等带速度和时间参数的函数配合详细中文注释便于逐步理解小车行为逻辑并直接调用改写。压缩包共 192 个文件约 6.49MB以 34 个 .c 源文件与 35 个 .h 头文件为主体涵盖 STM32 标准外设库的定时器、GPIO、串口等驱动另附 Keil 工程文件、hex/axf 可执行固件以及编译产生的 .o、.crf 中间文件目录保留完整工程结构可直接打开编译烧录验证。目前已有 1187 人学习下载适合按模块对照代码复现巡线与避障效果并在此基础上扩展赛道识别或避障策略。1. 黑线循迹与红外避障同时开真正的难点在两个功能争用同一套底盘控制把五路红外循迹模块和两个避障探头拧上小车底盘接好线、烧进程序第一次上跑道多数人的车会做出同一个动作直道上走得挺稳一进弯道就直冲出去或者刚避完障车头一偏就原地打转再也找不到线。STM32 智能小车的黑线循迹和红外避障综合实验难的地方从来不在单个传感器能不能读到值而在于两套控制逻辑同时占用同一套底盘控制。这套实验工程值得拿来练手的原因也在这里它把循迹所需的连续调节和避障所需的紧急打断放在同一份代码里你能看到定时器节拍、PWM 差速、状态优先级是怎么配合的。适合已经点过灯、调过串口想从单模块 Demo 走到“整车能跑”的嵌入式初学者也适合拿它当底盘控制模板做二次开发的人。2. 技术选型与资源分配一颗 F103 怎么同时处理 5 路循迹和 2 路避障2.1 外设资源分配不用 RTOS 也能按时执行这类小车工程的常见配置是 STM32F103C8T6 加标准外设库主频 72MHz64KB Flash、20KB RAM。资源看着紧张但跑循迹加避障完全够用前提是把外设职责分清楚GPIO 负责把传感器的数字电平读进来定时器 PWM 负责输出两路占空比驱动左右轮另一个定时器出 10ms 周期中断当整个系统的节拍基准。我一般不会在这类项目里上 FreeRTOS。控制周期只有 10ms任务只有“读传感器、算偏差、更新电机”三件事裸机加一个节拍标志就够了调度开销和调试复杂度都不划算。关键是不能让任何一个环节阻塞尤其是避障触发后的倒车动作如果写成delay_ms(300)整个循迹就停止刷新 300ms车在高速下已经冲出十几厘米。/* main.c10ms 节拍驱动的调度骨架 */ volatile uint8_t g_tick_10ms 0; /* TIM3 中断里置 1主循环消费后清 0 */ int main(void) { SystemInit(); Board_Init(); /* 时钟、串口、LED */ Sensor_Init(); /* 循迹 5 路 避障 2 路全部上拉输入 */ Motor_Init(); /* TIM2_CH3/CH4 输出 10kHz PWM */ Tick_Init(); /* TIM3 每 10ms 进一次中断 */ while (1) { if (g_tick_10ms) { g_tick_10ms 0; Obstacle_Scan(); /* 先判避障紧急事件优先 */ if (g_avoid_state AVOID_NONE) { Line_Track_Run(); /* 无障碍才允许循迹接管 */ } Motor_Update(); /* 统一出口把目标速度落到 PWM */ } } }这段骨架的逻辑是“一个节拍只做一轮决策”Obstacle_Scan()内部只更新状态机和计时变量不做任何阻塞等待Motor_Update()是唯一的执行出口。参数上10ms 对应 100Hz 的控制频率舵机式转向的小车在 0.5m/s 以内足够如果你的车减速比小、跑得快把节拍缩到 5ms 更稳代价是串口打印要降频否则会拖慢主循环。2.2 循迹是连续闭环避障是离散事件这两类逻辑不能混写循迹的输出是连续的偏差从 -2 到 2经过比例系数放大后叠加到左右轮占空比上偏差越大内外轮速差越大。避障的输出是离散的探到障碍就是一次事件触发后要连续执行“停车、后退、转向、再判断”一串动作中间不能被循迹结果覆盖。这两者混写在一起常见写法是if (障碍) { 后退 } else if (黑线) { 循迹 }看起来没问题实际一跑就出问题避障动作执行到一半循迹分支因为else关系被跳过车退到一半停了或者避障刚结束立刻恢复循迹车头还斜着偏差瞬间到极值直接冲出赛道。合格的做法是把避障做成独立状态机用g_avoid_state这个变量当互斥锁只有它为AVOID_NONE时循迹才有资格写电机目标值。2.3 传感器与驱动选型对照为什么数字量比模拟量省事环节常见型号输出形式接线成本备注循迹TCRT5000 五路模块数字量比较器输出5 根信号线 电源电位器调阈值黑线有效电平随电路而定循迹灰度传感器模拟量需 ADC 阈值算法抗环境光好代码复杂度高避障红外对管 / E18 类探头数字量2 根信号线检测距离靠电位器2~30cm 可调驱动TB6612 / L298N双路 H 桥2 路 PWM 4 路方向TB6612 效率高、发热小L298N 压降大选数字量传感器的理由很实际五路模拟量要占 ADC 通道、要做滑动滤波、要处理环境光漂移对初学者来说全是玄学数字量模块自带比较器和电位器你只需要读一个 GPIO 电平。代价是阈值固定后对地面材质敏感白色瓷砖和哑光纸上的读数能差一截这是后面避坑章节要专门讲的一点。3. 目录结构与硬件接线先把引脚表和代码变量对应上3.1 资源包目录拆解这类基于标准外设库的工程目录结构基本都是“库 用户代码 Keil 工程”三块。拿到压缩包先别急着双击工程用文本编辑器看一眼目录能省掉后面一半的编译报错。SmartCar_Track_Avoid/ ├── Doc/ # 原理图、接线图、模块说明 ├── Libraries/ │ ├── CMSIS/ # 内核头文件与启动文件 startup_stm32f10x_md.s │ └── STM32F10x_StdPeriph_Driver/ │ ├── inc/ # stm32f10x_gpio.h、tim.h、usart.h 等 │ └── src/ ├── Project/ │ └── MDK-ARM/ │ ├── SmartCar.uvprojx # Keil 工程入口 │ └── Objects/ # 编译输出可整目录删除 └── User/ ├── main.c # 调度骨架 ├── board.c / board.h # 时钟、LED、串口初始化 ├── motor.c / motor.h # PWM 与方向控制 ├── tracking.c / tracking.h # 五路循迹采样与差速 ├── avoid.c / avoid.h # 避障状态机 ├── delay.c / delay.h # 仅用于上电初始化控制环内禁用 └── stm32f10x_it.c # TIM3 中断里置节拍标志值得注意的是delay.c的位置。它在User/下很容易被随手调用但只有上电初始化传感器和串口时才该用控制环里出现一次delay_ms就是一次潜在的问题。我一般会在delay.h顶部加一行注释写明“禁止在 10ms 节拍内调用”靠注释提醒比靠记忆更可靠。3.2 引脚接线表照着这张表插线不会错/* board.h把引脚集中定义改板子只动这一个文件 */ #define LINE_PORT GPIOB #define LINE_PIN_0 GPIO_Pin_0 /* 最左 */ #define LINE_PIN_1 GPIO_Pin_1 #define LINE_PIN_2 GPIO_Pin_5 /* 中间 */ #define LINE_PIN_3 GPIO_Pin_6 #define LINE_PIN_4 GPIO_Pin_7 /* 最右 */ #define AVOID_PORT GPIOB #define AVOID_PIN_L GPIO_Pin_8 /* 左前避障 */ #define AVOID_PIN_R GPIO_Pin_9 /* 右前避障 */ #define MOTOR_PWMA_PIN GPIO_Pin_2 /* PA2 TIM2_CH3 */ #define MOTOR_PWMB_PIN GPIO_Pin_3 /* PA3 TIM2_CH4 */ #define MOTOR_DIR_PORT GPIOB #define MOTOR_AIN1 GPIO_Pin_12 #define MOTOR_AIN2 GPIO_Pin_13 #define MOTOR_BIN1 GPIO_Pin_14 #define MOTOR_BIN2 GPIO_Pin_15 #define MOTOR_STBY GPIO_Pin_11 /* TB6612 使能必须拉高 */功能外设资源引脚GPIO 模式五路循迹GPIOBPB0 / PB1 / PB5 / PB6 / PB7上拉输入左右避障GPIOBPB8 / PB9上拉输入左右轮 PWMTIM2_CH3 / CH4PA2 / PA3复用推挽 50MHz电机方向GPIOBPB12 ~ PB15推挽输出驱动使能GPIOBPB11推挽输出上电拉高调试串口USART1PA9 / PA10复用推挽 / 浮空输入这张表里最容易被忽略的是 STBY。TB6612 的待机脚悬空时芯片处于待机态PWM 给再多占空比电机也不转现象是“程序跑得好好的轮子纹丝不动”第一次遇到很容易怀疑人生。3.3 电机 PWM 初始化10kHz 载波是怎么算出来的void Motor_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); /* PA2/PA3 复用推挽交给 TIM2 输出波形 */ GPIO_InitStructure.GPIO_Pin MOTOR_PWMA_PIN | MOTOR_PWMB_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); /* 方向脚与 STBY 普通推挽输出 */ GPIO_InitStructure.GPIO_Pin MOTOR_AIN1 | MOTOR_AIN2 | MOTOR_BIN1 | MOTOR_BIN2 | MOTOR_STBY; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(MOTOR_DIR_PORT, GPIO_InitStructure); /* 72MHz / (711) 1MHz 计数时钟10000 计数 10kHz PWM */ TIM_TimeBaseStructure.TIM_Period 99; /* ARR占空比分辨率 0~99 */ TIM_TimeBaseStructure.TIM_Prescaler 71; /* PSC */ TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 0; /* 上电占空比 0车不动 */ TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC3Init(TIM2, TIM_OCInitStructure); TIM_OC4Init(TIM2, TIM_OCInitStructure); TIM_Cmd(TIM2, ENABLE); GPIO_SetBits(MOTOR_DIR_PORT, MOTOR_STBY); /* 退出待机 */ }TIM_Prescaler 71把 72MHz 分频成 1MHzTIM_Period 99让计数器每 100 个 1MHz 时钟溢出一次得到 10kHz 的 PWM 载波。选 10kHz 是为了避开人耳可闻的 20Hz~20kHz 频段电机不会发出刺耳啸叫分辨率取 100 级够用且计算简单Motor_Set(60)就是 60% 占空比。载波再低会有明显抖动再高则受限于 H 桥开关速度反而发热。3.4 速度接口与死区为什么给 10% 占空比车轮还是不动直流减速电机的静摩擦力决定了存在一个启动死区通常占空比低于 15%~25% 时轮子不转只发出轻微的电流声。如果代码里直接Motor_Set(0~100)线性映射低速段就会出现“给了一点速度但车不走、再给一点突然窜出去”的突变感。/* 把 -100~100 的目标速度映射到实际占空比并补偿启动死区 */ void Motor_Set(int16_t left, int16_t right) { left (left 100) ? 100 : ((left -100) ? -100 : left); right (right 100) ? 100 : ((right -100) ? -100 : right); Motor_OneSide(MOTOR_L, left); Motor_OneSide(MOTOR_R, right); } static void Motor_OneSide(uint8_t side, int16_t speed) { const int16_t DEAD_ZONE 18; /* 实测得到的启动阈值逐台标定 */ int16_t duty; uint8_t forward (speed 0); if (speed 0) { Set_PWM(side, 0); return; } duty (speed 0 ? speed : -speed); if (duty DEAD_ZONE) duty DEAD_ZONE; /* 低速补到刚能转 */ Set_PWM(side, duty); /* 0~100 映射到 CCR 0~99 */ Set_Dir(side, forward); }这段代码的关键在DEAD_ZONE这个常量它不是理论值而是把车架起来、从 0 开始逐级加占空比看轮子刚好开始转的那个数。左右两个电机因为减速箱摩擦不同实际值往往差 2~5 个点所以Motor_OneSide里分别处理更稳妥。补充一句方向切换时最好先置零占空比再翻转 AIN1/AIN2否则 H 桥会出现瞬时反接电流长期这么用驱动芯片发热明显。4. 核心模块拆解循迹状态机与避障优先级的完整链路4.1 循迹采样加权求和比查表更好调五路传感器贴在一根横杆上从左到右编号 0~4。最直接的写法是查表0b00100直行0b01100微左0b11000大左……五路一共 32 种组合全写完是体力活而且换个赛道宽度就得重写。我一般用加权法把位置编号映射成偏差值简单几步就能得到连续输出还带一定滤波效果。/* 返回 -2 ~ 2负值偏左正值偏右0 表示居中 */ static int8_t g_last_err 0; int8_t Line_Get_Error(void) { static const int8_t weight[5] {-2, -1, 0, 1, 2}; uint16_t raw GPIO_ReadInputData(LINE_PORT); int8_t sum 0, cnt 0, i; for (i 0; i 5; i) { /* 这里假设黑线输出高电平反了就把 if 条件取反 */ if (raw line_mask[i]) { sum weight[i]; cnt; } } if (cnt 0) return g_last_err; /* 全白丢线沿用上次偏差 */ if (cnt 4) return g_last_err; /* 全黑横线或路口同样不更新 */ g_last_err (int8_t)(sum / cnt); return g_last_err; }weight数组就是传感器在横杆上的相对位置中间那路权重 0。sum / cnt取平均而不是取和好处是多路同时压线时结果不会超出 -2~2 的范围避免偏差值过大导致修正过猛。丢线和路口两种情况都返回上次偏差这是有意为之宁可按原方向继续走一小段也不要在瞬间给出错误修正赛道上的横线标识和起跑线都靠这个逻辑平稳通过。需要把“黑线有效电平”确认清楚。TCRT5000 模块在不同比较器电路下压黑线时输出高还是低完全相反代码里一个取反就是“完全正常”和“反着跑”的区别。上台架前先用串口把raw的十六进制打出来手推着车过一遍黑线看哪几位翻转比看原理图快。4.2 差速控制纯 P 够用加一点 D 更稳拿到偏差后把它放大成左右轮的速差。这里用比例控制P就足以跑完大部分赛道加一个很小的微分项D能明显抑制进弯时的过冲但 D 太大会让车在直道上抖动。void Line_Track_Run(void) { const int16_t BASE_SPEED 55; /* 基础占空比决定整体车速 */ const int16_t KP 16; /* 比例系数偏差 2 时修正 32 */ const int16_t KD 8; /* 微分系数抑制过冲 */ static int8_t last_err 0; int8_t err Line_Get_Error(); int16_t corr KP * err KD * (err - last_err); int16_t left, right; last_err err; if (corr 60) corr 60; /* 限幅防止单侧反转 */ if (corr -60) corr -60; left BASE_SPEED corr; right BASE_SPEED - corr; Motor_Set(left, right); }BASE_SPEED决定车速也决定过弯能力速度越高同样的 KP 越压不住线所以这两个参数要一起调。KP 16配合BASE_SPEED 55偏差拉满时单侧速度到 87、另一侧到 23速差足够把车拧回来又不至于原地转圈。corr限幅到 ±60 是防止BASE_SPEED - corr变成负数导致内轮反转反转会让车突然原地打转这是新手赛道上常见的现象之一。调参顺序建议是先把 KD 置 0只调 KP从小往大加到车能贴着线过弯但略微摆动再加 KD从 2 开始摆头明显减小就停。这套参数和电池电压、地面摩擦都有关系换一次场地就得微调不用指望一次调完永久有效。4.3 丢线恢复给一个超时别让车一直冲加权法在真正丢线时返回的是历史偏差如果车已经被推出赛道这个值毫无意义车会沿着错误方向一直走。所以要加一个丢线计时器连续丢线超过一定时间就进入搜索模式。/* 在 10ms 节拍里调用返回 1 表示正在恢复循迹逻辑让位 */ uint8_t Line_Recover_Check(void) { static uint16_t lost_ms 0; if (Line_All_White()) { lost_ms 10; } else { lost_ms 0; return 0; } if (lost_ms 200 lost_ms 800) { Motor_Set(-30, 40); /* 原地向左后方搜索 */ } else if (lost_ms 800) { Motor_Set(0, 0); /* 超过 800ms 放弃停车等人工 */ } return 1; }计时用 10而不是读系统时钟因为这段逻辑本身就在 10ms 节拍里累加最省事。200ms 是经验阈值对应车速 0.5m/s 时冲出约 10cm再晚就来不及修正800ms 是放弃阈值防止车在赛道外空转到电池耗尽。4.4 避障状态机优先级、倒车时长与再判断避障部分的关键词是“非阻塞”。整个动作拆成几个阶段每个阶段只做判断和置标志靠节拍推进。typedef enum { AVOID_NONE 0, AVOID_BACK, /* 后退中 */ AVOID_TURN, /* 转向中 */ AVOID_CHECK /* 转向结束重新观察 */ } avoid_state_t; avoid_state_t g_avoid_state AVOID_NONE; static uint16_t avoid_timer 0; void Obstacle_Scan(void) { uint8_t obs_l (GPIO_ReadInputDataBit(AVOID_PORT, AVOID_PIN_L) Bit_RESET); uint8_t obs_r (GPIO_ReadInputDataBit(AVOID_PORT, AVOID_PIN_R) Bit_RESET); switch (g_avoid_state) { case AVOID_NONE: if (obs_l obs_r) { g_avoid_state AVOID_BACK; avoid_timer 0; } else if (obs_l) { g_avoid_state AVOID_TURN; avoid_timer 0; } else if (obs_r) { g_avoid_state AVOID_TURN; avoid_timer 0; } break; case AVOID_BACK: Motor_Set(-50, -50); if (avoid_timer 30) { g_avoid_state AVOID_TURN; avoid_timer 0; } break; case AVOID_TURN: if (obs_l) Motor_Set(-45, 45); /* 右转绕开左侧障碍 */ else Motor_Set( 45, -45); /* 左转绕开右侧障碍 */ if (avoid_timer 25) { g_avoid_state AVOID_CHECK; avoid_timer 0; } break; case AVOID_CHECK: Motor_Set(40, 40); if (!obs_l !obs_r) { g_avoid_state AVOID_NONE; } else if (avoid_timer 20) { g_avoid_state AVOID_BACK; avoid_timer 0; } break; } }参数都是“节拍数 × 10ms”得来的后退 30 拍是 300ms转向 25 拍是 250ms再观察 20 拍是 200ms。这套时长对应的是探测距离约 15cm、车速 0.5m/s 的场景。避障探头调到 20cm 以上后退时间就得加长否则转向后车身还没完全避开障碍物AVOID_CHECK又会被立刻触发车在障碍物前反复横跳。注意Obstacle_Scan()里绝对不能出现delay。一旦阻塞节拍丢拍状态机的计时就全乱了。4.5 两个模块合到一起状态变量当互斥锁整合逻辑就一句话g_avoid_state ! AVOID_NONE的时候循迹模块没有写电机的权限。这个约束放在main的调度里执行模块内部各自不知道对方的存在耦合度最低。当前状态循迹避障电机控制权AVOID_NONE正常执行只做检测循迹AVOID_BACK挂起后退计时避障AVOID_TURN挂起转向计时避障AVOID_CHECK挂起观察超时保护避障挂起不是停止运行而是让循迹模块算完之后丢弃结果这样避障结束时偏差值是新鲜的不用等下一拍才有数据恢复循迹会平顺很多。5. 编译烧录与联调避坑五个上车就出问题的点5.1 Keil 工程导入与下载配置打开Project/MDK-ARM/下的工程文件第一件事是确认三处配置这三处不对后面全是白忙。器件选型在 Options → Device 里选择与芯片丝印一致的型号F103C8 选 STM32F103C8。如果下拉框里找不到说明器件支持包没装去 Pack Installer 里装 STM32F1 系列的支持包别用别的系列硬顶。下载器配置在 Options → Debug 里选 ST-Link 或 J-Link点旁边 SettingsPort 选 SW右边 SWDIO 框里能读到芯片 ID 说明连接正常。Flash Download 页要勾上 Reset and Run不勾的话每次下载完都要手动按复位联调阶段频繁下载会非常烦。时钟和宏定义在 Options → C/C 里USE_STDPERIPH_DRIVER必须有启动文件按容量选startup_stm32f10x_md.s中容量64KB/128KB Flash 都用它。启动文件选错会直接 HardFault现象是下载成功但一上电就卡死。5.2 台架自检上车之前先把这四件事做完把车架空四轮离地别急着放跑道。先看串口波特率 115200每 100ms 打印一次五路循迹原始值和两路避障状态。手拿一张白纸和一支黑笔依次从五个探头下面划过确认每一位都按预期翻转顺序和line_mask数组一致。这一步能提前发现焊接虚焊和引脚定义写反。再单独测电机。写一个临时循环左轮正转 1 秒、反转 1 秒、停 1 秒右轮同样观察转向是否符合“正转前进”的定义。方向反了不用改硬件把Set_Dir里的电平取反就行但一定要在代码层面统一别一半正一半反。最后测避障触发。用纸板从 30cm 慢慢靠近左右探头看串口打印的状态是否在 15~20cm 处翻转同时看g_avoid_state是否正确进入 BACK。这三项通过再放跑道能省掉一大半“上车就乱跑”的排查时间。5.3 五个上车就出问题的记录1上电瞬间四轮猛转一下然后停住现象接通电源的一瞬间车会窜一下之后正常。 原因GPIO 上电默认是浮空输入电机的方向脚在初始化完成前处于不确定状态H 桥被随机电平触发同时TIM_Pulse初值不是 0。 解决把所有方向脚在Motor_Init最开头先全部写低再配置为推挽输出并且TIM_Pulse初值设 0、Motor_Set(0,0)在使能 TIM 之前调用。顺序是“先定电平再开外设”。2直道走得稳一进弯就冲出去现象偏差值看着正常但车压不住线。 原因两个电机的实际转速不一致同一占空比下左轮比右轮快直道靠 KP 硬拉回来了进弯时叠加偏差直接超出修正能力。 解决写一个Motor_Calibrate()给左右轮同样的 50% 占空比跑 2 米量出偏移距离然后把慢的那一侧BASE_SPEED加 3~8 个点做静态补偿。这一步不做后面调 KP 永远在跟机械误差较劲。3避障触发后车就一直退停不下来现象探到障碍后车持续后退AVOID_BACK状态出不去。 原因Obstacle_Scan()里用了delay_ms()做倒车计时主循环被卡住g_tick_10ms在中断里反复置位但没人消费节拍计数完全失效。 解决所有时长改成节拍计数avoid_timer函数内不出现任何阻塞调用。这条是这套代码里最容易再犯的问题改完记得全局搜一遍delay。4五路循迹读数全是 1或者全是 0现象串口打印的原始值不随黑白变化。 原因TCRT5000 模块供电电压与比较器参考不匹配或者探头离地高度不对常见要求 5~15mm太高时反射光太弱全部判定为白。 解决先量模块 VCC 是否稳定在 3.3V 或 5V按模块标注再把探头降到离地 8mm 左右重试最后调模块上的电位器调到压白纸时 LED 熄灭、压黑线时点亮。三个因素按顺序排查别一上来就改代码。5下载成功但车不动按一下复位才跑现象Keil 提示下载完成车毫无反应手按 NRST 后正常。 原因Flash Download 里没勾 Reset and Run程序下载后停在复位向量处没执行。 解决勾上 Reset and Run如果勾了还是不跑检查 BOOT0 是否被拉低接地BOOT0 为高会进系统存储器启动程序根本不会运行。6. 二次开发把硬编码的循迹逻辑改成可替换的控制器这套工程跑通之后最值得做的改造是把“引脚、参数、控制算法”三样东西解耦不然每换一块板子就要改十几个文件。第一步把所有引脚定义收进board_config.hmotor.c、tracking.c只引用宏名不出现具体GPIO_Pin_x。这样换板子只改一个头文件编译一次就能验证有没有漏改。第二步把循迹算法抽成函数指针接口。现在Line_Track_Run()里写死了 PPD想换成纯比例或者加积分项就得改函数体。改成下面这样算法可以运行时切换也方便做 A/B 对比。typedef struct { const char *name; void (*init)(void); int16_t (*update)(int8_t err); /* 输入偏差输出修正量 */ } line_ctrl_t; static const line_ctrl_t ctrl_p_only { P, Ctrl_Init, Ctrl_P_Update }; static const line_ctrl_t ctrl_pd { PD, Ctrl_Init, Ctrl_PD_Update }; const line_ctrl_t *g_ctrl ctrl_pd; /* 串口命令可切换 */第三步给底盘加编码器做速度闭环。F103 的 TIM3 现在被 10ms 节拍占用把节拍挪到 TIM4TIM3 的 CH1/CH2 就能接左右编码器做正交解码。有了实测转速BASE_SPEED就从“占空比”变成“目标转速 mm/s”电池电压下降时 PID 会自动补占空比车速不再随电量变化这是从“能跑”到“跑得稳”的关键一步。验证方法我一般用串口打 CSV每 20ms 输出一行时间, 左轮目标, 左轮实际, 偏差, 修正量用串口助手存成文本丢进表格软件画曲线。看三条线就知道控制器是不是在震荡、积分有没有饱和比盯着车看直观得多。台架测试阶段也可以把车架起来空转先确认曲线形态再放跑道。血泪经验是每次改完参数一定要先在台架上跑一遍空转再放地面。我吃过一次亏改完 KP 直接上跑道车冲出赛道撞到桌腿舵机支架断了等配件等了一周。从那以后我每次都强制走一遍“编译 → 台架空转看串口曲线 → 低速单圈 → 正常速度”的流程一步都不跳。希望帮到你。本文还有配套的精品资源点击获取