1. GATT 协议栈分层与特征数据结构
GATT(Generic Attribute Profile)是 BLE 协议栈中定义数据交互规则的最高层。它并不负责物理传输,而是建立在一个名为 ATT(Attribute Protocol)的底层协议之上,将设备间的通信抽象为对“属性”的查找、读、写与通知。本章聚焦 GATT 在协议栈中的位置、特征(Characteristic)如何被声明、属性表(Attribute Table)的构成方式以及句柄(Handle)与属性之间的映射关系。
1.1 BLE 协议栈中的 GATT 层职责
BLE 协议栈自底向上分为:
| 层级 | 主要职责 | GATT 依赖关系 |
|---|---|---|
| Physical / Link Layer | 射频收发、连接建立、跳频 | 无关 |
| HCI(Host Controller Interface) | Host 与 Controller 之间命令/事件传输 | 无关 |
| L2CAP(Logical Link Control and Adaptation) | 信道复用、分段重组 | GATT 承载于 L2CAP CID=0x0004 |
| ATT | 属性读写、通知、指示原子操作 | GATT 直接调用 ATT PDU |
| GATT | 服务/特征/描述符建模、客户端-服务器交互流程 | 依赖 ATT 提供传输 |
| Application / Profile | 具体业务(如心率、电池服务) | 通过 GATT API 访问 |
GATT 的核心职责可归纳为三点:
- 层次化建模:将数据组织成 Service → Characteristic → Descriptor 三层的树形结构。
- 属性类型注册:每一个暴露给对端的数据单元都必须以固定格式在属性表中注册。
- 访问控制:依据属性权限(读/写/通知/指示)决定哪些 ATT 操作被允许。
开发提示:GATT 本身不包含安全加密逻辑。加密由 SMP(Security Manager Protocol)层完成,但 GATT 可以在属性权限中标记"加密读/加密写",从而间接要求 SMP 先执行配对。
1.2 特征声明与属性表结构
在 GATT 属性表中,一个 Characteristic 并非单个属性,而是由三个连续的属性条目构成:
| 句柄 | 属性类型(UUID) | 属性值 | 权限 |
|---|---|---|---|
| Handle N | 0x2803 (Characteristic Declaration) | [属性句柄 | 特征 UUID] | 只读 |
| Handle N+1 | 特征 UUID (如 0x2A37) | 实际特征值数据 | 根据 Profile 设置(读/写/无需响应写) |
| Handle N+2 | 0x2902 (Client Characteristic Configuration Descriptor, CCCD) | 0x0000 / 0x0001 / 0x0002 | 读/写 |
属性表在固件中的典型表示方式如下(以 Zephyr 为例):
// 定义自定义特征声明
struct bt_gatt_attr my_attrs[] = {
BT_GATT_PRIMARY_SERVICE(&my_service_uuid), // 主服务声明
BT_GATT_CHARACTERISTIC(&my_char_uuid, // 特征声明
BT_GATT_CHRC_READ |
BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_READ, // 特征值权限
read_my_char, // 读回调
NULL, // 写回调(无需写)
&my_char_value), // 特征值缓冲区
BT_GATT_CCC(my_ccc_changed, // CCCD 配置变化回调
BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),
};
上述代码对应的属性表布局为 5 个条目,其中特征值属性(my_char_value)存储在 RAM 中,对端通过 ATT 读请求直接读取该内存区域。
关键理解:特征值属性是唯一真正承载业务数据的属性,而声明和描述符则提供元信息。对端设备不能直接“写特征声明”,只能通过访问声明来发现下一个特征。
1.3 句柄映射关系
句柄是 GATT 属性表中每个条目的 16 位逻辑地址,范围 0x0001 ~ 0xFFFF。连接建立后,GATT 客户端通过句柄来定位目标属性。句柄的分配遵循以下规则:
- 递增性:句柄按照属性表中的顺序从 1 开始递增,不允许跳跃或重复。
- 连续性:同一特征的三条属性(声明 + 值 + 描述符)句柄必须连续。
- 分组性:服务的句柄范围由服务声明属性及其包含的所有子属性构成一个闭区间(start_handle ~ end_handle)。
句柄映射的一个实际例子(针对上述 Zephyr 代码):
- 句柄 0x0001 —— 主服务声明(UUID =
my_service_uuid) - 句柄 0x0002 —— 特征声明(UUID = 0x2803,值中存储特征句柄 0x0003 和我方特征 UUID)
- 句柄 0x0003 —— 特征值(UUID =
my_char_uuid) - 句柄 0x0004 —— CCCD(UUID = 0x2902)
对端执行 Service Discovery 时,会发 Read By Group Type 按组获取服务;再对每个服务执行 Read By Type(UUID=0x2803)得到特征声明的句柄列表,进而通过特征声明中携带的 特征值句柄 字段直接定位到数据属性。
常见陷阱:很多开发者误以为特征声明中的句柄字段是“特征值的属性句柄”,而它实际上是指向“该特征的第二个属性(特征值)”的指针。如果你在特征声明中写入了错误的句柄值(例如自身句柄),对端通过声明跳转到特征值时将读到错误的数据。
下面使用一张结构图完整展示 GATT 属性表、特征三元组与句柄映射的关系。
1.4 常见错误与调试方法
在实际嵌入式开发中,GATT 属性表配置错误是导致连接后无法发现服务或读写失败的主要原因。
| 错误现象 | 根本原因 | 调试检查点 |
|---|---|---|
| 手机 App 扫描不到服务 | 主服务声明未注册或 UUID 未通过广播包发放 | 使用 nRF Connect 访问属性表;检查 bt_gatt_register 是否被调用 |
| 特征值句柄错误 | 特征声明中属性句柄字段未设置或指向自身 | 使用 Sniffer 抓包,检查 Read By Type 响应中声明内容的第 2-3 字节 |
| CCCD 写入无效 | CCCD 权限未设置 BT_GATT_PERM_WRITE 或回调未注册 | 检查 bt_gatt_attr 数组中 CCCD 条目的 .perm 和 .write 字段 |
| 句柄不连续导致发现中断 | 动态添加属性时句柄分配逻辑未对齐 | 打印 bt_gatt_attr 数组索引与期望句柄的对照表 |
推荐工具:使用 Nordic nRF Sniffer + Wireshark 分析 ATT PDU 中的 Read By Type Response 和 Find Information Response,可以直接看到每个句柄对应的 UUID 和权限,这是验证属性表布局最可靠的手段。