第四章 广播与扫描:让设备被看见,也看见别人
好,咱们进入蓝牙开发里最基础也最关键的一环——广播与扫描。
你想想看,两个蓝牙设备要通信,第一步是什么?总得先找到对方吧。广播就是让设备「喊一嗓子」:我在这儿!扫描就是另一台设备「竖起耳朵听」:谁在喊我?
我在做第一个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_BYTES和BT_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,协议栈会打印详细的广播和扫描日志。我第一次看到那些日志时,才真正理解了广播数据在底层是怎么流转的。
好了,广播和扫描的核心内容就这些。下一章咱们讲连接——让两个设备真正「牵上手」。到时候你会发现,前面的广播和扫描,其实都是在为连接做铺垫。