ESP32 BLE GATT/ATT协议栈原理与工程实现

开发板推荐:天空星STM32F407VET6开发板

超高性价比 STM32主控 | 超高主频 | 一板兼容百芯 | 比赛神器 | 沉金彩色丝印

1. BLE协议栈中的GATT与ATT:从规范到工程实现

在嵌入式蓝牙低功耗(BLE)系统开发中,GATT(Generic Attribute Profile)与ATT(Attribute Protocol)并非孤立存在的抽象概念,而是构成设备间数据交互骨架的底层协议实体。它们共同定义了“服务器如何组织数据”、“客户端如何发现并访问数据”以及“双方如何协商通信行为”的完整语义体系。对ESP32开发者而言,理解GATT/ATT不是为了背诵标准文档,而是为了精准控制数据在应用层、协议栈与物理层之间的流向、格式与权限边界。本节将剥离教学视频的口语化表述,以工程师视角重构GATT/ATT的核心逻辑,聚焦其在ESP-IDF框架下的工程映射关系。

1.1 协议栈定位与职责边界

BLE协议栈采用分层设计,GATT与ATT位于主机(Host)层,处于应用层与控制器(Controller)层之间。这种分层并非简单的功能划分,而是明确的职责隔离:

  • 控制器层 (Controller):负责射频信号调制解调、链路层(LL)状态机管理、物理信道跳频等硬件相关操作。ESP32的BLE控制器由内部RF模块与专用固件实现,开发者通过HCI接口与其交互。
  • 主机层 (Host):包含L2CAP、ATT、GATT、GAP等协议实体。其中:
  • ATT 是基础数据协议,定义了“属性”(Attribute)这一核心数据单元的结构与访问操作(Read/Write/Notify/Indicate)。
  • GATT 是基于ATT构建的应用框架,定义了“服务”(Service)、“特征”(Characteristic)、“描述符”(Descriptor)等高层数据模型,并规定了客户端与服务器间的发现、读写、通知等交互流程。
  • 应用层 (Application):开发者编写的业务逻辑,通过调用ESP-IDF提供的 esp_ble_gatts_* (服务端)或 esp_ble_gattc_* (客户端) API与GATT层交互。

关键点在于: 应用层不直接操作ATT,而是通过GATT API间接驱动ATT行为 。例如,调用 esp_ble_gatts_set_attr_value() 设置特征值,本质是向ATT层提交一个写入请求;而客户端调用 esp_ble_gattc_read_char() ,则触发ATT层向服务器发送Read Request PDU。这种分层确保了应用逻辑与协议细节的解耦,但同时也要求开发者必须理解每一层API调用所引发的底层协议动作。

1.2 ATT属性:BLE数据的原子存储单元

ATT协议将所有可被访问的数据抽象为“属性”(Attribute)。每个属性是一个结构化的四元组,其定义直接映射到ESP32内存中的数据结构:

字段 长度 含义 ESP-IDF映射
属性句柄(Handle) 2字节 属性在服务器属性表中的唯一索引,用于快速定位。 esp_gatt_if_t 关联的 gatts_attr_db_t 数组下标
属性类型(Type) 2或16字节 属性的UUID,标识其语义。常用16位UUID(如0x2800表示Primary Service)或128位自定义UUID。 esp_bt_uuid_t 结构体, len 字段区分16/128位
属性值(Value) 可变长 属性的实际数据内容,长度由属性类型和业务需求决定。 uint8_t *value 指针,指向 gatts_attr_db_t 中的 value 成员
属性权限(Permission) 2字节 定义对该属性的访问控制策略,包括读、写、通知、指示等位标志。 esp_gatt_perm_t 枚举值,如 ESP_GATT_PERM_READ

