
简介基于STM32蓝牙模块与Android手机通讯的完整工程包适合嵌入式入门者、物联网爱好者以及需要快速搭建蓝牙上位机的开发者。资源覆盖STM32端蓝牙通信逻辑与Android Studio APP上层界面通过具体代码展示串口数据收发、蓝牙设备扫描配对、自定义协议解析等关键环节可直接迁移到小车控制、环境监测等场景。压缩包共1103个文件以xml界面布局与配置、rawproto资源、png图标、json数据及Java源码为主并包含class、jar库与gradle构建文件整体约21.05MB目录结构接近标准Android工程便于对照学习。包内已附可直接安装的调试版APK也有编译中间产物与完整工程目录既能直接安装体验也可结合源码追踪从底层蓝牙通信到应用层UI的完整链路。已有219人学习该资源内容紧凑对理解Android Bluetooth API与嵌入式协同开发有参考价值。1. 从“MyBluetooth.rar”看 STM32 蓝牙通讯项目真正要解决的问题拿到的压缩包名字暴露了它是一套典型的 STM32 蓝牙通讯工程下位机是 STM32 跑串口加蓝牙模块上位机是 Android 端 App通过蓝牙把手机和单片机连起来用上位机界面发指令、收数据、看状态。这类项目的应用场景很宽从调试电机参数、采集传感器数据到毕业设计里的智能小车、鱼缸控制器本质都是一条无线串口链路手机发一帧命令、单片机回一帧数据两端的解析规则必须一致否则乱码、粘包、丢指令轮番上演。项目标题里“MyBluetooth_STM32app_STM32蓝牙通讯_android_stm32 上位”这几个关键词实际上描述了一条典型的蓝牙调试生态链低成本的 HC-05/HC-06 或 HM-10 模块接 STM32 串口Android 端用系统 BluetoothAdapter 走 SPP 或 BLE两边约定一个帧格式。你会发现“蓝牙通讯”本身并不难难的是数据链路里的波特率匹配、帧边界判定、Android 权限适配、以及断线重连的稳定性。这篇文章就按这个标题对应的完整落地路径来展开先定协议再写 STM32 端再写 Android 上位机最后给一组调试验证技巧。适合正在做 STM32 蓝牙控制类项目的人也适合拿到这类源码包但不知道怎么改协议的人。2. 协议先行STM32 蓝牙通讯的帧格式与 CRC 校验设计2.1 为什么先设计帧而不是先接线路很多初学者拿到 STM32 蓝牙模块第一件事就是拿手机串口助手收发测试这种方式验证链路通不通没问题但一旦进入真实数据传输问题立刻出现单片机发送 10 个字节Android 端可能一次只读到 3 个字节或者两条消息粘在一起。这跟硬件无关是数据流的传输特性决定的。蓝牙 SPP 底层是 RFCOMM数据以流的形式传输没有消息边界。换句话说发送方发了两帧接收方读到的可能是一段连续字节流你必须自己从流里“切”出每一帧。所以设计 STM32 蓝牙通讯项目的第一步是定义帧协议而不是写发送代码。帧协议要回答三个问题一帧从哪里开始一帧有多长这一帧有没有被传错。2.2 一种够用的帧结构定义我一般建议做安卓与 STM32 间蓝牙通讯时帧结构越简单越好。下面这套结构在产品项目中实践过适合大部分传感器查询、电机控制、参数配置场景字段长度字节说明帧头 HEAD11固定 0xAA标志帧起始帧头 HEAD21固定 0x55防止单字节误触发长度 LEN1从 cmd 到 payload 末端的长度不含 LEN 本身命令字 CMD1高 4 位为功能分组低 4 位为命令序号载荷 PAYLOAD0~16参数数据长度可为 0CRC81校验 LENCMDPAYLOAD 共 LEN 个字节CRC8 多项式用 0x31x8x5x41初值为 0x00。选轮询实现而不是查表因为在 STM32F103 这类芯片上每帧才十几字节查表带来的速度提升没有实际意义反而增加代码量。STM32 端发一帧的 C 代码#define FRAME_FLAG1 0xAA #define FRAME_FLAG2 0x55 uint8_t calc_crc8(uint8_t *ptr, uint8_t len) { uint8_t crc 0x00; uint8_t i; while (len--) { crc ^ *ptr; for (i 0; i 8; i) { if (crc 0x80) crc (crc 1) ^ 0x31; else crc 1; } } return crc; } void ble_send_frame(uint8_t cmd, uint8_t *payload, uint8_t plen) { uint8_t txbuf[22]; uint8_t len plen 1; // CMD 算在长度里 txbuf[0] FRAME_FLAG1; txbuf[1] FRAME_FLAG2; txbuf[2] len; txbuf[3] cmd; if (plen payload) { memcpy(txbuf[4], payload, plen); } txbuf[4 plen] calc_crc8(txbuf[2], len); HAL_UART_Transmit(huart2, txbuf, plen 5, 100); }逻辑说明calc_crc8 用位循环逐字节计算每字节都做 8 次移位异或ble_send_frame 先拼帧头、长度、命令字再把载荷拷入最后追加 CRC整帧通过串口 2 发给蓝牙模块。参数说明plen 最大为 16是因为 CRC 字段本身不算进 LENLEN 最大为 17CMD16 字节载荷CRC 单独放在帧尾如果将来要扩展载荷LEN 可以改成 2 字节但别在项目初期这么做。2.3 Android 端帧解析实现Android 端收 BluetoothSocket 的流和串口中断收数据一样要逐字节状态机解析。直接用 BufferedInputStream 读然后去 head 找帧头是最简单的但要防止数据量大时解析错位。我常用的是一个轻量状态机只依赖帧头判断private fun parseFrame(byte: Int) { when (state) { 0 - if (byte 0xAA) { state 1; frameBuf.clear() } 1 - if (byte 0x55) { state 2 frameBuf.add(0xAA.toByte()) frameBuf.add(byte.toByte()) } else state 0 2 - { frameLen byte.toInt() state 3 frameBuf.add(byte.toByte()) } 3 - { // 先把剩余字节收进缓冲再整体校验 if (frameBuf.size frameLen 4) { frameBuf.add(byte.toByte()) if (frameBuf.size frameLen 4) { val crc calcCrc8(frameBuf, 2, frameLen) if ((crc.toInt() and 0xFF) frameBuf[frameBuf.size - 1].toInt() and 0xFF) { val cmd frameBuf[3] val payload frameBuf.copyOfRange(4, frameBuf.size - 1) onFrameReceived(cmd, payload) } state 0 } } } } }逻辑说明state 机器依次吃 AA、55、长度、数据区等数据区长度收满再算 CRC。为什么长度字段从 state2 开始存因为在 state2 时收的才是 LEN。这里的状态位 0 是等待帧头如果一直收到垃圾数据状态永远卡在 0不会误判中间字节所以抗干扰能力好。CRC 计算和 C 端必须一致从 LEN 字段开始到 payload 最后一个字节为止。注意 Kotlin 的 Byte 是有符号的做位运算时要把 byte.toInt() and 0xFF 转成无符号值。2.4 心跳与握手的基本参数蓝牙链路断开时Android 端不会立刻感知经常是写着写着才发下一个指令发现没响应。协议里可以加一个周期 1000ms 的握手帧CMD 固定为 0xF1Android 端收到不回响应而是主动等待 STM32 的心跳回复等字段。握手频率参数设 1Hz 对功耗影响很小对链路感知明显。若希望省电可以把心跳间隔放到 3000ms但断线感知时间要以除零为边界测试。3. STM32 端串口与蓝牙模块的初始化与转储实现3.1 串口外设配置与波特率选择STM32 与蓝牙模块之间走的是 USART 串口常见接法是 HC-05 的 TXD 接 STM32 的 RX、RXD 接 STM32 的 TX。波特率我一般选 115200因为 HC-05 默认是 9600但 115200 下 Android 蓝牙 SPP 的吞吐表现更好数据量大的时候不会让蓝牙成为瓶颈。需要注意的是 AT 指令配置蓝牙模块时要用模块当前的波特率若模块掉电后保持 9600你开机即 115200 反而无法发 AT 指令所以初始化顺序先低速后高速。STM32F103 的串口初始化与中断配置void MX_USART2_UART_Init(void) { huart2.Instance USART2; huart2.Init.BaudRate 115200; huart2.Init.WordLength UART_WORDLENGTH_8B; huart2.Init.StopBits UART_STOPBITS_1; huart2.Init.Parity UART_PARITY_NONE; huart2.Init.Mode UART_MODE_TX_RX; huart2.Init.HwFlowCtl UART_HWCONTROL_NONE; HAL_UART_Init(huart2); HAL_UART_Receive_IT(huart2, rx_data, 1); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { ringbuffer_write(uart_rb, rx_data); HAL_UART_Receive_IT(huart2, rx_data, 1); } }参数说明WordLength 8 位、无校验、1 停止位这是蓝牙串口最通用的 8N1 配置HAL_UART_Receive_IT 一次只收一个字节收完进回调再重新使能这是最简中断法。回调里 ringbuffer_write 把字节塞入环形缓冲回调函数中不能做耗时操作解析操作放到主循环里。3.2 环形缓冲区与数据帧提取串口中断里写缓冲主循环读缓冲然后调帧解析这是让 STM32 蓝牙通讯稳的关键做法。环形缓冲区代码#define RB_SIZE 256 typedef struct { uint8_t buf[RB_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ringbuffer_t; uint8_t ringbuffer_write(ringbuffer_t *rb, uint8_t data) { uint16_t next (rb-head 1) % RB_SIZE; if (next rb-tail) return 0; // 满 rb-buf[rb-head] data; rb-head next; return 1; } uint8_t ringbuffer_read(ringbuffer_t *rb, uint8_t *data) { if (rb-head rb-tail) return 0; *data rb-buf[rb-tail]; rb-tail (rb-tail 1) % RB_SIZE; return 1; }代码后的逻辑说明head 指向下一个写入位置tail 指向下一个读取位置缓冲区满时写操作返回 0防止覆盖未读数据head 和 tail 用 volatile 修饰是因为中断和主循环共享这两个变量。参数说明RB_SIZE 用 256 位可以覆盖 200Hz 的传感器数据流如果你的项目一秒钟要转储 2KB 以上的内容改成 1024 更安全。主循环里就是不断 ringbuffer_read 后逐个字节喂给 2.2 节解析逻辑。3.3 HC-05 与 HM-10 的初始化命令HC-05 走 SPP 经典蓝牙HM-10 走 BLE两者初始化命令不同。手上源码包如果标注的是 android 上位机说明是 SPP 模式居多因为 Android 原生对经典蓝牙串口的支持更直接如果包名里出现 BLE 扫描、GATT 之类才需要走 HM-10。结合标题“MyBluetooth”看不出具体模块常见做法是两种都预留。HC-05 初始化 AT 指令指令作用示例AT测试连接AT\r\n 返回 OKATNAMEMyBle设置蓝牙名称手机扫描可见ATPSWD1234设置配对密码默认 1234ATUART115200,0,0设置串口参数0 表示停止位 1无校验ATROLE1主从模式上位机为从机则设 0发送 AT 指令时注意每个指令必须以 \r\n 结尾且上电后等待模块初始化完成再发一般延时 500ms。如果 HC-05 状态灯慢闪说明未连接、快闪说明已配对这对调试时判断是主机没配对还是从机没广播很有用。3.4 电源与引脚电平的一个坑HC-05 供电 3.6V~6V但它的逻辑电平是 3.3VSTM32 的 TTL 引脚输出也是 3.3V理论上可以直接连。但 STM32 的 TX 在空闲时为高电平 3.3V如果板子上把蓝牙模块错误接到了 5V 供电的引脚或者 RX 引脚没做分压长期用会有掉包风险。稳妥做法是看原理图确认模块 VCC 与逻辑参考一致。HM-10 的供电上限 3.6V超了就烧接 STM32 开发板的 3.3V 输出时还要确认稳压芯片电流充足BLE 模块配对瞬间电流接近 40mA。4. Android 上位机的骨架BLE 扫描 / SPP 连接与数据收发4.1 权限适配Android 6 到 Android 14 的差异Android 做蓝牙通讯最烦的是权限矩阵同一套代码在不同机型上偶发卡顿、扫不到设备、断开后重连失败多半是权限没适配到位。对照一张表解决问题Android 版本主要权限运行时请求方式API 23ACCESS_FINE_LOCATION 开启蓝牙扫描需要位置权限运行时 requestPermissionsAPI 31BLUETOOTH_SCAN、BLUETOOTH_CONNECT运行时 requestPermissions需要同时声明 BLUETOOTH_ADVERTISE 视功能而定API 33不再强制位置权限用于 BLE 扫描但 SPP 设备发现仍需 BLUETOOTH_CONNECT通过 Activity Result API 请求Android 12 开始 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT 是危险权限不弹窗授权就会直接抛 SecurityException。AndroidManifest.xml 里需声明 BLUETOOTH、BLUETOOTH_ADMIN兼容老版本以及 BLUETOOTH_CONNECT 与 BLUETOOTH_SCAN。4.2 传统 SPP 连接的建立流程SPP 连接的关键在于 UUID。Android 端与 HC-05 配对时用系统保留的 SPP UUIDprivate static final UUID SPP_UUID UUID.fromString(00001101-0000-1000-8000-00805F9B34FB); BluetoothDevice device adapter.getRemoteDevice(macAddress); BluetoothSocket socket device.createRfcommSocketToServiceRecord(SPP_UUID); socket.connect();createRfcommSocketToServiceRecord 走 SDP 查询如果对方不支持标准 SPP UUID 的则会抛 IOException。遇到连接报错的常见备选方案是反射调用 createRfcommSocket(1)但这在 Android 8 以后成功率低。SPP 连接成功后socket.getInputStream() 读数据socket.getOutputStream() 写数据这就是一条与串口几乎等价的管道。read() 方法是阻塞的必须放子线程或协程。4.3 数据读取线程与 UI 更新我用一个独立线程做读取读到的字节回调到主线程避免 ANR。核心结构如下class BluetoothReadThread(private val socket: BluetoothSocket) : Thread() { private val handler Handler(Looper.getMainLooper()) override fun run() { val input socket.inputStream val buffer ByteArray(256) while (!isInterrupted) { val len input.read(buffer) if (len 0) { val frame buffer.copyOf(len) handler.post { parseFrame(frame) } } } } }逻辑说明read 在收到数据前阻塞不会空转占 CPUcopyOf 拷贝出每一段数据再交给解析器防止下一次 read 覆盖前一次的内容。handler.post 把解析动作切回主线程这样解析里 CambiarUI 不会触碰线程安全。参数说明buffer 大小 256 与 STM32 端帧最大长度 22 字节之间留了余量但若蓝牙模块一次一包超过 256可以加大为 1024否则读回来的数据被截断会导致粘包识别失败。4.4 多设备连接与参数记忆上位机一般会保存上次连接设备列表避免每次重新配对。SharedPreferences 里存 MAC 地址启动时直接找设备尝试 connect。多设备同时在线的问题要提前想好Android 的 BluetoothAdapter 同一时刻只能维持一个经典蓝牙连接如果项目需求是多个 SLAVE 节点连接那得走 BLE central 模式。BLE 方式需要扫描服务、获取特征值、使能通知核心逻辑代码与 SPP 差异较大参数上要注意 MTU 最小值是 23 字节若一次传输超过 20 字节需要拆包。5. 调试验证三件套抓串口日志 / 看蓝牙流 / 对帧结构5.1 用 USB-TTL 转接板并行监视 STM32 串口STM32 与蓝牙模块之间的数据肉眼没法看只能把蓝牙模块拆掉或并联一根 TTL 转 USB用串口调试助手同时监视。常见的并联接法是把 USB-TTL 的 RX 与 STM32 的 TX 接在一起GND 共地。此时留意电平冲突如果 USB-TTL 是 5V 逻辑直接接 STM32 的 TX 可能把 RX 引脚拉坏。我一般用带 3.3V 电平的 CP2102 模块。5.2 Android 侧抓 InputStream 的日志输出在 Android 读取线程里临时加日志每收满一帧就把十六进制字节打出来格式类似[TX] AA 55 03 F1 11、[RX] AA 55 05 A2 01 02 03 97。对比 STM32 端串口调试助手的输出就能确认两端协议是否严格一致。常见不一致包括 CRC 计算的字节范围不同、CMD 宏定义错位、以及 int 转 byte 时正负数问题。出现 RX 里首字节不是 AA 时先看是不是粘包检查解析状态机在异常数据后是否能回到 state 0。5.3 临时透传模式与 ATRESTORE 恢复默认参数调试优先级最高的操作是让 STM32 端进入透传模式。HC-05 按住模块按键上电可以进入 AT 模式此时模块以 38400 波特率接受指令松手重新上电则回到数据透传模式。如果固件里模块名或串口波特率被改乱执行 ATRESTORE 恢复出厂再用 ATUART115200,0,0 重新设置。一旦发现 Android 侧连接稳定但收不到数据先确认模块状态灯是否常亮再确认手机与模块是否配对成功最后用串口助手给模块直接发十六进制测试帧看 STM32 端串口日志是否有输出——这三步能把排查范围立刻缩到蓝牙链路或串口链路中的某一段。5.4 吞吐验证与稳定测试完成基本通讯后衰减传输是压测的重点。把 STM32 端每 20ms 发送一帧带递增序列号的遥测帧Android 端记录序列号跑 30 分钟计算丢帧率。丢帧率高于千分之一时优先查串口中断是否被更高优先级打断、环形缓冲区大小是否足够再查蓝牙模块与开发板的供电是否稳定。序列号计数比 CRC 更能反映链路质量因为 CRC 只能抓错帧不能抓丢帧。也可以同时验证字节序多字节参数按大端序发送STM32 端解释出来与 Android 端能对上就说明两端统一用了大端协议。本文还有配套的精品资源点击获取