广播与扫描响应: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 的 iBeaconGoogle 的 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,可以两个都广播。不过要注意广播包长度限制,可能需要分时发送。

知识体系总览

下面这张图,帮你理清广播与扫描响应的核心逻辑:

BLE 广播与扫描响应知识体系 广播包 (Advertising) 31 字节,主动发送 AD Structure 1 AD Structure 2 AD Structure N 总长度 ≤ 31 字节 扫描响应 (Scan Response) 31 字节,主动扫描时回复 AD Structure 1 AD Structure 2 AD Structure N 总长度 ≤ 31 字节 主动扫描请求 常见广播协议:iBeacon / Eddystone

这张图展示了广播包和扫描响应的关系。左边是外设主动发送的广播包,右边是中央设备请求后回复的扫描响应。两者结构相同,但用途不同。底部是 iBeacon 和 Eddystone 这两个最常用的广播协议标准。

避坑指南:

  • 广播间隔:不要设得太短(比如 20ms),否则会大量占用广播信道,导致其他设备无法正常工作。我一般设 100ms-200ms。
  • 广播功率:不要设得太高。功率越大,耗电越快,而且会干扰其他设备。在室内场景,0 dBm 通常就够了。
  • 扫描响应:不是所有设备都会回复扫描响应。有些低功耗设备为了省电,只发广播包,不处理扫描请求。设计时要考虑到这种情况。

好了,广播与扫描响应就讲到这里。你只要记住:广播包是“名片正面”,扫描响应是“名片背面”。iBeacon 和 Eddystone 是两种常见的“名片格式”。解析广播包就是“拆积木”,按 Length -> AD Type -> AD Data 的顺序一个个拆开就行。

公众号:蓝海资料掘金营,微信deep3321