GATT 层详解:Service、Characteristic、UUID、Profile、数据读写操作
GATT 层,说白了就是 BLE 的「数据交换协议」。
我刚开始做 BLE 项目时,总觉得 GATT 很抽象。后来调了几个月设备,才真正理解它到底在干什么。GATT 定义了一整套规则——数据怎么组织、怎么发现、怎么读写。没有它,两个 BLE 设备就是两个哑巴,互相干瞪眼。
GATT 的核心概念
先看一张整体结构图,把 GATT 的骨架搭起来。
UUID:全球唯一的身份标识
UUID 就是每个 Service 和 Characteristic 的「身份证号」。BLE 用 16 位和 128 位两种 UUID。
| 类型 | 长度 | 举例 | 用途 |
|---|---|---|---|
| 标准 UUID | 16 位 | 0x180D(心率服务) | 蓝牙 SIG 定义的标准服务 |
| 自定义 UUID | 128 位 | 0000xxxx-0000-1000-8000-00805F9B34FB | 厂商自定义私有服务 |
我的经验:做产品时尽量复用标准 UUID。有一次我图省事,给心率服务用了自定义 UUID,结果手机端通用健康 App 全都不识别。用户只能装我们的专用 App,体验很差。后来老老实实改回 0x180D,问题秒解。
Service:服务的容器
Service 是一组相关功能的集合。比如「电池服务」包含电量百分比、充电状态等数据。
每个 Service 都有一个 UUID 标识。Service 之间可以互相包含——这叫「包含服务」(Include Service)。
我做过一个智能门锁项目,里面有个「门锁服务」,UUID 是自定义的 128 位。它包含了「开锁服务」和「状态服务」。这种嵌套结构在复杂设备里很常见。
Characteristic:真正的数据载体
Characteristic 才是真正存数据的地方。它有三个组成部分:
- 声明(Declaration):固定格式,包含属性、值句柄、UUID
- 值(Value):实际数据,比如温度值 36.5
- 描述符(Descriptor):对值的补充说明,比如单位、配置信息
每个 Characteristic 都有属性(Properties),决定了你能对它做什么:
| 属性 | 含义 | 实际场景 |
|---|---|---|
| Read | 客户端可以读 | 读取设备型号 |
| Write | 客户端可以写 | 发送开锁指令 |
| Notify | 服务端主动推送 | 实时心率数据 |
| Indicate | 带确认的推送 | 重要告警信息 |
注意:Notify 和 Indicate 的区别在于确认机制。Notify 发完就忘,Indicate 必须等客户端确认。我遇到过用 Notify 传关键数据,结果丢包了设备也没重传,用户那边显示异常。后来改成 Indicate,问题解决。
Profile:应用层的规范
Profile 不是实体,而是一份「合同」。它规定了设备应该包含哪些 Service、Characteristic,以及它们的行为方式。
举个例子,心率 Profile(HRS)要求:
- 必须包含心率测量 Characteristic(0x2A37)
- 必须支持 Notify 属性
- 可选包含电池服务
我做过一个体温贴项目,客户要求兼容 Apple Health。那就必须严格按照 Health Thermometer Profile 来设计 Service 和 Characteristic。少一个描述符,iPhone 就不认。
数据读写操作实战
理论说完了,上代码。这是我在 nRF52832 上实际跑过的 GATT 读写示例。
1. 定义 Service 和 Characteristic
// 自定义服务 UUID:0xFFE0
// 自定义特征 UUID:0xFFE1
// 添加服务
sd_ble_gatts_service_add(BLE_GATTS_SRVC_TYPE_PRIMARY,
&custom_service_uuid,
&service_handle);
// 添加特征
ble_gatts_char_md_t char_md = {0};
char_md.char_props.read = 1;
char_md.char_props.write = 1;
char_md.char_props.notify = 1;
// 设置特征值
ble_gatts_attr_md_t attr_md = {0};
attr_md.vloc = BLE_GATTS_VLOC_STACK;
sd_ble_gatts_characteristic_add(service_handle,
&char_md,
&attr_value,
&char_handle);
2. 处理写请求
// 在 BLE 事件回调中
case BLE_GATTS_EVT_WRITE:
{
uint8_t *data = p_ble_evt->evt.gatts_evt.params.write.data;
uint16_t len = p_ble_evt->evt.gatts_evt.params.write.len;
// 处理收到的数据
handle_received_data(data, len);
break;
}
3. 发送 Notify 通知
// 准备数据
uint8_t heart_rate = 72;
uint16_t len = 1;
// 发送通知
sd_ble_gatts_hvx(conn_handle,
&hvx_params);
// hvx_params.type = BLE_GATT_HVX_NOTIFICATION
避坑指南:我曾经在 Notify 发送频率上踩过坑。心率模块每秒产生 100 个数据点,我直接全发出去,结果手机端蓝牙缓冲区爆了,连接直接断开。后来加了限速——每秒最多发 10 个 Notify,每个 Notify 打包多个数据点。稳定多了。
GATT 数据流完整流程
一个完整的 GATT 数据交换,从发现服务到读写,大概分这几步:
- 服务发现:客户端请求服务列表,服务端返回所有 Service 和 Characteristic
- 使能通知:客户端写 CCCD(客户端特征配置描述符),开启 Notify
- 数据推送:服务端主动发送 Notify/Indicate
- 数据读写:客户端发起 Read/Write 请求
我调试时最喜欢用 nRF Connect 这个 App。它能直接看到 GATT 表,手动读写数据,比看日志快多了。
总结一下
GATT 层说白了就是一套「数据组织规范」。Profile 定规矩,Service 分功能,Characteristic 存数据,UUID 做标识。读写操作就是客户端和服务端之间的数据交换。
做项目时记住一句话:先定 Profile,再定 Service,最后定 Characteristic。顺序搞反了,后面改起来很痛苦。我见过一个团队先写代码再画 GATT 表,结果 Service 嵌套了三层,调试时自己都找不到数据在哪。
嗯,GATT 层就聊到这儿。下一层是 ATT 层,负责底层的数据传输——那是另一个故事了。