简介:这份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的请求。
下面这张表是实际选型时用得最多的几个维度:
| 对比项 | 经典蓝牙 SPP | BLE(GATT) |
|---|---|---|
| 传输模型 | RFCOMM 流式串口 | 属性读写 / Notify |
| Android 侧代码量 | BluetoothSocket 几十行 | GATT 回调状态机,量级翻倍 |
| 单包时延 | 稳定,通常 10~30ms | 受连接间隔牵制,最快 7.5ms 起 |
| 配对方式 | 系统配对 + PIN 码 | 多数场景免配对 |
| 典型模块 | HC-05 / HC-06 | ESP32、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 e1和aa 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 开始,BLUETOOTH和BLUETOOTH_ADMIN对旧版本仍然有效,但新系统上连接和扫描分别需要BLUETOOTH_CONNECT与BLUETOOTH_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,实践中包在Thread或coroutineScope(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 + turn与forward - 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 占空比映射
小功率直流电机在低占空比下不转,只是嗡嗡响,占空比映射要留死区:
| 速度百分比 | 输出占空比 | 说明 |
|---|---|---|
| 0 | 0 | 彻底停,同时拉低方向脚 |
| 1 ~ 15 | 0 | 落在死区内,直接视为停 |
| 16 ~ 100 | 200 + 速度 × 10 | PWM 周期设为 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 靠谱得多。
本文还有配套的精品资源,点击获取