广播与扫描响应:BLE 的“自我介绍”与“名片夹”
BLE 设备之间怎么打招呼?说白了,就是靠广播。
一个外设(比如心率带、温度计)不断在喊:“我在这儿!我是谁!我能干嘛!” 而中央设备(比如你的手机)就在旁边听着,偶尔回一句:“嘿,给我看看你的详细资料?”
这个“喊话”的过程,就是 Advertising(广播)。而“回一句”拿到的详细资料,就是 Scan Response(扫描响应)。
我个人习惯把广播包比作一个人的“名片正面”——名字、公司、职位。扫描响应则是“名片背面”——更详细的联系方式、个人简介。你想想看,如果每个人都在名片正面写满所有信息,那名片盒得多大?BLE 也一样,广播包有长度限制,所以得把最核心的信息放广播里,次要的放扫描响应里。
广播包的结构:31 字节的“黄金地段”
BLE 广播信道上的数据包,最大只有 31 字节。这 31 字节里,还得包含固定的报头(2 字节)和可选的地址(6 字节)。真正留给应用层数据的,通常只有 24-26 字节。
嗯,这里要注意:这 31 字节是广播包的总长度,不是数据长度。我刚开始做 BLE 时,就犯过这个错——想塞一个 30 字节的 UUID 进去,结果发现怎么都发不出去。
广播包的数据结构是 AD Structure(Advertising Data Structure),每个结构由三部分组成:
- Length(1 字节):表示后面 AD Type + AD Data 的总长度。
- AD Type(1 字节):表示数据类型,比如是设备名称、UUID 列表、还是制造商数据。
- AD Data(N 字节):具体的数据内容。
举个例子,一个包含“完整设备名称”的广播包,结构是这样的:
// 假设设备名称为 "MyHeart"
// 0x05 表示后面有 5 个字节(0x09 + "MyHeart" 的 4 个字节)
// 0x09 表示这是完整设备名称的 AD Type
// 0x4D 0x79 0x48 0x65 0x61 0x72 0x74 是 "MyHeart" 的 ASCII 码
// 实际广播数据(十六进制):
// 05 09 4D 79 48 65 61 72 74
核心要点:广播包的数据是“拼积木”式的。你可以把多个 AD Structure 拼在一起,只要总长度不超过 31 字节。常见的 AD Type 有:
- 0x01:Flags(标志位,如是否支持经典蓝牙、是否处于可发现模式)
- 0x02/0x03:16-bit Service UUID 列表(部分/完整)
- 0x06/0x07:128-bit Service UUID 列表(部分/完整)
- 0x08/0x09:设备名称(缩短/完整)
- 0xFF:制造商特定数据(Manufacturer Specific Data)
扫描响应数据:广播的“B 面”
广播包只有 31 字节,不够用怎么办?这时候扫描响应就派上用场了。
当中央设备发起主动扫描(Active Scan)时,外设可以额外发送一个 Scan Response 包,同样是 31 字节。这个包的结构和广播包完全一样,也是由 AD Structure 组成。
我在项目中遇到过这样一个场景:一个温湿度传感器,需要在广播包里放设备名称、服务 UUID、还有温湿度数据。31 字节根本不够。我的做法是:广播包里只放设备名称(缩短版)和服务 UUID,温湿度数据放在扫描响应里。这样,只有主动扫描的设备才能拿到详细数据,既节省了广播信道带宽,又保护了数据隐私。
我的习惯:
- 广播包:放设备名称、服务 UUID、必要的标志位。让其他设备能快速识别你是谁。
- 扫描响应:放制造商数据、更长的设备名称、额外的服务数据。给那些“想深入了解”的设备看。
广播包解析:从字节流到可读信息
解析广播包,说白了就是“拆积木”。你拿到一串字节流,按 Length -> AD Type -> AD Data 的顺序一个个拆开。
我写了一个简单的解析函数,你可以参考一下:
// Kotlin 代码:解析广播包数据
fun parseAdvertisementData(scanRecord: ByteArray): Map<Int, ByteArray> {
val result = mutableMapOf<Int, ByteArray>()
var index = 0
while (index < scanRecord.size) {
// 第一个字节是 Length(包含 AD Type 和 AD Data 的总长度)
val length = scanRecord[index].toInt() and 0xFF
if (length == 0) break
// 第二个字节是 AD Type
val adType = scanRecord[index + 1].toInt() and 0xFF
// 剩下的就是 AD Data(长度 = length - 1,因为 AD Type 占了一个字节)
val adData = scanRecord.copyOfRange(index + 2, index + 1 + length)
result[adType] = adData
// 跳到下一个 AD Structure
index += 1 + length
}
return result
}
// 使用示例
val scanRecord = byteArrayOf(
0x02, 0x01, 0x06, // Flags: 0x06 (LE General Discoverable + BR/EDR Not Supported)
0x03, 0x02, 0x0F, 0x18, // 16-bit Service UUID: 0x180F (Battery Service)
0x05, 0x09, 0x4D, 0x79, 0x48, 0x65, 0x61, 0x72, 0x74 // Complete Local Name: "MyHeart"
)
val parsedData = parseAdvertisementData(scanRecord)
// parsedData[0x01] = [0x06]
// parsedData[0x02] = [0x0F, 0x18]
// parsedData[0x09] = [0x4D, 0x79, 0x48, 0x65, 0x61, 0x72, 0x74]
我曾经踩过的坑:
解析广播包时,一定要处理 长度字段为 0 的情况。有些设备会在广播包末尾填充 0x00 作为结束标志。如果不加判断,你的 while 循环会一直跑下去,直到数组越界崩溃。我曾经因为这个 bug,在客户现场调试了一整个下午……嗯,从那以后我再也不敢忽略这个边界条件了。
iBeacon 与 Eddystone:两大广播协议标准
广播包本身只是一个“容器”,怎么装数据、装什么数据,由具体的协议标准决定。目前最流行的两个标准是 Apple 的 iBeacon 和 Google 的 Eddystone。
iBeacon:苹果的“室内定位”方案
iBeacon 的数据结构非常固定,全部塞在 制造商特定数据(AD Type = 0xFF) 里。它的结构如下:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| Company ID | 2 | Apple 的 Company ID 是 0x004C |
| iBeacon Type | 1 | 固定为 0x02 |
| iBeacon Length | 1 | 固定为 0x15(21 字节) |
| Proximity UUID | 16 | 128 位 UUID,用于区分不同的 iBeacon 区域 |
| Major | 2 | 主编号,用于区分同一区域内的不同子区域 |
| Minor | 2 | 次编号,用于区分同一子区域内的具体设备 |
| TX Power | 1 | 1 米处的参考 RSSI 值(有符号整数) |
你想想看,一个 iBeacon 广播包,光制造商数据就占了 23 字节(2+1+1+16+2+2+1)。再加上前面的 AD Structure 头(2 字节)和 Flags(3 字节),总共 28 字节。嗯,刚好塞进 31 字节的广播包里。
我在项目中用过 iBeacon 做室内导航。每个展位放一个 iBeacon,手机根据收到的信号强度(RSSI)和 TX Power 估算距离。不过说实话,RSSI 受环境影响很大,人一多、墙一多,距离估算就不太准了。
Eddystone:Google 的“开放”方案
Eddystone 比 iBeacon 更灵活。它支持多种帧类型,每种帧类型有不同的用途:
- Eddystone-UID:类似 iBeacon,用于设备标识。包含一个 10 字节的 Namespace 和一个 6 字节的 Instance。
- Eddystone-URL:广播一个 URL。手机收到后可以直接打开网页。这个很实用,比如在博物馆展品旁放一个 Eddystone,手机靠近就能弹出展品介绍页面。
- Eddystone-TLM:广播设备的遥测数据,比如电池电量、温度、湿度。这个帧通常和 UID 或 URL 帧一起发送。
- Eddystone-EID:加密标识符,用于安全场景。需要和 Google 的注册服务配合使用。
Eddystone 的数据结构也是放在 制造商特定数据 里,但它的 Company ID 是 Google 的 0x00E0(或者 0xFEAA,取决于具体实现)。
我的选择建议:
- 如果你的应用场景是 iOS 生态(比如商场室内导航、门店签到),选 iBeacon。苹果原生支持,不需要额外开发 App。
- 如果你的应用场景是 跨平台 或者需要 广播 URL(比如智能广告牌、博物馆导览),选 Eddystone。Google 的 Physical Web 项目就是基于 Eddystone-URL 的。
- 如果你需要 同时支持 iOS 和 Android,可以两个都广播。不过要注意广播包长度限制,可能需要分时发送。
知识体系总览
下面这张图,帮你理清广播与扫描响应的核心逻辑:
这张图展示了广播包和扫描响应的关系。左边是外设主动发送的广播包,右边是中央设备请求后回复的扫描响应。两者结构相同,但用途不同。底部是 iBeacon 和 Eddystone 这两个最常用的广播协议标准。
避坑指南:
- 广播间隔:不要设得太短(比如 20ms),否则会大量占用广播信道,导致其他设备无法正常工作。我一般设 100ms-200ms。
- 广播功率:不要设得太高。功率越大,耗电越快,而且会干扰其他设备。在室内场景,0 dBm 通常就够了。
- 扫描响应:不是所有设备都会回复扫描响应。有些低功耗设备为了省电,只发广播包,不处理扫描请求。设计时要考虑到这种情况。
好了,广播与扫描响应就讲到这里。你只要记住:广播包是“名片正面”,扫描响应是“名片背面”。iBeacon 和 Eddystone 是两种常见的“名片格式”。解析广播包就是“拆积木”,按 Length -> AD Type -> AD Data 的顺序一个个拆开就行。