2. GATT协议详解:BLE GATT架构、Service/Characteristic/Descriptor层级关系、UUID定义

好,咱们进入第二章。上一章我们把UWB和BLE的物理层、协议栈捋了一遍,这一章要啃的是GATT——通用属性协议。说白了,它就是BLE设备之间“怎么交换数据”的那套规矩。

我刚开始做BLE项目时,总觉得GATT这玩意儿有点绕。Service、Characteristic、Descriptor,三层嵌套,还有UUID那一大串十六进制数……但后来我发现,只要理解了它的设计哲学,这东西其实特别优雅。你想想看,它本质上就是一个轻量级的客户端-服务器模型。

2.1 GATT架构概览:谁是谁的“服务员”?

在BLE的世界里,GATT定义了两种角色:GATT客户端和GATT服务器。

  • GATT服务器:持有数据的一方。比如你的UWB定位基站,它把测距结果、设备状态这些信息组织好,等着别人来读。
  • GATT客户端:发起请求的一方。比如你的手机App,它去读基站上的数据,或者写一些配置指令下去。

嗯,这里要注意:一个设备可以同时是客户端和服务器。比如我们的UWB标签,它既要把自己的位置数据暴露给手机(服务器角色),又可能要去读基站的配置信息(客户端角色)。

核心概念:GATT通信永远是客户端发起请求,服务器响应。服务器不会主动“推”数据给客户端,除非客户端先订阅了通知(Notification)或指示(Indication)。

我在项目中遇到过一个问题:有个同事把UWB标签设计成主动往手机发数据,结果发现手机根本收不到。原因就是他没有理解GATT的“请求-响应”模型。后来改成手机先订阅通知,标签再通过Notification推送数据,问题就解决了。

2.2 层级关系:Service、Characteristic、Descriptor

GATT的数据结构是分层的,像一棵树。我习惯把它想象成一个文件柜:

  • Service(服务):相当于一个抽屉。每个抽屉代表一类功能,比如“电池服务”、“设备信息服务”、“UWB定位服务”。
  • Characteristic(特征):相当于抽屉里的文件夹。每个文件夹里装着一份具体的数据,比如“电池电量百分比”、“设备序列号”、“UWB测距结果”。
  • Descriptor(描述符):相当于文件夹上的标签。它描述了这份数据的属性,比如“这个数据支持通知吗?”、“这个数据的单位是米还是厘米?”。

说白了,就是Service包含Characteristic,Characteristic包含Descriptor。一个Service可以有多个Characteristic,一个Characteristic可以有多个Descriptor。

下面这张图,我画了GATT的层级关系,你一看就明白:

GATT 层级结构图 GATT Server Service 1 Service 2 Service 3 Char 1.1 Char 1.2 Char 2.1 Char 3.1 Char 3.2 Desc 1.1.1 Desc 1.2.1 Desc 2.1.1 Desc 3.1.1 Desc 3.2.1 一个 Service 包含多个 Characteristic 一个 Characteristic 包含多个 Descriptor

2.3 Characteristic:数据的“最小单元”

Characteristic是GATT里最核心的概念。它不仅仅是数据本身,还包含了属性(Properties)和权限(Permissions)。

属性决定了客户端能对这个Characteristic做什么:

属性 含义 典型用途
Read 客户端可以读取这个值 读取设备型号、固件版本
Write 客户端可以写入这个值 下发配置参数、启动测距
Notify 服务器主动推送值(无确认) 实时推送UWB测距结果
Indicate 服务器主动推送值(有确认) 推送关键事件,如设备故障

我的经验:Notify和Indicate的区别在于可靠性。Notify不要求客户端确认,速度快但可能丢包;Indicate要求客户端回复确认,可靠但慢半拍。对于UWB定位数据,我一般用Notify,因为测距频率高(比如10Hz),偶尔丢一帧不影响整体定位。但如果是“设备掉线报警”这种关键事件,我会用Indicate。

2.4 Descriptor:数据的“说明书”

Descriptor是附加在Characteristic上的元数据。最常见的Descriptor是客户端特征配置描述符(CCCD,Client Characteristic Configuration Descriptor)。

CCCD的作用是什么?说白了,它就是客户端用来“订阅”通知或指示的开关。客户端往CCCD里写0x0001,表示“我要收通知”;写0x0002,表示“我要收指示”;写0x0000,就是“我不收了”。

