GATT 层详解:Service、Characteristic、UUID、Profile、数据读写操作

GATT 层,说白了就是 BLE 的「数据交换协议」。

我刚开始做 BLE 项目时,总觉得 GATT 很抽象。后来调了几个月设备,才真正理解它到底在干什么。GATT 定义了一整套规则——数据怎么组织、怎么发现、怎么读写。没有它,两个 BLE 设备就是两个哑巴,互相干瞪眼。

GATT 的核心概念

先看一张整体结构图,把 GATT 的骨架搭起来。

GATT 层级结构 Profile(应用规范) Service(服务) Service(服务) Characteristic Characteristic Characteristic Value(值) Value(值) Value(值) Descriptor(描述符) Descriptor(描述符) Descriptor(描述符) 一个 Profile 包含多个 Service,一个 Service 包含多个 Characteristic

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 数据交换,从发现服务到读写,大概分这几步:

  1. 服务发现:客户端请求服务列表,服务端返回所有 Service 和 Characteristic
  2. 使能通知:客户端写 CCCD(客户端特征配置描述符),开启 Notify
  3. 数据推送:服务端主动发送 Notify/Indicate
  4. 数据读写:客户端发起 Read/Write 请求

我调试时最喜欢用 nRF Connect 这个 App。它能直接看到 GATT 表,手动读写数据,比看日志快多了。

总结一下

GATT 层说白了就是一套「数据组织规范」。Profile 定规矩,Service 分功能,Characteristic 存数据,UUID 做标识。读写操作就是客户端和服务端之间的数据交换。

做项目时记住一句话:先定 Profile,再定 Service,最后定 Characteristic。顺序搞反了,后面改起来很痛苦。我见过一个团队先写代码再画 GATT 表,结果 Service 嵌套了三层,调试时自己都找不到数据在哪。

嗯,GATT 层就聊到这儿。下一层是 ATT 层,负责底层的数据传输——那是另一个故事了。