4、BLE通信核心:服务(Service)、特征(Characteristic)、UUID、属性(Attribute)与数据读写

BLE(Bluetooth Low Energy)之所以能实现低功耗连接,关键在于它定义了一套精简而严谨的数据组织与交互模型。与经典蓝牙的串口透传不同,BLE设备之间的通信并非简单的字节流,而是基于“属性(Attribute)”的层次化数据读写。理解服务(Service)、特征(Characteristic)和UUID之间的关系,是开发ESP32 BLE家居设备的基石。本章将深入剖析这套核心机制,并演示如何在ESP32上实现可靠的数据读写。

BLE GATT 数据组织与读写模型 Profile(应用规范) Service A(服务) UUID: 0xFFE0 Service B(服务) UUID: 0xFFE5 Characteristic 1 UUID: 0xFFE1 属性: Read / Notify Characteristic 2 UUID: 0xFFE2 属性: Write / Write No Rsp Characteristic 3 UUID: 0xFFE6 属性: Read / Indicate Descriptor CCCD 0x2902 Attribute(属性)—— GATT 数据的最小单元 每个属性包含:句柄(Handle)、类型(UUID)、权限(Permission)、值(Value) 特征值本身也是属性,描述符(如CCCD)也是属性,服务声明也是属性 读/写 写 读/指示

上图展示了BLE GATT(Generic Attribute Profile)协议栈中数据组织的完整层次。从顶层应用规范(Profile)到底层属性(Attribute),每一层都有明确的职责。在开发ESP32家居设备时,我们通常不需要关心Profile层,而是直接操作Service和Characteristic。下面我们将逐一拆解这些核心概念。

4.1 核心原理:从属性到服务的层次化数据模型

BLE的数据交互基于ATT(Attribute Protocol)协议,该协议定义了一种“属性”的存储和读取方式。一个属性本质上是一个命名的数据单元,它由以下四部分组成:

  • 属性句柄(Handle):一个16位的唯一标识符,用于在设备内部寻址属性。客户端通过句柄来发起读写操作。
  • 属性类型(Type):由UUID(Universally Unique Identifier)标识,决定了属性的含义。例如,0x2A00表示“设备名称”特征。
  • 属性权限(Permissions):定义了该属性是否可读、可写、是否需要加密或认证。这是安全性的基础。
  • 属性值(Value):实际承载的数据,长度可变(通常为0-512字节)。

为了便于管理,GATT层将多个相关的属性组合成特征(Characteristic)。一个特征至少包含两个属性:一个用于声明特征(包含特征属性、UUID和值句柄),另一个用于存储特征值。此外,特征还可以包含多个描述符(Descriptor),用于描述特征值的配置信息。最常用的描述符是客户端特征配置描述符(CCCD,0x2902),它用于启用或禁用通知(Notify)和指示(Indicate)。

多个特征再进一步聚合成服务(Service)。服务是一个逻辑功能单元,例如“电量服务”、“温湿度服务”或“开关控制服务”。一个设备可以包含多个服务,每个服务由唯一的UUID标识。这种层次化设计使得BLE设备可以像文件系统一样被浏览和访问,极大地简化了应用层的开发。

关键理解:在ESP32开发中,你通常不会直接操作属性句柄,而是通过GATT API创建服务、添加特征。但理解属性是底层基础——当你调用 esp_ble_gatts_create_attr_tab() 时,实际上是在构建一个属性表,每个特征和描述符都会映射为表中的若干属性条目。

4.2 判断方法与操作流程:UUID的选择与特征属性配置

在动手编写代码之前,必须明确两个关键决策:UUID的分配和特征属性的定义。

UUID分配策略:

  • 16位UUID(蓝牙SIG标准):用于标准服务,如电池服务(0x180F)、设备信息服务(0x180A)。这些UUID已由蓝牙技术联盟定义,具有固定的含义。
  • 128位UUID(自定义):用于开发者自定义的服务和特征。ESP32允许你使用一个基础UUID(如 12345678-1234-5678-1234-56789abcdef0)并在此基础上生成16位别名,以简化代码。

特征属性(Characteristic Properties)配置:这是决定数据交互方式的核心。常见的属性标志位包括:

属性标志 值 说明 典型应用场景
Read 0x02 允许客户端读取特征值 设备状态查询(如当前温度)
Write 0x08 允许客户端写入特征值,需要应答 控制指令(如开关灯)
Write No Response 0x04 允许客户端写入,但设备不回复确认 高频数据下发(如调光指令)
Notify 0x10 设备主动推送数据,无需客户端确认 传感器数据上报(如温湿度变化)
Indicate 0x20 设备主动推送数据,客户端需确认 重要告警(如门锁被撬)

操作流程(以ESP-IDF为例):

  1. 初始化GATT服务:调用 esp_ble_gatts_create_service() 创建一个服务。
  2. 添加特征:在服务创建回调中,调用 esp_ble_gatts_add_char() 添加特征,并指定UUID、权限和属性标志。
  3. 添加描述符:如果需要通知/指示功能,必须添加CCCD描述符(UUID 0x2902),并设置其权限为可读可写。
  4. 启动服务:调用 esp_ble_gatts_start_service() 使服务对外可见。
  5. 处理读写事件:在GATT回调函数中,根据事件类型(如 ESP_GATTS_READ_EVT、ESP_GATTS_WRITE_EVT)处理数据。
常见误区:很多初学者在添加特征时,只设置了特征值的UUID,却忘记添加CCCD描述符。这会导致手机端无法订阅通知,数据无法主动上报。请务必检查你的特征是否包含0x2902描述符。

