4、GATT Profile 详解:Service、Characteristic、Descriptor 的结构与UUID定义
聊到蓝牙协议栈,很多人一上来就啃 Host 层,结果被各种 Profile 绕晕。其实 GATT 没那么玄乎,它就是个「数据字典」——定义了设备之间怎么读、怎么写、怎么通知数据。我刚开始做 BLE 项目时,也把 Service 和 Characteristic 搞混过,后来踩了几个坑才真正理解。
今天咱们就把 GATT 的骨架拆开看:Service、Characteristic、Descriptor 到底长什么样,UUID 又是怎么一回事。
4.1 GATT 的分层结构
GATT 协议把数据组织成三层:Service → Characteristic → Descriptor。你可以把它想象成一个文件系统:
- Service 就是文件夹,代表一类功能(比如「电池服务」)
- Characteristic 就是文件,里面存着具体数据(比如「电池电量 85%」)
- Descriptor 就是文件的属性标签(比如「这个电量值支持通知」)
每一层都用 UUID 来唯一标识。UUID 就是身份证号,告诉对端「我是谁」。
核心要点:一个 Service 包含多个 Characteristic,一个 Characteristic 可以带零个或多个 Descriptor。数据读写的最小单位是 Characteristic Value。
4.2 Service:功能模块的容器
Service 是最高层级的逻辑分组。每个 Service 代表一个完整的功能模块。比如:
- 电池服务 (0x180F):提供电池电量、充电状态等信息
- 设备信息服务 (0x180A):提供制造商、型号、序列号等
- 心率服务 (0x180D):提供心率测量值、传感器位置等
Service 分为两种:
| 类型 | 说明 | 示例 |
|---|---|---|
| Primary Service | 主要服务,独立存在 | 电池服务、心率服务 |
| Secondary Service | 次要服务,被其他 Service 引用 | 某些私有协议中的辅助数据通道 |
我的经验:设计私有 Service 时,UUID 一定要用 128 位的。16 位 UUID 是蓝牙联盟分配的,乱用会被拒。我之前有个项目用了 0xFFF0,结果认证时被卡住,最后只能改。
4.3 Characteristic:真正的数据载体
Characteristic 是 GATT 的核心。它包含三个部分:
- Declaration:声明,包含 UUID、属性、值句柄
- Value:实际数据,比如温度值、开关状态
- Descriptors:描述符,可选
Characteristic 的 属性 (Properties) 决定了你能对它做什么:
| 属性 | 含义 | 典型场景 |
|---|---|---|
| Read | 客户端可以读取值 | 读取设备名称 |
| Write | 客户端可以写入值 | 设置开关状态 |
| Notify | 服务端主动推送值(无确认) | 实时心率数据 |
| Indicate | 服务端主动推送值(有确认) | 关键告警信息 |
| Write Without Response | 写入无需应答 | 批量数据下发 |
注意:Notify 和 Indicate 必须配合 CCCD (Client Characteristic Configuration Descriptor) 使用。客户端要先订阅,服务端才能推送。我见过有人忘了配 CCCD,结果数据死活推不出来。
4.4 Descriptor:数据的元信息
Descriptor 用来描述 Characteristic 的行为或配置。常用的标准 Descriptor 有:
| UUID | 名称 | 作用 |
|---|---|---|
| 0x2900 | Characteristic Extended Properties | 扩展属性,比如是否支持可靠写入 |
| 0x2901 | Characteristic User Description | 用户可读的描述,比如"温度传感器" |
| 0x2902 | Client Characteristic Configuration | CCCD,控制 Notify/Indicate 开关 |
| 0x2904 | Characteristic Presentation Format | 数据格式,比如单位、精度、指数 |
Descriptor 不是必须的。但如果你想让数据更「智能」,比如告诉 App「这个值是摄氏度,精度 0.1」,那就得用 0x2904。
避坑指南:我曾经在做一个温湿度计项目时,Characteristic Value 直接传了整数 235。App 端以为是 23.5°C,结果实际是 235°C。后来加了 Presentation Format Descriptor,指定格式为 sint16、指数 -1、单位是摄氏度,问题才解决。所以,数据格式一定要在 Descriptor 里说清楚。
4.5 UUID 的定义与分类
UUID 是 128 位的唯一标识符。蓝牙协议栈里用两种:
- 16 位 UUID:蓝牙联盟定义的标准化服务/特征,比如 0x180F、0x2A19
- 128 位 UUID:自定义的私有服务/特征,格式如 0000xxxx-0000-1000-8000-00805F9B34FB
16 位 UUID 其实是 128 位 UUID 的「缩写」。蓝牙核心规范规定:所有 16 位 UUID 都可以扩展为 128 位,基址是:
0000xxxx-0000-1000-8000-00805F9B34FB
其中 xxxx 就是 16 位 UUID。比如 0x180F 实际等于:
0000180F-0000-1000-8000-00805F9B34FB
自定义服务必须用完整的 128 位 UUID。我一般用在线 UUID 生成器,或者直接用工具链自带的生成命令。
小技巧:调试时可以用 nRF Connect 或 LightBlue 扫描设备,直接看 Service/Characteristic 的 UUID 列表。比翻文档快多了。
4.6 实战:定义一个自定义 Service
假设我们要做一个智能灯,需要一个自定义 Service 来控制颜色和亮度。代码片段如下(基于 Zephyr RTOS):
// 自定义 Service UUID: 12345678-1234-5678-1234-56789abcdef0
#define BT_UUID_LIGHT_SERVICE \
BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef0))
// 颜色 Characteristic UUID: 12345678-1234-5678-1234-56789abcdef1
#define BT_UUID_LIGHT_COLOR \
BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef1))
// 亮度 Characteristic UUID: 12345678-1234-5678-1234-56789abcdef2
#define BT_UUID_LIGHT_BRIGHTNESS \
BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef2))
// 注册 Service 和 Characteristic
BT_GATT_SERVICE_DEFINE(light_svc,
BT_GATT_PRIMARY_SERVICE(BT_UUID_LIGHT_SERVICE),
BT_GATT_CHARACTERISTIC(BT_UUID_LIGHT_COLOR,
BT_GATT_CHRC_WRITE,
BT_GATT_PERM_WRITE,
NULL, on_color_write, NULL),
BT_GATT_CHARACTERISTIC(BT_UUID_LIGHT_BRIGHTNESS,
BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_READ,
on_brightness_read, NULL, NULL),
BT_GATT_CCC(on_brightness_ccc_cfg),
);
这段代码定义了一个 Primary Service,包含两个 Characteristic:
- 颜色:只写,客户端设置 RGB 值
- 亮度:可读 + 通知,带 CCCD,客户端可以订阅亮度变化
嗯,这里要注意:CCCD 必须配在支持 Notify/Indicate 的 Characteristic 后面,否则蓝牙协议栈会报错。
4.7 总结一下
GATT Profile 的结构其实不复杂:
- Service 是功能模块,用 UUID 标识
- Characteristic 是数据点,有属性决定读写权限
- Descriptor 是附加信息,比如配置、格式、描述
- UUID 是身份证,16 位标准,128 位自定义
做蓝牙开发,理解这三层就够了。剩下的就是根据业务需求,把数据塞进合适的 Characteristic 里。别把温度值和开关状态塞到同一个 Characteristic 里——我见过有人这么干,结果 App 端解析时一脸懵。