4. GATT 服务端开发实战(基于ESP32):创建自定义Service与Characteristic、实现Read/Write回调、配置CCCD使能Notify

好,咱们直接进入正题。上一章我们把GATT协议栈的骨架聊清楚了,这一章就来点实在的——在ESP32上亲手搭一个自定义GATT服务。说白了,就是让你的设备能“说话”,能被手机读写,还能主动往外推数据。

我个人习惯把GATT服务端开发分成三步走:搭骨架(创建Service和Characteristic)、填血肉(实现回调)、通经络(配置CCCD使能Notify)。下面咱们一步步来。

核心要点:一个完整的GATT服务端,本质就是“属性表”+“回调函数”+“通知通道”的组合。ESP32的Bluedroid协议栈把这些封装成了API,但底层逻辑你得心里有数。

4.1 创建自定义Service与Characteristic

先看代码。我习惯用esp_bt_gatts_register_cb注册回调,然后在ESP_GATTS_REG_EVT事件里开始创建服务。你想想看,这就像盖房子——先打地基(注册),再搭框架(创建Service),最后装门窗(添加Characteristic)。

// 1. 定义Service和Characteristic的UUID
#define GATTS_SERVICE_UUID     0x00FF
#define GATTS_CHAR_UUID_READ   0xFF01
#define GATTS_CHAR_UUID_NOTIFY 0xFF02

// 2. 定义Characteristic的属性
#define CHAR_READ_FLAGS   (ESP_GATT_CHAR_PROP_BIT_READ)
#define CHAR_NOTIFY_FLAGS (ESP_GATT_CHAR_PROP_BIT_READ | \
                           ESP_GATT_CHAR_PROP_BIT_NOTIFY)

// 3. 在回调中创建
static void gatts_profile_event_handler(esp_gatts_cb_event_t event,
                                        esp_gatt_if_t gatts_if,
                                        esp_ble_gatts_cb_param_t *param) {
    switch (event) {
        case ESP_GATTS_REG_EVT:
            // 注册成功后,开始创建Service
            esp_ble_gatts_create_service(gatts_if, &service_id, 2);
            break;
            
        case ESP_GATTS_CREATE_EVT: {
            // Service创建成功,开始添加Characteristic
            esp_ble_gatts_start_service(param->create.service_handle);
            
            // 添加可读Characteristic
            esp_ble_gatts_add_char(
                param->create.service_handle,
                &char_uuid_read,
                ESP_GATT_PERM_READ,
                CHAR_READ_FLAGS,
                &char_val_read,
                NULL
            );
            
            // 添加可读+Notify Characteristic
            esp_ble_gatts_add_char(
                param->create.service_handle,
                &char_uuid_notify,
                ESP_GATT_PERM_READ,
                CHAR_NOTIFY_FLAGS,
                &char_val_notify,
                NULL
            );
            break;
        }
    }
}

嗯,这里要注意一个坑:Characteristic的权限(Permission)和属性(Property)是两回事。权限决定“能不能读/写”,属性决定“怎么读/写”。我刚开始做的时候,把ESP_GATT_CHAR_PROP_BIT_READ当成了权限用,结果手机端死活读不到数据。后来才搞明白——权限是ESP_GATT_PERM_READ,属性是ESP_GATT_CHAR_PROP_BIT_READ,两个都得配。

4.2 实现Read/Write回调

服务创建好了,Characteristic也加上了,但手机读数据时,ESP32怎么响应?这就需要实现回调函数。说白了,就是告诉协议栈:“当手机读这个Characteristic时,你该返回什么数据”。

我在项目中遇到过一个问题:手机端读到的数据总是空的。排查了半天,发现是回调里没有调用esp_ble_gatts_send_response。记住,Read请求必须显式回复,否则协议栈会超时。

// Read回调示例
case ESP_GATTS_READ_EVT: {
    esp_gatt_rsp_t rsp;
    memset(&rsp, 0, sizeof(esp_gatt_rsp_t));
    rsp.attr_value.handle = param->read.handle;
    rsp.attr_value.len = 4;
    rsp.attr_value.value[0] = 0x12;
    rsp.attr_value.value[1] = 0x34;
    rsp.attr_value.value[2] = 0xAB;
    rsp.attr_value.value[3] = 0xCD;
    
    // 必须调用这个函数回复
    esp_ble_gatts_send_response(gatts_if, param->read.conn_id,
                                param->read.trans_id, ESP_GATT_OK, &rsp);
    break;
}

// Write回调示例
case ESP_GATTS_WRITE_EVT: {
    // 检查是否是长写(prepare write)
    if (param->write.is_prep == false) {
        // 普通写请求,直接处理
        uint8_t *data = param->write.value;
        uint16_t len = param->write.len;
        // 处理写入的数据...
        
        // 回复写成功
        esp_ble_gatts_send_response(gatts_if, param->write.conn_id,
                                    param->write.trans_id, ESP_GATT_OK, NULL);
    } else {
        // 长写需要特殊处理,这里先不展开
    }
    break;
}

避坑指南:我曾经在Write回调里忘记检查param->write.is_prep,结果手机端发长写时,程序直接崩溃。后来加了个判断,长写走单独的逻辑,问题就解决了。另外,send_response的trans_id一定要用回调参数里的,别自己瞎编。

4.3 配置CCCD使能Notify

Notify是GATT里最实用的功能之一。你想让设备主动给手机发数据,就得靠它。但Notify不是默认开启的——手机端需要先往CCCD(Client Characteristic Configuration Descriptor)里写0x0001,设备端才能发通知。

