很多工科生的大学四年里,都有一个绕不开的夜晚:实验室的灯还亮着,赛道上小车一遍遍试跑,摄像头采回来的图像在屏幕上闪烁。那句“谨以此片纪念这段智能车生涯”,记录的不只是比赛结果,更是无数个调参、焊板、改代码的瞬间。这篇文章不想只做纪念,我想把智能车开发这条路上的核心知识整理成一套可以照着做的实战教程,包括硬件选型、环境配置、图像处理、PID 控制、代码调试和常见问题排查,送给正在准备比赛或者打算入门智能车开发的你。
智能车是一个很综合的嵌入式项目,它把单片机、传感器、电机驱动、控制算法压缩在一个小车平台上。很多新手刚开始会觉得无从下手,网上资料虽然多,但比较零散。本文以最常见的摄像头循迹方案为主线,从零开始梳理整个开发流程。无论你是参赛队员、毕业设计选了这个方向,还是单纯想折腾嵌入式,这篇文章都能帮你少走不少弯路。
1. 背景与核心概念
1.1 智能车到底在做什么
智能车竞赛,本质上是让一辆小车在没有遥控器的情况下,依靠车载传感器识别赛道元素,完成一圈又一圈的自主循迹。这里面最核心的输入是“赛道长什么样”,最核心的输出是“左右轮各应该转多快”。
把这句话拆开,你会发现它涵盖了几个技术模块:
- 感知层:用摄像头采集赛道图像,或者用电感采集电磁信号。
- 处理层:单片机对采集到的数据进行二值化、边界提取,计算出车相对于赛道的偏差。
- 决策层:根据偏差计算转向量,根据速度反馈计算期望 PWM。
- 执行层:电机驱动芯片把 PWM 信号转换成电机转速,编码器再把转速读回来形成闭环。
一辆能稳稳跑完赛道的车,靠的不是某一个环节特别强,而是这四个模块的配合。很多队伍车跑不稳,往往不是硬件坏了,而是图像处理和 PID 参数不匹配。
1.2 智能车常见的技术分组
在正式动手之前,先了解主流的几种方案,方便你判断自己应该从哪个方向切入。
摄像头组是最热门的方向。它用灰度摄像头采集赛道图像,通过软件识别赛道边界。优点是信息量丰富,能提前看弯道、识别十字路口和坡道;缺点是需要花较多时间调图像处理算法。电磁组则用工字电感感应赛道下的交变磁场,对硬件的依赖更大,算法相对简单,适合刚接触单片机的新手。此外还有单车组、越野组等,它们对机械结构和控制算法的要求更高。
对于大多数想入门智能车的人来说,我的建议是先做摄像头的简单循迹,不追求高速,先把“图像采集 → 图像处理 → 转向控制 → 速度闭环”这条路跑通。这条链路跑通以后,再考虑复杂元素识别和高速过弯。
1.3 智能车开发为什么值得认真学
智能车不是一个“玩具项目”,它包含了嵌入式开发中最重要的一组能力:资源受限下的编程、传感器数据的实时处理、控制理论的工程落地,以及软硬件联合调试。这些能力在后续的嵌入式岗位、自动驾驶领域、机器人开发中都是通用的。
更重要的是,智能车项目倒逼你养成工程习惯。代码要版本管理,参数要记录调参表,硬件接线要有规范,遇到问题要能根据现象反推原因。这些习惯不在课本里,但比赛结束后你会发现,它们比奖项本身更有价值。
2. 环境准备与版本说明
2.1 硬件清单
智能车的硬件方案很多,本文以最常见的摄像头后驱车为例,列出基本组成。
| 模块 | 常见型号 | 作用 |
|---|---|---|
| 主控 MCU | STM32F407、K60、TC264 | 图像处理与控制逻辑 |
| 摄像头 | 总钻风 OV7725、山外灰度摄像头 | 采集赛道图像 |
| 电机 | 带编码器直流减速电机 | 驱动后轮 |
| 电机驱动 | DRV8701、BTN7971、MOS 全桥 | 放大 PWM 驱动电机 |
| 编码器 | Mini 编码器、欧姆龙编码器 | 测量车轮转速 |
| 电源 | 7.2V 镍铬电池或 2S 锂电池 | 整车供电 |
| 稳压模块 | 5V、3.3V DC-DC 模块 | 给单片机、传感器供电 |
具体型号要根据你所在队伍的主控方案来定,不同学校常用的库不一样。本文重点讲思路,代码层面以常见嵌入式 C 语言风格为例,你需要根据自己的 SDK 做适配。
2.2 软件工具链
软件环境需要准备下面几类工具:
- 开发环境:如果你用 STM32,一般用 Keil MDK 或 STM32CubeIDE;如果使用逐飞库开发的 K60、TC264,则常用 IAR 或 CodeWarrior。
- 摄像头调试工具:逐飞串口助手、山外多功能调试助手,用来查看摄像头采集的原始图像和处理后的二值图。
- 波形查看工具:VOFA+ 或者逐飞虚拟示波器,用来观察 PID 输出、编码器速度、偏差值等数据曲线。
- 串口工具:任何支持十六进制收发功能的串口助手都可以。
版本方面,Keil MDK 建议使用 5.x 以上版本,芯片支持包单独安装。IAR 需要根据芯片厂商选择对应的版本。由于各厂家库函数接口差异较大,本文的代码以逻辑可移植为主,平台相关部分会标注清楚,你替换成自己 SDK 的对应接口即可。
2.3 项目目录结构建议
嵌入式工程的目录结构决定了后期维护效率。下面给出一个比较干净的划分方式。
smart_car_project/ ├── doc/ # 原理图、芯片手册、调参记录 ├── firmware/ │ ├── app/ # 应用层:图像处理、控制逻辑 │ ├── bsp/ # 板级驱动:摄像头、电机、编码器、串口 │ ├── libraries/ # 官方库或逐飞库 │ └── main/ # 主函数入口 ├── tools/ # 串口助手、虚拟示波器 └── README.md # 工程说明很多队伍习惯把所有代码堆在 main.c 里,前期跑通倒是快,但一旦接入复杂元素识别,代码会越来越难改。建议一上来就按“应用层”和“驱动层”分离的思路组织代码。
3. 核心语法、配置或原理拆解
3.1 图像处理:从灰度图到赛道偏差
摄像头输出的原始数据是一幅灰度图。控制车转向,并不需要识别出物体是什么,只需要知道“赛道在哪”。因此,第一步通常是把灰度图转成二值图,也就是把每个像素点标记为“白色赛道”或者“黑色背景”。
二值化的关键在于阈值的选取。固定阈值最简单,但不同光照下赛道灰度会变化,效果不稳定。稍微进阶一点的做法是动态阈值,常用的有最大类间方差法(Otsu),它根据图像灰度直方图自动计算区分前后景的最佳阈值。
得到二值图之后,下一步是提取赛道边界。逐行扫描图像,从中间向两边寻找黑白跳变点,就能得到左右边界位置。左右边界的中间点就是当前行赛道的中心线。将中心线位置与图像底部的预期位置做差,得到转向偏差。
下面给出一段伪代码风格的边界提取核心逻辑,它不依赖具体硬件,重点展示思路:
// 假设 image 是已经二值化的图像 // image[row][col] == 1 表示白色赛道,0 表示背景 // 输入:二值图像,输出:当前行赛道中心偏移量 int16_t get_center_offset(uint8_t image[IMAGE_H][IMAGE_W]) { int row = IMAGE_H - 1; // 从最靠近车身的一行开始扫描 int left_boundary = -1; int right_boundary = -1; for (int col = IMAGE_W / 2; col >= 0; col--) { if (image[row][col] == 1) { left_boundary = col; break; } } for (int col = IMAGE_W / 2; col < IMAGE_W; col++) { if (image[row][col] == 1) { right_boundary = col; break; } } if (left_boundary == -1 || right_boundary == -1) { return 0; // 本行没找到完整赛道,返回 0 } int center = (left_boundary + right_boundary) / 2; return center - IMAGE_W / 2; // 返回相对图像中心的偏差 }这里有几个容易被忽略的细节:
- 扫描方向非常重要。如果车身压线,图像底部一行容易整行都是黑的,此时应从略高的行开始扫描。
- 每行都计算边界会浪费算力。实际工程中通常只取底部若干行计算偏差,或者对边界做“由近及远”的线性化处理。
- 只取单行偏差的缺点是弯道信息不足,后期可以提取多行边界,拟合出赛道曲率。
3.2 转向控制:增量式 PID
PID 是智能车转向和速度控制中最常用的算法。它的三个参数各有分工。
比例项 P 根据当前偏差大小给出与偏差成正比的输出。偏差越大,转向越猛。积分项 I 累积过去的偏差,用来消除稳态误差,但智能车上使用不当容易造成积分饱和,导致过弯抖动。微分项 D 根据偏差变化趋势提前刹车,减少超调,对弯道动态性能影响很大。
智能车常用的是增量式 PID,它并不直接计算目标 PWM 的绝对值,而是计算每次输出相对于上一次输出的增量,然后叠加到上一次输出上。这样做的好处是执行机构本身带有一定的记忆性,输出变化更平滑,不容易产生突变。
一个典型的增量式 PID 计算函数如下:
typedef struct { float kp; float ki; float kd; float target; // 目标值 float last_error; // 上一次误差 float prev_error; // 上上次误差 float out; // 当前输出 } PidController; void pid_calc(PidController *pid, float current, float dt) { float error = pid->target - current; float delta_out = pid->kp * (error - pid->last_error) + pid->ki * error + pid->kd * (error - 2.0f * pid->last_error + pid->prev_error); pid->out += delta_out; pid->prev_error = pid->last_error; pid->last_error = error; }在转向控制中,current是摄像头计算出的偏差,target通常设为 0,即希望车始终对准赛道中心。在速度控制中,current是编码器读回的实时速度,target是期望速度。
增量式 PID 的两个输出限幅非常重要。如果不限幅,弯道上偏差突然变大,PWM 增量会瞬间冲到极限,车就会猛地打方向甚至甩尾。一般做法是给out设置一个最大最小值,并限制每次delta_out的变化幅度。
3.3 速度闭环:让车跑得稳
速度环是智能车控制中相对容易被忽视的部分。很多新手一开始只做转向环,车速靠开环 PWM 硬顶,结果车越跑越慢或者忽快忽慢。
速度闭环的结构是:编码器读取两个后轮的实时转速 → 与期望速度做差 → 经过速度 PID → 输出电机 PWM。电机的响应比图像慢,所以速度环的控制周期可以设置得比图像处理周期长一些,常见做法是用定时器每 10ms 到 20ms 执行一次速度控制。
需要注意的是,编码器读数往往带有噪声,直接用于 PID 会导致输出抖动。解决办法是在读取编码器后做一次简单的滤波。
// 一阶低通滤波,alpha 取值 0~1,越小越平滑 float speed_filter(float raw_speed, float last_speed, float alpha) { return alpha * raw_speed + (1.0f - alpha) * last_speed; }alpha 太大会导致速度滞后明显,太小则噪声滤不干净。具体取值要看实际波形,一般从 0.3 到 0.7 之间尝试。
4. 完整实战案例
前面把原理拆开了,这一节用一个最小可运行的结构,把整个控制链路串起来。由于不同开发板的 SDK 差异较大,下面代码以 Core 逻辑为核心,平台相关部分用函数名代替,你可以在自己的工程里找到对应的函数填入。
4.1 创建项目结构
假设你已经用对应开发环境新建了工程,先规划模块文件:
app/ ├── image_process.c # 图像二值化、边界提取 ├── image_process.h ├── control.c # 转向 PID、速度 PID ├── control.h └── main.c # 主循环4.2 定义数据结构
为了让代码逻辑更清晰,先把需要跨模块共享的数据封装成结构体。
// control.h #ifndef CONTROL_H #define CONTROL_H #include <stdint.h> typedef struct { float kp; float ki; float kd; float target; float last_error; float prev_error; float out; float max_out; float max_delta; } Pid_t; typedef struct { float left_speed; // 左轮实时速度 float right_speed; // 右轮实时速度 float set_speed; // 期望速度 float steer_pwm; // 转向控制量 float motor_pwm; // 速度控制量 } CarState_t; void pid_init(Pid_t *pid, float kp, float ki, float kd, float max_out, float max_delta); float pid_calc(Pid_t *pid, float current); #endif4.3 编写图像处理逻辑
图像处理模块的职责是输入一幅灰度图,输出赛道偏差。下面把二值化和边界提取合并到一个函数里。实际工程中为了性能,一般会直接在原始灰度图上做行扫描,避免二次拷贝图像。
// image_process.c #include "image_process.h" #define IMAGE_W 188 #define IMAGE_H 120 // 固定阈值二值化,实际推荐使用动态阈值 static uint8_t binary_image[IMAGE_H][IMAGE_W]; void image_to_binary(uint8_t gray_image[IMAGE_H][IMAGE_W], uint8_t threshold) { for (int row = 0; row < IMAGE_H; row++) { for (int col = 0; col < IMAGE_W; col++) { binary_image[row][col] = (gray_image[row][col] > threshold) ? 1 : 0; } } } int16_t get_track_offset(void) { int row = IMAGE_H - 15; // 取靠近车身区域的某一行 int left = -1; int right = -1; for (int col = IMAGE_W / 2; col >= 0; col--) { if (binary_image[row][col] == 1) { left = col; break; } } for (int col = IMAGE_W / 2; col < IMAGE_W; col++) { if (binary_image[row][col] == 1) { right = col; break; } } if (left < 0 || right < 0) { return 0; } return (left + right) / 2 - IMAGE_W / 2; }这个实现简单但有效。真实场景中,出场率最高的坑是某一行只有单边边界。比如车在急弯中,车身内侧压线,会先找到一条边界,另一条始终找不到,这时候返回 0 会导致车短暂失去转向,后续可以用“记忆上一次有效偏差”的方式改善。
4.4 编写 PID 控制逻辑
PID 模块需要同时支持转向环和速度环。两者结构相同,只是参数不同。
// control.c #include "control.h" void pid_init(Pid_t *pid, float kp, float ki, float kd, float max_out, float max_delta) { pid->kp = kp; pid->ki = ki; pid->kd = kd; pid->target = 0.0f; pid->last_error = 0.0f; pid->prev_error = 0.0f; pid->out = 0.0f; pid->max_out = max_out; pid->max_delta = max_delta; } float pid_calc(Pid_t *pid, float current) { float error = pid->target - current; float delta = pid->kp * (error - pid->last_error) + pid->ki * error + pid->kd * (error - 2.0f * pid->last_error + pid->prev_error); // 限制单次变化量 if (delta > pid->max_delta) { delta = pid->max_delta; } else if (delta < -pid->max_delta) { delta = -pid->max_delta; } pid->out += delta; // 限制输出幅值 if (pid->out > pid->max_out) { pid->out = pid->max_out; } else if (pid->out < -pid->max_out) { pid->out = -pid->max_out; } pid->prev_error = pid->last_error; pid->last_error = error; return pid->out; }调试时建议先把max_out设小一点,比如 200 到 500,让车在低速下先跑起来,再逐步放开限幅。上来就给大舵量,车会非常敏感,很难判断是 P 太大还是 D 不够。
4.5 编写主循环
主循环要完成“采集图像 → 处理图像 → 计算转向 → 更新速度 → 输出 PWM”这一整套流程。不同方案的主频和摄像头帧率不同,这里给出常见框架。
// main.c 核心示意 #include "control.h" #include "image_process.h" static Pid_t steer_pid; static Pid_t speed_pid; static CarState_t car; void setup(void) { // 初始化摄像头、编码器、PWM 输出、串口 camera_init(); encoder_init(); pwm_init(); uart_init(); // 转向 PID 和速度 PID 初始化为不同参数 pid_init(&steer_pid, 65.0f, 0.0f, 3.5f, 380.0f, 40.0f); pid_init(&speed_pid, 25.0f, 0.8f, 0.2f, 4200.0f, 300.0f); car.set_speed = 1500.0f; // 初始期望速度,单位与编码器一致 } void loop(void) { // 1. 摄像头图像回调已经在 DMA 中断里完成,这里直接取最新帧 uint8_t gray[IMAGE_H][IMAGE_W]; camera_get_frame(gray); // 2. 二值化并提取偏差 image_to_binary(gray, 128); int16_t track_offset = get_track_offset(); // 3. 转向 PID,目标 0,当前偏差 steer_pid.target = 0.0f; float steer = pid_calc(&steer_pid, (float)track_offset); car.steer_pwm = steer; // 4. 差速输出:转向量叠加到左右电机 set_left_duty((int)(car.motor_pwm - steer)); set_right_duty((int)(car.motor_pwm + steer)); // 5. 速度环在定时器中断里周期性执行 } void timer_isr_10ms(void) { float left = encoder_get_left_speed(); float right = encoder_get_right_speed(); speed_pid.target = car.set_speed; car.motor_pwm = pid_calc(&speed_pid, (left + right) / 2.0f); }这个框架省略了具体芯片的寄存器操作,因为不同 SDK 的函数名差异很大,但流程是一致的。
4.6 运行与验证
把程序下载到小车后,先不要放到赛道上跑。用串口把偏差值track_offset和转向输出steer打印出来,观察两个数据:
- 摄像头正对直道时,偏差应该接近 0,并且抖动很小。
- 用手把车在赛道上左右移动,
steer应该跟随偏差变化,方向比偏差慢半拍。
如果发现偏差方向反了,最常见的原因是摄像头安装方向颠倒。如果速度环不工作,先检查编码器读数是否正常,可以用串口打印编码器的原始脉冲数。速度读取有问题时,PID 调得再好也没有意义。
5. 常见问题与排查思路
智能车调试过程中,很多问题看起来复杂,实际原因就那么几个。下面汇总一些高频问题:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 图像全黑或全白 | 摄像头初始化失败、阈值设置不合适 | 用串口助手查看原始灰度图,调节曝光和阈值 |
| 电机不转 | 电源没接通、PWM 引脚接错、使能脚未拉高 | 先用开发板输出固定占空比,排除软件问题 |
| 转向方向反了 | 摄像头安装方向反、差速符号错误 | 检查图像坐标系与转向输出的映射关系 |
| 车左右摆动剧烈 | 转向 P 过大、图像偏差抖动明显 | 减小 P,图像做滤波或降低扫描行分辨率 |
| 速度环跑不上去 | 编码器读数偏低、PWM 满占空比 | 检查编码器供电和接线,确认占空比范围 |
| 过弯严重外抛 | 转向 D 太大或速度环响应慢 | 降低速度期望,调整转向环 D 参数 |
| 上电反复重启 | 电源纹波大、稳压模块电流不足 | 单独给舵机和电机供电,避免与单片机共地干扰 |
| 摄像头帧率低 | 选择的输出模式不对,单片机处理耗时过长 | 使用硬件二值化摄像头或降低分辨率 |
排查时我建议遵循一个原则:每次只改一个变量。很多同学出问题后同时调整了 PID 参数、换了阈值、改了车速,最后车出了问题根本不知道是哪一步引起的。正确做法是恢复到一个已知稳定状态,然后只改变一个条件,验证它的影响。
6. 最佳实践与工程建议
6.1 代码与调参规范
智能车代码的复杂度看起来不高,但它对实时性要求高,而且需要在现场频繁改参数。没有规范的队伍,往往到最后代码一团糟。
参数配置建议集中管理。把所有 PID 参数、图像阈值、目标速度都放在一个config.h里,不要散落在各个源文件中。调参时用串口指令实时修改,避免每次烧录程序。
// config.h #define STEER_KP 65.0f #define STEER_KI 0.0f #define STEER_KD 3.5f #define SPEED_KP 25.0f #define SPEED_KI 0.8f #define SPEED_KD 0.2f #define BASE_SPEED 1500.0f调参记录表是很多人忽略但非常关键的习惯。每次修改参数后,记录下赛道类型、车速、现象,哪怕只是拍一张屏幕照片。比赛前几天,你会因为有记录而节省大量时间。
6.2 硬件与供电安全
硬件上最常出问题的是供电。智能车电机启动电流很大,如果单片机、摄像头、编码器与电机共用同一组电源,电机启动瞬间会把电压拉低,导致单片机复位或者摄像头掉帧。
规范的做法是驱动电源与控制电源分开。电机单独用动力电源,单片机与传感器用稳压模块供电,并且在电源入口并联一个大容量电解电容。共地处理也要注意,必须在主控板处单点共地,避免地线环路引入干扰。
线径和接插件也不能将就。比赛整车剧烈震动,杜邦线很容易松脱。建议直接把关键信号线焊死在 PCB 上,或者使用带锁扣的接插件。
6.3 团队协作与备赛节奏
智能车比赛通常不是一个人能完成的。三个人如果都去写代码,不如明确分工:一个人负责机械与硬件,一个人负责图像算法,一个人负责控制与调试。每个人的代码需要放到同一个仓库里,用 Git 管理,避免互相覆盖。
节奏上,建议第一版车跑起来的时间尽量提前。很多队伍第一个月都在纠结摄像头选型,迟迟没有跑起来。其实最快的路径是用最简单的方案,哪怕只跑 1m/s,先把整个链路打通。有了这个基础,后面每一步优化都有明确目标:提高一点速度,观察哪里先撑不住,再针对性地改进那个环节。
6.4 不断用数据驱动调参
很多同学调 PID 完全是靠感觉,觉得转向太猛就减小 P,觉得过弯慢就加大 P。这样调出来的参数换一段赛道可能就失效了。更可靠的做法是使用虚拟示波器观察曲线,让数据告诉你哪里出了问题。
转向环重点看偏差曲线和输出曲线是否平滑,有没有明显的高频抖动。速度环重点看编码器速度是否紧跟目标速度,超调量有多大。当你把曲线和现象对应起来之后,调参的盲目性就会大大降低。
7. 总结与学习路线
智能车开发是一条上手门槛不低、但回报很实在的技术路线。它没有太多花哨的概念,核心就是图像处理 + PID 控制 + 嵌入式工程化这三件事。新手建议先照着本文的思路把最小系统跑通,再逐步添加弯道识别、十字处理、坡道检测等元素。当你把一辆车调试到稳定跑完赛道时,回头再看那些资料,你会发现很多当时看不懂的细节都有了答案。
这篇文章更像是一份“技术复盘笔记”。如果你正在准备自己的第一辆智能车,可以先收藏备用,把环境搭建和基础代码跑通,然后在调试中慢慢补充属于你自己的参数经验。硬件方案和库版本会变,但“感知 → 决策 → 执行”的链路和工程调试的思路是通用的。希望这篇整理能帮你少熬几个改代码的夜,也希望你的智能车生涯,不只是在比赛的录像里,更留在你认真写过、认真调过的每一段代码里。