属性句柄是ATT层最核心的寻址机制。它并非随机分配,而是按服务器属性表(Attribute Database)的声明顺序严格递增。当客户端发起读写请求时,仅需提供目标属性的句柄,服务器即可在O(1)时间内定位并处理该请求。这种设计极大提升了协议栈的执行效率,但也意味着开发者必须精确管理属性表的初始化顺序——句柄值由 esp_ble_gatts_create_attr_tab() 函数根据传入的属性数组自动分配,任何顺序调整都会导致句柄值变更,进而破坏客户端代码的兼容性。

属性权限(Permission)则构成了BLE安全模型的第一道防线。一个典型的特征值属性可能被配置为 ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE | ESP_GATT_PERM_NOTIFY ,表明客户端可读取、写入该值,并能订阅其变化通知。而客户端特征配置描述符(Client Characteristic Configuration Descriptor, CCCD)的权限通常仅为 ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE ,因为其作用就是让客户端写入通知/指示使能位。权限配置错误是调试中最常见的问题之一:若忘记为CCCD添加写权限,客户端将无法启用通知;若为只读特征误加写权限,则可能引发非法访问异常。

1.3 GATT模型:服务、特征与描述符的层级架构

GATT在ATT之上构建了一套面向对象的数据组织模型,其核心实体具有严格的父子关系:

  • 服务(Service) :数据的逻辑容器,代表一个完整的功能单元(如“LED控制服务”)。服务本身不包含业务数据,而是作为特征的集合。
  • 特征(Characteristic) :服务内的数据节点,包含实际的业务值(如“LED状态”)及其元信息(如是否可读、是否支持通知)。
  • 描述符(Descriptor) :特征的附属信息,用于描述特征值的含义、格式、用户可读名等。最常见的是CCCD,用于控制通知/指示开关。

这种层级结构在ESP32的属性表中体现为一种嵌套式的声明序列。一个最小化的LED控制服务属性表初始化代码如下:

// LED Control Service (UUID: 0xABCD)
static const uint16_t led_service_uuid = 0xABCD;
// LED State Characteristic (UUID: 0xEF01)
static const uint16_t led_state_char_uuid = 0xEF01;
// Client Characteristic Configuration Descriptor (UUID: 0x2902)
static const uint16_t cccd_uuid = 0x2902;

// 属性表定义 (按声明顺序,句柄自动分配)
static const esp_gatts_attr_db_t gatt_db[LED_ATTR_NUM] = {
    // [0] Primary Service Declaration
    [LED_IDX_SVC] = 
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&primary_service_uuid, ESP_GATT_PERM_READ}},

    // [1] Characteristic Declaration for LED State
    [LED_IDX_CHAR_LED_STATE_DECL] = 
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&characteristic_uuid, ESP_GATT_PERM_READ}},

    // [2] Characteristic Value for LED State (initial value: 0x00)
    [LED_IDX_CHAR_LED_STATE_VAL] = 
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&led_state_char_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE | ESP_GATT_PERM_NOTIFY}},

    // [3] Client Characteristic Configuration Descriptor (CCCD)
    [LED_IDX_CHAR_LED_STATE_CCCD] = 
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&cccd_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE}},
};

此代码清晰地展示了GATT模型的物理实现:
- 句柄 LED_IDX_SVC (0)处是服务声明,其值为服务UUID(0x2800)。
- 句柄 LED_IDX_CHAR_LED_STATE_DECL (1)处是特征声明,其值为特征UUID(0x2803)。
- 句柄 LED_IDX_CHAR_LED_STATE_VAL (2)处是特征值,其值即LED当前状态(0x00),权限支持读、写、通知。
- 句柄 LED_IDX_CHAR_LED_STATE_CCCD (3)处是CCCD,其值为客户端写入的16位标志(bit0=通知使能,bit1=指示使能)。

关键工程实践 :服务UUID、特征UUID、描述符UUID必须严格遵循蓝牙SIG官方分配或使用128位自定义UUID,避免与通用UUID(如0x2800)冲突。在ESP-IDF中,可通过 esp_bt_dev_set_device_name() 设置设备名,但服务/特征UUID的注册与解析完全由GATT层完成,与GAP层的设备名广播无直接关联。

