手写BLE调试助手源码:从GATT到MTU的完整实践指南

手写BLE调试助手源码:从GATT到MTU的完整实践指南 简介蓝牙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协商BleGattParserService/Characteristic/Descriptor解析BlePacketCodec帧缓冲与私有协议编解码BleLogger带时间戳的日志记录与回放2. 协议栈里必须吃透的几个概念否则源码写出来也是花架子2.1 从广播到连接GAP层藏着的坑BLE的链路层管理由GAPGeneric Access Profile负责。调试助手的第一个操作是扫描而扫描的背后是监听广播包。广播包的结构是AD Structure序列每个AD Structure包含三部分长度、AD Type、AD Data。常见的AD Type有这么几个AD Type值含义Flags0x01广播能力标志如是否可连接、是否支持双模Complete Local Name0x09完整的设备名称Shortened Local Name0x08缩短的设备名称Tx Power Level0x0A发射功率可用于距离估算Manufacturer Specific Data0xFF厂商自定义数据调试助手的扫描模块里最值得做的是按服务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/DescriptorBLE里的数据库表结构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()拿到服务列表后再请求MTUoverride 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里是两条独立的通道源码处理逻辑也要分开下行手机发数据给外设调用writeCharacteristicAndroid 13开始需要指定写类型WRITE_TYPE_DEFAULT会等待外设确认WRITE_TYPE_NO_RESPONSE则只发不管吞吐量更高。对注重传输速率的场景比如OTA升级建议用No Response。上行外设发给手机先setCharacteristicNotification(characteristic, true)再写CCCDval 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. 从手机到PCWindows和Linux上的移植思路调试BLE设备不只在手机上做。产测工位、开发板联调、自动化测试这些场景PC端工具往往更顺手。我实现这套源码的时候顺手做了PC端移植两条路线都验证过。4.1 WindowsWinRT C# 是最顺的一条路Windows平台做BLE开发最省力的API是WinRTWindows 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) { // 遍历特征 }相比AndroidWinRT的API封装得更高层做调试工具上手更快。要留意的是如果电脑的蓝牙适配器驱动工作不正常设备管理器里看到蓝牙设备带黄色感叹号这个API会直接抛异常。所以PC端工具启动时最好加一个环境自检先确认蓝牙适配器状态再初始化扫描器。4.2 Linuxbleak 与 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界面不用做太复杂因为真正的价值在底层逻辑。调试工具最核心的画面就三个——扫描列表、特征浏览、数据收发日志。把这三个做到稳定流畅比堆十个炫酷图表都管用。尤其是按时间戳记录每一包原始数据这个功能它才是所有疑难杂症排查的核心入口。本文还有配套的精品资源点击获取