4. GATT核心概念
广播,是蓝牙设备向外界说的第一句话。你想想看,一个蓝牙设备要被人发现,总得先喊一嗓子吧?这一嗓子怎么喊、喊什么内容、喊多大声,就是本章要聊的事。
我个人习惯把广播比作「名片」。你的设备在广播信道里不断散发名片,周围的主机(手机、平板、树莓派)捡到这张名片,就知道你是谁、能干什么。而扫描响应数据,相当于对方主动来问你要的「详细简历」。
4.1 广播包的结构:AD Structure
广播包不是随便塞一堆字节进去的。BLE 规范规定,广播数据由若干个 AD Structure 组成。每个 AD Structure 长这样:
| 字段 | 长度 | 说明 |
|---|---|---|
| Length | 1 字节 | 表示后面 AD Type + AD Data 的总长度 |
| AD Type | 1 字节 | 数据类型标识,比如 0x01 = Flags,0x09 = Complete Local Name |
| AD Data | Length - 1 字节 | 具体数据内容 |
说白了,这就是一个 TLV(Type-Length-Value)结构。我在项目中遇到过不少新手,自己拼广播数据时把 Length 算错了,结果手机死活扫描不到设备。嗯,这里要注意:Length 不包含自身那个字节。
4.2 广播包与扫描响应数据的区别
很多初学者搞混这两个概念。我简单解释一下:
- 广播包(Advertising Data):设备主动发出的,31 字节上限。所有扫描设备都能收到。
- 扫描响应数据(Scan Response Data):也是 31 字节上限。只有当主机主动发起扫描请求时,设备才回复这个包。
为什么要分两个?因为广播信道很拥挤,31 字节往往不够用。你可以把核心信息放广播包里,把补充信息(比如完整的设备名称、更多的服务 UUID)放扫描响应里。这样既节省了广播信道带宽,又保留了完整信息。
经验之谈: 我一般把设备名称和主要服务 UUID 放广播包,把制造商自定义数据、附加服务列表放扫描响应。这样大部分场景下,主机扫一次就能拿到关键信息,不需要额外交互。
4.3 Zephyr 中的广播配置 API
Zephyr 的广播配置,核心围绕 bt_le_adv_param 和 bt_le_adv_data 这两个结构体展开。先看参数结构:
struct bt_le_adv_param {
uint8_t id; // 控制器 ID,默认 0
uint32_t options; // 选项位掩码
uint32_t interval_min;// 最小广播间隔,单位 0.625ms
uint32_t interval_max;// 最大广播间隔
bt_addr_le_t *peer; // 定向广播时的对端地址
};
options 字段里常用的几个标志:
| 宏定义 | 含义 |
|---|---|
| BT_LE_ADV_OPT_CONNECTABLE | 可连接广播 |
| BT_LE_ADV_OPT_SCANNABLE | 可扫描广播(支持扫描响应) |
| BT_LE_ADV_OPT_ONE_TIME | 只广播一次 |
| BT_LE_ADV_OPT_USE_IDENTITY | 使用身份地址而非随机地址 |
数据部分用 bt_data 结构体数组来组织:
struct bt_data {
uint8_t type;
uint8_t data_len;
const uint8_t *data;
};
然后调用 bt_le_adv_start() 启动广播。这个函数签名是:
int bt_le_adv_start(const struct bt_le_adv_param *param,
const struct bt_data *ad, size_t ad_len,
const struct bt_data *sd, size_t sd_len);
ad 是广播数据数组,sd 是扫描响应数据数组。传 NULL 和 0 表示不提供对应数据。
小技巧: 如果你只需要广播包,不需要扫描响应,直接把 sd 参数传 NULL、sd_len 传 0 即可。Zephyr 内部会帮你处理好。
4.4 实战:配置一个完整的广播
下面我写一个完整的例子。这个设备会广播自己的名字叫 "ZephyrDev",同时暴露一个自定义服务 UUID,扫描响应里再带一段制造商数据。
#include <zephyr/types.h>
#include <stddef.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/hci.h>
/* 自定义服务 UUID:随便编一个 16-bit UUID */
#define CUSTOM_SVC_UUID 0xFEED
static const struct bt_data ad[] = {
/* Flags:通用可发现模式,BR/EDR 不支持 */
BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
/* 完整设备名称 */
BT_DATA(BT_DATA_NAME_COMPLETE, "ZephyrDev", 9),
/* 16-bit 服务 UUID 列表 */
BT_DATA_BYTES(BT_DATA_UUID16_SOME, 0xED, 0xFE),
};
static const struct bt_data sd[] = {
/* 制造商数据:公司 ID 0x0059(Nordic),后面跟两个字节 0x01 0x02 */
BT_DATA_BYTES(BT_DATA_MANUFACTURER_DATA, 0x59, 0x00, 0x01, 0x02),
};
void main(void)
{
int err;
err = bt_enable(NULL);
if (err) {
printk("蓝牙初始化失败: %d\n", err);
return;
}
printk("蓝牙初始化成功\n");
err = bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad),
sd, ARRAY_SIZE(sd));
if (err) {
printk("广播启动失败: %d\n", err);
return;
}
printk("广播已启动,设备名: ZephyrDev\n");
/* 主循环保持运行 */
while (1) {
k_sleep(K_SECONDS(1));
}
}
这段代码里,BT_LE_ADV_CONN 是一个预定义的广播参数宏,等价于可连接、可扫描的通用广播,间隔 100ms 左右。如果你需要更精细的控制,可以自己构造 bt_le_adv_param。
注意: 广播数据总长度不能超过 31 字节,扫描响应数据也是 31 字节。我曾经见过有人把完整的设备描述塞进广播包,结果超了,Zephyr 直接返回 -EINVAL。调试了半天才发现是数据超长。
4.5 广播参数的高级配置
有时候默认的广播间隔不够用。比如你的设备需要快速被发现,或者为了省电想拉长间隔。这时候就要自己构造参数了:
static const struct bt_le_adv_param adv_param = {
.id = BT_ID_DEFAULT,
.options = BT_LE_ADV_OPT_CONNECTABLE | BT_LE_ADV_OPT_SCANNABLE,
.interval_min = BT_GAP_ADV_FAST_INT_MIN_2, // 约 30ms
.interval_max = BT_GAP_ADV_FAST_INT_MAX_2, // 约 60ms
};
err = bt_le_adv_start(&adv_param, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd));
Zephyr 预定义了几组广播间隔宏:
| 宏 | 值(单位 0.625ms) | 实际间隔 | 适用场景 |
|---|---|---|---|
| BT_GAP_ADV_FAST_INT_MIN_1 | 48 | 30ms | 快速可发现,高功耗 |
| BT_GAP_ADV_FAST_INT_MAX_1 | 80 | 50ms | 快速可发现 |
| BT_GAP_ADV_FAST_INT_MIN_2 | 160 | 100ms | 默认推荐 |
| BT_GAP_ADV_FAST_INT_MAX_2 | 240 | 150ms | 默认推荐 |
| BT_GAP_ADV_SLOW_INT_MIN | 800 | 500ms | 省电模式 |
| BT_GAP_ADV_SLOW_INT_MAX | 1600 | 1000ms | 深度省电 |
我个人习惯在产品开发阶段用快速间隔(30~60ms),方便调试。量产时再根据功耗要求调整到 100ms 或更慢。
4.6 广播数据动态更新
有时候广播数据需要动态变化。比如设备状态变了,想更新广播里的某个字段。Zephyr 提供了 bt_le_adv_update_data() 函数:
int bt_le_adv_update_data(const struct bt_data *ad, size_t ad_len,
const struct bt_data *sd, size_t sd_len);
调用这个函数不需要先停止广播。我在做一个传感器项目时,就用这个接口把实时温度值塞进制造商数据字段里,手机端扫描就能看到温度变化,不用建立连接。
避坑指南: 我曾经在更新广播数据时,忘了同步更新扫描响应数据,结果手机端读到的制造商数据还是旧的。后来我养成了习惯:每次更新都同时传 ad 和 sd,哪怕 sd 没变化也传原来的指针。
4.7 广播类型的选择
BLE 广播分好几种类型,Zephyr 通过 options 字段控制:
| 广播类型 | options 设置 | 说明 |
|---|---|---|
| 可连接广播 | BT_LE_ADV_OPT_CONNECTABLE | 主机可以发起连接,最常用 |
| 可扫描广播 | BT_LE_ADV_OPT_SCANNABLE | 主机可以扫描获取更多数据,但不能连接 |
| 不可连接广播 | 不设置上述选项 | 纯广播,常用于 Beacon |
| 定向广播 | BT_LE_ADV_OPT_DIR_ADDR | 只对特定设备广播,连接速度快 |
你想想看,如果你的设备只是发个 Beacon 信息,不需要连接,那就用不可连接广播,省电又省带宽。如果需要被手机连接,那就用可连接广播。
4.8 本章知识结构图
下面这张图把广播配置的核心流程串起来了:
4.9 常见问题与调试技巧
最后分享几个我在调试广播时踩过的坑:
- 广播启动失败返回 -EINVAL:八成是数据超长,或者 options 组合不合法。比如同时设置了可连接和不可连接,Zephyr 会直接拒绝。
- 手机扫描不到设备:先检查 Flags 字段有没有设置
BT_LE_AD_GENERAL。没有这个标志,很多手机不会显示你的设备。 - 扫描响应数据没生效:确认广播参数里设置了
BT_LE_ADV_OPT_SCANNABLE。不可扫描的广播不会响应扫描请求。 - 广播间隔不准确:蓝牙协议允许控制器在一定范围内调整实际间隔,你设的 min/max 只是建议值。不要期望精确到毫秒级。
调试利器: 用 nRF Connect 或者 LightBlue 这类 App 扫描,可以直观看到广播包和扫描响应数据的每个字节。我每次调广播数据都会开着它,比看日志高效多了。
广播配置说难不难,说简单也不简单。核心就是搞清楚 31 字节的限制、AD Structure 的拼法、以及广播参数的含义。把这些基础打牢了,后面做 GATT 服务广播、iBeacon、Eddystone 都会顺手很多。