
简介面向电子设计竞赛参赛者的智能控制小车程序包专注自动寻迹、避障、定位与路径规划等赛题热点注重C语言与嵌入式硬件的结合。代码采用模块化结构涵盖主程序调度、传感器读取、电机控制与PID控制器等关键模块清晰呈现从GPIO数据采集、PWM调速到控制算法落地的完整流程。压缩包共4个文件均为C源代码包体仅4KB轻量且便于逐行精读与二次改造。这套程序已有721人学习下载适合掌握C语言与嵌入式基础的电赛选手深入研究。学习这份代码既能快速复用基础控制框架也能借鉴传感器数据处理、PID参数整定及模块划分思路借助串口调试等常见手段更可进一步梳理控制逻辑为功能扩展与性能调优提供实用参考。1. 项目背景这辆小车到底在解决什么问题做智能控制小车程序这事儿最容易让人栽跟头的不是硬件焊接也不是传感器贵不贵而是你脑子里对“控制”的理解还停在“按键按下就往前跑”。我最早做这个项目时以为把电机驱动代码写完、让轮子转起来就算完了结果小车一放到地上就原地打转不是冲出赛道就是撞墙那种挫败感相信很多入门嵌入式的人都经历过。这个项目里我选的是一块常见的STM32F103C8T6主控板配L298N电机驱动、HC-SR04超声波传感器、4路红外循迹模块、MG310电机带霍尔编码器和一块HC-05蓝牙模块。程序一共实现了三种模式循迹模式、避障模式、手机蓝牙遥控模式通过按键循环切换并在OLED小屏上显示当前状态。这套组合非常典型电子竞赛、课程设计、毕业设计里到处能看到类似结构拿来练手、拿来参赛、拿来当作品交都拿得出手。它的核心价值不只是“让小车跑起来”而是让你理解一套完整的程序该怎么组织传感器数据怎么采、控制决策怎么下、电机输出怎么调、多任务怎么切换。很多人写小车程序代码全堆在main函数里循迹逻辑和避障逻辑互相干扰加一个蓝牙功能就把整个程序搞得一团糟。这篇文章不会只丢给你一份能编译通过的代码而是把我在实际调试里踩过的坑和最终跑通的程序设计思路一起讲清楚。适合刚学完单片机基础、想做一个完整项目的读者也适合正在准备比赛或毕设、代码写得比较乱想重构的人。2. 硬件选型与程序接口设计2.1 主控板怎么选才不给自己挖坑主控是整辆小车的“大脑”选型直接决定程序怎么写。很多人贪便宜买那种几块钱的STM32F103C8T6最小系统板拿来点个灯没问题但一接电机驱动、一开超声波板子就频繁复位。原因很简单普通最小系统板的稳压电路很弱而L298N电机驱动如果直接从板子取电电流一冲击电压就跌到单片机复位阈值以下。我实际用下来比较稳的方案是主控板用带AMS1117-3.3稳压的版本电机驱动用L298N模块电池用两节18650锂电池串联7.4V给驱动板供电驱动板再输出5V给主控板供电传感器和蓝牙模块从主控板的3.3V或5V引脚取电。这样电源是分级结构电池→L298N→主控→外设每一级都有稳压和电容缓冲程序跑起来不会因为供电波动重启。接口分配也得提前设计好。我的分配方案如下外设引脚说明左电机PWMPA0TIM2_CH1左电机方向1PB0正转/反转控制左电机方向2PB1正转/反转控制右电机PWMPA1TIM2_CH2右电机方向1PB2正转/反转控制右电机方向2PB3正转/反转控制编码器左A相PA6TIM3_CH1输入捕获编码器右A相PA7TIM3_CH2输入捕获超声波TrigPA2输出10us高电平超声波EchoPA3输入捕获测脉宽循迹模块左1PB12数字输入循迹模块左2PB13数字输入循迹模块右1PB14数字输入循迹模块右2PB15数字输入蓝牙TXDPA9USART1_RX蓝牙RXDPA10USART1_TX这个表格里的分配原则是有讲究的PWM引脚必须选在同一个定时器上这样两个电机能共用时基调占空比时互不干扰编码器输入要选在同一个定时器的不同通道上方便用定时器的编码器模式直接读转速省掉外部中断的软件开销。如果你拿到的是其他型号的板子第一步先对着数据手册把这些复用关系理清楚再开始写代码不然写一半发现引脚冲突改起来相当痛苦。2.2 电机驱动与传感器接口的细节L298N模块虽然老但它皮实耐用适合新手。它的IN1~IN4接主控的GPIOENA和ENB接PWM输入控制左右电机速度。有个很容易踩的坑如果你把ENA和ENB跳线帽插上模块会默认全速运行这时候程序里控制PWM占空比根本没效果。所以想用PWM调速必须把跳线帽拔掉把ENA、ENB分别接到主控的PWM引脚上。超声波HC-SR04的供电电压是5V逻辑电平也是5V而STM32的GPIO容忍5V输入所以Trig和Echo可以直接接主控引脚不用电平转换。但Arduino用户要特别注意如果主控是3.3V供电Echo脚的5V高电平会烧引脚最好用电阻分压。测距的时序是Trig给10us以上高电平触发模块内部发8个40kHz脉冲然后Echo引脚输出高电平高电平持续的时间就是声音往返的时间。距离高电平时间×340m/s÷2实际代码里我习惯用微秒计时的数值除以58来得到厘米因为58近似于2÷0.034。红外循迹模块用的是TCRT5000这种反射式光电传感器黑线反射率低、输出高电平白底反射率高、输出低电平。注意不同厂家的模块逻辑电平可能相反用之前一定要用万用表或串口打印确认。4路循迹的配置可以让小车在偏离白线时还有修正空间比双路循迹容错率高很多这也是我最后选了4路而不是省两个引脚用2路的原因。3. 程序总体架构状态机驱动的主循环3.1 主循环与定时调度小车程序最忌讳的做法是在主循环里用delay延时。比如你写了超声波测距函数里面delay(10)等Echo电平变化这10毫秒里电机PWM照样输出硬件PWM不受影响但其他传感器和按键扫描全被卡住了外部信号一多程序就像个反应迟钝的人。我用的是“定时调度 状态机”的结构。主循环只有一件事不断检查一个全局标志位看当前时间片到了没有。核心代码如下// 主循环调度 int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM2_Init(); // PWM MX_TIM3_Init(); // 编码器模式 MX_USART1_Init(); // 蓝牙 MX_TIM4_Init(); // 超声波测距用输入捕获 while (1) { if (flag_10ms 1) { flag_10ms 0; Task_10ms(); // 按键扫描、循迹传感器采样 } if (flag_50ms 1) { flag_50ms 0; Task_50ms(); // 超声波测距、避障决策 } if (flag_100ms 1) { flag_100ms 0; Task_100ms(); // OLED刷新、速度环计算 } Delay_1ms(); } }定时器中断里只做一件事把对应对应的时间片标志位置1。比如TIM4每1ms产生一次中断中断服务程序里用计数器累加每到10、50、100就置位对应标志。这样做的价值是主循环永远不会被阻塞每个任务在固定的时间片里执行任务之间天然隔离。你要加新功能只需要新增一个Task函数挂到合适的调度周期上不用动其他代码。3.2 状态机的设计小车有三种模式如果写if-else嵌套代码会越来越乱。我采用一个简单的状态机typedef enum { MODE_STANDBY 0, // 待机 MODE_LINE, // 循迹 MODE_AVOID, // 避障 MODE_BT // 蓝牙遥控 } CarMode_t; CarMode_t currentMode MODE_STANDBY;按键每按一次切换一个模式。状态切换时要做“退出清理”和“进入初始化”比如从循迹切到避障要把电机的当前输出清零避免上一模式的残余指令让小车突然冲出去。这个细节很多人忽略结果模式切换时小车总会顿一下或猛一下。在状态机实现里每个模式对应一个处理函数函数内部用switch或者查表实现行为。避障模式里那些“前方有障碍往左转还是往右转”的判断逻辑抽成独立子函数不要在状态函数里堆一大坨。我在做这个项目时强制自己遵守一条规则一个函数超过60行必须拆。拆出来的函数不仅能复用调试时也能单独验证比如单独测试“超声波读出来80cm时避障判断是否合理”不用把整辆车跑起来才能发现问题。4. 循迹与避障的代码逻辑4.1 循迹判定逻辑与丢线处理循迹的原理不复杂4路红外传感器排在车头正常情况下中间两路压着黑线外侧两路在白底上。当小车偏左时右边的传感器会扫到黑线程序就该往右打方向。我的传感器排布顺序是左1、左2、右1、右2对应的黑线检测值为0探测到黑线时模块输出低电平当然实际取决于你的模块接线。我定义了一个8位状态值只用了低4位uint8_t line_status 0; line_status | (HAL_GPIO_ReadPin(LINE1_GPIO_Port, LINE1_Pin) GPIO_PIN_RESET) 3; line_status | (HAL_GPIO_ReadPin(LINE2_GPIO_Port, LINE2_Pin) GPIO_PIN_RESET) 2; line_status | (HAL_GPIO_ReadPin(LINE3_GPIO_Port, LINE3_Pin) GPIO_PIN_RESET) 1; line_status | (HAL_GPIO_ReadPin(LINE4_GPIO_Port, LINE4_Pin) GPIO_PIN_RESET) 0;然后根据这4位的组合决定电机控制0b0110中间两路压线直行0b0100右2偏出左1右1压线小车偏右需要左转0b0010小车偏左需要右转0b0000全丢线进入丢线处理丢线处理是这个项目最容易忽略但决定成败的部分。小车在急转弯处因为速度太快传感器全部冲出黑线如果这时候你什么都不做车就彻底跑飞了。我的策略是记录上一次有效的转向方向丢线后继续按上次方向打更大的转向角同时降低速度直到传感器重新捕捉到黑线。这个“记忆转向”的思路算是循迹算法里比较实用的技巧很多比赛代码里都有类似处理。我还加了PWM差速而不是简单的左右满转左右轮速度不是固定一快一慢而是根据偏转程度动态调整。基础速度设为基础速度speed_base转向时左轮speed_base加上偏移量右轮减去偏移量。偏移量可以是一条分段线性函数传感器状态越偏偏移量越大。循迹车的“手感”好不好很大程度上取决于这个差速比例有没有调好。4.2 避障策略决策优先级比超声波精度更重要避障程序的核心不是“怎么测距”而是“测到距离后怎么决策”。HC-SR04的读数本来就有波动如果只依据单次测量做判断小车会在障碍物边缘来回抖动。我维护了一个简单的滑动滤波连续读3次距离取中位数避免异常值影响决策。控制逻辑的伪代码如下uint8_t avoid_mode 0; // 0直行 1左转 2右转 3后退 void Task_Avoid(uint16_t distance_cm) { if (distance_cm 30) { avoid_mode 0; // 前方安全直行 SetMotorSpeed(BASE_SPEED, BASE_SPEED); } else if (distance_cm 15) { // 进入警戒区减速 avoid_mode 1; // 默认左转试探 SetMotorSpeed(BASE_SPEED * 0.5, BASE_SPEED * 0.8); } else { // 危险区后退右转 avoid_mode 3; SetMotorSpeed(-BASE_SPEED * 0.6, -BASE_SPEED * 0.6); HAL_Delay(200); avoid_mode 2; SetMotorSpeed(BASE_SPEED * 0.6, -BASE_SPEED * 0.6); HAL_Delay(300); } }注意这套逻辑里有一个避障盲区问题超声波装在车头正前方但车身两侧是盲区。如果小车前方没有障碍但左侧有墙直行不会撞上是因为还没到一旦到直角转弯处正前方传感器突然测到障碍再转向就可能来不及。所以我的方案里加了“左转优先”的习惯在警戒区默认往左试探因为我的测试场地左边通常比较开阔。如果你的场地右侧开阔就把默认转向方向反过来。这是根据实际场地灵活调整的经验不是死板的算法。超声波测距本身也有一个坑Echo引脚返回的高电平脉宽最长能到20ms左右。如果你的主循环里轮询等待这个引脚期间所有其他任务都会卡住。所以我不用轮询而是用输入捕获加超时机制在中断里测量脉宽超过一定时间没捕获到就认为测距超量程直接返回一个有效最大距离值。这样即使超声波模块掉线或前方没有反射面程序也不会死等。5. 速度控制与转向平滑的调参心得5.1 为什么我最后选了增量式PID如果只是做展示用开环PWM控制也“看起来能动”但小车在电池电压充足时跑得飞快、电压下降后明显变慢走直线也歪歪扭扭。要让小车真正“稳”必须让电机的实际转速跟上目标转速这就是闭环控制。我用了增量式PID而不是位置式PID。原因是增量式PID的输出是“本次需要增加或减少多少PWM”它不需要累加历史误差所以不会有积分饱和的问题而且调参时安全性更高。计算式是dKp * (e_k - e_k1) dKi * e_k dKd * (e_k - 2*e_k1 e_k2)对应代码int32_t speed_pid(int32_t target, int32_t actual, PidObject *pid) { int32_t error target - actual; int32_t output pid-Kp * (error - pid-err[1]) pid-Ki * error pid-Kd * (error - 2 * pid-err[1] pid-err[2]); pid-err[2] pid-err[1]; pid-err[1] error; return output; }我用的PID参数是Kp12、Ki0.6、Kd0.5这是针对我这对电机实测出来的。先只加Kp让小车走直线看有没有低频震荡再慢慢加Ki消除静差最后加一点点Kd抑制超调。这个过程每辆车的机械结构不同参数都不会一样重点是要理解调参顺序。电机编码器读数用的是TIM3编码器模式int16_t speed (int16_t)__HAL_TIM_GET_COUNTER(htim3); __HAL_TIM_SET_COUNTER(htim3, 0);编码器的分辨率是每转560线我按10ms周期读取计数算出来的速度单位是“每100ms的脉冲数”。不需要换算成rpm只要目标值和实际值用同一个单位就行。我把这个变量值通过串口打印出来配合上位机看曲线观察PID调节效果。5.2 转向平滑PWM直接跳变会让车点头调试中我发现一个问题小车从全速直行突然进入急转弯车身会明显抖一下甚至前轮离地。这不是机械问题而是PWM从一个值瞬间跳到另一个值加速度太大。单片机控制电机的是PWM占空比但电机的电气时间常数让电流不能突变机械结构又有惯性所以剧烈跳变必然造成冲击。我加了一个斜坡限幅函数uint32_t ramp_pwm(uint32_t target, uint32_t current, uint32_t max_step) { if (target current max_step) return current max_step; if (target current - max_step) return current - max_step; return target; }每次PWM更新只允许变化max_step这么一点比如每10ms最多变化20。这样转向时小车会平滑地减速、转向、再加速视觉上很舒服机械结构也不容易坏。这个斜坡限幅可以理解成给电机指令加了一层“缓冲垫”代价是响应变慢了一点但在小车上完全够用。转向平滑和后文要讲的PID有一个配合点如果你在PID输出之后再套一层斜坡限幅相当于给速度环的输出做后置滤波会让系统更稳定但也会让PID的快速纠偏效果打折。我的经验是把斜坡限幅放在“目标速度”层面而不是放在最终PWM输出层面。也就是说模式决策层给出的目标速度先经过斜坡再进入PID作为目标值PID的输出直接给电机。这样既平滑了不同模式切换时的冲击又不影响PID本身的动态响应。6. 串口调试、遥控扩展与问题排查6.1 串口日志没有它你根本不知道程序在想什么我见过很多初学者调小车程序一跑乱套就开始“盲调”改几个参数烧录观察不行再改。这种方法的效率极低因为你根本不知道小车内部的状态变量是什么。正确的做法是把关键状态实时打印出来配合上位机看曲线。我在代码里加了一个轻量的串口调试功能通过蓝牙模块或被USB转TTL接到电脑上以CSV格式输出printf(mode:%d,line:%d,dist:%d,lspeed:%d,rspeed:%d\n, currentMode, line_status, distance_cm, motor_l_speed, motor_r_speed);在调试环境下我用的USB转TTL模块接PA9/PA10这里注意蓝牙模块和USB转TTL模块不能同时接在同一串口上会冲突波特率115200每隔100ms输出一行。把这串数据导入到串口助手的“波形显示”功能或者用Python的pyserial读出来再plot就能看到“超声波距离在避障过程中变化是否合理”“循迹状态切换是否频繁抖动”“两个轮子速度差是否稳定”。这种数据驱动的方式比用眼睛盯着小车猜原因靠谱得多。6.2 蓝牙遥控与后续扩展思路蓝牙遥控相对简单HC-05上电后默认进入AT模式时波特率是38400配对成功后通信波特率是你的AT指令设定的数值。我这边设成了9600手机App端也需要设成一样的波特率。接收数据用串口中断定义一个协议帧头0xAA、类型、数据、校验和。最简单的遥控指令格式指令值动作0x01前进0x02后退0x03左转0x04右转0x05停止0x0F蜂鸣器/喇叭我实际在蓝牙模式下也套用了状态机把蓝牙数据解出来的指令作为“目标速度”经过斜坡限幅后交给PID执行。这样蓝牙遥控和循迹模式底层用的是同一套速度控制逻辑代码复用率很高。后面如果你想用微信小程序遥控小车只要把小程序蓝牙API发过来的数据按照这个协议解析就行底层完全不用动。扩展方向其实很明显加一个MPU6050陀螺仪就能通过融合编码器和IMU数据估算小车位姿实现更高精度的走直线和定点转弯加一个摄像头模组把图像传到电脑端用OpenCV识别特定颜色块能升级成视觉跟随小车。这些功能都建立在当前这个程序架构上因为你已经把底层封装好了上层加模块只是新增一个Task而已。6.3 环境与工具链的坑关于开发环境我踩过一个大坑值得单独说。刚开始我在Windows上装好了STM32CubeMX和Keil编译烧录全都正常后来换了台电脑把整个工程拷贝过去Keil提示找不到芯片型号折腾了半天才发现是Keil的芯片器件库没装。这种问题不是代码问题但卡住的往往是它。还有一次我把代码改成用HAL库后编译报错说找不到“stm32f1xx_hal_conf.h”这个问题十有八九是工程包含的路径没配对。Keil里魔术棒选项卡的C/C栏Include Paths要把所有存放头文件的目录加进去。类似的问题在命令行编译时也常见比如提示“make不是内部或外部命令”或者“gcc无法识别”多半是环境变量里的路径没配置好。那些报错信息里说“不是内部或外部命令也不是可运行的程序”跟程序本身没关系百度一下配置好PATH就能解决。程序跑起来之后如果突然卡住打开调试器发现停在一个叫HardFault_Handler的地方十有八九是越界访问了。常见的诱因是数组越界、空指针、或者中断里访问了没初始化的外设。排查方法是在HardFault_Handler里打断点然后看Call Stack调用栈一路回溯到main里的哪个函数触发的异常。比如我在做超声波输入捕获时一开始在中断服务程序里调用了HAL_Delay结果系统直接进入HardFault后来查到HAL_Delay依赖Systick中断而输入捕获中断优先级更高把它打断后Systick一直没机会执行就死锁了。中断服务程序里尽量别调耗时函数这个教训希望大家不用像我一样踩一遍。最后分享一个我自己的调试习惯每次改完代码先在固定场地上录一段小车运行的视频再配合串口导出的数据回放对比“程序判断”和“实际现象”之间的差异。很多时候你以为的“传感器没检测到黑线”看数据才发现是另一个变量在捣乱。这种数据比对的方法调过几次之后你就会离不开它。本文还有配套的精品资源点击获取