第4章:服务与特征发现——GATT数据库结构与发现流程
BLE 连接建立之后,Central 必须通过发现 GATT 数据库来了解 Peripheral 的能力。本章深入 GATT 层次模型,解析 Primary Service / Characteristic 的发现机制,并封装可复用的客户端发现代码。
4.1 GATT 数据库结构
GATT 将属性(Attribute)组织为三层层次:Service → Characteristic → Descriptor。每个 Service 由一个 16-bit 或 128-bit UUID 标识,且分为 Primary Service(主要服务)和 Secondary Service(次要服务)。Characteristic 包含值、属性(Properties)和可选的描述符。
| 层级 | 用途 | UUID 示例 | 发现方式 |
|---|---|---|---|
| Primary Service | 声明设备的主要功能模块(如电池、设备信息) | 0x180F (Battery Service) | GATT Primary Service Discovery |
| Characteristic | 包含具体数据的可读/可写/可通知属性 | 0x2A19 (Battery Level) | Discover All Characteristics of a Service |
| Descriptor | 描述 Characteristic 的配置或说明(如 CCCD) | 0x2902 (CCCD) | Discover All Descriptors of a Characteristic |
核心规则:Central 必须逐层发现:先枚举 Primary Service,然后对每个 Service 枚举其 Characteristic,最后可选枚举 Characteristic 的 Descriptor。跳过某个层级将导致无法正确访问属性。
4.2 Primary Service 发现流程
BLE 规范定义了两种发现服务的请求:Read By Group Type 和 Find By Type Value。最常用的是 Read By Group Type 请求,UUID 为 0x2800(Primary Service)或 0x2801(Secondary Service)。
- Central 发送 ATT Read By Group Type 请求,属性类型设为 0x2800,起始句柄 0x0001,结束句柄 0xFFFF。
- Peripheral 返回一组服务记录,每条记录包含 起始句柄、结束句柄 和 服务 UUID。
- 如果响应中的服务句柄范围未覆盖所有句柄,Central 继续发送下一条请求(从上一响应末尾+1 开始)。
- 重复直到收到错误响应或句柄越界,服务发现结束。
性能提示:大多数设备会在一次响应中返回所有 Primary Service,但若 GATT 表较长(超过 MTU 承载能力),蓝牙协议栈会自动分片。封装发现函数时务必循环处理,不要假设一次完成。
4.3 Characteristic 发现流程
针对每个已发现的 Primary Service,Central 使用 Read By Type 或 Read By Group Type 请求来发现包含在该服务句柄范围内的 Characteristic。请求类型 UUID 为 0x2803(Characteristic Declaration)。
| 步骤 | 操作 | ATT 命令 | 返回内容 |
|---|---|---|---|
| 1 | 设置句柄范围 | 服务起始句柄 ~ 服务结束句柄 | — |
| 2 | 发送 Read By Type | UUID = 0x2803 | 属性句柄 + 特性值句柄 + 属性 + UUID |
| 3 | 解析响应 | — | 提取 UUID、属性、值句柄 |
| 4 | 迭代 | 若响应未覆盖全范围,发送下一段 | 继续发现后续 Characteristic |
常见错误:假设所有 Characteristic 的 UUID 长度为 16-bit。部分自定义服务使用 128-bit UUID,发现时需检查 UUID 长度字段(2字节或16字节)。错误解析 UUID 会导致查找失败。
4.4 发现代码封装(C 伪代码 / API 示例)
以下基于标准 BLE Host API(如 Zephyr、BlueZ、NimBLE)封装一个通用的“发现所有 Primary Service 及其 Characteristic”函数。省略平台细节,突出流程。
// 定义服务与特征的回调结构
typedef struct {
uint16_t start_handle;
uint16_t end_handle;
uint8_t uuid[16];
bool uuid_is_16bit;
} service_t;
typedef struct {
uint16_t decl_handle;
uint16_t value_handle;
uint8_t properties;
uint8_t uuid[16];
bool uuid_is_16bit;
} characteristic_t;
// 发现 Primary Service 的回调
static void on_service_discovered(service_t *svc, void *user_data) {
printf("Service discovered: start=0x%04x, end=0x%04x\n",
svc->start_handle, svc->end_handle);
// 在此服务范围内继续发现特征
discover_characteristics(svc->start_handle, svc->end_handle);
}
// 发现 Characteristic 的回调
static void on_char_discovered(characteristic_t *chr, void *user_data) {
printf("Char discovered: handle=0x%04x, props=0x%02x\n",
chr->value_handle, chr->properties);
// 可选:在此特征下发现描述符
}
// 主发现函数
void discover_gatt_database(uint16_t conn_handle) {
// 第1步:发现 Primary Service
// API 调用示例(Zephyr 风格):
// bt_gatt_discover_all_services(conn_handle, on_service_discovered, NULL);
// 第2步:在回调中触发特征发现
// bt_gatt_discover_characteristics(conn_handle, start, end, on_char_discovered, NULL);
}
封装建议:将发现逻辑封装为状态机或异步队列,避免在回调中执行耗时操作。使用 discovery_state 枚举记录当前发现阶段(IDLE、SERVICE_DISCOVERY、CHAR_DISCOVERY、DESC_DISCOVERY、DONE)。
4.5 调试与常见问题
- 问题:只发现部分 Service — 检查句柄范围是否被截断;确认 Peripheral 未使用 Secondary Service(UUID 0x2801)且你只请求了 Primary。
- 问题:Characteristic 属性为空 — 很多芯片的 Characteristic Declaration 包含属性字节,解析时注意字节序(小端)。
- 问题:发现过程中断 — 可能 MTU 协商未完成,导致响应分片丢失。确保在发现前已通过 Exchange MTU 提升链路吞吐。
- 调试命令示例(使用 HCI 或 BlueZ 原始 ATT):
# 使用 btmon 或 hcidump 监视 ATT 流量
btmon -d hci0
# 手工发送发现请求(仅用于调试)
bluetoothctl
menu gatt
discover-services [device]
discover-characteristics [service-uuid]
4.6 性能与稳定性优化
| 优化项 | 具体做法 | 收益 |
|---|---|---|
| 批量发现 | 一次 Read By Group Type 请求尽可能覆盖大的句柄范围 | 减少 ATT 请求次数,降低延迟 |
| 缓存结果 | 连接断开后保留已发现的 GATT 结构,重连时不重新发现 | 节省至少 300~500ms |
| 异步队列 | 使用独立任务处理发现回调,不阻塞主循环 | 避免丢包或超时 |
| 超时处理 | 每条发现请求设置 5 秒超时,超时后重置状态机 | 防止死锁 |
| MTU 调优 | 发现前协商 MTU 至 247 字节 | 单次响应可携带更多记录 |
总结:GATT 发现是 BLE 开发中最基础也最容易踩坑的环节。牢记层次顺序 Primary → Characteristic → Descriptor,使用迭代方式处理分片响应,并封装成可重入的异步状态机,能显著提高代码稳定性和调试效率。