ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于STM32C8T6的循迹避障小车:从硬件选型到算法调参的完整实战

基于STM32C8T6的循迹避障小车:从硬件选型到算法调参的完整实战

简介:这是一份面向嵌入式初学者与电子竞赛选手的STM32循迹避障小车完整工程,基于STM32F103C8T6最小系统板搭建,采用L298N驱动两路直流电机(12V供电),通过Timer3输出两路PWM实现调速,并集成四路红外循迹、超声波测距配合舵机自动转向的避障方案,同时支持手机蓝牙调试助手远程控制小车运动方向。资源包共227个文件,以c源码、h头文件、o目标文件、crf交叉引用文件及s启动文件为主,另含hex烧录文件、uvprojx工程文件与map映射文件,压缩包约7.38MB,工程结构完整、代码注释详尽,便于对照理解各外设驱动与主控逻辑。目前已有1677人学习下载,适合希望掌握电机驱动、PWM调速、传感器融合与蓝牙通信等综合应用的读者参考实践。

1. 从一堆散件到能跑的车:循迹避障小车的真实门槛在哪

很多人第一次拿到 STM32C8T6 最小系统板、几块钱的红外对管、一个 L298N 驱动模块和四节 18650 电池时,脑子里想的是「焊完就能跑」。实际通电那一刻,车要么原地打转,要么直冲桌角,要么循迹时在十字路口反复横跳。基于 STM32C8T6 的循迹避障小车,核心不是把模块插满,而是把「感知—决策—执行」这条链路调稳:红外对管告诉你黑线在哪,超声波告诉你前方有没有墙,STM32 用定时器和中断把这两路信息揉成左右轮的速度差,L298N 再把 PWM 变成电机力矩。它适合已经学过 GPIO 和定时器、想找一个能把中断、PWM、传感器滤波串起来的综合练手项目的人。如果你连 Keil 新建工程、下载器接线都还没跑通,先补那一步,否则后面每一步都是玄学。

2. 硬件选型与接线:为什么 C8T6 够用,以及哪些模块不能省

2.1 主控、驱动、传感器的三角关系

STM32C8T6 是 48 脚封装,GPIO 数量对于「两路电机 PWM + 四路循迹输入 + 一路超声波 Trig/Echo + 一路调试串口」刚好够用,不需要上 C8T6 以上的型号。选它的理由很直接:定时器资源够,TIM1 出四路 PWM 中的两路给左右电机,TIM2 做超声波回波捕获,TIM3 做循迹采样定时,剩下的 SysTick 给主循环节拍。驱动模块常见做法是 L298N 或 TB6612,前者便宜、发热大、压降明显,后者效率高但接线更密。如果你用四节 18650 串联(约 14.8V),L298N 的 5V 输出还能给 C8T6 供电,但电机启动瞬间的电流跌落会让主控复位,所以主控最好单独一路 5V 降压。循迹传感器用 TCRT5000 红外对管,输出数字量,黑线吸光、白底反光,调节电位器让指示灯在黑白交界处刚好翻转。避障用 HC-SR04,Trig 发 10us 高电平,Echo 回高电平,高电平持续时间除以 58 就是厘米数。

2.2 接线表与供电去耦

下面这张表是我实际接线时用的映射,你可以按自己板子改,但别把电机电源和主控电源混在一起。

功能STM32C8T6 引脚外设引脚备注
左电机 PWMPA8 (TIM1_CH1)L298N ENA频率 10kHz
左电机方向PB0L298N IN1高电平正转
左电机方向PB1L298N IN2与 IN1 反相
右电机 PWMPA9 (TIM1_CH2)L298N ENB频率 10kHz
右电机方向PB10L298N IN3高电平正转
右电机方向PB11L298N IN4与 IN3 反相
循迹左PA0TCRT5000 DO上拉输入
循迹中左PA1TCRT5000 DO上拉输入
循迹中右PA2TCRT5000 DO上拉输入
循迹右PA3TCRT5000 DO上拉输入
超声波 TrigPB6HC-SR04 Trig推挽输出
超声波 EchoPB7HC-SR04 Echo浮空输入/捕获
调试串口 TXPA9 复用前先确认CH340 RX波特率 115200

