第3章 GATT层详解:Service与Characteristic定义、UUID分配、属性读写通知、体温计Profile设计

好,咱们接着聊。上一章我们把GAP层讲透了,这一章进入GATT层。说实话,GATT层才是蓝牙通信的灵魂所在。你想想看,两个设备连上了,然后呢?总得干点正事吧?GATT就是干这个的——它定义了数据怎么组织、怎么读写、怎么通知。

我在做体温计项目时,最头疼的就是GATT层的设计。一开始觉得不就是定义几个服务嘛,后来发现这里面的坑真不少。今天我把这些经验都倒出来,希望能帮你少走弯路。

3.1 Service与Characteristic:GATT的骨架

GATT层的数据模型,说白了就是一棵树。树根是Profile,树干是Service,树枝是Characteristic,树叶是Descriptor。我习惯这么记:Profile是整套协议,Service是功能模块,Characteristic是具体数据点。

举个例子,体温计Profile里有一个「体温测量服务」(Health Thermometer Service)。这个服务下面,会有一个「体温测量值」的Characteristic。嗯,就是这么个层级关系。

核心概念速记:

  • Service:一组相关功能的集合。比如电池服务、设备信息服务
  • Characteristic:一个具体的数据点。比如温度值、电量百分比
  • Descriptor:对Characteristic的描述或配置。比如客户端特征配置描述符(CCCD)

每个Service和Characteristic都有一个UUID来标识。UUID就是身份证号,全球唯一。蓝牙SIG组织定义了一些标准UUID,比如体温计服务的UUID是0x1809。如果你自己定义私有服务,那就得用128位的UUID。

3.2 UUID分配:标准与自定义的选择

UUID分配这块,我建议你遵循一个原则:能用标准的就别自己造。为什么?因为标准UUID是16位的,省空间。自定义128位UUID,每次通信都要传16个字节,浪费带宽。

体温计相关的标准UUID有哪些?我整理了一个表,你直接拿去用:

名称 UUID 说明
Health Thermometer Service 0x1809 体温计服务
Temperature Measurement 0x2A1C 温度测量值Characteristic
Temperature Type 0x2A1D 温度类型(口温/腋温/肛温)
Measurement Interval 0x2A21 测量间隔

如果你需要自定义数据,比如设备固件版本、校准参数,那就用128位UUID。我一般这么生成:取公司名+功能名的MD5,然后按标准格式排列。注意,UUID的字节序是小端模式,这个坑我踩过。

避坑指南:我曾经在UUID字节序上栽过跟头。定义了一个128位UUID,结果手机端解析出来完全不对。后来发现是大小端搞反了。记住:蓝牙协议里,UUID的字节序是小端(Little Endian)。

3.3 属性读写通知:数据怎么流动

Characteristic定义好了,数据怎么传?GATT定义了四种操作方式:读、写、通知、指示。我一个个说。

读(Read):手机主动问,设备回答。适合查询静态数据,比如设备型号。但体温计这种实时数据,用读就不太合适——你总不能让手机一直轮询吧?

写(Write):手机给设备发数据。比如设置测量间隔、校准参数。写操作分两种:带响应的写(Write Request)和不带响应的写(Write Command)。我建议关键参数用带响应的写,确保设备收到了。

通知(Notify):设备主动推数据给手机,不需要手机确认。体温计测量完成,直接通过通知把温度值发出去。这是最常用的方式,效率高。

指示(Indicate):和通知类似,但手机必须回复确认。适合传输关键数据,比如报警信息。缺点是慢,因为要等确认。

这里有个关键点:通知和指示需要手机端先使能。怎么使能?通过CCCD(客户端特征配置描述符)。手机往CCCD里写0x0001,通知就开了;写0x0002,指示就开了。这个逻辑,我建议你在固件里一定要做校验,防止非法使能。

