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 服务中的分配逻辑:

GATT 服务 Handle 分配示例 Service: 心率服务 (UUID: 0x180D) Handle: 0x0010 Characteristic 声明 (UUID: 0x2803) Handle: 0x0011 心率测量值 (UUID: 0x2A37) Handle: 0x0012 CCCD (UUID: 0x2902) Handle: 0x0013 用户描述 (UUID: 0x2901) Handle: 0x0014 Handle 从 0x0010 开始,每个属性依次递增:Service → 声明 → 值 → CCCD → 描述符

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 的基石。搞懂了它们,后面的联调工作才能顺利进行。