STM32五路红外循迹小车源代码:PID差速与PWM调速

发布时间:2026/10/30 3:08:16
STM32五路红外循迹小车源代码:PID差速与PWM调速

简介:面向单片机课程设计、电子竞赛与循迹小车入门实践的STM32红外智能循迹小车源代码,主控围绕STM32F103系列展开,整合红外对管循迹检测、L298N电机驱动、OLED路程速度显示、红外按键控制及PWM差速转向等功能。工程由红外传感器反馈路线偏差,单片机输出PWM调节左右轮速度,实现偏左减速右轮、偏右减速左轮的自动纠偏,并保留前进、后退与左右转弯控制,适合理解信号采集、主控决策与电机执行的完整闭环。压缩包共294个文件、约7.18MB,含121个.h头文件、74个.c源文件,以及Keil工程、CubeMX配置、hex固件和编译中间文件,并保留较完整的工程目录与模块化驱动代码,便于打开工程、对照模块代码与验证烧录。已有745人学习,可作为课程设计、毕业设计前期原型与STM32外设练习参考。

1. 从一块跑偏的小车说起:这套 STM32 红外循迹小车源代码能给你什么

地上贴一圈黑色电工胶带,小车一上电,要么原地画圈,要么直冲出去撞墙——凡是自己动手做过循迹小车的人,大概都经历过这个阶段。这套基于 STM32 的红外智能循迹小车源代码,把“五路红外采样 → 加权误差计算 → PID 差速决策 → PWM 输出”这条链路完整地写了下来,工程可以直接编译、烧录、上车。它适合两类人:一类是刚摸到 STM32、想找一个外设覆盖面够全(GPIO、定时器、PWM、串口、中断)的入门项目练手;另一类是玩过 51 单片机循迹车,想看看换到 32 位平台上,控制节拍和 PID 该怎么写得更干净。代码没有堆中间件,外设库的用法直白,跑通之后照着改参数、改传感器数量都不费劲,算是一份适合拿来当二次开发底稿的源代码。

2. 工程骨架拆解:从启动文件到 main 的调用链

拿到一份单片机工程的源代码,最忌讳的做法是直接从 main 往下读。硬件相关的宏、外设初始化、中断服务函数分散在好几个文件里,先看清楚谁调谁,后面改起来才不会牵一发动全身。这套工程的骨架属于很典型的“标准外设库 + 分层硬件驱动”结构,没有上 RTOS,逻辑全在裸机里跑,对新手比较友好。

2.1 目录结构与文件分工

工程根目录大致是这个样子:

SmartCar/ ├── CMSIS/ # 内核寄存器定义与启动文件,芯片厂随包提供 │ ├── core_cm3.c │ ├── startup_stm32f10x_md.s # 中容量启动文件,中断向量表在这里 │ └── system_stm32f10x.c # SystemInit,负责把主频拉到 72MHz ├── FWLIB/ # 标准外设库 │ ├── inc/ │ └── src/ │ ├── stm32f10x_gpio.c │ ├── stm32f10x_tim.c │ └── stm32f10x_usart.c ├── HARDWARE/ # 自己写的板级驱动,按外设切分 │ ├── board.h # 所有引脚宏集中在这一个头文件 │ ├── track.c/.h # 五路红外循迹传感器 │ ├── motor.c/.h # 电机方向 + PWM 调速 │ ├── ctrl.c/.h # PID 与差速分配 │ └── usart.c/.h # 串口打印、在线调参 ├── USER/ │ ├── main.c # 初始化 + 主循环 │ └── stm32f10x_it.c # 中断服务函数 └── OBJ/ # 编译中间文件与 hex/bin 产物

值得说的是board.h这个文件。很多人写单片机代码,引脚号直接散落在各个.c里,等到换一块板子、或者把传感器从 PA0~PA4 挪到 PB8~PB12,就得全局搜索替换,还容易漏。这套代码把“哪根线接哪个脚”全部收敛到一个头文件里,驱动层只认TRACK_S1_PIN这种逻辑名。我一般也会这么干,尤其在同一个项目要维护两三块不同硬件版本的时候,这个习惯能省下大量返工。

