2. GATT Profile 结构:Service 与 Characteristic 的骨架

好,咱们今天来聊聊 GATT Profile 的结构。说白了,这就是 BLE 设备之间怎么“说话”的规矩。你设计一个蓝牙设备,不管是手环、体脂秤还是传感器,都得按照这个规矩来。我个人习惯把 GATT Profile 想象成一棵倒着长的树——根在最上面,叶子在最下面。

这棵树的顶层是 Profile,它定义了设备的完整功能。往下是 Service(服务),再往下是 Characteristic(特征)。每个 Characteristic 还有自己的 Value 和 Descriptor(描述符)。今天咱们重点啃 Service 和 Characteristic 这两层。

核心要点:GATT Profile 的层次结构是:Profile → Service → Characteristic → Value + Descriptor。每一层都有严格的 UUID 定义和属性规则。

GATT Profile 层次结构图 GATT Profile 层次结构 Profile (UUID: 自定义) Service 层 Primary Service UUID: 0x1800 (Generic Access) Secondary Service UUID: 0x180F (Battery) Included Service UUID: 0x180A (Device Info) Characteristic 层 Characteristic 声明 UUID + 属性 + 值句柄 Characteristic Value 实际数据存储位置 Characteristic Descriptor CCC、格式、用户描述等 Characteristic 属性(Properties) • Broadcast(广播) • Read(读) • Write Without Response(无应答写) • Write(有应答写) • Notify(通知) • Indicate(指示) • Authenticated Signed Writes(签名写) • Extended Properties(扩展属性)

2.1 Service 的 UUID 定义

每个 Service 都有一个唯一的标识符,就是 UUID。你想想看,蓝牙 SIG(Special Interest Group)定义了一堆标准 Service,比如电池服务(0x180F)、设备信息服务(0x180A)、心率服务(0x180D)。这些标准 Service 用的是 16 位 UUID,省空间、效率高。

但如果你做的是自家产品,比如一个智能咖啡机,那就得用 128 位 UUID。格式长这样:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX。我建议你直接用在线 UUID 生成器搞一个,别自己瞎编,万一跟别人的冲突了,那调试起来可够你喝一壶的。

UUID 类型 长度 用途 示例
16 位 UUID 2 字节 蓝牙 SIG 标准 Service 0x180F(电池服务)
128 位 UUID 16 字节 自定义 Service 0000xxxx-0000-1000-8000-00805F9B34FB

我的小技巧:开发阶段先用标准 UUID 做原型验证,等产品定型了再换成自定义 128 位 UUID。这样前期用 nRF Connect 调试时,工具能自动识别服务名,省得你老去翻协议栈文档。

2.2 Primary Service 与 Secondary Service

Service 分两种:Primary Service(主要服务)和 Secondary Service(次要服务)。

Primary Service 是设备对外暴露的核心功能。比如一个心率带,它的 Primary Service 就是心率服务(0x180D)。每个设备至少得有一个 Primary Service,不然手机端根本发现不了你。

Secondary Service 呢?它是被 Primary Service 引用的辅助服务。说白了,它不能独立存在,必须挂在某个 Primary Service 下面。我在项目中遇到过一种情况:一个体脂秤,它的 Primary Service 是体脂测量服务,但里面引用了电池服务(Secondary Service)来上报电量。这样设计的好处是——电池服务可以被多个 Primary Service 复用。

注意:Secondary Service 不能单独被客户端发现。你必须在 Primary Service 的 Included Service 列表里显式引用它,手机端才能看到。我曾经有个同事忘了加这个引用,结果设备死活连不上,查了两天才发现是这个问题。

2.3 Included Service

Included Service 是 Service 之间的一种引用关系。一个 Service 可以把另一个 Service “包含”进来,形成层级结构。

举个例子:你的智能手表有一个“运动监测服务”(Primary Service),它里面包含了“心率服务”和“计步服务”作为 Included Service。这样客户端在访问运动监测服务时,就能顺藤摸瓜找到心率数据和步数数据。

Included Service 在 GATT 数据库里是用一个特殊的 Attribute 来表示的。它的结构长这样:

Attribute Handle: 0x0020
Type: 0x2802 (GATT Include Declaration)
Value: [Included Service UUID] + [起始句柄] + [结束句柄]

