做IoT开发这几年,我发现一个特别普遍的现象:很多人项目框架搭得很好,云端上报、App展示、Web管理全都有模有样,结果卡在最底层的设备通信上——BLE扫不到、连不上、数据发到一半掉线。BLE蓝牙基础通信这块,教科书里讲得干干净净,真下手调试却一个坑接一个坑。这篇博文我用一个最简单的IoT Demo把完整链路拆开讲:扫描、连接、收发数据,每一步都说明白为什么这样做、参数怎么选、出问题怎么排查。
这个Demo的定位不复杂:一块开发板模拟物联网节点,周期性地把温湿度数据打成广播包,同时对外提供可连接的服务;手机端负责扫描、连接,再通过GATT读写特征值,既收设备上报的数据,也能下发控制指令。它适合两类人:一类是刚接触BLE开发、想把这几个环节一次跑通的新手;另一类是已经在做IoT项目、但总在通信层翻车的老朋友。把这个基础链路吃透,后面再去折腾加密、OTA、Mesh,地基才算稳。
1. BLE通信的整体设计思路
BLE不是"打开蓝牙然后像串口一样丢字节"这么简单,它背后有一套完整规范,把设备角色、数据组织结构、交互流程都定义好了。想顺利跑通Demo,先把这几个概念理顺。
1.1 角色划分:中心设备与外围设备
BLE通信里最常见的两种角色:外围设备和中心设备。外围设备通常是传感器、手环、门锁这类资源受限的小节点,它自己不做主动连接,只是不断广播"我在这儿",等别人来连;中心设备则是手机、网关、电脑这类有完整处理能力的设备,负责扫描周围的广播、发起连接、执行数据交换。
这个Demo里,开发板默认是外围设备,Android手机是中心设备。但是要注意,这个角色划分不是锁死的:有些双模芯片能切换角色,手机和手机之间也能做BLE通信,只是IoT场景里绝大多数是"手机中心+设备外设"这个组合。把这一套先吃透,后面看代码就不会迷路。
还要区分一对概念:GATT服务器和GATT客户端。简单说,外围设备一般承担GATT服务器,负责把数据组织成一套服务结构;中心设备是客户端,连接后去发现这套结构,再针对某个具体特征值做读写。GATT的关系就像外卖平台:服务器是商家,负责把菜品(数据)上架;客户端是顾客,打开菜单选菜下单。
1.2 为什么是BLE而不是经典蓝牙
经典蓝牙和低功耗蓝牙是两套不同的协议栈。经典蓝牙擅长传大数据——音频、文件、高质量串口流,但连接建立慢,功耗也高。BLE的设计目标反着来:低功耗、连接快、短数据收发。IoT节点普遍靠电池供电,传感器上报的数据往往几十个字节,BLE天然合适。
BLE还有一个经典蓝牙没有的能力:广播通道。外围设备不建立连接就能把数据塞进广播包,中心设备扫描时就能解析出设备名、服务UUID,甚至业务数据。这意味着有些场景根本不用连接就能拿数据,比如信标定位、设备状态广播。这个Demo也会演示广播数据的解析,后面展开讲。
1.3 简易Demo的通信链路设计
我给Demo定了一个三段式链路。
第一段,开发板不断广播,广播包里塞设备名、服务UUID,以及最近一次采集到的温湿度数据。手机扫描时不用连上去,就能在列表里看到类似"26.3°C / 48%"的实时信息。
第二段,用户点击某个设备,手机发起连接,走GATT服务发现流程,找到带Notify属性的特征值并订阅。之后开发板每2秒主动推送一次最新温湿度。
第三段,手机通过另一个可写特征值下发指令,比如"把上报周期改成5秒"。开发板收到指令后解析执行,并回写一个确认包。
为什么这样设计?因为真实IoT产品基本都能拆成这三件事:广播唤醒、连接上报、下发控制。同时还能覆盖"非连接态读广播数据"和"连接态收通知数据"两条路径,正好把BLE的主要特性都练一遍。
2. 扫描阶段:发现设备的关键细节
扫描是BLE开发最前面的环节,看着简单,但扫描参数、广播解析、列表刷新这些细节经常决定体验好坏。
2.1 扫描参数对结果的影响
Android上做BLE扫描,主要用BluetoothLeScanner,几行代码就能跑起来。但你会发现同样一段代码在不同手机上效果天差地别:有的手机一扫一大片,有的扫半天什么都扫不到。问题多半出在扫描参数上。
核心参数有两个:scanMode和reportDelay。scanMode决定扫描窗口与扫描间隔的比例。SCAN_MODE_LOW_LATENCY是尽可能连续扫描,发现快、耗电;SCAN_MODE_LOW_POWER是"扫一会儿歇一会儿",省电但发现慢。调试阶段我建议直接用LOW_LATENCY,很多第三方小外设广播间隔很长,LOW_POWER模式下经常错过。
reportDelay是批次上报间隔,默认0表示实时回调。如果设成1000,扫描结果会攒1秒统一回调一次。省电、减少回调次数,但实时性变差。我的经验是:做设备列表页设0就好;做长时间被动探测才考虑批次上报。
还有ScanFilter的坑。按Service UUID过滤能大幅减少无关设备,但前提是外设的广播包规范地声明了服务UUID。如果广播格式不规范,过滤条件会把目标设备直接滤掉。调试阶段先别加过滤条件,确认广播内容合法再加。
2.2 广播数据解析:不是所有字节都是给人看的
扫描回调拿到的是ScanResult,其中有广播原始字节。这些字节不是乱填的,BLE广播包由一条条AD Structure组成,每条有长度、类型、数据三部分。
常见类型有FLAGS、Local Name、Tx Power、Service UUID、Manufacturer Specific Data。系统已经解析好了一部分:getName()能拿设备名,getServiceUuids()能拿服务UUID。但如果你把业务数据塞进广播,就得自己去解析Manufacturer Specific Data或者Service Data。
举个例子,我在开发板上定义了一个自定义广播结构,把温度整数和小数分别放进去,Android端解析时先读厂商ID,再读后面两个字节,就能还原温度值。这里有个体会:广播里塞自定义数据,字节序尽量和BLE协议栈保持一致,统一用大端。否则手机和单片机两头各按各的解析,很容易搞出数值错乱。
广播包里还有RSSI信号强度,这是判断设备远近的重要依据。扫描列表按RSSI从大到小排序,用户一眼就能看出哪个设备离自己最近。这一条在真实产品选型时特别有用。
2.3 设备去重与列表更新策略
扫描回调会把同一台设备反复发给你。如果列表无脑添加,几分钟就是几百条重复项。常规做法是用Map以设备地址为key保存ScanResult,每次回调就put,同时完成去重和更新。
不过有个系统行为要注意:部分手机厂商会随机化扫描结果里的设备地址,同一台设备每次扫出来地址都不一样。这不是代码问题,是系统出于隐私考虑做的地址随机化。规避办法是同时用设备名兜底去重,但注意不同设备可能重名,多设备场景下还是要优先信任BLE地址。
UI刷新也别直接在回调线程里做。扫描回调的频率很高,一秒钟几十次甚至上百次,直接更新ListView会卡顿。正确姿势是把结果post到主线程,或者用DiffUtil做增量更新,只刷新变化的项。
3. 连接与参数协商:稳定链路的根基
设备扫描到了,连接这一步才开始暴露真正的坑。很多人的代码从扫描到列表一切正常,但一点连接就失败,或者连上几秒就断开,问题往往出在三个方向。
3.1 连接间隔、从机延迟与超时时间
BLE连接建立后,通道质量由几个连接参数决定:连接间隔、从机延迟、超时时间。
连接间隔是两个设备之间交换数据的周期,单位是1.25ms。间隔短,数据往来及时,功耗大;间隔长,省电,延迟高。开发板上可以通过配置把连接间隔调到最低档,实测响应飞快,但手机端系统可能为了省电把后台连接的间隔拉长,这也就是很多开发者发现"前台好好的,锁屏之后就变卡"的原因。
从机延迟允许外设在N个连接事件内不响应,能大幅省电。比如数据每5秒上报一次,从机延迟设到4以上,外设可以连着但不费电。这类参数的坑在于:不是所有手机都允许主机端调整它,需要外设端和主机端都预留协商能力。
超时时间则是连接监控的兜底机制。超时时间必须大于连接间隔和从机延迟的乘积,否则可能出现"设备明明还活着,但被判定连接超时而断开"的情况。这个细节在低功耗场景尤其重要,因为从机延迟拉高之后,真实交互频率变低,超时判断很容易误触。
3.2 连接建立与失败重试的实战细节
调用connectGatt之后,真正结果在onConnectionStateChange回调里判断。连接失败主要有三种原因:设备不在广播范围内、两台设备同时连同一个从机导致挤占、外设连接后因为参数不匹配直接被断开。
我的经验是,连接必须做超时控制。Android原生的connectGatt如果一直没回调,可能十几秒都没有结果,用户早就失去耐心了。做法很简单:起一个8~10秒的Handler超时任务,到期没回调就主动断开并重试。这个DIY超时在真实项目里几乎是必需品,不加就是给自己埋雷。
连接成功后还要立刻处理一件事:连接优先级。Android提供了CONNECTION_PRIORITY_HIGH、CONNECTION_PRIORITY_BALANCED等设置项,对应不同的连接间隔。需要低延迟数据交互时调成HIGH,只是偶尔收数据的场景用BALANCED就行。这个设置对链路稳定性和功耗都有明显影响。
3.3 MTU协商:很多隐藏问题的起点
MTU是单包最大传输字节数。BLE 4.0默认单包有效载荷只有20字节,很多"数据发不出去、收不全"的问题,根源就在这里。
协商MTU要在连接成功后调用requestMtu(),系统会和外设交互协商出双方都能接受的值。现代Android手机上大多能协商到185~247字节,一次能发送约244字节有效数据。如果你拿到MTU值一直停在23(默认值),说明外设没有正确响应MTU请求,要去检查外设固件。
有个很常见的误区:requestMtu是异步接口,必须在onMtuChanged回调里拿到最终值才算成功。我踩过这个坑:发完请求立刻写数据,结果数据被按旧MTU分包,对面只能收到残缺包。所以规范做法是维护一个"MTU协商完成"标志位,协商完成再允许发送大数据。
4. 收发数据核心:GATT与特征值的实战配置
数据和传输是BLE的核心。连接只是管道,管道的进出口是GATT服务里那些特征值,理解它们的组织方式和属性,才算真正学会收发。
4.1 GATT结构:服务、特征值、描述符
GATT就像BLE世界里的文件系统,分三层:服务、特征值、描述符。
服务是一组功能的集合,比如"环境传感器服务"就是一个Service;Service里包含多个特征值,每个特征值是一个具体数据实体,比如"温度特征值""湿度特征值";描述符则描述特征值的额外信息,比如单位、取值范围、是否支持通知。
开发板上用BLE库定义服务和特征值,本质上就是注册几个UUID和回调。UUID的选择有讲究:自定义业务服务不要用SIG联盟保留的16位短UUID,容易和设备厂商标准服务撞车;用128位UUID,或者带自己公司标识的UUID。我见过不少Demo直接拿来一个厂商的标准UUID改两笔就用,结果连上之后发现读写的是别家服务,数据错乱得很。
特征值的属性(Properties)决定了它能做什么:可读、可写、可通知、可指示。这些属性可以组合,比如一个特征值既能读又能通知。开发的时候用代码注册这些属性,手机端用getProperties()读取并判断支持哪些操作。属性不匹配是"连上了但操作失败"的一大来源。
4.2 Write、Notify、Indicate到底怎么选
这是BLE开发里最容易被搞混的一组概念。
Write是客户端主动写值给服务器,适合下发指令。Write又分带响应和不带响应两种:带响应会等设备确认,可靠但慢;不带响应速度快,但对面可能收到不了。短指令用带响应,大数据流才考虑无响应写。
Notify和Indicate都是服务器主动通知客户端数据已更新。区别在于:Notify走的是无确认通知,速度快但可能丢包;Indicate有确认机制,可靠但吞吐量减半。IoT传感器周期上报用Notify就够,偶尔丢一包,下一包就补回来了。但涉及控制指令回执、或者丢了会出事故的数据,就必须用Indicate。
我做固件升级时,数据分包传输用的就是Indicate。每发一包都等确认,慢是慢了点,但绝对稳。文件传输这种场景最怕静默丢包,确认机制能省下大量排查时间。
4.3 分包与粘包:数据量上来之后的必修课
BLE单包有效载荷最多20字节(默认MTU)或244字节(协商后)。要传的消息超过这个长度,就得自己处理分包。最常见的方案是设计一个简单的帧协议:包头存总长度和包序号,按固定窗口发送,接收端拼接后做完整性校验。
和分包相对的坑是粘包。外设如果高频发送Notify,手机端的onCharacteristicChanged回调会把多个包连续怼过来。你必须有一套接收缓冲,按帧格式切分。我在Demo里就把切分逻辑做成一个环形缓冲区,每收到一个包先缓冲,再尝试按帧头解析,只有拼出完整一帧才抛给业务层。这套逻辑看着基础,但很多真实产品的BLE通讯模块,核心就是它。
帧协议里还应该加校验位。CRC16或者简单的异或校验都行。不加校验,偶发一两个字节的错乱很难被发现,尤其是你拿到的温度变成个离谱的数字,排查半天最后发现是链路噪声。
4.4 数据写回的正确姿势与回调时序
向特征值写入数据时,有时会在同一个连接里同时收到Write和Notify的回调。要注意回调里的数据操作不能太耗时,因为BLE回调线程是非常宝贵的资源,在这个线程里做了IO、数据库写这些操作,会阻塞后续大量BLE事件分发,导致整个链路卡顿。最佳实践是回调里只做数据拷贝,然后丢到业务线程池。
还见过一个常见错误:写入数据后立刻在同一个方法里读回来。写操作是异步的,写入完成回调还没触发时,读回来的只能是旧值。一切通信操作都要基于回调结果,不要靠时序猜测。
5. 一个可运行的Demo全程实现
理论说完,该动手了。我用的组合是开发板加Android手机,代码量不大,但覆盖了完整链路。
5.1 硬件与整体结构
开发板用的是带BLE 4.2功能的ESP32开发板,编程框架选Arduino,BLE库成熟稳定。Android端用Java编写,测试机型是Android 9以上。整体结构:
- 设备端:广播温湿度数据、可连接、周期Notify上报、接收Write指令
- 手机端:扫描展示设备列表、连接、订阅通知、下发指令
5.2 设备端核心代码:广播与Notify
ESP32上定义一个Service,两个Characteristic:一个Notify属性用于温湿度上报,一个Write属性用于接收指令。
#include <BLEDevice.h> #include <BLEServer.h> #include <BLEUtils.h> #define SERVICE_UUID "0000ff00-0000-1000-8000-00805f9b34fb" #define CHAR_NOTIFY_UUID "0000ff01-0000-1000-8000-00805f9b34fb" #define CHAR_WRITE_UUID "0000ff02-0000-1000-8000-00805f9b34fb" BLECharacteristic *notifyChar; bool isConnected = false; class MyServerCallbacks : public BLEServerCallbacks { void onConnect(BLEServer* server) { isConnected = true; } void onDisconnect(BLEServer* server) { isConnected = false; // 关键:断开后重新广播,否则手机扫描不到 BLEDevice::startAdvertising(); } }; void setup() { BLEDevice::init("IoTNode"); BLEServer *server = BLEDevice::createServer(); server->setCallbacks(new MyServerCallbacks()); BLEService *service = server->createService(SERVICE_UUID); notifyChar = service->createCharacteristic( CHAR_NOTIFY_UUID, BLECharacteristic::PROPERTY_NOTIFY ); notifyChar->addDescriptor(new BLE2902()); // 使能通知的CCCD描述符 BLECharacteristic *writeChar = service->createCharacteristic( CHAR_WRITE_UUID, BLECharacteristic::PROPERTY_WRITE ); service->start(); BLEAdvertising *advertising = BLEDevice::getAdvertising(); advertising->addServiceUUID(SERVICE_UUID); BLEDevice::startAdvertising(); }这里有两个细节必须说。第一,Notify特征值必须添加BLE2902描述符,否则手机端订阅通知时找不到CCCD,通知开关永远开不起来。第二,断开后必须重新调startAdvertising,否则手机一旦断开连接,之后就再也扫不到这台设备,除非给开发板断电重启。
上报数据的部分,我在主循环里按配置的周期调用notifyChar的setValue和notify:
void loop() { if (isConnected) { float temp = readTemp(); float humi = readHumi(); uint8_t data[4]; // 简单编码:温度整数、温度小数、湿度整数、湿度小数 data[0] = (int)temp; data[1] = (int)((temp - (int)temp) * 10); data[2] = (int)humi; data[3] = (int)((humi - (int)humi) * 10); notifyChar->setValue(data, 4); notifyChar->notify(); } delay(intervalMs); }5.3 手机端核心代码:扫描与连接
扫描代码很简单,但参数设置不要省:
private void startScan() { BluetoothLeScanner scanner = mBluetoothAdapter.getBluetoothLeScanner(); ScanSettings settings = new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setReportDelay(0) .build(); scanner.startScan(null, settings, mScanCallback); }连接成功后的核心逻辑在onServicesDiscovered里。这里有一个新手必踩的坑:很多设备光调setCharacteristicNotification还不够,必须在CCCD描述符上写入0x01,才能真正打开外设的通知开关。漏了这一步,外设照样发数据,手机端却一个包都收不到。
@Override public void onServicesDiscovered(BluetoothGatt gatt, int status) { BluetoothGattService service = gatt.getService(SERVICE_UUID); BluetoothGattCharacteristic notifyChar = service.getCharacteristic(CHAR_NOTIFY_UUID); gatt.setCharacteristicNotification(notifyChar, true); BluetoothGattDescriptor descriptor = notifyChar.getDescriptor( CLIENT_CHARACTERISTIC_CONFIG_DESCRIPTOR_UUID); descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); gatt.writeDescriptor(descriptor); }5.4 数据帧协议与分包发送
我定义了一个极简帧协议:一个字节消息类型、一个字节数据长度、两个字节序号,再加CRC校验。发送时先判MTU,超长就循环分包:
public static byte[] buildFrame(byte type, byte[] payload) { ByteBuffer buf = ByteBuffer.allocate(4 + payload.length + 1); buf.put(type); buf.put((byte) payload.length); buf.putShort((short) (seq++)); buf.put(payload); buf.put(calcCRC(payload)); return buf.array(); }接收端把上一次没处理完的残余字节缓存起来,每次新数据到达都先进入缓存,然后从缓存里按帧头长度字段切分。切不完整就先留着,等下一包补齐。这个"环形缓存+切帧"的思路是BLE数据可靠传输的标配,别偷懒。
6. 常见问题与排查实录
这一节我直接把平时最常碰到的几个问题和排查顺序写出来,省得你东查西查。
6.1 扫描不到设备的几种原因
扫描不到设备,第一排查方向是权限。Android 6.0以后需要定位权限,Android 10以后扫描BLE需要精确位置权限。代码写得再好,权限没给全,扫描结果始终空白。其次是蓝牙开关状态和后台省电策略,不少系统会把未在前台运行的扫描进程掐断,扫描器要放在前台服务里才稳定。
再看广播侧:很多开发板默认的广播超时只有几十秒,错过那个窗口,手机自然扫不到。需要明确配置成一直广播。广播间隔也有影响:间隔太长,手机要等好几个轮次才发现设备,体感就是"怎么扫了半天都没出现"。
6.2 连接后马上掉线
连接成功几秒内掉线,八成是连接参数协商没谈拢。尤其外设连接间隔设得太短,而手机端又试图把间隔拉长,外设不支持就直接断开。对策是外设端把连接参数范围放宽,允许手机调长间隔。调试时屏幕保持常亮,排除系统后台挂起策略干扰。
另一个原因是并发连接冲突。BLE 4.0之后的手机普遍只能同时维护少量连接,新连接会把老连接挤掉。如果测试环境里同时有多个App在扫描连接,偶尔出现连接失败,很可能是因为连接名额被占满。
6.3 数据收不全或延迟很大
收不全第一查MTU。先在onMtuChanged里打日志看最终协商值;如果永远是23,说明外设没正确响应MTU请求,去检查外设固件有没有处理MTU包。MTU没问题,再查分包逻辑,特别是大数据的帧头里长度字段是不是正确填充。
延迟大则跟连接间隔强相关。连接间隔长,每次Notify的送达延迟就大。想要毫秒级响应,手机端要把连接优先级设成HIGH,外设端也要允许更短的连接间隔。广播间隔也影响首次发现延迟,但连接建立后的延迟主要看连接间隔。
6.4 反复断连与掉线重连策略
真实场景里信号遮挡、设备断电都会导致连接断开。成熟项目一定要做自动重连机制。我的策略是:收到DISCONNECT回调后,先判断是主动断开还是意外断开,主动断开就不重连;意外断开则做指数退避重连,间隔从1秒、2秒、4秒逐步拉长,最多重试10次。不加退避的重连是会出问题的——两台设备都在疯狂互相连接,连接风暴能把双方功耗直接拉满。
7. 一些实测体会与后续扩展方向
7.1 调试阶段的效率心得
调试BLE,强烈建议一边看手机端日志,一边看串口监视器,两边对照着分析比瞎猜快十倍。手机端打印扫描结果、连接状态、MTU值、收发数据;设备端打印连接状态、收到的原始字节。只要两边的日志时间戳能对上,问题十有八九能定位。
还有一个建议:先用通用BLE调试工具(这类工具在很多应用商店都能找到)把设备端验证一遍,确认广播、服务、特征值都正常,再动手机端代码。这样可以把出问题的边界大幅缩小。我在团队里推这个流程之后,新人上手BLE的排查时间至少缩短一半。
7.2 从Demo到真实IoT产品还差什么
这个Demo跑通之后,离一个真实IoT产品还差几步。
第一是安全。BLE的配对绑定在现代产品里基本是必需的,不然任何人拿着手机都能连你的设备发指令,这在门锁、控制类产品里是致命问题。配对绑定还涉及密钥存储、MITM防护,需要专门设计。
第二是功耗。真实产品里要把广播间隔调长、把从机延迟调高、尽量用周期唤醒模式而不是常连接,让节点能够撑过几个月甚至一年。这块要用电流计实测,不能靠估算,因为BLE在广播、连接、传输三个状态下的电流差异非常大。
第三是OTA。设备固件升级必须走BLE传输,这就需要把前面说的分包协议升级成一个带重传、超时、断点续传的可靠传输层。我做过一次OTA流程之后才意识到,BLE数据可靠传输的难度不在协议本身,而在各种异常场景的处理——半包、错序、断连、内存限制。
我现在的习惯是拿这个Demo作为新同事上手BLE的练习项目,跑通一遍之后再让他们去加配对、加OTA、加低功耗策略。目前看下来,"扫描—连接—收发"这个思路框架始终是BLE开发的地基。地基打得牢,后面玩智能灯、资产追踪、环境监测这些项目,都是在这套链路上面做文章。
最后再分享一个小技巧:所有BLE相关代码,务必把日志打印完整——扫描回调、连接状态、MTU、每个收发数据包的字节内容,全打出来。BLE的链路长、设备多、环境干扰大,没有日志,排查问题就像在黑灯瞎火的房间里找一根针。有日志,你至少能知道问题在哪个环节,而不是从协议栈第一行开始猜。