/* board.h —— 硬件差异只在这一处体现 */ #ifndef __BOARD_H #define __BOARD_H #include "stm32f10x.h" /* 五路红外,从左到右依次排列,顺序不能乱 */ #define TRACK_PORT GPIOA #define TRACK_PIN_MASK 0x001F /* PA0 ~ PA4 */ /* 电机 PWM:TIM3 四个通道,CH1/CH2 给左轮,CH3/CH4 给右轮 */ #define MOTOR_PWM_PORT GPIOA #define MOTOR_L_FWD_PIN GPIO_Pin_6 /* TIM3_CH1 */ #define MOTOR_L_BWD_PIN GPIO_Pin_7 /* TIM3_CH2 */ #define MOTOR_R_FWD_PIN GPIO_Pin_0 /* TIM3_CH3 */ #define MOTOR_R_BWD_PIN GPIO_Pin_1 /* TIM3_CH4 */ /* 方向使能脚,接 L298N 的 IN1~IN4 */ #define DIR_PORT GPIOB #define DIR_L_IN1 GPIO_Pin_12 #define DIR_L_IN2 GPIO_Pin_13 #define DIR_R_IN3 GPIO_Pin_14 #define DIR_R_IN4 GPIO_Pin_15 #define STATUS_LED_PIN GPIO_Pin_13 /* PC13,低电平点亮 */ #define STATUS_LED_PORT GPIOC #endif

这里的引脚分配不是随手写的:STM32F103 的 TIM3 默认复用映射下,CH1/CH2 正好落在 PA6/PA7,CH3/CH4 落在 PB0/PB1,不需要开重映射,省掉一次 AFIO 配置。如果手上的板子 PB0/PB1 已经被占用,那就必须调GPIO_PinRemapConfig(GPIO_PartialRemap_TIM3, ENABLE)把 CH3/CH4 挪到 PB4/PB5 上,这一步忘了,PWM 就出不来,示波器一测是死电平,很容易误判成定时器没使能。

2.2 时钟树与 PWM 频率的换算

整套代码跑在 72MHz 主频上,SystemInit()里已经把 PLL 配好了,业务代码不用再碰。真正需要自己算的是定时器的预分频和重装载值,这也是新手最容易翻车的地方之一。

TIM3 挂在 APB1 上,APB1 的分频系数是 2,但因为 APB1 预分频不为 1 时定时器时钟会自动倍频,所以 TIM3 的计数时钟实际还是 72MHz。要让 PWM 落在 20kHz,计算过程是:

  • 计数时钟 72MHz,先分频到 1MHz:PSC = 72 - 1 = 71
  • 1MHz 下数 50 个数就是 20kHz:ARR = 50 - 1 = 49
  • 占空比分辨率是 1/50,也就是 2% 一档
/* motor.c —— TIM3 四通道 PWM 初始化 */ void Motor_Init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); /* PA6/PA7/PB0/PB1 复用推挽输出,否则 PWM 出不来 */ gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; gpio.GPIO_Pin = MOTOR_L_FWD_PIN | MOTOR_L_BWD_PIN; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = MOTOR_R_FWD_PIN | MOTOR_R_BWD_PIN; GPIO_Init(GPIOB, &gpio); /* 72MHz / 72 = 1MHz,再数 50 个 = 20kHz,超出人耳范围 */ tb.TIM_Prescaler = 72 - 1; tb.TIM_Period = 50 - 1; tb.TIM_CounterMode = TIM_CounterMode_Up; tb.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &tb); oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_Pulse = 0; /* 上电先停转 */ oc.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &oc); TIM_OC2Init(TIM3, &oc); TIM_OC3Init(TIM3, &oc); TIM_OC4Init(TIM3, &oc); TIM_Cmd(TIM3, ENABLE); }

参数上有两个坑需要提前说清。第一个是 PWM 频率,很多人图省事把预分频设成 7200,频率掉到 1kHz 以下,电机就会发出明显的“嗡嗡”声,低速时还会一顿一顿地爬。把频率顶到 20kHz 之后,人耳听不见,电机运转也顺滑得多。第二个是TIM_Pulse的初值必须给 0,不能留着寄存器复位值不管——某些库版本里通道默认输出有效电平,上电瞬间两个轮子会猛地转一下,小车直接从桌上窜下去。

