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 数据结构串起来了。你盯着看两分钟,后面写代码会清晰很多。
实际项目中的避坑指南
讲完理论,我分享几个实战中容易踩的坑。
坑一: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 设备的服务表都会觉得眼熟。