news 2026/9/16 2:48:14

STM32+Android蓝牙透传开发:从串口配置到上位机协议解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32+Android蓝牙透传开发:从串口配置到上位机协议解析

简介:这是一份以STM32微控制器和安卓手机为核心的双向蓝牙通信工程,目标是解决嵌入式设备与手机应用之间的无线数据交互,适合嵌入式入门者、安卓开发人员及需要构建蓝牙上位机的工程师参考。工程完整保留了安卓开发项目结构,包含接口逻辑代码、界面布局、构建脚本、图标资源,还附带已编译的安装包、运行库与中间文件,可直接安装到手机验证,也可导入开发工具继续修改,降低二次开发门槛。压缩包共有一千一百零三个文件,大小约二十一兆字节,源码、资源与构建产物分层存放,核心模块容易定位。当前已有二百一十九人学习下载,通过对照代码与可执行程序学习,能够梳理蓝牙权限申请、设备搜索、配对连接、串口收发、界面刷新等关键实现思路,并可作为课程设计或毕业设计的完整参考项目。

1. 先拆开这个"MyBluetooth.rar":STM32+Android+上位机到底在做什么

拿到这种命名混乱的压缩包,第一反应别急着解压找app。标题里的MyBluetoothSTM32+appandroid上位这几个关键词,已经暴露了这个项目的完整链路:STM32通过串口外接一个蓝牙透传模块(常见HC-05/HC-06),Android手机或PC上位机通过蓝牙串口与板卡通信。绝大多数这类工程包里的代码质量参差不齐,但核心思路都一样:MCU把采集到的数据打包成自定义帧,经过蓝牙透传,手机端解析后显示或下发控制指令。这篇博文就顺着这条链路,把STM32端、Android端、上位机解析三部分的可复现步骤和常见坑讲清楚,最后聊几个让通讯稳定上线的习惯。适合正在做STM32蓝牙项目、想快速把下位机到手机或上位机数据通路跑通的人。

2. STM32端蓝牙通讯的底层配置:从串口到透传模块

2.1 为什么用UART接蓝牙而不是直接用协议栈

STM32本身没有蓝牙控制器,也不可能在裸机工程里直接跑BLE协议栈,成本最低的方案就是外接一个串口转蓝牙的透传模块。HC-05和HC-06是最常见的两种:前者支持主从模式切换、可用AT指令配置,后者只能做从机,但便宜、稳定。整个通讯原理是:STM32把要发送的数据通过UART发送给蓝牙模块,模块内部完成射频收发;接收方向则反过来,手机发送的数据经过蓝牙模块变成串口数据,再灌进STM32的UART接收引脚。应用层完全感受不到蓝牙的存在,写代码时就是操作串口。这样做的优势是开发周期短,缺点也明显:SPP在2.4GHz频段上容易受干扰,波特率一旦不匹配就是满屏乱码。做毕业设计或原型验证够用,做产品就得加校验和重传。

2.2 最小可用的串口初始化代码

下面这段是基于STM32标准外设库的UART2初始化,常用于连接HC-05。注意不是HAL库,老工程里更常见,新工程用CubeMX生成后逻辑也一样。

void USART2_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; // 使能GPIOA和USART2时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); // PA2 = TX,PA3 = RX,注意交叉连接 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 9600; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, &USART_InitStructure); USART_Cmd(USART2, ENABLE); }

这里有几个参数要重点看:波特率必须和蓝牙模块的串口波特率一致,HC-05出厂默认是9600,所以先用9600是最稳的。如果想提高到115200,必须先进入AT命令模式改模块配置,再改STM32这边的初始化。完成初始化后,发送一字节要等发送寄存器空,最简单写法是:

void UART2_SendByte(uint8_t data) { USART_SendData(USART2, data); while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET); }

不要把USART_SendDataUSART_GetFlagStatus调用顺序写反,否则丢字符。发送字符串就循环调这个函数。

2.3 接收数据要用中断加环形缓冲

轮询接收在裸机主循环里很危险,因为蓝牙数据是异步到达的,如果主循环忙于其它计算,串口溢出标志会置位,数据直接丢。我一般会在UART接收中断里把每个字节塞进环形缓冲区,主循环再按协议帧取出来解析。接收中断的写法:

void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART2); // bt_ring_write 是环形缓冲区写入函数 bt_ring_write(&bt_rx, ch); } }

使用中断前要在定时器或初始化函数里使能USART2中断并设置优先级:

USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure);

环形缓冲区的写法不需要很复杂,定两个数组下标就能实现。这样做的原因是蓝牙连续发送一串数据时,中断逐字节接收,如果不在中断里快速缓存,主循环的解析逻辑就会被字节间隔打断,导致帧切错。

2.4 用AT命令把HC-05配置成从机

模块的默认参数不一定适合你的项目,比如名称太通用、配对密码是1234。配置HC-05的关键是进入AT模式:在模块上电前把KEY引脚(有些板子标记为EN)拉高,然后通过USB-TTL按9600波特率发AT指令。常用的几条命令整理成表:

AT命令作用示例
AT测试通信是否正常返回OK
AT+NAME设置蓝牙广播名称AT+NAME=BT_Device
AT+PSWD设置配对密码AT+PSWD=1122
AT+UART设置串口波特率、停止位、校验位AT+UART=115200,0,0
AT+ROLE设置主从模式,0=从机,1=主机AT+ROLE=0

注意HC-05的AT指令必须以\r\n结尾,有些USB-TTL工具会自动加,手写时不要漏。HC-06不支持AT+ROLEAT+UART,但支持改名和改密码。改完AT+UART后,立刻按新波特率重新连接,否则收不到任何回复。STM32端接模块时,TX/RX交叉接,GND必须共地,VCC按模块标记供电,5V模块接5V,3.3V模块接3.3V,接错会烧。

3. Android端蓝牙通讯:从权限到收发

3.1 权限申请与蓝牙适配器初始化

Android开发中,蓝牙权限是动态申请的重灾区。Android 12(API 31)之后,你不能再只依赖AndroidManifest.xml里的BLUETOOTH权限,还必须申请BLUETOOTH_SCANBLUETOOTH_CONNECT两个运行时权限。写给老项目升级的朋友:如果目标SDK设置大于等于31,不申请新权限,getSocket().connect()会直接抛SecurityException。最小权限集合如下:

<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"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>

在Android 6到11之间,扫描蓝牙设备还需要定位权限,否则扫描列表永远为空。如果你是拿到别人的MyBluetooth项目源码,建议先检查清单文件里有没有这几行。

3.2 设备发现:不要漏掉被系统缓存的老配对

发现设备的代码网上到处都是,几个容易翻车的细节:发现过程大约12秒,必须在主线程注册BroadcastReceiver,然后在ACTION_FOUND里过滤EXTRA_DEVICE。如果目标设备之前配对过,它可能不会出现在扫描结果里,而是直接通过getBondedDevices()获取。所以建议先把已配对设备列表读出来,再叠加扫描结果,否则用户会发现明明断电重启了就是连不上。获取已配对设备:

Set<BluetoothDevice> pairedDevices = bluetoothAdapter.getBondedDevices();

判断HC-05是否在列表里,可以用device.getName()查找你的蓝牙名称。注意Android 12上,调用getBondedDevices()也需要BLUETOOTH_CONNECT权限,记得在调用前检查。

3.3 经典蓝牙SPP连接与UUID

HC-05走的是SPP协议,Android里对应的UUID是固定的00001101-0000-1000-8000-00805F9B34FB。这是蓝牙SDP服务表中串口服务的唯一标识,几乎所有蓝牙透传模块都用它。连接的核心代码:

