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服务端初始化和交互的脉络串起来了。
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测试,比你自己写手机端快得多。