GATT 数据模型深度解析:Service、Characteristic、Descriptor 的层级关系

好,咱们今天来啃 GATT 数据模型这块硬骨头。说实话,我刚接触 BLE 时,被 Service、Characteristic、Descriptor 这三个东西绕得晕头转向。后来在做一个心率监测项目时,因为把 Characteristic 的属性配错了,设备死活连不上手机——嗯,从那以后我才真正搞懂它们之间的关系。

说白了,GATT 的数据模型就是一个树形结构。你想想看,它就像你电脑里的文件夹系统:最顶层是 Service(文件夹),里面放着 Characteristic(文件),而 Descriptor(文件属性)则描述这个文件的具体信息。

1. 层级关系:从 Service 到 Descriptor

咱们先看一张整体结构图,我习惯用这种方式来理解复杂概念:

GATT 数据模型层级结构 GATT 数据模型层级结构 Profile(应用规范) Service(服务) Service(服务) Service(服务) Characteristic Characteristic Characteristic Characteristic Descriptor Descriptor Descriptor Descriptor Descriptor 关键说明 • 一个 Profile 包含多个 Service • 一个 Service 包含多个 Characteristic • 一个 Characteristic 包含多个 Descriptor

这张图把层级关系讲得很清楚了。从上到下依次是:Profile → Service → Characteristic → Descriptor。我刚开始做项目时,总以为 Profile 是必须的,其实它只是蓝牙 SIG 定义的一个规范文档,真正在设备上跑的是 Service。

2. Service:服务的容器

Service 说白了就是一组相关功能的集合。比如你做一个智能灯泡,就需要一个「灯控服务」;做心率带,就需要一个「心率服务」。每个 Service 都有一个唯一的 UUID 来标识。

我记得有一次调试一个温湿度计,发现手机扫描不到设备,折腾了半天——原来是 Service 的 UUID 写错了。所以这里要特别提醒:

⚠️ 注意:Service 分为两种:Primary Service(主服务)和Secondary Service(次要服务)。主服务是独立暴露给外部的,次要服务通常被其他服务引用。绝大多数场景下,你只需要 Primary Service。

3. Characteristic:数据的载体

Characteristic 才是真正干活的东西。它包含三个部分:

  • 声明(Declaration):固定格式,包含属性、句柄和 UUID
  • 值(Value):实际的数据内容
  • 描述符(Descriptor):对值的补充说明

这里有个关键点——Characteristic 的属性(Properties)决定了你能对它做什么。我见过太多新手把 Read 和 Notify 搞混,结果数据读不出来。

属性 含义 典型场景
Read 客户端可以读取值 读取设备型号
Write 客户端可以写入值 发送控制指令
Notify 设备主动通知客户端(无需确认) 实时心率数据
Indicate 设备主动通知客户端(需要确认) 关键告警信息
💡 我的经验:Notify 和 Indicate 的区别在于可靠性。Notify 速度快但可能丢包,Indicate 可靠但慢。做数据采集时,如果数据量不大,我建议用 Indicate;如果是高频传感器数据,用 Notify 更合适。

4. Descriptor:数据的说明书

Descriptor 是很多人容易忽略的部分。它用来描述 Characteristic 的额外信息,比如:

  • Client Characteristic Configuration Descriptor (CCCD):最常用的描述符,用来开启/关闭 Notify 或 Indicate
  • Characteristic User Description:给用户看的文字说明
  • Characteristic Presentation Format:描述数据的格式(比如单位、精度)

我曾经在一个项目中,因为忘记配置 CCCD,导致手机收不到 Notify 数据。排查了整整两天才发现——嗯,从那以后我每次写代码都会检查 CCCD 是否配置正确。

5. UUID 的定义与类型

UUID 就是每个 Service、Characteristic、Descriptor 的身份证。BLE 协议定义了三种 UUID 长度:

类型 长度 使用场景 示例
16-bit UUID 2 字节 蓝牙 SIG 标准服务/特征 0x180D(心率服务)
32-bit UUID 4 字节 蓝牙 SIG 预留,实际很少用 0x0000180D
128-bit UUID 16 字节 自定义服务/特征 0000xxxx-0000-1000-8000-00805F9B34FB

这里有个坑:很多人以为 16-bit UUID 和 128-bit UUID 可以随便混用。其实不行。标准服务必须用 16-bit,自定义服务必须用 128-bit。我见过有人把自定义服务写成 16-bit,结果手机端解析出错。

🔑 关键点:128-bit UUID 的格式是固定的:0000xxxx-0000-1000-8000-00805F9B34FB。其中 xxxx 是你的自定义部分。蓝牙 SIG 规定,自定义 UUID 必须使用这个基址,不能自己乱改。

6. Attribute Protocol (ATT) 基础

ATT 是 GATT 的底层传输协议。说白了,GATT 定义的是「数据长什么样」,ATT 定义的是「数据怎么传」。ATT 的核心概念是属性(Attribute),每个属性包含:

  • 句柄(Handle):属性的唯一编号,从 0x0001 开始递增
  • 类型(Type):就是 UUID
  • 权限(Permissions):读、写、加密等
  • 值(Value):实际数据

ATT 定义了 6 种 PDU(协议数据单元)类型,最常用的几个:

PDU 类型 方向 作用
Read Request / Response Client → Server → Client 读取属性值
Write Request / Response Client → Server → Client 写入属性值(需要确认)
Write Command Client → Server 写入属性值(无需确认)
Handle Value Notification Server → Client Notify 数据推送
Handle Value Indication Server → Client → Server Indicate 数据推送(需要确认)

我刚开始做 BLE 时,总搞不清 Write Request 和 Write Command 的区别。后来在做一个固件升级项目时,因为用了 Write Command 来传输固件包,结果丢包导致升级失败——Write Command 不保证送达,而 Write Request 会返回确认。

⚠️ 重要提醒:ATT 的 MTU(最大传输单元)默认是 23 字节,其中 3 字节是 ATT 头部,实际有效数据只有 20 字节。如果你要传输大数据,需要先协商 MTU。这个我在后面的章节会详细讲。

7. 实战中的层级关系示例

咱们用一个实际的心率监测设备来串一遍整个模型:

Profile: Heart Rate Profile (HRP)
  └── Service: Heart Rate Service (UUID: 0x180D)
      ├── Characteristic: Heart Rate Measurement (UUID: 0x2A37)
      │   ├── Properties: Notify
      │   ├── Value: [心率数据]
      │   └── Descriptor: CCCD (UUID: 0x2902)
      │       └── Value: 0x0001 (开启 Notify)
      ├── Characteristic: Body Sensor Location (UUID: 0x2A38)
      │   ├── Properties: Read
      │   └── Value: 0x01 (胸部)
      └── Characteristic: Heart Rate Control Point (UUID: 0x2A39)
          ├── Properties: Write
          └── Value: [控制指令]

你看,这个结构非常清晰。每个 Characteristic 都有明确的用途,Descriptor 为它们提供额外的配置能力。我在实际项目中,都是先画好这个树形图,再开始写代码——这样能避免很多低级错误。

💡 我的习惯:在开始写 GATT 服务代码之前,先用表格把 Service、Characteristic、Descriptor 的 UUID、属性、权限列清楚。这个表格就是你的「数据字典」,调试时对照着查,效率高很多。