☰
STM32蓝牙循迹小车融合方案:模式切换、串口协议与联调排坑
2026/10/16 9:22:33 网站建设 项目流程

做这个系列到第三篇,硬件层面的东西我默认你已经搭完了——底盘、电机、STM32最小系统、驱动板的接线,这些在前两篇里都有。这篇我们要面对的是最麻烦的一环:当循迹小车装上了蓝牙模块,手机要能控制方向、改速度、按倒车,车底的红外传感器还要在自动循迹时不断修正跑偏。这不是把遥控代码和循迹代码简单拼在一起,而是同一颗MCU内部,两组控制源的优先级、数据链路、切换逻辑全缠在一起。

我先把这篇解决的问题说清楚:Android端怎么把按钮变成一串可靠的数据帧发出去;STM32端怎么从串口噪声里准确解出命令;循迹和遥控两种模式怎么安全切换;最后是联调时我实打实撞过的几个坑和完整的排查过程。适合的读者是那些已经把小车跑起来、但还没有把蓝牙和循迹融合的人,以及准备做手机控制功能的同学。内容基于我常用的方案,具体型号你可以在自己板子上对应调整。

1. 遥控与循迹的共存方案:先分清谁说了算

1.1 系统角色的分工表

先把整条链路上每个环节的位置摆清楚,后面写代码才不会头晕。

环节典型器件职责关键参数
手机AppAndroid手机人机交互、蓝牙连接、指令编码SPP协议、运行时权限
蓝牙模块HC-05/HC-06无线透传,手机与MCU之间只相当于一根无线串口线波特率9600或115200
主控STM32F103C8T6串口解析、模式切换、PWM输出USART2接蓝牙,USART1调试
驱动板TB6612两路电机独立调速与换向PWM频率10kHz左右
循迹传感器3路或5路红外模块识别黑白线,输出数字信号GPIO输入,需校准阈值

这个分工里面,最容易被人忽视的是蓝牙模块的角色。很多新手把它当成一个"无线网络设备",实际上HC-05面对STM32就是一个标准的UART外设,它只负责把手机发来的字节原样吐到串口线上,也把STM32发出的字节原样传回手机。理解这一点,你就明白后面所有调试为什么都要从串口信号入手。

1.2 蓝牙模块对MCU来说只是一根"无线串口线"

因为蓝牙模块本质上是串口透传,所以在STM32里,处理手机指令的代码和处理一个有线串口数据的代码几乎一样。你需要做的只是把蓝牙模块的TXD接到STM32的某个USART的RX引脚,RXD接到对应TX引脚,供电3.3V或者5V取决于模块规格,然后保证两边共地。

我建议把USART1预留给调试打印,USART2接蓝牙。这个习惯非常关键:联调阶段你需要同时看调试信息,如果只有一个串口,那调试和蓝牙通信就会互相干扰,你会被逼疯。波特率我在这套系统里喜欢用9600,虽然115200速度快,但9600在HC-05的默认配置上更稳,对小车这种低频控制指令完全够用,而且抗干扰能力更好一些。

我见过不少朋友把波特率设成115200,然后手机端也设置115200,结果实际通信时偶发乱码,最后查半天发现是模块原来的固件默认波特率跟AT指令配置没对上。用9600少折腾,真觉得慢了再往上提。

1.3 模式与优先级:急停高于一切

在同一个小车里,遥控和循迹共存,首先要定义清楚"谁的命令更有效"。我用的方案是定义三种模式:空闲、遥控、循迹。

  • 空闲模式:上电默认状态,电机不转,避免小车一上电就冲出去。
  • 遥控模式:方向完全由手机指令决定,PWM输出直接映射按键。
  • 循迹模式:方向由红外传感器自动决定,但速度依然可以由手机下发——你只管油门,方向盘交给传感器。

模式切换也不能随心所欲。我设计了一个原则:急停指令优先级最高,任何时候收到停止帧都立刻切断PWM输出;其次是遥控/循迹模式切换指令;最后才是普通的方向和速度指令。这个逻辑用代码实现时就是几个if的顺序问题,但想清楚之后,能避免很多"小车突然不听话"的情况。

