2、GATT与属性协议:GATT角色定义、Service与Characteristic、UUID分配规则

好,咱们接着聊。上一章我们把蓝牙协议栈的底子打好了,这一章要进入真正干活的部分——GATT。说白了,GATT就是蓝牙设备之间怎么“聊天”的规矩。我当年第一次做蓝牙项目时,被GATT的角色定义绕得晕头转向,后来踩了几个坑才明白,其实没那么复杂。

2.1 GATT角色定义:谁说了算?

GATT定义了两种角色:GATT ServerGATT Client。你想想看,就像医生和病人——医生(Server)有你的病历数据,病人(Client)来查询或修改这些数据。

  • GATT Server:持有数据的一方。比如按摩仪,它存储了力度、模式、电量等信息。
  • GATT Client:发起请求的一方。比如手机App,它读取按摩仪的状态,或者下发控制指令。

嗯,这里要注意:一个设备可以同时是Server和Client。我在项目中就遇到过这种情况——按摩仪既是Server(给手机上报数据),又是Client(从另一个传感器读取温度)。但大多数场景下,按摩仪只做Server,手机只做Client。

核心原则:谁提供数据,谁就是Server。谁消费数据,谁就是Client。

2.2 Service与Characteristic:数据的骨架

GATT的数据组织方式,我习惯把它想象成一个文件柜:

  • Service(服务):相当于一个抽屉。每个抽屉代表一类功能,比如“电池服务”、“设备信息服务”。
  • Characteristic(特征):相当于抽屉里的文件夹。每个文件夹存放具体的数据项,比如“电池电量”、“设备名称”。
  • Descriptor(描述符):相当于文件夹上的标签。它描述了这个特征怎么用,比如“是否支持通知”、“单位是什么”。

举个例子,按摩仪的“力度控制服务”可以这样设计:

Service: 力度控制服务 (UUID: 0xFFE0)
  ├── Characteristic: 当前力度 (UUID: 0xFFE1)
  │   ├── Properties: Read, Write
  │   └── Value: 0-100 (表示力度百分比)
  │   └── Descriptor: 用户描述 (UUID: 0x2901)
  │       └── Value: "当前按摩力度"
  └── Characteristic: 力度档位 (UUID: 0xFFE2)
      ├── Properties: Read, Write, Notify
      └── Value: 1-5 (表示档位)

我个人习惯,每个Service只放3-5个Characteristic。太多了,手机端解析起来麻烦,而且容易出错。我曾经见过一个项目,一个Service里塞了20多个Characteristic,调试时简直想骂人。

2.3 UUID分配规则:别撞车!

UUID就是每个Service和Characteristic的身份证号。蓝牙规范里有两类UUID:

类型 长度 用途 示例
16-bit UUID 2字节 蓝牙联盟定义的标准服务 0x180A (设备信息服务)
128-bit UUID 16字节 厂商自定义服务 0000FFE0-0000-1000-8000-00805F9B34FB

这里有个坑:千万别用16-bit UUID做自定义服务。为什么?因为16-bit UUID是蓝牙联盟分配好的,你随便用可能会跟别人的标准服务冲突。我刚开始做时图省事,用了0xFFE0这种看起来“没人用”的16-bit UUID,结果跟某款手机的蓝牙库冲突了,查了两天才发现是UUID撞车。

避坑指南:自定义Service和Characteristic,一律用128-bit UUID。别偷懒,否则后面有你哭的。

那128-bit UUID怎么生成?我建议用在线UUID生成器,或者直接用固定模板:

// 我常用的自定义UUID模板
// 基础UUID: 0000XXXX-0000-1000-8000-00805F9B34FB
// 其中XXXX替换为自定义的16位值

#define SERVICE_UUID        "0000FFE0-0000-1000-8000-00805F9B34FB"
#define CHAR_UUID_LEVEL     "0000FFE1-0000-1000-8000-00805F9B34FB"
#define CHAR_UUID_MODE      "0000FFE2-0000-1000-8000-00805F9B34FB"

嗯,这里要注意:UUID的字节序问题。蓝牙协议里UUID是小端存储,但很多SDK帮你处理好了。如果你要手动拼数据包,记得检查一下字节序,否则会出大问题。

2.4 实战经验:按摩仪的GATT设计

好了,理论说完了,咱们看看实际怎么做。以下是我给按摩仪设计的GATT结构:

Service: 设备信息服务 (UUID: 0x180A)  // 标准服务
  ├── Characteristic: 设备名称 (UUID: 0x2A00)
  │   └── Properties: Read
  └── Characteristic: 序列号 (UUID: 0x2A25)
      └── Properties: Read

Service: 按摩控制服务 (UUID: 0000FFF0-...)
  ├── Characteristic: 开关控制 (UUID: 0000FFF1-...)
  │   ├── Properties: Write, Notify
  │   └── Value: 0x00(关) / 0x01(开)
  ├── Characteristic: 力度设置 (UUID: 0000FFF2-...)
  │   ├── Properties: Read, Write
  │   └── Value: 0-100
  └── Characteristic: 模式选择 (UUID: 0000FFF3-...)
      ├── Properties: Read, Write, Notify
      └── Value: 0x00(揉捏) / 0x01(敲打) / 0x02(推拿)

Service: 电池服务 (UUID: 0x180F)  // 标准服务
  └── Characteristic: 电池电量 (UUID: 0x2A19)
      ├── Properties: Read, Notify
      └── Value: 0-100

我个人习惯,把标准服务和自定义服务分开。标准服务用蓝牙联盟的UUID,兼容性好。自定义服务用128-bit UUID,灵活度高。这样手机端用系统蓝牙API就能直接读取电池信息,不用写额外代码。

小技巧:给Characteristic加上Notify属性,这样数据变化时能主动推送给手机,不用手机轮询。省电又高效。

最后说一句,GATT设计没有绝对的对错,但一定要保持一致性。我见过最头疼的项目,同一个产品不同批次用的UUID都不一样,导致App要维护多个版本。所以,设计好之后,写进文档,团队所有人都按这个来。

下一章,咱们聊聊怎么用代码实现这些Service和Characteristic。到时候我会把完整的代码贴出来,一步步带你跑通。