一、BLE与GATT概述:先搞懂协议骨架

做 BLE 开发,绕不开 GATT。说白了,GATT 就是蓝牙低功耗设备之间交换数据的规则。你想想看,一台心率带、一个温度传感器、一个智能门锁,它们怎么跟手机 App 对话?就是靠 GATT 这个“语言框架”。

我在项目里见过不少新手直接上手写 BLE 代码,结果发现连 Service 和 Characteristic 都分不清——嗯,那后面调试的时候肯定寸步难行。所以第一节课,我们先稳稳地把 GATT 的核心概念吃透。

1.1 GATT 是什么?

GATT(Generic Attribute Profile)是蓝牙协议栈中负责数据交互的层。它建立在 ATT(Attribute Protocol)之上,定义了 服务(Service)、特征(Characteristic)、描述符(Descriptor) 这三个核心元素。

核心要点:

  • GATT 是 C/S(客户端/服务器)模型。手机通常是客户端,设备端是服务器。
  • 数据以“属性”的形式存储,每个属性都有 UUID、权限和值。
  • GATT 负责把属性组织成有意义的业务单元。

我个人习惯把 GATT 比作一个文件系统:

  • 属性 = 文件块
  • 服务 = 文件夹
  • 特征 = 里面的具体文件
  • 描述符 = 文件的元信息(作者、日期、权限等)

你想想看,这样理解是不是清晰多了?

1.2 服务(Service)—— 功能的容器

一个 BLE 设备可以包含多个 Service。每个 Service 代表一类独立功能。比如:
电池服务(0x180F)、设备信息服务(0x180A)、心率服务(0x180D)。

服务本身不存数据,它只是把相关的特征“打包”在一起。你可以理解成它就是一个“组”。

我记得有一次调试一款智能手环,厂商自定义了一个 Service,UUID 长长一串。结果我读不到特征值,后来才发现他们 Service 的 UUID 写错了——花了两天才定位到。所以 UUID 必须严格匹配,这是避坑第一课。

1.3 特征(Characteristic)—— 真正的数据接口

Characteristic(特征)才是我们读写数据的实际手柄。一个 Service 下面可以有一个或多个 Characteristic。每个 Characteristic 包含:

  • 声明(Declaration):固定属性,描述特征本身
  • 值(Value):数据本体,比如电量百分比 85%
  • 描述符(Descriptor):可选的附加信息

特征还带了 属性权限:

属性名 含义 举例
Read 客户端可以读取值 读取固件版本
Write 客户端可以写入值 写入目标温度
Notify 服务端主动推送值(需要客户端开启CCC描述符) 实时心率数据
Indicate 类似Notify,但需要确认 高精度传感器校准

这里要注意:Notify 和 Indicate 不要求客户端一直轮询,省电的关键就在这里。我在做运动手环时,一开始用了 Read 轮询,结果电量一天就耗光。换成 Notify 后,续航直接翻倍——这是血的教训啊。

1.4 描述符(Descriptor)—— 特征的“说明书”

描述符是附着在特征上的附加属性。最经典的就是 客户端特征配置描述符(CCCD),UUID 固定为 0x2902。它用来控制是否开启 Notify 或 Indicate。

其他常见描述符:

  • 用户描述描述符(0x2901):给人看的名称,比如“电量百分比”
  • 表示格式描述符(0x2904):描述数据类型、单位、指数
  • 范围描述符(0x2906):限制值的最大最小值

我曾经遇到一个问题:读心率值时返回了一个很大的负数,查了半天才发现是表示格式描述符里的指数字段没解析正确。所以说,描述符不仅仅是摆设,它直接影响数据解析。

1.5 一个完整的例子:电池服务

拿最常用的电池服务(Battery Service,UUID = 0x180F)来说:

Service: 0x180F (Battery Service)
├── Characteristic: 0x2A19 (Battery Level)
│   ├── Value: 0-100 百分比
│   ├── Properties: Read, Notify
│   └── Descriptor: 0x2902 (CCCD)
│       └── Value: 0x0001 开启 Notify
│       └── Descriptor: 0x2901 (User Description)
│           └── Value: "Battery Percentage"

解读一下:

  • Service 声明了这是一个电池相关的功能组。
  • Characteristic 0x2A19 存的是电量百分比,可读也可通知。
  • CCCD 描述符控制是否让设备主动上报电量变化。
  • 用户描述描述符是个可选的信息。

当你用手机连接一个支持电池服务的蓝牙耳机时,App 只需要读取 Battery Level 的值就能显示电量。如果开启了 Notify,耳机电量从 80% 降到 79%,它会自动告诉手机——不需要手机一直问。

1.6 GATT 结构图:一张图看清三层关系

下面这张 SVG 图把 GATT 的层级结构画出来了。我建议你把它存下来,后面写代码时随时对照。

GATT 协议层级结构图 Profile(应用规范) Service 1 (UUID: 0x180F) Service 2 (UUID: 0x180A) Characteristic 1 (UUID: 0x2A19) Properties: Read, Notify Value: 0-100 (battery level) Descriptor 0x2902 (CCCD) Value: 0x0001 (Notify enabled) Descriptor 0x2901 (User Description) 属性是GATT的最小数据单元,服务、特征、描述符都是由属性构成的逻辑容器。 示意图仅展示典型层级,实际设备可以包含多个服务和特征。

1.7 核心概念速查表

概念 英文 作用 类比
服务 Service 将相关特征分组,代表一个功能模块 文件夹
特征 Characteristic 数据读写的实际接口 文件
描述符 Descriptor 附加的元数据或配置 文件属性
属性 Attribute GATT的最小数据单元,包含UUID、权限、值 磁盘上的字节块
UUID UUID 唯一标识一个服务、特征或描述符 文件名

我的小技巧:

在 Android 开发中,我习惯先用手册或 nRF Connect 扫描设备,把 Service 和 Characteristic 的 UUID 全部记录成常量类。这样后面写 Kotlin 代码时直接引用,不会写错。

曾经踩过的坑:

我第一次写 BLE 读写代码时,忽略了特征属性权限。明明设备支持 Notify,我却用了 Read 去轮询,结果设备返回了空值。后来发现:每次操作前先用 BluetoothGattCharacteristic.getProperties() 检查权限。这句话,建议你记下来。