1. 从一场比赛到一套方法论的沉淀当我把最后一个代码文件上传到开源仓库合上笔记本电脑窗外天色已经微亮。历时近一年的第十八届全国大学生智能汽车竞赛四轮组备赛终于画上了一个阶段性的句号。这不仅仅是一个项目的结束更像是一次漫长而深入的工程实践复盘。我习惯在每个重大技术项目收尾时写一篇总结性的文字不是为了宣告胜利而是为了把那些在紧张备赛中来不及细想的“为什么”、那些在调试中反复验证的“怎么做”、以及那些踩过坑才明白的“原来如此”系统地梳理出来。这篇“写在最后”就是想把我们团队从零到一再到最终稳定运行的智能车系统其背后的设计逻辑、工程取舍和实战心得毫无保留地分享出来。如果你正在备赛或者对嵌入式系统、控制算法、机器视觉的工程化落地感兴趣希望这些文字能帮你少走一些我们走过的弯路。2. 系统架构的顶层设计为什么是“感知-决策-控制”三环在项目初期我们面临的首要问题不是某个具体算法的实现而是整个系统的架构应该如何搭建。市面上有很多成熟的机器人框架如ROS但对于资源极其有限的单片机我们主控采用STM32系列和追求极致实时性的竞速场景引入重型框架往往得不偿失。因此我们回归本质采用了最经典也最有效的“感知-决策-控制”三层架构但每一层的具体实现都充满了工程上的权衡。2.1 感知层多传感器融合与数据同步的艺术感知层的任务是告诉车“你在哪周围环境如何”。我们使用了摄像头、编码器、陀螺仪和激光雷达部分高级组别。单一传感器都有其局限性摄像头受光照影响大但信息丰富编码器有累积误差但短时精准陀螺仪存在漂移但响应快。核心挑战在于数据同步。摄像头采集一帧图像需要几十毫秒而编码器和陀螺仪的数据以几百赫兹的频率涌入。如果处理不同步的“过时”数据会导致定位和控制的混乱。我们的解决方案是建立一个基于硬件定时器的“数据快照”机制。我们设置了一个1kHz的硬件定时器中断作为整个系统的时间基准。在每个中断服务程序ISR中我们并不处理复杂逻辑而是做两件事读取编码器计数和陀螺仪角速度的原始值并打上当前的时间戳一个从系统启动开始累加的微秒计数值。检查摄像头DMA传输是否完成。如果完成则将最新的图像缓冲区指针和一个对应的时间戳存入一个队列。这样一来在决策层的主循环中当需要一次完整的感知数据时我们可以从队列中取出最新的一帧图像及其时间戳然后根据这个时间戳去线性插值出“那个时刻”的编码器里程和陀螺仪角度。这就保证了我们用于决策的所有感知数据在时间上是对齐的是针对“同一时刻”的世界状态做出的估计。注意中断服务程序里绝对不能进行浮点运算、动态内存分配或任何可能阻塞的操作。我们的ISR只做最简单的整数读写和标志位设置所有复杂计算都留给主循环。2.2 决策层有限状态机与行为树的实践决策层是车的“大脑”它根据感知信息决定“现在要干什么”。是正常循迹还是需要过弯减速是识别到了十字路口还是发现了障碍物初期我们尝试用一堆if-else语句来组织逻辑很快代码就变得难以维护和调试。我们引入了有限状态机FSM来管理主要的行车模式例如直线加速、弯道循迹、出入环岛、坡道处理等。每个状态都是一个独立的结构体包含进入状态、状态运行、退出状态三个函数指针。主循环中根据当前状态标识调用对应的状态运行函数。状态之间的转换由明确的规则触发例如从直线加速切换到弯道循迹的规则是“检测到的赛道曲率连续N帧超过阈值”。对于更复杂、具有层次化结构的行为例如“通过环岛”这个任务它本身包含了“识别环岛入口”、“切入环岛”、“环岛内循迹”、“识别出口并切出”等一系列子任务。我们用一种简化的行为树BT思想来实现。将每个子任务封装成一个“行为节点”节点返回成功、失败或运行中。通过序列节点、选择节点来组合它们。这样决策逻辑变得清晰可视调试时可以通过打印当前执行到哪个行为节点快速定位问题所在。// 一个简化的行为节点示例 typedef enum { BT_SUCCESS, BT_FAILURE, BT_RUNNING } BT_Status; typedef BT_Status (*BT_Action)(void); BT_Status Action_IdentifyRoundaboutEntry(void) { // 识别逻辑 if (识别到环岛入口特征) { return BT_SUCCESS; } return BT_RUNNING; } // 在决策主循环中 BT_Status roundabout_task_status Execute_BehaviorTree(roundabout_tree_root); if (roundabout_task_status BT_SUCCESS) { // 环岛任务完成切换回普通循迹状态 Switch_State(STATE_TRACKING); }2.3 控制层PID的“魔改”与前馈补偿控制层负责将决策层的输出如目标速度、目标转向角转化为电机的PWM波和舵机的PWM波。PID控制器是这里的主角但直接用课本上的公式往往效果不佳。速度环PID我们面对的是直流减速电机存在明显的死区和非线性。简单的PID在低速时可能无法克服静摩擦力导致车“卡顿”。我们的改进是加入死区补偿和变积分分离。死区补偿当目标速度很低时直接输出一个固定的、足以克服静摩擦的PWM基础值。变积分分离当误差很大时例如急加速积分项会迅速累积导致超调。我们设定一个误差阈值只有当误差小于该阈值时才启用积分项这样可以快速响应又避免超调。转向环PID车的转向通过舵机实现其响应有延迟。我们发现单纯根据当前赛道中心线的横向偏差来计算转向角在高速过弯时总是“慢半拍”车会不断左右修正画龙。因此我们引入了前馈控制。前馈的核心思想是“预测”。我们不仅看车当前偏离中心多少反馈还看赛道在前方是什么走向前馈。通过图像处理我们可以拟合出前方一段赛道的曲率。将这个曲率乘以一个前馈系数直接加到舵机控制量上。这个系数需要实地调参但它极大地改善了过弯的平滑性和前瞻性让车的转向更像一个老司机“预判”弯道而不是被动地“纠正”错误。// 转向控制量计算示例 float error current_center_offset; // 当前横向偏差 float curvature predict_forward_curvature; // 预测前方曲率 // 反馈部分 (PID) float feedback pid_steering.Kp * error pid_steering.Kd * (error - last_error); // 前馈部分 float feedforward pid_steering.Kff * curvature; float steering_output feedback feedforward; // 限制输出范围并发送给舵机3. 图像处理管线从原始数据到赛道信息的“翻译官”摄像头是我们的核心传感器图像处理管线是将像素矩阵转化为决策依据的关键。这个过程必须高效、鲁棒。我们的管线主要分为以下几个步骤3.1 图像预处理与二值化我们使用的是全局快门摄像头采集灰度图像。第一步是降噪我们尝试了均值滤波和高斯滤波但发现在单片机上计算开销较大。最终采用了简单的中值滤波在去除椒盐噪声的同时对边缘保持较好。二值化是关键一步目的是将灰度图像转化为黑白赛道和非赛道。固定阈值法在光照变化时完全失效。我们采用了大津法OTSU动态计算阈值但其计算量对单片机是挑战。我们的优化是ROI感兴趣区域处理我们只处理图像下半部分靠近车体的赛道因为远处赛道对即时控制影响小且更容易受透视畸变和噪声干扰。这直接减少了近一半的运算量。隔行/隔列采样在计算灰度直方图时不需要对ROI内每一个像素进行统计隔行隔列采样足以反映整体的灰度分布这又将计算量降低了约75%。阈值缓存与更新大津法不需要每帧都计算。我们设计了一个简单的自适应机制连续N帧图像的阈值变化很小时就沿用上一帧的阈值每隔M帧再重新计算一次。这大大降低了CPU负载。3.2 赛道边界提取与中线拟合二值化后我们得到了一幅黑白图像白色代表赛道。接下来需要找到左右边界。我们采用了经典的“扫线法”从图像底部开始每隔若干行例如10个像素设置一条扫描线从左到右寻找该行上黑白跳变的点即为边界点。这里有几个坑边界点丢失在某些行可能因为反光、污渍导致找不到边界点。我们不能简单地丢弃这一行否则中线拟合会出错。我们的策略是使用滑动窗口预测。如果当前行找不到左边界则根据上一行有效的左边界点位置设定一个搜索窗口在窗口内寻找最可能的点。如果还找不到则使用上一行的位置进行插值。噪点干扰可能会找到错误的跳变点如赛道上一个黑斑。我们通过边界连续性约束来过滤相邻行边界点的位置变化不能超过一个最大像素差超过则认为可能是噪点沿用上一行的值。收集到一系列左右边界点后我们需要拟合出赛道中线。直接用所有点拟合一条曲线如二次曲线在弯道时效果很好但在长直道上可能因为拟合误差产生不必要的微小曲率导致车微微摆动。我们的改进是分段拟合与模型选择首先计算所有边界点的总体曲率可以通过边界点拟合的曲线参数粗略估计。如果曲率很小近似直道我们则采用线性回归拟合中线这样更稳定。如果曲率较大弯道则采用二次曲线拟合以更好地描述弯道形状。在直道入弯的过渡区我们会给两种模型的结果一个加权融合避免控制量的突变。3.3 特殊元素识别十字、环岛、坡道的特征工程国赛赛道包含十字路口、环岛、坡道等元素。识别这些元素不能依赖复杂的深度学习模型必须设计轻量级的“特征工程”。十字路口识别在二值化图像中十字路口表现为左右边界突然消失同时出现横向的赛道区域。我们的算法是在图像中上部划定一个检测区域统计该区域内白色像素赛道的连通域。正常循迹时该区域通常只有一个主要的连通域前方的赛道。当检测到两个或以上面积足够大的、且在水平方向上有分离的连通域时就判断可能遇到了十字。同时结合底部扫描线发现的边界突然丢失“断头”现象进行综合判断提高准确率。环岛识别环岛的特征更复杂。我们主要识别两个阶段入口和出口。入口的特征是赛道一侧的边界出现一个向内的、连续的弧线凸起环岛外沿同时另一侧边界可能消失环岛内侧。我们通过计算边界点的曲率变化来检测这个凸起。一旦检测到结合当前车速和位置触发“进入环岛”的决策状态。在环岛内我们不再寻找传统左右边界而是将环岛视为一个“大弯道”追踪其内沿作为单边边界进行控制。出口的识别则依赖于重新发现正常的赛道边界。坡道识别坡道的主要挑战是摄像头俯仰角变化导致图像畸变。我们尝试用陀螺仪的俯仰角数据进行图像校正但实时计算透视变换矩阵开销太大。更实用的方法是动态调整ROI和搜索范围。当陀螺仪检测到车头上仰上坡时我们将图像处理的ROI向上移动并适当放宽边界搜索的横向范围因为坡道上赛道在图像中更“陡峭”。这更像一种经验补偿虽然不精确但能显著提升坡道通过的稳定性。4. 软件工程实践让代码在单片机上也能优雅在资源受限的单片机上开发复杂系统良好的软件工程习惯不是奢侈而是必需品。它直接决定了调试效率和最终系统的可靠性。4.1 模块化与接口设计我们将系统严格划分为硬件抽象层HAL、驱动程序层、核心算法层和应用层。硬件抽象层封装了所有对单片机外设GPIO, TIMER, UART, SPI, I2C, DMA, ADC的操作。这一层的函数接口是稳定的例如Motor_SetPwm(int16_t pwm)。如果更换单片机型号理论上只需要重写这一层。驱动程序层基于HAL编写具体传感器的驱动如Camera_Init(),Encoder_GetSpeed()。驱动只负责数据的收发和基本解析不包含业务逻辑。核心算法层包含图像处理、控制算法、状态机、滤波器等。这一层是平台无关的只依赖标准C库和驱动程序层提供的干净数据接口。应用层也就是我们的main.c它负责初始化所有模块并组织主循环的逻辑流。模块间通过清晰的函数接口和数据结构进行通信避免使用全局变量满天飞。我们使用一个全局的SystemState_t结构体作为共享数据的唯一中心所有模块需要读写共享数据如当前车速、目标速度、赛道曲率都必须通过访问这个结构体的特定字段并在关键部位使用简单的互斥机制如开关中断来防止数据竞争。4.2 调试与日志系统在单片机上调试printf重定向到串口是最基本但也最重要的手段。但我们不能随意打印否则会拖慢系统。我们实现了一个分级的日志系统#define LOG_LEVEL_ERROR 1 #define LOG_LEVEL_WARNING 2 #define LOG_LEVEL_INFO 3 #define LOG_LEVEL_DEBUG 4 extern uint8_t system_log_level; // 通过上位机可动态调整 #define LOG(level, format, ...) do { \ if (level system_log_level) { \ printf([%s] format \r\n, #level, ##__VA_ARGS__); \ } \ } while(0) // 使用示例 LOG(LOG_LEVEL_INFO, State switched to: %d, current_state); LOG(LOG_LEVEL_DEBUG, Curvature: %.2f, Offset: %.2f, curvature, offset);在开发初期将日志级别设为DEBUG可以打印大量信息。在后期优化性能时可以降为WARNING或ERROR只打印关键错误。我们还将关键变量如控制量、传感器数据通过另一个高速串口以二进制流的形式实时发送到上位机如MATLAB或Python脚本进行可视化绘图这对于调参和问题定位有巨大帮助。4.3 版本控制与自动化构建即使是一个人开发我们也坚持使用Git进行版本控制。每一次重要的算法更改、参数调整都对应一个清晰的提交信息。我们利用Git分支来管理不同的开发阶段main分支是稳定版本develop分支是集成测试版本feature/*分支用于开发新功能如feature/roundabout。此外我们编写了简单的Makefile脚本实现一键编译、下载和擦除。将常用的调试命令如通过OpenOCD连接调试器、下载程序、复位芯片也集成进去极大提升了迭代效率。在比赛现场时间就是生命能快速烧录一个修复了bug的版本可能就决定了最终名次。5. 性能优化与资源管理榨干单片机的每一分潜力智能车对实时性要求极高主循环必须在几毫秒内完成一次感知-决策-控制的全流程。优化无处不在。5.1 计算优化查表法代替实时计算对于频繁调用且输入范围有限的函数如sin,cos,arctan我们预先计算好一张表存储在Flash中。需要时直接查表用空间换时间。例如舵机转角到PWM脉宽的映射就是一张256个元素的查找表。定点数运算STM32没有硬件浮点单元FPU浮点运算靠软件模拟极其缓慢。我们将所有算法中的浮点数全部转换为Q格式定点数。例如使用Q1.15格式1位符号位15位小数位来表示范围在[-1, 1)的小数。加减乘除都在整数层面进行只需在乘除法后做一些移位操作速度比浮点快几十倍。编译器优化合理使用编译器的优化选项如-O2,-O3。对于最核心的图像处理循环我们甚至用手写的汇编指令内联汇编来优化例如用SIMD指令如果MCU支持一次性处理多个像素。5.2 内存优化静态分配替代动态分配在嵌入式系统尤其是实时系统中绝对禁止使用malloc/free。所有内存都在编译时静态分配。我们仔细计算每个缓冲区如图像缓冲区、数据队列、滤波窗口的最大所需尺寸并定义为全局数组。内存复用例如图像采集缓冲区有两个双缓冲一个用于DMA接收另一个用于处理。处理完成后两个缓冲区角色互换。一些中间计算结果数组如果生命周期不重叠也可以复用同一块内存。使用const和static将常量数据如查找表、配置参数声明为const编译器会将其放入Flash而非RAM。将只在本文件内使用的全局变量声明为static可以避免命名空间污染有时也能帮助编译器优化。5.3 时序与中断优化主循环耗时分析我们使用一个空闲的GPIO引脚在主循环开始和结束时拉高拉低用示波器测量脉冲宽度从而精确测量一次循环的耗时。确保在最坏情况下如处理复杂弯道图像耗时也低于控制周期例如10ms。中断优先级管理系统中存在多个中断定时器中断系统心跳、编码器中断测速、串口中断通信。必须合理设置它们的优先级。我们的原则是对实时性要求最高的、执行时间最短的中断优先级最高。例如确保编码器中断能及时响应避免丢失脉冲。而串口接收中断的优先级可以设低因为它处理的数据可以稍晚一点被主循环读取。避免在中断中处理复杂任务重申这一点中断处理函数应该快进快出。我们曾经因为在一个中断里做了过多的图像预处理导致主循环被严重阻塞车控反应迟钝。后来将所有耗时操作都移到了主循环。6. 测试、调试与参数整定从实验室到赛道的鸿沟算法在仿真和实验室平滑地面上跑得很好一上赛道就出问题这是常态。一套完整的测试调试流程至关重要。6.1 分层测试策略单元测试在PC上使用测试框架如Unity对核心算法函数如PID计算、图像滤波、曲线拟合进行测试。输入各种边界条件如全0数据、异常值验证其输出是否符合预期。这能快速发现算法逻辑错误。模块集成测试在单片机上将传感器模块单独测试。例如用手转动车轮看编码器数据是否正确晃动车身看陀螺仪数据是否灵敏对着不同图案看摄像头二值化效果。确保每个硬件模块工作正常。整车静态测试车放地上但不启动电机用手推着车在赛道上走通过无线串口将决策数据如识别到的赛道曲率、边界发回上位机查看。这可以验证感知算法在真实赛道上的表现而不受控制干扰。低速动态测试以极低速度让车自动运行人随时准备干预。重点观察控制输出是否平滑状态切换是否正常。用手机慢动作录像回放分析过弯时车的姿态。全速测试与压力测试在安全环境下进行全速运行。并故意制造一些干扰如突然改变光照、在赛道上放置小块障碍物测试系统的鲁棒性。6.2 参数整定不是玄学是系统工程整定PID参数、图像二值化阈值、前馈系数等是调车的主要工作。我们摒弃了“瞎试”的方法采用系统化的步骤确定优先级先调内环再调外环先调反馈再调前馈。对于智能车通常先让车在固定PWM下能跑直开环然后调速度环PID让车速能稳定跟随最后再调转向环PID和前馈。分离变量法调转向环时先把速度环设为恒定输出避免两者耦合相互影响。定量分析利用上位机实时绘图工具。调转向PID时同时绘制横向偏差、控制输出舵机PWM的曲线。观察响应是否快速、超调是否过大、稳态误差是否为零。通过曲线来指导参数调整例如超调大就减小Kp或增大Kd响应慢就增大Kp。记录与回溯每调整一组参数都在日志中记录参数值和当时的测试现象如“入弯流畅但出弯有振荡”。当调乱了或者想尝试不同风格激进/保守时可以快速回溯到某个已知可用的参数集。6.3 现场调试策略比赛现场环境与实验室天差地别光线、地面摩擦力、电池电压都在变。我们准备了以下几套预案多套参数预设在代码中预置几组参数分别对应“强光室内”、“弱光室内”、“体育馆木地板”、“跑道胶面”等。通过拨码开关或蓝牙指令快速切换。在线微调接口通过蓝牙串口我们可以在车运行时实时微调关键参数如PID的Kp值。这比修改代码、重新编译下载快得多。核心状态监控现场没有复杂的上位机我们利用OLED屏幕实时显示最关键的信息当前车速、电池电压、状态机模式、图像处理耗时。扫一眼就能对车况有个大致判断。7. 团队协作与项目管理112的保障智能车竞赛从来不是单打独斗。硬件、软件、算法、机械需要多人紧密协作。我们团队采用每周例会制度同步进度、讨论技术难点、决定下一步方向。任务分解到人并设置明确的里程碑如“第一版循迹代码跑通”、“环岛识别功能上线”。使用在线文档如腾讯文档共享设计思路、参数记录、问题清单。最重要的经验是建立清晰的接口协议。硬件组给电路板时必须提供明确的接口定义如电机驱动接口是哪个排针、电压多少。软件组在调用图像处理模块时必须知道输入输出的数据结构是什么。我们甚至为一些核心模块编写了简明的API说明文档虽然不正式但极大减少了沟通成本。在项目后期集成测试阶段最容易出现互相指责的情况。“你的代码有问题”“不是你的硬件不稳定”为了避免这种情况我们约定任何问题都必须有可复现的现象和日志作为依据。硬件问题要提供示波器波形图软件问题要提供导致异常的输入数据和函数调用栈。用事实和数据说话而不是感觉。回顾整个备赛历程从最初面对一堆散件的茫然到最终小车在赛道上风驰电掣的激动其价值远超一块奖牌。它是一次完整的、高强度的、贴近工业实践的工程项目训练。我们不仅学会了PID、图像处理、状态机这些具体技术更深刻理解了如何架构一个复杂嵌入式系统如何管理项目进度和团队如何在有限的资源和无限的问题之间做权衡。开源我们所有的代码和设计是希望这份经验能成为后来者的一块垫脚石。智能车的赛道有终点但从实践中学习、在挑战中成长的道路没有终点。如果我们的代码和文档能帮你节省哪怕一个晚上的调试时间或者提供一个不同的解题思路那么这一切的努力就都有了额外的意义。