我曾经踩过一个坑:在调试UWB标签时,手机App一直收不到测距数据。我检查了Characteristic的属性,Notify是打开的。但就是收不到。后来发现,我忘了让客户端去写CCCD。没有这个订阅动作,服务器是不会主动推数据的。嗯,这个细节很容易被忽略。

2.5 UUID:全球唯一的“身份证号”

每个Service、Characteristic、Descriptor都需要一个UUID来标识。UUID是128位的,但为了节省BLE的传输带宽,蓝牙SIG定义了一组16位UUID作为“短ID”。

蓝牙SIG标准服务(如电池服务、设备信息服务)都使用16位UUID。比如:

名称 16位UUID 完整128位UUID
电池服务 0x180F 0000180F-0000-1000-8000-00805F9B34FB
设备信息服务 0x180A 0000180A-0000-1000-8000-00805F9B34FB
电池电量特征 0x2A19 00002A19-0000-1000-8000-00805F9B34FB

对于自定义服务(比如我们的UWB定位服务),我们需要使用自定义128位UUID。格式是:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx。

注意:自定义UUID不能与SIG标准UUID冲突。我习惯用在线UUID生成器生成一个随机UUID,然后固定下来。千万别随便写一个,万一跟别人的服务撞上了,设备之间会互相干扰。

2.6 实战:设计一个UWB定位服务的GATT Profile

好了,理论讲完了。咱们来点实际的。假设我们要设计一个UWB定位标签的GATT Profile,它需要暴露以下数据:

  • 标签的硬件版本
  • 当前的测距结果(单位:厘米)
  • 测距启动/停止控制

我们可以这样设计:

Service Characteristic 属性 UUID
设备信息服务 (0x180A) 硬件版本 (0x2A27) Read 标准16位
UWB定位服务 (自定义) 测距结果 Notify 自定义128位
测距控制 Write 自定义128位

对应的代码实现(以Zephyr RTOS为例):

/* 自定义UUID定义 */
#define BT_UWB_SERVICE_UUID \
    BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x9ABC, 0xDEF012345678))
#define BT_UWB_RANGE_CHAR_UUID \
    BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x9ABC, 0xDEF012345679))
#define BT_UWB_CTRL_CHAR_UUID \
    BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x9ABC, 0xDEF012345680))

/* 注册UWB定位服务 */
BT_GATT_SERVICE_DEFINE(uwb_svc,
    BT_GATT_PRIMARY_SERVICE(BT_UWB_SERVICE_UUID),
    BT_GATT_CHARACTERISTIC(BT_UWB_RANGE_CHAR_UUID,
                           BT_GATT_CHRC_NOTIFY,
                           BT_GATT_PERM_NONE,
                           NULL, NULL, NULL),
    BT_GATT_CCC(&uwb_range_ccc_cfg),
    BT_GATT_CHARACTERISTIC(BT_UWB_CTRL_CHAR_UUID,
                           BT_GATT_CHRC_WRITE,
                           BT_GATT_PERM_WRITE,
                           NULL, uwb_ctrl_write_cb, NULL),
);

你看,代码里定义了一个自定义Service,里面有两个Characteristic:一个用于Notify测距结果,一个用于Write控制指令。CCCD也注册了,这样客户端才能订阅通知。

我个人习惯在写GATT Profile时,先把表格画出来,再写代码。表格能帮你理清层级关系,避免漏掉某个Characteristic或Descriptor。

2.7 避坑指南

最后,分享几个我踩过的坑:

  • MTU大小:BLE默认MTU是23字节,其中3字节是协议头,实际有效载荷只有20字节。如果你的UWB测距数据超过20字节(比如包含多个标签的距离),记得要协商更大的MTU,或者分包发送。
  • Notification频率:不要以超过BLE连接间隔的频率发送Notification。我曾经试过10ms发一次,结果手机端疯狂丢包。后来改成50ms一次,稳得很。
  • UUID大小写:自定义UUID在代码里一定要统一大小写。我见过有人在小写和大写之间混用,结果两个设备对不上,折腾了半天。

嗯,这一章的内容就到这里。GATT协议是BLE应用层的核心,理解了它,你就能自由地设计自己的数据交换协议了。