Android 经典蓝牙 SPP 遥控小车:协议、权限与低延迟链路
2026/9/17 17:15:11 网站建设 项目流程

简介:这份PDF是一份高校课程设计报告,主题为基于Android平台的蓝牙遥控小车,面向电子信息、嵌入式与物联网方向的在校学生及单片机初学者。内容完整覆盖从方案论证到实验验证的全过程:硬件部分讲解STC89C52单片机、L298N直流电机驱动与HC-05蓝牙模块的电路原理;软件部分包含单片机C语言运动控制程序,以及基于Eclipse与J2ME开发的Android蓝牙客户端界面与BluetoothCar类设计,可实现前进、后退、左转、右转与停止等遥控动作。资源包仅1个PDF文件,约971KB,篇幅紧凑便于通读。目前已有210人学习,可作为课程设计、嵌入式入门项目或答辩复现的参考范本,帮助读者理清软硬件协同开发思路、无线通信调试要点与报告撰写结构,快速搭建同类蓝牙遥控小车方案。

1. 蓝牙遥控小车的坑不在电机,在连接和时序

第一次做基于 Android 的蓝牙遥控小车,多数人把时间花在画摇杆界面和挑电机上,真正卡住的却是另一件事:手机侧 BluetoothSocket 建连成功、串口也开始收数,小车要么一顿一顿,要么松手之后还在往前冲。问题很少出在硬件本身,出在链路上一串被忽略的默认值——Android 12 起蓝牙权限被拆成三组运行时权限,HC-05 这类经典蓝牙模块出厂的 AT 模式波特率和通信波特率不是同一个数,下位机状态机在没有超时复位时会一直等一个永远不来的校验字节。这篇按协议怎么定、Android 侧怎么写、下位机怎么解析、延迟怎么压的顺序走一遍,需要装好 android studio 与 android sdk,有串口或 Android 蓝牙开发经验跟起来更顺,读完能拿到一套可复现的 SPP 遥控链路。

2. 经典蓝牙 SPP 选型与遥控指令帧设计

2.1 SPP 与 BLE 在遥控场景下的取舍

经典蓝牙协议栈里的 SPP 是 RFCOMM 之上的串口仿真,手机侧拿到的就是一个流式 socket,写进去多少字节,模块那端就吐多少字节,天然适合"连续发遥控指令"这种模型。BLE 走的是 GATT,读写属性和 Notify 都要经过连接间隔调度,从建立时序图上看,一次 Notify 的往返受连接间隔、MTU、从机处理时间三者叠加影响,很多国内 ROM 还不认requestConnectionPriority的请求。

下面这张表是实际选型时用得最多的几个维度:

对比项经典蓝牙 SPPBLE(GATT)
传输模型RFCOMM 流式串口属性读写 / Notify
Android 侧代码量BluetoothSocket 几十行GATT 回调状态机,量级翻倍
单包时延稳定,通常 10~30ms受连接间隔牵制,最快 7.5ms 起
配对方式系统配对 + PIN 码多数场景免配对
典型模块HC-05 / HC-06ESP32、nRF 系列
适用场景连续方向与速度流低频传感器上报、省电设备

遥控小车需要的是"按住即走、松手即停"的连续流,SPP 的流式语义正好匹配;如果小车还要上报电量、里程、IMU 数据并且大部分时间休眠,BLE 才更划算。混用的方案也有,用 ESP32 同时跑经典蓝牙和 Wi-Fi 联网上报,不过蓝牙和 Wi-Fi 共用 2.4G 射频,同开时吞吐会互相让步,调试阶段建议先关掉一路。

2.2 五字节遥控帧:帧头、指令、双轮速度与异或校验

串口上没有包边界,收到的是连续字节流,所以必须自己划帧。常见做法是定长帧加异或校验,长度短、解析快,单片机上一段状态机就能处理。字段设计如下:

字段长度取值作用
帧头1 字节0xAA让状态机在乱码中重新对齐
指令码1 字节0x01 前进 / 0x02 后退 / 0x03 刹车 / 0x04 心跳区分动作类型
左轮速度1 字节-100 ~ 100,有符号百分比占空比
右轮速度1 字节-100 ~ 100,有符号百分比占空比
校验1 字节前四字节逐字节异或丢弃误码帧

左右轮独立给速度是差速转向的基础,转弯时两轮给不同值即可,不需要额外的转向指令码。用 Python 先把组装逻辑写出来对着串口助手验证:

