eefocus_4087784 发表于 2026-8-14 18:11:15

零知派——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 是蓝牙协议栈中的一个标准规范,它规定如何在两个蓝牙设备之间建立一个虚拟串行链路
https://file1.elecfans.com/web3/M00/71/6A/wKgZPGp-5AyAfFLhAADSqsUXQ3k922.png      这个链路模拟了传统 RS-232 串口的所有特性——数据以字节流形式传输、全双工通信、无需复杂的握手协议。对上层应用来说,蓝牙连接和一根 USB 串口线没有区别。


HC-05 与 HC-06 的区别:
本项目同时支持 HC-05 和 HC-06 两种蓝牙模块:

对比项HC-05HC-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 指令时,每条指令必须以 \r\n

三、硬件接线说明3.1 引脚定义本项目蓝牙模块的引脚定义(与 pinsdefine.h 一致):
<pre><code class="language-cpp">#define BTSerial      Serial2   // 蓝牙模块使用硬件串口3</code></pre><p></p>
零知派迷你板的硬件串口分配:
串口引脚用途
SerialPA9(TX)/PA10(RX)USB 串口(调试用)
Serial1PA2(TX)/PA3(RX)预留
Serial2PB10(TX)/PB11(RX)蓝牙模块

3.2 接线方案表
模块引脚零知派迷你板引脚说明
HC-05/06VCC5V供电
HC-05/06GNDGND共地
HC-05/06TXDPB11(RX3)模块发送→主控接收
HC-05/06RXDPB10(TX3)主控发送→模块接收
HC-05KEY可选 GPIOAT 模式使能(HC-06 无此引脚)
      模块的 TXD 接主控的 RX,模块的 RXD 接主控的 TX——这是串口通信的基本规则,接反了将无法通信