2.3 main 里的初始化次序

初始化顺序不是随便排的,正确做法是先开时钟、再配 GPIO、再配外设、最后开中断,main的骨架大致如下:

/* main.c */ int main(void) { /* 1. 系统时钟由 SystemInit 完成,这里只做中断分组 */ NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); /* 2. 板级外设初始化,顺序:输入 -> 输出 -> 通信 -> 定时器 */ Track_Init(); /* 五路红外,GPIO 上拉输入 */ Motor_Init(); /* PWM + 方向脚 */ Usart1_Init(115200); /* 调试串口 */ Ctrl_Init(); /* PID 参数与状态清零 */ CtrlTimer_Init(); /* TIM2 10ms 控制节拍,最后开 */ printf("car boot ok\r\n"); while (1) { /* 主循环只做低速任务:按键扫描、状态灯、串口命令解析 */ Key_Scan(); Led_Heartbeat(); Cmd_Poll(); } }

把控制节拍放在 TIM2 中断里、把串口和按键留在主循环,是这个工程结构上最值得学的一点。如果循迹逻辑写在while(1)里,printf一多,控制周期就从 10ms 抖到 30ms,过弯时明显感觉车“反应慢半拍”。中断里只做采样、算 PID、写 CCR 这三件事,执行时间控制在几十微秒,节拍就很稳。

2.4 中断向量与优先级安排

stm32f10x_it.c里实际用到的中断只有三个:SysTick 负责毫秒计时,TIM2 负责 10ms 控制节拍,USART1 负责接收调参命令。优先级分组选了NVIC_PriorityGroup_2,抢占位 2 位、子优先级 2 位,配置时把 TIM2 的抢占优先级设得比 USART1 高:

/* CtrlTimer_Init 中的 NVIC 配置 */ NVIC_InitTypeDef nvic; nvic.NVIC_IRQChannel = TIM2_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; /* 控制节拍优先 */ nvic.NVIC_IRQChannelSubPriority = 0; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic);

优先级颠倒的后果不算致命,但很隐蔽:当串口正在收一长串调参命令时,控制中断被推迟,车会在赛道上出现周期性的“抽一下”。这种现象看起来像机械问题或者电源问题,实际是中断优先级没排对,排查时容易走弯路。

3. 传感器层与电机驱动:红外采样和 PWM 输出的落地写法

外设初始化和引脚都定好了,接下来要处理真实信号。红外循迹模块输出的是开关量,电机要的是占空比,中间隔着一层“状态到动作”的映射,这层映射写得糙不糙,直接决定小车是贴着线走还是左右横跳。

3.1 五路红外传感器的采样与消抖

常见的红外循迹模块,原理是红外发射管照地面,接收管根据反射强度输出模拟电压,再经过板上比较器整形成数字量。黑线吸收红外,反射弱,多数模块此时输出高电平;白底反射强,输出低电平。但这个极性不是绝对的,跟模块上的电位器调节位置有关,所以代码里一定要留一个宏来翻转,别把极性写死在判断逻辑里。

/* track.c —— 一次读整个端口,再拆位 */ #define TRACK_BLACK_LEVEL 1 /* 黑线为高电平时填 1,反接就改成 0 */ uint8_t Track_ReadRaw(void) { uint16_t port = GPIO_ReadInputData(TRACK_PORT); uint8_t raw = 0; uint8_t i; for (i = 0; i < 5; i++) { uint8_t bit = (port >> i) & 0x01; /* PA0 对应最左 S1 */ if (bit == TRACK_BLACK_LEVEL) raw |= (uint8_t)(1 << i); } return raw; /* bit0 最左,bit4 最右 */ }

用GPIO_ReadInputData一次读 16 位端口再拆位,比连续调五次GPIO_ReadInputDataBit更靠谱。倒不是说速度差多少,而是五次读取之间存在几十个指令周期的时间差,车在高速过弯时,理论上可能读到“左边已经是黑线、右边还在白底”的错位状态,虽然概率不高,但这种玄学问题排查起来极其难受。

