GATT 数据模型:Service、Characteristic、Descriptor 的层级结构

聊到 BLE 开发,GATT 数据模型是绕不开的核心。说白了,它就是蓝牙设备之间「怎么说话、说什么话」的一套规则。我刚开始接触时也觉得抽象,后来亲手搭了几个服务,才真正理解它的设计有多巧妙。

咱们今天就把这个模型拆开揉碎,从根上理清楚。

三层结构:从根到叶

GATT 的数据组织方式,其实特别像文件系统。你想想看:

  • Service(服务) —— 相当于一个文件夹,代表一类功能。比如「电池服务」、「心率服务」。
  • Characteristic(特征) —— 相当于文件夹里的文件,是具体的数据通道。比如「电池电量百分比」、「心率测量值」。
  • Descriptor(描述符) —— 相当于文件的属性标签,描述这个特征怎么用。比如「客户端特征配置(CCCD)」、「用户描述」。

这个层级关系是固定的:一个 Service 包含多个 Characteristic,一个 Characteristic 可以挂多个 Descriptor。不能反过来,也不能跨层嵌套。

核心要点: 真正传输数据的是 Characteristic。Service 只是逻辑分组,Descriptor 是元数据。你在开发时,90% 的精力会花在定义 Characteristic 上。

UUID:全球唯一的身份证

每个 Service、Characteristic、Descriptor 都需要一个 UUID(Universally Unique Identifier)来标识。UUID 分两种:

类型 长度 范围 典型例子
16-bit UUID 2 字节 0x1800 – 0xFFFF 0x180F(电池服务)、0x2A19(电池电量)
128-bit UUID 16 字节 自定义 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

16-bit UUID 是蓝牙 SIG 组织定义的标准服务,比如心率服务(0x180D)、设备信息服务(0x180A)。这些是免费的,直接用就行。

128-bit UUID 是给开发者自定义用的。我见过不少新手直接拿随机数生成器搞一个,结果跟别人的冲突了。嗯,这里要注意:自定义 UUID 必须保证全球唯一。我个人习惯用 UUID 生成工具(比如在线生成器),然后固定下来,写到代码注释里。

小技巧: 如果你只是做原型验证,可以用 SIG 保留的 16-bit UUID 范围(0xFF00 – 0xFFFF)做临时测试。但产品上线前,一定要换成正式的 128-bit UUID。

Characteristic 的三大属性

一个 Characteristic 不只是「一个数据」,它包含三样东西:

  1. 声明(Declaration) —— 固定格式,包含 UUID、属性、值句柄。这是协议规定的,你改不了。
  2. 值(Value) —— 实际数据。比如温度值 36.5°C。
  3. 描述符(Descriptors) —— 可选,但常用。比如 CCCD 用来使能通知。

我举个例子,你马上就能明白:

// 伪代码:定义一个温度特征
characteristic_t temp_char = {
    .uuid = 0x2A1C,           // 温度测量 UUID
    .properties = READ | NOTIFY, // 属性:可读 + 可通知
    .value = {0x00, 0x00},    // 初始值
    .descriptors = {
        {.uuid = 0x2902, .value = 0x0000} // CCCD,默认关闭通知
    }
};

这里有个坑:属性(Properties)和权限(Permissions)是两码事。Properties 告诉对端「我能做什么」,Permissions 告诉协议栈「谁可以做什么」。我曾经在一个项目里把属性设成了可写,但权限没开,结果手机端写数据一直失败,查了半天才发现是权限没配。

避坑指南: 我曾经在量产固件里用了一个 16-bit 自定义 UUID(0xFFE0),结果跟某款手机的蓝牙芯片冲突,导致服务发现失败。从那以后,所有自定义服务我都强制用 128-bit UUID,哪怕只是内部测试。

层级结构图:一图胜千言

下面这张图把 GATT 数据模型的关系画清楚了。我建议你保存下来,开发时对照着看。

GATT 数据模型层级结构 GATT 数据模型层级结构 GATT Server Service (UUID: 0x180D 心率服务) Characteristic (UUID: 0x2A37 心率测量) 声明 (Declaration) 值 (Value) 描述符 (Descriptors) CCCD (0x2902) 用户描述 (0x2901) 其他描述符 说明 • 每个 Service 可包含 多个 Characteristic • 每个 Characteristic 可 包含多个 Descriptor • CCCD 是最常用的

Descriptor 里最常用的那个:CCCD

在所有 Descriptor 里,客户端特征配置描述符(CCCD,0x2902) 是出镜率最高的。它只有两个字节,控制着通知(Notification)和指示(Indication)的开关。

CCCD 值 含义 使用场景
0x0000 关闭通知和指示 默认状态,客户端未订阅
0x0001 使能通知 心率数据持续上报,不需要确认
0x0002 使能指示 关键数据,需要对方确认收到
0x0003 同时使能通知和指示 极少用,一般二选一

我遇到过不少开发者,把 CCCD 的值写反了。0x0001 是通知,0x0002 是指示,别搞混。还有一个常见问题:CCCD 必须由 GATT 客户端(通常是手机)来写,服务端只能读。如果你在服务端代码里硬编码 CCCD 为 0x0001,那是没用的——手机端不订阅,数据就发不出去。

实际开发中的层级设计原则

说了这么多理论,咱们聊聊实战。我总结了几条原则,你可以直接拿来用:

  • 一个 Service 只做一件事。比如「电池服务」只包含电量百分比和电量状态,别把温度、湿度也塞进去。我见过一个产品把所有传感器数据都塞到一个 Service 里,结果手机端解析起来非常痛苦。
  • Characteristic 的粒度要适中。太粗(一个特征包含所有数据)会导致频繁读写;太细(每个数据一个特征)会浪费句柄资源。我个人习惯:一组相关的数据放一个特征,用数组或结构体表示。
  • 能用通知就别用读写。通知不需要手机端主动请求,省电、省带宽。但要注意:通知不保证送达,关键数据(比如门锁开关状态)要用指示。
  • 自定义 Service 的 UUID 一定要注册。虽然不注册也能用,但手机端的蓝牙库可能会缓存错误的 UUID 信息,导致服务发现失败。

调试小技巧: 用 nRF Connect 或 LightBlue 扫描你的设备,看 Service 和 Characteristic 的 UUID 是否跟你代码里定义的一致。我每次写完服务都会先扫一遍,确认层级结构没问题再继续往下开发。