3.3 连接示意图https://file1.elecfans.com/web3/M00/71/6A/wKgZPGp-4_WAQa80AAYbY9V4ZVs345.png蓝牙模块初始化
在 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;
    uint8_t _usbIdx = 0;
    char    _btBuf;
    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 = '\0'; parseLine(_usbBuf, false); _usbIdx = 0; }
      } else if (_usbIdx < sizeof(_usbBuf) - 1) {
            _usbBuf = 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 = '\0'; parseLine(_btBuf, true); _btIdx = 0; }
      } else if (_btIdx < sizeof(_btBuf) - 1) {
            _btBuf = c;
      } else { _btIdx = 0; }
    }
    if (_btIdx > 0 && (millis() - _btLastByte) >= BT_FLUSH_MS) {
      _btBuf = '\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(" L=")); Serial.print(lIR);
            Serial.print(F(" R=")); Serial.println(rIR);
            return;
      }
      case 'U': {// 启动自整定
            if (g_mode != MODE_STRAIGHT) {
                Serial.println(F(" 请先切换到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;
    uint8_t len = 0;
    for (const char *p = raw; *p && len < 47; p++) {
      char ch = *p;
      if (ch == ' ' || ch == '\t') continue;
      buf = (ch >= 'a' && ch <= 'z') ? (ch - 32) : ch;
    }
    buf = '\0';
    if (len == 0) return;
    if (len == 1) { parseCmd(buf, 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(" TKp=")); Serial.println(g_track.Kp);
      BTSerial.print(F(" TKp=")); BTSerial.println(g_track.Kp); return;
    }
    if (startsWithCS(buf, "TKD=")) {
      g_track.setTunings(g_track.Kp, floatAfter(buf, 4));
      Serial.print(F(" TKd=")); Serial.println(g_track.Kd);
      BTSerial.print(F(" TKd=")); BTSerial.println(g_track.Kd); return;
    }
    if (startsWithCS(buf, "SPD=")) {
      g_track.setBaseSpeed(intAfter(buf, 4));
      Serial.print(F(" TSPD=")); Serial.println(g_track.baseSpeed);
      BTSerial.print(F(" 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(" WKp=")); Serial.println(g_wall.Kp);
      BTSerial.print(F(" 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(" WKi=")); Serial.println(g_wall.Ki);
      BTSerial.print(F(" 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(" WKd=")); Serial.println(g_wall.Kd);
      BTSerial.print(F(" WKd=")); BTSerial.println(g_wall.Kd); return;
    }
    if (startsWithCS(buf, "WSET=")) {
      g_wall.setDistance(floatAfter(buf, 5));
      Serial.print(F(" WDist=")); Serial.println(g_wall.setpoint);
      BTSerial.print(F(" 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(" Setpoint=")); Serial.println(s_setpoint);
      BTSerial.print(F(" Setpoint=")); BTSerial.println(s_setpoint); return;
    }
    // 新增:码盘每圈脉冲数现场调整(原来写死为20)
    if (startsWithCS(buf, "PPR=")) {
      int ppr = intAfter(buf, 4);
      setEncoderPPR(ppr);
      Serial.print(F(" PPR=")); Serial.println(ppr);
      BTSerial.print(F(" PPR=")); BTSerial.println(ppr); return;
    }

    if (startsWithCS(buf, "EA=")) {
      setEncoderEmaAlpha(floatAfter(buf, 3));
      BTSerial.print(F(" EA=")); BTSerial.println(floatAfter(buf, 3));
      return;
    }

    Serial.print(F(" 未知命令: ")); Serial.println(raw);
    if (fromBT) { BTSerial.print(F(" 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(" 强制中止"));
    }

    // ── 离开舵机相关模式时舵机回正 ──────────────────────────────
    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 BTSerialSerial2

char    g_usbBuf;
uint8_t g_usbIdx = 0;
char    g_btBuf;
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(" 单字符命令: '"));
    Serial.print(c);
    Serial.print(F("'来源: "));
    Serial.println(fromBT ? F("蓝牙") : F("USB"));

    switch (c) {
      case 'A': case 'a': Serial.println(F(" 切换到手动模式")); break;
      case 'B': case 'b': Serial.println(F(" 切换到避障模式")); break;
      case '1': Serial.println(F(" 前进")); break;
      case '2': Serial.println(F(" 后退")); break;
      default:Serial.println(F(" 未识别的单字符命令")); break;
    }
    if (fromBT) BTSerial.println(F(" 命令已收到"));
}

void parseLine(const char *raw, bool fromBT)
{
    char buf;
    uint8_t len = 0;
    for (const char *p = raw; *p && len < 47; p++) {
      char ch = *p;
      if (ch == ' ' || ch == '\t') continue;
      buf = (ch >= 'a' && ch <= 'z') ? (ch - 32) : ch;
    }
    buf = '\0';
    if (len == 0) return;
    if (len == 1) { parseCmd(buf, fromBT); return; }

    Serial.print(F(" 多字符命令: \""));
    Serial.print(buf);
    Serial.print(F("\"来源: "));
    Serial.println(fromBT ? F("蓝牙") : F("USB"));

    if (strcmp(buf, "INFO") == 0) {
      Serial.println(F(" 打印系统信息(模拟)"));
      if (fromBT) BTSerial.println(F(" System OK"));
      return;
    }
    if (strncmp(buf, "TKP=", 4) == 0) {
      float val = atof(buf + 4);
      Serial.print(F(" 设置 Kp = ")); Serial.println(val);
      if (fromBT) { BTSerial.print(F(" Kp=")); BTSerial.println(val); }
      return;
    }
    Serial.println(F(" 未识别的多字符命令"));
}

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 = '\0'; parseLine(g_usbBuf, false); g_usbIdx = 0; }
      } else if (g_usbIdx < sizeof(g_usbBuf) - 1) {
            g_usbBuf = 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 = '\0'; parseLine(g_btBuf, true); g_btIdx = 0; }
      } else if (g_btIdx < sizeof(g_btBuf) - 1) {
            g_btBuf = c;
      } else { g_btIdx = 0; }
    }
    if (g_btIdx > 0 && (millis() - g_btLastByte) >= BT_FLUSH_MS) {
      g_btBuf = '\0'; parseLine(g_btBuf, true); g_btIdx = 0;
    }
}
https://file1.elecfans.com/web2/M00/03/FB/poYBAGDYdXCAWkKMAAAAK8RNs4s030.png5.1 测试步骤①验证USB通道
零知派串口监视器发送INFO(带换行),确认打印出 多字符命令: "INFO" 来源: USB
②验证蓝牙通道多字符命令
用手机蓝牙APP连接后发送INFO(不带换行也可以),确认约30ms后同样能正确识别为多字符命令而不是被拆成I、N、F、O四条无效单字符命令
③验证带参数命令
发送TKP=6.5,确认能正确解析出数值6.5,两条通道结果应该一致

5.2 系统流程图https://file1.elecfans.com/web3/M00/71/6A/wKgZO2p-47iAc71TABpWif_5QYY783.png
六、蓝牙控制模式      本项目支持四种蓝牙控制模式
enum BTCtrlMode : uint8_t {
    Button   = 0,   // 按键模式:单字节命令
    Gravity= 1,   // 重力感应:手机姿态控制
    JoyStick = 2,   // 摇杆模式:双轴遥感
    Rocker   = 3    // 摇杆模式:另一种协议
};
按键模式(Button)
最简单的控制模式,每个按键对应一个单字节命令
void LZSmartCarMotor::btnControl()
{
    switch (cmd) {
      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; y = cmd;
    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; y = cmd;
    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;
    // 原代码 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;
      }
    }
}

七、常见问题 FAQQ1:蓝牙模块可以同时连接多个设备吗?      A:不能。HC-05 和 HC-06 都是点对点蓝牙模块,同一时间只能与一个设备保持连接。如果需要多设备连接,需要考虑其他方案(如 WiFi 或 BLE 多连接)
Q2:收到数据全是乱码?      A:波特率不匹配或数据格式不一致。确认 USB 串口监视器波特率为 115200、确认蓝牙模块波特率为 9600(HC-06 默认)或 38400(HC-05)、蓝牙模块数据格式固定为 8N1(8 位数据、无校验、1 位停止位)

通信协议是整车项目的“神经系统”——它把用户的意图从手机/电脑传递到小车,把小车状态从传感器反馈给用户。一套设计良好的通信协议,应该做到:有线/无线双通道统一、单字符/多字符命令兼容、解析逻辑高效稳定、模式切换干净利落

页: [1]
查看完整版本: 零知派——STM32智能小车系列教程(六):蓝牙/串口双通道通信协议设计