2. ESP32 BLE初始化:服务端与客户端的双轨启动

ESP32的BLE初始化并非单一线性过程,而是服务端(GATTS)与客户端(GATTC)两条独立路径的并行启动。二者共享底层控制器资源,但在应用层API调用、事件回调注册及任务创建上存在根本性差异。理解这种双轨结构,是避免初始化失败、事件丢失或资源竞争的前提。

2.1 公共底层初始化:控制器与协议栈

无论服务端还是客户端,其启动均始于对ESP32 BLE硬件资源的统一初始化。此阶段代码高度复用,通常封装在 app_main() 的初始部分:

// 1. 初始化NVS(Non-Volatile Storage)
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
    ESP_ERROR_CHECK(nvs_flash_erase());
    ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);

// 2. 初始化蓝牙控制器(仅BLE模式)
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
ret = esp_bt_controller_init(&bt_cfg);
ESP_ERROR_CHECK(ret);

// 3. 开启BLE控制器
ret = esp_bt_controller_enable(ESP_BT_MODE_BLE);
ESP_ERROR_CHECK(ret);

// 4. 初始化Bluedroid协议栈(ESP-IDF的BLE Host实现)
ret = esp_bluedroid_init();
ESP_ERROR_CHECK(ret);
ret = esp_bluedroid_enable();
ESP_ERROR_CHECK(ret);

此流程的关键点在于:
- NVS Flash初始化 :为BLE地址、配对密钥、服务数据库等持久化数据提供存储空间。若跳过此步,设备重启后将丢失所有配对信息与服务配置。
- 控制器模式选择 ESP_BT_MODE_BLE 明确禁用经典蓝牙(BR/EDR)控制器,释放其占用的RAM与CPU资源,确保BLE性能最优。这是低功耗应用的强制要求。
- Bluedroid使能 :Bluedroid是ESP-IDF集成的开源BLE协议栈实现,其使能标志着主机层协议实体(ATT、GATT、GAP)开始运行。此时,控制器与主机间的HCI通道已建立,但应用层尚未注册任何回调,因此协议栈处于“静默”状态。

2.2 服务端(GATTS)初始化:构建数据服务器

服务端初始化的核心是向Bluedroid注册一个GATT应用(APP),并为其绑定事件回调函数。此过程建立了应用层与GATT层的双向通信通道:

// 1. 注册GATTS回调函数(处理GATT层事件)
esp_ble_gatts_register_callback(gatts_event_handler);

// 2. 注册GAP回调函数(处理广播、连接等事件)
esp_ble_gap_register_callback(gap_event_handler);

// 3. 创建GATT应用(返回应用ID,用于后续所有API调用)
esp_err_t ret = esp_ble_gatts_app_register(APP_ID_LED_SERVER);
ESP_ERROR_CHECK(ret);

gatts_event_handler() 是服务端的中枢神经,它必须能处理以下关键事件:
- ESP_GATTS_CREATE_EVT : 应用注册成功,获取 gatts_if (GATT接口ID),用于后续所有GATTS API调用。
- ESP_GATTS_START_EVT : 服务启动完成,此时可安全地调用 esp_ble_gap_start_advertising() 开始广播。
- ESP_GATTS_CONNECT_EVT : 客户端连接成功,获取 conn_id (连接ID)与 remote_bda (远程设备地址),用于后续针对该连接的操作。
- ESP_GATTS_WRITE_EVT : 客户端向特征值写入数据, param->write.handle 指明目标句柄, param->write.value 为写入内容。
- ESP_GATTS_EXEC_WRITE_EVT : 执行准备写入(Prepare Write),用于长数据分片传输。