import struct FRAME_HEAD = 0xAA CMD_FORWARD = 0x01 CMD_BRAKE = 0x03 def build_frame(cmd: int, left: int, right: int) -> bytes: """组装一帧:帧头|指令|左轮|右轮|异或校验""" left &= 0xFF # 负数转成补码,-100 -> 0x9C right &= 0xFF body = bytes([FRAME_HEAD, cmd, left, right]) checksum = 0 for b in body: # 逐字节异或,0xAA 也参与 checksum ^= b return body + bytes([checksum]) if __name__ == "__main__": print(build_frame(CMD_FORWARD, 60, 60).hex(" ")) print(build_frame(CMD_BRAKE, 0, 0).hex(" "))

运行后得到aa 01 3c 3c e1aa 03 00 00 a9,把这两串十六进制丢进串口调试助手,下位机就能先脱离手机单独验证解析逻辑。参数上有两个约定要固定:速度用百分比而不是绝对占空比,换电机或换电池电压时不用改协议;帧长固定五字节,下位机数组开五个字节就够,不必动态分配。校验只覆盖四个字节,成本几乎为零,却能挡掉绝大多数串口噪声引起的误动作。

2.3 HC-05 的 AT 配置与波特率对齐

HC-05 出厂默认通信波特率常见是 9600,而多数固件例程按 115200 初始化串口,这一条就能解释一大半"连上了但全是乱码"的现象。进入 AT 模式的方式有两种:一种是上电前按住 KEY/EN 引脚再上电,此时 AT 波特率固定 38400;另一种是在已配对状态下直接发 AT。不同固件版本行为不一致,先按前一种做最稳。AT 指令必须以回车换行结尾。

常用配置指令:

AT 指令含义建议值
AT+NAME=BT_CAR修改设备名便于在配对列表里辨认
AT+PSWD=1234配对码4 位数字
AT+UART=115200,0,0波特率、停止位、校验位与 MCU 串口一致
AT+ROLE=0设为从机手机做主设备
AT+CMODE=1允许任意地址连接调试期方便
AT+STATE?查询当前状态确认是否已连接

配完之后发AT+RESET重启生效,LED 从快闪变成慢闪说明进入可被搜索状态。角色设定是最容易漏的一步:AT+ROLE=1会把它设成主机,手机反而搜不到,遇到"能搜到但配对失败"先回来查这一条。

3. Android 端蓝牙连接的权限、线程与摇杆映射

3.1 Android 12 以上必须补的三组运行时权限

从 API 31 开始,BLUETOOTHBLUETOOTH_ADMIN对旧版本仍然有效,但新系统上连接和扫描分别需要BLUETOOTH_CONNECTBLUETOOTH_SCAN,漏掉任何一个都会在建连时抛 SecurityException,且堆栈里看不出是权限问题。用maxSdkVersion把新旧权限隔开是标准写法:

<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <!-- Android 11 及以下扫描设备需要定位权限 --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" /> <uses-feature android:name="android.hardware.bluetooth" android:required="true" />

neverForLocation这个标记值得加上,它告诉系统这次扫描不用于推断位置,能避免被强制要求定位权限。运行时申请按版本分叉:

private val btPerms: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN) } else { arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.ACCESS_FINE_LOCATION) } // 在 Activity 中调用,requestCode 自定 registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> if (result.values.all { it }) initBluetooth() else showToast("缺少蓝牙权限") }.launch(btPerms)

参数说明:Build.VERSION_CODES.S对应 API 31,是权限模型的分界线;result.values.all { it }要求整组权限都授予,只给一半会导致扫描能跑但连接失败。

3.2 已配对设备直连与 SPP UUID 的固定写法

遥控小车几乎不做扫描发现,因为设备是固定的,直接遍历已配对列表按名字取,能让冷启动快好几秒。SPP 的 UUID 是协议规定的常量,不要随便改:

private val SPP_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB") fun connect(adapter: BluetoothAdapter, targetName: String): BluetoothSocket? { adapter.cancelDiscovery() // 发现过程会大幅拖慢建连,必须先取消 val device = adapter.bondedDevices.firstOrNull { it.name == targetName } ?: return null val socket = device.createRfcommSocketToServiceRecord(SPP_UUID) return try { socket.connect() // 阻塞调用,必须放在工作线程 socket } catch (e: IOException) { socket.close() // 失败要关掉,否则残留 fd 会导致后续连接一直失败 null } }