CCCD是啥?说白了,就是每个支持Notify的Characteristic自带的一个“开关”。手机写1,开关打开;手机写0,开关关闭。ESP32的协议栈会自动处理CCCD的读写,但你需要监听ESP_GATTS_WRITE_EVT事件,判断是不是在写CCCD。

// 在Write回调中判断CCCD
case ESP_GATTS_WRITE_EVT: {
    // 判断是否是CCCD写操作
    if (param->write.handle == notify_char_cccd_handle) {
        uint16_t cccd_value = param->write.value[0] | 
                              (param->write.value[1] << 8);
        if (cccd_value == 0x0001) {
            // 手机使能了Notify
            notify_enabled = true;
            ESP_LOGI(TAG, "Notify enabled by client");
        } else if (cccd_value == 0x0000) {
            // 手机禁用了Notify
            notify_enabled = false;
            ESP_LOGI(TAG, "Notify disabled by client");
        }
    }
    break;
}

// 发送Notify数据
void send_notify_data(uint8_t *data, uint16_t len) {
    if (notify_enabled == false) {
        ESP_LOGW(TAG, "Notify not enabled, cannot send");
        return;
    }
    
    esp_ble_gatts_send_indicate(gatts_if, conn_id,
                                notify_char_handle,
                                len, data, false);  // false表示Notify,true表示Indicate
}

重要提醒:Notify和Indicate的区别一定要搞清楚。Notify是“发出去就不管了”,不保证对方收到;Indicate需要对方回复确认,可靠性更高但速度慢。我一般用Notify传传感器数据(丢几帧无所谓),用Indicate传控制指令(必须确保收到)。

4.4 整体流程梳理

为了让你看得更清楚,我画了一张流程图。这张图把整个GATT服务端初始化和交互的脉络串起来了。

GATT服务端开发流程 1. 注册GATT回调 2. 创建Service 3. 添加Characteristic 4. 实现Read/Write回调 5. 配置CCCD使能Notify 6. 发送Notify/Indicate 关键点 • 注册后等待REG_EVT • Service创建后需start • CCCD写0x0001才可Notify 常见错误 • 忘记send_response • Permission/Property混淆

4.5 完整代码骨架

最后,我把上面所有的片段拼成一个完整的骨架。你直接复制到ESP-IDF工程里,改改UUID和数据处理逻辑就能用。

// gatts_demo.c - 完整骨架
#include "esp_bt.h"
#include "esp_bt_main.h"
#include "esp_gap_ble_api.h"
#include "esp_gatts_api.h"

#define GATTS_TAG "GATTS_DEMO"

// UUID定义
static esp_bt_uuid_t service_uuid = {
    .len = ESP_UUID_LEN_16,
    .uuid.uuid16 = 0x00FF,
};

static esp_bt_uuid_t char_read_uuid = {
    .len = ESP_UUID_LEN_16,
    .uuid.uuid16 = 0xFF01,
};

static esp_bt_uuid_t char_notify_uuid = {
    .len = ESP_UUID_LEN_16,
    .uuid.uuid16 = 0xFF02,
};

// 全局状态
static bool notify_enabled = false;
static uint16_t conn_id = 0;
static uint16_t notify_char_handle = 0;
static uint16_t notify_char_cccd_handle = 0;

// 回调函数
static void gatts_event_handler(esp_gatts_cb_event_t event,
                                esp_gatt_if_t gatts_if,
                                esp_ble_gatts_cb_param_t *param) {
    switch (event) {
        case ESP_GATTS_REG_EVT:
            // 创建Service
            esp_ble_gatts_create_service(gatts_if, &service_id, 2);
            break;
            
        case ESP_GATTS_CREATE_EVT:
            // 启动Service,添加Characteristic
            esp_ble_gatts_start_service(param->create.service_handle);
            // ... 添加两个Characteristic
            break;
            
        case ESP_GATTS_READ_EVT:
            // 处理读请求
            break;
            
        case ESP_GATTS_WRITE_EVT:
            // 处理写请求,包括CCCD
            break;
            
        case ESP_GATTS_CONNECT_EVT:
            conn_id = param->connect.conn_id;
            break;
            
        case ESP_GATTS_DISCONNECT_EVT:
            notify_enabled = false;
            // 重新开始广播
            break;
            
        default:
            break;
    }
}

void app_main(void) {
    // 初始化NVS
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES) {
        esp_partition_iterator_t pi = esp_partition_find_first(
            ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, NULL);
        if (pi != NULL) {
            esp_partition_erase_range(esp_partition_get(pi), 0, 
                                      esp_partition_get(pi)->size);
            esp_partition_iterator_release(pi);
        }
        nvs_flash_init();
    }
    
    // 初始化蓝牙
    esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
    esp_bt_controller_init(&bt_cfg);
    esp_bt_controller_enable(ESP_BT_MODE_BTDM);
    
    // 初始化Bluedroid
    esp_bluedroid_init();
    esp_bluedroid_enable();
    
    // 注册GATT回调
    esp_ble_gatts_register_callback(gatts_event_handler);
    esp_ble_gatts_app_register(0);
}

调试小技巧:我习惯在ESP_GATTS_READ_EVT和ESP_GATTS_WRITE_EVT里加ESP_LOGI打印handle和data。这样手机端一操作,串口就能看到,排查问题快很多。另外,用nRF Connect或LightBlue这类APP测试,比你自己写手机端快得多。