
简介这是一份2019年机器人竞赛旅游小车的完整源码工程主要面向大学生机器人竞赛、电子设计与嵌入式开发学习者也适合计算机、数学、电子信息等专业作为项目参考。工程中包含小车运动控制、定时器/PWM输出、传感器采集、电机驱动以及相关算法实现等核心模块下载后可直接在对应IDE中打开编译适合有一定C语言与单片机基础、希望对照完整工程进行二次开发的读者。压缩包共1459个文件以873个C源文件、300个H头文件为主体并包含79个汇编启动文件、链接脚本icf/xcl、库文件a/lib以及Keil、IAR、Visual Studio等多种工程配置文件整体大小约57.62MB目录结构清晰还提供了清理编译中间文件的辅助脚本便于定位驱动层与应用层代码。目前已有89人学习/浏览作为竞赛备赛参考可帮助理解从底层寄存器配置、外设驱动到上层业务逻辑的完整代码组织方式节省自行搭建框架的时间也能通过实际源码积累排错与调试思路。1. 2019机器人竞赛旅游小车源码先别急着解压拿到一份名为“2019机器人竞赛旅游小车源码.zip”的压缩包第一反应不该是双击解压然后直奔 main.c而是先想清楚这份代码所处的时间线和技术基线。2019 年前后的竞赛小车主流平台还是 STM32F103 这类 Cortex-M3 单片机工程用标准外设库或 HAL 库编写整车形态通常是两轮差速或四轮配合一排灰度传感器做巡线。“旅游”二字体现在任务上沿固定路线行驶、经过若干站点时停车等待、再继续巡线有时还要完成语音播报或 LED 显示。和现在开口就是 ROS2、激光雷达、SLAM 的小车开发不同这套源码的价值恰恰藏在它的“朴素”里。模块分层方式、任务状态机骨架、参数标定流程这些在今天的全国大学生智能汽车竞赛和各类机器人竞赛里依然是下位机侧的可靠范式。适合读这份源码的人有两类一类是马上要备赛、想从成熟工程里找组织结构的在校生另一类是工作后要维护旧固件却发现自己写的代码比这份 2019 年的还难下手的嵌入式工程师。2. 源码结构先读懂竞赛工程的分层和 2019 年的典型写法2.1 压缩包里最常见的目录布局与模块归属拿到这类 2019 年的竞赛源码解压后大概率看到一个按“板级驱动、逻辑控制、配置、文档”切分的目录树。虽然每个队伍的命名习惯不同但分层逻辑高度一致读取顺序也基本固定。我一般会先看目录再决定哪些文件值得逐行读、哪些只需要看接口。2019_robot_tour/ ├── App/ │ ├── main.c // 入口完成初始化和主循环 │ ├── task_control.c // 任务状态机 │ └── task_control.h ├── Bsp/ │ ├── bsp_gpio.c // 引脚复用与按键 │ ├── bsp_timer.c // 定时器中断与ms节拍 │ ├── bsp_pwm.c // 电机PWM输出 │ ├── bsp_uart.c // 调试串口 │ └── bsp_i2c.c // 语音模块/OLED ├── Drivers/ │ ├── motor.c // 电机方向与速度映射 │ ├── line_sensor.c // 灰度传感器采集与阈值处理 │ ├── pid.c // 增量式PID │ └── station.c // 站点表管理与识别 ├── Config/ │ └── config.h // 整车参数集中配置 └── Doc/ └── 工程说明.md把这段结构和自己手头工程的映射关系做成一张表能省掉后面阅读时来回跳文件的成本目录职责改动概率App业务逻辑定义“小车该干嘛”高Bsp芯片外设封装只暴露简单接口低Drivers具体器件驱动如电机、传感器中Config宏定义参数调车最先动这里最高Doc说明文档往往滞后于代码不看也罢这一层的常见问题是把 App 和 Bsp 混在一起写。比如有人会把电机 PWM 初始化直接写在任务状态机里换取暂时的方便代价是换平台时要重写整个任务文件。读这类源码时我习惯先确认Drivers层的 API 是否只依赖Bsp层不依赖App层如果是反向依赖那这份代码的移植成本就会很高复制它之前要想清楚。2.2 main.c 里被很多人忽略的初始化顺序2019 年代的竞赛工程初始化代码长这样是常态先是系统时钟和引脚再是定时器和 PWM接着把电机、传感器、PID 逐个准备好最后原地等待起跑按键。这个顺序看起来顺理成章但每一步之间都有因果PWM 初始化必须在电机初始化之前因为 Motor_Init 里通常会把占空比写成 0如果 PWM 外设时钟还没打开这个“停住”动作就是无效的。int main(void) { SystemInit(); // 1. 板级外设时钟、引脚、通信接口 BSP_GPIO_Init(); BSP_TIM_Init(); // 系统节拍也用作编码器计数 BSP_PWM_Init(); // 必须先于电机驱动 BSP_UART_Init(115200); // 调试串口越早越好 // 2. 器件驱动初始化 Motor_Init(); // 内部将PWM占空比清零 LineSensor_Init(); PID_Init(pid_left, PID_PARAM); PID_Init(pid_right, PID_PARAM); // 3. 任务数据准备 Station_Load(s_stations, STATION_COUNT); // 4. 等待起跑信号 while (KEY_PRESS RESET) { ; // 按住按键再上电进入阈值标定模式 } Task_Dispatch(); // 正常流程不会执行到 while (1) { } }这里有一个值得注意的细节UART 的初始化被放在比较靠前的位置。原因是 2019 年那个阶段很多队伍已经开始用串口打印来观察传感器原始值而打印语句往往会出现在最底层的驱动里。如果串口初始化晚于某个驱动初始化一旦驱动内部触发日志输出结果就是死机或者乱码干扰排错判断。按键等待起的第二个作用是提供“标定模式入口”。按住启动按键上电时可以在进入主逻辑之前切到一个独立的分支去做全白/全黑阈值扫描。这个设计在竞赛源码里很常见也是裸机工程里少见的“运行模式选择”做法比用拨码开关更节省硬件。2.3 从 config.h 看懂整车参数config.h是调试效率最高的入口。2019 年的竞赛小车参数都是宏定义不搞运行时配置理由是裸机环境下少一层间接访问编译后直接就是立即数省指令周期。下面是我在类似工程里见过的参数集合集中用一张表说明它们的物理含义#define WHEEL_BASE_MM 185.0f // 左右轮距用于转向幅度估算 #define ENCODER_PULSE_PER_REV 20 // 编码器每圈脉冲数 #define SAMPLE_TIME_MS 10 // PID与巡线采样周期 #define LINE_THRESHOLD 40 // 灰度二值化阈值0~1000 #define STATION_STOP_MS 3000 // 站点默认停车时长 #define MAX_SPEED_PWM 800 // 满占空比对应的PWM计数 #define BASE_SPEED_PWM 520 // 直线行驶速度宏名数值影响调参方向WHEEL_BASE_MM185.0f转向灵敏度越短越灵活换底盘必改SAMPLE_TIME_MS10控制周期越小转向越激进5~20 之间尝试LINE_THRESHOLD40传感器二值化边界用串口日志标定STATION_STOP_MS3000站点停车时间按规则要求调整BASE_SPEED_PWM520整体车速弯道能力决定上限这些参数很少一开始就是对的。我的习惯是先把SAMPLE_TIME_MS固定再调BASE_SPEED_PWM最后碰LINE_THRESHOLD。因为前两个参数决定稳定性后者只决定传感器分类边界如果把阈值数字按场地光线重新扫一遍多数“发车跑飞”问题都能解决而不是真的需要重写 PID。3. 旅游小车的运动控制源码怎么读从传感器到 PWM3.1 灰度传感器数据的二值化与归一化巡线是这类旅游小车的基础能力控制链路的源头是一排 8 路或 6 路灰度传感器。它们输出的是模拟电压经 ADC 采样后变成 0~4095 的数字量。不同传感器个体之间有不小的离散性同一个传感器在不同高度下反应也完全不同所以源码里一般不会直接用原始 ADC 值做对比而是先做一个归一化。uint16_t ConvertToGray(uint8_t ch, uint16_t raw) { uint16_t cal; // 缩放到 0~1000消除个体差异 cal (uint16_t)((raw - calib_min[ch]) * 1000 / (calib_max[ch] - calib_min[ch] 1)); // 超过标定范围时截断避免后续PID计算溢出 if (cal 900) { cal 900; } if (cal 10) { cal 10; } return cal; }标定过程是先把全部传感器对准白色地面读出一组最大值存进calib_max[]再全部对准黑色胶带读出最小值存进calib_min[]。这样做的限制也很明显只在当前光照下有效比赛场地换了一块亚克力板灰度分布就会整体偏移必须重新标定。经过归一化后每个通道的数值就能当成分数用。常见做法是加权平均求“线位置”给中间通道更高的权重两边的通道权重递减算出一个小车相对黑线的横向偏差偏差范围一般在 -1000 到 1000 之间正值表示黑线在车体右侧。这个偏差直接作为 PID 的误差输入是整个巡线逻辑中最关键的中间量。3.2 差速转向为什么竞赛源码只用增量式 PID旅游小车的转向不是打方向盘而是靠左右轮速度差。直线时两轮各自输出BASE_SPEED_PWM遇到弯道时一侧加速、另一侧减速差值由 PID 输出决定。竞赛源码里用得最普遍的是增量式 PID输出的是本轮相对上一轮的控制增量而不是绝对控制量typedef struct { float kp, ki, kd; float integral; float last_err; int16_t out_limit; } Pid_t; int16_t Pid_Update(Pid_t *pid, int16_t err) { // 积分项先做限幅防止长时间飞线导致积分饱和 pid-integral err; if (pid-integral pid-out_limit) { pid-integral pid-out_limit; } if (pid-integral -pid-out_limit) { pid-integral -pid-out_limit; } float d err - pid-last_err; int16_t out (int16_t)(pid-kp * err pid-ki * pid-integral pid-kd * d); pid-last_err err; if (out pid-out_limit) { out pid-out_limit; } if (out -pid-out_limit) { out -pid-out_limit; } return out; }这段代码里最容易犯错的是积分限幅和输出限幅混用。积分限幅只需要让积分项不失控输出限幅是为了保护电机驱动不被过大 PWM 冲击。如果把积分限幅也设成out_limit的同一数值长直道上传感器短暂丢失时积分项会涨满等到重新看到线时就会出现一次猛烈的甩尾。参数初始值可以参考下面这张表但一定要基于自己的SAMPLE_TIME_MS换算参数建议起点现象表现kp0.4~0.8太小走不出弯太大会左右低频摆动ki0.02~0.10过大在直道上画龙kd0.1~0.4用于抑制摆动过大会在入弯时延迟out_limit200~350限制差速幅度防止电机堵转巡线循环中最终两个 PWM 通道的输出是BASE_SPEED_PWM ± pid_out。注意符号方向要做一次统一许多源码的坑就在这PID 计算出的正偏差到底应该加速左轮还是右轮取决于传感器阵列的安装方向。判断方法很简单把小车抬起来手工给一个正占空比看轮子的实际转向是否和代码注释一致不一致就翻转符号。3.3 里程累积与站点减速信号旅游小车和普通巡线车的差别在于它要在固定位置停车所以运动控制层必须提供“当前位置”的粗略估计。2019 年的很多队伍不肯加编码器用纯时间积分来推测位置误差其实非常大。稍微靠谱的写法是借助定时器外部脉冲计数模式把编码器 A、B 相接进定时器的编码器接口位置信息就从轮子真实转过的圈数里来。// 编码器位置累加在定时器更新中断里调用 void Encoder_Update(void) { int16_t left_pulse TIM_GetCounter(TIM2); // 读取并清零 int16_t right_pulse TIM_GetCounter(TIM3); g_left_mm (int32_t)(left_pulse * MM_PER_PULSE); g_right_mm (int32_t)(right_pulse * MM_PER_PULSE); }这里的MM_PER_PULSE等于轮子周长除以编码器每转脉冲数再除以减速比。常见错误是忘记细分数。很多编码器本身每转只有 20 个脉冲但通过定时器 4 倍频后变成了 80 个脉冲。如果ENCODER_PULSE_PER_REV还填 20里程计算就会差 4 倍站点会在预计距离的 1/4 处被触发。有了里程之后站点的减速策略就可以写成距离下一个站点还剩 500mm 时把BASE_SPEED_PWM从 520 降到 300同时开始检查停车线。纯靠灰度传感器临时切减速是来不及的等看到停车线再降速车已经冲过两三厘米了。4. 旅游任务的精髓把“逛景点”写成状态机源码4.1 站点表用一张静态结构体数组描述整个行程旅游小车要跑的路线是固定的站点顺序也不会变所以源码里最自然的建模方式就是一串结构体数组。每个站点记录三样东西第几个停车线、要停多久、要不要播报。这种静态表的好处是想调整行程不用改逻辑代码只改数组内容。typedef struct { uint16_t stop_line_idx; // 第几条停车线从1开始 uint16_t stop_ms; // 停车时长 uint8_t voice_id; // 语音播报索引0表示不播 uint8_t is_last; // 是否为终点 } Station_t; static Station_t s_stations[] { {1, 2500, 1, 0}, // 第一站停2.5秒播报1 {2, 3000, 2, 0}, // 第二站停3.0秒播报2 {3, 4000, 3, 1}, // 终点停4.0秒播报3结束 }; #define STATION_COUNT (sizeof(s_stations) / sizeof(s_stations[0]))站点识别不能只靠里程因为里程存在累计误差跑完三米多之后误差可能已经超过一个站点的宽度。所以要里程加传感器双重确认预估距离进入允许窗口后开始检测停车线信号二者同时满足才算到位。只有传感器判定时地面反光或者压到其他黑线会误触发只有里程判定时误差没法收敛。4.2 主状态机的状态划分与切换条件任务层最稳妥的结构是一张有限状态机状态数量控制在三到五个。竞赛中常见的划分是待机、巡线行驶、站点停车、终点停止。用枚举表达状态用一个大循环里的 switch 做调度简单、直观、易调试typedef enum { ST_IDLE 0, ST_RUN, ST_STATION_WAIT, ST_FINISH } TaskState; void Task_Dispatch(void) { TaskState st ST_RUN; uint32_t wait_start 0; while (1) { switch (st) { case ST_RUN: if (Station_Detect()) { st ST_STATION_WAIT; wait_start SysTick_GetTick(); Motor_Stop(); // 停稳再计时 } else { Motor_FollowLine(); // 常规巡线 } break; case ST_STATION_WAIT: if (SysTick_GetTick() - wait_start Station_StopMs()) { st ST_RUN; // 时间到继续巡线 } break; case ST_FINISH: Motor_Stop(); break; default: st ST_RUN; break; } } }状态触发条件执行动作迁移去向ST_RUN启动按键按下后无条件进入巡线同时判断站点站点识别到则停ST_STATION_WAIT站点检测函数返回真电机停转定时器计时时间到回 ST_RUNST_FINISH站点表最后一个站完成全停不可逆写状态机时有一个容易忽略的“自锁”问题站点停车结束后如果车没有完全驶离停车线Station_Detect()依然为真会导致刚恢复运行又重新停车。解决办法有两种要么在切回ST_RUN时给站点检测加一个 500ms 的屏蔽窗口要么在站点识别条件里加入“刚才检测到的站点序号与当前站点表序号不同”的互斥判断。第二种种方法更可靠。4.3 用 10ms 节拍做任务调度而不是硬上 RTOS很多第一次看竞赛源码的人会问巡线、站点检测、语音播报都是同时发生的需求为什么 2019 年的源码不上 FreeRTOS答案很实际竞赛小车是周期强实时任务裸机用定时器节拍就能满足而且 RTOS 的调度切换会引入不确定延时对 10ms 控制周期来说不是好事。常见的实现是 SysTick 每 1ms 产生一次中断累加g_tick主循环里用g_tick做时间片轮询uint32_t next_control 0; uint32_t next_log 0; while (1) { uint32_t now SysTick_GetTick(); // 10ms 控制周期巡线 PID 状态机 if (now next_control) { next_control now 10; Task_Dispatch(); } // 100ms 日志周期通过串口输出关键变量 if (now next_log) { next_log now 100; Log_Output(); } }这里的一个隐藏要点是语音播报不能阻塞在任务里。很多 AVR 和 STM32 工程会用HAL_Delay等待语音模块播完导致任务函数被卡住几百毫秒巡线中断车体跑飞。正确做法是对语音模块写入播报索引后立即返回播报由模块自己完成结束不占用主循环时间。5. 把旧源码移到新板引脚映射、阈值标定、回归三坑5.1 先做引脚映射表再做硬件移植拿到 2019 年的源码往自己的新板子上移植时第一步不是看代码而是对照新板的原理图把源码里每个用到的引脚整理出来。这个动作往往能发现一半的问题因为当年的板子喜欢把 PWM 输出和编码器接口排在一起新板可能把它们换到了不同的定时器通道上。功能源码旧引脚新板引脚定时器通道左电机 PWMPA8PB6TIM4_CH1右电机 PWMPA9PB7TIM4_CH2编码器 A 相PA0PA0TIM2_CH1编码器 B 相PA1PA1TIM2_CH2启动按键PB0PC13GPIO_EXTI修改点集中在bsp_gpio.c和bsp_pwm.c两个文件里尽量不要在Drivers层出现具体引脚号这样才能保持上层逻辑不动。检查映射表时还要确认引脚的工作模式编码器输入必须配置为浮空输入或上拉输入PWM 输出为复用推挽输出。一个引脚在 2019 年能正常数脉冲很可能只是因为它刚好被初始化为输入而不是因为当初写代码的人理解了这个参数。5.2 用串口日志标定阈值而不是肉眼调参灰度阈值是最值得重新标定的参数。旧源码里的LINE_THRESHOLD 40是针对当年的传感器和地形调出来的直接沿用大概率失效。标定不是先改数字再试跑而是先读数据把传感器原始值打印到串口然后分别把小车摆在黑线上和白色地面上记录两组数据void LineSensor_LogRaw(void) { uint8_t i; for (i 0; i LINE_SENSOR_NUM; i) { printf(CH%d:%04d , i, LineSensor_ReadRaw(i)); } printf(\r\n); }拿到串口助手里的实际数值后建议在黑色采样值和白色采样值之间取中点作为初始阈值。比如黑色值稳定在 200白色值稳定在 800阈值就取 500 附近而不是沿用 40。这里的经验是标定要在比赛场地实际光照下完成白纸打印出来的代码不解决任何问题。5.3 移植后必查的三个回归点第一是 PWM 极性。不同电机驱动板的 IN1/IN2 逻辑可能相反同一个 50% 占空比下旧板正转新板可能反转。修整方式是把motor.c里的方向宏反向或者在Motor_Init中对比方向表。用万用表量电机两端电压极性是最快的验证手段。第二是编码器方向。左右轮编码器相位接反会导致车速里程方向相反表现是小车在直道上能巡线但距离越算越错。判断方法手动正转左轮观察串口脉冲计数是增大还是减小和另一个轮子对比。发现方向不一致时要么换线要么在Encoder_Update里对TIM_GetCounter结果取负。第三是站点触发抖动。新板子如果是用按键输入来模拟站点信号没有做按键消抖状态机会出现一次触发连续跳两次的问题。至少要在站点检测层加“同一站点不能连续触发”的标志位或者用 20ms 延时重新采样确认。这个回归点最隐蔽因为它不是每次跑都发生而是发生在站点信号沿的抖动时刻。把这三个检查点固定在移植清单里比拿到代码后猜来猜去要快得多。剩下的工作就是回到训练场重新标定一遍阈值和 PID 参数让小车重新适应新场地的光照和地面摩擦系数。本文还有配套的精品资源点击获取