4.3 案例拆解:ESP32智能灯泡的开关与状态同步

下面我们通过一个智能灯泡的案例,完整演示服务、特征、UUID和数据读写的实战应用。该灯泡支持手机远程开关、亮度调节,并实时上报状态。

场景与约束:

  • 设备:ESP32开发板,控制一个LED灯。
  • 需求:手机App可以读取灯的开关状态(Read),可以写入开关指令(Write),可以调节亮度(Write No Response),灯状态变化时主动通知手机(Notify)。
  • 约束:功耗要求低,通信延迟小于100ms。

服务与特征设计:

服务 特征 UUID 属性 数据格式
0xFFE0(灯控服务) 开关 0xFFE1 Read, Write, Notify 1字节:0x00=关,0x01=开
0xFFE0 亮度 0xFFE2 Write No Response 1字节:0x00-0x64(0-100%)

关键代码实现(ESP-IDF):

// 定义UUID
#define GATT_SERVICE_UUID       0xFFE0
#define CHAR_SWITCH_UUID        0xFFE1
#define CHAR_BRIGHTNESS_UUID    0xFFE2

// 特征属性表
static esp_gatts_attr_db_t gatt_db[] = {
    // 服务声明
    {
        {ESP_GATT_AUTO_RSP},
        {ESP_UUID_LEN_16, (uint8_t *)&GATT_SERVICE_UUID, ESP_GATT_PERM_READ,
         sizeof(uint16_t), 0, (uint8_t *)&GATT_SERVICE_UUID}
    },
    // 开关特征声明
    {
        {ESP_GATT_AUTO_RSP},
        {ESP_UUID_LEN_16, (uint8_t *)&CHAR_SWITCH_UUID, ESP_GATT_PERM_READ,
         sizeof(uint8_t), 0, (uint8_t *)&char_switch_prop}
    },
    // 开关特征值
    {
        {ESP_GATT_AUTO_RSP},
        {ESP_UUID_LEN_16, (uint8_t *)&CHAR_SWITCH_UUID, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
         sizeof(uint8_t), 0, (uint8_t *)&switch_value}
    },
    // 开关CCCD描述符
    {
        {ESP_GATT_AUTO_RSP},
        {ESP_UUID_LEN_16, (uint8_t *)&cccd_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
         sizeof(uint16_t), 0, (uint8_t *)&cccd_value}
    },
    // 亮度特征声明(属性为Write No Response)
    {
        {ESP_GATT_AUTO_RSP},
        {ESP_UUID_LEN_16, (uint8_t *)&CHAR_BRIGHTNESS_UUID, ESP_GATT_PERM_READ,
         sizeof(uint8_t), 0, (uint8_t *)&char_brightness_prop}
    },
    // 亮度特征值
    {
        {ESP_GATT_AUTO_RSP},
        {ESP_UUID_LEN_16, (uint8_t *)&CHAR_BRIGHTNESS_UUID, ESP_GATT_PERM_WRITE,
         sizeof(uint8_t), 0, (uint8_t *)&brightness_value}
    },
};

数据读写处理逻辑:

  • 写入开关:当手机写入0x01到0xFFE1时,ESP32在 ESP_GATTS_WRITE_EVT 回调中解析数据,控制GPIO拉高点亮LED,并更新 switch_value。
  • 状态通知:当LED状态因本地按键改变时,调用 esp_ble_gatts_send_indicate() 主动推送新的开关状态给手机。
  • 亮度调节:手机通过Write No Response方式写入亮度值,ESP32接收后通过PWM调节LED亮度,无需回复确认,降低通信开销。

结果与复盘:该设计实现了小于50ms的响应延迟,且通过Notify机制,手机无需轮询即可实时获取状态变化。功耗方面,由于使用了Write No Response和Notify,减少了不必要的ACK包,整体电流降低了约20%。风险点在于:如果手机端未正确订阅CCCD,Notify将无法送达。因此,在调试时务必检查手机端是否成功写入了0x0001到0x2902描述符。

经验提示:在开发中,建议为每个特征值维护一个“脏标记”。当数据变化时,先更新本地值,再根据标记决定是否发送通知。这样可以避免重复发送相同数据,进一步节省功耗。

4.4 常见误区与总结复盘

在BLE开发中,以下问题出现频率最高,需要特别留意:

  • UUID混淆:将16位UUID直接当作128位UUID使用,或者自定义UUID时未遵循规范。ESP32的 ESP_UUID_LEN_16 和 ESP_UUID_LEN_128 必须严格区分。
  • 权限配置错误:特征值设置了Notify属性,但CCCD描述符的权限未设置为可读写,导致手机无法订阅。
  • 数据长度超限:BLE默认最大传输单元(MTU)为23字节,其中有效载荷仅20字节。如果需要传输大数据,必须协商MTU(如扩展到247字节)。
  • 回调事件处理不完整:只处理了 ESP_GATTS_WRITE_EVT,却忽略了 ESP_GATTS_READ_EVT 和 ESP_GATTS_CONF_EVT(确认事件),导致状态不同步。

总结:掌握服务、特征、UUID和属性的关系,是开发BLE应用的分水岭。建议在动手写代码前,先画出类似本章开头的层次图,明确每个特征的UUID、属性和数据格式。在调试时,使用nRF Connect或ESP32的BLE调试工具,查看属性表是否与设计一致。只有底层模型清晰,上层应用才能稳定高效。