我的经验:体温数据用通知,配置参数用带响应的写。别问为什么,问就是我在项目里试过用指示传体温数据,结果手机端处理不过来,丢包了。通知虽然不保证送达,但体温数据是连续上报的,丢一帧没关系。

3.4 体温计Profile设计:从理论到实战

好了,理论讲完了,咱们动手设计一个体温计Profile。我直接给你一个完整的例子,这是我之前一个项目里用过的,稍作修改。

首先,定义Service:

// 体温计服务 UUID: 0x1809
// 这个服务包含三个Characteristic

// 1. 温度测量值 Characteristic
// UUID: 0x2A1C
// 属性: 通知
// 权限: 读(用于获取CCCD状态)
// 数据格式: 浮点数,单位摄氏度

// 2. 温度类型 Characteristic  
// UUID: 0x2A1D
// 属性: 读
// 权限: 读
// 数据格式: 8位无符号整数
// 0x01 = 腋温, 0x02 = 口温, 0x03 = 肛温

// 3. 测量间隔 Characteristic
// UUID: 0x2A21
// 属性: 读 + 写
// 权限: 读 + 写
// 数据格式: 16位无符号整数,单位秒

代码实现上,我习惯用结构体来管理每个Characteristic:

typedef struct {
    uint16_t handle;          // 属性句柄
    uint16_t cccd_handle;     // CCCD句柄
    uint8_t  properties;      // 属性标志
    uint8_t  uuid[16];        // UUID
    uint8_t* value;           // 数据指针
    uint16_t value_len;       // 数据长度
} characteristic_t;

// 温度测量值实例
characteristic_t temp_meas = {
    .properties = GATT_CHAR_PROP_NOTIFY,
    .uuid = {0x1C, 0x2A},    // 注意小端序
    .value = &temp_value,
    .value_len = 4
};

这里要注意handle的分配。每个Service、Characteristic、Descriptor都有一个唯一的handle。handle从0x0001开始递增。我建议你在设计阶段就规划好handle范围,比如Service用0x0010-0x001F,Characteristic用0x0020-0x002F,这样后期扩展方便。

实战要点:

  • 体温数据用IEEE 11073格式编码,这是医疗设备的通用格式。简单说就是32位浮点数,但字节序有讲究
  • 测量间隔的默认值设为60秒,用户可以通过手机App修改
  • 温度类型在设备初始化时固定,不需要用户修改
  • 记得在CCCD里做权限校验,防止非法使能

最后,说说Profile的注册流程。在蓝牙协议栈初始化时,你需要按顺序注册Service、Characteristic、Descriptor。我一般这么写:

// 伪代码,具体API看你的协议栈
void profile_init(void) {
    // 1. 注册Service
    service_handle = gatt_add_service(0x1809);
    
    // 2. 注册温度测量值Characteristic
    temp_handle = gatt_add_characteristic(
        service_handle,
        0x2A1C,
        GATT_CHAR_PROP_NOTIFY,
        GATT_PERM_READ
    );
    
    // 3. 添加CCCD
    cccd_handle = gatt_add_descriptor(
        temp_handle,
        0x2902,  // CCCD的标准UUID
        GATT_PERM_READ | GATT_PERM_WRITE
    );
    
    // 4. 注册温度类型Characteristic
    type_handle = gatt_add_characteristic(
        service_handle,
        0x2A1D,
        GATT_CHAR_PROP_READ,
        GATT_PERM_READ
    );
    
    // 5. 注册测量间隔Characteristic
    interval_handle = gatt_add_characteristic(
        service_handle,
        0x2A21,
        GATT_CHAR_PROP_READ | GATT_CHAR_PROP_WRITE,
        GATT_PERM_READ | GATT_PERM_WRITE
    );
}

嗯,到这里,GATT层的基础知识就讲完了。下一章我们会深入GATT层的回调机制,看看数据到底是怎么在设备和手机之间流转的。到时候我会分享一个我调试了三天才搞定的bug,保证让你印象深刻。