注意:PA9 如果同时被 TIM1_CH2 和串口占用,要么换串口到 USART2,要么换 PWM 到 TIM3。我一般把串口放 USART2 的 PA2/PA3,但 PA2/PA3 又被循迹占了,所以最终方案是循迹用 PB12~PB15,串口用 PA9/PA10,PWM 用 TIM3_CH1/CH2 的 PB4/PB5。接线前先在纸上把复用关系画一遍,比烧录后调不出来再回头查省两个小时。

2.3 电机与轮径的机械匹配

很多人忽略轮径和减速比。常见黄色 TT 马达配 65mm 轮,空载转速约 200rpm,实际带车约 120rpm,对应线速度约 0.4m/s。循迹时如果速度超过 0.6m/s,红外对管的响应时间(约 10ms)加上主循环周期,会让车在弯道直接冲出去。所以 PWM 占空比上限我一般限制在 70%,给纠偏留余量。轮距也要量,左右轮中心距 12cm 和 15cm 的转向灵敏度差很多,代码里的差速系数要跟着改。

3. 让车自己找线:循迹算法的状态机与 PWM 差速实现

3.1 四路红外怎么读成「偏差量」

四路循迹的输出是四个数字量,黑线为 1(或 0,取决于模块),白底相反。不要用 if-else 硬编码十六种情况,而是把四路加权成一个偏差值。比如从左到右权重 -3、-1、+1、+3,检测到黑线的通道权重相加,得到 error。error 为 0 表示居中,负值偏左,正值偏右。这样即使只有两路压线,也能算出连续偏差,比离散状态机平滑。

// 循迹偏差计算,sensor[0]最左,sensor[3]最右 // 黑线输出为1,白底为0 int16_t get_line_error(void) { int16_t weight[4] = {-3, -1, 1, 3}; int16_t error = 0; uint8_t count = 0; for (int i = 0; i < 4; i++) { if (sensor[i] == 1) { // 检测到黑线 error += weight[i]; count++; } } if (count == 0) return 0; // 丢线,交给上层处理 return error / count; // 归一化,避免多路压线时偏差过大 }

这段代码的关键在最后除以 count。如果不除,四路同时压线时 error 为 0,但两路压线时 error 可能是 -2 或 +2,差速会突然变大。归一化后,偏差范围稳定在 -3 到 +3 之间,方便映射到 PWM 增量。丢线时返回 0 是权宜之计,实际要在主循环里判断 count==0 持续超过 200ms 就进入「丢线找回」状态:减速、原地小角度旋转,直到重新检测到黑线。

3.2 差速 PWM 的映射与限幅

得到 error 后,基础速度 base 设为 50%(对应 TIM1 的 CCR 值 500,假设 ARR=999),左右轮速度:

// base_speed 范围 0~1000,对应 0~100% 占空比 // Kp 是比例系数,先取 80,再根据弯道调 void set_motor_by_error(int16_t error) { int16_t base_speed = 500; // 50% int16_t Kp = 80; int16_t left = base_speed - Kp * error; int16_t right = base_speed + Kp * error; // 限幅,防止负数或超过 ARR if (left < 0) left = 0; if (left > 999) left = 999; if (right < 0) right = 0; if (right > 999) right = 999; __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, left); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, right); }

Kp 从 80 开始调:太小则弯道反应慢,车会压线甚至冲出去;太大则直道左右抖动,像喝醉。调的时候先让车低速跑,用串口把 error 和左右 PWM 打出来,看直道 error 是否在 -1 到 +1 之间波动,弯道是否能及时给出 ±3 的偏差。如果直道就抖,先把 Kp 降到 50,再检查红外对管高度——离地 1cm 左右最稳,太高会受环境光干扰,太低会蹭到地面。

3.3 定时器中断里的采样节拍

循迹采样不要放在主循环里裸跑,主循环里还有超声波和串口打印,周期不稳定。我一般用 TIM3 每 5ms 中断一次,在中断里读四路 GPIO 并计算 error,存到全局变量。主循环只负责根据 error 更新 PWM。这样采样周期固定,滤波也好做:连续三次读到相同 error 才更新,避免单次干扰导致电机抽搐。

