零知派——STM32智能小车系列教程(六):蓝牙/串口双通道通信协议设计
引言:智能小车要实现的功能很多——避障、跟随、贴边、灭火、直线行驶,每一个模块单独调试起来都有自己的难点。如果一开始就把所有模块塞进一个工程里联调,出了问题很难分清到底是传感器硬件的问题、电平极性的问题,还是上层控制算法的问题
所以这一系列教程的思路是:先把每个功能模块单独拎出来,配一个不依赖其他硬件的最小测试程序,把模块本身调通调透,再回头看它在完整项目里是怎么被使用的
目录
一、引言
前五篇教程分别讲了循迹、超声波云台、红外双目跟随、测速+PID闭环、DHT11与灭火,每一篇的核心都是某一个功能模块。这一篇不一样——它讲的是把前面所有模块串起来、对外提供操作入口的那一层:通信协议。
这台小车目前支持两条完全独立的通信通道同时工作:
- 一条是USB转串口(连电脑,通常配合XCOM或Arduino串口监视器调试)
- 另一条是HC-05/BT06蓝牙模块(连手机APP或者电脑虚拟串口)
| 特性 | 说明 |
|---|---|
| 双通道 | USB 串口(有线调试)+ 蓝牙(无线遥控),共用同一套命令解析 |
| 双模式 | 单字符命令(模式切换/方向控制)+ 多字符命令(参数设置/查询) |
| 实时性 | 非阻塞轮询,蓝牙超时自动刷帧 |
| 可扩展性 | 命令表驱动,新增命令无需改动核心解析逻辑 |
两条通道物理上互不干扰,但需要共享同一套命令解析逻辑——用户不管是在电脑上敲命令还是拿手机发指令,最终都应该执行完全一致的行为,"双通道、单一解析入口"的架构设计,
系列进度:
| 篇号 | 主题 | 核心硬件 |
|---|---|---|
| 第一篇(已完成) | 五路循迹模块 | TCRT5000 + LM393/LM339 |
| 第二篇(已完成) | HC-SR04超声波+SG90舵机云台 | HC-SR04 + SG90 |
| 第三篇(已完成) | 红外双目跟随模块 | 红外避障对管 |
| 第四篇(已完成) | 红外对射式测速+直线行驶PID闭环 | 槛型红外计数模块+码盘+PID |
| 第五篇(已完成) | DHT11温湿度、火焰传感器、风扇灭火 | DHT11+火焰传感器+风扇 |
| 第六篇(本文) | 蓝牙/串口双通道通信协议设计 | HC-05/BT06 |
| 终篇(待更新) | 整车项目架构总览 | 全模块整合 |
二、蓝牙串口模块硬件原理
蓝牙串口模块是一种基于蓝牙 SPP(Serial Port Profile,串口端口协议) 的无线通信模块。它的核心作用是“模拟有线串口”——把蓝牙无线通信转换成标准的 UART 串口通信,开发者不需要理解复杂的蓝牙协议栈,只需要像操作普通串口一样收发数据即可
2.1 SPP协议的本质
SPP 是蓝牙协议栈中的一个标准规范,它规定如何在两个蓝牙设备之间建立一个虚拟串行链路

这个链路模拟了传统 RS-232 串口的所有特性——数据以字节流形式传输、全双工通信、无需复杂的握手协议。对上层应用来说,蓝牙连接和一根 USB 串口线没有区别。
HC-05 与 HC-06 的区别:
本项目同时支持 HC-05 和 HC-06 两种蓝牙模块:
| 对比项 | HC-05 | HC-06 |
|---|---|---|
| 主从模式 | 主从一体,可动态切换 | 纯从机,只能被连接 |
| 角色切换 | 支持(AT+ROLE=1 切为主机) | 不支持 |
| AT 指令 | 完整指令集,可配置所有参数 | 精简指令集,仅支持基础配置 |
| 默认波特率 | 38400(AT 模式) | 9600 |
| 价格 | 较高 | 较低 |
| 适用场景 | 需要主动连接其他设备 | 手机→小车单向控制 |
如果只需要手机控制小车(手机作为主机主动连接),HC-06 完全够用且更便宜。如果需要小车之间组网通信或主动发起连接,选择 HC-05
2.2 两种工作模式
HC-05 模块具有两种工作模式:
① 命令响应工作模式(AT 模式) :
-
用于配置模块参数(修改名称、波特率、主从角色等)
-
进入方式:上电时将 KEY 引脚拉高
-
串口波特率通常为 38400(HC-05)或 9600(HC-06)
-
数据格式固定:8 位数据位、1 位停止位、无校验位、无流控制
② 自动连接工作模式(透传模式) :
-
正常通信模式,数据透明传输
-
模块可作为主机(Master)、从机(Slave)或回环(Loopback)角色
-
配对成功后,串口数据直接通过蓝牙发送
2.3 常用 AT 指令
HC-05 模块的所有功能通过 AT 指令集控制。以下是最常用的几条:
| AT 指令 | 功能 | 示例响应 |
|---|---|---|
AT | 测试指令 | OK |
AT+NAME=<name> | 设置模块名称 | OK |
AT+BAUD=<n> | 设置波特率(1=1200, 2=2400, 3=4800, 4=9600, 5=19200, 6=38400...) | OK |
AT+ROLE=<n> | 设置角色(0=从机, 1=主机) | OK |
AT+ADDR? | 查询模块地址 | +ADDR:xxxx:xx:xxxxxx |
AT+RESET | 复位模块 | OK |
发送 AT 指令时,每条指令必须以
\r\n
三、硬件接线说明
3.1 引脚定义
本项目蓝牙模块的引脚定义(与 pinsdefine.h 一致):
#define BTSerial Serial2 // 蓝牙模块使用硬件串口3
零知派迷你板的硬件串口分配:
| 串口 | 引脚 | 用途 |
|---|---|---|
| Serial | PA9(TX)/PA10(RX) | USB 串口(调试用) |
| Serial1 | PA2(TX)/PA3(RX) | 预留 |
| Serial2 | PB10(TX)/PB11(RX) | 蓝牙模块 |
3.2 接线方案表
| 模块 | 引脚 | 零知派迷你板引脚 | 说明 |
|---|---|---|---|
| HC-05/06 | VCC | 5V | 供电 |
| HC-05/06 | GND | GND | 共地 |
| HC-05/06 | TXD | PB11(RX3) | 模块发送→主控接收 |
| HC-05/06 | RXD | PB10(TX3) | 主控发送→模块接收 |
| HC-05 | KEY | 可选 GPIO | AT 模式使能(HC-06 无此引脚) |
模块的 TXD 接主控的 RX,模块的 RXD 接主控的 TX——这是串口通信的基本规则,接反了将无法通信
3.3 连接示意图

