GATT 数据结构:Service、Characteristic、Descriptor 与 UUID、Handle

好,咱们今天来啃一块硬骨头——GATT 的数据结构。说实话,我刚接触 BLE 开发那会儿,被 Service、Characteristic、Descriptor 这三个东西绕得晕头转向。后来调了一个星期的 bug,才真正搞明白它们是怎么串在一起的。你只要把今天的内容吃透,后面写采集代码会顺手很多。

三层结构:Service → Characteristic → Descriptor

GATT 的数据组织方式,说白了就是一个「树形结构」。顶层是 Service,中间是 Characteristic,底层是 Descriptor。我习惯把它想象成一个文件柜:

  • Service 就是柜子里的一个抽屉,代表一类功能。比如「电池服务」、「设备信息服务」。
  • Characteristic 是抽屉里的文件夹,存放具体的数据。比如「电池电量百分比」。
  • Descriptor 是文件夹上的标签或说明,描述这个数据怎么用。比如「电量百分比是 0-100 的整数」。

一个 Service 可以包含多个 Characteristic,一个 Characteristic 又可以包含多个 Descriptor。层级关系非常清晰。

核心要点:Characteristic 是数据交换的最小单元。你读数据、写数据,操作的都是 Characteristic。Service 只是逻辑分组,Descriptor 只是附加说明。

UUID:全球唯一的身份标识

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

类型 长度 示例 说明
16-bit UUID 2 字节 0x180F 蓝牙 SIG 定义的标准服务/特征,比如电池服务
128-bit UUID 16 字节 0000xxxx-0000-1000-8000-00805F9B34FB 自定义服务/特征,由开发者自行分配

我记得第一次做自定义服务时,随手写了个 16-bit UUID,结果跟标准服务冲突了。设备死活连不上,查了两天才发现是 UUID 撞车了。从那以后,自定义服务我全用 128-bit UUID,省心。

小技巧:如果你只是做原型验证,可以用 0000xxxx-0000-1000-8000-00805F9B34FB 这个格式,把 xxxx 换成你喜欢的四位十六进制数。但产品量产前,一定要去蓝牙 SIG 申请正式的 UUID。

Handle:GATT 世界的门牌号

Handle 是一个 16 位的数字,GATT 服务器用它来唯一标识每个 Attribute(属性)。你可以把 Handle 理解为「门牌号」。客户端(比如手机)要读写某个数据,必须知道它的 Handle。

Handle 的分配规则很简单:从 0x0001 开始,按顺序递增。每个 Service 声明、Characteristic 声明、Characteristic Value、Descriptor 都会占用一个 Handle。

举个例子,一个简单的电池服务:

Handle  | Type                | Value
--------|---------------------|-------------------
0x0010  | Service (0x180F)    | 电池服务
0x0011  | Characteristic Decl | 属性: 读, Value Handle: 0x0012
0x0012  | Battery Level       | 当前电量 (0-100)
0x0013  | Client Characteristic Config | 通知开关

你看,一个 Service 声明占一个 Handle,一个 Characteristic 声明占一个 Handle,Characteristic Value 再占一个,Descriptor 再占一个。客户端要读电量,直接发 Read Request 给 Handle 0x0012 就行。

注意:Handle 是动态分配的,不同设备、不同固件版本,同一个服务的 Handle 可能不一样。所以写上位机程序时,千万别硬编码 Handle。我见过有人把 Handle 写死在代码里,换了个设备就全崩了。正确的做法是通过 UUID 去搜索,拿到 Handle 后再操作。

层级关系全景图

下面这张图把整个 GATT 数据结构串起来了。你盯着看两分钟,后面写代码会清晰很多。

GATT 数据结构层级关系图 GATT 数据结构层级关系 GATT Server Service (UUID: 0x180F - 电池服务) Handle: 0x0010 Characteristic (电量) UUID: 0x2A19 Handle: 0x0012 Characteristic (电量状态) UUID: 0x2A1A Handle: 0x0015 CCCD (通知开关) Handle: 0x0013 CPFD (格式说明) Handle: 0x0014 CCCD (通知开关) Handle: 0x0016 说明: • Service 是逻辑分组,Characteristic 是数据实体,Descriptor 是附加信息 • 每个 Attribute 都有唯一的 Handle,客户端通过 Handle 读写数据 • CCCD (0x2902) 是最常用的 Descriptor,用于使能通知/指示

实际项目中的避坑指南

讲完理论,我分享几个实战中容易踩的坑。

坑一:Characteristic 声明和 Value 的 Handle 搞混。

Characteristic 声明本身占一个 Handle,它的 Value 又占一个 Handle。读数据要读 Value 的 Handle,不是声明的 Handle。我曾经在代码里把声明的 Handle 传进去了,读回来的数据全是 0x00。调了半天才发现是 Handle 搞错了。

坑二:Descriptor 不是必须的。

很多人以为每个 Characteristic 都要配 Descriptor。其实不是。只有需要通知/指示功能时,才需要加 CCCD(Client Characteristic Configuration Descriptor)。如果只是简单的读写,完全可以不加 Descriptor。

坑三:UUID 大小写敏感。

标准 UUID 通常用小写,但有些 SDK 内部转成了大写。如果你在代码里写死了大小写,可能会匹配不上。我建议统一用小写,然后在匹配时做大小写不敏感的比较。

我的习惯:每次新建 GATT 服务,我都会先画一张类似上面的层级图,把每个 Handle、UUID、权限都标清楚。画完再写代码,基本不会出大问题。你也不妨试试。

小结

今天的内容就这些。Service、Characteristic、Descriptor 三层结构,UUID 做标识,Handle 做寻址——这四个概念是 GATT 的基石。你理解了它们,后面看任何 BLE 设备的服务表都会觉得眼熟。