做这个小车项目的人不少,但大多数教程要么只给单一传感器的示例,要么代码贴出来写了等于没写。这个基于STM32F103的项目把红外循迹、红外避障和超声波测距揉在一起,做成了一套既能沿着黑线跑、又能自动躲障碍的小车,我前后改了三版才跑得像个样子,这里把方案选型、接线、代码实现和踩过的坑一次性整理清楚。不管你是拿它当课程设计,还是想入门嵌入式,或者准备参加小车类比赛,这篇都能帮你少走不少弯路。
1. 项目概览与方案选型
1.1 为什么选STM32F103C8T6:三个理由和一个对比
先解决一个问题:为什么不用Arduino、不用树莓派,偏偏选STM32F103C8T6这块“老古董”?F103C8T6是Cortex-M3内核,主频最高72MHz,Flash 64KB,RAM 20KB。跑一个超声波的测量时序加上两路红外信号处理和差速转向逻辑,资源绰绰有余,价格却只要几块钱。对于学生党和个人项目来说,这是典型的够用就好。
三方面考量比较实在:第一,资料齐全。F103是ST出货量最大的系列之一,寄存器手册、HAL库例程、各种开发板的原理图到处都是,遇到问题搜索一下基本都有答案;第二,学习曲线平滑。从GPIO操作到定时器PWM,再从外部中断到串口调试,整个项目涉及的知识点刚好覆盖嵌入式入门需要掌握的核心模块,做完这个小车,再去做其他单片机项目心里就有底了。第三,5V容忍的IO比较多。F103系列绝大部分普通GPIO都标了FT(5V容忍),意味着可以直接接5V逻辑的传感器模块,不需要额外买电平转换芯片,省事也省钱。
有人会问,树莓派或者ESP32不是更强吗?树莓派跑Linux确实能干更复杂的视觉导航,但电源需求高、启动慢、成本翻好几倍;ESP32主频更高还有WiFi,但做这种硬实时的传感器轮询,F103的裸机中断响应反而是优势。做小车,实时性比算力更宝贵。对于传感器融合这个需求,F103的性能刚好卡在“紧张但够用”的甜点上。
1.2 红外与超声波怎么分工才不算重复造轮子
很多人做避障小车只装一个超声波,跑起来会发现一个很尴尬的问题:超声波模块的探测盲区通常是2到3厘米,等到车头距离障碍物只有两厘米,读回来一个乱跳的数,转向逻辑还没来得及执行就已经撞上了。另一个问题是超声波测距一次要来回十几个毫秒,连续测三次再做滤波,响应速度会明显变慢。
红外传感器正好补齐这两个短板。红外收发对管是近距离检测的能手,车身两侧装两个红外避障模块,专门负责20厘米以内的近距离感知;车底再装两个红外循迹模块,用于黑线检测。这就是整个项目的融合思路:超声波管远,红外管近,各自在最擅长的范围内工作,再通过决策逻辑把信息汇总,而不是简单粗暴地把几个传感器的数据加在一起。
逻辑上分成两个模式来跑。循迹模式下,车底的TCRT5000是主输入,负责跟着黑线走,同时超声波实时检测前方,发现障碍临时避让,绕过去之后继续寻线;纯避障模式下,超声波做前向测距主决策,左右红外做近距离判断,组成分级避让策略。这种“远中近三层感知+双模式复用”的结构,比单独用任何一类传感器都可靠。
1.3 整车架构划分
整个系统可以拆成五个部分:感知层、控制层、执行层、供电层和调试层。感知层包括一个超声波模块和四个红外模块;控制层是STM32F103C8T6最小系统板;执行层是两路电机驱动加两个直流减速电机;供电层用两节18650锂电池串联,经过降压模块输出稳定电压;调试层用OLED屏幕实时显示距离和模式状态,同时通过串口把内部变量打出来。
讲道理,这五个部分单独做都不难,真正考验耐心的是它们组合在一起时出现的各种工程问题——供电干扰、电平不匹配、传感器安装角度、机械结构的重心偏移,每一个坑都能让车跑不起来。下面分章节把这些细节展开说。
2. 传感器选型与硬件接线细节
2.1 TCRT5000循迹模块:原理、接线与电平确认
TCRT5000是光电反射式传感器,模块上有一颗发射管、一颗接收管,外加一个LM393比较器输出数字电平。发射管持续发射红外光,红外光打到地面上会反射回来一部分,接收管收到的反射光强度不同,输出端电平就不同。黑色表面会吸收红外光,反射少,白色表面反射多,所以它天生就能区分黑线和白底。
接线非常简单,VCC接3.3V或5V都可以,GND接地,DO接单片机GPIO。AO口是模拟输出,在有特殊需要时可以用ADC读反射强度,实现灰线检测或阈值自定义。顺带说一句,如果你想更精准地检测不同灰度的线,建议把AO引出来接ADC,后面可以做比例控制,而不是简单的开和关。
重点:不同厂家生产的TCRT5000模块,对“黑线”输出的电平定义可能相反。有的黑线输出高电平,有的黑线输出低电平。接完线先不要写算法,用串口打印读到的GPIO电平,拿一张黑纸在传感器下面划过,确认一下你想要的“检测到黑线”到底对应的哪个值。这个花两分钟确认的动作,能避免后面调试逻辑时怀疑人生。
模块上有一个蓝色电位器,用来调节比较器的参考阈值。顺时针拧到底和逆时针拧到底,探测灵敏度差异非常大,调节原则是:把传感器放在白色地面,调到输出刚好为白地电平,再放到黑线上确认输出翻转,反复几次找到一个临界点。
2.2 HC-SR04测距:时序与电平注意事项
HC-SR04工作原理很简单:给TRIG引脚一个10微秒以上的高电平脉冲,模块内部的电路会发出8个40kHz的超声波脉冲,同时ECHO引脚输出一个高电平,高电平持续的时间就是超声波从发射到接收的往返时间。距离等于时间乘以声速再除以2。
声速在20摄氏度空气里大约每秒340米,换算一下就是0.034厘米每微秒,而超声波是往返的,所以时间要除以2,最终系数是0.017。也就是距离(厘米)= 回波高电平时间(微秒)× 0.017。这个换算关系我刚开始总记错,特别提醒各位:0.034是单程系数,0.017才是往返之后的结果。
HC-SR04用5V供电,回波ECHO输出的高电平最高是5V。F103的GPIO标注为5V容忍,所以直接接到单片机的PB口或者PA口一般没问题。但我建议宁可多一分小心,在ECHO引脚和单片机引脚之间串联一个1k电阻做限流,完全不影响信号读取,就算模块电平特性有差异也不会烧引脚。
安装位置对测量结果影响很大。模块有两个像眼睛一样的探头,发射探头和接收探头要在同一水平线上,正对前方。不要贴地安装,超声波打到地面会乱反射,推荐安装在车头离地5到10厘米的位置,稍微向上仰一点角度,大约10度到15度,这样既能测到前方的障碍物,又不会频繁被地面触发。
2.3 电机驱动与引脚分配
电机驱动有三种常见选择:L298N模块、TB6612模块和DRV8833模块,我实际对比过三者的表现。L298N是老牌方案,功率大,能驱动大电流电机,但压降大,内部的三极管导通损耗让5V稳压输出不稳定,供电久了芯片烫手;TB6612是东芝的MOS管驱动,压降低、体积小,我后期主力用它;DRV8833和TB6612类似,双路驱动,体积更小,适合超迷你小车。对学生项目来说,TB6612是性价比和稳定性最平衡的选择,如果你手里只有L298N也不是不能用,注意别把单片机的5V从L298N上取,电机瞬间抽流会导致单片机复位。
电机控制需要两路PWM(调速)和四个方向GPIO(控制正反转)。方向引脚接H桥的IN1/IN2,逻辑是:IN1高IN2低为正转,IN1低IN2高为反转,IN1和IN2同高同低都算刹车。调速用PWM接STBY或者PWMA/PWMB,注意TB6612有一个STBY引脚,必须拉高芯片才工作,有些人接完线车不动,查半天发现STBY悬空。
我使用的引脚分配表如下,供参考:
| 模块 | 信号 | 单片机引脚 | 说明 |
|---|---|---|---|
| 超声波 | TRIG | PA0 | 推挽输出,10us高电平触发 |
| 超声波 | ECHO | PA1 | 输入,测量高电平时间 |
| 左电机 | PWM | PA8 | TIM1_CH1,10kHz |
| 左电机 | DIR1/DIR2 | PB0/PB1 | 高低电平控制方向 |
| 右电机 | PWM | PA9 | TIM1_CH2,10kHz |
| 右电机 | DIR1/DIR2 | PB2/PB10 | 高低电平控制方向 |
| 红外循迹L | DO | PB3 | 注意:默认是JTAG引脚 |
| 红外循迹R | DO | PB4 | 注意:默认是JTAG引脚 |
| 红外避障L | DO | PB5 | 数字电平 |
| 红外避障R | DO | PB6 | 数字电平 |
| 模式按键 | KEY | PC14 | 低电平有效 |
| 串口 | TX/RX | PA9/PA10 | 调试基线,若与PWM冲突可改用PB6/PB7的USART1重映射 |
一是PB3和PB4默认被JTAG占用,要用它们做普通IO得先关闭JTAG复用功能,这个细节在下一章展开;二是OLED如果加的话用PB6/PB7挂I2C1,或者用软件I2C随便挑两个IO都行。
供电部分是整个项目最容易出问题的地方。两个18650锂电池串联标称7.4V,经过一个低压差降压芯片转成5V给单片机和传感器供电。电机的电源直接接电池正极,但地线与单片机系统共地,否则逻辑电平没有参考点。共地这事怎么强调都不为过:模块地和单片机地必须是同一个地,否则传感器读回来的电平全是乱的。
3. 核心代码实现:从读传感器到自主决策
3.1 初始化必须处理的两个坑:JTAG复用与微秒延时
初始化阶段有两件事要处理干净,不然后面全部白搭。第一件事是PB3/PB4的复用问题。STM32F103上电后,PB3、PB4和PA15默认功能是JTAG引脚,如果直接当普通IO用,读回来的电平始终不对。解决办法是在GPIO初始化之前,把JTAG的调试口关掉,保留SWD下载功能,代码如下:
__HAL_AFIO_REMAP_SWJ_NOJTAG();这行代码执行后,PB3和PB4才能被当作普通IO使用,同时PA13/PA14两个SWD引脚依然保留,可以继续用调试器下载程序。不写这行的话,你在PB3上焊个传感器,读到的数据永远像在跳大神。
第二件事是微秒级延时。超声波的TRIG引脚要求10微秒的高电平脉冲,回波时间测量精度直接影响距离精度。用HAL_Delay这种毫秒级延时远远不够,必须用微秒级延时。最可靠的办法是用Cortex-M3内核自带的DWT循环计数器,不需要额外占用定时器:
void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } void delay_us(uint32_t us) { DWT->CYCCNT = 0; while (DWT->CYCCNT < us * 72); // 系统时钟72MHz,1us = 72个周期 }第一次用DWT的时候我确认过:CYCCNT是32位计数器,溢出周期大约59.6秒,做单次微秒延时完全不用担心溢出问题。如果你不想用DWT,用SysTick做微秒延时也不是不行,但代码会比这个啰嗦一倍。
GPIO初始化用标准的HAL库函数,方向引脚配成推挽输出,循迹和避障引脚配成上拉输入,PWM输出引脚在MX配置里设置好复用功能。系统时钟通过SystemClock_Config配成72MHz,这行配置可以直接用CubeMX生成,建议别手写时钟树,容易配错导致外设频率不对。
3.2 超声波测距代码:加上滤波才能真正用
一次完整的超声波测距流程可以拆成四个步骤:TL;DR就是拉低TRIG、拉高10us、拉低、等ECHO高电平计时。写成函数如下:
float Get_Ultrasonic_CM(void) { // 1. 触发脉冲 HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_RESET); delay_us(5); HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_SET); delay_us(12); HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_RESET); // 2. 等待ECHO上升沿,超时保护 uint32_t timeout = 0; while (HAL_GPIO_ReadPin(ECHO_PORT, ECHO_PIN) == GPIO_PIN_RESET) { if (++timeout > 30000) return -1; } // 3. 测量高电平持续时间 uint32_t start = DWT->CYCCNT / 72; // 转为微秒 timeout = 0; while (HAL_GPIO_ReadPin(ECHO_PORT, ECHO_PIN) == GPIO_PIN_SET) { if (++timeout > 60000) { start = 0; break; } } uint32_t dt = DWT->CYCCNT / 72 - start; // 4. 超时判断与距离换算 if (dt > 60000) return -1; float dist_cm = dt * 0.017f; return dist_cm; }这里有两个关键工程细节。第一,等待ECHO上升沿的过程必须加超时保护。当超声波模块前面没有障碍物,或者反射面太远超出量程上限,ECHO会一直保持低电平,如果没有超时保护,程序会卡死在while循环里,小车当场死机。第二,回声高电平时间上限是60毫秒,对应距离大约是1020厘米,但HC-SR04实际量程只有400厘米左右,超过量程的读数直接丢弃,后续逻辑不要使用。
实际跑起来还会发现一个问题:单次测量出来的距离会在原地抖动,同一个静态障碍物连续测五次,可能一次16厘米一次19厘米,这种抖动会让转向逻辑来回犹豫,小车蛇形前进。解决方法是加一个三样本中位值滤波,取三次连续测量的中间值作为有效距离:
float Read_Distance_Filtered(void) { float a = Get_Ultrasonic_CM(); delay_us(80); float b = Get_Ultrasonic_CM(); delay_us(80); float c = Get_Ultrasonic_CM(); if (a < 0) a = 999; if (b < 0) b = 999; if (c < 0) c = 999; // 简单中位:三个数排序取中间 if (a >= b && b >= c) return b; if (a >= c && c >= b) return c; if (b >= a && a >= c) return a; if (b >= c && c >= a) return c; if (c >= a && a >= b) return a; return b; }注意三次测量之间要有短暂间隔,连续快速触发会让模块内部的电容充放电来不及恢复,导致测量值系统性偏大。间隔80微秒到1毫秒都行,实测差别不大。
3.3 循迹与避障的决策逻辑:先画流程再写代码
写决策之前先把逻辑在纸上画清楚,不然代码写出来一堆if套娃,后面改一个阈值都不知道影响哪个分支。循迹模式的核心判断是四个组合,对应四种情况:左黑右白、左白右黑、双黑、双白。双黑说明两个传感器都压线,也就是车和线的偏差很小,直行;单边压线说明车偏了,差速转弯修回来;双白是最头疼的丢线状态。
循迹控制代码如下:
void Follow_Line(uint8_t L, uint8_t R, float dist) { if (dist > 0 && dist < 20) { // 循迹中前方突然出现障碍,临时左转避让 Set_Speed(80, 200); return; } if (L == BLACK && R == BLACK) { Set_Speed(150, 150); // 正常直线行驶 } else if (L == BLACK && R == WHITE) { Set_Speed(80, 180); // 线偏左,向左修正(右轮快) } else if (L == WHITE && R == BLACK) { Set_Speed(180, 80); // 线偏右,向右修正(左轮快) } else { Set_Speed(120, -120); // 双白丢线,原地旋转找线 } }再说一次方向逻辑:Set_Speed(左轮速度,右轮速度),速度范围 -255到255,正数前进负数后退。左轮慢右轮快,车的转弯方向是向左,所以左传感器压线(线在左边),用左慢右快的差速修正,这里的方向很容易搞反,我当时就是反了一次之后把所有注释都标好才记住。
避障模式采用三级决策,核心是优先级判断:前方安全距离大于30厘米,直线行驶;前方20到30厘米,减速警戒;前方小于20厘米,结合左右红外判断往哪边转。判断原则是选择有空间的一侧转弯,两边都有障碍就原地掉头:
void Avoid_Obstacle(float dist, uint8_t obsL, uint8_t obsR) { if (dist < 0) { Set_Speed(0, 0); return; } if (dist < 15) { if (obsL == 0 && obsR == 1) { Set_Speed(120, 220); // 右侧堵,左侧有空,左转 } else if (obsL == 1 && obsR == 0) { Set_Speed(220, 120); // 左侧堵,右侧有空,右转 } else if (obsL == 0 && obsR == 0) { Set_Speed(150, 200); // 两边都空,优先左转绕行 } else { Set_Speed(200, -200); // 两侧都堵,原地掉头 } } else if (dist < 30) { Set_Speed(100, 100); // 警戒距离,减速直行 } else { if (obsL == 0 && obsR == 1) { Set_Speed(180, 200); // 左侧障碍,轻微右偏 } else if (obsL == 1 && obsR == 0) { Set_Speed(200, 180); // 右侧障碍,轻微左偏 } else { Set_Speed(200, 200); // 安全,全速直行 } } }这里obsL和obsR指的是左右红外避障模块的检测结果,检测到障碍物定义为1,没检测到定义为0。具体电平取决于你的模块,有的模块障碍物输出低电平,串口一测就知道了。注意15厘米这个阈值和车身宽度相关,如果小车比较宽,要提前到20厘米,不然转向过程中车身侧边会蹭到障碍物。
主循环里所有逻辑每50毫秒执行一次,这个时间比超声波单次测量周期(大约20到30毫秒)稍长,既保证响应及时,又不会因为频繁切换方向让电机抖动。主循环代码结构如下:
while (1) { float dist = Read_Distance_Filtered(); uint8_t L = Read_Line_Left(); uint8_t R = Read_Line_Right(); uint8_t obsL = Read_IR_Left(); uint8_t obsR = Read_IR_Right(); if (mode == LINE_MODE) { Follow_Line(L, R, dist); } else { Avoid_Obstacle(dist, obsL, obsR); } // OLED刷新 + 串口调试数据 HAL_Delay(50); }3.4 模式切换和OLED调试:让状态看得见
两种模式之间通过一个按键切换,按键接PC14配置为外部中断,按下时翻转模式变量。如果不用外部中断,在主循环里做一个按键消抖轮询也可以,就是响应会慢一点点。代码如下:
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == KEY_PIN) { delay_us(20000); // 简单消抖,实际建议改用定时器计数消抖 while (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET); mode = !mode; OLED_Show_String(0, 0, mode ? "Avoid Mode" : "Line Mode"); } }调试信息输出我强烈建议加一块OLED屏幕。OLED0.96寸屏幕用软件模拟I2C驱动,只需要两根IO线,实时显示当前距离和传感器状态。调传感器位置、调电位器阈值的时候,不用每次插线连电脑看串口,低头看屏幕就够。串口还是要保留,作为第二通道打印详细变量,比如三个红外逐次读到的电平变化,方便分析传感器是不是时好时坏。
我的调试界面简单到不能再简单:第一行显示模式,第二行显示距离值和滤波前后的对比,第三行显示四个红外电平,第四行显示左右电机的PWM占空比。看到数据再调参数,效率比改一行编译一次高不知道多少。
4. 实测调参与常见问题排查
4.1 红外阈值标定:用串口打印代替瞎猜电位器
调红外传感器的阈值电位器是最容易烦的环节。一开始我都是凭感觉拧,拧几圈之后发现黑线和白地识别混乱,要么全黑要么全白。后来学乖了:先把传感器固定好,接好线,在串口里打印数字输出,然后拿一块黑色电工胶带贴在地面,传感器在黑白交界处来回移动,眼睛盯着串口输出,手动调电位器,直到边界翻转干净利落。
有一个好用的判断标准:白色地面输出一个稳定电平,黑色胶带输出另一个稳定电平,两者边界切换时没有抖动和毛刺。如果边界处电平跳来跳去出现几十毫秒的抖动,说明阈值太接近边界值了。往前调一点,让白地更稳定,别让黑线稍微脏一点就触发误判。
循迹传感器安装间距也有讲究。两个传感器之间的距离要略小于黑线的宽度,安装高度保持1到2厘米,太远反射光衰减厉害,太近容易被地面划伤。实际装车之后建议跑几次直线,观察小车是不是偏航,如果偏航就微调左右两个传感器的安装位置,把机械偏差消除掉。
4.2 常见故障速查表:复现最多的问题汇总
我把这个项目调试过程中遇到的问题整理成一个表格,后面做类似项目的朋友可以对照排查:
| 故障现象 | 可能原因 | 排查方法 |
|---|---|---|
| 超声波距离一直显示0或-1 | TRIG引脚没接对;ECHO电平超出容忍范围;模块损坏 | 先用万用表测TRIG有没有10us脉冲,再测ECHO有没有高电平 |
| 超声波距离值跳变超过5cm | 供电不稳;模块前方有遮挡物干扰;滤波没加 | 加中值滤波,检查电池电压,把模块和电机电源分离 |
| 小车直线跑偏 | 两个电机转速不一致;轮胎规格偏差 | 用手悬空测试两个轮空转速度,程序里给慢的一侧补偿 |
| 循迹时小车蛇形走 | 差速幅度太大;主循环周期太短 | 降低差速比例,适当增加HAL_Delay延时 |
| 循迹丢线找不回 | 双白丢线后旋转速度不对;线宽与传感器间距不匹配 | 调双白状态的旋转速度,检查传感器间距 |
| 按下避障键小车没反应 | 模式变量没切换;OLED显示更新失败 | 串口打印mode值,先排除程序问题 |
| 电机嗡嗡响但不转 | PWM频率太低;STBY没拉高;占空比太小 | PWM频率提到10kHz以上,检查STBY引脚 |
| 电池电量充足但单片机复位 | 电机启动瞬间拉低电源轨;共地不良 | 电机电源独立,降压模块输出并联大电容,加TVS |
| 红外传感器白天地面误判 | 环境红外光干扰;阈值太灵敏 | 调低阈值,给传感器加遮光罩,换用带调制频率的模块 |
第四个和第五个问题在循迹小车里出现频率最高。蛇形走和丢线,基本都是差速参数和安装间距不对。我的经验是先调机械,再调软件,最后调参数。顺序错了会越调越乱。
4.3 下一步优化:从开关量到连续量的融合
这个项目做完之后如果想继续进阶,有几个明确的方向。第一个方向是循迹从开关量升级到连续量。TCRT5000的AO口输出模拟电压,通过ADC读回,可以得到传感器距离黑线的偏移量,然后把这个偏移量作为比例项输入PWM控制,转向就会平滑很多,不再是一档一档的开关切换。
第二个方向是给超声波加舵机云台。用一个SG90舵机带动超声波模块左右转动,测量前方三个角度的距离,分别是左45度、正前方、右45度。避障决策从简单的左右二选一变成三方向最优选择,小车能绕开一些更复杂的障碍布局。
第三个方向是引入闭环速度控制。在电机后面加装霍尔编码器,读取实际转速,通过PID控制器让两个轮子的速度精确匹配。循迹和避障的性能都会上一个台阶,但这个改动会明显增加代码复杂度,建议先把开环逻辑吃透再上闭环。
第四个方向是状态机重构。把循迹中、避障中、脱困中、停止作为四个显式状态,通过事件触发状态迁移,而不是在函数里层层嵌套if。状态机的优点是每个状态内逻辑清晰,出问题好定位。适合在代码规模超过200行之后做。
从传感器融合的角度讲,还可以尝试加权融合。比如近距离数据以红外为主,超声波为辅;远距离反过来。两种传感器测同一方向时,按可信度加权平均。对于F103来说,这点计算量完全不是问题。
最后再分享一个习惯
这个项目真正做到最后,我最深的体会是传感器不是拿来就能用的,每个模块都有它的性格。HC-SR04怕盲区怕干扰,TCRT5000怕环境光怕安装位置偏,电机怕供电波动,每个坑都是单独跑才能发现。做硬件项目最忌讳的就是直接上完整逻辑,然后发现跑不起来不知道从哪查起。我后来的做法是先让每个传感器单独工作,串口打印原始值,确认数据和手册对上了,再开始写融合逻辑。这个过程多花了一个晚上,但之后几乎没遇到找不出原因的毛病。
做智能小车这个项目,最有价值的不是小车最后跑得有多顺,而是把“数据采集-数据处理-执行控制”这条链路的每一步都在真实物理环境里验证过一遍。这套思路放到任何单片机项目里都通用。