原始值不能直接拿去用,还要过一层消抖。红外模块对地面材质、环境光很敏感,尤其是浅色瓷砖上贴着磨砂胶带,边界处的反射强弱是渐变的,输出会在临界点反复跳。

/* 连续三次一致才认,10ms 调一次,等价于 30ms 确认窗口 */ static uint8_t s_last = 0; static uint8_t s_cnt = 0; uint8_t Track_ReadFiltered(void) { uint8_t now = Track_ReadRaw(); if (now == s_last) { if (s_cnt < 3) s_cnt++; } else { s_cnt = 0; s_last = now; } return (s_cnt >= 3) ? s_last : 0; /* 未确认时返回 0,交给上层维持上一状态 */ }

这里的返回值 0 在业务上表示“没有有效状态”,上层拿到 0 就沿用上一次的误差,而不是当成“全白脱线”处理。这个约定很关键,如果不区分“无效”和“全白”,车在边界抖动时会频繁触发脱线保护,表现为一顿一顿地原地找线。

3.2 L298N 电机驱动与最小启动占空比

L298N 是双 H 桥,每个电机需要两个方向脚加一路 PWM。常见的接法是方向脚决定正反转,PWM 脚给使能端。但这套代码用的是另一种更省引脚的方式:每个电机两路 PWM,一路正转一路反转,方向脚只做辅助使能。

/* motor.c —— 输入范围 -100 ~ 100,负数代表反转 */ void Motor_Set(int8_t left, int8_t right) { /* 左轮 */ if (left >= 0) { TIM_SetCompare1(TIM3, 0); /* 反转通道关掉 */ TIM_SetCompare2(TIM3, (uint16_t)(left * 50 / 100)); GPIO_SetBits(DIR_PORT, DIR_L_IN1); GPIO_ResetBits(DIR_PORT, DIR_L_IN2); } else { TIM_SetCompare2(TIM3, 0); TIM_SetCompare1(TIM3, (uint16_t)((-left) * 50 / 100)); GPIO_ResetBits(DIR_PORT, DIR_L_IN1); GPIO_SetBits(DIR_PORT, DIR_L_IN2); } /* 右轮逻辑同理,注意 IN3/IN4 的顺序和实际接线一致 */ if (right >= 0) { TIM_SetCompare3(TIM3, 0); TIM_SetCompare4(TIM3, (uint16_t)(right * 50 / 100)); GPIO_SetBits(DIR_PORT, DIR_R_IN3); GPIO_ResetBits(DIR_PORT, DIR_R_IN4); } else { TIM_SetCompare4(TIM3, 0); TIM_SetCompare3(TIM3, (uint16_t)((-right) * 50 / 100)); GPIO_ResetBits(DIR_PORT, DIR_R_IN3); GPIO_SetBits(DIR_PORT, DIR_R_IN4); } }

参数说明上要抠两个细节。一个是left * 50 / 100这个换算,50 是 ARR+1 的值,乘除顺序必须先是乘法后是除法,写成left / 100 * 50在整数运算下会直接把小数部分抹掉,占空比永远出不来。另一个是最小启动占空比,这是血泪经验:普通的 TT 电机加 L298N,占空比低于 30% 时电机根本不转,只是发出轻微的电流声;但 PID 在小偏差时会输出 5%、10% 这种值,表现出来就是“车在直道上不动,一到弯道才猛地窜一下”。解决办法是在Motor_Set里加一段死区补偿:

/* 死区补偿:非零输出一律抬到最小可转占空比 */ static int8_t Motor_DeadZone(int8_t v) { const int8_t MIN_DRIVE = 32; /* 实测该底盘约 32% 才能起转 */ if (v == 0) return 0; if (v > 0 && v < MIN_DRIVE) return MIN_DRIVE; if (v < 0 && v > -MIN_DRIVE) return -MIN_DRIVE; return v; }

这个 32 不是通用值,跟电池电压、轮子摩擦力、车身重量都有关系。我一般会把车架空,从 0 开始以 5% 步进往上加,记下轮子刚开始转的那个值,再往上留 3~5 个点作为补偿量,这样低速循迹会稳很多。