逻辑说明:cancelDiscovery()放在 connect 之前是关键,扫描和建连抢射频资源,不取消的话 connect 超时概率明显上升。bondedDevices只能读到已配对的设备,首次使用要先在系统设置里配对一次。整个 connect 是阻塞的,放在主线程会触发 ANR,实践中包在ThreadcoroutineScope(Dispatchers.IO)里。

3.3 单线程发送队列,避免高频写把 socket 写崩

摇杆事件一秒能触发上百次,如果每次onTouchEvent都直接outputStream.write,多个线程并发写同一个流会把帧冲散。正确做法是让 UI 线程只往队列里丢帧,单独一个发送线程顺序取出:

private val txQueue = LinkedBlockingQueue<ByteArray>(8) // 队列封顶,满了就丢旧帧 private val txThread = Thread { while (!Thread.currentThread().isInterrupted) { val frame = txQueue.take() try { outputStream.write(frame) outputStream.flush() } catch (e: IOException) { break // 写失败说明链路断了,退出线程交给重连逻辑 } } } fun send(frame: ByteArray) { if (!txQueue.offer(frame)) { // 队列满,丢掉最旧的一帧再放 txQueue.poll() txQueue.offer(frame) } }

参数说明:容量设成 8 是经验值,对应大约 400ms 的缓冲。设太大反而有害——链路一旦拥塞,队列里堆的是过时的速度指令,小车会按几百毫秒前的动作执行。offer失败时丢最旧帧而不是丢新帧,保证执行的是最新意图。

3.4 摇杆坐标换算成左右轮速度

界面上拿到的是屏幕坐标,y 轴向下为正,需要先取反再分解成差速:

private const val MAX_SPEED = 100 fun joystickToFrame(x: Float, y: Float): ByteArray { val forward = -y // 归一化坐标,范围 -1f ~ 1f val turn = x var left = (forward + turn) * MAX_SPEED var right = (forward - turn) * MAX_SPEED left = left.coerceIn(-MAX_SPEED.toFloat(), MAX_SPEED.toFloat()) right = right.coerceIn(-MAX_SPEED.toFloat(), MAX_SPEED.toFloat()) return buildFrame(if (forward >= 0) CMD_FORWARD else CMD_BACK, left.toInt(), right.toInt()) }

逻辑说明:forward + turnforward - turn是差速转向的标准分解,摇杆推右上角时左轮快、右轮慢,车体自然右转。coerceIn防止两个方向的叠加超出量程。指令码只区分前进和后退,左右轮的符号已经足够表达原地旋转。

4. 小车端帧解析、PWM 输出与联调排查

4.1 环形缓冲加状态机的字节解析

单片机串口中断里不要做耗时解析,中断只负责把字节塞进环形缓冲,主循环里跑状态机。状态机在任何一步失败都要回到找帧头,不能死等:

#include <stdint.h> #include <stdlib.h> #define FRAME_HEAD 0xAA #define FRAME_LEN 5 typedef enum { ST_HEAD, ST_CMD, ST_L1, ST_R1, ST_SUM } parse_state_t; static parse_state_t st = ST_HEAD; static uint8_t frame[FRAME_LEN]; static uint8_t idx = 0; static uint8_t sum = 0; static uint32_t last_frame_tick = 0; void bt_parse_byte(uint8_t b) { switch (st) { case ST_HEAD: if (b == FRAME_HEAD) { frame[0] = b; sum = b; idx = 1; st = ST_CMD; } break; case ST_CMD: case ST_L1: case ST_R1: frame[idx++] = b; sum ^= b; st = (parse_state_t)(st + 1); break; case ST_SUM: if (b == sum) { motor_apply((int8_t)frame[2], (int8_t)frame[3]); /* 有符号还原 */ last_frame_tick = get_tick_ms(); } st = ST_HEAD; /* 校验错也归位,避免卡在这一步 */ break; } } /* 主循环里调用:超过 500ms 没有合法帧就急停 */ void safety_check(void) { if (get_tick_ms() - last_frame_tick > 500) { motor_apply(0, 0); } }

参数说明:(int8_t)frame[2]把 0x9C 还原成 -100,这一步不能省,否则反向速度会被当成正的大速度。last_frame_tick配合safety_check构成软件看门狗,是防失控最省事的一道保险——手机没电、走出范围、程序卡死,小车都会在 500ms 内停下。

4.2 电机死区补偿与 PWM 占空比映射

小功率直流电机在低占空比下不转,只是嗡嗡响,占空比映射要留死区:

速度百分比输出占空比说明
00彻底停,同时拉低方向脚
1 ~ 150落在死区内,直接视为停
16 ~ 100200 + 速度 × 10PWM 周期设为 1000,留 20% 起步补偿
#define PWM_PERIOD 1000 static uint16_t speed_to_duty(int8_t speed) { int16_t v = speed < 0 ? -speed : speed; if (v < 16) return 0; /* 死区 */ return (uint16_t)(200 + v * 10); /* 200~1200 线性映射 */ } void motor_apply(int8_t left, int8_t right) { set_dir(LEFT_MOTOR, left >= 0); set_dir(RIGHT_MOTOR, right >= 0); pwm_set(LEFT_MOTOR, speed_to_duty(left)); pwm_set(RIGHT_MOTOR, speed_to_duty(right)); }

死区阈值和补偿量跟电机型号、供电电压都有关,换电池从 12V 到 7.4V 时起步补偿要重新调。调的方法是把速度从 1 慢慢往上加,记录两轮同时开始转的那个百分比,把它设成死区上限。

4.3 HC-05 连不上与丢帧的排查对照表

调试期九成问题集中在下面几类,按现象查最快:

现象常见原因处理方式
手机搜不到设备模块已连接别的主机 / ROLE 被设成 1断电重启,发AT+ROLE=0
配对成功但 connect 抛异常未取消 discovery / 模块被占用connect 前调用cancelDiscovery()
收到数据全是乱码AT 波特率与 MCU 串口不一致AT+UART=115200,0,0后复位
小车偶尔抽一下帧未对齐,状态机收到半帧确认校验分支无条件归位到 ST_HEAD
Android 12 上直接崩缺少 BLUETOOTH_CONNECT补运行时权限申请
连上后几十毫秒断开供电电流不足,模块复位单独给模块供电或加大电容

用 android debug bridge 抓日志时,adb logcat | grep -i bluetooth能看到底层断开原因码,比在代码里打日志更快定位。验证解析逻辑时可以先不用手机,用通用蓝牙串口调试 App 手发十六进制帧,能把问题域一刀切在"手机程序"还是"下位机固件"。

5. 把端到端延迟压到 50ms 以内与断连自愈

5.1 影响手感的三处延迟来源

第一处是发送频率。手指滑动时onTouchEvent触发频率远高于人眼需要,把发送节流到 20Hz 就够,还能顺带减少队列积压:

private var lastSendTime = 0L fun sendThrottled(frame: ByteArray, minIntervalMs: Long = 50) { val now = SystemClock.elapsedRealtime() if (now - lastSendTime < minIntervalMs) return lastSendTime = now send(frame) }

第二处是 MCU 侧的串口接收方式。用阻塞轮询读串口会在等待字节时浪费主循环时间,改成 DMA 加空闲中断,一帧到齐后一次性解析,能省下几个毫秒。第三处是波特率,9600 下五字节帧的传输时间是 5ms 左右,115200 下不到 0.5ms,遥控场景直接上 115200,模块和 MCU 同步改。这三点做完,从手指离开屏幕到轮子响应通常能进 50ms。

5.2 心跳与超时停车的双保险

主循环加一个 200ms 的心跳,链路空闲时也持续发帧,下位机的看门狗就能一直刷新:

private val heartbeat = object : Runnable { override fun run() { if (SystemClock.elapsedRealtime() - lastSendTime > 200) { send(buildFrame(CMD_BRAKE, 0, 0)) // 空闲即发刹车帧 } handler.postDelayed(this, 200) } }

配合下位机 500ms 的safety_check,形成两层保护:一层是主动刹车,一层是失联急停。重连逻辑放在连接线程退出时触发,用指数退避,首次 500ms、随后翻倍、封顶 5 秒,避免频繁重试把手机的蓝牙协议栈打满。

遥控场景里还有一种容易走偏的做法,就是拿 RSSI 做蓝牙测距来控制距离,实测同一位置 RSSI 抖动经常超过 10dBm,换算成距离误差能到好几米,只能做粗略的远近分档,不能作为控制量。真要判断距离,加个超声波或红外测距模块比调 RSSI 靠谱得多。

本文还有配套的精品资源,点击获取

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

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

立即咨询