工程陷阱 esp_ble_gatts_app_register() 是异步操作。其返回 ESP_OK 仅表示注册请求已提交,真正的应用创建由Bluedroid在后台完成,并通过 ESP_GATTS_CREATE_EVT 事件通知应用。若在收到该事件前就调用 esp_ble_gatts_create_attr_tab() 创建属性表,将导致 ESP_FAIL 错误。正确的做法是:在 gatts_event_handler() 中,于 ESP_GATTS_CREATE_EVT 分支内完成属性表创建、服务启动等后续操作。

2.3 客户端(GATTC)初始化:构建数据消费者

客户端初始化与服务端类似,但回调函数与API调用逻辑截然不同,体现了其“主动发现、按需访问”的角色:

// 1. 注册GATTC回调函数(处理客户端事件)
esp_ble_gattc_register_callback(gattc_event_handler);

// 2. 注册GAP回调函数(同服务端)
esp_ble_gap_register_callback(gap_event_handler);

// 3. 创建GATT客户端应用
esp_err_t ret = esp_ble_gattc_app_register(APP_ID_LED_CLIENT);
ESP_ERROR_CHECK(ret);

gattc_event_handler() 需重点处理:
- ESP_GATTC_REG_EVT : 客户端应用注册成功,获取 gattc_if
- ESP_GATTC_OPEN_EVT : 与目标服务器成功建立连接,获取 conn_id ,此时可发起服务发现。
- ESP_GATTC_SEARCH_CMPL_EVT : 服务发现完成,通过 esp_ble_gattc_search_service() 获取的服务列表已填充完毕。
- ESP_GATTC_READ_CHAR_EVT : 特征值读取完成, param->read.value 即为读取到的数据。
- ESP_GATTC_WRITE_CHAR_EVT : 特征值写入完成,确认写入结果。

关键配置项:MTU(Maximum Transmission Unit)
在客户端连接建立后( ESP_GATTC_OPEN_EVT 事件中),必须调用 esp_ble_gattc_send_mtu_req() 协商MTU。MTU决定了单次ATT PDU(Protocol Data Unit)的最大有效载荷长度,默认为23字节。对于需要传输较长数据(如固件升级包)的应用,必须协商更大的MTU(如517字节)。协商过程是双向的:客户端发送请求,服务器响应,最终双方采用较小的MTU值。若忽略此步,所有超过23字节的写入操作将被截断,导致数据损坏。

3. GATT交互流程:从广播发现到读写通知的闭环

GATT交互的本质是客户端与服务器之间围绕属性表的一系列状态同步与数据交换。这一流程并非教科书式的线性序列,而是在事件驱动模型下,由多个并发任务协同完成的动态闭环。理解其内在的“事件-响应-状态迁移”机制,是调试连接失败、数据不一致等问题的关键。

3.1 服务端广播与客户端扫描:发现的起点

服务端的广播是整个交互的触发器。广播数据(Advertising Data)与扫描响应数据(Scan Response Data)共同构成了设备的“数字名片”。在ESP32中,这通过 esp_ble_gap_config_adv_data() esp_ble_gap_config_scan_rsp_data() 配置:

// 广播数据:包含设备名、服务UUID等
static esp_ble_adv_data_t adv_data = {
    .set_scan_rsp = false,
    .include_name = true,
    .include_txpower = true,
    .min_interval = 0x0006, // 100ms
    .max_interval = 0x0010, // 160ms
    .appearance = 0x00,
    .manufacturer_len = 0,
    .p_manufacturer_data = NULL,
    .service_data_len = 0,
    .p_service_data = NULL,
    .service_uuid_len = sizeof(led_service_uuid),
    .p_service_uuid = (uint8_t*)&led_service_uuid,
    .flag = 0x06, // GENERAL_DISCOVERABLE | BREDR_NOT_SUPPORTED
};

// 扫描响应数据:可包含更多服务信息
static esp_ble_adv_data_t scan_rsp_data = {
    .set_scan_rsp = true,
    .include_name = true,
    .include_txpower = false,
    // ... 其他字段
};