// TIM3 中断回调,5ms 一次 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { static int16_t last_error = 0; int16_t raw = get_line_error(); // 简单中值滤波:连续两次相同才采纳 static int16_t prev_raw = 0; if (raw == prev_raw) { last_error = raw; } prev_raw = raw; g_line_error = last_error; } }

这里的中值滤波只做了一层,实际如果环境光变化大,可以改成连续三次采样取中间值。注意中断里不要做浮点运算和串口打印,否则会阻塞其他中断。

4. 避障与循迹的优先级:超声波怎么不跟循迹打架

4.1 超声波测距的触发与捕获

HC-SR04 的 Echo 高电平时间用输入捕获测最准。TIM2 配置成捕获模式,Trig 发 10us 脉冲后,Echo 上升沿捕获到 CCR1,下降沿捕获到 CCR2,差值乘以计数周期就是时间。如果不想用捕获,也可以用外部中断加定时器计数,但精度差一些。测距周期不要低于 60ms,否则上一次的回波还没消失,下一次触发会收到余波,读数跳变。

// 超声波测距,返回厘米,超时返回 -1 float get_distance_cm(void) { uint32_t start, end; HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); delay_us(10); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); // 等待 Echo 上升沿,超时 30ms uint32_t timeout = 30000; while (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_7) == GPIO_PIN_RESET) { if (--timeout == 0) return -1; delay_us(1); } start = __HAL_TIM_GET_COUNTER(&htim2); timeout = 30000; while (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_7) == GPIO_PIN_SET) { if (--timeout == 0) return -1; delay_us(1); } end = __HAL_TIM_GET_COUNTER(&htim2); uint32_t ticks = (end >= start) ? (end - start) : (0xFFFF - start + end); return ticks / 58.0f; // 72MHz 下 1us 约 58 个计数,具体看预分频 }

这段代码用轮询而不是捕获,好处是逻辑直白,坏处是占用 CPU。实际跑的时候主循环里每 100ms 调一次,期间循迹中断照常跑,不会冲突。注意 delay_us 要用定时器实现,不要用空循环,否则编译器优化等级一变,延时就不准了。

4.2 避障优先级的仲裁逻辑

循迹和避障同时开的时候,必须定优先级。我的做法是:超声波距离小于 20cm 时,避障优先,强制覆盖循迹的 PWM 输出,让车停下或后退转向;距离在 20~40cm 之间,减速并把循迹的 base_speed 降到 30%;大于 40cm 才全速循迹。这样不会出现「一边循迹一边撞墙」的尴尬。

// 主循环里的仲裁 float dist = get_distance_cm(); if (dist > 0 && dist < 20.0f) { // 避障优先:停车,然后右转 set_motor(0, 0); HAL_Delay(200); set_motor(400, -400); // 原地右转 HAL_Delay(300); } else if (dist >= 20.0f && dist < 40.0f) { // 减速循迹 g_base_speed = 300; set_motor_by_error(g_line_error); } else { g_base_speed = 500; set_motor_by_error(g_line_error); }

注意 set_motor 里要处理负数,负数表示反转,L298N 的 IN1/IN2 要对应翻转。原地右转时左轮正转、右轮反转,PWM 绝对值给 400 左右,太大容易烧驱动。

4.3 传感器融合的时序安排

循迹 5ms 一次,超声波 100ms 一次,串口打印 500ms 一次。这三个周期不要用同一个定时器,否则中断嵌套会乱。TIM3 给循迹,TIM4 给超声波触发,主循环里做串口打印。超声波测距期间会关中断吗?不会,但 Echo 等待循环里如果被循迹中断打断,测出来的时间会偏大。所以我在超声波测距前先关掉循迹中断,测完再开,虽然会丢一两次循迹采样,但 100ms 内丢 1 次 5ms 采样,对循迹影响可以忽略。

5. 避坑与排查:从「能跑」到「跑得稳」的五个血泪教训

5.1 电机一转,主控就复位