UUID SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); BluetoothSocket socket = null; try { socket = device.createRfcommSocketToServiceRecord(SPP_UUID); // 在连接前停止扫描,因为扫描会拖慢连接 bluetoothAdapter.cancelDiscovery(); socket.connect(); } catch (IOException e) { // 连接失败,常见原因是设备不在附近、配对密码错误、UUID不对 e.printStackTrace(); }

必须放在子线程里执行,直接在UI线程调用connect()会抛NetworkOnMainThreadException或直接ANR。这里有个隐藏的兼容性问题:某些国产低端机对createRfcommSocketToServiceRecord支持不完整,连接失败时可以尝试反射方法createRfcommSocket

Method m = device.getClass().getMethod("createRfcommSocket", int.class); BluetoothSocket socket = (BluetoothSocket) m.invoke(device, 1);

这个技巧在好几种安卓平板上都验证过,能解决一部分连接超时的问题。

3.4 收发数据的读线程与写方法

连接成功之后,socket.getInputStream()socket.getOutputStream()分别是数据入口和出口。读数据是阻塞的,需要开一个Thread循环读,把读到的字节交给解析器。一个干净的读数据循环:

private void startReadLoop(final InputStream inputStream) { new Thread(new Runnable() { @Override public void run() { byte[] buffer = new byte[1024]; int bytes; while (true) { try { bytes = inputStream.read(buffer); if (bytes > 0) { // 这里把字节流交给协议解析函数 onDataReceived(buffer, bytes); } } catch (IOException e) { // 连接被断开,退出循环并通知主线程 break; } } } }).start(); }

read(buffer)会一直阻塞,直到有数据或socket断开。不要在这里做耗时解析,否则后续数据会堆积在底层缓冲区。解析应该放到另一个线程或使用队列。写数据就比较简单:

public void sendCommand(byte[] cmd) { if (socket != null && socket.isConnected()) { try { OutputStream out = socket.getOutputStream(); out.write(cmd); out.flush(); } catch (IOException e) { e.printStackTrace(); } } }

建议发送命令加一个同步锁,避免两个线程同时写导致字节交错。

3.5 Android端如何解析STM32发来的帧

STM32端如果只发hello这种字符串,那解析无所谓。但实际项目里要传温度、转速或开关状态,就必须定制协议。我常用一种简单的帧格式:帧头AA 55+ 数据长度LEN+ 命令字CMD+ 数据区DATA[N]+ 校验和SUM。校验和取前面所有字节累加的低8位。Android端的解析不能只indexOf(0xAA),因为数据区里也可能出现AA。正确做法是滑动窗口:

public class FrameParser { private static final int HEAD1 = 0xAA; private static final int HEAD2 = 0x55; private final byte[] buffer = new byte[256]; private int position = 0; public void pushBytes(byte[] data, int len) { for (int i = 0; i < len; i++) { buffer[position++] = data[i]; if (position >= 2 && (buffer[position-2] & 0xFF) == HEAD1 && (buffer[position-1] & 0xFF) == HEAD2) { // 找到帧头,等待后续长度字节 } if (position == 4) { int dataLen = buffer[3] & 0xFF; if (position == dataLen + 4) { // 收到完整帧,校验后处理 int sum = 0; for (int j = 0; j < dataLen + 3; j++) { sum = (sum + (buffer[j] & 0xFF)) & 0xFF; } if (sum == (buffer[dataLen + 3] & 0xFF)) { handleFrame(buffer, dataLen); } position = 0; } } } } }

这个状态机没有处理帧头后数据里再次出现AA 55的情况,实际开发中可以在帧头后加一个固定标识符,或者用更严格的长度校验。

4. 上位机数据解析与协议对齐

4.1 上位机与下位机的协议要先口头对齐

这里说的“上位机”可以是PC端C#/Python程序,也可以理解为手机App本身。不管用哪种,协议必须由STM32端和上位机端共同制定,而且长度、顺序、校验规则都要写成文档。很多人拿到STM32+app源码,发现STM32发的是AT+DATA=xxx,而App却按二进制解析,那就是协议没对齐的典型症状。一套能覆盖多数场景的协议表如下:

字段长度说明
帧头2字节0xAA 0x55
数据长度1字节从命令字到数据区的字节数
命令字1字节0x01读状态,0x02控制,0x03应答
数据区N字节按命令定义,比如温度高字节、低字节
校验和1字节前N+3字节累加和的低8位

这个表格必须在STM32代码和上位机代码里各写一份注释,否则日后改协议时两边不同步。

4.2 用Python快速验证下位机数据

做原型验证时,我常用Python的pyserial写一个临时上位机,比用C#、安卓快得多。假设蓝牙模块在PC上映射为串口COM5,波特率9600,读取一帧并打印:

import serial ser = serial.Serial('COM5', 9600, timeout=1) while True: data = ser.read(128) if len(data) > 0: hexs = data.hex() print(hexs) # 这里再加状态机解析

Python上位机的优势是写起来快,分享代码给同事也方便。缺点是不能做复杂的界面,但用来验证STM32发什么数据绰绰有余。如果你拿到的是C#上位机源码,想用VS2019打开,一般需要改一下项目文件里的TargetFramework,老版本是v4.0,新版可以改成v4.7.2net6.0-windows

4.3 C#上位机读取串口的最小实现

很多工业现场的上位机是C#写的,用SerialPort控件。核心是事件驱动而不是轮询,因为蓝牙数据到达时间不确定。最小代码:

SerialPort _sp = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One); _sp.DataReceived += Sp_DataReceived; _sp.Open(); private void Sp_DataReceived(object sender, SerialDataReceivedEventArgs e) { int bytesToRead = _sp.BytesToRead; byte[] buffer = new byte[bytesToRead]; _sp.Read(buffer, 0, bytesToRead); // 将buffer交给解析器,注意线程切换 }

DataReceived事件里直接处理字节流没问题,但如果要更新UI控件,必须通过InvokeBeginInvoke切换到UI线程,否则会抛跨线程异常。解析逻辑与上一章Android的状态机一致,只是语言换成C#。这里有个坑:蓝牙模块在Windows上的虚拟串口可能不返回DataReceived事件,尤其是在蓝牙驱动进入省电模式后。我遇到过只能读到前几帧、之后永久卡住的情况,解决方法是定时器主动查询_sp.BytesToRead,有数据才读,避免事件丢失。

4.4 时间戳与数据对齐

蓝牙链路里的数据不是同步的,STM32可能每100ms发一帧,但经过蓝牙缓冲、重传后,上位机收到的时间间隔完全不等。如果你一边看曲线一边做时间轴,直接用DateTime.Now记录会导致曲线横轴不算均匀但看着还行;真正做数据记录时,要使用帧里的计数器或时间戳字段,而不是上位机的本地时间。在STM32端可以每帧加一个16位的序号,从0递增,上位机检测到序号不连续就说明发生了丢帧,这时候可以提示用户重试或开启重传请求。

// STM32端发送前累加序号 uint16_t frame_seq = 0; void BuildFrame(uint8_t cmd, uint8_t* data, uint8_t len, uint8_t* tx_buf) { tx_buf[0] = 0xAA; tx_buf[1] = 0x55; tx_buf[2] = len + 1; // 命令+数据 tx_buf[3] = cmd; tx_buf[4] = frame_seq >> 8; // 序号高字节 tx_buf[5] = frame_seq & 0xFF; // 序号低字节 memcpy(&tx_buf[6], data, len); uint8_t sum = 0; for (int i = 0; i < 6 + len; i++) sum += tx_buf[i]; tx_buf[6 + len] = sum; frame_seq++; }

上位机端解析出序号后,如果发现跳变,就把对应帧丢弃或置为无效,而不是硬画在曲线上。这种处理方式能让数据看上去“断得更合理”,也方便排查是哪一段链路出问题。

5. 进阶技巧:把蓝牙通讯从“能通”变成“稳定”

5.1 增加心跳包与断线自动重连

蓝牙连接看起建立成功,但经常会因为模块进入休眠或手机蓝牙协议栈异常而静默断开。判断是否真的断开,最好的办法是定时发心跳包。STM32端每500ms发一个0xF0命令字的心跳帧,如AA 55 01 F0 00 0A。Android端在收到心跳帧后,才认为链路存活;如果超过3秒没有收到任何数据,就主动close()旧socket并重新执行连接逻辑。这里的关键是重连时不能直接重用旧BluetoothSocket,必须新建,因为旧socket的输入输出流已经失效。

5.2 数据包重发机制

蓝牙SPP没有内置的可靠传输确认,你在上位机发一条控制指令,STM32到底是收到没收到?我一般把需要可靠传输的命令设计成“请求-应答”模式:STM32收到命令后回一个同命令字的应答帧,上位机在1秒内没收到应答就自动重发最多3次。重发必须避免重复执行,比如控制电机转一圈,重发会导致多转。因此在命令参数里加序号,STM32端如果最近处理过的序号与当前相同就忽略。这个机制成本很低,但可以让项目从“演示正常”变成“长时间运行也不出错”。

5.3 用Logcat和串口日志双端对照

调试时不要只盯着一边。我在STM32端每个解析成功的帧后,用另一个串口打印原始十六进制到电脑;在Android端用Log.i("BT-AUX", hexStr)记录收到的字节。然后两边对照,看看是STM32没发对,还是Android解析错。如果STM32串口打印有数据但Android收不到,问题一定在蓝牙连接或波特率;如果Android收到了完整十六进制但界面没有变化,问题在解析逻辑,与蓝牙无关。logcat里可以用HEX格式过滤,adb logcat -s BT-AUX:*只打印蓝牙日志,避免刷屏。

5.4 验证通讯质量的三个指标

项目验收时,不要只拿“能连上”说事。建议统计以下三个指标:

指标计算方法合理范围
帧接收率成功解析帧数 / 收到帧头总次数99%以上
块误码率校验失败帧数 / 总帧数1%以下
断线次数运行期间socket异常关闭次数0

STM32可以每100帧统计一次校验错误,通过另一个串口输出;Android端则记录连接断开次数。如果误码率高于1%,先降低波特率试试,常见的蓝牙模块在115200下误码率明显高于9600。这个技巧能帮你快速定位是模块问题还是环境干扰问题,也能给老师或老板一个量化结论。

以上就是从MyBluetooth.rar这个压缩包延伸出来的整套实现路径。拿到源码先别急着烧录,按这个主线核对STM32波特率、HC-05/06的AT设置、Android的UUID、上位机的校验算法,四条线对齐后,整个链路的坑基本都被你踩过了。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 2:48:13

PanCheck网盘链接检测服务:Docker Compose部署与运维实践

做资源导航站那阵子&#xff0c;我每天最不想干的事就是打开一堆网盘分享链接逐个验证。后来我在 GitHub 上翻到 PanCheck 这个项目——一个专门做网盘链接检测的自建服务&#xff0c;直接打算用容器化部署到自己服务器上&#xff0c;从此链接巡检基本没再手动碰过。这篇东西就…

作者头像 李华
网站建设 2026/9/16 2:48:12

Oracle SESSIONS_PER_USER 详解:会话并发限制配置与踩坑实战

第一次遇到 ORA-02391 这个报错&#xff0c;是很多年前在客户现场排查一套财务系统的时候。当时开发那边反馈业务突然大面积报错&#xff0c;登录用户集体掉线&#xff0c;我拉了一下v$session&#xff0c;发现某个业务账号的会话数已经冲到两百多&#xff0c;直接把实例的 SES…

作者头像 李华
网站建设 2026/9/16 2:48:09

PostgreSQL 18实战:Pgpool-II负载均衡配置与生产避坑指南

做PostgreSQL生产环境运维的同学&#xff0c;大概率迟早会碰上一个场景&#xff1a;业务量上来之后&#xff0c;读请求把主库CPU打到90%以上&#xff0c;慢查询一条接一条&#xff0c;监控告警响个不停。这时候很多人第一反应是加内存、加SSD&#xff0c;但硬件堆完没几天&…

作者头像 李华
网站建设 2026/9/16 2:47:59

数据结构与算法全攻略:从入门到进阶与面试实战

先聊点实在的&#xff1a;数据结构与算法&#xff0c;几乎每个计算机相关专业的人都会被它拦一道&#xff0c;面试前突击背题、考试前熬夜刷卷、刷题平台上“Easy”到“Hard”的挫败感&#xff0c;这些我都经历过。作为一个毕业后从纯业务开发摸爬滚打、后来靠“补课式刷题”拿…

作者头像 李华
网站建设 2026/9/16 2:47:12

Sentry+Flutter跨端崩溃监控实战:从接入到鸿蒙适配全指南

做 Flutter 项目最难受的时刻&#xff0c;不是功能赶不上版本&#xff0c;而是线上崩溃了你却不知道为什么。用户那边一句话“打开就闪退”&#xff0c;你这边无论如何复现不了&#xff0c;日志库里也没有历史记录&#xff0c;只能靠猜。等跨端场景再加上鸿蒙&#xff0c;问题会…

作者头像 李华
网站建设 2026/9/16 2:46:57

CAD图库添加自定义图形全攻略:从设计中心到工具选项板

干施工图这几年&#xff0c;我见过太多人把时间浪费在重复画同一个构件上。门窗、洁具、家具、设备符号&#xff0c;画完一次就丢&#xff0c;下次换个项目又重新画。真正会画图的人&#xff0c;一定会把那些“重复出现的第三遍”沉淀下来&#xff0c;做成自定义图形放进CAD建筑…

作者头像 李华