资讯详情

资讯详情

STM32蓝牙与Android上位机开发实战:SPP通讯协议与数据帧解析

简介一套基于STM32与Android的蓝牙通讯完整工程面向嵌入式开发者和安卓初学者解决单片机与手机之间无线数据交互的问题。包内包含STM32端蓝牙模块的通讯代码以及Android Studio构建的APP上位机源码适合用在实际项目教学或毕业设计中。包体共1103个文件总大小约21.05MB。以Java和XML为主呈现Android工程结构与界面布局另有gradle/Gradle构建脚本、APK安装包和Dex/Class编译产物可直接安装或二次编译PNG等资源文件与JSON配置则用于UI和工程配置整体目录结构完整、层级清楚。目前已有219人学习浏览可作为STM32蓝牙通信调试的参考。通过源码可快速掌握串口蓝牙数据收发、Android端蓝牙权限管理、设备扫描与连接等关键流程并可直接复用APP界面与协议设计减少从零搭建的工作量。1. 为什么STM32蓝牙方案里Android上位机要自己写做嵌入式的人大多遇到过这类尴尬STM32代码调通了蓝牙模块也连上了最后却在手机端卡住。用现成的蓝牙串口助手只能看到十六进制数连按几个按钮、显示曲线、设置参数这些最基本的需求都没法满足。这个资源包我拆过一遍里面是完整的Android Studio工程带两份APK配合STM32端做蓝牙通讯正好把“手机当上位机”这条路走通了。它适合两类人一是想快速给STM32项目配一个定制化手机控制界面的嵌入式工程师二是刚接触Android蓝牙开发、想找一个真实SPP通讯案例的开发者。下面从协议到代码按实际开发顺序一条条捋。2. 蓝牙通讯协议与数据帧设计先把“说人话”的格式定下来2.1 经典蓝牙SPP还是BLE选型依据STM32蓝牙通讯通常会遇到两种选择经典蓝牙SPP和低功耗BLE。很多新手看到BLE省电就选BLE但对于做上位机控制SPP有不可替代的优势SPP模拟串口STM32那端只要把蓝牙模块当普通串口设备用硬件串口发什么手机端就收什么不需要像BLE那样处理GATT服务、特征值、MTU大小等一大堆抽象概念。这个项目里从资源包内文件命名和代码痕迹看使用的是经典蓝牙SPP方案。最直接的证据是Android端通过BluetoothSocket创建RFCOMM通道UUID固定为“00001101-0000-1000-8000-00805F9B34FB”这是SPP的标准UUID。BLE使用GATT回调而SPP使用传统Socket流这对从串口思维转过来的工程师非常友好。2.2 数据帧结构定义无论协议多简单都要先定义帧格式。不要在代码里直接写裸的“发送0x01”这类散数据否则后续加功能会极其痛苦。这里我按这个项目常见的做法给出一个通用帧结构字节位置字段名长度说明0帧头11固定0xAA1帧头21固定0x552命令字10x01查询状态0x02设置参数0x03控制输出3数据长度1后续数据字节数N4 ~ 4N-1数据域N按命令字解析4N校验和1前面所有字节累加取低八位帧头用0xAA55可以大幅降低误同步概率因为普通数据里连续出现这两个值的概率较低。命令字分几类可以让手机端switch语句非常清晰。长度字段用于应对粘包这会在后面章节详细说。2.3 上位机封包与解包代码Android端封装一个发送方法典型代码如下public byte[] buildFrame(byte cmd, byte[] payload) { int len (payload null) ? 0 : payload.length; byte[] frame new byte[4 len 1]; frame[0] (byte) 0xAA; frame[1] (byte) 0x55; frame[2] cmd; frame[3] (byte) len; if (len 0) { System.arraycopy(payload, 0, frame, 4, len); } byte checksum 0; for (int i 0; i 4 len; i) { checksum frame[i]; } frame[4 len] checksum; return frame; }这段代码先计算数据域长度然后填充帧头、命令字和长度最后把从帧头到数据域的所有字节累加得到校验和。注意校验和覆盖面必须包含命令字和长度否则长度字段被干扰时无法识别。解包时建议不要用循环挨个判断而是设计一个状态机按“找帧头0xAA55→读长度→累计接收→校验”的顺序推进这样可以天然对抗粘包。private static final int STATE_HEAD1 0; private static final int STATE_HEAD2 1; private static final int STATE_CMD 2; private static final int STATE_LEN 3; private static final int STATE_DATA 4; private static final int STATE_CHECK 5;这个项目的Android端就是按这种状态机实现的每个状态只干一件事调试时加log定位到具体状态比一坨while循环好排查得多。状态机不是炫技而是因为蓝牙串口是数据流没有“一条条消息”的天然边界靠这种逐步转移的方式才能精确切分出每一帧。3. Android Studio APP 实现从蓝牙连接到收发线程3.1 工程结构与Gradle构建入口资源包里的文件清单很典型gradlew.bat是Windows下Gradle构建脚本app-debug.apk是带调试信息的包app_c.apk通常是另一个变体或签名版本。resources-debug.ap_是从AAPT阶段生成的中间资源文件说明打包过程完整走过。拿到这个包后在Android Studio里直接Open工程等待Gradle同步完就能构建出APK。文件/目录作用app/src/main/javaJava源码主逻辑所在app/src/main/res布局、字符串、图标gradlew.bat命令行构建入口app-debug.apkDebug签名APKapp_c.apk另一个应用变体常用于客户演示版3.2 权限声明与蓝牙适配器初始化Android 6.0以上运行时权限是个坎除了在Manifest里声明还要在MainActivity中动态请求。Manifest里至少要这些uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION /BLUETOOTH用于常规操作BLUETOOTH_ADMIN用于扫描和修改蓝牙设置ACCESS_FINE_LOCATION是Android 6.0以后扫描设备必须的位置权限因为蓝牙扫描被系统视为定位行为。这个位置权限漏掉时startDiscovery()会直接返回false而且不报异常排查起来很坑。初始化适配器的标准写法BluetoothAdapter adapter BluetoothAdapter.getDefaultAdapter(); if (adapter null) { // 设备不支持蓝牙 } else if (!adapter.isEnabled()) { startActivityForResult(new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), 1); }获取到适配器后通过startDiscovery()扫描周围设备在BroadcastReceiver里接收ACTION_FOUND广播读取设备名和MAC地址。注意扫描到的设备往往很多一定要先按名称过滤掉无关音箱、手环再用BluetoothDevice.createBond()触发配对。3.3 连接线程与数据接收SPP连接建议放在子线程不能卡主UI线程。常见做法是用一个单独线程执行connect()成功后再开启读线程。UUID要固定写成常量private static final UUID SPP_UUID UUID.fromString(00001101-0000-1000-8000-00805F9B34FB); public void connect(BluetoothDevice device) throws IOException { BluetoothSocket socket device.createRfcommSocketToServiceRecord(SPP_UUID); socket.connect(); inputStream socket.getInputStream(); outputStream socket.getOutputStream(); }这里有一个容易踩的坑socket.connect()在某些设备上会阻塞很久如果手机蓝牙栈异常会一直卡住。我一般会包一层FutureTask实现超时控制比如8秒连接失败就取消同时回调UI提示“连接超时”。接收数据用循环读取注意InputStream.read()每次读一个字节必须放入队列或状态机不能直接扔给主线程更新TextView否则频繁刷新会导致界面卡顿。常用做法是用Handler把解析后的结果发回主线程并使用ListView或RecyclerView展示历史帧。3.4 连接释放与异常处理蓝牙连接结束后务必关闭输出流、输入流和socket否则下次连接会报socket closed或read failed错误。释放顺序先关流再关socket并且把相关的流对象置空避免重复关闭触发IOException。手机端页面销毁时也要主动断开不能只依赖系统回收。4. STM32 端蓝牙模块配置与联调AT指令与串口对接4.1 HC-05/HC-06的AT指令配置市面上最常见的STM32蓝牙模块是HC-05和HC-06。HC-05支持主从模式HC-06只支持从机。作为手机上位机的接收端HC-05从机模式足够。模块上电前按住按键再上电进入AT指令模式串口配置为9600 8N1发送AT指令测试。AT指令作用示例AT测试通信返回OKATNAME设置设备名ATNAMEMySTM32ATPSWD设置配对密码ATPSWD8888ATROLE设置主从角色ATROLE0从机ATUART设置串口波特率ATUART115200,0,0注意HC-05默认波特率是9600如果你把STM32串口设为115200就必须用ATUART指令改否则两边数据完全错乱。这个项目里STM32端串口配置一般会写成115200因为数据量较大时9600会明显感觉卡顿。4.2 STM32串口初始化与中断接收STM32端把蓝牙模块的TX/RX交叉连接到USART的RX/TX注意共地。初始化代码以标准库为例void Bluetooth_UART_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate 115200; 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(USART1, USART_InitStructure); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_Cmd(USART1, ENABLE); }这段配置把PA9设为TX推挽复用PA10设为RX浮空输入波特率1152008位数据位、1位停止位、无校验并使能接收中断。注意实际工程还要配置NVIC优先级分组并使能USART1_IRQn这里省略了。中断回调里用状态机接收帧与Android端形成对称结构void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); parse_byte(data); } }4.3 手机与STM32的数据握手联调时最容易犯的错是波特率不一致。你先用串口调试助手确认STM32发出的数据能正确显示再连蓝牙。手机端连上蓝牙后发一帧0xAA 0x55 0x01 0x00 0x00STM32收到后返回状态帧这就算握手成功。很多模块默认加密手机首次配对会要求输PIN码一般是1234或8888如果总是配对失败检查模块ATPSWD是否被改过。5. 数据校验、粘包处理与真实设备调优技巧5.1 CRC校验还是累加和第二章使用的是累加和校验简单快速但抗干扰能力有限。真实工业现场建议升级为CRC16-CCITT。STM32硬件CRC单元可以加速Android端用查表法实现。两种校验的选择依据是数据长度超过16字节、单帧重发成本高时必须CRC“短帧频繁交互”场景累加和够用。private static int crc16(byte[] data) { int crc 0xFFFF; for (byte b : data) { crc ^ (b 0xFF); for (int i 0; i 8; i) { crc (crc 1) ! 0 ? (crc 1) ^ 0x8408 : crc 1; } } return crc; }这是CRC16-CCITT的查表算法优化版乘法改成移位异或在Android低端机上也不会卡顿。与累加和相比两个连续字节同时翻转也能大概率检测出来适合传输温湿度、电压这些对可靠度要求高的数据。5.2 粘包与半包状态机很多开发者把“收到数据”和“完整一条消息”混为一谈。蓝牙串口是流不是消息边界手机端和STM32端都可能一次收到多个帧也可能一帧被拆成两三次到达。解析必须维护状态变量不能read()完直接处理。核心状态转换如下switch (state) { case STATE_HEAD1: if (data 0xAA) state STATE_HEAD2; break; case STATE_HEAD2: if (data 0x55) state STATE_CMD; else state STATE_HEAD1; // 重新同步 break; case STATE_CMD: cmd data; state STATE_LEN; break; case STATE_LEN: len data; buffer new byte[len]; count 0; state len 0 ? STATE_DATA : STATE_CHECK; break; case STATE_DATA: buffer[count] data; if (count len) state STATE_CHECK; break; case STATE_CHECK: if (checkSumValid(cmd, len, buffer, data)) { onFrameReceived(cmd, buffer); } state STATE_HEAD1; break; }状态机的好处是每一帧的边界都有迹可循。如果错误发生时能打日志看到状态停在哪个环节就能判断是帧头丢失还是数据长度不对。调这种问题最忌讳直接把接收数组全打印出来一大堆而应该把状态和关键字段打出来。5.3 双APK验证与日志定位资源包里同时存在app-debug.apk和app_c.apk我一般会保留一个带详细日志的Debug包和一个干净的演示包。联调时用Debug包在Android Studio的Logcat里过滤“BT”标签查看收发日志给客户演示时用干净包避免日志刷屏。真机调试的一个技巧先用“蓝牙调试器”这类工具验证STM32端没问题再切换到自己的App避免两头同时出问题无法定位。另外Android蓝牙连接后偶尔出现第一包数据丢失这多半是打开Socket后立即发送导致底层连接未稳定。我在connect()成功后固定加200ms延时再发首帧这个习惯救过很多次场。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →