GATT 服务结构:三层架构与核心机制
聊到 BLE 开发,GATT 服务结构是绕不开的核心。说白了,它就是蓝牙设备之间「怎么说话、说什么话」的规则。我刚开始接触时也被 Service、Characteristic、Descriptor 这三层绕得头晕,后来亲手调了几个项目才真正吃透。
嗯,咱们今天就把这三层架构掰开揉碎,再聊聊 UUID 和 Handle 这两个关键机制。
三层架构:Service → Characteristic → Descriptor
GATT 的数据模型是典型的树形结构。你可以把它想象成一个文件系统:
- Service 就像文件夹,代表一类功能
- Characteristic 就像文件,里面存着具体数据
- Descriptor 就像文件的属性描述,告诉你怎么读这个文件
我习惯用「服务-特征-描述符」这个顺序去理解,从上往下,一层包一层。
Service(服务)
Service 是功能的逻辑分组。比如一个心率设备,会有「心率服务」「电池服务」「设备信息服务」。每个 Service 都包含一个或多个 Characteristic。
举个例子,标准的心率服务 UUID 是 0x180D。你只要看到这个 UUID,就知道这个设备能测心率。
关键点:Service 可以包含其他 Service(称为 Include Service),但实际项目中用得不多。我一般建议保持扁平结构,调试起来省心。
Characteristic(特征)
Characteristic 是 GATT 数据交换的最小单元。它有三个组成部分:
- 声明(Declaration):固定格式,包含属性、Handle、UUID
- 值(Value):实际数据存放的地方
- 描述符(Descriptor):可选,用于描述值的属性
每个 Characteristic 都有属性(Properties),决定了你能对它做什么:
| 属性 | 含义 | 常见场景 |
|---|---|---|
| Read | 客户端可以读取值 | 设备名称、序列号 |
| Write | 客户端可以写入值 | 控制指令、配置参数 |
| Notify | 设备主动推送数据(无确认) | 心率数据、传感器读数 |
| Indicate | 设备主动推送数据(有确认) | 重要告警、状态变更 |
我的经验:Notify 和 Indicate 的区别很多人搞混。Notify 发出去就不管了,适合高频数据;Indicate 会等对方确认,适合关键事件。我在做血糖仪项目时,血糖告警必须用 Indicate,因为漏掉一次可能出医疗事故。
Descriptor(描述符)
Descriptor 是 Characteristic 的「说明书」。最常见的两个:
- Client Characteristic Configuration Descriptor (CCCD):UUID 为
0x2902,用来开启/关闭 Notify 或 Indicate - Characteristic User Description:UUID 为
0x2901,存一个可读的字符串描述
CCCD 是调试中最容易出问题的地方。我曾经遇到一个 case:手机端发了写 CCCD 的指令,但设备没响应。查了半天,发现是设备端没把 CCCD 的 Write 属性打开。嗯,这种坑踩过一次就记住了。
UUID 定义与分配
UUID(Universally Unique Identifier)是 GATT 世界里唯一的「身份证」。它分两种:
16-bit UUID(标准 UUID)
由 Bluetooth SIG 组织定义,用于通用的服务和特征。比如:
| UUID | 含义 |
|---|---|
| 0x1800 | Generic Access Service |
| 0x180A | Device Information Service |
| 0x2A37 | Heart Rate Measurement |
| 0x2A19 | Battery Level |
128-bit UUID(自定义 UUID)
当标准 UUID 不够用时,你就得自己定义。格式是 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx。
我建议自定义 UUID 时遵循一个规则:固定一个 base UUID,只改最后几位。比如:
// 我的产品 base UUID
#define BASE_UUID "12345678-1234-5678-1234-56789abcdef0"
// 不同特征用不同后缀
#define SERVICE_UUID "12345678-1234-5678-1234-56789abcdef1"
#define CHAR_READ_UUID "12345678-1234-5678-1234-56789abcdef2"
#define CHAR_WRITE_UUID "12345678-1234-5678-1234-56789abcdef3"
注意:128-bit UUID 在协议栈里占 16 字节,传输效率比 16-bit UUID 低。如果你的产品对功耗敏感,尽量复用标准 UUID。我见过有人把所有特征都定义成 128-bit,结果每次连接都要多传几十个字节,白白浪费电量。
Handle 机制
Handle 是 GATT 协议栈给每个属性分配的 16-bit 索引。它从 0x0001 开始递增,每个 Service、Characteristic、Descriptor 都有唯一的 Handle。
为什么需要 Handle?说白了,就是为了快速定位。UUID 虽然唯一,但比较起来慢(尤其是 128-bit)。Handle 就是个数字,直接比较,效率高得多。
Handle 的分配规则
我画了一张图,帮你理解 Handle 在 GATT 服务中的分配逻辑:
Handle 的实战意义
在联调过程中,Handle 是定位问题的利器。比如你用抓包工具看到一条 Write Request,目标 Handle 是 0x0013,你马上就能知道这是在写 CCCD。
我习惯在代码里把 Handle 定义成宏,方便调试:
// 定义 Handle 偏移
#define HANDLE_SERVICE 0x0010
#define HANDLE_CHAR_DECL 0x0011
#define HANDLE_CHAR_VALUE 0x0012
#define HANDLE_CCCD 0x0013
#define HANDLE_USER_DESC 0x0014
// 判断是否在写 CCCD
if (p_event->handle == HANDLE_CCCD) {
// 处理 Notify/Indicate 开关
}
避坑指南:我曾经在 Nordic 平台上遇到一个坑——Handle 分配不是连续的。因为协议栈内部会预留一些 Handle 给 GAP Service。所以千万别硬编码 Handle 值,一定要通过 API 动态获取。否则换个芯片平台,代码就废了。
三层架构与 Handle 的关系
最后总结一下这三层架构和 Handle 的关系:
- Service:一个 Handle,指向 Service 定义
- Characteristic:占用两个 Handle(声明 + 值),声明里记录了值的 Handle
- Descriptor:每个 Descriptor 一个 Handle,跟在 Characteristic 值后面
你想想看,手机端要读心率数据,流程是这样的:先通过 Service UUID 找到 Service Handle,再遍历下面的 Characteristic,找到 UUID 为 0x2A37 的那个,然后读它的 Value Handle。每一步都离不开 Handle 的索引作用。
嗯,这一章的内容就到这儿。三层架构、UUID、Handle,这三个概念是 GATT 的基石。搞懂了它们,后面的联调工作才能顺利进行。