最近在智能车竞赛的备赛圈子里,最常听到的一句话就是“我们的进度要完蛋了”。尤其是第21届全国大学生智能车竞赛的节奏逐步推进之后,不少队伍发现自己还停在“车能走直线”或者“图像还没调稳”的阶段,而别人已经在跑元素、刷圈速了。这种对比确实容易让人焦虑。但作为经历过完整备赛周期的人,我想说一句:大多数所谓“进度完了”,其实并不是真的完了,而是任务拆分不清楚、调试效率太低、没有抓住优先级。
这篇文章不是来给谁灌鸡汤的,而是围绕智能车竞赛中最常见的“进度失控”场景,整理一套从系统拆解、模块开发、联调排错到冲刺管理的完整思路。里面会给出可以直接参考的代码结构、调试方法、常见问题排查表,以及最后阶段如何合理排优先级。无论是正在准备21届智能车比赛,还是后面几届想提前规划的同学,都可以把这篇文章当作一份“备赛避坑手册”来用。
1. 为什么你的进度会失控
1.1 进度焦虑的根源
每当比赛临近,进度焦虑几乎是所有参赛队的共同状态。但冷静下来分析,你会发现进度失控通常不是某一个模块做不完,而是下面几类问题叠加在一起:
- 任务没有按系统拆解,想到哪做到哪。
- 硬件和软件并行开发,互相等待。
- 调试工具不完善,出现问题只能靠猜。
- 一开始追求完美算法,忽视了“先跑起来再优化”的原则。
- 没有为机械、电池、线材等“非代码因素”预留时间。
智能车竞赛有一个特点:你永远不可能把所有参数调到最优,但你可以通过合理的工程管理,让车在一个可接受的稳定状态下进入下一轮调试。进度焦虑的本质,是把“不知道怎么做”和“还没做”混在了一起。前者需要通过学习和实验解决,后者只需要排列优先级并执行。
1.2 一辆智能车到底包含哪些部分
很多队伍进度乱,是因为一上来就写图像处理、写PID,却忽略了系统整体结构。先把系统拆开,你才知道自己的进度到底卡在哪里。
一辆典型的摄像头/光电组智能车,按功能可以拆成五个层面:
| 层面 | 核心内容 | 主要风险点 |
|---|---|---|
| 机械层 | 车模组装、重心、轮胎、悬挂、舵机安装 | 装好不改,否则后面全部白调 |
| 硬件层 | 主控、摄像头/传感器、电机驱动、电源、编码器 | 供电不稳,接错线,烧板子 |
| 控制层 | 电机速度环、舵机转向环、PID参数 | 参数不收敛,车发飘 |
| 感知层 | 图像采集、灰度/二值化、赛道元素识别 | 图像延迟大、误判严重 |
| 策略层 | 状态机、速度规划、元素处理 | 逻辑混乱,越改越差 |
如果当前进度落后,第一步不是写新代码,而是对照这张表,逐项确认“哪一层还没有跑通”。大多数队伍卡在感知层和控制层的衔接上:图像已经能看到了,但车不知道怎么根据图像转向;或者PID只写了速度环,转向还在开环。
2. 环境准备与版本说明
2.1 开发环境怎么选
智能车主控目前以 STM32 系列为主流,也有部分队伍使用恩智浦、灵动微或者其他国产芯片。这里以 STM32 为例,环境选择思路是通用的:
- 如果是 STM32,推荐用 STM32CubeMX 生成初始化代码,再配合 Keil MDK 或者 IAR 进行编译下载。
- 如果熟悉寄存器操作,也可以直接用标准外设库或 HAL 库,但建议新手优先使用 CubeMX,因为它能减少底层配置出错概率。
- 上位机推荐串口助手或者自定义的 Python 脚本,用于实时查看图像和参数曲线。
版本需要根据你的实际芯片和开发环境调整,本文示例以常见环境为例,重点演示思路。不要照搬具体版本号,因为芯片型号、库版本不同,代码细节会有差异。
2.2 调试工具清单
进度慢的队伍普遍有一个特征:调试工具只有一根下载线。车跑歪了,屏幕没有数据,只能靠肉眼猜测。下面的工具至少应该配齐:
- 串口模块或USB转TTL,用于输出日志。
- OLED 屏幕或蓝牙模块,用于显示关键参数。
- 逻辑分析仪或示波器,用于排查编码器信号、PWM 波形问题。
- 独立电源开关和电流表,用于检测堵转和短路。
- 摄像头图像回传工具,很多视觉处理板卡或配套上位机支持实时查看二值化图像。
不需要所有工具一步到位,但串口输出和图像回传是强烈建议优先准备的。调试效率直接决定你剩下的时间够不够。
2.3 项目目录结构建议
无论你用哪个 IDE,都可以按照模块化思路组织源码,避免所有代码堆在一个 main.c 里。推荐结构如下:
project/ ├── Core/ │ ├── Inc/ │ └── Src/ ├── Drivers/ ├── App/ │ ├── control/ │ │ ├── pid.c │ │ └── motor.c │ ├── sensor/ │ │ ├── camera.c │ │ └── image_process.c │ ├── strategy/ │ │ └── state_machine.c │ └── debug/ │ └── debug_uart.c这样做的目的是让每个模块可以单独测试。比如 PID 写好了,可以先不给舵机输出,只在串口打印计算值,确认逻辑正确后再接硬件。这个习惯能避免“不知道是代码问题还是硬件问题”的尴尬局面。
3. 核心模块开发顺序:先求能跑,再求快
如果进度已经很紧张,我的建议是不要按书本目录从底层开始学,而是按“控制闭环→感知→策略→提速”的顺序推进。先把车变成一个“能根据输入信号稳定行动的基础平台”,再往上加识别和策略。
3.1 第一步:让电机形成闭环
很多队伍第一版代码是开环给PWM,车能跑,但速度不受控,坡道、电池电压下降都会导致速度变化,后面所有算法都会受影响。所以第一步应该把电机的速度闭环做起来。
基础硬件配置包含:电机驱动(常见如BTN7971、DRV8701)、直流减速电机、编码器。编码器一般接在定时器的编码器模式引脚上。
下面以 STM32 HAL 库为例,给出编码器配置思路:
// 文件路径:App/control/motor.c(核心片段) // 假设编码器A相接TIM3_CH1,编码器B相接TIM3_CH2 void Encoder_Init(void) { TIM_Encoder_InitTypeDef encoder_cfg = {0}; TIM_MasterConfigTypeDef master_cfg = {0}; __HAL_RCC_TIM3_CLK_ENABLE(); htim3.Instance = TIM3; htim3.Init.Prescaler = 0; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 65535; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; encoder_cfg.EncoderMode = TIM_ENCODERMODE_TI12; encoder_cfg.IC1Polarity = TIM_ICPOLARITY_RISING; encoder_cfg.IC1Selection = TIM_ICSELECTION_DIRECTTI; encoder_cfg.IC1Prescaler = TIM_ICPSC_DIV1; encoder_cfg.IC1Filter = 0; encoder_cfg.IC2Polarity = TIM_ICPOLARITY_RISING; encoder_cfg.IC2Selection = TIM_ICSELECTION_DIRECTTI; encoder_cfg.IC2Prescaler = TIM_ICPSC_DIV1; encoder_cfg.IC2Filter = 0; HAL_TIM_Encoder_Init(&htim3, &encoder_cfg); HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL); }读取速度时,只需要读取定时器计数器的值,并做差频计算:
int16_t speed_count = (int16_t)__HAL_TIM_GET_COUNTER(&htim3); __HAL_TIM_SET_COUNTER(&htim3, 0);然后写一个速度环 PID。这里给出一个通用位置式 PID 控制器,公式和实现都比较直观:
// 文件路径:App/control/pid.c typedef struct { float kp; float ki; float kd; float integral; float last_error; float output_max; float integral_max; } PidObject; float PID_Update(PidObject *pid, float target, float current) { float error = target - current; pid->integral += error; // 积分限幅,防止长时间误差积累导致积分饱和 if (pid->integral > pid->integral_max) pid->integral = pid->integral_max; else if (pid->integral < -pid->integral_max) pid->integral = -pid->integral_max; float diff = error - pid->last_error; pid->last_error = error; float output = pid->kp * error + pid->ki * pid->integral + pid->kd * diff; // 输出限幅 if (output > pid->output_max) output = pid->output_max; else if (output < -pid->output_max) output = -pid->output_max; return output; }这里需要注意的是:电机输出的 PWM 占空比与电压相关,PID 输出需要映射到 PWM 的 CCR 范围。通常还要根据方向引脚决定正反转。示例代码如下:
void Motor_SetOutput(uint8_t dir_pin, uint8_t pwm_pin, int16_t output) { if (output >= 0) { HAL_GPIO_WritePin(GPIOA, dir_pin, GPIO_PIN_SET); __HAL_TIM_SET_COMPARE(&htim1, pwm_pin, output); } else { HAL_GPIO_WritePin(GPIOA, dir_pin, GPIO_PIN_RESET); __HAL_TIM_SET_COMPARE(&htim1, pwm_pin, -output); } }速度闭环做好的标志是:给定固定目标速度,代码输出稳定,转速不随电池电压下降而明显漂移。达到这个标准,才进入下一阶段。
3.2 第二步:转向环与基本循迹
电机速度闭环之后,接下来是转向。常见方案有两种:
- 基于摄像头/灰度传感器的偏差信号得到赛道偏差。
- 根据偏差映射到舵机PWM占空比。
对于摄像头组,默认你已经获得了图像中赛道中线的偏差。这里先不纠结图像处理,重点看如何根据偏差控制舵机:
// 文件路径:App/control/steer.c(核心片段) // 输入偏差范围假设为 -100 ~ +100,输出为舵机PWM比较值 uint16_t Steer_Control(int16_t error) { int16_t center = 7500; // 中位PWM,需要根据实际舵机标定 int16_t range = 3000; // 最大转向范围 int16_t output = center + error * range / 100; if (output > center + range) output = center + range; if (output < center - range) output = center - range; return (uint16_t)output; }实际调试中,舵机中位非常重要。如果中位不准,车会一直往一边偏。建议在代码里加上舵机中位和左右限位的显示,方便机械调整。
3.3 第三步:图像处理与赛道中线提取
图像处理是很多队伍进度崩掉的重灾区。如果你当前卡在这里,先不要急着写复杂的透视变换和寻找赛道边界,而是按以下顺序实现一个“能用”的版本:
- 读取摄像头图像,转为灰度数组。
- 对灰度图像做二值化,区分赛道和赛道外。
- 从图像底部向上扫描,提取每一行的赛道中心位置。
- 得到中线数组后,计算近处平均偏差,作为转向控制输入。
二值化最简单高效的方法是固定阈值,但光线变化时容易失效。可以改用大津法(Otsu)自动计算阈值。核心思路是最大化类间方差,下面是简化版实现:
// 文件路径:App/sensor/image_process.c(核心片段) // 假设灰度图像为 img,宽度为 W,高度为 H,灰度范围 0~255 uint8_t Otsu_Threshold(uint8_t *img, int W, int H) { int histogram[256] = {0}; int total = W * H; for (int i = 0; i < total; i++) { histogram[img[i]]++; } float sum = 0; for (int i = 0; i < 256; i++) { sum += (float)i * histogram[i]; } float sum_bg = 0; int weight_bg = 0; float max_var = 0; uint8_t threshold = 0; for (int t = 0; t < 256; t++) { weight_bg += histogram[t]; if (weight_bg == 0) continue; int weight_fg = total - weight_bg; if (weight_fg == 0) break; sum_bg += (float)t * histogram[t]; float mean_bg = sum_bg / weight_bg; float mean_fg = (sum - sum_bg) / weight_fg; float var_between = (float)weight_bg * (float)weight_fg * (mean_bg - mean_fg) * (mean_bg - mean_fg); if (var_between > max_var) { max_var = var_between; threshold = (uint8_t)t; } } return threshold; }得到阈值后做二值化,然后提取赛道边界。这部分通常需要针对你的赛道颜色和光线做多次试验,不必追求完美,只要在比赛场地能稳定识别中线即可。
3.4 第四步:状态机与元素处理
当车已经能稳定循迹,再加入元素识别和状态机。元素包括十字路口、环岛、坡道、路障、断路等,不同组别元素不同。
这里不建议把所有判断逻辑堆在中断或者主循环的 if-else 里。建议建立一个简单的状态机:
// 文件路径:App/strategy/state_machine.c typedef enum { STRAIGHT, CURVE, CROSS, RAMP, FINISH } TrackState; TrackState g_state = STRAIGHT; void StateMachine_Update(void) { switch (g_state) { case STRAIGHT: if (Check_Curve_Enter()) { g_state = CURVE; } break; case CURVE: if (Check_Curve_Exit()) { g_state = STRAIGHT; Set_Target_Speed(BASE_SPEED); } break; case CROSS: // 十字处理逻辑 break; case RAMP: // 坡道处理逻辑 break; default: break; } }状态机的核心价值在于:每个状态只关注“进入条件”和“退出条件”,不会因为某个元素处理失败导致整车逻辑混乱。这也是调试效率提升的关键。
4. 调试效率才是救命稻草
4.1 串口输出与 printf 重定向
进度落后的队伍,往往在调试时没有数据支撑。第一步是打通串口输出。以 STM32 HAL 库为例,可以重定向 printf 到串口:
// 文件路径:App/debug/debug_uart.c #include <stdio.h> UART_HandleTypeDef huart5; int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart5, (uint8_t *)&ch, 1, 100); return ch; }之后你就能在代码里用 printf 输出中间变量:
printf("err=%d, speed=%d, steer=%d\r\n", error, current_speed, steer_output);有了串口日志,很多“玄学问题”会变成可分析的问题。注意串口波特率要和上位机一致,常用 115200 或 460800。
4.2 图像回传:调车不靠猜
摄像头组的调试,强烈建议做图像回传。最简单的方案是通过串口发送二值化后的图像数组,上位机用 Python 或配套软件还原图像。下面是一个极简的 Python 可视化思路:
import serial import cv2 import numpy as np ser = serial.Serial('COM3', 460800, timeout=1) while True: # 根据协议读取一帧图像数据,这里演示单行数据 line = ser.readline().decode('utf-8', errors='ignore').strip() # 示例格式:"row, col0 col1 col2 ..." if line.startswith("ROW"): parts = line.split() row = int(parts[0][3:]) values = list(map(int, parts[1:])) img[row, :, 0] = np.array(values, dtype=np.uint8) img[row, :, 1] = np.array(values, dtype=np.uint8) img[row, :, 2] = np.array(values, dtype=np.uint8) cv2.imshow("binary_image", img) if cv2.waitKey(1) & 0xFF == ord('q'): break这个示例只是思路,具体协议需要根据你的发送代码调整。关键是:看到现场图像,比任何猜测都有效。
4.3 用数据曲线观察 PID 响应
调整 PID 参数时,尽量输出“目标值、实际值、PID输出”三组数据。用串口绘图工具或写一个小的 Python 脚本绘制曲线,判断超调、震荡、稳态误差。
一个实用的调参经验:
- 先调 P,让系统能接近目标值但允许震荡。
- 再调 D,抑制超调和震荡。
- 最后加 I,消除稳态误差。
- 每次只变一个参数,记下变化前后的现象。
不要同时改三个参数,否则出问题了你根本不知道是哪个参数导致的。
5. 常见问题与排查思路
进度紧张的时候,最怕的就是被一个 bug 卡住大半天。这里整理一份智能车调试排错表,供大家快速定位问题。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 电机不受控制,PWM输出异常 | 定时器通道配置错误,或占空比比较值方向反了 | 检查CubeMX定时器配置,用示波器看PWM波形 |
| 编码器读数一直为0 | 编码器接线错误、定时器未启用编码器模式 | 用逻辑分析仪检查A/B相波形,确认引脚配置 |
| 速度环震荡,电机嗡嗡响 | PID参数过大,或控制周期不合理 | 降低P值,增加D值,确保控制周期稳定 |
| 图像一片黑或一片白 | 二值化阈值不对,或摄像头曝光参数不合适 | 先查看原始灰度图,再调整自动阈值范围 |
| 图像中心线与实际赛道偏差大 | 摄像头安装角度、透视关系没标定 | 固定安装后,在直道重新采样标定 |
| 舵机转向滞后明显 | 中位不对,或PID输出被限幅 | 先标定舵机中位,再检查转向控制输出范围 |
| 电池电量掉得快 | 电机堵转、驱动电路效率低 | 检查机械阻力,避免过度抱死车轮 |
| 跑几圈后参数漂移 | 电池电压变化导致输出能力变化 | 速度闭环必须做好,必要时加入电压补偿 |
| 程序下载失败 | 调试器连接不稳或芯片锁定 | 检查接线,尝试按住复位再下载,必要时用ISP擦除 |
排查思路永远是:先确认硬件输入(电源、信号),再确认软件逻辑(变量、分支),最后再调数值参数。不要一上来就怀疑 PID 参数不对,很多问题根源其实是机械卡顿或者供电不足。
6. 冲刺阶段的工程建议
6.1 版本控制与风险控制
备赛到了后期,代码改动频率会非常高。强烈建议建立代码版本控制,哪怕是简单的本地备份,也要保证每天结束时的代码是能编译、能跑的。
比较好的做法是:
- 每次大改动前保存一个可用版本。
- 改动后记录改动内容和测试结果。
- 比赛前确定一个“保底版本”,这个版本只修 bug,不再加新功能。
这不仅是代码管理,更是风险控制。很多队伍在比赛前一天还在大改参数,结果现场环境一变,回归到场地的反而是最好的老版本。
6.2 每日联调流程
冲刺阶段不要每天埋头写新功能,建议固定一套调试流程:
- 早上先跑一遍昨天的基线版本,确认没有回退。
- 选择当天要解决的一个最核心问题。
- 修改参数或代码,每步都要有数据反馈。
- 下午集中做完整赛道测试,记录圈速和失败点。
- 晚上备份代码,整理失败原因。
这个流程的优点是:每天都有一个“跑得动的版本”,即使当天新功能没做完,也不会影响基础稳定性。
6.3 不要忽视机械与硬件稳定性
到了后期,决定比赛成绩的往往不是算法有多先进,而是硬件稳不稳定。以下几项必须提前检查:
- 电池固定是否牢靠,会不会在高速过弯时位移。
- 轮胎磨损情况,落场后是否打滑。
- 排线是否有松动,用扎带和热熔胶做好应力释放。
- 主控板、驱动板、摄像头是否固定结实,是否有共振。
- 舵机拉杆是否顺滑,有没有虚位。
如果时间不够,优先保证硬件不松动、不断线。这比多写一个元素识别重要得多。因为比赛现场的震动和室内测试差异很大,很多队伍就是死在了看似不起眼的排线松动上。
6.4 参数管理与现场调整预案
比赛现场光线、赛道摩擦力、电池状态都会变化,所以赛前需要准备一套“现场调参预案”。例如:
- 记录当前赛道最适应的二值化阈值范围。
- 准备两套速度参数,一套保守,一套激进。
- 比赛前先试跑一圈,观察图像和舵机响应,再决定是否调整参数表。
把常用参数集中放在代码开头,方便现场修改:
// 文件路径:App/config/config.h #define BASE_SPEED 2000 #define RAMP_SPEED 1800 #define STEER_P 60 #define STEER_D 12 #define IMAGE_THRESHOLD 128 #define CAMERA_EXPOSURE 50集中管理参数,避免在代码深层到处找魔数。
7. 回到“进度要完蛋”这个问题
如果用一句话回答“我们的进度要完蛋了吗”,我的答案是:只要车还能动、代码还能编译、串口还能输出数据,进度就没有完蛋。真正完蛋的是盲目加班、无计划改动、不记录结果、发现问题不排查而是反复换参数乱试。
第21届智能车竞赛的备赛周期已经到了需要做减法的时候。现在最该做的不是去网上收藏更多源码,也不是下载一堆从没跑通的参考程序,而是打开自己的工程,对照本文的模块表格,找出当前最薄弱、最影响整车奔跑的环节,用半天时间把它打通,然后跑起来看数据。
如果你现在还处在“车能跑但说不清为什么跑不好”的阶段,先从串口调试和图像回传入手,把调试通道建立起来。能看见数据,就不怕时间紧张;数据能说话,你就知道自己下一步该改什么。
祝大家备赛顺利,场上发挥稳定。留下来的代码和调试经验,比最终的名次更值得带走。