你想想看,这种设计有什么好处?模块化!你可以把通用的 Service(比如设备信息、电池)封装好,然后在不同的 Primary Service 里引用。代码复用率一下子就上去了。

2.4 Characteristic 的声明与属性

Characteristic 是 GATT 里真正干活的东西。Service 只是个容器,Characteristic 才是装数据的抽屉。

每个 Characteristic 由三部分组成:

  1. Characteristic 声明:一个特殊的 Attribute,类型是 0x2803。它里面包含了 Characteristic 的属性、值句柄和 UUID。
  2. Characteristic 值:实际存储数据的 Attribute。
  3. Characteristic 描述符:可选的附加信息,比如客户端特征配置描述符(CCCD)、用户描述等。

Characteristic 声明(0x2803)的 Value 格式如下:

+--------+--------+--------+--------+--------+--------+
| 属性   | 值句柄 | UUID (16位或128位)                |
+--------+--------+--------+--------+--------+--------+
| 1 字节 | 2 字节 | 2 或 16 字节                      |
+--------+--------+--------+--------+--------+--------+

这里的“属性”字段就是 Characteristic 的能力标识。它是个位掩码,每一位代表一种能力:

位 属性名 说明
0 Broadcast 特征值可被广播
1 Read 客户端可读取特征值
2 Write Without Response 客户端可无应答写入
3 Write 客户端可有应答写入
4 Notify 服务端可主动通知客户端
5 Indicate 服务端可主动指示(需确认)
6 Authenticated Signed Writes 带签名的写入
7 Extended Properties 扩展属性标志

实战经验:Notify 和 Indicate 的区别你一定要搞清楚。Notify 只管发,不管对方收没收到;Indicate 会等对方确认。我做过一个血糖仪项目,数据必须可靠传输,所以用了 Indicate。但 Indicate 的吞吐量比 Notify 低,因为每次都要等确认。如果你做的是传感器数据流,用 Notify 就够了。

2.5 实战:定义一个简单的 Service

光说不练假把式。咱们用 Nordic nRF5 SDK 的代码风格,定义一个“环境传感器服务”。

// 自定义 128 位 UUID
#define BLE_UUID_ENV_SENSOR_SERVICE  0x0001
#define BLE_UUID_ENV_SENSOR_TEMP     0x0002
#define BLE_UUID_ENV_SENSOR_HUMID    0x0003

// 服务初始化
static uint32_t env_sensor_service_init(void)
{
    uint32_t   err_code;
    ble_uuid_t ble_uuid;
    
    // 注册自定义 UUID 类型
    ble_uuid128_t base_uuid = {
        .uuid128 = {0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0,
                    0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}
    };
    
    err_code = sd_ble_uuid_vs_add(&base_uuid, &ble_uuid.type);
    APP_ERROR_CHECK(err_code);
    
    // 设置 Service UUID
    ble_uuid.uuid = BLE_UUID_ENV_SENSOR_SERVICE;
    
    // 添加 Primary Service
    err_code = sd_ble_gatts_service_add(BLE_GATTS_SRVC_TYPE_PRIMARY,
                                        &ble_uuid,
                                        &m_env_sensor.service_handle);
    APP_ERROR_CHECK(err_code);
    
    // 添加温度 Characteristic(支持 Read + Notify)
    // ... 此处省略 Characteristic 添加代码
    
    return NRF_SUCCESS;
}

这段代码做了三件事:注册自定义 UUID 类型、添加 Primary Service、预留 Characteristic 添加位置。你实际开发时,记得把 base_uuid 换成你自己生成的 128 位 UUID。

避坑指南:我曾经在量产阶段发现两个产品线用了相同的 base UUID,只是 16 位 UUID 不同。结果手机端把两个设备当成同一个服务,数据全乱了。从那以后,我要求每个产品线必须用独立的 128 位 base UUID,哪怕只是内部测试用。

2.6 小结

今天咱们把 GATT Profile 的骨架搭起来了。Service 是容器,Characteristic 是数据抽屉。Primary Service 是门面,Secondary Service 是幕后帮手。Included Service 让服务之间可以互相引用,实现模块化设计。