零知派——STM32智能小车系列教程(六):蓝牙/串口双通道通信协议设计
引言:智能小车要实现的功能很多——避障、跟随、贴边、灭火、直线行驶,每一个模块单独调试起来都有自己的难点。如果一开始就把所有模块塞进一个工程里联调,出了问题很难分清到底是传感器硬件的问题、电平极性的问题,还是上层控制算法的问题
所以这一系列教程的思路是:先把每个功能模块单独拎出来,配一个不依赖其他硬件的最小测试程序,把模块本身调通调透,再回头看它在完整项目里是怎么被使用的
目录
一、引言
二、蓝牙串口模块硬件原理
2.1 SPP协议的本质
2.2两种工作模式
2.3 常用 AT 指令
三、硬件接线说明
3.1 引脚定义
3.2 接线方案表
3.3 连接示意图
四、通信协议设计
4.1 双通道架构
4.2 单字符命令解析
4.3多字符命令解析
4.4模式切换与状态管理
五、独立测试程序
5.1 测试步骤
5.2 系统流程图
六、蓝牙控制模式
七、常见问题 FAQ
Q1:蓝牙模块可以同时连接多个设备吗?
Q2:收到数据全是乱码?
一、引言
前五篇教程分别讲了循迹、超声波云台、红外双目跟随、测速+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= | 设置模块名称 | OK |
| AT+BAUD= | 设置波特率(1=1200, 2=2400, 3=4800, 4=9600, 5=19200, 6=38400...) | OK |
| AT+ROLE= | 设置角色(0=从机, 1=主机) | OK |
| AT+ADDR? | 查询模块地址 | +ADDR:xxxx:xx:xxxxxx |
| AT+RESET | 复位模块 | OK |
发送 AT 指令时,每条指令必须以rn
三、硬件接线说明
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] = ''; 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] = ''; 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] = ''; 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] = '';
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] = '';
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] = ''; 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] = ''; 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] = ''; 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 位停止位)
通信协议是整车项目的“神经系统”——它把用户的意图从手机/电脑传递到小车,把小车状态从传感器反馈给用户。一套设计良好的通信协议,应该做到:有线/无线双通道统一、单字符/多字符命令兼容、解析逻辑高效稳定、模式切换干净利落
审核编辑 黄宇
