☰
Android蓝牙串口调试助手源码解析:经典蓝牙SPP协议从连接到真机调试
2026/10/15 13:57:31 网站建设 项目流程

简介:这是一份面向Android蓝牙通信开发者的串口调试助手源码,适合需要测试蓝牙设备、验证串口数据传输的开发者与学习者使用。源码围绕BluetoothAdapter、BluetoothSocket与RFCOMM通道展开,涵盖设备扫描、配对连接、数据收发、连接状态监听等核心流程,并涉及Handler异步处理、权限声明与Logcat调试等实践要点,对理解Android蓝牙通信机制有较高参考价值。资源包共38个文件,包含17个class、8个xml、4个java及apk、dex等,涵盖源码、布局、资源与可运行安装包,整体约80KB,结构紧凑便于快速导入分析。目前已有1446人学习下载。通过阅读与修改源码,读者可掌握蓝牙连接建立、数据交换与异常处理思路,并可按需扩展加密传输、连接稳定性优化或更多蓝牙协议支持,为自身应用添加蓝牙功能提供可复用的基础模板。

1. 蓝牙串口调试助手:从源码到真机跑通的完整路径

做嵌入式或者硬件联调的人,手里大概率都备着一个蓝牙串口调试工具。手机端打开 App,连上 HC-05、HC-06 这类经典蓝牙模块,直接收发十六进制指令,比抱着电脑插 USB 转 TTL 方便太多。但市面上现成的调试助手要么广告多,要么不支持自定义波特率和数据位,要么连上就断。这份 Android 蓝牙串口调试助手源码,解决的就是「我想自己改一个趁手工具」的问题——它基于 Android 经典蓝牙 SPP 协议实现,覆盖设备扫描、配对、连接、数据收发、十六进制显示与发送、日志导出等核心功能,适合有 Android 基础、需要跟蓝牙模块打交道的嵌入式工程师、硬件测试人员和物联网方向的学生。源码结构清晰,没有过度封装,拿到手就能编译运行,改起来也不费劲。

2. 经典蓝牙 SPP 协议:为什么不用 BLE,以及源码里的连接链路

2.1 SPP 与 BLE 的选型分水岭

很多人一上来就问「为什么不用 BLE」,这个问题在蓝牙串口场景里几乎每次都要解释一遍。经典蓝牙(Bluetooth Classic)的 SPP(Serial Port Profile)本质上是在蓝牙链路上模拟一条串口,走的是 RFCOMM 协议,对上层应用来说就是「打开一个 Socket,往里写字节,从里读字节」。而 BLE(低功耗蓝牙)走的是 GATT 协议,数据以 Characteristic 为单位组织,收发都要经过 Service UUID、Characteristic UUID 的层层查找,还要处理 MTU 协商、Notify 开关、连接间隔等一堆参数。

对于 HC-05、HC-06、JDY-31 这类模块,它们默认跑的就是 SPP,你拿 BLE 的 API 去连根本连不上。反过来,如果你用的是 CC2541、nRF52 这类 BLE 模块,那 SPP 也帮不了你。所以选型的第一刀就切在这里:看你的蓝牙模块是经典蓝牙还是 BLE。这份源码走的是经典蓝牙路线,对应的就是那些「配对码 1234 或 0000」的老牌串口模块。

源码里连接链路的核心逻辑在BluetoothChatService这个类里,它继承自Thread,内部维护了一个BluetoothSocket。连接过程分三步:先通过BluetoothAdapter.getRemoteDevice(address)拿到远端设备对象,再调用createRfcommSocketToServiceRecord(SPP_UUID)创建 Socket,最后socket.connect()发起连接。这里的SPP_UUID是固定的00001101-0000-1000-8000-00805F9B34FB,所有经典蓝牙串口设备都用这个 UUID,不需要改。

// BluetoothChatService.java 中的连接线程核心片段 private class ConnectThread extends Thread { private final BluetoothSocket mmSocket; private final BluetoothDevice mmDevice; public ConnectThread(BluetoothDevice device) { mmDevice = device; BluetoothSocket tmp = null; try { // SPP UUID 固定,所有经典蓝牙串口设备通用 tmp = device.createRfcommSocketToServiceRecord(MY_UUID); } catch (IOException e) { Log.e(TAG, "Socket 创建失败", e); } mmSocket = tmp; } public void run() { // 连接前必须取消扫描,否则会拖慢甚至阻塞连接 mAdapter.cancelDiscovery(); try { mmSocket.connect(); } catch (IOException connectException) { try { mmSocket.close(); } catch (IOException closeException) { Log.e(TAG, "Socket 关闭异常", closeException); } return; } // 连接成功,交给 ConnectedThread 管理数据流 synchronized (BluetoothChatService.this) { mConnectThread = null; } connected(mmSocket, mmDevice); } }

这段代码里有两个参数值得注意。MY_UUID就是上面说的 SPP UUID,不要改成 BLE 的 UUID,否则createRfcommSocketToServiceRecord会直接抛异常。mAdapter.cancelDiscovery()这一行是血泪经验——如果不取消扫描就发起连接,在部分机型上连接成功率会明显下降,因为扫描和连接共用射频通道,互相抢资源。

2.2 数据收发线程与流式读写

连接建立之后,ConnectedThread接管数据流。它从BluetoothSocket拿到InputStream和OutputStream,然后在一个while(true)循环里阻塞读取。读到的字节通过Handler发回 UI 线程,更新到接收区的TextView或RecyclerView里。

// ConnectedThread 中的数据读取循环 public void run() { byte[] buffer = new byte[1024]; int bytes; while (true) { try { // 阻塞读取,直到有数据到达或连接断开 bytes = mmInStream.read(buffer); // 将读到的字节数组通过 Handler 发回主线程 mHandler.obtainMessage(MESSAGE_READ, bytes, -1, buffer) .sendToTarget(); } catch (IOException e) { Log.e(TAG, "连接断开", e); connectionLost(); break; } } } // 写入方法,供 UI 线程调用 public void write(byte[] buffer) { try { mmOutStream.write(buffer); // 把发送的数据也回显到界面 mHandler.obtainMessage(MESSAGE_WRITE, -1, -1, buffer) .sendToTarget(); } catch (IOException e) { Log.e(TAG, "写入失败", e); } }

缓冲区大小设的是 1024 字节,对于串口调试场景够用了。如果你要传大块数据,比如固件升级包,可以调到 4096 甚至 8192,但要注意部分低端模块的射频缓冲区有限,一次写太多会丢包。写入方法里把发送数据也回显到界面,这是调试助手的标配——你得看到自己发了什么,才能对照设备返回判断问题。

2.3 权限声明与运行时申请

Android 6.0 之后,蓝牙权限从安装时授予变成了运行时申请。源码的AndroidManifest.xml里声明了BLUETOOTH、BLUETOOTH_ADMIN、ACCESS_FINE_LOCATION三个权限。前两个是普通权限,安装即授予;ACCESS_FINE_LOCATION是危险权限,必须动态申请,因为蓝牙扫描可以推断用户位置。

<!-- AndroidManifest.xml 中的蓝牙权限声明 --> <uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <!-- Android 6.0 起扫描蓝牙设备需要定位权限 --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <!-- Android 12 起新增的蓝牙权限,按需添加 --> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

如果你的目标机型是 Android 12 及以上,BLUETOOTH_SCAN和BLUETOOTH_CONNECT必须加上,否则扫描和连接会直接失败。neverForLocation这个标志告诉系统「我扫描蓝牙不是为了定位」,可以避免申请定位权限,但前提是你的 App 确实不拿扫描结果推断位置。源码里保留了定位权限的申请逻辑,兼容老版本。

3. 从源码到真机:编译、配对、收发指令的完整操作链

3.1 导入工程与 Gradle 配置调整

拿到源码后,第一步是用 Android Studio 打开工程根目录。源码的build.gradle里compileSdkVersion和targetSdkVersion可能比较旧,比如 28 或 29。如果你用的是新版 Android Studio,Gradle 插件版本需要同步升级,否则会报「Gradle 版本不兼容」。

// app/build.gradle 中需要关注的配置项 android { compileSdkVersion 33 // 建议升到 33 或更高 defaultConfig { minSdkVersion 21 // 最低支持 Android 5.0 targetSdkVersion 33 // 目标版本,影响权限行为 // ... } } dependencies { // 源码可能用了 support 库,建议迁移到 AndroidX implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.recyclerview:recyclerview:1.3.0' }

minSdkVersion设 21 是合理的,再低会缺少一些蓝牙 API。targetSdkVersion建议跟compileSdkVersion保持一致,否则 Android 12 以上的权限行为会变得很诡异。如果源码用的是android.support.v7老库,在 Android Studio 里右键工程 → Refactor → Migrate to AndroidX 一键迁移,省得手动改包名。

3.2 真机配对与连接的操作顺序

编译通过后,装到手机上,操作顺序有讲究。先把蓝牙模块上电,确认指示灯在闪烁(说明未配对)。然后打开手机系统设置 → 蓝牙,搜索到模块名称(通常是 HC-05 或类似),点击配对,输入配对码 1234 或 0000。配对成功后,模块指示灯变成慢闪或常亮。

这时候再打开调试助手 App,点击「扫描设备」,在列表里找到已经配对的模块,点击连接。注意:不要在 App 里直接配对,因为 App 内的配对流程在部分机型上会弹不出配对框,先走系统设置配对更稳妥。连接成功后,App 界面上的连接状态会变成「已连接」,接收区开始等待数据。

3.3 十六进制收发与常用 AT 指令验证

调试助手的核心功能是十六进制收发。源码里有一个HexUtil工具类,负责字符串和字节数组之间的转换。发送区输入01 03 00 00 00 01,勾选「十六进制发送」,点击发送按钮,底层会把空格去掉、每两个字符解析成一个字节,然后通过OutputStream写出去。

// HexUtil.java 中的十六进制字符串转字节数组 public static byte[] hexStringToBytes(String hexString) { if (hexString == null || hexString.equals("")) { return null; } // 去掉空格和换行,统一大写 hexString = hexString.replaceAll(" ", "").toUpperCase(); int length = hexString.length() / 2; char[] hexChars = hexString.toCharArray(); byte[] d = new byte[length]; for (int i = 0; i < length; i++) { int pos = i * 2; // 高四位和低四位分别解析后合并 d[i] = (byte) (charToByte(hexChars[pos]) << 4 | charToByte(hexChars[pos + 1])); } return d; } private static byte charToByte(char c) { return (byte) "0123456789ABCDEF".indexOf(c); }

这个转换逻辑很直白,但有一个坑:如果输入的十六进制字符串长度是奇数,比如1 03,length算出来是 0,直接返回空数组。所以发送前要保证每个字节都是两位,不够的补零。接收方向同理,bytesToHexString把字节数组转成带空格的十六进制字符串显示,方便肉眼比对。

验证连接是否正常,最常用的方法是发 AT 指令。对于 HC-05,发送AT\r\n(十六进制是41 54 0D 0A),如果模块返回OK,说明链路通了。注意 AT 指令末尾的\r\n不能省,很多新手只发AT两个字符,然后纳闷为什么没反应。

3.4 日志导出与数据回放

源码里还带了一个简单的日志导出功能,把接收区的数据按时间戳写入文件,存到Environment.getExternalStorageDirectory()下的某个目录里。这个功能在长时间跑数据采集时很有用——你不可能一直盯着屏幕,导出日志后用脚本分析更靠谱。

// 日志写入的简化逻辑 public void saveLog(String data) { File logFile = new File(Environment.getExternalStorageDirectory(), "BtSerialLog_" + System.currentTimeMillis() + ".txt"); try { FileWriter writer = new FileWriter(logFile, true); // 每行前面加时间戳,方便回放时对齐 writer.write(new SimpleDateFormat("HH:mm:ss.SSS").format(new Date()) + " " + data + "\n"); writer.close(); } catch (IOException e) { Log.e(TAG, "日志写入失败", e); } }

