ESP32蓝牙开发实战:从零搭建GATT服务器(基于ESP-IDF 4.4)
最近在做一个智能家居传感器项目,需要让ESP32通过蓝牙向手机App实时上报温湿度数据。翻遍了官方文档和社区帖子,发现很多教程要么过于简略,要么直接扔出一段看不懂的例程代码。真正要从零开始,理解GATT服务器的每个环节并跑通数据流,还是得自己动手趟一遍。这篇文章就是我这趟“趟坑之旅”的完整记录,我会用最直白的语言,拆解ESP-IDF 4.4框架下构建一个功能完备的GATT服务器的全过程,不仅告诉你怎么做,更解释清楚为什么要这么做。
如果你也是物联网开发者或硬件工程师,正面临蓝牙数据交互的需求,无论是电池供电的传感器、可穿戴设备,还是需要无线配置的智能硬件,这套从初始化到数据收发的实战流程,应该能帮你省下不少摸索的时间。我们不止步于让灯闪烁,而是要构建一个稳定、可扩展的蓝牙服务核心。
1. 项目构思与蓝牙协议栈核心认知
在动手写代码之前,我们先得想清楚两件事:我们的设备要扮演什么角色?蓝牙协议栈里哪些部分是我们必须打交道的?
在蓝牙低功耗(BLE)的世界里,通信双方有明确的角色划分。最常见的是外围设备(Peripheral) 和中央设备(Central)。我们的ESP32在大多数物联网场景下,都是作为外围设备存在的——比如一个温湿度计,它电量有限,需要被动地被手机(中央设备)发现、连接,然后提供服务。GATT服务器就是运行在外围设备上,用于对外提供数据服务的那套逻辑。相反,手机上的App则作为GATT客户端,来发现、读取和写入这些数据。
理解了这个角色,我们再来看ESP-IDF蓝牙协议栈的架构。它像是一个分层清晰的工厂:
应用层 (Your App)
|
GATT API / GAP API (管理层)
|
BLE Controller (控制器)
|
物理层 (Radio)
我们开发者主要工作在GATT API和GAP API这一层。GAP(通用访问配置文件)管“外交”:设备怎么被看见(广播)、怎么建立和断开连接。GATT(通用属性配置文件)管“内政”:设备内部有哪些数据服务(Service),每个服务里有哪些特征值(Characteristic),这些值能不能读、能不能写、能不能主动通知(Notify)客户端。
一个常见的误解是以为GATT服务表是一成不变的。实际上,它是一个在运行时动态构建的数据库。我们的代码需要定义这个数据库的“蓝图”(属性表),然后在蓝牙协议栈初始化完成后,命令控制器根据这个蓝图在内存中创建出真正的服务句柄。这个“创建”动作本身,也是一个异步事件,需要我们在回调函数里捕获并处理。
2. 开发环境搭建与项目初始化
工欲善其事,必先利其器。虽然VSCode+ESP-IDF插件是官方推荐的高效组合,但这里我想分享一些能提升体验的细节配置,特别是对于蓝牙开发来说。
首先,确保你的ESP-IDF版本在4.4或以上。蓝牙协议栈的API在4.0之后有过一次较大的优化,4.4版本已经非常稳定。你可以通过idf.py –version来查看。如果还没安装,从乐鑫官方GitHub仓库拉取指定版本是更稳妥的做法。
创建一个新项目时,不要从空项目开始。ESP-IDF提供了丰富的示例,我们可以从gatt_server_service_table这个例程的副本入手。但我的建议是,在复制后,立即执行以下几步“清洁工作”:
- Component config -> Bluetooth -> Bluedroid Enable:确保已启用。这是ESP32经典的蓝牙协议栈实现。
- Component config -> Bluetooth -> Bluetooth controller -> BLE only:对于只需BLE的项目,选择此项可以节省一些内存和功耗。
一个干净的sdkconfig.defaults文件可以固化这些配置,方便团队协作:
# 蓝牙基础配置
CONFIG_BT_ENABLED=y
CONFIG_BT_BLUEDROID_ENABLED=y
CONFIG_BT_DM_SCAN_DUPL=y
CONFIG_BT_BLE_ENABLED=y
CONFIG_BT_GATTS_ENABLE=y
CONFIG_BT_GATTS_SEND_SERVICE_CHANGE_MODE=0
# 优化内存与日志
CONFIG_BT_BTU_TASK_STACK_SIZE=4096
CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT=30
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
注意:蓝牙协议栈任务(如BTU、HCI)会消耗不少内存(通常需要8KB以上栈空间)。如果你的项目还有其他繁重任务(如Wi-Fi、音频处理),务必在menuconfig中适当调大相关任务的栈大小,否则可能导致难以排查的崩溃。
3. 蓝牙协议栈初始化与事件回调框架
一切就从app_main()函数开始。这里的初始化流程像启动一台精密仪器,顺序错了就可能无法正常工作。
3.1 控制器与协议栈初始化
首先,我们需要配置并启动蓝牙控制器。ESP-IDF提供了一个默认配置宏,对于大多数应用来说,直接使用它是个安全的起点。
void ble_init(void) {
// 1. 初始化控制器配置(使用默认值)
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
// 你可以在此覆盖默认值,例如修改蓝牙模式或TX功率
// bt_cfg.ble_max_conn = 3; // 最大连接数
// bt_cfg.controller_task_stack_size = 4096; // 控制器任务栈大小
// 2. 初始化蓝牙控制器
esp_err_t ret = esp_bt_controller_init(&bt_cfg);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Bluetooth controller initialize failed: %s", esp_err_to_name(ret));
return;
}
// 3. 使能控制器(选择BLE模式)
ret = esp_bt_controller_enable(ESP_BT_MODE_BLE);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Bluetooth controller enable failed: %s", esp_err_to_name(ret));
return;
}
// 4. 初始化并启用Bluedroid协议栈
ret = esp_bluedroid_init();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Bluedroid initialize failed: %s", esp_err_to_name(ret));
return;
}
ret = esp_bluedroid_enable();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Bluedroid enable failed: %s", esp_err_to_name(ret));
}
}
提示:ESP_BT_MODE_BLE是功耗最低的模式。如果你的设备需要同时支持经典蓝牙(如A2DP音频),则需要选择ESP_BT_MODE_BTDM(双模)。
3.2 理解并注册GAP与GATT事件回调
协议栈运行起来后,它如何把“连接建立了”、“有数据写进来了”这些消息告诉我们?答案就是回调函数。我们需要为GAP和GATT分别注册一个事件处理函数。
这里有一个关键概念:GATT接口(GATT_IF)。你可以把它理解为一个虚拟的“服务端口”。一个GATT应用(Application)对应一个GATT_IF。一个物理设备(ESP32)可以注册多个GATT应用,每个应用管理自己的一套服务。对于大多数单服务设备,注册一个就够了。
// GAP事件处理器 – 处理广播、连接等“外交”事件
static void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
switch (event) {
// 当原始广播数据设置完成时触发
case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT:
ESP_LOGI(TAG, "Broadcast data set complete.");
// 通常在这里检查所有广播参数是否就绪,然后开始广播
break;
// 当广播启动完成时触发(成功或失败)
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT:
if (param->adv_start_cmpl.status == ESP_BT_STATUS_SUCCESS) {
ESP_LOGI(TAG, "Broadcast started successfully.");
} else {
ESP_LOGE(TAG, "Failed to start broadcast.");
}
break;
// 当有设备连接上来时触发
case ESP_GAP_BLE_CONNECT_EVT:
ESP_LOGI(TAG, "Device connected.");
// 可以在这里保存连接句柄和对方地址,用于后续数据通信
memcpy(connected_addr, param->connect.remote_bda, 6);
break;
// 当连接断开时触发
case ESP_GAP_BLE_DISCONNECT_EVT:
ESP_LOGI(TAG, "Device disconnected, reason: 0x%x", param->disconnect.reason);
// 断开后,通常需要重新开始广播以等待下一次连接
esp_ble_gap_start_advertising(&adv_params);
break;
default:
// 其他事件,如连接参数更新等,可根据需要处理
break;
}
}
// GATT事件处理器 – 处理数据读写等“内政”事件
static void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) {
// 这个gatts_if参数很重要,它指明了是哪个GATT应用触发的事件
if (event == ESP_GATTS_REG_EVT) {
// 当GATT应用注册成功时,我们拿到了它的接口号(gatts_if)
ESP_LOGI(TAG, "GATT application registered, interface: %d", gatts_if);
// 保存这个接口号,后续创建服务、发送数据都要用到它
gl_profile_tab[PROFILE_A].gatts_if = gatts_if;
}
// 将事件分发到具体的Profile(服务组)处理函数
if (gatts_if == ESP_GATT_IF_NONE || gatts_if == gl_profile_tab[PROFILE_A].gatts_if) {
profile_a_event_handler(event, gatts_if, param);
}
}
注册回调的代码放在协议栈启用之后:
// 注册GAP和GATT全局回调函数
esp_ble_gap_register_callback(gap_event_handler);
esp_ble_gatts_register_callback(gatts_event_handler);
// 注册一个GATT应用(会触发ESP_GATTS_REG_EVT事件)
esp_ble_gatts_app_register(PROFILE_A_APP_ID);
这个阶段最容易遇到的坑是事件顺序。你必须确保在ESP_GATTS_REG_EVT事件发生后,拿到了有效的gatts_if,才能进行后续的服务创建和广播启动操作。否则,函数调用会失败。
4. 构建GATT服务表:定义你的数据蓝图
这是GATT服务器的核心。你需要像设计数据库表结构一样,仔细定义你的服务。一个完整的服务通常包含以下几个部分:
| 服务(Service) | 数据功能的容器,如“电池服务”、“温度服务”。 | 一个数据库(Database) |
| 特征(Characteristic) | 服务内的具体数据点,如“电池电量”、“温度值”。 | 数据库里的一张表(Table) |
| 特征值(Characteristic Value) | 特征的实际数据存储位置。 | 表里的具体数据行(Row) |
| 描述符(Descriptor) | 描述或配置特征的元数据,最常用的是CCCD。 | 表的索引或触发器(Index/Trigger) |
让我们用代码来定义一个简单的“环境传感器服务”,它包含一个可读的温度特征和一个可写、可通知的湿度特征。
4.1 定义UUID
UUID是服务和特征的唯一身份证。16位的短UUID常用于标准服务(如0x180F是电池服务),128位的长UUID用于自定义服务。为了节省空间,我们通常使用16位UUID,但要在前面加上蓝牙基础UUID。
// 定义16位UUID(可以自己定义,但避免与标准UUID冲突)
#define ESP_SENSOR_SERVICE_UUID 0xA001
#define ESP_CHAR_TEMP_READ_UUID 0xA002
#define ESP_CHAR_HUMI_RW_NOTIFY_UUID 0xA003
// 声明UUID变量(它们会被填充为完整的128位UUID)
static uint16_t sensor_service_uuid = ESP_SENSOR_SERVICE_UUID;
static uint16_t char_temp_read_uuid = ESP_CHAR_TEMP_READ_UUID;
static uint16_t char_humi_rw_notify_uuid = ESP_CHAR_HUMI_RW_NOTIFY_UUID;
// 客户端特征配置描述符(CCCD)的UUID是标准的
static uint16_t char_client_config_uuid = ESP_GATT_UUID_CHAR_CLIENT_CONFIG;
4.2 构建属性数据库
属性数据库是一个esp_gatts_attr_db_t类型的数组。每个元素对应GATT数据库中的一个属性句柄。创建的顺序就是索引的顺序。
// 定义数据库属性数量:1个服务声明 + (2个特征 * (1声明+1值)) + 1个CCCD = 6个属性
#define SENSOR_DB_NUM 6
// 特征值属性权限宏组合(更清晰)
#define ESP_GATT_PERM_READ_ENCRYPTED (ESP_GATT_PERM_READ | ESP_GATT_PERM_READ_ENCRYPTED)
#define ESP_GATT_PERM_WRITE_ENCRYPTED (ESP_GATT_PERM_WRITE | ESP_GATT_PERM_WRITE_ENCRYPTED)
// 特征属性(可读、可写、可通知等)
static uint8_t char_prop_read = ESP_GATT_CHAR_PROP_BIT_READ;
static uint8_t char_prop_write = ESP_GATT_CHAR_PROP_BIT_WRITE;
static uint8_t char_prop_notify = ESP_GATT_CHAR_PROP_BIT_NOTIFY;
// 组合属性:可读、可写、可通知
static uint8_t char_prop_rw_notify = ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_WRITE | ESP_GATT_CHAR_PROP_BIT_NOTIFY;
// 实际存储特征值的内存
static uint8_t temp_value[2] = {0x00, 0x00}; // 假设温度是16位整数,单位0.01摄氏度
static uint8_t humi_value[2] = {0x00, 0x00}; // 湿度值
static uint8_t humi_cccd[2] = {0x00, 0x00}; // CCCD值,默认为0(通知关闭)
static const esp_gatts_attr_db_t sensor_gatt_db[SENSOR_DB_NUM] = {
// 索引0: 主服务声明
[IDX_SVC] = {
{ESP_GATT_AUTO_RSP}, // 自动响应标志
{ESP_UUID_LEN_16, (uint8_t *)&primary_service_uuid, ESP_GATT_PERM_READ,
sizeof(uint16_t), sizeof(sensor_service_uuid), (uint8_t *)&sensor_service_uuid}
},
// — 温度特征(只读)—
// 索引1: 温度特征声明
[IDX_CHAR_TEMP_DECL] = {
{ESP_GATT_AUTO_RSP},
{ESP_UUID_LEN_16, (uint8_t *)&character_declaration_uuid, ESP_GATT_PERM_READ,
sizeof(uint8_t), sizeof(char_prop_read), (uint8_t *)&char_prop_read}
},
// 索引2: 温度特征值
[IDX_CHAR_TEMP_VAL] = {
{ESP_GATT_AUTO_RSP},
{ESP_UUID_LEN_16, (uint8_t *)&char_temp_read_uuid, ESP_GATT_PERM_READ,
sizeof(temp_value), sizeof(temp_value), (uint8_t *)temp_value}
},
// — 湿度特征(可读、可写、可通知)—
// 索引3: 湿度特征声明
[IDX_CHAR_HUMI_DECL] = {
{ESP_GATT_AUTO_RSP},
{ESP_UUID_LEN_16, (uint8_t *)&character_declaration_uuid, ESP_GATT_PERM_READ,
sizeof(uint8_t), sizeof(char_prop_rw_notify), (uint8_t *)&char_prop_rw_notify}
},
// 索引4: 湿度特征值
[IDX_CHAR_HUMI_VAL] = {
{ESP_GATT_AUTO_RSP},
{ESP_UUID_LEN_16, (uint8_t *)&char_humi_rw_notify_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
sizeof(humi_value), sizeof(humi_value), (uint8_t *)humi_value}
},
// 索引5: 湿度特征的CCCD(用于开关通知)
[IDX_CHAR_HUMI_CCCD] = {
{ESP_GATT_AUTO_RSP},
{ESP_UUID_LEN_16, (uint8_t *)&char_client_config_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
sizeof(uint16_t), sizeof(humi_cccd), (uint8_t *)humi_cccd}
},
};
关键点解析:
- ESP_GATT_AUTO_RSP:这是魔法所在。设置为它,协议栈会自动处理该属性的读/写响应。如果设置为ESP_GATT_RSP_BY_APP,则需要你在事件回调中手动调用esp_ble_gatts_send_response。对于简单的数据存储,用AUTO_RSP更省心。
- 权限(Perm):定义了客户端能对这个属性做什么。ESP_GATT_PERM_READ和ESP_GATT_PERM_WRITE是最基本的。如果需要加密连接后才能访问,可以加上ESP_GATT_PERM_READ_ENCRYPTED等。
- 特征声明(Characteristic Declaration):这是一个特殊的属性,它的“值”部分存放的不是用户数据,而是该特征的属性位(Properties)(即可读、可写、可通知等)。这个位图必须与后面特征值属性的实际权限匹配,否则客户端会困惑。
- CCCD:它是一个16位的值。当客户端写入0x0001时,表示启用通知(Notification);写入0x0002,表示启用指示(Indication,带确认的通知);写入0x0000则关闭。服务器需要检查这个值来决定是否主动发送数据。
4.3 创建服务并启动广播
属性表定义好后,需要在ESP_GATTS_REG_EVT事件中创建它。
static void profile_a_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:
ESP_LOGI(TAG, "Creating attribute table…");
// 使用注册得到的gatts_if来创建我们定义好的属性表
esp_err_t create_ret = esp_ble_gatts_create_attr_tab(sensor_gatt_db, gatts_if, SENSOR_DB_NUM, 0);
if (create_ret) {
ESP_LOGE(TAG, "Create attribute table failed, error=0x%x", create_ret);
}
break;
case ESP_GATTS_CREAT_ATTR_TAB_EVT:
// 属性表创建完成事件
if (param->add_attr_tab.status == ESP_GATT_OK) {
ESP_LOGI(TAG, "Attribute table created, number of handles: %d", param->add_attr_tab.num_handle);
// 保存返回的句柄数组!后续操作全靠这些句柄。
memcpy(handle_table, param->add_attr_tab.handles, param->add_attr_tab.num_handle * sizeof(uint16_t));
// 启动服务:参数是服务声明的句柄(通常是第一个句柄)
esp_ble_gatts_start_service(handle_table[IDX_SVC]);
// 服务启动后,可以开始广播了
start_advertising(gatts_if);
}
break;
// … 其他事件处理
}
}
start_advertising函数负责配置并启动广播,让手机能发现我们的设备。
static void start_advertising(esp_gatt_if_t gatts_if) {
// 1. 设置设备名称(会出现在手机扫描列表中)
esp_ble_gap_set_device_name("ESP32_SENSOR");
// 2. 配置广播参数
esp_ble_adv_params_t adv_params = {
.adv_int_min = 0x20, // 最小广播间隔:0x20 * 0.625ms = 40ms
.adv_int_max = 0x40, // 最大广播间隔:0x40 * 0.625ms = 64ms
.adv_type = ADV_TYPE_IND, // 可连接的非定向广播
.own_addr_type = BLE_ADDR_TYPE_PUBLIC, // 地址类型
.channel_map = ADV_CHNL_ALL, // 在所有3个广播信道上广播
.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, // 允许任何设备扫描和连接
};
// 3. 配置广播数据包(必须包含Flags和部分或全部UUID)
uint8_t adv_data[31] = {
// Flags: 普通发现模式,不支持经典蓝牙
0x02, 0x01, 0x06,
// 完整的16位服务UUID列表
0x03, 0x03, 0x01, 0xA0, // 低字节在前:0xA001 -> 0x01, 0xA0
// 设备名称(缩短版)
0x0A, 0x09, 'E','S','P','3','2','_','S','e','n'
};
esp_ble_gap_config_adv_data_raw(adv_data, sizeof(adv_data));
// 4. 启动广播
esp_ble_gap_start_advertising(&adv_params);
}
广播启动后,用手机BLE调试App应该就能搜到名为“ESP32_SENSOR”的设备了。连接后,能看到我们定义的服务和特征。
5. 实现数据交互:读、写与主动通知
服务建好,连接建立,接下来就是真正的数据交换。所有交互都通过GATT事件回调来驱动。
5.1 处理读取请求
当客户端读取一个特征值时,如果该属性是ESP_GATT_AUTO_RSP,协议栈会自动将我们存储在属性表中的当前值返回。但有时我们需要动态生成值(比如读取实时传感器数据),这时就需要在ESP_GATTS_READ_EVT事件中处理。
case ESP_GATTS_READ_EVT: {
ESP_LOGI(TAG, "Read event, handle=%d", param->read.handle);
// 判断读取的是哪个特征
if (param->read.handle == handle_table[IDX_CHAR_TEMP_VAL]) {
// 假设从传感器读取最新温度(这里是模拟)
int16_t temp_actual = read_temperature_sensor(); // 单位0.01°C
temp_value[0] = temp_actual & 0xFF;
temp_value[1] = (temp_actual >> 8) & 0xFF;
ESP_LOGI(TAG, "Dynamic temp read: %d.%02d °C", temp_actual/100, temp_actual%100);
// 因为是AUTO_RSP,更新了temp_value数组后,协议栈会自动返回新值。
// 如果需要复杂处理,可以设置属性为RSP_BY_APP,并在此调用esp_ble_gatts_send_response。
}
break;
}
5.2 处理写入请求
客户端写入数据时,触发ESP_GATTS_WRITE_EVT。这是接收客户端指令的主要方式。
case ESP_GATTS_WRITE_EVT: {
ESP_LOGI(TAG, "Write event, handle=%d, len=%d", param->write.handle, param->write.len);
// 判断写入位置
if (param->write.handle == handle_table[IDX_CHAR_HUMI_VAL]) {
// 客户端写湿度特征值(例如,设置湿度报警阈值)
if (param->write.len <= sizeof(humi_value)) {
memcpy(humi_value, param->write.value, param->write.len);
ESP_LOGI(TAG, "Humidity threshold set to new value.");
// 这里可以触发一个任务,去更新实际的传感器报警逻辑
}
}
else if (param->write.handle == handle_table[IDX_CHAR_HUMI_CCCD]) {
// 客户端写CCCD,用于开启/关闭通知
uint16_t cccd_value = param->write.value[0] | (param->write.value[1] << 8);
if (param->write.len == 2) {
if (cccd_value == 0x0001) {
ESP_LOGI(TAG, "Notification ENABLED for humidity.");
is_notification_enabled = true;
} else if (cccd_value == 0x0000) {
ESP_LOGI(TAG, "Notification DISABLED for humidity.");
is_notification_enabled = false;
}
// 更新本地CCCD值,以便后续读取能返回正确状态
humi_cccd[0] = param->write.value[0];
humi_cccd[1] = param->write.value[1];
}
}
// 如果需要响应(非AUTO_RSP),在这里发送响应
// if (!param->write.need_rsp) { … }
break;
}
5.3 主动发送通知(Notification)
这是服务器主动向客户端推送数据的机制,非常适合传感器数据周期性上报。
// 假设在一个定时器回调或传感器读取任务中
void sensor_task(void *arg) {
while(1) {
vTaskDelay(pdMS_TO_TICKS(2000)); // 每2秒一次
if (is_connected && is_notification_enabled) {
// 1. 读取最新的传感器数据(例如湿度)
uint16_t latest_humi = read_humidity_sensor();
uint8_t send_data[2];
send_data[0] = latest_humi & 0xFF;
send_data[1] = (latest_humi >> 8) & 0xFF;
// 2. 更新本地特征值(可选,但保持同步是好的做法)
memcpy(humi_value, send_data, 2);
// 3. 发送通知
esp_err_t send_ret = esp_ble_gatts_send_indicate(
gatts_if, // GATT接口
conn_id, // 连接ID(从ESP_GATTS_CONNECT_EVT获取)
handle_table[IDX_CHAR_HUMI_VAL], // 湿度特征值的句柄
sizeof(send_data), // 数据长度
send_data, // 数据指针
false // 是否是Indication(需要确认)?false表示Notification
);
if (send_ret != ESP_OK) {
ESP_LOGE(TAG, "Send notification failed: %s", esp_err_to_name(send_ret));
// 发送失败可能因为连接已断开,可以在此检查并更新状态
} else {
ESP_LOGI(TAG, "Notification sent, humidity: %d", latest_humi);
}
}
}
}
Notification vs Indication:两者都用于服务器主动发送数据。区别在于Indication要求客户端收到后必须回复一个确认(Confirmation),协议栈会因此触发一个ESP_GATTS_CONF_EVT事件。Indication更可靠,但延迟稍高;Notification更轻快,但可能丢包。根据数据重要性选择。
6. 连接管理、安全与功耗优化
一个健壮的GATT服务器不能只管功能,还得考虑稳定性、安全性和续航。
6.1 连接参数协商
连接建立后(ESP_GATTS_CONNECT_EVT),中央设备(手机)会提议一套连接参数(间隔、延迟、超时)。外围设备可以接受,也可以发起更新请求以获得更优的功耗或吞吐量。
case ESP_GATTS_CONNECT_EVT: {
conn_id = param->connect.conn_id;
memcpy(connected_addr, param->connect.remote_bda, 6);
is_connected = true;
// 发起连接参数更新请求(更省电的参数)
esp_ble_conn_update_params_t conn_params = {0};
memcpy(conn_params.bda, connected_addr, sizeof(esp_bd_addr_t));
conn_params.latency = 0; // 从机延迟
conn_params.max_int = 0x18; // 最大连接间隔:0x18 * 1.25ms = 30ms
conn_params.min_int = 0x0C; // 最小连接间隔:0x0C * 1.25ms = 15ms
conn_params.timeout = 600; // 监控超时:600 * 10ms = 6s
esp_ble_gap_update_conn_params(&conn_params);
break;
}
更小的连接间隔意味着更快的响应速度,但功耗更高。需要根据应用场景(是频繁交互的遥控器,还是几分钟上报一次的传感器)来权衡。
6.2 实现简单的配对与绑定
对于需要防止数据被窃听或篡改的应用,可以启用BLE安全配对。ESP-IDF支持Just Works、Passkey Entry等多种配对方式。
// 在广播启动前,设置安全参数
static void set_security(void) {
esp_ble_auth_req_t auth_req = ESP_LE_AUTH_REQ_SC_MITM_BOND; // 要求安全连接、MITM保护、绑定
esp_ble_io_cap_t iocap = ESP_IO_CAP_NONE; // 无输入输出能力(Just Works配对)
uint8_t init_key = ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK;
uint8_t rsp_key = ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK;
esp_ble_gap_set_security_param(ESP_BLE_SM_AUTHEN_REQ_MODE, &auth_req, sizeof(uint8_t));
esp_ble_gap_set_security_param(ESP_BLE_SM_IOCAP_MODE, &iocap, sizeof(uint8_t));
esp_ble_gap_set_security_param(ESP_BLE_SM_SET_INIT_KEY, &init_key, sizeof(uint8_t));
esp_ble_gap_set_security_param(ESP_BLE_SM_SET_RSP_KEY, &rsp_key, sizeof(uint8_t));
}
启用安全后,特征值的权限可能需要加上ESP_GATT_PERM_READ_ENCRYPTED或ESP_GATT_PERM_WRITE_ENCRYPTED,确保只有在加密连接下才能访问。
6.3 深度睡眠与广播优化
对于电池供电的设备,功耗是生命线。ESP32的蓝牙控制器在深度睡眠时可以被唤醒,但协议栈状态会丢失,需要重新初始化。一个更实用的低功耗策略是优化广播和连接参数。
- 降低广播频率:增大adv_int_min和adv_int_max。牺牲被发现的速度,换取更低的待机功耗。
- 使用定向广播:如果只与特定中央设备连接,可以使用定向广播,但限制较多。
- 快速断开与睡眠:在数据发送完毕后,可以主动断开连接(esp_ble_gap_disconnect),然后让ESP32进入轻睡眠或深度睡眠,定时醒来广播一小段时间。
// 一个简单的节能策略示例
static void enter_low_power_mode(void) {
if (is_connected) {
// 如果还连着,先发完最后的数据再考虑断开
return;
}
// 停止广播
esp_ble_gap_stop_advertising();
// 设置更稀疏的广播参数(仅当需要被连接时)
// 在实际项目中,这里可能会触发一个定时器,休眠一段时间后再唤醒广播
ESP_LOGI(TAG, "Entering low power state.");
}
调试功耗时,一定要用实际的万用表或功耗分析仪测量,软件估算往往不准确。关注广播期间、连接期间、以及睡眠期间的电流曲线。
7. 调试技巧与常见问题排雷
即使按照步骤来,也难免会遇到各种奇怪的问题。这里分享几个我踩过的坑和解决方法。
问题1:手机搜不到设备
- 检查广播数据:广播数据包结构必须正确。至少包含Flags(0x01)和Complete List of 16-bit Service UUIDs(0x03)或Shortened Local Name(0x08)等字段。用esp_ble_gap_config_adv_data_raw配置后,查看ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT事件是否成功。
- 检查物理层:确保天线连接正常,没有金属外壳严重屏蔽信号。尝试增加tx_power。
- 检查手机App:有些手机系统对BLE扫描有后台限制,确保App在前台,并尝试重启手机蓝牙。
问题2:连接后立即断开
- 查看断开原因码:在ESP_GAP_BLE_DISCONNECT_EVT事件中,param->disconnect.reason会给出原因。0x08通常是连接超时,0x3E可能是本地主机终止。检查连接参数是否过于极端(间隔太短或超时太长)。
- MTU问题:默认MTU是23字节。如果第一次数据交换就尝试发送超过这个长度的数据,可能导致问题。可以在连接后协商更大的MTU(esp_ble_gattc_send_mtu_req)。
问题3:通知发送失败,返回错误码0x85(ESP_GATT_NOT_FOUND)
- 句柄错误:确保esp_ble_gatts_send_indicate中使用的特征值句柄是正确的,并且是该特征值的句柄,不是特征声明的句柄。
- CCCD未启用:确认客户端已经成功写入了0x0001到CCCD,并且你的代码正确设置了is_notification_enabled标志。
- 连接已失效:发送前检查is_connected标志,并确保conn_id是当前有效连接的ID。连接断开后,旧的conn_id会失效。
问题4:内存不足或任务栈溢出
蓝牙协议栈任务需要足够的栈空间。如果出现***ERROR*** A stack overflow in task之类的错误,或者系统不稳定,请增大相关任务的栈配置。
# 在menuconfig中调整
Component config -> Bluetooth -> Bluedroid options -> GATT task stack size (建议 >= 3072)
Component config -> Bluetooth -> Bluetooth controller -> BLE task stack size (建议 >= 2048)
最有效的调试方法:分层日志与对比验证
最后,代码的健壮性离不开异常处理。每一个esp_ble_开头的函数调用,都应该检查其返回值。重要的全局状态(如连接状态、CCCD状态)要考虑多任务访问的互斥保护,虽然在这个简单服务器中可能不必要,但在复杂的、包含Wi-Fi等功能的项目中,这能避免许多玄学问题。
折腾ESP32蓝牙大半年,最大的体会是:理解协议本身比死记代码更重要。当你明白了GATT数据库是一个属性表,通知是CCCD这个“开关”控制的,很多问题就自然有了排查方向。希望这份结合了实战和原理的梳理,能让你在下次点亮ESP32蓝牙时,心里更有底。
网硕互联帮助中心



评论前必须登录!
注册