客户端的扫描则是被动监听这些广播。 esp_ble_gap_start_scanning() 启动扫描后, gap_event_handler() 会持续收到 ESP_GAP_BLE_SCAN_RESULT_EVT 事件。每次事件都携带一个 esp_ble_gap_cb_param_t::scan_rst 结构,其中 bda (Bluetooth Device Address)是发现设备的MAC地址, adv_data 是其广播数据。客户端需解析 adv_data ,匹配预设的服务UUID(如 led_service_uuid )或设备名,以筛选出目标服务器。

工程要点 :广播间隔( min_interval / max_interval )直接影响设备的可发现性与功耗。较短的间隔(如100ms)能快速被发现,但显著增加功耗;较长的间隔(如1s)则更省电,但发现延迟增大。在电池供电设备中,需根据应用场景权衡。

3.2 服务发现(Service Discovery):构建本地属性视图

当客户端扫描到目标服务器并成功连接后,首要任务是获取服务器的完整属性表结构。这一过程称为服务发现(Service Discovery),其实质是客户端向服务器发起一系列ATT Read By Group Type Request,遍历所有服务与特征。

在ESP-IDF中,此过程由 esp_ble_gattc_search_service() API封装。调用后,Bluedroid自动发送请求,并在后台解析响应,最终通过 ESP_GATTC_SEARCH_CMPL_EVT 事件通知应用。此时,应用可通过 esp_ble_gattc_get_service() 等API遍历已发现的服务列表:

// 在 ESP_GATTC_SEARCH_CMPL_EVT 事件处理中
esp_ble_gattc_get_service(gattc_if, conn_id, &service_list, &count);
for (int i = 0; i < count; i++) {
    esp_gatt_srvc_id_t *srvc_id = &service_list[i].service_id;
    if (srvc_id->id.uuid.len == ESP_UUID_LEN_16 && 
        srvc_id->id.uuid.uuid.uuid16 == led_service_uuid) {
        // 找到LED服务,记录其handle_range
        led_service_start_handle = service_list[i].start_handle;
        led_service_end_handle = service_list[i].end_handle;
        break;
    }
}

服务发现的结果是客户端本地构建了一个与服务器属性表逻辑一致的“镜像”。这个镜像不包含实际数据值,但包含了所有服务、特征、描述符的UUID、句柄范围等元信息。后续的所有读写操作,都依赖于此镜像进行句柄定位。 若服务发现失败或超时,所有后续的GATT操作都将无效。

3.3 读写操作与通知机制:数据流动的三种模式

GATT定义了三种核心的数据交互模式,每种模式对应不同的ATT操作码与事件流:

  • 读取(Read) : 客户端发起 Read Request ,服务器返回 Read Response 。在ESP-IDF中,客户端调用 esp_ble_gattc_read_char() ,服务端在 ESP_GATTS_READ_EVT 中处理。此模式适用于获取静态或变化不频繁的状态(如LED当前状态)。

  • 写入(Write) : 客户端发起 Write Request (带响应)或 Write Command (无响应)。前者在 ESP_GATTS_WRITE_EVT 中处理,后者在 ESP_GATTS_EXEC_WRITE_EVT 中处理。写入操作是改变服务器状态的主要手段(如将LED状态写为0x01以点亮)。

  • 通知(Notify)与指示(Indicate) : 这是服务器主动向客户端推送数据的机制。客户端首先需向CCCD写入0x0001(通知使能)或0x0002(指示使能)。此后,当服务器特征值发生变化时,可调用 esp_ble_gatts_send_indicate() (指示,需客户端确认)或 esp_ble_gatts_send_notify() (通知,无需确认)发送更新。客户端在 ESP_GATTC_NOTIFY_EVT ESP_GATTC_INDICATE_EVT 中接收。 通知是实现低延迟状态同步(如传感器实时数据)的唯一高效方式。