3.3 五路状态到差速的动作表

最直观的一层决策是查表。五路传感器组合出 32 种状态,实际有意义的只有十来种,剩下的都归到“脱线保护”。

传感器状态(S1~S5,1 为黑线)车体相对赛道左轮右轮说明
00100正中基速基速直行,误差 0
01100略偏右基速-15基速+15需要左转修正
01000偏右基速-30基速+30中度修正
11000大幅偏右基速-55基速+55大角度修正
10000极限偏右0基速+70单轮制动,接近原地转
00010略偏左基速+15基速-15对称逻辑
11111全黑(十字路口或终点)基速基速保持直行若干周期
00000全白(脱线)-基速-基速后退找线

查表法的优点是行为可预测,调车时改一个数字马上能看出效果;缺点是参数是离散的,直道和弯道之间切换时会有突变。所以这套代码最终的实现并不是纯查表,而是把查表结果当作 PID 失效时的兜底,正常行驶走连续量控制,下一章展开。

/* ctrl.c —— 查表兜底,仅在 PID 输出异常或脱线时调用 */ void Ctrl_Fallback(uint8_t st, int16_t base) { switch (st) { case 0x04: Motor_Set(base, base); break; /* 00100 */ case 0x06: Motor_Set(base - 15, base + 15); break; /* 01100 */ case 0x02: Motor_Set(base - 30, base + 30); break; /* 01000 */ case 0x03: Motor_Set(base - 55, base + 55); break; /* 11000 */ case 0x01: Motor_Set(0, base + 70); break; /* 10000 */ case 0x08: Motor_Set(base + 30, base - 30); break; /* 00010 */ case 0x1F: Motor_Set(base, base); break; /* 11111 */ default: Motor_Set(-base, -base); break; /* 全白后退 */ } }

查表值里的 15、30、55、70 都是百分比量纲,跟Motor_Set的入参范围对齐。这几个数不是拍脑袋定的,跟轮距、传感器间距、基速都有关系:传感器间距越大,同样偏移量下误差信号越“钝”,修正力度就要给得更狠;基速越高,修正的时间窗口越短,差值也要相应放大。换底盘之后这几个数必须重调,直接照抄大概率过不了弯。

4. 循迹控制核心:从阈值判断到 PID 方向闭环

查表能跑,但跑不快。想让小车在直道上冲起来、进弯道再收油,就得把离散状态换成连续误差,用 PID 做闭环。这一章是整套代码里最值得细看的部分,也是二次开发时改动最频繁的地方。

4.1 五路传感器的加权误差计算

把五个传感器从左到右赋予权重 -2、-1、0、1、2,误差等于“所有黑线传感器的权重之和”除以“黑线传感器个数”。这样得到的误差是归一化到 [-2, 2] 的连续量,中间位置正好是 0,左侧为负、右侧为正。

/* ctrl.c —— 加权误差,返回 -200 ~ 200 的定点量,避免浮点 */ int16_t Track_CalcError(uint8_t st) { static const int8_t weight[5] = { -2, -1, 0, 1, 2 }; int16_t sum_w = 0; int16_t cnt = 0; uint8_t i; for (i = 0; i < 5; i++) { if (st & (1 << i)) { sum_w += weight[i]; cnt++; } } if (cnt == 0) return 9999; /* 全白,脱线 */ if (cnt == 5) return 0; /* 全黑,按正中处理 */ /* 乘 100 转成定点,后续 PID 全程用整数运算 */ return (int16_t)(sum_w * 100 / cnt); }

为什么要乘 100 转定点?因为 Cortex-M3 没有硬件浮点单元,一次float乘加要几百个时钟周期,10ms 的控制周期里跑三次 PID 加上串口打印,时间就开始紧张了。定点化之后全是整数移位和加减,几十微秒就能算完。误差范围的取值也有讲究:权重最大是 2,乘以 100 之后上限 200,PID 的比例项系数就可以按“误差 200 时输出多少占空比”来直观地调。

