☰
STM32循迹小车与蓝牙遥控融合实战指南
2026/10/16 1:49:28 网站建设 项目流程

刚把循迹和蓝牙遥控在同一个底板上调通那会儿,说实话我是松了一口气的。前两篇我们把环境搭好、电机转起来了,蓝牙透传也跑通了,但那些都是单条线。真正让人头大的是让这两套东西在同一辆STM32小车里互不打架——按遥控器的时候循迹别出来抢方向盘,跑循迹的时候蓝牙消息又不能把控制逻辑搞乱。

这篇“(三)”重点就讲这个:怎么设计系统架构、怎么定通信协议、怎么处理循迹和遥控的模式切换,以及实测中真正踩过的坑。适合已经跑通过基础电机和蓝牙通信的玩家,也适合准备做课程设计或比赛作品的人拿来对照参考。

1. 整体方案:两条功能线怎么装进同一辆车

1.1 先想清楚:循迹和蓝牙控制共用的是什么

很多新手拿到这个题目就直接分模块写代码,循迹一套逻辑、蓝牙一套逻辑,最后拼在一起发现完全没法用。问题不在于代码量,而在于这两条功能线共享的东西太多了。

先说硬件层面。不管走循迹还是被遥控,最终执行的都是两个直流电机。电机驱动模块(我用的是常见的小型双路驱动板,也可以用成品模块)只有两路PWM和两路方向IO,你不可能让循迹逻辑和蓝牙逻辑同时去写这几根引脚。电源也是共用的,蓝牙模块、灰度传感器、单片机、电机驱动全部挂在同一块电池上,再加上电机启停的大电流冲击,电源噪声会直接影响灰度传感器的ADC读数。软件层面就更不用说了,PID计算周期、蓝牙接收中断、PWM输出更新,这些都在抢同一个CPU。

所以第一步不是写代码,而是画一张数据流图。我习惯把系统拆成三层:感知层(灰度传感器)、决策层(主控逻辑,决定了当前处于什么模式、该往哪走)、执行层(电机驱动)。蓝牙指令进来之后,它不是直接控制电机,而是先进入决策层,告诉决策层“我现在想切换模式”或者“我现在想往左转”。循迹传感器也是一样的,它的数据交给决策层处理,而不是自己直接去改PWM。这样设计之后,底层电机驱动只需要面对一个“我该输出什么”的接口,上层逻辑再花哨也不怕。

1.2 工作模式:从状态机开始设计

我用一个枚举变量管整个小车的状态,代码里叫car_mode。目前定义了四个模式:待机模式、手动遥控模式、循迹模式、参数整定模式。

待机模式就是上电后的初始状态,两侧轮子不动,蓝牙模块等待连接。手动遥控模式下,蓝牙字节流解析出来的指令直接决定两轮PWM目标值。循迹模式下,灰度传感器每10ms采一次样,算误差、跑PID,输出也是PWM目标值。参数整定模式比较特殊,是用蓝牙临时修改PID参数用的,这个后面细说。

状态机最关键的一点是切换条件的唯一性。手动模式切循迹模式,只能通过蓝牙指令完成;循迹模式切回手动,也必须有明确的指令。不能让“小车自己判断要不要从循迹切到手动”,那样逻辑就会乱掉。我用了一个很简单的做法:所有模式切换都走同一个mode_switch(new_mode)函数,在这个函数里统一处理“切换前该做的事”和“切换后该做的事”。

void mode_switch(uint8_t new_mode) { // 退出当前模式前的清理工作 if (car_mode == MODE_MANUAL) { set_motor_stop(); // 防止切换瞬间电机继续跑 } // 切换 car_mode = new_mode; // 进入新模式后的初始化 if (car_mode == MODE_LINE) { pid_reset(); // PID 积分/微分历史清零 line_err = 0; } }

这个函数里的pid_reset()很重要。如果从手动模式直接切进循迹模式,PID控制器还带着上次的误差历史,上车瞬间就可能给一个奇怪的转向值,小车直接歪掉。清零之后让它重新累积,会平稳很多。

1.3 系统硬件清单与连接规划

列一下我这辆车上的实际配置,不一定是最优解,但都是验证过能稳定跑的方案:

模块型号/规格说明
主控STM32F103C8T6性价比高,资料多,够用
电机驱动双路H桥驱动模块(TB6612类)比L298N轻,效率高,压降小
电机带编码器N20减速电机编码器这次没用,但后续扩展速度闭环有用
灰度传感器五路数字量输出模块单排一字布局
蓝牙模块HC-05主从一体蓝牙透传模块经典方案,手机连它很方便
电源7.4V 2S锂电池经降压模块给主控和传感器供电

连接上,SPI/I2C我这次都没用,全部走GPIO和串口。电机驱动PWM引脚接主控的TIM1或TIM2通道,灰度传感器五路接普通GPIO输入,蓝牙模块接USART1的TX/RX。

注意:电机驱动的地线一定要和单片机共地。很多人蓝牙连不上或者传感器读数飘,查半天发现是模块之间地电位不一致,信号根本就没法正确传输。

2. 循迹模块:从灰度传感器到动态转向

2.1 五路灰度传感器布局与阈值标定

灰度传感器的原理其实很简单,红外发射管发光,红外接收管接收反射回来的光强,黑色赛道吸收大部分红外光,白色地面反射很强,传感器输出的电压就会明显不同。市面上买的五路灰度模块普遍是数字量输出,模块上自带比较器,阈值通过电位器调。

布局上有讲究。我一开始把五个传感器等距排开,间距设成了1.5cm,后来发现跑起来反应太迟钝。原因在于小车的前轮距和赛道黑线宽度是有匹配关系的。一般比赛或课设的黑线宽度在1.5cm到2cm之间,如果传感器间距太小,过弯时中间三路全在黑线上,误差变化不线性,转向会一顿一顿的。我最后用的间距是2cm,这样小车在直道上只有中间一路压线,左右偏移时旁边两路能迅速给出反馈。

标定阈值是很多人的第一道坎。数字量传感器模块上有个电位器,顺时针拧是抬高比较阈值,逆时针是降低。我的标定方法是这样的:先把小车放到场地的白色区域,用万用表或者直接看板载LED,调整电位器直到当前输出为0(白纸不触发);然后手动把传感器挪到黑线上方,确认输出变为1。这块要多试几个位置,保证整条赛道上所有传感器都能可靠地分辨黑白。如果你用的是模拟量输出的版本,那就需要读ADC,记录白底和黑线上的稳定值,把阈值设在两者中间附近。

2.2 误差计算与PD控制参数整定

五路传感器从左到右我标记为S1到S5,把它们映射成一组位置权重。这里用加权平均的思想:S1对应-2,S2对应-1,S3对应0,S4对应1,S5对应2。当前误差line_err等于所有触发的传感器对应权重的加权和除以触发数量。举个例子,小车稍微偏左,触发了S1和S2,误差就是(-2-1)/2 = -1.5,一个负值,说明车身相对黑线往左偏了,此时需要往右打方向。

PID这里我最终用的是PD控制器,没用积分项。原因是赛道循迹是一个动态跟随过程,积分项很容易饱和,尤其在连续大弯上会产生严重的超调。而且灰度传感器本身有噪声,积分把历史误差累起来反而会放大抖动。代码里我直接用增量式PD,输出的是一个转向修正量:

int16_t line_pd_control(int16_t err) { static int16_t last_err = 0; int16_t output; output = KP_LINE * err + KD_LINE * (err - last_err); last_err = err; return output; }

调参顺序很重要。先把KD设为0,KP从0.1开始往上加。放到直道上观察,如果小车左右摆动幅度越来越大,说明KP偏大;如果出弯后回正很慢,则KP偏小。等KP调好之后再加KD,KD的作用是在误差变化大的时候提前减速转向变化,抑制过冲。我的车最终KP在0.35左右,KD在0.5左右,这个数值和你车的轴距、轮距、电机响应都有关系,只能做参考,不能照抄。

转向输出最终映射为左右轮的差速。比如输出值为正,说明需要右转(传感器告诉我们偏左),那就左轮PWM加到基础值、右轮PWM降到基础值的某个比例上。要注意的是,转向幅度必须限幅,两个轮子的PWM差值不能超过一个安全范围,否则过弯速度一快,外侧轮的PWM瞬间拉满,内侧轮甚至反转,会导致小车原地打转。

2.3 弯道和十字路口:策略比PID更重要

PID参数再好,遇到十字路口、急转弯也一样会翻车,这时候策略的重要性就出来了。

十字路口是最常见的翻车点。五路传感器全部压线时,如果不做特殊处理,PID算出来的误差是0,控制器认为自己在直道上,小车会直直冲过去。如果你的赛道设计里十字路口必须左转或右转的话,靠PID本身是解决不了的。我的处理方案是加一个路口状态判断:当五路传感器全部触发并持续超过50ms,判定为进入十字路口。此时如果下一段赛道需要转弯,直接强制让小车按预定方向转,并在规定时间内停止这个强制转向,把小车的控制权交还给PID。

急弯的处理则是另一个思路。小车在入弯前会先出现“线从视野边缘消失”的情况,比如左急弯时,最左侧的传感器先压线,然后S1、S2同时触发,继续转弯后S3也开始触发。观察误差导数可以估算出弯道曲率。如果误差在短时间内急剧变大,说明是急弯,这时候需要主动减速,而不是继续保持基础速度。我是在控制器里加了一个动态速度因子,误差绝对值大于1.5时,基础PWM往下修正20%左右,这样入弯速度降下来,出弯之后再恢复。

3. 蓝牙控制链路:Android端到STM32到底传了什么

3.1 协议帧设计:不是随便发字符串

前两篇很多人做蓝牙控制,就是手机端发个字符“1”代表前进,“2”代表后退,单片机上用switch-case处理。这种写法在demo阶段没问题,但到了要正经控制的时候,你会发现字符串解析不可靠:发一个“10”,里面既有“1”又有“0”,万一App端逻辑写得不严谨,小车就会同时执行前进和后退。

我这次定了统一的帧格式:每一帧固定5个字节,AA 55 CMD DATA CHECK。开头两个字节AA 55是帧头,用于接收端识别一帧数据的开始。第三个字节CMD是命令字。第四个字节DATA是数据域,放数值或参数。最后一个字节CHECK是校验字节,取值是前四个字节的累加和。

拿控制指令举例,CMD=0x01表示方向控制,DATA定义为0x00停止、0x01前进、0x02后退、0x03左转、0x04右转。CMD=0x02表示速度设置,DATA直接填目标占空比百分比,比如50就是50%的PWM占空比。CMD=0x03表示模式切换,DATA填要切换到的模式编号。CMD=0x10是PID参数整定指令,DOG=0x10,DATA前两个字节是参数类型(1表示Kp,2表示Ki,3表示Kd),后面再跟两个字节的参数值,这种情况帧长度会变成6字节,但帧头、命令字、校验的整体思路不变。

校验字节千万别省。蓝牙传输偶尔会丢字节或出错,没有校验的话,一个CRC错的数据可能让小车误判成“左转最大幅度”,现场直接表演漂移,很吓人。

3.2 Android端App的数据发送设计

Android端我用的是标准的BluetoothSocket连接,UUID用的是Android蓝牙串口服务通用的那个(00001101-0000-1000-8000-00805F9B34FB),HC-05就是模拟串口,所以这个UUID通用性很高。

App界面不需要复杂,一个连接按钮、四个方向按钮加一个停止按钮就够用。方向按钮用setOnTouchListener而不是setOnClickListener,按下时连续发送方向指令,松开时自动发送停止指令。这里有一个非常重要的细节:发送频率要控制。我之前直接在触摸事件里发数据,系统回调频率不稳定,有时候一秒钟发了四五十条指令,蓝牙模块缓冲区直接满掉,后面几条数据被丢弃或错位。

后来我在发送线程里加了一个限流逻辑:每50ms最多发送一帧数据,如果触摸事件产生的指令时间间隔小于50ms就不予发送,只记录最新状态。这样既保证了控制连续性,又不会把串口打爆。

private var lastSendTime = 0L private val sendInterval = 50L fun sendCommand(cmd: ByteArray) { val now = System.currentTimeMillis() if (now - lastSendTime < sendInterval) return lastSendTime = now try { bluetoothSocket?.outputStream?.write(cmd) bluetoothSocket?.outputStream?.flush() } catch (e: IOException) { // 连接断开处理 } }

3.3 STM32端接收解析与PWM映射

STM32端接收蓝牙数据用的是串口中断加环形缓冲区。很多新手喜欢在中断回调里直接处理业务逻辑,这是噩梦的开始。串口中断应该只做一件事:把收到的字节扔进环形缓冲区,然后尽快退出。主循环里再定期从环形缓冲区取数据,做帧解析。

帧解析用状态机,从左到右扫描字节流,遇到0xAA先不急着确认,要确认下一个字节是0x55才算真正的帧头。一套简化的解析流程如下:

void parse_byte(uint8_t byte) { static uint8_t state = 0; static uint8_t buf[6]; static uint8_t idx = 0; static uint8_t sum = 0; switch (state) { case 0: if (byte == 0xAA) { buf[0] = 0xAA; sum = 0xAA; idx = 1; state = 1; } break; case 1: if (byte == 0x55) { buf[1] = 0x55; sum += 0x55; idx = 2; state = 2; } else state = 0; // 帧头不匹配,重新等 break; case 2: buf[2] = byte; sum += byte; idx = 3; // 根据 buf[2] 的 CMD 判断后续长度 state = (CMD_IS_EXTERN(buf[2])) ? 3 : 4; break; // ... 后续 DATA 和 CHECK 的处理 } }

解析出完整一帧之后,先校验和,校验通过再执行对应的命令操作。执行操作又走回上一节说的模式切换函数,手动模式下直接把DATA里的方向值映射到两个轮子的PWM目标值上。方向到PWM的映射我加了一个转向斜坡函数:目标值不是瞬间拉满,而是以每50ms增加5%的斜率渐变过去。这样遥控时小车不会突然窜出去,手感也柔和很多。

4. 模式切换与循迹遥控融合的工程细节

4.1 模式切换状态机实现

状态机前面提了一下,这里展开讲具体实现。我定义了一个主循环任务,10ms跑一次。每次先读蓝牙帧解析结果,看是否有新的模式切换请求;如果没有请求,就按当前模式执行对应的控制逻辑。

模式切换时有个容易被忽略的坑:切换瞬间的PWM突变。举个例子,循迹模式下因为正以80%的PWM左转,这帧蓝牙命令说“切手动”,如果切换函数只是改了模式标志位,电机仍然保持80%的左转输出,手动模式下第一次发停止指令前,小车会继续歪着跑一小段,反应快的还好,反应慢的直接撞墙。所以mode_switch里必须处理“退出清理”,先把两路PWM的目标值归零,再改变模式。这样即使手机端还没来得及发新指令,小车也是安全停住的。

另外,无论什么模式下收到停止指令(0x00),都应该直接停车并清空所有历史状态。这是安全的底线。我在代码里单独处理了这个例外,因为它不依赖模式,任何时候都应当有效。

4.2 手动模式下的循迹软辅助

这个功能是我在玩的时候自己加上的。手动遥控模式下,如果你把车推到黑线上,或者遥控方向没掌握好让车压线了,其实可以做一个很轻的辅助修正,让小车不那么容易跑出赛道。

实现方式不复杂:手动模式下也持续读取灰度传感器,但不改变遥控指令的优先权。只有当用户没有发方向指令(比如一直发“停止”或者松手了)并且传感器检测到偏差时,才把循迹PD输出以20%的权重叠加到当前PWM上。这种“软辅助”不会和用户意图打架,因为用户一旦按下方向键,遥控指令的权重就覆盖掉辅助PD。

这个功能实际用起来很有意思。它相当于给小车的遥控驾驶加了一个“电子围栏”,手残玩家也能稳稳地把车开在赛道里,比赛的时候观感提升巨大。但这个功能也有代价,就是代码里多了一个模式状态的判断分支,逻辑复杂度上来了。如果想快速完成课设,可以不做这个功能,先把基础模式切换跑通。

4.3 用蓝牙远程调PID:调试方式的升级

说到选型,我觉得你必须知道为什么不能直接接管。很多人拿到循迹小车,第一反应就是“我直接读五路传感器,然后写死几个if判断,不就行了?”确实可以,但那种实现方式是在“硬编规则”,它只能应对你写if时想到的场景。而PID控制是连续输出,直道、缓弯、急弯、S弯都能平滑过渡,if判断则是一段一段的,切换感很明显,跑快一点就甩出去了。实测下来,PID方案在小车跑60%以上基础速度时依然可控,if方案往往在快一点的时候就开始左右横跳。

到这一步,PID的三个参数是通过蓝牙实时写的,不用重新烧录程序,效率非常高。调试的时候把小车架空拿在手里,手机蓝牙连上,发一组参数进去,观察电机响应,不行就再发一组,几轮就能找到一个比较合适的点。这也是为什么我把参数整定单独设计成一个模式,它在工程上太实用了。

5. 实测记录与常见问题排查手册

5.1 循迹跑飞的经典原因

第一常见的是阈值没标定好。传感器模块的电位器稍微偏一点点,白底误触发或者黑线上不触发,小车跑起来就是一路歪。尤其是光线变化的时候,下午三点的阳光和晚上开灯的环境,反射强度完全不一样。解决了这个问题,你才会发现原来小车能跑得这么直。

第二常见的是PID参数过大导致震荡。现象是直道上来回摆,幅度越来越大,最后冲出去。这种问题非常好判断:把KP和KD都调小再试。我给新手一个经验值范围:基础速度控制在40%PWM左右,KP从0.2到0.5之间试,KD从0.3到0.8之间试,大部分小车都能收敛。

第三是物理层面的问题,轮胎打滑。我原来用的轮子是普通的硬塑料轮,跑得快一点过弯时抓地力不够,传感器明明已经检测到偏了,转向指令也输出了,但车身因为惯性还是往外滑,看起来就像“失控”。换了软硅胶轮之后改善非常明显。这块容易被忽略,但它的重要性不比算法低。

5.2 蓝牙偶发失效的处理

蓝牙连接不稳定的案例我见过太多了。大部分人第一反应是换模块或者换手机,其实问题经常出在供电和地线上。

HC-05模块正常工作需要3.3V供电,有些人直接拿单片机的3.3V引脚给它供电。单片机的3.3V一般是LDO芯片从5V或电池电压转出来的,本身电流余量不大,电机一转,电压被拉低,蓝牙就会断连或者重启。正确的做法是给蓝牙单独用一个3.3V稳压模块,或者确保你的3.3V供电能力足够强。我实测中把小车的电机驱动电源、传感器电源、蓝牙电源分开走线之后,蓝牙连接稳定了很多。

还有一个低级但常见的坑:蓝牙模块的TXD接单片机的RXD,RXD接单片机的TXD,这种交叉连接很多人会接反。焊上去之后发现收不到数据,第一反应是代码问题,查半天才发现连线反了。排查的时候先量一下蓝牙模块有没有正常输出,再用串口调试助手在电脑上单独测模块,等模块本身确认没问题了再往单片机这边查。

5.3 电机响应不一致的排查

有时候小车在一侧打方向盘之后回不正,或者直道上跑着跑着自动偏向一边,这很可能是两个电机特性不一致造成的。即使标称同型号的N20电机,空载转速和扭矩也会有差异,再加上左右轮子的摩擦阻力不同、安装偏心不同,必然导致同样的PWM占空比下实际速度不一样。

我这次没有用编码器做速度闭环(硬件上有,软件没写),所以是通过软件校准来补偿的。具体做法是:在手动模式下发送一个固定的速度指令,目测或用量具测量左右轮转速,然后给慢的那个轮子的PWM目标值乘一个补偿系数。比如测量发现左轮慢5%,就把所有发送到左轮的PWM值乘以1.05。这个系数简单粗暴但有效,能解决大部分“车总是往一个方向偏”的问题。

如果你做了速度闭环,那就完全不需要这个补偿系数了。编码器反馈会实时修正每个轮子的速度,这也是后续升级的方向。

5.4 问题故障速查表

现象可能原因处理办法
循迹直道上来回摆,振幅越来越大PID参数B过倍调小KP和KD,先减KD
五路传感器全压线但车不转弯阈值误判为直道检查阈值标定,确认五路都能可靠触发
蓝牙连不上供电不足或连线接反独立供电,核对TX/RX交叉连接
蓝牙连上但收不到数据波特率不匹配STM32和蓝牙模块波特率设为一致(常见9600或115200)
手动模式下遥控转向过度转向映射限幅过小增大差速比例,或减小PWM变化斜率
小车从循迹切手动瞬间乱跑切换前PWM状态未清理在模式切换函数中先停电机
过弯打滑冲出去轮胎抓地力不足换软质轮胎,或降低急弯入弯速度

6. 代码结构参考与复现建议

把整套代码的目录结构梳理一下,方便你们照着搭项目。我用的是STM32标准外设库,工程结构如下:

User/ main.c // 主循环,10ms任务调度 car_mode.c/h // 状态机、模式切换 line_follow.c/h // 灰度采样、误差计算、PD控制 bluetooth.c/h // 串口中断、环形缓冲、帧解析 motor.c/h // PWM初始化、差速输出、斜坡函数 pid_tune.c/h // 蓝牙远程调参支持

main.c里的主循环是核心调度器,所有模块都是被调度的角色,不搞中断里处理业务那一套。任务调度的周期我在前面说过是10ms,这对于循迹控制和蓝牙遥控来说足够及时了。如果以后要做高速小车,可能需要把控制频率提到5ms,但那就需要仔细评估每个模块的执行时间。

代码的可移植性还不错,如果你之前用的是HAL库,把外设初始化部分换成HAL的写法,业务逻辑部分基本可以直接搬。灰度传感器用的是GPIO库函数读取,蓝牙是串口中断,这些在各个型号的STM32上差异不大。

Android端我是在Android Studio里写的原生蓝牙App,代码结构上分了三块:蓝牙连接管理、指令发送线程、UI事件绑定。蓝牙连接管理负责扫描设备、配对、建立Socket;指令发送线程就是我前面提到的限流控制逻辑;UI事件绑定把按钮的触摸事件和发送函数关联起来。整体代码量不大,核心逻辑集中在指令发送的限流和帧构造上。

7. 实操心得与一个小建议

这个项目做到第三篇,我最大的体会是:单片机和Android之间真正费时间的不是代码,而是通信协议的设计。只要协议定得清晰,帧头、命令字、数据、校验每一位都有明确含义,排查问题就会快很多。反过来,如果协议糊里糊涂,你在现场会同时面临“不知道是单片机错了还是手机错了”的死局。

最后分享一个我一直在用的小技巧:在代码里保留一个调试用的串口输出通道,把蓝牙收到的每一帧原始字节都打印出来,拿串口调试助手在电脑上记录。每次小车出现奇怪的“自主行动”时,翻记录看手机到单片机之间到底传了什么,基本能第一时间定位问题。这个习惯让我的排查速度至少快了一倍。调试蓝牙和循迹融在一起的真车现场,手忙脚乱是常态,有一个能追溯数据的手段,会让你稳很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询