权限与CCCD的协同 :通知/指示功能的启用,是权限(Permission)与CCCD写入的双重约束结果。即使特征值属性设置了 ESP_GATT_PERM_NOTIFY ,若客户端未向其CCCD写入使能位,服务器调用 send_notify() 将被静默丢弃。反之,若CCCD被写入使能位,但特征值属性未设置相应权限,服务器在处理CCCD写入时会返回 ESP_GATT_INVALID_ATTR_LEN 错误。

4. 服务特征属性表深度解析:从UUID到数据格式

属性表是GATT服务器的“心脏”,其设计质量直接决定了客户端交互的灵活性与健壮性。一个精心设计的属性表不仅满足基本功能,更能通过标准化的描述符为客户端提供丰富的语义信息,降低集成难度。

4.1 UUID:服务与特征的全球唯一标识

UUID(Universally Unique Identifier)是BLE设备间互操作的基石。它分为两类:
- 16位UUID :由蓝牙SIG分配的标准化UUID,如 0x2800 (Primary Service)、 0x2803 (Characteristic Declaration)、 0x2902 (CCCD)。使用16位UUID可大幅减少空中传输的数据量,提升效率。
- 128位UUID :开发者可自由定义的UUID,格式为 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 。适用于私有服务与特征,确保全球唯一性,避免与标准UUID冲突。

在ESP-IDF中,UUID的使用需严格匹配其长度:

// 16位UUID
esp_bt_uuid_t uuid16 = {
    .len = ESP_UUID_LEN_16,
    .uuid.uuid16 = 0xABCD
};

// 128位UUID (需完整16字节)
uint8_t uuid128[16] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
                       0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00};
esp_bt_uuid_t uuid128_struct = {
    .len = ESP_UUID_LEN_128,
    .uuid.uuid128 = uuid128
};

最佳实践 :对于通用功能(如LED控制、温度读取),优先使用16位UUID以节省带宽;对于专有协议或复杂产品,必须使用128位UUID,并在产品文档中明确定义,供客户端开发者集成。

4.2 特征值描述符:超越二进制的数据语义

描述符为特征值赋予了人类可读的含义与机器可解析的格式。除了必选的CCCD,以下描述符在工程中极具价值:

  • Characteristic User Description (0x2901) :提供特征值的可读名,如”LED State”。客户端可将其显示在UI上,提升用户体验。
  • Characteristic Presentation Format (0x2904) :定义特征值的数据格式,是实现跨平台数据解析的关键。其结构为5字节:
  • Format (1字节): 数据类型编码(0x04=uint8, 0x06=uint16, 0x0A=float32等)。
  • Exponent (1字节): 10的幂次,用于表示小数(如温度25.5°C,Exponent=-1)。
  • Unit (2字节): 标准化单位UUID(0x2700=摄氏度, 0x2729=秒)。
  • Name Space (1字节): 命名空间(1=Bluetooth SIG, 2=Microsoft)。
  • Description (2字节): 描述符的补充说明。

例如,一个温度传感器特征,其Presentation Format可设为 {0x06, 0xFF, 0x00, 0x27, 0x01, 0x00} ,表示: uint16 类型(0x06),指数-1(0xFF),单位为摄氏度(0x2700),命名空间为SIG。

  • Valid Range (0x2906) :定义特征值的有效取值范围,防止客户端写入非法值。

在ESP-IDF中,为特征添加描述符,需在属性表中为其分配额外的条目,并在特征声明后紧随其后:

// [4] Characteristic User Description for LED State
[LED_IDX_CHAR_LED_STATE_DESC] = 
{{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&user_desc_uuid, ESP_GATT_PERM_READ}},

// [5] Characteristic Presentation Format for LED State
[LED_IDX_CHAR_LED_STATE_FORMAT] = 
{{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&format_uuid, ESP_GATT_PERM_READ}},

然后,在 ESP_GATTS_READ_EVT 事件中,根据 param->read.handle 判断是否为描述符读取,并返回对应的描述符值。

4.3 权限组合的工程意义:构建安全的数据管道