现象:下载完程序,车不动的时候串口正常,一给 PWM 电机启动,串口就断,主控重启。 原因:电机启动电流瞬间拉到 1A 以上,电池电压跌落,L298N 的 5V 输出跟着掉,C8T6 的欠压复位触发。 解决:主控单独用一路 5V 降压模块(比如从电池经 MP1584 降到 5V),电机电源和主控电源只在电池端共地,不共用 L298N 的 5V。同时在 C8T6 的 5V 和 GND 之间并一个 470uF 电解电容加 0.1uF 陶瓷电容。

5.2 循迹在直道画龙,弯道冲出去

现象:直道上车左右摆,弯道上不减速直接冲出赛道。 原因:Kp 太大导致直道过调,弯道时 error 饱和但 base_speed 没降,差速不够。 解决:直道抖动先把 Kp 从 80 降到 50,再检查红外对管是否四路高度不一致。弯道冲出去要在 error 绝对值大于 2 时主动降 base_speed,比如 base_speed = 500 - 100 * abs(error),让车在急弯自动减速。

5.3 超声波读数偶尔跳成 0 或 999

现象:串口打印的距离大部分正常,偶尔出现 0cm 或 999cm。 原因:Echo 等待循环没有超时保护,或者周围有同频超声波干扰(比如另一辆车也在测距)。 解决:等待循环加超时,超时返回 -1,上层丢弃这次读数。连续三次读数取中间值。如果两辆车同时跑,把触发周期错开,比如一辆 60ms,另一辆 70ms。

5.4 丢线后原地打转找不回来

现象:车冲出赛道后原地转圈,再也回不到线上。 原因:丢线判断只看了 count==0,没有限制旋转方向和时长。 解决:丢线后先停车 200ms,然后按最后一次 error 的方向小角度旋转(比如左转 30 度),再直行一小段,如果还没找到线就反向转。旋转 PWM 不要超过 400,否则转太快红外对管来不及采样。

5.5 电池电量下降后行为完全变了

现象:满电时跑得好好的,电压降到 7V 左右时循迹迟钝、避障距离变短。 原因:电机转速下降,同样的 PWM 占空比对应线速度变慢,但循迹的 Kp 和 base_speed 是写死的。 解决:加一路电池电压检测(分压到 ADC),电压低于阈值时自动把 base_speed 提高 10%,或者干脆换更好的电池。更省事的做法是每次跑之前用满电电池,别在低电量下调参数。

6. 进阶调参与验证:用串口波形把「感觉」变成数据

调车最怕凭感觉。我后来养成一个习惯:所有关键变量都通过串口打到上位机,用波形看。具体做法是每 20ms 发一帧,格式为「error, left_pwm, right_pwm, distance」,波特率 115200,用任意串口示波器工具解析。这样直道抖动、弯道响应、避障触发时机都一目了然。

// 每 20ms 调用一次,打印 CSV 格式 void debug_output(void) { static uint32_t last_tick = 0; if (HAL_GetTick() - last_tick < 20) return; last_tick = HAL_GetTick(); printf("%d,%d,%d,%.1f\r\n", g_line_error, __HAL_TIM_GET_COMPARE(&htim1, TIM_CHANNEL_1), __HAL_TIM_GET_COMPARE(&htim1, TIM_CHANNEL_2), g_last_distance); }

看波形时重点看三个地方:直道 error 是否在 ±1 内高频小幅波动,如果是,Kp 还可以再降;弯道 error 从 0 变到 ±3 的时间是否小于 100ms,如果大于,采样周期要缩短或 Kp 要加;避障触发时 left_pwm 和 right_pwm 是否立刻归零,如果有延迟,检查主循环里超声波调用是不是被其他延时阻塞了。

另一个验证方法是把车架起来,轮子悬空,用手转动轮子模拟地面摩擦,看 PWM 变化是否跟手。这个方法能快速排除机械卡死和驱动接线错误。我一般先架空调参,再落地跑,能省一半调试时间。

最后说一个我踩过的坑:不要一边充电一边跑。充电模块的共地噪声会让红外对管误判,车会突然抽风。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进