返回 9999 作为脱线标志是个约定俗成的做法,比用全局变量标志位更简洁,上层判断err > 9000就知道该走脱线保护了。但要注意 int16_t 的范围是 -32768~32767,如果误差量纲继续放大,这个哨兵值就得跟着换,别一不小心溢出成负数,车会朝着反方向狂奔。

4.2 增量式 PID 与差速分配

位置式 PID 需要累加积分项,一旦输出饱和,积分会越积越大,退出饱和时车会猛地冲一下。增量式 PID 只算本次相对于上次的变化量,天然抗积分饱和,适合这种“输出直接驱动物理机构”的场景。

/* ctrl.c —— 增量式 PID,输入误差,输出占空比修正量 */ typedef struct { int16_t kp, ki, kd; /* 定点系数,实际值 = 系数 / 100 */ int16_t err_now; int16_t err_last; int16_t err_prev; int16_t out; int16_t out_limit; /* 输出限幅,单位 % */ } PID_t; static PID_t s_pid = { 80, 4, 30, 0, 0, 0, 0, 60 }; int16_t PID_Calc(PID_t *p, int16_t err) { int16_t delta; p->err_prev = p->err_last; p->err_last = p->err_now; p->err_now = err; /* Δu = Kp*(e(k)-e(k-1)) + Ki*e(k) + Kd*(e(k)-2e(k-1)+e(k-2)) */ delta = (int16_t)(p->kp * (p->err_now - p->err_last) / 100); delta += (int16_t)(p->ki * p->err_now / 100); delta += (int16_t)(p->kd * (p->err_now - 2 * p->err_last + p->err_prev) / 100); p->out += delta; if (p->out > p->out_limit) p->out = p->out_limit; if (p->out < -p->out_limit) p->out = -p->out_limit; return p->out; }

三个系数的作用和调法:kp决定“看到偏差后修得多狠”,给太小车会沿着线慢慢晃,给太大直接左右震荡;ki负责消除稳态误差,比如车总是往右偏一点点跑不直,加一点积分就能拉回来,但给多了会积分饱和;kd是阻尼,用来压住超调,弯道出口的“甩尾”基本靠它治。

差速分配就是把 PID 的输出加减到基速上:

/* 定时器中断里调用,base 是当前基速,单位 % */ void Ctrl_Step(void) { uint8_t st = Track_ReadFiltered(); int16_t err; int16_t turn; if (st == 0) return; /* 无效状态,维持上次输出 */ err = Track_CalcError(st); if (err > 9000) /* 脱线,走兜底逻辑 */ { Ctrl_Fallback(st, g_base_speed); return; } turn = PID_Calc(&s_pid, err); /* 基速做动态调整:误差大时自动收油,进弯更稳 */ if (err > 100 || err < -100) g_base_speed = 45; else g_base_speed = 65; Motor_Set((int8_t)(g_base_speed - turn), (int8_t)(g_base_speed + turn)); }

这段里“误差大自动收油”的逻辑是我个人很推荐的做法。纯固定基速的 PID,在急弯处需要极大的差速比才能转过来,一旦out_limit给到 80 以上,车在直道上就会因为一点点噪声而左右抽。把基速做成随误差变化的,等于给控制器减负:直道上跑得快、弯道里慢下来,同样一组 PID 参数能覆盖的赛道范围会宽很多。

4.3 定时器中断里的控制节拍

控制频率选 100Hz(10ms 一次)是这套小车的一个平衡点。频率太高,红外模块的响应跟不上,采到的是同一个状态的重复样本;频率太低,过弯时修正次数不够,轨迹会变成一段段折线。

/* stm32f10x_it.c —— TIM2 中断,10ms 一次 */ void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); Ctrl_Step(); /* 采样 -> 滤波 -> PID -> 输出 */ } }

中断里的代码要守住一条底线:不做阻塞操作。printf、delay_ms、等待串口发送完成,这些统统不能出现在这里,否则一次打印就能把 10ms 的节拍拖成 30ms。调试信息要么在主循环里打印,要么先存进环形缓冲区,由主循环取走。

