3、GAP广播与扫描:广播者与观察者角色、广播数据包格式、扫描请求与响应

好,咱们今天聊聊GAP层最基础、也最核心的两个角色——广播者和观察者。说白了,这就是蓝牙设备互相发现的过程。你想想看,两个设备要建立连接,总得先知道对方的存在吧?广播和扫描就是干这个活的。

3.1 广播者与观察者:谁在喊,谁在听?

在BLE的世界里,角色划分其实很简单:

  • 广播者(Advertiser):周期性发送广播包,相当于在喊「我在这里!我在这里!」
  • 观察者(Observer):被动监听广播包,相当于竖起耳朵听周围有没有人在喊。

我个人习惯把广播者想象成一个举着广告牌在街上走的人,观察者就是路过的行人。广播者不需要知道谁在听,观察者也不需要回应——除非它想进一步了解。

关键点:广播者和观察者之间没有连接。广播是单向的、无连接的通信方式。这是BLE低功耗特性的基石。

我在项目中遇到过一个问题:有个同事把广播间隔设得太短,结果设备功耗飙升。嗯,这里要注意——广播间隔越短,被发现得越快,但电池也掉得越快。这是个典型的trade-off。

3.2 广播数据包格式:里面到底装了啥?

广播包不是随便扔一堆字节出去就完事了。它有严格的格式要求。一个完整的广播包,结构是这样的:

字段 长度 说明
前导码(Preamble) 1字节 用于接收端同步时钟
访问地址(Access Address) 4字节 广播通道固定为0x8E89BED6
PDU(Protocol Data Unit) 2-39字节 真正的数据内容
CRC 3字节 校验用

咱们重点关注PDU部分。PDU里又分两种:

  • 广播指示(ADV_IND):可连接的非定向广播,最常用
  • 扫描响应(SCAN_RSP):对扫描请求的回复

PDU的数据部分,是由一个个AD Structure(广告数据结构)组成的。每个AD Structure长这样:

| Length (1字节) | AD Type (1字节) | AD Data (N字节) |

举个例子,如果你想让设备显示名字「MyDevice」,广播包里的AD Structure就是:

0x09  // Length = 9(1字节类型 + 8字节名字)
0x09  // AD Type = Complete Local Name
0x4D 0x79 0x44 0x65 0x76 0x69 0x63 0x65  // "MyDevice"的ASCII码

小技巧:我调试广播包时,常用nRF Connect或者Wireshark抓包看原始数据。看到一堆十六进制别慌,按AD Structure拆开读就行。

3.3 扫描请求与响应:你问我答

观察者光听广播还不够,有时候想了解更多信息。这时候就会发一个扫描请求(SCAN_REQ)。广播者收到后,可以回复一个扫描响应(SCAN_RSP)

为什么会这样设计?你想想看,广播包只有31字节的有效载荷,装不下太多东西。所以通常的做法是:

  • 广播包:放最重要的信息,比如设备地址、是否可连接
  • 扫描响应:放次要信息,比如设备全名、支持的服务UUID

我曾经踩过一个坑:有个设备广播包里放了完整的设备名,结果扫描响应里又放了一遍。浪费啊!广播包空间那么宝贵,应该把名字挪到扫描响应里,广播包腾出位置放服务UUID才对。

扫描请求和响应的流程是这样的:

  1. 广播者发送ADV_IND广播包
  2. 观察者收到后,发送SCAN_REQ(包含自己的设备地址)
  3. 广播者回复SCAN_RSP(包含额外数据)

注意:扫描请求会暴露观察者的设备地址。如果你在做隐私保护相关的应用(比如防追踪),记得使用随机地址或者干脆禁用主动扫描。

3.4 实际代码示例:用Python模拟广播与扫描

咱们用Python的bleak库来演示一下。这个库封装了BLE的底层操作,用起来很方便。

广播者代码(模拟)

import asyncio
from bleak import BleakScanner

# 实际上,广播者通常由硬件实现
# 这里我们用扫描来模拟观察者行为

async def scan_for_devices():
    print("开始扫描...")
    devices = await BleakScanner.discover(timeout=5.0)
    for d in devices:
        print(f"发现设备: {d.name}, 地址: {d.address}")
        print(f"广播数据: {d.metadata['manufacturer_data']}")

asyncio.run(scan_for_devices())

观察者代码(主动扫描)

import asyncio
from bleak import BleakScanner

def detection_callback(device, advertisement_data):
    print(f"收到广播: {device.name}")
    print(f"RSSI: {advertisement_data.rssi} dBm")
    print(f"广播包内容: {advertisement_data.service_uuids}")

async def main():
    scanner = BleakScanner(detection_callback)
    await scanner.start()
    await asyncio.sleep(10.0)
    await scanner.stop()

asyncio.run(main())

实战建议:调试时先用手机上的nRF Connect扫一下,确认广播包格式对不对。我每次写广播代码都这么干,省了不少抓包时间。

3.5 避坑指南

最后分享几个我踩过的坑:

  • 广播间隔别设太短:20ms以下会导致信道拥堵,而且功耗翻倍。我一般用100ms起步。
  • 扫描窗口和扫描间隔:扫描窗口越大,发现设备越快,但功耗也高。我习惯设扫描窗口=扫描间隔的一半。
  • 别忘了过滤重复包:观察者收到同一个广播包多次,如果不做去重,回调函数会被疯狂调用。

嗯,广播和扫描这部分内容,说白了就是BLE设备互相打招呼的方式。搞懂了这些,后面的连接建立就顺理成章了。下一章咱们聊聊连接参数和连接建立过程,到时候见!