属性权限(Permission)是BLE安全模型的执行单元。其组合并非随意堆砌,而是反映了严格的数据访问策略:

  • ESP_GATT_PERM_READ : 允许客户端读取属性值。对于只读传感器数据(如温度),此权限足够。
  • ESP_GATT_PERM_WRITE : 允许客户端写入属性值。对于可配置参数(如LED亮度),必须开启。
  • ESP_GATT_PERM_WRITE_ENCRYPTED : 要求写入操作必须在加密连接下进行,增强安全性。
  • ESP_GATT_PERM_NOTIFY : 允许客户端向CCCD写入0x0001以启用通知。
  • ESP_GATT_PERM_INDICATE : 允许客户端向CCCD写入0x0002以启用指示。

一个典型的可通知、可写入的LED状态特征,其权限应为 ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE | ESP_GATT_PERM_NOTIFY 。而其CCCD的权限则必须为 ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE ,否则客户端无法配置通知开关。

调试技巧 :当客户端无法读取或写入某个特征时,首先检查服务端属性表中该特征的权限位是否正确设置。其次,确认客户端是否已成功完成服务发现,获取了正确的句柄。最后,检查连接是否已加密(若权限要求加密)。

5. 实战:基于GATT模型的LED控制服务实现

理论的终点是实践的起点。本节将前述所有概念整合,构建一个完整的、可运行的ESP32 LED控制服务。该服务将暴露一个可读、可写、可通知的LED状态特征,并通过标准化的描述符提供语义信息,为客户端提供开箱即用的交互体验。

5.1 服务端属性表定义与初始化

首先,定义服务、特征及描述符的UUID:

#define LED_SERVICE_UUID        0xABCD
#define LED_CHAR_STATE_UUID     0xEF01
#define USER_DESC_UUID          0x2901
#define FORMAT_UUID             0x2904
#define CCCD_UUID               0x2902

// 属性表条目数量
#define LED_ATTR_NUM            7

// 属性表
static const esp_gatts_attr_db_t gatt_db[LED_ATTR_NUM] = {
    // [0] Primary Service Declaration
    [LED_IDX_SVC] = 
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&primary_service_uuid, ESP_GATT_PERM_READ}},

    // [1] Characteristic Declaration for LED State
    [LED_IDX_CHAR_LED_STATE_DECL] = 
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&characteristic_uuid, ESP_GATT_PERM_READ}},

    // [2] Characteristic Value for LED State (initial value: 0x00)
    [LED_IDX_CHAR_LED_STATE_VAL] = 
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&led_char_state_uuid, 
                           ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE | ESP_GATT_PERM_NOTIFY}},

    // [3] Client Characteristic Configuration Descriptor (CCCD)
    [LED_IDX_CHAR_LED_STATE_CCCD] = 
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&cccd_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE}},

    // [4] Characteristic User Description ("LED State")
    [LED_IDX_CHAR_LED_STATE_DESC] = 
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&user_desc_uuid, ESP_GATT_PERM_READ}},

    // [5] Characteristic Presentation Format (uint8, no exponent, unit=none)
    [LED_IDX_CHAR_LED_STATE_FORMAT] = 
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&format_uuid, ESP_GATT_PERM_READ}},

    // [6] Reserved for future use or padding
    [LED_IDX_RESERVED] = 
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t*)&reserved_uuid, ESP_GATT_PERM_READ}},
};

初始化流程在 gatts_event_handler() ESP_GATTS_CREATE_EVT 事件中执行:

case ESP_GATTS_CREATE_EVT: {
    esp_ble_gatts_create_attr_tab(gatt_db, gatts_if, LED_ATTR_NUM, SVC_INST_ID);
    break;
}

5.2 服务端事件处理:状态同步与通知触发

服务端的核心逻辑集中在 gatts_event_handler() 中,处理连接、写入与状态变更:

case ESP_GATTS_CONNECT_EVT: {
    // 记录连接ID,启动通知
    led_conn_id = param->connect.conn_id;
    led_gatts_if = gatts_if;
    ESP_LOGI(GATTS_TAG, "Connected to device: " MACSTR, 
              MAC2STR(param->connect.remote_bda));
    break;
}

case ESP_GATTS_WRITE_EVT: {
    if (param->write.handle == LED_IDX_CHAR_LED_STATE_VAL) {
        // 写入LED状态
        uint8_t new_state = param->write.value[0];
        led_set_state(new_state); // 硬件控制LED
        ESP_LOGI(GATTS_TAG, "LED state set to: %d", new_state);

        // 如果CCCD已使能,立即发送通知
        if (cccd_enabled) {
            esp_ble_gatts_send_notify(led_gatts_if, led_conn_id, 
                                      LED_IDX_CHAR_LED_STATE_VAL, 
                                      sizeof(new_state), &new_state);
        }
    }
    break;
}

case ESP_GATTS_EXEC_WRITE_EVT: {
    // 处理CCCD写入
    if (param->exec_write.handle == LED_IDX_CHAR_LED_STATE_CCCD) {
        uint16_t cccd_value = *(uint16_t*)param->exec_write.value;
        cccd_enabled = (cccd_value & 0x0001) ? true : false;
        ESP_LOGI(GATTS_TAG, "CCCD updated: Notify=%s", cccd_enabled ? "ON" : "OFF");
    }
    break;
}

led_set_state() 函数负责将接收到的字节值(0x00或0x01)转换为GPIO电平,控制LED的亮灭。这是一个典型的硬件抽象层(HAL)调用。

5.3 客户端交互逻辑:发现、配置与控制

客户端应用需实现完整的交互闭环:

// 在 ESP_GATTC_OPEN_EVT 中启动服务发现
esp_ble_gattc_search_service(gattc_if, conn_id, &led_service_uuid);

// 在 ESP_GATTC_SEARCH_CMPL_EVT 中解析服务
case ESP_GATTC_SEARCH_CMPL_EVT: {
    // ... 解析服务,获取LED特征值句柄 (led_char_handle) 和CCCD句柄 (cccd_handle)
    // 然后向CCCD写入0x0001以启用通知
    esp_ble_gattc_write_char_descr(gattc_if, conn_id, cccd_handle, 
                                   (uint8_t*)&notify_en, sizeof(notify_en), 
                                   ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
    break;
}

// 在 ESP_GATTC_NOTIFY_EVT 中处理LED状态更新
case ESP_GATTC_NOTIFY_EVT: {
    if (param->notify.handle == led_char_handle) {
        uint8_t led_state = param->notify.value[0];
        ESP_LOGI(GATTC_TAG, "LED state notified: %d", led_state);
        // 更新UI或执行其他业务逻辑
    }
    break;
}

// 提供一个函数供用户控制LED
void client_control_led(uint8_t state) {
    esp_ble_gattc_write_char(gattc_if, conn_id, led_char_handle, 
                             &state, sizeof(state), ESP_GATT_WRITE_TYPE_RSP, 
                             ESP_GATT_AUTH_REQ_NONE);
}

此客户端代码实现了完整的GATT交互:从发现服务、配置通知、到接收通知与主动写入。它不再依赖于特定的串口透传(SPP)协议,而是直接在GATT层构建了轻量、高效、标准化的控制通道。

在实际项目中,我曾将此LED服务模型扩展为一个通用的“设备控制服务”,通过为每个可控外设(继电器、PWM舵机、ADC通道)分配唯一的特征UUID,并复用相同的属性表结构与事件处理逻辑,仅需修改硬件驱动部分,便在一周内完成了对12种不同传感器与执行器的统一BLE接入。这种基于GATT规范的模块化设计,正是其强大生命力的直接体现。

开发板推荐:天空星STM32F407VET6开发板

超高性价比 STM32主控 | 超高主频 | 一板兼容百芯 | 比赛神器 | 沉金彩色丝印

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值