TIM2 的配置本身很常规,PSC 和 ARR 按 72MHz 算:要 10ms 中断,就让计数器每 720000 个时钟溢出一次,PSC = 7200-1、ARR = 100-1,即 10kHz 计数、数 100 个溢出。这两个值一旦改动,PID 的积分和微分系数都得跟着重调,因为它们的物理含义跟采样周期强相关,换周期不换参数,车的行为会完全变样。

5. 编译烧录与联调排查:从 Keil 工程到跑完全程

代码读懂了,还得让它真的在车上跑起来。这一章按“编译 → 烧录 → 分级调试 → 排错”的顺序走一遍,中间列几条我实际踩过的坑,按现象、原因、解决办法写清楚,遇到问题时可以对着查。

5.1 编译选项与烧录配置

Keil MDK 里新建或者打开工程之后,几个地方必须核对:

配置项取值说明
DeviceSTM32F103C8选错容量会导致启动文件不匹配
C/C++ DefineUSE_STDPERIPH_DRIVER,STM32F10X_MD前者启用标准库,后者声明中容量
Include PathsCMSIS;FWLIB/inc;HARDWARE;USER漏一个就是一堆未定义
DebugST-Link / CMSIS-DAP按手上的仿真器选
Flash Download勾选 Reset and Run烧完自动复位运行
HSE_VALUE8000000板载晶振是 8M 就填 8M

STM32F10X_MD这个宏经常被忽略。工程的启动文件是startup_stm32f10x_md.s,对应中容量(64K/128K Flash),如果编译宏里写的是STM32F10X_HD,链接时中断向量表的长度对不上,烧进去能跑但中断一进就飞,现象是“主循环正常、定时器中断完全不响应”,第一次遇到很容易怀疑人生。

烧录不一定非得用 IDE,命令行更顺手:

# 用 st-flash 烧 bin,起始地址固定为 0x08000000 st-flash --reset write OBJ/SmartCar.bin 0x08000000 # 用 OpenOCD 走 SWD,适合接自动化脚本 openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c "program OBJ/SmartCar.elf verify reset exit"

烧录前确认 BOOT0 拉低、BOOT1 随意(F103 上 BOOT1 与 PB2 复用,一般不用管)。如果仿真器能连上但烧录报“Flash Download failed”,八成是芯片被读保护了,用st-flash erase全片擦一次再烧。

5.2 上车前的分级调试顺序

我的习惯是分五步,每一步都确认通过再进下一步,不要一上来就把车放赛道上跑:

  1. 断电测通断。万用表蜂鸣档量电机线、传感器线、电源线,确认没有虚焊和短路,特别是 L298N 的输出端,接反了上电就烧。
  2. 只烧点灯程序。确认烧录链路、时钟配置、下载算法全对,LED 能按 1Hz 闪。
  3. 架空测电机。把车架起来,串口发命令让左轮正转、反转、右轮正转、反转,四个方向逐个确认。这一步能提前发现接线反了、方向宏定义写反的问题。
  4. 串口打印传感器。车放在赛道上方 1cm 处,手动左右平移,看串口打印的五路状态是否跟预期一致,顺便把黑线极性确认下来。
  5. 低速上赛道。基速先给 30,PID 的 kp 从 30 开始往上加,直到车能勉强跟着线走,再逐步提速、加 kd 压震荡。

这个顺序看起来啰嗦,但它能把问题隔离在最小的范围里。跳过第 3 步直接上赛道,一旦车不动,你没法立刻判断是 PWM 没出来、还是电机线断了、还是 PID 输出被限幅成 0。

5.3 联调时的五条踩坑记录

第一条:上电后两个轮子反向转,车原地打转。现象是车身不动,两个轮子一个正转一个反转。原因基本是两个:要么左右电机的接线插反了,要么Motor_Set里右轮的方向脚写成了和左轮一样。解决办法是架空之后单独发“左轮正转”命令,看哪个轮子转,确认物理映射,再检查DIR_R_IN3/IN4的置位顺序是否与 L298N 实际接线对应。

第二条:电机发出啸叫,低速时一顿一顿。现象是车能走,但声音刺耳,速度越低抖得越厉害。原因是 PWM 频率落在音频范围(1~8kHz),加上电源和单片机没有共地,PWM 信号的参考电平漂移。解决办法是把预分频改成 72、周期 50,频率顶到 20kHz,同时把 L298N 的 GND、电池负极、单片机 GND 三根线拧在一起,共地点只留一个。

