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 不只是「一个数据」,它包含三样东西:
- 声明(Declaration) —— 固定格式,包含 UUID、属性、值句柄。这是协议规定的,你改不了。
- 值(Value) —— 实际数据。比如温度值 36.5°C。
- 描述符(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 数据模型的关系画清楚了。我建议你保存下来,开发时对照着看。
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 是否跟你代码里定义的一致。我每次写完服务都会先扫一遍,确认层级结构没问题再继续往下开发。