简介:蓝牙BLE调试助手软件源码是一套基于安卓平台的蓝牙4.0调试工具完整工程,面向物联网开发者与蓝牙初学者,可快速实现BLE设备的扫描、连接、服务与特性值查看,以及读写操作,从而简化蓝牙开发中的协议交互与排错流程。压缩包为RAR格式,共52个文件,文件类型以Java源代码、class编译文件、XML界面配置、PNG图标和APK安装包为主,整体仅172KB,结构紧凑,适合直接导入安卓开发环境学习或二次开发。整套源码涵盖完整的安卓项目目录,包括工程配置文件、资源目录、源代码和生成文件,并附有Eclipse项目配置。开发者可借此深入理解BLE服务发现、特征读写、广播扫描等核心逻辑,并通过调试助手实际体验蓝牙4.0的低功耗通信机制。已有3959人学习或下载,是一份面向BLE入门与进阶的实用参考资料。 上个月我在调试一款新到的BLE温湿度计模块,第一反应是打开手机上的nRF Connect,扫描、连接、看服务特征、抓原始数据。前面几分钟一切正常,数据能出来,但紧接着要连续采样24小时、按模块私有协议自动解析温湿度帧、统计丢包率的时候,我发现自己被现有工具死死卡住了:每一条数据得手动复制,私有协议得自己对着十六进制数位慢慢拆,更别提做自动化的压力测试。那天晚上我下定决心,自己写一套BLE调试助手,把源码完全攥在自己手里。
这套源码不是给用户用的产品,而是给开发者用的工具。它的定位非常明确:帮你在硬件联调阶段完成扫描、连接、MTU协商、服务发现、数据收发、私有协议解析和日志回放。如果你也在做蓝牙BLE相关的硬件开发、嵌入式固件调试、或者App联调,下面这些内容应该能帮你省掉不少弯路。
1. 为什么放着现成的调试工具不用,非要自己写一套
1.1 现成工具的边界:能看数据,不能帮你理解数据
nRF Connect、LightBlue这类商业调试软件,本质上是一个通用的GATT客户端。它们做得很好的一点是,把BLE协议栈的扫描、连接、服务发现、读写这些底层能力全部封装成了可视化的UI,让开发者无需关心协议细节就能看到设备里有哪些Service、哪些Characteristic、数据长什么样。
但通用工具的代价就是"谁都照顾,谁都没照顾到位"。我在联调过程中遇到最典型的三类问题:
- 私有协议解析能力为零。很多硬件模块的数据帧是自己定义的,比如温湿度计常见的帧结构是
AA 55 + 长度 + 类型 + 温湿度数据 + CRC校验。nRF Connect只会把原始字节流扔给你,你需要自己在脑子里拆包、对齐、校验,数据一多就眼花。 - 自动化能力缺失。连续采样1000包数据做丢包率统计,或者按特定顺序写一组命令做产测,用现成工具几乎没法做,只能手工一条条点。
- 日志和导出格式不可定制。选中的数据没法按自己的格式导成CSV、没法加时间戳、没法生成带协议解析结果的报告。
这些需求在量产测试、硬件验收、固件升级验证阶段几乎必然出现。自己写源码的意义,不在于做一个"更好看的nRF Connect",而在于把整个调试链路变成可编程、可复用、可自动化的东西。
1.2 调试助手源码的本质:一个带私有协议解析能力的GATT客户端
想清楚这个定位之后,源码的架构就清晰了。BLE调试助手不是串口工具,它和SSCOM这类串口调试助手有本质区别:串口面对的是无结构的字节流,你只需要把数据发出去、收进来就行;但BLE面对的是一个有结构的属性交互模型,你需要处理扫描回调、连接状态机、服务发现结果、通知开关、MTU协商、分包粘包……然后才能拿到"看起来像串口"的数据流。
所以源码设计的第一原则是:把蓝牙协议栈交互层和业务解析层彻底分离。底层只负责和系统BLE API打交道,把连接状态、原始数据、RSSI等信息通过回调抛给上层;上层只负责根据具体设备协议做帧解析、命令构造、数据显示。这样换一块新的BLE模块,不需要动蓝牙交互代码,只需要在解析层加一套对应协议。
我自己在工程里是这样分模块的:
- BleScanner:扫描与广播过滤
- BleConnector:连接状态管理与MTU协商
- BleGattParser:Service/Characteristic/Descriptor解析
- BlePacketCodec:帧缓冲与私有协议编解码
- BleLogger:带时间戳的日志记录与回放
2. 协议栈里必须吃透的几个概念,否则源码写出来也是花架子
2.1 从广播到连接:GAP层藏着的坑
BLE的链路层管理由GAP(Generic Access Profile)负责。调试助手的第一个操作是扫描,而扫描的背后是"监听广播包"。广播包的结构是AD Structure序列,每个AD Structure包含三部分:长度、AD Type、AD Data。常见的AD Type有这么几个:
| AD Type | 值 | 含义 |
|---|---|---|
| Flags | 0x01 | 广播能力标志,如是否可连接、是否支持双模 |
| Complete Local Name | 0x09 | 完整的设备名称 |
| Shortened Local Name | 0x08 | 缩短的设备名称 |
| Tx Power Level | 0x0A | 发射功率,可用于距离估算 |
| Manufacturer Specific Data | 0xFF | 厂商自定义数据 |
调试助手的扫描模块里,最值得做的是按服务UUID过滤和按厂商数据过滤。比如一个设备广播时声明了自己包含0xFFF0这个Service,我们构造ScanFilter的时候就可以只发现这类设备,避免被周围十几个蓝牙音箱、体脂秤干扰。
实际的坑在于:Android从8.0开始对后台扫描做了严格限制,如果你的调试助手退到后台,扫描结果会变得非常不稳定。所以源码里扫描逻辑必须考虑前台服务或者WorkManager唤醒,不能简单在Activity的onStart里启动扫描就完事。另外,有些设备广播完就进入休眠,需要按一下板子上的按键才会重新广播,这一条在排查"扫不到设备"时首先要确认。
2.2 MTU与GATT:一包能发多少字节,由协商决定
很多从串口转入BLE开发的人会犯一个习惯性错误:以为writeCharacteristic一次能发几百个字节。实际上BLE在默认状态下,单次ATT有效载荷只有20字节。因为这个默认MTU是23字节,其中ATT协议头占3字节。
MTU协商是调试助手源码里不能省的一个步骤。requestMtu(247)发出后,实际上最终的MTU是"两端取最小值"。比如手机端请求247,但外设固件只支持127,那协商结果就是127,单包有效载荷是124字节。如果外设固件压根没实现MTU exchange,那请求会失败,必须回到20字节分包发送。
这里有个很容易被忽视的点:MTU协商是异步的,不能在发请求后立刻写大包数据。正确的做法是维护一个等待队列,接收到onMtuChanged回调后再通知上层"通道已就绪,可以开始发送"。我在源码里用了一个简单的状态机:
sealed class BleConnectionState { object Idle : BleConnectionState() object Connecting : BleConnectionState() object DiscoveringServices : BleConnectionState() object NegotiatingMtu : BleConnectionState() object Ready : BleConnectionState() // 只有这个状态允许大包收发 }只有状态机进入Ready后,UI上的"发送"按钮才可点。这个小细节能避免绝大多数"为什么发不出去"的困惑。
2.3 Service/Characteristic/Descriptor:BLE里的数据库表结构
GATT层的模型,用数据库来类比特别好理解:一个Service是一张表,Characteristic是表里的字段,Descriptor是字段的扩展属性。调试助手里最核心的交互就是读写某个Characteristic。
Characteristic有一个属性字段,声明了它支持的操作类型:Read、Write、Write No Response、Notify、Indicate。这里有一个经典坑:这个属性说的是"设备端支持什么",不代表"手机端直接写就完事"。对于Notify/Indicate,你必须在调用setCharacteristicNotification之后,再往它的CCCD描述符(UUID固定是0x2902)里写入0x0001或0x0002,设备才会真正开始推送数据。
这个CCCD写入步骤,是BLE联调中最高频的翻车点之一。很多新手发现"开启了通知但收不到任何数据",八成都是漏写了这一步。
3. Android端源码核心模块:扫描、连接、MTU协商与数据收发
3.1 权限与扫描配置
Android端的权限在API 31前后差异巨大。旧版本需要定位权限才能扫描到外设,而从Android 12开始官方引入了独立的BLUETOOTH_SCAN和BLUETOOTH_CONNECT运行时权限,不再需要定位权限。源码里必须按SDK版本做条件判断:
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) } else { arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) }扫描配置上,联调场景最常用的组合是:
val scanFilters = listOf( ScanFilter.Builder() .setServiceUuid(ParcelUuid.fromString("0000fff0-0000-1000-8000-00805f9b34fb")) .build() ) val scanSettings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build()SCAN_MODE_LOW_LATENCY会把扫描窗口调得比较勤,适合主动发现场景;如果是长时间后台监听,建议用SCAN_MODE_LOW_POWER,省电但对广播间隔长的设备可能漏报。
3.2 连接状态机与MTU协商
连接调用的核心是BluetoothDevice.connectGatt(context, autoConnect, gattCallback)。autoConnect这个参数非常值得说一下:true表示只要设备出现在范围内就自动重连,适合长期保持连接的应用;调试场景建议用false,连接失败会立刻回调,方便快速排查。调试助手这种工具型App,用false更可控。
在onConnectionStateChange回调里,连接成功后我会先调discoverServices(),拿到服务列表后再请求MTU:
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices() } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { state = BleConnectionState.NegotiatingMtu gatt.requestMtu(247) } } override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { state = BleConnectionState.Ready // 通知UI可以开始收发 } }需要注意:不同厂商外设的MTU能力差异很大。有些老模块固件不支持MTU协商,onMtuChanged会回调失败,这时候你要主动降级到20字节分包模式,而不是一直卡在等待状态。源码里我加了一个超时保护,如果MTU协商3秒没回调,自动按默认MTU进入Ready状态。
3.3 Notify/Write两条数据通道的正确打开方式
数据上行和下行在BLE里是两条独立的通道,源码处理逻辑也要分开:
- 下行(手机发数据给外设):调用
writeCharacteristic,Android 13开始需要指定写类型,WRITE_TYPE_DEFAULT会等待外设确认,WRITE_TYPE_NO_RESPONSE则只发不管,吞吐量更高。对注重传输速率的场景比如OTA升级,建议用No Response。 - 上行(外设发给手机):先
setCharacteristicNotification(characteristic, true),再写CCCD:
val cccd = characteristic.getDescriptor( UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") ) cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(cccd)之后数据会通过onCharacteristicChanged回调出来。这里有个体验优化:BLE回调默认跑在Binder线程,直接在里面操作UI会崩溃,源码里统一用一个Handler把回调抛回主线程。这个Handler还可以顺带做日志打点,把所有原始数据流按时间戳记录到文件里——调试联调阶段,一个完整可回放的日志比什么都值钱。
3.4 包解析缓冲:BLE分包之后怎么还原完整帧
BLE单包最大有效载荷哪怕协商到244字节,也还是会出现"一帧数据太大被拆成多包"的情况。更常见的是UART透传模块场景,模块内部缓冲有限,固件把一个完整NMEA语句或私有协议帧切成好几包发出来。如果每收到一个onCharacteristicChanged回调就直接丢给上层解析,几乎必然出现"帧不完整"。
我在这套源码里的做法是维护一个累积缓冲区,按协议头里的长度字段判断帧边界:
fun push(raw: ByteArray) { buffer.write(raw) while (true) { val available = buffer.size() if (available < MIN_FRAME_LENGTH) break val header = buffer.toByteArray() // 判断帧头 if (header[0] != 0xAA.toByte() || header[1] != 0x55.toByte()) { // 丢弃一字节重新对齐 buffer.reset() buffer.write(header.copyOfRange(1, header.size)) continue } val frameLen = header[2].toInt() + 3 // 长度字段 + 3字节头 if (available < frameLen) break // 等下一包 val frame = buffer.readBytes(frameLen) // 校验CRC后交给上层解析器 parseAndDispatch(frame) } }核心思路就是"数据不够就攒着,数据不对就移位对齐"。这种缓冲逻辑不复杂,但极其实用,能让调试体验提升一个档次。
4. 从手机到PC:Windows和Linux上的移植思路
调试BLE设备不只在手机上做。产测工位、开发板联调、自动化测试这些场景,PC端工具往往更顺手。我实现这套源码的时候顺手做了PC端移植,两条路线都验证过。
4.1 Windows:WinRT + C# 是最顺的一条路
Windows平台做BLE开发,最省力的API是WinRT(Windows Runtime)的Windows.Devices.Bluetooth命名空间。它可以用来开发WinForms项目,不用非得走UWP。在.NET Framework 4.7.2工程里,通过Microsoft.Windows.SDK.Contracts包可以引用这些API。
核心流程大概是:
var device = await BluetoothLEDevice.FromBluetoothAddressAsync(address); var services = await device.GetGattServicesAsync(); foreach (var service in services.Services) { // 遍历特征 }相比Android,WinRT的API封装得更高层,做调试工具上手更快。要留意的是,如果电脑的蓝牙适配器驱动工作不正常(设备管理器里看到蓝牙设备带黄色感叹号),这个API会直接抛异常。所以PC端工具启动时最好加一个环境自检,先确认蓝牙适配器状态再初始化扫描器。
4.2 Linux:bleak 与 bluetoothctl 的组合拳
Linux上我推荐Python的bleak库。它底层调用BlueZ,跨平台API非常简洁,特别适合写快速验证脚本:
import asyncio from bleak import BleakClient async def main(): address = "AA:BB:CC:DD:EE:FF" async with BleakClient(address) as client: value = await client.read_gatt_char("0000fff1-0000-1000-8000-00805f9b34fb") print(value) asyncio.run(main())如果只是临时看一眼数据,直接用系统自带的bluetoothctl交互式命令也行。bluetoothctl scan on、bluetoothctl connect <addr>、bluetoothctl menu gatt这几条命令足够完成基础调试。但要做自动化测试、批量产测,还是得用bleak这种可编程方案。
这套源码在设计时就把协议解析层做成了纯逻辑库,和具体的蓝牙后端解耦。同一套包解析代码,在Android上是Kotlin版本,在PC上是C#或Python版本,逻辑完全一致,减少了跨端联调时"两边行为不一致"的问题。
5. 实测中最容易翻车的五类问题与排查方案
5.1 扫描不到设备
这个问题的排查顺序,我整理成一个清单:
- 确认外设真的在广播。很多BLE模块在连接过的设备列表里会隐藏广播,需要重置或者按键唤醒。
- 确认广播类型可被发现。设备如果设置为不可发现模式,扫描再久也白搭。
- 确认Android权限配置正确。Android 12以下漏了定位权限,扫描结果为空;Android 12以上漏了
BLUETOOTH_SCAN也一样。 - 确认扫描过滤条件没写错。用Service UUID过滤时,大小写和字节序都有可能造成漏匹配,先用不过滤的方式扫描确认设备存在,再逐步加过滤条件。
- 确认没有反复启停扫描。频繁stopScan/startScan会导致系统过滤掉部分广播包。
5.2 MTU协商后依然丢包
MTU协商成功不代表高枕无忧。我遇到过一次比较坑的问题是:外设固件在MTU协商后返回了成功,但内部缓冲区依然按20字节处理,导致单包超过20字节就丢数据。这种问题在Android端无法判断,只能靠抓包工具对比。后来我们在外设固件侧做了强制检查:协商成功后如果收到超过缓冲区大小的包,直接返回错误状态,调试助手收到错误后自动降级为20字节分包,问题解决。
所以调试助手里一定要有"手动设置MTU"和"强制20字节分包模式"两个开关,这在排查外设兼容性时是刚需。
5.3 开启了Notify却收不到任何回调
先检查CCCD有没有写进去。这是最高频的低级错误。再检查特征属性,有些特征用的是Indicate而不是Notify,两者虽然都是订阅推送,但需要写入0x0002而不是0x0001。还有一种情况是外设要求连接后先发送一条"使能数据"命令才会启动推送,这种纯属业务逻辑,需要看外设的协议文档。
5.4 连接频繁掉线
排查掉线问题,首先要看连接参数。BLE的连接间隔(Connection Interval)由外设决定,如果外设设置的连接间隔过短,比如7.5ms,而外设主控芯片忙不过来,就会导致丢包率上升甚至supervision timeout断连。反过来,如果连接间隔过长,比如100ms,则会有明显的发送延迟。
另外2.4GHz频段的Wi-Fi干扰也是掉线的重要原因。如果你在办公环境调试,旁边的Wi-Fi流量大,BLE数据重传会明显增多。调试时最好把Wi-Fi切到5GHz,或者在屏蔽环境里观察。源码里记录RSSI和重传标志的功能,就是为这种排查准备的。
5.5 数据解析错位
这是处理串口透传类BLE模块时的经典问题。模块透传的数据往往来自MCU串口,MCU侧的UART输出和BLE广播节奏不完全同步,会把两条数据帧拼到一起。解法就是我前面说的累积缓冲区加帧边界对齐。但还要注意一个问题:如果数据流里本身包含帧头字节0xAA 0x55的随机组合,移位对齐就会误判。所以协议设计里最好加上长度字段和CRC校验,双保险,缺一不可。
我在这套源码里把日志记录功能做成了默认开启:所有收发的原始数据、时间戳、当时的连接参数、RSSI都会写入本地文件。任何一次现场的诡异问题,只要把日志拉出来回放,基本都能定位到是在哪一步出的问题。这个习惯强烈建议保留,一次日志回放省下的排查时间,远远超过那一点点存储空间。
最后再分享一个最实在的经验:源码里的UI界面不用做太复杂,因为真正的价值在底层逻辑。调试工具最核心的画面就三个——扫描列表、特征浏览、数据收发日志。把这三个做到稳定流畅,比堆十个炫酷图表都管用。尤其是"按时间戳记录每一包原始数据"这个功能,它才是所有疑难杂症排查的核心入口。
本文还有配套的精品资源,点击获取