ESP32-C3蓝牙GATT服务开发实战:从模型解析到智能灯控应用

发布时间:2026/7/29 7:22:50
ESP32-C3蓝牙GATT服务开发实战:从模型解析到智能灯控应用 1. 项目概述为ESP32-C3蓝牙应用注入“服务”灵魂如果你已经跟着前面的系列把ESP32-C3的蓝牙广播、扫描、连接这些基础流程都跑通了那恭喜你你已经成功迈入了蓝牙开发的大门。但门后的世界才是真正的应用舞台。今天我们要聊的“添加Service”就是搭建这个舞台的核心骨架。没有Service的蓝牙设备就像一个没有房间的空壳房子虽然能连上门开了但里面空空如也什么功能也实现不了。简单来说蓝牙通信的核心是“服务-特征值”模型。一个蓝牙外设Peripheral比如我们的ESP32-C3就像一个提供多种服务的商店。每个“Service”就是商店里的一个柜台比如“心率监测柜台”、“温度数据柜台”、“控制指令柜台”。而每个柜台里具体的商品就是“Characteristic”特征值它是实际承载数据比如心率数值、温度读数或者接收指令比如开关命令的单元。我们本章要做的就是在ESP32-C3这个“设备”里新建一个或多个这样的“服务柜台”并摆上对应的“商品”让中心设备Central比如手机能够发现、读取、写入甚至监听这些数据。为什么非得用这套模型因为它标准化、可发现、可交互。手机上的蓝牙APP不需要事先知道你的设备具体怎么通信它只需要通过标准的GATT通用属性协议去“浏览”设备提供的服务列表找到它认识的服务UUID比如标准心率服务是0x180D就能立刻知道如何解析里面的数据。对于我们自定义的功能我们也需要定义自己的Service和Characteristic来构建清晰、可扩展的数据通道。接下来我们就从设计思路开始一步步在ESP32-C3上实现自定义服务。2. 服务与特征值模型深度解析在动手写代码之前我们必须吃透蓝牙GATT的模型。这绝非纸上谈兵理解深度直接决定了你未来调试的效率和代码的健壮性。你可以把整个模型想象成一个树状结构设备Device树的根就是我们的ESP32-C3本身。服务Service树干上分出的主要枝干。每个服务是一个逻辑功能的集合。它由一个128位的UUID唯一标识。为了节省空间蓝牙技术联盟SIG定义了一些16位或32位的标准UUID如0x180F为电池服务。特征值Characteristic枝干上的叶子和果实是实际进行数据交互的单元。每个特征值也拥有自己的UUID。它包含几个核心部分值Value实际存储的数据比如一个温度整数。属性Properties定义了中心设备可以对它做什么。这是关键中的关键常见属性有READ允许读取。WRITE或WRITE_NR允许写入后者无响应。NOTIFY允许服务器主动向已订阅的客户端发送更新需客户端先启用。INDICATE类似NOTIFY但要求客户端确认收到更可靠。描述符Descriptor可以附着在特征值上的额外信息最常见的是“客户端特征值配置描述符”CCCD专门用于管理NOTIFY和INDICATE的订阅开关。它们之间的关系是一个设备包含多个服务一个服务包含多个特征值一个特征值可能包含多个描述符。当手机连接上ESP32-C3后它会发起一个“服务发现”过程获取到这整棵“树”的结构。之后所有的通信都是针对某个具体的特征值进行的。注意属性Properties决定了通信的“方向”和“方式”。如果你希望手机能主动给ESP32发命令那么对应的特征值必须包含WRITE属性如果你希望ESP32能主动向手机推送传感器数据那么特征值必须包含NOTIFY或INDICATE属性并且手机需要先向CCCD写入0x0001来启用通知。设计时务必想清楚每个数据通道的流向。3. 基于ESP-IDF的GATT服务实现详解理论清晰后我们进入实战环节。ESP-IDF的蓝牙协议栈已经为我们封装好了创建GATT服务的完整接口主要位于esp_gatts_api.h中。整个过程是事件驱动的我们需要注册一个GATT事件处理回调函数然后在回调函数中响应各种事件一步步构建起我们的服务。3.1 服务与特征值的UUID定义首先我们需要定义标识符。对于自定义功能我们通常使用128位的UUID以确保全球唯一。我们可以用在线工具生成也可以自己定义一个。在代码中我们这样声明// 定义一个自定义服务UUID (这里是一个示例实际项目应使用自己生成的UUID) #define GATTS_SERVICE_UUID 0x00FF // 定义一个自定义特征值UUID用于数据传输 #define GATTS_CHAR_UUID 0xFF01 // 定义一个自定义特征值UUID用于接收控制指令 #define GATTS_CHAR_UUID_CTRL 0xFF02 // 为了方便使用将它们转换为128位的格式ESP32的API通常需要128位UUID static uint16_t primary_service_uuid ESP_GATT_UUID_PRI_SERVICE; static uint16_t char_declaration_uuid ESP_GATT_UUID_CHAR_DECLARE; static uint16_t char_client_config_uuid ESP_GATT_UUID_CHAR_CLIENT_CONFIG; static uint16_t heart_rate_meas_uuid ESP_GATT_UUID_HEART_RATE_MEAS; // 我们的自定义UUID以16位为例实际会扩展为128位 static uint16_t custom_service_uuid GATTS_SERVICE_UUID; static uint16_t custom_char_uuid GATTS_CHAR_UUID; static uint16_t custom_char_ctrl_uuid GATTS_CHAR_UUID_CTRL;对于标准服务如电池、设备信息ESP-IDF提供了常量如ESP_GATT_UUID_BATTERY_SERVICE。使用标准服务可以极大提升与通用APP如nRF Connect、LightBlue的兼容性。3.2 创建服务表GATT Database这是构建服务的核心步骤。我们需要创建一个esp_gatts_attr_db_t类型的查找表按顺序定义服务中的所有元素服务声明、特征值声明、特征值值、描述符等。这张表定义了GATT数据库的静态结构。/// 首先定义特征值的属性权限 static const esp_gatt_perm_t perm_read_write ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE; static const esp_gatt_perm_t perm_read ESP_GATT_PERM_READ; static const esp_gatt_perm_t perm_write ESP_GATT_PERM_WRITE; /// 然后定义特征值的属性位Properties static const uint8_t char_prop_read_write ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_WRITE; static const uint8_t char_prop_read_notify ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_NOTIFY; /// 准备特征值的数据存储空间 static uint8_t char_value[10] {0x00}; // 示例特征值数据初始化为0 static uint8_t ctrl_value[5] {0x00}; // 控制指令特征值数据 /// 定义GATT属性数据库 static const esp_gatts_attr_db_t gatt_db[HRS_IDX_NB] { // 服务声明 (Service Declaration) [HRS_IDX_SVC] {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)primary_service_uuid, ESP_GATT_PERM_READ, sizeof(uint16_t), sizeof(custom_service_uuid), (uint8_t *)custom_service_uuid}}, // 第一个特征值可读可写的数据通道 (Characteristic Declaration Value) [HRS_IDX_CHAR_A] {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)char_declaration_uuid, ESP_GATT_PERM_READ, CHAR_DECLARATION_SIZE, CHAR_DECLARATION_SIZE, (uint8_t *)char_prop_read_write}}, [HRS_IDX_CHAR_VAL_A] {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)custom_char_uuid, perm_read_write, sizeof(char_value), sizeof(char_value), (uint8_t *)char_value}}, // 第一个特征值的CCCD描述符用于启用Notify [HRS_IDX_CHAR_CFG_A] {{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(heart_rate_cccd), (uint8_t *)heart_rate_cccd}}, // 第二个特征值只写的控制通道 [HRS_IDX_CHAR_B] {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)char_declaration_uuid, ESP_GATT_PERM_READ, CHAR_DECLARATION_SIZE, CHAR_DECLARATION_SIZE, (uint8_t *)char_prop_write}}, [HRS_IDX_CHAR_VAL_B] {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)custom_char_ctrl_uuid, perm_write, sizeof(ctrl_value), sizeof(ctrl_value), (uint8_t *)ctrl_value}}, };这段代码定义了一个简单的服务包含两个特征值。第一个CHAR_A可读、可写、可通知并配有一个CCCD描述符。第二个CHAR_B仅可写用于接收控制命令。ESP_GATT_AUTO_RSP表示让协议栈自动回复读写响应简化开发。对于需要自定义处理的情况比如写入时需要触发某个动作可以设置为ESP_GATT_RSP_BY_APP然后在事件回调中手动响应。3.3 GATT事件回调处理创建好数据库表后我们需要在GATT事件回调函数中处理关键事件主要是ESP_GATTS_CREATE_EVT和ESP_GATTS_ADD_CHAR_EVT。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: // 当GATT应用注册成功时创建服务 esp_ble_gatts_create_service(gatts_if, gl_profile_tab[PROFILE_A].service_id, GATTS_NUM_HANDLE_TEST); break; case ESP_GATTS_CREATE_EVT: // 服务创建成功开始添加特征值 if (param-create.status ESP_GATT_OK) { esp_ble_gatts_add_char(param-create.service_handle, char_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_WRITE | ESP_GATT_CHAR_PROP_BIT_NOTIFY, NULL, NULL); } break; case ESP_GATTS_ADD_CHAR_EVT: { // 特征值添加成功保存其句柄Handle这个句柄是后续读写操作的唯一标识 if (param-add_char.status ESP_GATT_OK) { gl_profile_tab[PROFILE_A].char_handle param-add_char.attr_handle; // 为该特征值添加CCCD描述符 esp_ble_gatts_add_char_descr(param-add_char.service_handle, desc_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, NULL, NULL); } break; } case ESP_GATTS_CONNECT_EVT: // 连接建立事件 break; case ESP_GATTS_DISCONNECT_EVT: // 连接断开事件可能需要重新启动广播 esp_ble_gap_start_advertising(adv_params); break; case ESP_GATTS_READ_EVT: // 处理读请求。如果设置了AUTO_RSP协议栈会自动用当前特征值的数据回复。 // 如果需要动态生成数据如实时传感器读数可以在这里更新特征值的值然后手动回复。 ESP_LOGI(GATTS_TAG, GATT_READ_EVT, handle: %d, param-read.handle); break; case ESP_GATTS_WRITE_EVT: // 这是最重要的写事件处理 // 当手机向特征值写入数据时触发。 ESP_LOGI(GATTS_TAG, GATT_WRITE_EVT, handle: %d, value len: %d, param-write.handle, param-write.len); if (param-write.is_prep false) { // 非长写操作 // 1. 判断是写到了哪个特征值通过句柄对比 if (param-write.handle gl_profile_tab[PROFILE_A].char_handle) { // 2. 处理数据 memcpy(char_value, param-write.value, param-write.len); // 3. 可以在这里触发一个任务根据写入的数据执行相应操作比如控制LED xTaskCreate(led_control_task, led_ctrl, 2048, (void*)char_value, 5, NULL); } // 4. 如果是写到了CCCD描述符用于开关Notify else if (param-write.handle gl_profile_tab[PROFILE_A].descr_handle) { uint16_t descr_value param-write.value[0] | (param-write.value[1] 8); if (descr_value 0x0001) { ESP_LOGI(GATTS_TAG, Notify enable); // 设置一个标志位允许后续发送通知 notify_enable true; } else if (descr_value 0x0000) { ESP_LOGI(GATTS_TAG, Notify disable); notify_enable false; } } } break; // ... 处理其他事件如MTU交换事件ESP_GATTS_MTU_EVT等 } }这个回调函数是整个GATT服务的大脑。ESP_GATTS_WRITE_EVT事件是交互的核心你需要在这里解析手机发来的指令并执行相应的硬件操作如GPIO控制、读取传感器。ESP_GATTS_READ_EVT则可用于在读取发生时动态更新数据。3.4 发送通知Notify推送数据当特征值具有NOTIFY属性且客户端已启用CCCD后服务器就可以主动推送数据了。这通常用于定时发送传感器数据。static void send_sensor_data_notify(void) { if (!notify_enable) { return; // 客户端未启用通知直接返回 } uint8_t sensor_data[5]; // 假设这里读取了传感器数据并填充到 sensor_data 数组 // read_sensor(sensor_data); esp_ble_gatts_send_indicate(gatts_if, conn_id, gl_profile_tab[PROFILE_A].char_handle, sizeof(sensor_data), sensor_data, false); // 最后一个参数为false表示NOTIFY为true表示INDICATE需要确认 }你可以将这个函数放在一个定时器回调或者独立的FreeRTOS任务中周期执行从而实现数据的主动上报。4. 实战构建一个“智能灯控”蓝牙服务让我们把上面的知识整合到一个具体场景用ESP32-C3控制一个LED灯并上报一个模拟的“环境亮度”传感器值。4.1 硬件与软件设计硬件ESP32-C3开发板一个LED接在GPIO8上通过限流电阻。服务设计服务UUID0xFFF0 (自定义)特征值1控制UUID 0xFFF1属性为WRITE。手机写入0x01开灯0x00关灯。特征值2传感器UUID 0xFFF2属性为READ和NOTIFY。可读取当前亮度值并支持通知每2秒上报一次模拟亮度值。4.2 核心代码实现片段// 定义 #define LED_GPIO 8 static bool g_notify_enable false; static uint16_t g_conn_id 0; static uint16_t g_led_ctrl_handle 0; static uint16_t g_light_sensor_handle 0; // 在 write 事件处理中 case ESP_GATTS_WRITE_EVT: if (param-write.handle g_led_ctrl_handle) { uint8_t cmd param-write.value[0]; if (cmd 0x01) { gpio_set_level(LED_GPIO, 1); ESP_LOGI(LED, ON); } else if (cmd 0x00) { gpio_set_level(LED_GPIO, 0); ESP_LOGI(LED, OFF); } } else if (param-write.handle g_light_sensor_cccd_handle) { uint16_t cccd_value param-write.value[0] | (param-write.value[1] 8); g_notify_enable (cccd_value 0x0001); } break; // 模拟传感器数据发送任务 static void sensor_task(void *arg) { while(1) { if (g_notify_enable) { // 生成一个模拟的亮度值 (0-100) uint8_t light_level (uint8_t)(esp_random() % 101); esp_ble_gatts_send_indicate(gatts_if, g_conn_id, g_light_sensor_handle, 1, light_level, false); } vTaskDelay(2000 / portTICK_PERIOD_MS); // 每2秒发送一次 } }4.3 手机端测试验证代码编译烧录后使用手机上的蓝牙调试APP如nRF Connect或LightBlue进行测试。扫描并连接找到你的ESP32设备名称在代码中设置点击连接。发现服务连接成功后APP会自动列出所有服务。你应该能看到我们自定义的服务FFF0。控制LED点击服务下的特征值FFF1控制通道。在写值Write Value区域输入01十六进制并发送观察ESP32-C3板载LED或外接LED是否点亮。输入00关闭。启用通知并接收数据点击特征值FFF2传感器通道。你会看到一个“启用通知”或类似按钮这对应CCCD。点击启用。然后你就能在日志或值显示区域看到每隔2秒从ESP32发来的亮度数据。同时你也可以尝试“读取”该特征值获取当前瞬时值。5. 开发调试中的常见问题与解决思路在实际操作中你几乎一定会遇到下面这些问题。这里我把自己踩过的坑和解决方法整理出来。5.1 连接不稳定或频繁断开可能原因1电源问题。ESP32-C3在射频工作时峰值电流较大使用不稳定的USB线或电源适配器会导致电压跌落引起复位或断连。排查尝试更换高质量的USB数据线并连接到电脑后置USB口或5V/2A以上的适配器。可以在代码中打印电源电压进行监控。可能原因2Wi-Fi与蓝牙共存干扰。如果同时启用了Wi-Fi和蓝牙射频资源可能存在冲突。排查在sdkconfig中检查蓝牙和Wi-Fi的共存配置。对于纯蓝牙应用可以关闭Wi-Fi以释放资源。可能原因3内存不足或堆栈溢出。GATT事件处理、数据发送等操作如果在中断中耗时过长或分配内存不当会导致看门狗复位。排查确保在回调函数中不做延时操作将复杂逻辑放到FreeRTOS任务中处理。使用heap_caps_print_heap_info()监控内存使用。5.2 手机APP无法发现服务或特征值可能原因1服务创建未完成就开始了广播。蓝牙协议栈初始化、服务创建是异步的需要等待ESP_GATTS_CREATE_EVT等事件完成后再启动广播。解决将esp_ble_gap_start_advertising()的调用放在ESP_GATTS_CREATE_EVT或ESP_GATTS_REG_EVT事件处理完成后进行。可能原因2UUID格式错误。手机APP可能对128位UUID的格式有要求如需要连字符。解决在定义UUID时确保其字节序正确。使用ESP-IDF提供的esp_bt_uuid_t结构体并正确初始化。在广播数据或设备名称中也可以提示自定义服务的UUID。可能原因3MTU太小。某些服务或特征值的描述信息可能超过默认的MTU23字节导致发现过程失败。解决在连接建立后主动发起MTU交换请求 (esp_ble_gattc_send_mtu_req)尝试协商一个更大的MTU如247字节。5.3 写入特征值无反应或读取返回错误数据可能原因1特征值属性未正确设置。这是最常见的原因。你想写入但特征值只定义了READ属性。解决仔细检查esp_gatts_attr_db_t表中特征值声明char_prop_*和值属性perm_*的权限设置是否匹配你的需求。可能原因2数据长度超限。写入的数据长度超过了创建特征值时指定的最大长度esp_gatts_attr_db_t中的max_length。解决在ESP_GATTS_WRITE_EVT事件中检查param-write.len确保它小于等于你定义的缓冲区大小。可以适当增大max_length。可能原因3回调函数中未正确处理写请求。如果设置了手动响应ESP_GATT_RSP_BY_APP必须在回调函数中调用esp_ble_gatts_send_response()否则连接会超时断开。解决除非有特殊需求建议初学者先使用ESP_GATT_AUTO_RSP。5.4 Notify功能无法工作可能原因1CCCD未正确写入或使能。这是99%的问题所在。NOTIFY功能必须由客户端手机向CCCD描述符写入0x0001来显式开启。排查在ESP_GATTS_WRITE_EVT事件中打印出写入的句柄和值确认是否是CCCD的句柄以及写入的值是否为0x0001。可能原因2连接句柄或特征值句柄错误。发送通知时使用的连接ID或特征值句柄不正确。解决确保在ESP_GATTS_CONNECT_EVT事件中保存了param-connect.conn_id。确保发送通知时使用的特征值句柄是之前ESP_GATTS_ADD_CHAR_EVT事件中保存的正确句柄。可能原因3发送速率过快。蓝牙协议栈有流量控制如果发送通知的频率过高可能会导致缓冲区满或丢包。解决在两次esp_ble_gatts_send_indicate调用之间增加适当的延迟或者检查发送函数的返回值。5.5 性能优化与稳定性提升心得句柄管理使用一个结构体数组来管理不同Profile应用的连接ID、服务句柄、特征值句柄、CCCD句柄等。这样在多服务、多连接时不会混乱。错误处理对所有蓝牙API的返回值进行检查esp_err_t。特别是esp_ble_gatts_send_indicate如果返回错误如ESP_GATT_NO_RESOURCES说明协议栈缓冲区满应等待或重试。连接参数更新连接建立后可以尝试更新连接参数间隔、延迟、超时以获得更低的功耗或更高的吞吐量。使用esp_ble_gap_update_conn_params()函数。使用标准服务如果可能尽量使用SIG定义的标准服务如电池、设备信息。这能让你免去自定义UUID的麻烦并且与大多数通用APP兼容性更好。ESP-IDF的components/bt/host/bluedroid/api/include/esp_gatts_api.h和示例代码中列出了很多标准UUID常量。调试信息充分利用ESP_LOGI,ESP_LOGD,ESP_LOGW分级打印日志。在关键事件连接、断开、读写、通知处打印句柄、长度、数据内容这是定位问题最直接的手段。走到这一步你的ESP32-C3已经从一个简单的蓝牙信标变成了一个具备完整交互能力的智能终端。添加服务的过程本质上是在定义设备与外界通信的“语言”和“协议”。设计良好的服务结构能让你的固件更清晰也让客户端开发更简单。当你掌握了自定义服务后就可以去探索更复杂的应用比如结合Wi-Fi做蓝牙配网BLE Provisioning或者使用蓝牙Mesh组网。蓝牙的世界很大从点对点到网状网络ESP32-C3都能胜任而这一切都始于你今天搭建的这个小小的“服务柜台”。

相关新闻

最新新闻

日新闻

周新闻

月新闻