Android 10 之后,getExternalStorageDirectory()的行为变了,需要改用getExternalFilesDir()或者申请MANAGE_EXTERNAL_STORAGE权限。源码如果没处理这个,在 Android 10 以上的机器上导出会失败。常见做法是改成getExternalFilesDir(null),路径变成Android/data/包名/files/,不需要额外权限。

4. 避坑与排查:连接失败、乱码、断连的五个真实翻车现场

4.1 扫描不到设备,列表一直是空的

现象:打开 App 点击扫描,等了十几秒列表里一个设备都没有,但系统蓝牙设置里能看到模块。

原因:最常见的是定位权限没给。Android 6.0 到 Android 11 之间,扫描蓝牙设备必须要有ACCESS_FINE_LOCATION权限,而且用户必须手动在设置里打开定位开关(不是权限,是系统级的定位总开关)。另一个原因是BLUETOOTH_SCAN权限在 Android 12 以上没声明。

解决:检查AndroidManifest.xml里的权限声明,然后在 App 首次启动时动态申请定位权限。如果用户拒绝了,弹一个对话框解释为什么需要,引导去设置页手动开启。代码里可以用ActivityCompat.checkSelfPermission判断,用ActivityCompat.requestPermissions申请。

4.2 连接时抛出Service discovery failed

现象:点击设备连接,日志里报java.io.IOException: Service discovery failed,连接直接断开。

原因:这个异常通常意味着 SPP UUID 不对,或者模块根本不支持 SPP。有些 BLE 模块虽然名字里带「串口」,但走的是 GATT,用createRfcommSocketToServiceRecord连不上。另一个可能是模块已经被其他设备连接了,经典蓝牙 SPP 不支持多主连接。

解决:先确认模块型号,查手册看是经典蓝牙还是 BLE。如果是经典蓝牙,检查 UUID 是不是00001101-0000-1000-8000-00805F9B34FB。如果模块已被其他设备占用,断开那个设备的连接再试。还有一个偏方:调用createInsecureRfcommSocketToServiceRecord代替安全版本,绕过配对验证,但这样连接不安全,只适合调试。

4.3 接收到的数据是乱码

现象:设备明明返回的是 ASCII 字符串,但接收区显示一堆问号或者方块。

原因:编码格式不匹配。源码默认按 UTF-8 解码,但有些模块返回的是 GBK 编码,或者你勾选了「十六进制显示」但设备发的是 ASCII。反过来,设备发的是二进制数据,你没勾十六进制,按文本解码自然乱码。

解决:先确认设备输出的是文本还是二进制。文本的话,在源码里把解码字符集改成GBK试试;二进制的话,勾选「十六进制显示」,直接看字节。源码里new String(buffer, 0, bytes, "UTF-8")这一行就是解码入口,改字符集就在这里改。

4.4 连接一段时间后自动断开

现象:连上后能正常收发,但过几分钟或者手机息屏后,连接就断了,重新打开 App 才能连上。

原因:Android 的电源管理会在息屏后限制后台网络活动,蓝牙 Socket 虽然不像 TCP 那样容易被杀,但部分机型(尤其是国产 ROM)会主动断开长时间空闲的蓝牙连接。另外,如果ConnectedThread的读取循环里抛了异常但没有正确处理,线程退出也会导致连接丢失。

解决:在ConnectedThread的catch块里加断线重连逻辑,或者至少发一个通知告诉用户连接断了。对于息屏断连,可以申请WAKE_LOCK权限,在连接期间持有唤醒锁,但这样费电。更温和的做法是让设备端定期发心跳包,保持链路活跃。

4.5 Android 12 以上扫描直接崩溃

现象:在 Android 12 或 13 的手机上,点击扫描按钮,App 直接闪退,日志里报SecurityException。