2. Android端蓝牙通信闭环:权限、连接、指令帧与断线兜底

2.1 为什么经典蓝牙SPP仍是这类小车的主流选项

做手机控制小车,首先要选通信协议。现在市场上很多教程推BLE低功耗蓝牙,功能确实强,但对你这种小项目是负担:BLE要处理GATT服务、特征值、通知回调,Android端的API抽象层次明显更绕。而经典蓝牙的SPP协议(Serial Port Profile)几乎就是为"无线串口"设计的,你的手机连上HC-05后,就能像读写文件一样往串口发字节。

对应到Android代码,最核心的是一个UUID:00001101-0000-1000-8000-00805F9B34FB。这是SPP服务的标准UUID,几乎所有HC系列模块都认它。你不需要理解UUID背后的原理,只需要知道用这个值去创建Socket,才能跟模块握手成功。用错UUID会导致连接无响应或者直接异常。

顺带说一句,Android的蓝牙接口在不同版本上有不少权限坑。Android 6之后需要动态申请定位权限,虽然这看起来跟蓝牙没关系,但系统把蓝牙扫描归类到了位置信息里。Android 12之后的版本还要额外申请BLUETOOTH_CONNECT和BLUETOOTH_SCAN权限。建议你写一个权限检查工具,不要等到运行时才崩溃。

2.2 权限配置与Socket连接线程

连接蓝牙最典型的错误是在UI线程里直接调用connect()。这个方法会阻塞直到连接成功或超时,一旦设备距离稍远,UI线程就会被卡死,界面直接无响应。

正确做法是开一个独立线程执行连接,同时用BluetoothSocket的connect()阻塞特性判断结果。核心流程大概是:

private static final UUID SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); private class ConnectThread extends Thread { private final BluetoothSocket socket; public ConnectThread(BluetoothDevice device) { BluetoothSocket tmp = null; try { tmp = device.createRfcommSocketToServiceRecord(SPP_UUID); } catch (IOException e) { // 创建Socket失败,这里一般是对端设备类型不支持SPP } socket = tmp; } @Override public void run() { try { socket.connect(); // 连接成功后启动读取线程 } catch (IOException connectException) { try { socket.close(); } catch (IOException closeException) { } } } }

连接成功之后,读数据也是阻塞的,必须再开一条单独线程循环调用inputStream.read()。我试过把读取和发送放在同一个线程,结果发送一条指令后读取就卡住,整个通信链路堵死。我的建议是读线程一直循环,收到任何字节都解析,而发指令则从UI线程调一个synchronized的写方法,两边互不干扰。

2.3 按键→指令帧→发送:把UI动作变成串口字节

协议设计是整个通信能否稳定的关键。我见过有人直接发ASCII字符串,比如按下前进就发"go",松开发"stop"。这样在丢掉一个字节的情况下,接收端可能把"go"当成"g",逻辑就乱了。

我的方案是用一个定长二进制帧:帧头2字节,命令1字节,速度1字节,校验1字节。具体格式是AA 55 CMD SPEED CHECK,校验位简单用前4个字节的异或值。这样即使某个字节受干扰,接收端也能通过帧头校验和丢弃坏帧,不会误动作。

private void sendFrame(byte cmd, byte speed) { byte[] frame = new byte[5]; frame[0] = (byte) 0xAA; frame[1] = (byte) 0x55; frame[2] = cmd; frame[3] = speed; frame[4] = (byte) (0xAA ^ 0x55 ^ cmd ^ speed); if (outStream != null) { try { outStream.write(frame); } catch (IOException e) { // 此处通常表示蓝牙已断开,需回调UI提示 } } }

配合UI,我通常用两个滚动条加四个方向按钮。方向按钮按下时连续发送方向指令,间隔大约50ms;松开的瞬间必须发送停止指令。这个"按下连发、松开刹车"的交互模型,比单纯发一次指令可靠得多,因为蓝牙偶发丢帧不会导致小车停不下来。速度滚动条变化时只发一条带速度的指令,不用连续刷。

2.4 断开判断与自动重连

蓝牙断开非常隐蔽,尤其是手机离开范围后,outputStream.write()不会立刻报错,可能要到缓冲区满才抛异常。我建议在读取线程中捕捉IOException作为断开信号,因为对端掉线后,读操作会先感知到。

捕捉到断开信号后,统一回调到主界面更新连接状态,并把outStream置空,防止UI还往坏管道里写数据。如果你想让体验好一点,可以记录之前配对过的设备MAC地址,在下一次启动时自动尝试连接。这套自动重连逻辑我在项目里加了之后,省了不少重新配对的时间。

3. STM32串口解析与执行:环形缓冲、状态机、超时刹车

3.1 串口资源划分:调试口与控制口分开

STM32端的第一步仍然是把USART1和USART2分配清楚。我的做法是:USART1接调试串口,波特率115200,用来打印printf日志;USART2接蓝牙模块,波特率9600,只处理控制指令。

void MX_USART2_UART_Init(void) { huart2.Instance = USART2; huart2.Init.BaudRate = 9600; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.Mode = UART_MODE_TX_RX; HAL_UART_Init(&huart2); }

调试口一定要保留下来。很多朋友图省事,把蓝牙模块接在唯一的串口上,结果后面想打印传感器状态只能望洋兴叹。我在做循迹调试时,需要实时输出每个红外传感器的GPIO电平、当前模式、左右PWM值,这些信息靠手机端看根本不直观,直接在串口助手上看才是最高效的。

3.2 环形缓冲区隔离中断与主循环

串口接收用中断方式,这一点不用讨论。但中断服务函数里只做一件事:把字节放进环形缓冲区。千万不要在中断里做解析、判断、PWM输出,因为串口中断频率高,任何多余的逻辑都会增大中断处理时间,轻则丢字节,重则影响PWM中断节拍。

环形缓冲区我用最简单的数组加读写索引实现,大概长这样:

#define RING_SIZE 128 static uint8_t ring_buf[RING_SIZE]; static volatile uint8_t rd_idx = 0; static volatile uint8_t wr_idx = 0; uint8_t ring_push(uint8_t data) { uint8_t next = (wr_idx + 1) % RING_SIZE; if (next == rd_idx) { return 0; // 缓冲区满,丢弃 } ring_buf[wr_idx] = data; wr_idx = next; return 1; } uint8_t ring_pop(uint8_t *data) { if (rd_idx == wr_idx) { return 0; // 空 } *data = ring_buf[rd_idx]; rd_idx = (rd_idx + 1) % RING_SIZE; return 1; }

接收中断里调用ring_push(接收到的字节),主循环里不断ring_pop出来做帧解析。这个结构隔离了中断和主循环,还能应付短时间内的高速数据涌入,128字节对于小车控制指令来说绰绰有余。注意写入索引和读取索引都要用volatile修饰,避免编译器优化导致数据错乱。

3.3 帧校验状态机的完整实现

有了环形缓冲区,主循环里的解析逻辑就可以用状态机实现了。我的帧格式是AA 55 CMD SPEED CHECK,解析时一旦发现帧头不匹配就丢弃,发现校验失败也丢弃,只有完全匹配才执行。

static uint8_t recv_buf[5]; static uint8_t recv_idx = 0; void process_incoming(void) { uint8_t b; while (ring_pop(&b)) { if (recv_idx == 0 && b != 0xAA) { continue; // 等待帧头 } if (recv_idx == 1 && b != 0x55) { if (b == 0xAA) { recv_idx = 1; // 可能是前一帧头后面紧跟新帧头 } else { recv_idx = 0; } continue; } recv_buf[recv_idx++] = b; if (recv_idx >= 5) { uint8_t check = recv_buf[0] ^ recv_buf[1] ^ recv_buf[2] ^ recv_buf[3]; if (check == recv_buf[4]) { execute_command(recv_buf[2], recv_buf[3]); } recv_idx = 0; } } }

这个实现有个细节值得注意:第二个帧头0x55之后,如果收到的字节又被认为是0xAA,意味着上一个帧可能还没完成,但新的帧头已经来了。这种情况下不能直接丢弃,否则连续发送时可能错失一帧。状态机的这个分支处理就是专门应对"串口数据流中帧与帧之间出现杂散字节"的情况。

校验算法虽然简单,但确实能挡住大部分偶发干扰。之前我用过完全不带校验的版本,结果小车偶尔会对着空气乱转一圈,后来发现是某个坏帧被当成方向指令执行了。加校验之后,这类问题基本绝迹。

3.4 指令执行策略与蓝牙超时刹车

解析到合法帧后,执行策略就是模式切换的核心了。我的execute_command函数大致逻辑是:

void execute_command(uint8_t cmd, uint8_t speed) { last_cmd_time = HAL_GetTick(); switch (cmd) { case CMD_STOP: set_motor_speed(0, 0); current_mode = MODE_IDLE; break; case CMD_FORWARD: if (current_mode == MODE_IDLE || current_mode == MODE_REMOTE) { current_mode = MODE_REMOTE; remote_speed = speed; target_dir = DIR_FORWARD; } break; // 左转、右转、倒车等类似 case CMD_FOLLOW: current_mode = MODE_FOLLOW; remote_speed = speed; break; default: break; } }

这里有个非常隐蔽的坑:循迹模式下,手机发来的左右转指令不应该被执行,否则自动转向会被手动转向覆盖。所以我在转向分支里加了模式判断,只有遥控模式才接受转向指令。但速度指令不一样,CMD_FOLLOW里却允许更新速度。这就是前面说的"方向盘交给传感器、油门交给手机"的设计落地。

超时刹车是我在这个项目里加上的保命功能。蓝牙断开、App闪退、手机死机,这些情况小车都不会主动收到停止指令,如果没有兜底,小车会按最后的指令一直跑下去。解决办法是记录最后一次收到合法帧的时间,在主循环里判断:超过300ms没有新帧,就自动降速并停止。

void safety_check(void) { if (current_mode != MODE_IDLE) { if (HAL_GetTick() - last_cmd_time > 300) { set_motor_speed(0, 0); current_mode = MODE_IDLE; } } }

这个300ms的阈值是我实测出来的。由于手机按键连发间隔约50ms,正常通信时每帧间隔远小于300ms;而一旦链路断了,300ms内车就会停住,融合到循迹模式也同样生效。实际用下来,这个小逻辑保护了小车好几次,避免了失控撞墙。

4. 循迹传感器与转向控制:让小车自己走线不走飞

4.1 传感器安装位置与黑白线电平确认

传感器安装是循迹最容易出问题的地方,位置不对,后面算法写得再漂亮也白搭。我用的五路红外模块,传感器之间的距离建议在1.5厘米左右,安装后要保证传感器能覆盖到黑线的中心、左侧边缘、右侧边缘。安装高度大约0.5到1厘米,这个距离对反射率最敏感。

每个模块上都有一个可调电位器,用来设置检测阈值。我的调试方法是:把小车放在白色地面,用万用表测传感器输出;再把它推到黑线上,测输出;调节电位器直到两种状态下输出的电平明确翻转。不要靠肉眼观察指示灯来判断阈值粗细,因为模块的一致性差异很大,五个传感器可能需要分别微调。

这里特别提醒:不同模块的输出逻辑可能完全相反。有的模块检测到黑线输出低电平,有的输出高电平。我最开始想当然按白底低电平写逻辑,结果小车出了起点就开始乱摆。正确的做法是在代码里定义一个标志位,比如BLACK_ON = 0或BLACK_ON = 1,然后统一转换,不要在每个分支里重复判断反向逻辑。

4.2 三路/五路传感器的状态映射表

循迹转向最简单的方案是查表。先把传感器状态组合映射成一个误差值,再用误差驱动差速转向。下面是五路传感器从二进制状态到误差的映射样例,这里假定检测到黑线为1:

传感器状态(左到右)误差含义
10000-4严重偏左
11000-3偏左较多
01000-2偏左
01100-1轻微偏左
001000居中
001101轻微偏右
000102偏右
000113偏右较多
000014严重偏右
001010两侧同时触发,当作路口或黑线较宽

这张表看起来简单,但它把传感器离散信号转换成了连续误差,是后面所有转向算法的基础。查表过程在你的主循环里执行,每次读取GPIO后组合成5位状态,然后查表得到误差。

还有两个状态需要专门处理:全0表示完全脱线;全1可能表示十字路口或者压到很粗的停止线。这两个状态必须单独定义行为,不能也当成普通误差计算。

4.3 差速转向与弯道降速

得到误差后,最直接的转向策略是线性映射:误差乘以一个系数,得到左右轮的速度差值。核心就是:

int diff = error * TURN_K; int left_speed = base_speed + diff; int right_speed = base_speed - diff;

注意这里的正负完全取决于你的传感器方向定义和电机接线方向,我的代码里假设误差为正时车偏右,需要加大左轮速度。实际调试时如果方向反了,把left_speed和right_speed交换就行,不要硬改系数。

弯道是循迹最容易翻车的地方。直道上你觉得误差小、速度可以拉满,但同样的速度进弯道,传感器可能直接冲出赛道。我的做法是加一个弯道识别逻辑:如果误差绝对值连续多帧都大于2,说明车正处在一个比较急的弯道,就把base_speed降到直道速度的60%左右。识别弯道不需要额外硬件,从传感器状态组合就能判断出来。

4.4 脱线与十字路口这些边界情况

边界情况处理得不好,循迹小车就只能跑固定赛道,换个场地就废。先说十字路口:五路传感器全部触发,真十字路口和粗黑线都无法区分。这里我的策略是当作直行处理——如果之前误差一直是0附近,说明车在直道上,遇到全1继续走就对了。但如果你需要在十字路口转弯,那就要配合更复杂的"路口计数"逻辑,那属于进阶玩法,这篇先不展开。

脱线则相反,必须保守处理。我设了一个脱线计数器:连续20ms所有传感器都没检测到黑线,就直接刹车并进入等待状态,不能继续根据上次误差盲开。因为盲开很可能越偏越远,等传感器重新看到黑线时,车已经跟赛道成很大夹角了。

最后是传感器信号抖动问题。红外传感器在黑白交界临界处会快速翻转,直接读GPIO很容易产生毛刺。我建议每2ms读一次,连续三次结果一致才算有效,这个消抖逻辑用简单计数就能实现,效果比加电容滤波更可控,也更容易调整响应速度。

5. 联调阶段的五个典型故障:排查链路与根因

5.1 故障A:手机显示已连接,小车一动不动

这个可能是最经典的联调问题。我遇到时的排查链路是这样的:

  1. 先确认蓝牙模块上的指示灯是否从慢闪变为快闪,快闪才表示与手机建立了连接。
  2. 再用USB转TTL直接接蓝牙模块的TXD和RXD,通过串口助手手动发送AA 55 01 60 3A这样的帧,看STM32调试串口有没有打印出CMD和SPEED。
  3. 如果串口助手发帧,STM32有反应,但手机发就无反应,问题出在Android端。
  4. 如果STM32完全没反应,检查USART2的TXD/RXD有没有接反,蓝牙模块的TXD要进STM32的RX,RXD要出STM32的TX。
  5. 最后用万用表量蓝牙模块的电源引脚,有些模块从STM32的3.3V引电,电流不够会导致模块工作异常。

排查到最后,我这个案例的根因是TXD和RXD交叉接反了,这大概是最常见的低级错误。交叉接线这件事,很多教程画图时都没强调,但实操中十个里能有一半栽在这上面。记住一个原则:发送端接接收端,即手机蓝牙模块的TXD接STM32的RX引脚。

5.2 故障B:电机抖动、转向抽搐、车原地乱转

这个现象通常不是逻辑错,而是硬件信号的问题。我的排查链路先是看PWM频率:如果PWM频率设置得太低,比如几十赫兹,电机会发出持续电流声中夹杂"咔咔"声,轮子一抖一抖。把定时器频率调到10kHz级别,抖动问题基本缓解。

然后是传感器供电问题。五路红外模块同时工作,电路瞬间电流需求不低。如果它们直接从一个IO口或者不稳的3.3V取电,电平可能跌落,导致传感器误判,表现为小车明明在直道上却频繁微调、左右摇摆。我在模块电源输入端并联了一个100uF的电解电容和0.1uF的陶瓷电容,传感器误触发明显减少。

还有一个因素是主控程序里传感器采样顺序问题。我最初在中断回调里读GPIO,中断源一变,读到的时间片就飘忽不定,采样结果不稳定。改成主循环定时轮询,2ms固定节拍读取后,转向明显平稳很多。

5.3 故障C:蓝牙能连上,但一发送就瞬间掉线

这个问题的根源我一开始完全没想到。手机端发送数据频率太高,又一次性写大量字节,蓝牙模块的无线缓冲区一下就被塞满,底层直接断开连接。我当时的发送逻辑是每次UI刷新都写一段数据,结果消息队列堆积,模块承受不住。

解决方案有两步:第一,所有发送指令都走同一个写管道,用一个简单的发送队列或者标志位保证同一时间只有一个线程在写;第二,连续发送的间隔控制在50ms以上。这两步加完,再没有出现过一发送就掉线的情况。

短时间掉一次线可能问题不大,但如果你的App用了多线程还在UI线程里直接调socket.getOutputStream(),那我建议你先统一线程模型,不然排查起来会非常痛苦。

5.4 故障D:循迹模式下速度无法被遥控改变

这个坑是我自己code review时发现的逻辑问题。最初我在循迹模式的状态机里只更新方向执行,速度字段一直没有被赋值,导致车进入循迹后,无论手机上怎么拖速度条,它都保持进入循迹前的那个速度。

排查链路倒是很简单:在调试串口打印execute_command收到的速度和模式状态。打印结果显示remote_speed确实更新了,但转向控制函数里读的却是另一个全局变量。这是因为我在两个源文件里各定义了一个同名变量,作用域不同,改了这个忘了那个。

教训很直接:全局变量命名要有明确前缀,比如模式相关的统一叫mode_开头,速度相关的统一叫speed_开头。别小看命名规范,这种问题在几百行代码里不明显,工程稍大就会埋雷。

5.5 故障E:松开手机按钮后,小车还在继续跑

这一类问题的原因非常集中:手机端在"松开"事件里没有发送停止指令。尤其是Android的触摸处理,如果你监听的是ACTION_DOWN和ACTION_UP,那必须在ACTION_UP里补发停止帧,而不是只在ACTION_MOVE里做判断。

我加这个功能时踩过一个更隐蔽的坑:方向按钮连续发送前进指令,同时速度滚动条在调节时又发送了速度指令,两路指令交错,松开按钮时最后一条是速度指令,停止指令反而被顶掉了。解决方案是发送指令统一走同一个事务,每帧都像完整的状态描述,而不是单独的增量事件。也就是说,每一帧都包含当前完整目标,松手时发送一个速度为零的前进指令,让接收端自然理解成"停车",比单独发STOP命令更不容易被中间帧覆盖。

在小车真正停下来之前,再确认一下outputStream.write()的异常处理已经正确关闭蓝牙Socket。来回调试几次就会发现,这个项目的核心难点并不在单个模块,而是整个链路里每个环节的信号完整性、协议一致性、状态边界条件是否都处理到位了。

我做完这版融合系统最大的体会是:先想清楚模式和优先级,再写代码;先做好协议校验,再联调硬件;先保证断线刹车,再谈控制算法。这三条原则,可以帮你少走很多弯路。

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

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

立即咨询