简介:面向电子设计竞赛参赛者的智能控制小车程序包,专注自动寻迹、避障、定位与路径规划等赛题热点,注重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→主控→外设,每一级都有稳压和电容缓冲,程序跑起来不会因为供电波动重启。
接口分配也得提前设计好。我的分配方案如下:
| 外设 | 引脚 | 说明 |
|---|---|---|
| 左电机PWM | PA0 | TIM2_CH1 |
| 左电机方向1 | PB0 | 正转/反转控制 |
| 左电机方向2 | PB1 | 正转/反转控制 |
| 右电机PWM | PA1 | TIM2_CH2 |
| 右电机方向1 | PB2 | 正转/反转控制 |
| 右电机方向2 | PB3 | 正转/反转控制 |
| 编码器左A相 | PA6 | TIM3_CH1输入捕获 |
| 编码器右A相 | PA7 | TIM3_CH2输入捕获 |
| 超声波Trig | PA2 | 输出10us高电平 |
| 超声波Echo | PA3 | 输入捕获测脉宽 |
| 循迹模块左1 | PB12 | 数字输入 |
| 循迹模块左2 | PB13 | 数字输入 |
| 循迹模块右1 | PB14 | 数字输入 |
| 循迹模块右2 | PB15 | 数字输入 |
| 蓝牙TXD | PA9 | USART1_RX |
| 蓝牙RXD | PA10 | USART1_TX |
这个表格里的分配原则是有讲究的:PWM引脚必须选在同一个定时器上,这样两个电机能共用时基,调占空比时互不干扰;编码器输入要选在同一个定时器的不同通道上,方便用定时器的编码器模式直接读转速,省掉外部中断的软件开销。如果你拿到的是其他型号的板子,第一步先对着数据手册把这些复用关系理清楚,再开始写代码,不然写一半发现引脚冲突,改起来相当痛苦。
2.2 电机驱动与传感器接口的细节
L298N模块虽然老,但它皮实耐用,适合新手。它的IN1~IN4接主控的GPIO,ENA和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参数是Kp=12、Ki=0.6、Kd=0.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一直没机会执行,就死锁了。中断服务程序里尽量别调耗时函数,这个教训希望大家不用像我一样踩一遍。
最后分享一个我自己的调试习惯:每次改完代码,先在固定场地上录一段小车运行的视频,再配合串口导出的数据回放,对比“程序判断”和“实际现象”之间的差异。很多时候你以为的“传感器没检测到黑线”,看数据才发现是另一个变量在捣乱。这种数据比对的方法,调过几次之后你就会离不开它。
本文还有配套的精品资源,点击获取