第四章 广播与扫描:让设备被看见,也看见别人

好,咱们进入蓝牙开发里最基础也最关键的一环——广播与扫描。

你想想看,两个蓝牙设备要通信,第一步是什么?总得先找到对方吧。广播就是让设备「喊一嗓子」:我在这儿!扫描就是另一台设备「竖起耳朵听」:谁在喊我?

我在做第一个Zephyr蓝牙项目时,就卡在广播参数配置上。设备死活搜不到,折腾了两天才发现是广播间隔设得太短,把手机扫描端给搞懵了。嗯,这些坑咱们今天一并填上。

4.1 广播数据格式:你喊的话,别人得听得懂

广播不是乱喊,得按规矩来。BLE广播数据包的结构其实很简单,就是一组AD Structure(Advertising Data Structure)的串联。

每个AD Structure长这样:

  • Length(1字节):表示后面Data的长度(不包含Length自身)
  • AD Type(1字节):标识数据类型,比如设备名、UUID、Tx Power等
  • AD Data(N字节):具体的数据内容

说白了就是「TLV」格式——Type-Length-Value。蓝牙协议栈里到处都是这种结构。

我习惯把广播数据包想象成一个「广告牌」。你可以在上面写你的设备名字,也可以贴出你支持的服务UUID,甚至可以告诉别人你的发射功率有多强。但广告牌空间有限——广播包最多31字节,扫描响应也是31字节,加起来62字节。你得精打细算。

常见AD Type(你一定会用到的):

  • 0x01 — Flags(标志位,必须包含)
  • 0x02/0x03 — 16-bit Service UUID(完整/部分)
  • 0x06/0x07 — 128-bit Service UUID(完整/部分)
  • 0x08/0x09 — 设备短名称/完整名称
  • 0x0A — Tx Power Level(发射功率)
  • 0xFF — Manufacturer Specific Data(厂商自定义数据)

举个例子,一个最简单的广播数据,包含Flags和完整设备名:

// 广播数据:Flags + 完整设备名
static const struct bt_data ad[] = {
    BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
    BT_DATA(BT_DATA_NAME_COMPLETE, "MyDevice", 8),
};

这里BT_DATA_BYTESBT_DATA是Zephyr提供的宏,帮你自动计算Length、填入Type。你只管给数据就行。

我的小技巧:调试阶段,我习惯在广播数据里加一个递增的计数器(放在Manufacturer Specific Data里)。这样扫描端一看计数器在变,就知道广播在正常发送,不用怀疑协议栈卡死了。

4.2 扫描响应:给好奇的人更多信息

广播包只有31字节,有时候不够用。比如你想把完整的128-bit服务UUID都广播出去,一个UUID就16字节,两个就超了。

这时候就需要扫描响应(Scan Response)。它的原理很简单:

  • 广播包(Advertising Data):放最重要的信息,比如Flags、设备名、核心服务UUID
  • 扫描响应(Scan Response Data):放补充信息,比如完整的服务列表、更多厂商数据

扫描端在收到广播包后,可以主动发起扫描请求(Scan Request),广播端收到后回复扫描响应(Scan Response)。注意,扫描响应也是31字节上限。

我曾经在一个项目中,把设备名放在广播包里,把完整的服务UUID列表放在扫描响应里。这样既保证了快速识别设备,又不会让广播包太臃肿。效果很好。

// 广播数据:只放Flags和短名称
static const struct bt_data ad[] = {
    BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
    BT_DATA(BT_DATA_NAME_SHORTENED, "MyDev", 5),
};

// 扫描响应:放完整名称和128-bit服务UUID
static const struct bt_data sd[] = {
    BT_DATA(BT_DATA_NAME_COMPLETE, "MyDevice-1234", 13),
    BT_DATA_BYTES(BT_DATA_UUID128_ALL,
                  0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
                  0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10),
};

注意:扫描响应不是必须的。如果你的广播包31字节够用,完全可以不用扫描响应。但如果你用了扫描响应,广播端必须支持接收扫描请求(可扫描广播),否则扫描端永远收不到响应。

4.3 配置广播参数:别让设备喊得太累

广播参数配置,说白了就是决定「怎么喊、喊多大声、喊多久」。Zephyr里用bt_le_adv_param结构体来配置。

关键参数有这几个:

参数 说明 我的建议
options 广播模式选项 一般用BT_LE_ADV_OPT_CONNECTABLE(可连接)或BT_LE_ADV_OPT_SCANNABLE(可扫描)
interval_min 最小广播间隔(单位:0.625ms) 产品中建议用160(100ms)以上,省电
interval_max 最大广播间隔(单位:0.625ms) 一般设成和min一样,固定间隔更稳定

广播间隔这个参数,我吃过亏。有一次为了追求设备被发现的速度,我把间隔设成了20ms(32个单位)。结果手机扫描端反而经常漏掉广播包——因为手机扫描窗口也是周期性的,两个周期对不上就错过了。后来我改成100ms,发现速度没慢多少,但稳定性好多了。

// 配置广播参数:可连接、不可扫描、间隔100ms
struct bt_le_adv_param adv_param = {
    .id       = BT_ID_DEFAULT,
    .sid      = 0,
    .secondary_max_skip = 0,
    .options  = BT_LE_ADV_OPT_CONNECTABLE,
    .interval_min = BT_GAP_ADV_FAST_INT_MIN_2,  // 约100ms
    .interval_max = BT_GAP_ADV_FAST_INT_MIN_2,  // 固定100ms
    .peer     = NULL,
};