原因:Android 12 引入了新的蓝牙权限模型,BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE三个权限必须动态申请。如果只在 Manifest 里声明了旧权限,运行时检查会直接抛异常。

解决:在AndroidManifest.xml里加上BLUETOOTH_SCAN和BLUETOOTH_CONNECT,并在代码里判断系统版本,如果是 Android 12 及以上,申请这两个新权限;否则申请旧的ACCESS_FINE_LOCATION。源码如果没做这个兼容,需要手动补上。

// 按系统版本申请不同权限的兼容写法 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12 及以上 requestPermissions(new String[]{ Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT }, REQUEST_CODE); } else { // Android 11 及以下 requestPermissions(new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, REQUEST_CODE); }

5. 进阶改造:自定义协议解析与多设备切换的实用技巧

源码跑通之后,大多数人不会满足于「能收发就行」。实际项目里,你面对的是带帧头、长度、校验位的自定义协议,接收区里一堆十六进制数字,靠肉眼找帧头效率太低。我一般会在ConnectedThread的读取循环里加一个环形缓冲区,按协议格式自动切分数据帧,解析出有效载荷再显示。

// 简易环形缓冲区,按帧头 0xAA 0x55 切分数据帧 private ByteArrayOutputStream frameBuffer = new ByteArrayOutputStream(); private void parseFrame(byte[] data, int length) { for (int i = 0; i < length; i++) { frameBuffer.write(data[i]); byte[] buf = frameBuffer.toByteArray(); // 检查是否以帧头开头 if (buf.length >= 2 && (buf[0] & 0xFF) == 0xAA && (buf[1] & 0xFF) == 0x55) { // 帧头正确,继续等长度字段 if (buf.length >= 4) { int payloadLen = buf[2] & 0xFF; int frameLen = 4 + payloadLen + 1; // 帧头2 + 长度1 + 载荷 + 校验1 if (buf.length >= frameLen) { // 完整帧到达,提取载荷 byte[] payload = new byte[payloadLen]; System.arraycopy(buf, 3, payload, 0, payloadLen); // 校验和验证 byte checksum = 0; for (byte b : payload) checksum ^= b; if (checksum == buf[frameLen - 1]) { // 校验通过,发到 UI 显示 mHandler.obtainMessage(MESSAGE_FRAME, payload).sendToTarget(); } // 移除已处理的帧,保留剩余数据 frameBuffer.reset(); frameBuffer.write(buf, frameLen, buf.length - frameLen); } } } else if (buf.length > 0 && (buf[0] & 0xFF) != 0xAA) { // 首字节不是帧头,丢弃 frameBuffer.reset(); } } }

这段代码的关键在于:不要假设一次read就能读到完整帧。蓝牙串口的传输是流式的,一帧数据可能分两次到达,也可能两帧粘在一起。环形缓冲区的作用就是攒够一帧再解析,攒不够就继续等。校验和用异或是最简单的,实际项目里可能是 CRC16,把校验逻辑换掉就行。

另一个实用改造是多设备切换。源码默认只维护一个BluetoothSocket,连新设备前必须先断开旧的。如果你需要在多个模块之间轮询,可以维护一个Map<String, BluetoothSocket>,按 MAC 地址索引,切换时只切换ConnectedThread的读写目标。但要注意,经典蓝牙同时只能维持有限个连接,一般不超过 7 个,实际用起来 2 到 3 个就差不多了。

最后说一个我踩过的坑:不要在onDestroy里只调socket.close()就完事。ConnectedThread的while(true)循环如果还在阻塞读,close()之后read()会抛IOException,线程才退出。如果你先cancel()线程再close(),顺序反了可能导致 Socket 泄漏。正确的顺序是:先cancel()中断线程,再close()释放 Socket,最后把引用置空。从那以后我每次改蓝牙连接逻辑,都强制走一遍「连接 → 收发 → 断开 → 重连」的完整流程,确认没有残留线程和 Socket 才提交代码。希望帮到你。

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

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

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

立即咨询