蓝牙模块初始化
在 setup() 中初始化蓝牙串口
Serial.begin(115200); // USB 串口,115200
delay(100);
BTSerial.begin(9600); // 蓝牙模块,9600(与手机蓝牙助手匹配)
delay(50);
波特率匹配:蓝牙模块的波特率必须与手机蓝牙助手 APP 中设置的波特率一致
四、通信协议设计
4.1 双通道架构
整车项目的通信系统采用双通道并行架构,给USB串口写一套parseUSB(),给蓝牙写一套parseBT(),两边各自处理各自的命令
class CommManager {
public:
void update(); // 每帧调用,非阻塞轮询两条物理通道
private:
char _usbBuf[48];
uint8_t _usbIdx = 0;
char _btBuf[48];
uint8_t _btIdx = 0;
unsigned long _btLastByte = 0;
static const uint8_t BT_FLUSH_MS = 30;
void parseLine(const char *raw, bool fromBT); // 统一入口
void parseCmd(char c, bool fromBT);
};
①双通道优势
| 通道 | 用途 | 特点 |
|---|---|---|
| USB 串口(Serial) | 固件调试、参数设置、日志输出 | 有线稳定,适合开发调试 |
| 蓝牙(Serial2) | 无线遥控、实时命令 | 无线便捷,适合成品使用 |
两个通道共用同一套命令解析逻辑,保证有线调试和无线遥控的行为完全一致
②结束行判定
手机蓝牙串口助手APP发送数据时不一定带换行符,如果只靠换行判断结束,用户在APP里点发送后命令会一直卡在缓冲区里等不到
\n
void CommManager::update()
{
// USB Serial(XCOM,换行结帧)
while (Serial.available()) {
char c = (char)Serial.read();
if (c == '\n' || c == '\r') {
if (_usbIdx > 0) { _usbBuf[_usbIdx] = '\0'; parseLine(_usbBuf, false); _usbIdx = 0; }
} else if (_usbIdx < sizeof(_usbBuf) - 1) {
_usbBuf[_usbIdx++] = c;
} else { _usbIdx = 0; }
}
// BTSerial(超时30ms自动刷帧,兼容不发换行的蓝牙助手)
while (BTSerial.available()) {
char c = (char)BTSerial.read();
_btLastByte = millis();
if (c == '\n' || c == '\r') {
if (_btIdx > 0) { _btBuf[_btIdx] = '\0'; parseLine(_btBuf, true); _btIdx = 0; }
} else if (_btIdx < sizeof(_btBuf) - 1) {
_btBuf[_btIdx++] = c;
} else { _btIdx = 0; }
}
if (_btIdx > 0 && (millis() - _btLastByte) >= BT_FLUSH_MS) {
_btBuf[_btIdx] = '\0'; parseLine(_btBuf, true); _btIdx = 0;
}
}
额外记录最后一次收到字节的时间_btLastByte,缓冲区里有数据、且超过30毫秒没有收到新字节,就认为这一帧已经发送完毕,主动触发解析
关键设计:
| 设计点 | 说明 |
|---|---|
| 非阻塞轮询 | 不会因为等待串口数据而阻塞主循环 |
| 行缓冲 | 分别维护 USB 和蓝牙的接收缓冲区 |
| 蓝牙超时刷帧 | 30ms 无新数据自动处理已收到的字符【sc_comms.h†L35】 |
| 双通道隔离 | USB 和蓝牙独立缓冲,互不干扰 |
4.2 单字符命令解析
void CommManager::parseCmd(char c, bool /*fromBT*/)
{
switch (c) {
// ── 模式切换 ──────────────────────────────────────────────
case 'A': setMode(MODE_MANUAL); return;
case 'B': setMode(MODE_AVOID); return;
// ... 其他模式
// ── 功能查询 ──────────────────────────────────────────────
case 'T': { // DHT11 温湿度
byte t = 0, h = 0;
g_env.getTempHumi(&t, &h);
return;
}
case 'I': { // IR 传感器状态
bool lIR = false, rIR = false;
g_ir.read(&lIR, &rIR);
Serial.print(F("[IR] L=")); Serial.print(lIR);
Serial.print(F(" R=")); Serial.println(rIR);
return;
}
case 'U': { // 启动自整定
if (g_mode != MODE_STRAIGHT) {
Serial.println(F("[AUTOTUNE] 请先切换到H(直线)模式"));
return;
}
startAutoTune();
return;
}
}
// ── 方向控制(手动/直线/灭火模式有效) ──────────────────────
if (g_mode == MODE_MANUAL || g_mode == MODE_STRAIGHT || g_mode == MODE_FIRE) {
switch (c) {
case '1': g_motor.goWithDirec(carForward); break;
case '2': g_motor.goWithDirec(carBack); break;
// ... 其他方向
case '0': case '9': g_motor.stop(); break;
}
}
}
单字符命令格式(即时响应)
| 命令 | 功能 | 说明 |
|---|---|---|
A | 切换到手动模式 | 用户遥控驾驶 |
B | 切换到避障模式 | 自动避障 |
C | 切换到循迹模式 | 自动循迹 |
D | 切换到魔术手模式 | 超声波手势跟随 |
E | 切换到贴边模式 | 沿墙行驶 |
F | 切换到灭火模式 | 手动驾驶 + 自动灭火 |
G | 切换到跟随模式 | 红外双目跟随 |
H | 切换到直线模式 | PID 闭环直线行驶 |
1~8 | 方向控制 | 前后左右及组合方向 |
0/9 | 停止 | 急停 |
T | 读取温湿度 | DHT11 数据 |
I | 读取 IR 状态 | 红外传感器调试 |
U | 启动自整定 | 仅在 H 模式下有效 |
HELP | 打印命令帮助 | 多字符命令 |
4.3 多字符命令解析
void CommManager::parseLine(const char *raw, bool fromBT)
{
char buf[48];
uint8_t len = 0;
for (const char *p = raw; *p && len < 47; p++) {
char ch = *p;
if (ch == ' ' || ch == '\t') continue;
buf[len++] = (ch >= 'a' && ch <= 'z') ? (ch - 32) : ch;
}
buf[len] = '\0';
if (len == 0) return;
if (len == 1) { parseCmd(buf[0], fromBT); return; }
if (strcmp(buf, "INFO") == 0) { printInfo(); return; }
if (strcmp(buf, "HELP") == 0) { printHelp(); return; }
if (startsWithCS(buf, "TKP=")) {
g_track.setTunings(floatAfter(buf, 4), g_track.Kd);
Serial.print(F("[SET] TKp=")); Serial.println(g_track.Kp);
BTSerial.print(F("[SET] TKp=")); BTSerial.println(g_track.Kp); return;
}
if (startsWithCS(buf, "TKD=")) {
g_track.setTunings(g_track.Kp, floatAfter(buf, 4));
Serial.print(F("[SET] TKd=")); Serial.println(g_track.Kd);
BTSerial.print(F("[SET] TKd=")); BTSerial.println(g_track.Kd); return;
}
if (startsWithCS(buf, "SPD=")) {
g_track.setBaseSpeed(intAfter(buf, 4));
Serial.print(F("[SET] TSPD=")); Serial.println(g_track.baseSpeed);
BTSerial.print(F("[SET] TSPD=")); BTSerial.println(g_track.baseSpeed); return;
}
if (startsWithCS(buf, "KP=")) { g_track.setTunings(floatAfter(buf, 3), g_track.Kd); return; }
if (startsWithCS(buf, "KD=")) { g_track.setTunings(g_track.Kp, floatAfter(buf, 3)); return; }
if (startsWithCS(buf, "WKP=")) {
g_wall.Kp = floatAfter(buf, 4);
g_wall.setTunings(g_wall.Kp, g_wall.Ki, g_wall.Kd);
Serial.print(F("[SET] WKp=")); Serial.println(g_wall.Kp);
BTSerial.print(F("[SET] WKp=")); BTSerial.println(g_wall.Kp); return;
}
if (startsWithCS(buf, "WKI=")) {
g_wall.Ki = floatAfter(buf, 4);
g_wall.setTunings(g_wall.Kp, g_wall.Ki, g_wall.Kd);
Serial.print(F("[SET] WKi=")); Serial.println(g_wall.Ki);
BTSerial.print(F("[SET] WKi=")); BTSerial.println(g_wall.Ki); return;
}
if (startsWithCS(buf, "WKD=")) {
g_wall.Kd = floatAfter(buf, 4);
g_wall.setTunings(g_wall.Kp, g_wall.Ki, g_wall.Kd);
Serial.print(F("[SET] WKd=")); Serial.println(g_wall.Kd);
BTSerial.print(F("[SET] WKd=")); BTSerial.println(g_wall.Kd); return;
}
if (startsWithCS(buf, "WSET=")) {
g_wall.setDistance(floatAfter(buf, 5));
Serial.print(F("[SET] WDist=")); Serial.println(g_wall.setpoint);
BTSerial.print(F("[SET] WDist=")); BTSerial.println(g_wall.setpoint); return;
}
if (strcmp(buf, "RESET") == 0) { resetStraightPID(); return; }
if (strcmp(buf, "LRESET") == 0) { setStraightPID(true, 0.5, 0.01, 0.0); return; }
if (strcmp(buf, "RRESET") == 0) { setStraightPID(false, 0.5, 0.01, 0.0); return; }
if (startsWithCS(buf, "LKP=")) { setStraightPID(true, floatAfter(buf, 4), s_lKi, s_lKd); return; }
if (startsWithCS(buf, "LKI=")) { setStraightPID(true, s_lKp, floatAfter(buf, 4), s_lKd); return; }
if (startsWithCS(buf, "LKD=")) { setStraightPID(true, s_lKp, s_lKi, floatAfter(buf, 4)); return; }
if (startsWithCS(buf, "RKP=")) { setStraightPID(false, floatAfter(buf, 4), s_rKi, s_rKd); return; }
if (startsWithCS(buf, "RKI=")) { setStraightPID(false, s_rKp, floatAfter(buf, 4), s_rKd); return; }
if (startsWithCS(buf, "RKD=")) { setStraightPID(false, s_rKp, s_rKi, floatAfter(buf, 4)); return; }
if (startsWithCS(buf, "SSPD=")) {
s_setpoint = floatAfter(buf, 5);
Serial.print(F("[SET] Setpoint=")); Serial.println(s_setpoint);
BTSerial.print(F("[SET] Setpoint=")); BTSerial.println(s_setpoint); return;
}
// 新增:码盘每圈脉冲数现场调整(原来写死为20)
if (startsWithCS(buf, "PPR=")) {
int ppr = intAfter(buf, 4);
setEncoderPPR(ppr);
Serial.print(F("[SET] PPR=")); Serial.println(ppr);
BTSerial.print(F("[SET] PPR=")); BTSerial.println(ppr); return;
}
if (startsWithCS(buf, "EA=")) {
setEncoderEmaAlpha(floatAfter(buf, 3));
BTSerial.print(F("[SET] EA=")); BTSerial.println(floatAfter(buf, 3));
return;
}
Serial.print(F("[WARN] 未知命令: ")); Serial.println(raw);
if (fromBT) { BTSerial.print(F("[WARN] Unknown: ")); BTSerial.println(raw); }
}
①字符串解析优化
// 全部基于 char*,不触碰堆,避免内存碎片
static bool startsWithCS(const char* s, const char* prefix) {
return strncmp(s, prefix, strlen(prefix)) == 0;
}
static float floatAfter(const char* s, uint8_t prefixLen) {
return atof(s + prefixLen);
}
static int intAfter(const char* s, uint8_t prefixLen) {
return atoi(s + prefixLen);
}
String 类在堆上动态分配内存,长时间运行会产生内存碎片。
buf本身是栈上的固定大小数组,strncmp和atof都是不涉及堆分配的标准库函数,全程不产生任何动态内存分配
②多字符命令格式(参数设置/查询)
| 命令格式 | 功能 | 示例 |
|---|---|---|
TKP=数值 | 设置循迹比例系数 | TKP=6.5 |
TKD=数值 | 设置循迹微分系数 | TKD=0.5 |
SPD=数值 | 设置循迹基础速度 | SPD=80 |
WKP=数值 | 设置贴边比例系数 | WKP=4.0 |
WKI=数值 | 设置贴边积分系数 | WKI=0.02 |
WKD=数值 | 设置贴边微分系数 | WKD=0.2 |
WSET=数值 | 设置贴边目标距离 | WSET=17 |
LKP=数值 | 设置左轮比例系数 | LKP=1.2 |
LKI=数值 | 设置左轮积分系数 | LKI=0.45 |
LKD=数值 | 设置左轮微分系数 | LKD=0.05 |
RKP=数值 | 设置右轮比例系数 | RKP=1.2 |
RKI=数值 | 设置右轮积分系数 | RKI=0.45 |
RKD=数值 | 设置右轮微分系数 | RKD=0.05 |
SSPD=数值 | 设置目标转速 | SSPD=120 |
PPR=数值 | 设置码盘每圈脉冲数 | PPR=20 |
EA=数值 | 设置滤波 EMA 系数 | EA=0.4 |
RESET | 恢复 PID 默认参数 | — |
LRESET | 恢复左轮默认参数 | — |
RRESET | 恢复右轮默认参数 | — |
INFO | 打印全部当前参数 | — |
HELP | 打印命令帮助 | — |
4.4 模式切换与状态管理
void CommManager::setMode(CarMode m)
{
if (g_mode == m) return;
// ── 离开直线模式时强制中止自整定 ────────────────────────────
if (g_mode == MODE_STRAIGHT && g_autoTuning) {
g_autoTuning = false;
Serial.println(F("[AUTOTUNE] 强制中止"));
}
// ── 离开舵机相关模式时舵机回正 ──────────────────────────────
if (g_mode == MODE_AVOID || g_mode == MODE_NEARWALL || g_mode == MODE_MAGIC) {
g_sonar.setAngle(SERVO_DEFAULT_POS);
}
g_motor.stop();
g_mode = m;
// ── 进入新模式时调用 onEnter() ──────────────────────────────
switch (m) {
case MODE_AVOID: g_avoid.onEnter(); break;
case MODE_TRACK: g_track.onEnter(); break;
// ...
}
}
状态管理的核心原则
| 原则 | 实现 |
|---|---|
| 离开时清理 | 停止电机、中止自整定、舵机回正 |
| 进入时初始化 | 调用对应模式的 onEnter() |
| 状态隔离 | 不同模式的状态变量互不干扰 |
五、独立测试程序
不依赖整车其他模块,单独验证双通道命令解析架构本身:
/**************************************************************************************
* 文件: DualChannel_Comm_Standalone_Test.ino
* 作者:零知实验室(深圳市在芯间科技有限公司)
* 功能:蓝牙/串口双通道命令解析架构 独立测试沙盒
* 复现主项目 sc_comms.cpp 的核心解析逻辑,验证USB和蓝牙两条通道
* 是否都能正确识别单字符命令和多字符命令(尤其是带=号的参数命令)
*
* 引脚:BTSerial = Serial2,与主项目 pinsdefine.h 保持一致
*
* 测试方法:
* 1. USB串口(Serial)用Arduino串口监视器测试,可以正常带换行发送
* 2. 蓝牙通道用手机APP或电脑虚拟串口测试,即使不带换行符也应该
* 在约30ms后被自动识别执行
* 3. 分别测试单字符命令(如 A)和多字符命令(如 INFO),两条通道
* 应该有完全一致的响应
**************************************************************************************/
#define BTSerial Serial2
char g_usbBuf[48];
uint8_t g_usbIdx = 0;
char g_btBuf[48];
uint8_t g_btIdx = 0;
unsigned long g_btLastByte = 0;
const uint8_t BT_FLUSH_MS = 30;
void parseCmd(char c, bool fromBT)
{
Serial.print(F("[CMD] 单字符命令: '"));
Serial.print(c);
Serial.print(F("' 来源: "));
Serial.println(fromBT ? F("蓝牙") : F("USB"));
switch (c) {
case 'A': case 'a': Serial.println(F("[ACTION] 切换到手动模式")); break;
case 'B': case 'b': Serial.println(F("[ACTION] 切换到避障模式")); break;
case '1': Serial.println(F("[ACTION] 前进")); break;
case '2': Serial.println(F("[ACTION] 后退")); break;
default: Serial.println(F("[WARN] 未识别的单字符命令")); break;
}
if (fromBT) BTSerial.println(F("[ACK] 命令已收到"));
}
void parseLine(const char *raw, bool fromBT)
{
char buf[48];
uint8_t len = 0;
for (const char *p = raw; *p && len < 47; p++) {
char ch = *p;
if (ch == ' ' || ch == '\t') continue;
buf[len++] = (ch >= 'a' && ch <= 'z') ? (ch - 32) : ch;
}
buf[len] = '\0';
if (len == 0) return;
if (len == 1) { parseCmd(buf[0], fromBT); return; }
Serial.print(F("[CMD] 多字符命令: \""));
Serial.print(buf);
Serial.print(F("\" 来源: "));
Serial.println(fromBT ? F("蓝牙") : F("USB"));
if (strcmp(buf, "INFO") == 0) {
Serial.println(F("[ACTION] 打印系统信息(模拟)"));
if (fromBT) BTSerial.println(F("[INFO] System OK"));
return;
}
if (strncmp(buf, "TKP=", 4) == 0) {
float val = atof(buf + 4);
Serial.print(F("[ACTION] 设置 Kp = ")); Serial.println(val);
if (fromBT) { BTSerial.print(F("[SET] Kp=")); BTSerial.println(val); }
return;
}
Serial.println(F("[WARN] 未识别的多字符命令"));
}
void setup()
{
Serial.begin(115200);
BTSerial.begin(9600);
delay(200);
Serial.println(F("=========================================="));
Serial.println(F(" 蓝牙/串口双通道命令解析 独立测试沙盒 "));
Serial.println(F(" USB波特率:115200 蓝牙波特率:9600 "));
Serial.println(F(" 测试命令: A B 1 2 INFO TKP=数值 "));
Serial.println(F("=========================================="));
}
void loop()
{
// ── USB通道:换行结帧 ──────────────────────────────────────
while (Serial.available()) {
char c = (char)Serial.read();
if (c == '\n' || c == '\r') {
if (g_usbIdx > 0) { g_usbBuf[g_usbIdx] = '\0'; parseLine(g_usbBuf, false); g_usbIdx = 0; }
} else if (g_usbIdx < sizeof(g_usbBuf) - 1) {
g_usbBuf[g_usbIdx++] = c;
} else { g_usbIdx = 0; }
}
// ── 蓝牙通道:换行或30ms超时自动结帧 ────────────────────────
while (BTSerial.available()) {
char c = (char)BTSerial.read();
g_btLastByte = millis();
if (c == '\n' || c == '\r') {
if (g_btIdx > 0) { g_btBuf[g_btIdx] = '\0'; parseLine(g_btBuf, true); g_btIdx = 0; }
} else if (g_btIdx < sizeof(g_btBuf) - 1) {
g_btBuf[g_btIdx++] = c;
} else { g_btIdx = 0; }
}
if (g_btIdx > 0 && (millis() - g_btLastByte) >= BT_FLUSH_MS) {
g_btBuf[g_btIdx] = '\0'; parseLine(g_btBuf, true); g_btIdx = 0;
}
}
5.1 测试步骤
①验证USB通道
零知派串口监视器发送
INFO(带换行),确认打印出[CMD] 多字符命令: "INFO" 来源: USB
②验证蓝牙通道多字符命令
用手机蓝牙APP连接后发送
INFO(不带换行也可以),确认约30ms后同样能正确识别为多字符命令而不是被拆成I、N、F、O四条无效单字符命令
③验证带参数命令
发送
TKP=6.5,确认能正确解析出数值6.5,两条通道结果应该一致
5.2 系统流程图

六、蓝牙控制模式
本项目支持四种蓝牙控制模式
enum BTCtrlMode : uint8_t {
Button = 0, // 按键模式:单字节命令
Gravity = 1, // 重力感应:手机姿态控制
JoyStick = 2, // 摇杆模式:双轴遥感
Rocker = 3 // 摇杆模式:另一种协议
};
按键模式(Button)
最简单的控制模式,每个按键对应一个单字节命令
void LZSmartCarMotor::btnControl()
{
switch (cmd[0]) {
case 1: goWithDirec(carForward); break;
case 2: goWithDirec(carBack); break;
case 3: goWithDirec(carForLeft); break;
case 4: goWithDirec(carForRight); break;
case 0: case 9: stop(); break;
}
}
重力感应模式(Gravity)
利用手机陀螺仪数据控制小车方向
void LZSmartCarMotor::gravityControl()
{
x = cmd[0]; y = cmd[1];
speed = map(x, 10, 90, 20, 255);
// y 值决定转向方向和幅度
int sR = (y < 90 && y > -90) ? map(y, 10, 90, speed, 20) : map(y, 170, 90, speed, 0);
int sL = (y < 90 && y > -90) ? map(y, -90, -10, 20, speed) : map(y, -90, -170, 0, speed);
// ...
}
摇杆模式(JoyStick / Rocker)
双轴摇杆控制,x 轴控制速度,y 轴控制转向
void LZSmartCarMotor::joyStickControl()
{
x = cmd[0]; y = cmd[1];
speedLeft = (y < 470) ? map(y, 470, 0, 0, -255) :
(y > 550) ? map(y, 550, 1023, 0, 255) : 0;
speedRight = speedLeft;
int xAdj = (x < 470) ? map(x, 470, 0, 0, 255) :
(x > 550) ? map(x, 550, 1023, 0, 255) : 0;
// [BUG_NOTE] 原代码 xAdj 在左右两侧都是 left+adj/right-adj(只向左偏),
// x>550 应为 left-adj / right+adj,此处仍保持原逻辑待实车验证
speedLeft += xAdj;
speedRight -= xAdj;
motorRun(speedLeft, speedRight);
}
void LZSmartCarMotor::bleControl()
{
if (getCommand(cmd)) {
switch (_mode) {
case Button: btnControl(); break;
case Gravity: gravityControl(); break;
case Rocker: rockerControl(); break;
case JoyStick: joyStickControl(); break;
}
}
}
七、常见问题 FAQ
Q1:蓝牙模块可以同时连接多个设备吗?
A:不能。HC-05 和 HC-06 都是点对点蓝牙模块,同一时间只能与一个设备保持连接。如果需要多设备连接,需要考虑其他方案(如 WiFi 或 BLE 多连接)
Q2:收到数据全是乱码?
A:波特率不匹配或数据格式不一致。确认 USB 串口监视器波特率为 115200、确认蓝牙模块波特率为 9600(HC-06 默认)或 38400(HC-05)、蓝牙模块数据格式固定为 8N1(8 位数据、无校验、1 位停止位)
通信协议是整车项目的“神经系统”——它把用户的意图从手机/电脑传递到小车,把小车状态从传感器反馈给用户。一套设计良好的通信协议,应该做到:有线/无线双通道统一、单字符/多字符命令兼容、解析逻辑高效稳定、模式切换干净利落
更多推荐



所有评论(0)