经验之谈:调试阶段用短间隔(50ms左右),方便快速看到广播。产品阶段用长间隔(200ms-1s),省电。我一般会在Kconfig里留一个配置项,方便切换。

4.4 启动广播:让设备开始喊话

参数配好了,数据准备好了,接下来就是启动广播。Zephyr里用bt_le_adv_start()函数。

int err = bt_le_adv_start(&adv_param, ad, ARRAY_SIZE(ad),
                          sd, ARRAY_SIZE(sd));
if (err) {
    printk("广播启动失败 (err %d)\n", err);
    return;
}
printk("广播已启动\n");

这个函数的参数顺序是:广播参数、广播数据数组、广播数据长度、扫描响应数组、扫描响应长度。如果不想要扫描响应,后两个参数传NULL和0就行。

启动成功后,你用手机上的nRF Connect或者LightBlue就能搜到你的设备了。如果搜不到,先检查一下有没有调用bt_enable()初始化协议栈——我犯过这种低级错误,排查了半天。

停止广播更简单:

bt_le_adv_stop();

嗯,就一行。但要注意,停止广播后,已经连接的设备不会断开,只是不再对外广播了。

4.5 启动扫描:去听别人在喊什么

广播是「说」,扫描就是「听」。Zephyr里扫描需要先注册一个回调函数,协议栈每收到一个广播包或扫描响应,就会调用这个回调。

// 扫描回调函数
static void scan_cb(const struct bt_le_scan_recv_info *info,
                    struct net_buf_simple *ad_data)
{
    char addr_str[BT_ADDR_LE_STR_LEN];
    bt_addr_le_to_str(info->addr, addr_str, sizeof(addr_str));

    printk("收到广播: %s, RSSI: %d\n", addr_str, info->rssi);

    // 解析广播数据,查找设备名
    char name[32];
    int len = bt_data_parse(ad_data, device_found, name);
    if (len > 0) {
        printk("设备名: %s\n", name);
    }
}

// 启动扫描
struct bt_le_scan_param scan_param = {
    .type       = BT_LE_SCAN_TYPE_ACTIVE,  // 主动扫描(会发扫描请求)
    .options    = BT_LE_SCAN_OPT_NONE,
    .interval   = BT_GAP_SCAN_FAST_INTERVAL,
    .window     = BT_GAP_SCAN_FAST_WINDOW,
};

int err = bt_le_scan_start(&scan_param, scan_cb);
if (err) {
    printk("扫描启动失败 (err %d)\n", err);
}

这里有个细节:BT_LE_SCAN_TYPE_ACTIVE是主动扫描,扫描器会向广播端发送扫描请求,从而获取扫描响应数据。如果只是想知道「周围有哪些设备」,用被动扫描(BT_LE_SCAN_TYPE_PASSIVE)就够了,更省电。

解析广播数据的标准做法:

bt_data_parse()函数遍历AD Structure,找到你感兴趣的AD Type。比如查找设备名:

static bool device_found(struct bt_data *data, void *user_data)
{
    char *name = (char *)user_data;
    switch (data->type) {
    case BT_DATA_NAME_COMPLETE:
    case BT_DATA_NAME_SHORTENED:
        memcpy(name, data->data, MIN(data->data_len, 31));
        name[MIN(data->data_len, 31)] = '\0';
        return false;  // 找到了,停止遍历
    default:
        return true;   // 继续遍历
    }
}

4.6 避坑指南:我踩过的那些坑

做广播和扫描,有几个坑特别容易踩。我一个个说:

坑一:广播数据超31字节

Zephyr编译时不会检查广播数据是否超长。你塞了40字节进去,它照样编译通过,但广播就是发不出去。我建议在代码里加个断言:

BUILD_ASSERT(sizeof(ad) <= 31, "广播数据超长!");

坑二:忘记设置Flags

蓝牙规范要求广播数据必须包含Flags AD Type。有些手机扫描器没有Flags会直接忽略这个设备。我曾经在调试时发现iPhone能搜到,Android搜不到,查了半天就是Flags没设。

坑三:扫描回调里做耗时操作

扫描回调是在蓝牙中断上下文里调用的。你在里面做打印、延时、内存分配,轻则丢包,重则系统崩溃。正确的做法是把数据拷贝出来,用工作队列(work queue)去处理。

// 错误示范:在回调里直接处理
static void scan_cb(...) {
    process_data(ad_data);  // 千万别!
}

// 正确做法:发信号给线程处理
static void scan_cb(...) {
    // 拷贝数据到全局缓冲区
    memcpy(g_buf, ad_data->data, ad_data->len);
    g_len = ad_data->len;
    // 通知线程
    k_sem_give(&g_sem);
}

坑四:广播和扫描同时进行

有些芯片不支持同时广播和扫描(比如Nordic nRF52系列在单模式下)。如果你需要设备既广播又扫描,得用交替模式。Zephyr里有BT_LE_ADV_OPT_EXT_ADV扩展广播可以部分解决这个问题,但具体要看芯片支持情况。

最后提醒一句:蓝牙调试,日志是最好的朋友。打开CONFIG_BT_DEBUG_LOG=y,协议栈会打印详细的广播和扫描日志。我第一次看到那些日志时,才真正理解了广播数据在底层是怎么流转的。

好了,广播和扫描的核心内容就这些。下一章咱们讲连接——让两个设备真正「牵上手」。到时候你会发现,前面的广播和扫描,其实都是在为连接做铺垫。