第三条:直道很稳,一进弯就冲出赛道。现象是直道上跑得挺好,到弯道口直接切出去。原因通常是控制周期太长,或者误差计算被消抖逻辑拖慢了。原来 30ms 才确认一次状态,车速一快,30ms 已经跑出去好几厘米。解决办法是把控制周期压到 10ms,消抖窗口改成“连续两次一致”,同时把弯道基速从 65 降到 45。

第四条:串口打印全是乱码。现象是助手里看到的都是问号或者方块。原因九成是波特率不匹配,剩下一成是外部晶振的实际频率和HSE_VALUE宏不一致。手上如果是一块 12M 晶振的板子,宏里还写 8000000,串口分频算出来就是错的。解决办法是先确认板上晶振丝印,再改HSE_VALUE和SystemInit里的 PLL 倍频系数,改完重新编译。

第五条:烧录成功但不运行,断电再上电才正常。现象是 IDE 提示下载成功,车没反应,拔掉仿真器重新上电就好了。原因是勾了“Reset and Run”但仿真器的复位信号没接到板子上,或者 BOOT0 悬空导致启动模式不确定。解决办法是把 BOOT0 用 10k 电阻下拉到地,同时在 Keil 的 Flash Download 设置里勾上“Reset and Run”,仿真器复位脚该接就接。

6. 二次开发:给循迹车加一套串口在线调参

跑通之后最容易冒出来的念头就是“能不能不重新烧录就改 PID”。答案是能,而且改动量不大,核心是在串口中断里收命令、在主循环里解析,把三个系数做成可写的全局变量。

/* usart.c —— 极简命令解析,格式 "P80" / "I4" / "D30" / "B65" */ void Cmd_Poll(void) { if (g_cmd_ready == 0) return; g_cmd_ready = 0; switch (g_cmd_buf[0]) { case 'P': s_pid.kp = (int16_t)atoi(&g_cmd_buf[1]); break; case 'I': s_pid.ki = (int16_t)atoi(&g_cmd_buf[1]); break; case 'D': s_pid.kd = (int16_t)atoi(&g_cmd_buf[1]); break; case 'B': g_base_speed = (int16_t)atoi(&g_cmd_buf[1]); break; case 'S': printf("kp=%d ki=%d kd=%d base=%d\r\n", s_pid.kp, s_pid.ki, s_pid.kd, g_base_speed); break; default: break; } }

串口中断里只做“收一个字节、存进缓冲区、遇到回车置标志”,解析放在主循环,中断执行时间控制在几微秒。这样调参时车可以一直在赛道上跑,边跑边改,改完立刻能看到行为变化,比反复烧录快十倍不止。要注意的是,改ki之前先把s_pid.out清零,否则积分项会带着上一组参数的残留值,表现是改完参数后车突然朝一边猛拐一下。

再往下走一步,可以加路径记忆:每 10ms 把Track_CalcError的结果连同状态码压进一个环形缓冲区,跑完一圈通过串口整包导出,在电脑上画成误差曲线。曲线比现场观察直观得多,哪里震荡、哪里响应慢、哪里脱线,一眼就能看出来。缓冲区大小按 10ms 一个样本、一圈 30 秒算,大约需要 3000 个样本,F103C8 的 20K RAM 完全放得下。

验证改造是否成功有一套固定流程:先架空,用串口发S确认参数回读正确;再低速上赛道,只改kp,看震荡临界点;然后固定kp,加kd压超调;最后加ki消除直道偏移。每一步只动一个参数,同时记下当前的数值和现象,这样出了问题可以立刻回滚到上一组。

有个教训值得说一下。早些年调这类小车,我总想一次性把三个参数都调到位,结果车在赛道上表现忽好忽坏,根本判断不出是哪个系数在捣乱,白白耗掉一整个下午。从那以后我每次改参数都强制走一遍单变量流程,改一个、跑三圈、记现象,确认了再动下一个。希望这套流程和这份源代码,能帮你少走几个弯路。

本文还有配套的精品资源,点击获取