资讯详情

资讯详情

基于C++的智能车竞赛源码解析:从传感器到PID控制

简介贝壳23年智能车校内赛设计源码是一份面向智能车竞赛选手和C学习者的完整项目参考涵盖图像处理、阈值计算、PID控制、速度规划与多轮分站赛迭代版本适合有基础编程能力并希望了解竞赛实战架构的读者。压缩包共45个文件以29个cpp源文件与6个h头文件为核心辅以少量c文件、Markdown文档和license授权说明整体仅332KB结构包含图像算法、控制程序集、速度规划等模块便于按需研读。已有92人学习浏览对于小范围校内赛资料而言具备一定参考价值。通过源码可学习八邻域搜索、大津法阈值、模糊PID等具体实现并观察从v0.0到v1.0的版本演进与工程组织方式对搭建完整智能车软件框架和提升C实战能力均有帮助。1. 贝壳23年校内智能车赛的C源码应该怎么读贝壳23年校内智能车赛的C设计源码最值得看的不是某个调好的参数而是一条从空白工程到稳定完赛的完整实现路径。相比全国大学生智能车竞赛的省赛国赛校内赛的赛道元素更基础圈速要求也没那么极限但代码的模块划分、控制环写法、调试手段全部复用得上。近几年用C而不是纯C写车控的队伍越来越多原因很直接摄像头巡线的二值化、编码器测速的滤波、PID控制器的参数封装用类和模板收敛之后可读性与可维护性远好于散落的全局函数。这份源码适合准备参赛的大二大三同学照着搭也适合想用嵌入式项目验证C工程能力的开发者拆开研究。读的时候带着一个问题看如果我把摄像头换掉、电机换掉这套代码骨架还能不能站住。2. 基于C的智能车软件框架从传感器到执行器的数据链路2.1 车控系统分层采集、决策、控制各自管什么我一般会把一辆摄像头的智能车拆成四层。感知层负责读传感器包括摄像头图像帧、编码器脉冲计数、陀螺仪的角速度算法层把原始图像变成赛道信息算中线偏差、判定十字和环岛控制层根据偏差算舵机角度和电机PWM执行层把算好的值写进定时器寄存器。这个分层对应到C工程里就是sensor、algorithm、control、driver四个目录编译时互相解耦调某一层时不用牵动其他代码。校内赛常用的MCU是STM32F103或RT1052这类Cortex-M内核芯片RAM往往只有几十到几百KB而一幅灰度图像就占几十KB所以代码里最需要盯紧的是内存生命周期。常见做法是把图像帧放进静态缓冲区反复覆盖而不是在循环里频繁new数组算法层拿到的应该是指针加宽高而不是一份拷贝。这个习惯在C里尤其重要因为一不留神就会写出vector和string满天飞的代码裸机环境下频繁动态分配会把堆掏碎跑着跑着就hardfault。2.2 用类封装传感器驱动C相比纯C的收益在哪很多老队伍用纯C写车控代码长到几千行之后改一个摄像头的接口地址要在三个文件里翻着找。用C最常见的做法是给每个传感器写一个类构造函数里完成GPIO和定时器初始化成员函数只暴露Read或GetXxx接口私有的寄存器操作不对外见。class Encoder { public: Encoder(TIM_HandleTypeDef* htim) : htim_(htim), last_count_(0) {} int32_t GetDelta() { int32_t now __HAL_TIM_GET_COUNTER(htim_); int32_t delta now - last_count_; last_count_ now; return delta; } private: TIM_HandleTypeDef* htim_; int32_t last_count_; };这段代码把编码器的清零自锁逻辑封装在类内部调用方只关心GetDelta返回的脉冲增量。构造函数入参是HAL库的定时器句柄同一个类可以实例化出左右两个编码器对象。成员变量加下划线后缀是C常见风格用来和局部变量区分避免在长函数里误赋值。注意__HAL_TIM_GET_COUNTER读的是16位计数器速度快时单次差值可能超过65535需要根据实际最大转速选定时器分频或者改用32位定时器。模板则更适合PID这类需要复用类型的控制组件同一份算法可以实例化成float和double两个版本比赛时发现精度不够就把模板参数换掉算法逻辑一行不用改。2.3 工程目录、编译环境与引脚表的基线源码分享里最容易让人卡住的就是工程打不开。校内赛通常用Keil或IAR也有人用VSCode配CMake做交叉编译。拿到任何工程先确认三件事芯片型号和启动文件是否匹配链接脚本时钟树配置能否重新生成输出文件的烧录起始地址对不对。这三项对不上其他代码看再多也跑不起来。一个能直接套用的目录结构大致长这样smart_car/ Core/ # 启动文件、中断向量、系统时钟 Drivers/ # HAL库与板级外设驱动 App/ main.cpp sensor/ # camera.cpp encoder.cpp imu.cpp algorithm/ # binaryzation.cpp line_detect.cpp control/ # pid.cpp servo.cpp motor.cpp comm/ # uart_debug.cpp Doc/ # 引脚表、调参记录写代码前先出一份引脚分配表能省掉大量排错时间尤其是多人同时改车的场景模块常用接口关键参数备注摄像头DCMIDMA分辨率、帧率行中断触发采集编码器TIM定时器计数模式、分频注意计数器溢出舵机PWM输出频率50Hz左右极限约±30°电机PWM方向IO频率10-20kHz死区需实测这张表不是标准答案校内赛每台车接法不同但“先定引脚表再动代码”的顺序是通用的。VSCode配C/C环境时记得把C标准选到C11以上很多队伍源码用了enum class和模板默认的gnu90编译直接报错。3. 关键模块的C实现巡线、测速与PID控制的代码落地3.1 摄像头巡线自适应阈值与中线提取摄像头拿到的原始图像是灰度数组第一步是二值化把赛道白色和背景深色分开。最常见的错误是写死一个固定阈值室内灯光变换一强就丢线。稳定做法是每帧统计灰度直方图用大津法算自适应阈值uint8_t CalOtsuThreshold(const uint8_t* gray, int size) { uint32_t hist[256] {0}; for (int i 0; i size; i) hist[gray[i]]; int total size; float sum_all 0; for (int i 0; i 256; i) sum_all i * hist[i]; float sum_fg 0, bg_weight 0; float max_var 0; int threshold 128; for (int t 0; t 256; t) { bg_weight hist[t]; if (bg_weight 0) continue; float fg_weight total - bg_weight; if (fg_weight 0) break; sum_fg t * hist[t]; float mean_bg sum_fg / bg_weight; float mean_fg (sum_all - sum_fg) / fg_weight; float var bg_weight * fg_weight * (mean_bg - mean_fg) * (mean_bg - mean_fg); if (var max_var) { max_var var; threshold t; } } return threshold; }这个函数遍历整帧做灰度直方图再在0到255之间找类间方差最大的分割点参数size是像素总数。校内赛常用32×40或40×60的低分辨率灰度图直方图统计加阈值搜索加起来不到1ms放在帧中断里也不会拖累主循环。注意入参gray必须是连续内存如果是隔行采样的数据要先做一次紧凑拷贝。提取中线时逐行找左右跳变沿、取中点集合遇到环岛或十字单看某一行会得到错误中点一般还要加行间连续性判断相邻行中点斜率突变超过阈值时暂不更新该行用前后行插值补上。分辨率越高图像细节越足但处理耗时和内存占用也同步上涨选型时可以按这张表估算分辨率每帧像素数灰度内存处理耗时量级适用场景32×4012801.25KB极低入门循迹40×6024002.34KB低校内赛主流80×12096009.38KB中需要识别复杂元素3.2 编码器测速与位置式PID的模板实现速度环是整车稳定的地基。编码器脉冲经定时器计数后每隔固定时间比如5ms算一次速度。PID控制器用模板实现实例化成float或double都行template typename T class PidController { public: void Init(T kp, T ki, T kd, T limit_i) { kp_ kp; ki_ ki; kd_ kd; limit_i_ limit_i; } T Update(T setpoint, T feedback) { T err setpoint - feedback; integral_ err; if (integral_ limit_i_) integral_ limit_i_; if (integral_ -limit_i_) integral_ -limit_i_; T out kp_ * err ki_ * integral_ kd_ * (err - last_err_); last_err_ err; return out; } void Reset() { integral_ T(0); last_err_ T(0); } private: T kp_, ki_, kd_, limit_i_; T integral_ T(0), last_err_ T(0); };位置式PID输出的是目标PWM值适用于舵机和直流电机。这里用limit_i_做了积分限幅避免积分项无脑累积导致启动时输出长时间饱和。启动瞬间设定值和反馈值差距大积分迅速顶满所以Init里要按PWM上下限的10%到20%反推一个合理限幅值。方向环和速度环一般做主从级联外环是方向控制根据图像偏差算目标车速内环是速度控制算出实际PWM。调参时先单独给内环喂固定目标速度确认车速稳定后再放开外环。3.3 主循环与状态机把启动、循迹、停车组织起来整车逻辑通常写成有限状态机C里用enum class加switch比散落一堆if-else清楚得多enum class CarState { kStandby, kStart, kRunning, kStop }; void StateLoop(CarState state, uint32_t now_ms) { switch (state) { case CarState::kStandby: if (g_key_press) { g_start_ms now_ms; state CarState::kStart; } break; case CarState::kStart: if (now_ms - g_start_ms 1000) state CarState::kRunning; break; case CarState::kRunning: RunControl(); // 读取图像、算偏差、输出PWM if (g_stop_line_detected) state CarState::kStop; break; case CarState::kStop: SetMotorPwm(0); SetServoAngle(kCenterAngle); break; } }kStandby等状态用强类型枚举避免和其他常量混淆。g_key_press是按键中断里置位的全局标志主循环消费后清零这是嵌入式典型的标志位模式。注意StateLoop里不能有阻塞延时一帧图像的行中断会持续触发长时间阻塞会丢帧。整个巡线加控制的时间预算要小于一帧图像间隔否则算法越跑越滞后车的反应会明显迟钝。4. 参数整定与赛道调试让PID和图像阈值在实车上稳定工作4.1 PID参数的三步整定顺序很多同学上来就给一个很大的Kp结果车在直道上左右猛甩还以为是舵机坏了。我一般的顺序是先只保留Kp让车在直道上小幅修正观察是否收敛到中线再逐步加Kd抑制震荡最后加很小的Ki消除静态误差。速度环永远先于方向环调速度本身不稳时方向环的任何参数都看不出真实效果。每次改参数只动一项做完记录再动下一项调参最忌讳同时改两三个数然后对着现象瞎猜。标志性现象和对应处理可以按这张表查现象原因处理直道高频抖动Kp过大Kp减半或加大Kd弯道明显转向不足输出饱和降低目标速度或查舵机机械行程启动瞬间猛冲积分饱和收紧limit_i_或启动时调用Reset出弯后左右横摆Kd不足小幅度加Kd并观察直道反应4.2 大津法阈值在逆光和暗场下的退化处理大津法的前提是图像灰度直方图存在明显双峰逆光时赛道灰度和背景灰度接近算出来的阈值会被拉偏。校内赛场地灯光常常不均匀处理思路有三个一是对靠近车头近处的行给更高权重因为近处图像最可靠二是保留每行独立阈值对行间阈值再做一次低通滤波避免相邻行阈值跳变太大三是用上一帧的有效中线做先验只在当前帧阈值明显偏离时启用重算。注意这些补偿方案都要在C源码里用数组缓存历史数据不能每帧重新初始化否则低通和权重计算全部失效。4.3 串口在线改参不烧录也能调Kp调试时最怕改一个PID值就重新编译烧录一次一次两分钟一下午就没了。常见做法是走串口调试协议MCU把目标值、反馈值、PID输出按固定格式发到上位机显示同时接收上位机下发的参数更新# 发送帧逗号分隔H为帧头T为帧尾 H,120,25.30,24.18,156,T # 参数下发帧K开头 K,1,kp,1.20,T发送帧各字段依次是目标速度、编码器反馈、PID输出、时间戳上位机按逗号拆分后就能画曲线。参数下发用K开头加参数名加值的形式MCU端解析字符串再更新对应变量。解析用sscanf比手拆string稳裸机上跑一个整型或浮点提取成本很低。这段协议代码不复杂但字段顺序必须固定加字段时先改上位机再改下位机两边同步做不然就会出现“车能跑但曲线对不上”的诡异情况。5. 从源码到可复用工程离线回放和版本标记的收尾技巧校内赛源码要变成能写进简历的项目光能跑完一圈不够还要证明它可复现、可迭代。第一个值得做的收尾工作是离线回放把摄像头原始灰度帧按序存到SD卡带回寝室后用同一份C算法在PC上重跑这样改图像阈值不用反复去赛道烧录验证。录制时同步存下每帧对应的编码器读数和舵机输出回放时就能逐帧对比算法改前改后的差异。g -stdc17 -fsanitizeaddress,undefined -g sim_main.cpp algorithm.cpp -o sim ./sim ./recorded_frames/这里在PC端编译回放工程时加了-fsanitizeaddress,undefined两个检查器能自动抓数组越界、野指针和未定义行为。嵌入式交叉编译用不了这套工具但算法层如果只依赖平面数组和标准库在PC上编译回放是完全可行的。跑一轮回放如果sanitizer报错说明源码里存在潜在内存问题趁早修比赛场上hardfault再查快得多。第二个收尾是版本管理每次试车结束后把有效的参数梯度记下来并打上标签git tag record_0315_loop_A git tag record_0316_loop_B_after_kd_up比赛现场只做减法回退到已验证的Tag不现场试新参数。配合Doc目录里的调参记录表每个Tag对应什么现象、改了什么、为什么改三个月后回看仍然能看懂。第三个是加一个Makefile的replay目标把录制帧目录作为参数传进去任何队员在自己电脑上都能复现前一天的抖动现场。记录格式做到每帧可回放、可对比这份源码才算真正具备了工程属性而不是一次性的比赛产物。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →