ESP32-S3 蓝牙 BLE 从零到一:广播、服务、特征,用一个智能灯的例子全讲透
前言:蓝牙,比我想象中复杂得多
之前学完了 Wi-Fi 配网、HTTP、MQTT、OTA,我觉得 ESP32-S3 的网络部分已经差不多了。接下来自然就是蓝牙。
我原以为蓝牙跟 Wi-Fi 差不多------调几个 API、连上、收发数据。结果打开官方文档,直接懵了:PHY、LL、GAP、GATT、ATT、L2CAP、SMP......一堆缩写砸过来,每个都是一层协议。
但静下心看完后发现,对于应用开发者来说,真正需要关心的只有两层:GAP(管连接)和 GATT(管数据)。 其余的都被协议栈封装好了。
今天就用一个智能蓝牙灯的例子,从广播到服务到特征,一步步把 BLE 开发讲透。
一、BLE 协议栈:看起来复杂,其实我们只用顶上两层
先看一下 BLE 的分层架构:


灰色部分全部由芯片原厂(乐鑫)封装好了,我们不需要碰。 我们只需要关注:
| 层 | 全称 | 干什么的 | 类比 |
|---|---|---|---|
| GAP | Generic Access Profile | 管广播、扫描、连接 | "我在这里!要不要连我?" |
| GATT/ATT | Generic Attribute Profile | 管数据的读写通知 | "连上了,你要什么数据?" |
GAP 层:谁在广播?谁来连接?
GAP 定义了设备的角色和连接状态:

- 广播者 → 外围设备(Peripheral):就是我们的 ESP32-S3,被动等待连接
- 扫描者 → 中央设备(Central):就是手机,主动发起连接
GATT 层:数据怎么组织?

GATT 用三个层次来组织数据:
- 特征数据 (Characteristic)
- 服务 (Service)
- 规范 (Profile)

我们假设用智能灯来类比:
| GATT 概念 | 智能灯举例 |
|---|---|
| Service(服务) | 灯服务(UUID: 0x00FF) |
| Characteristic(特征) | 灯颜色(可读)、灯开关(可写)、故障报警(可通知) |
| Descriptor(描述符) | 通知开关(开启/关闭通知功能) |
二、ESP-IDF 蓝牙协议栈的选择
ESP-IDF 支持两个蓝牙协议栈:
| Bluedroid | NimBLE | |
|---|---|---|
| 来源 | Google(Android 系统) | Apache(Mynewt 系统) |
| 支持 | 经典蓝牙 + BLE | 仅 BLE |
| 内存占用 | 较大 | 更小 |
| 适用场景 | 需要经典蓝牙时必选 | 仅用 BLE 时推荐 |
本文用的是 Bluedroid (默认),因为文档和例程最丰富。对于用户来说底层逻辑都是一样的,API有差异而已。所有的例程都在esp-idf/examples/bluetooth/bluedroid下方

menuconfig 配置
需要手动开启蓝牙和 BLE 4.2:
idf.py menuconfig
Component config → Bluetooth → [*] Bluetooth
→ Host (Bluedroid - Dual-mode)
→ Bluedroid Options → [*] Enable BLE 4.2 features

你也可以直接在sdkconfig,找到对应的打开。
三、iBeacon 广播:让世界知道你的存在
什么是 iBeacon?
iBeacon 是苹果推出的基于 BLE 广播的定位协议。本质就是一个不停向外发送数据的蓝牙设备,不需要连接,任何人都能收到。
商场定位、门店推送、资产追踪......都靠它。
iBeacon 数据包结构

| 字段 | 字节范围 | 长度 (字节) | 说明 |
|---|---|---|---|
| Flags | 0 | 1 | 广播标志,0x02表示LE General Discoverable Mode,0x06表示不支持BR/EDR |
| Length | 1 | 1 | 后续厂商数据长度(不含Flags) |
| Type | 2 | 1 | 0xFF 表示 Manufacturer Specific Data(厂商自定义数据) |
| Company ID | 3-4 | 2 | Apple 公司ID,固定 0x004C |
| iBeacon Type | 5 | 1 | 固定值 0x02,标识这是 iBeacon 数据 |
| iBeacon Length | 6 | 1 | 固定值 0x15(UUID+Major+Minor+TxPower 共21字节) |
| UUID | 7-22 | 16 | 自定义 UUID,用于唯一标识 iBeacon |
| Major | 23-24 | 2 | 分组 ID,用于逻辑分组 |
| Minor | 25-26 | 2 | 设备 ID,用于区分不同设备 |
| Tx Power (RSSI) | 27 | 1 | 发射功率,用于距离估算(RSSI @1m) |
iBeacon 实现流程

核心代码
c
// iBeacon 数据配置
#define ESP_UUID {0xFD, 0xA5, 0x06, 0x93, 0xA4, 0xE2, 0x4F, 0xB1, \
0xAF, 0xCF, 0xC6, 0xEB, 0x07, 0x64, 0x78, 0x25}
#define ESP_MAJOR 10167
#define ESP_MINOR 61958
// 广播参数:不可连接,纯广播
static esp_ble_adv_params_t ble_adv_params = {
.adv_int_min = 0x20, // 最小间隔 20ms
.adv_int_max = 0x40, // 最大间隔 40ms
.adv_type = ADV_TYPE_NONCONN_IND, // 不可连接广播
.own_addr_type = BLE_ADDR_TYPE_PUBLIC, // 公共地址
.channel_map = ADV_CHNL_ALL, // 使用所有通道
.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
};
广播参数中几个关键字段说明:
| 参数 | 说明 |
|---|---|
adv_int_min/max |
广播间隔,单位 0.625ms。0x20 = 20ms,0x40 = 40ms |
adv_type |
广播类型,决定是否可连接 |
own_addr_type |
地址类型,PUBLIC 为固定地址 |
channel_map |
广播通道,ALL = 37/38/39 三个通道 |
广播类型一览:
| 类型 | 说明 | 典型场景 |
|---|---|---|
ADV_TYPE_IND |
可连接可扫描 | 需要建立连接的设备 |
ADV_TYPE_NONCONN_IND |
不可连接 | iBeacon、传感器广播 |
ADV_TYPE_SCAN_IND |
可扫描不可连接 | 需要广播更多数据 |
ADV_TYPE_DIRECT_IND_HIGH |
定向高占空比 | 快速重连 |
ADV_TYPE_DIRECT_IND_LOW |
定向低占空比 | 省电重连 |
GAP 回调函数:
c
static void esp_gap_cb(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_ble_gap_start_advertising(&ble_adv_params);
break;
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT:
if (param->adv_start_cmpl.status != ESP_BT_STATUS_SUCCESS) {
ESP_LOGE(TAG, "广播启动失败");
}
break;
default:
break;
}
}
app_main 中的初始化流程:
c
void app_main(void)
{
// 1. 初始化 NVS
ESP_ERROR_CHECK(nvs_flash_init());
// 2. 释放经典蓝牙内存(只用 BLE,省内存)
ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT));
// 3. 初始化并启用蓝牙控制器
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_BLE);
// 4. 初始化并启用 Bluedroid 协议栈
esp_bluedroid_init();
esp_bluedroid_enable();
// 5. 注册 GAP 回调
esp_ble_gap_register_callback(esp_gap_cb);
// 6. 组装并设置 iBeacon 广播数据
esp_ble_ibeacon_t ibeacon_adv_data;
esp_ble_config_ibeacon_data(&vendor_config, &ibeacon_adv_data);
esp_ble_gap_config_adv_data_raw((uint8_t*)&ibeacon_adv_data, sizeof(ibeacon_adv_data));
}
完整代码
c
#include <stdint.h>
#include <stdio.h>
#include "esp_bt.h"
#include "esp_bt_defs.h"
#include "esp_bt_main.h"
#include "esp_err.h"
#include "esp_gap_ble_api.h"
#include "esp_ibeacon_api.h"
#include "esp_log.h"
#include "nvs_flash.h"
static const char *TAG = "BLE_IBEACON";
extern esp_ble_ibeacon_vendor_t vendor_config;
static esp_ble_adv_params_t ble_adv_params = {
.adv_int_min = 0x20,
.adv_int_max = 0x40,
.adv_type = ADV_TYPE_NONCONN_IND,
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
.channel_map = ADV_CHNL_ALL,
.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
};
static void esp_gap_cb(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_ERROR_CHECK(esp_ble_gap_start_advertising(&ble_adv_params));
break;
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT:
if (param->adv_start_cmpl.status != ESP_BT_STATUS_SUCCESS) {
ESP_LOGE(TAG, "Advertising start failed: %s",
esp_err_to_name(param->adv_start_cmpl.status));
} else {
ESP_LOGI(TAG, "Advertising started");
}
break;
default:
break;
}
}
void app_main(void)
{
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);
ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT));
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_bt_controller_init(&bt_cfg));
ESP_ERROR_CHECK(esp_bt_controller_enable(ESP_BT_MODE_BLE));
ESP_ERROR_CHECK(esp_bluedroid_init());
ESP_ERROR_CHECK(esp_bluedroid_enable());
ESP_ERROR_CHECK(esp_ble_gap_register_callback(esp_gap_cb));
esp_ble_ibeacon_t ibeacon_adv_data;
ESP_ERROR_CHECK(esp_ble_config_ibeacon_data(&vendor_config, &ibeacon_adv_data));
ESP_ERROR_CHECK(
esp_ble_gap_config_adv_data_raw((uint8_t *)&ibeacon_adv_data, sizeof(ibeacon_adv_data)));
}
烧录后用手机蓝牙扫描工具就能看到这个 iBeacon 设备了。如何确认是自己的设备? 修改 UUID、Major、Minor 为自定义值即可:
c
#define ESP_UUID {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, \
0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10}
#define ESP_MAJOR 0xAABB
#define ESP_MINOR 0x5566
并且将esp-idf/examples/bluetooth/bluedroid/ble/ble_ibeacon/main/esp_ibeacon_api.h
和esp-idf/examples/bluetooth/bluedroid/ble/ble_ibeacon/main/esp_ibeacon_api.c都复制到main文件夹下。

从 iBeacon 规范看,关键部分是从 1A FF 4C 00 02 15 开始:
- FF:类型 = Manufacturer Specific Data
- 4C 00:厂商 ID = Apple(0x004C)
- 02 15:iBeacon 类型 & 长度标识
- 接下来 16 字节:UUID
- 再后面 2 字节:Major
- 再后面 2 字节:Minor
四、自定义广播:不用 iBeacon 格式也行
iBeacon 是苹果定义的格式,如果不需要兼容 iBeacon 协议,完全可以自定义广播内容。
结构化方式
用 esp_ble_adv_data_t 结构体,清晰直观:
c
static uint8_t service_uuid[16] = {
0xfb, 0x34, 0x9b, 0x5f, 0x80, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00,
};
static uint8_t manufacturer_data[3] = {0x11, 0x22, 0x33};
/* The length of adv data must be less than 31 bytes */
static esp_ble_adv_data_t adv_data = {
.set_scan_rsp = false, // 这是广播数据
.include_name = true, // 包含设备名称
.include_txpower = true,// 包含发射功率
.min_interval = 0x0006, //slave connection min interval, Time = min_interval * 1.25 msec
.max_interval = 0x0010, //slave connection max interval, Time = max_interval * 1.25 msec
.appearance = 0x00,
.manufacturer_len = sizeof(manufacturer_data),
.p_manufacturer_data = manufacturer_data,
.service_data_len = 0,
.p_service_data = NULL,
.service_uuid_len = sizeof(service_uuid),
.p_service_uuid = service_uuid,
.flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT),
};
主函数
c
#define SAMPLE_DEVICE_NAME "HUAINING"
void app_main(void){
.....
....
...
esp_ble_gap_set_device_name(SAMPLE_DEVICE_NAME);
esp_ble_gap_config_adv_data(&scan_rsp_data);
esp_ble_gap_config_adv_data(&adv_data);
}
⚠️ 广播数据和扫描响应数据各自不能超过 31 字节! 如果装不下,可以把部分数据放到扫描响应包里,或者减少字段。
编译烧录后,用蓝牙工具扫描可看到:
原始字节方式
如果想完全控制每个字节:
c
static uint8_t raw_adv_data[] = {
0x02, ESP_BLE_AD_TYPE_FLAG, 0x06, // 标志
0x02, ESP_BLE_AD_TYPE_TX_PWR, 0xEB, // 发射功率
0x03, ESP_BLE_AD_TYPE_16SRV_CMPL, 0xFF, 0x00, // 服务 UUID
0x06, ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE, // 制造商数据
0x01, 0x02, 0x03, 0x04, 0x05
};
设置广播数据的 API 也有两套对应:
| 结构化方式 | 原始方式 |
|---|---|
esp_ble_gap_config_adv_data(&adv_data) |
esp_ble_gap_config_adv_data_raw(data, len) |
回调:ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT |
回调:ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT |
五、GATT 服务:从"我在这里"到"我能干什么"
广播只是告诉别人"我存在"。但别人连上我之后,想知道"你能干什么?"------这就需要 GATT 服务。
用智能灯来理解
我要做一个蓝牙智能灯,需要提供这些功能:
| 功能 | 对应特征 | UUID | 属性 |
|---|---|---|---|
| 读取灯颜色 | 特征 A | 0xFF01 | 只读 |
| 控制灯开关 | 特征 B | 0xFF02 | 只写 |
| 读取/设置亮度 | 特征 C | 0xFF03 | 读写 |
| 故障报警推送 | 特征 D | 0xFF04 | 读写+通知 |
它们都属于一个灯服务,UUID 为 0x00FF。
GATT 服务创建流程

属性表:ESP-IDF 推荐的 handle_table 方式
ESP-IDF 用一个枚举 + 数组来定义整个服务结构,非常清晰:
c
// 枚举定义每个属性的索引
enum {
IDX_SVC, // 服务声明
IDX_CHAR_A, // 特征 A 声明
IDX_CHAR_VAL_A, // 特征 A 值
IDX_CHAR_B, // 特征 B 声明
IDX_CHAR_VAL_B, // 特征 B 值
IDX_CHAR_C, // 特征 C 声明
IDX_CHAR_VAL_C, // 特征 C 值
IDX_CHAR_D, // 特征 D 声明
IDX_CHAR_VAL_D, // 特征 D 值
IDX_CHAR_CFG_D, // 特征 D 的通知描述符
HRS_IDX_NB, // 总数
};
uint16_t handle_table[HRS_IDX_NB]; // 运行时存储句柄
对应的属性表结构:
属性表结构
| Index | 描述 |
|---|---|
| IDX_SVC | 服务声明:UUID = 0x00FF |
| IDX_CHAR_A | 特征 A 声明:可读 |
| IDX_CHAR_VAL_A | 特征 A 值:{0x11,0x22,0x33,0x44} |
| IDX_CHAR_B | 特征 B 声明:可写 |
| IDX_CHAR_VAL_B | 特征 B 值:空 |
| IDX_CHAR_C | 特征 C 声明:可读写 |
| IDX_CHAR_VAL_C | 特征 C 值:{0x11,0x22,0x33,0x44} |
| IDX_CHAR_D | 特征 D 声明:可读写 + 通知 |
| IDX_CHAR_VAL_D | 特征 D 值:{0x11,0x22,0x33,0x44} |
| IDX_CHAR_CFG_D | 特征 D 描述符:通知开关 |
每种特征怎么写?逐个拆解
基础定义(所有特征共用):
c
static const uint16_t primary_service_uuid = ESP_GATT_UUID_PRI_SERVICE;
// GATT特征标识
static const uint16_t character_declaration_uuid = ESP_GATT_UUID_CHAR_DECLARE;
// GATT特征描述符标识
static const uint16_t character_client_config_uuid = ESP_GATT_UUID_CHAR_CLIENT_CONFIG;
// 自定义服务的UUID
static const uint16_t GATTS_SERVICE_UUID_TEST = 0x00FF;
// 自定义特征的UUID
static const uint16_t GATTS_CHAR_UUID_TEST_A = 0xFF01;
static const uint16_t GATTS_CHAR_UUID_TEST_B = 0xFF02;
static const uint16_t GATTS_CHAR_UUID_TEST_C = 0xFF03;
static const uint16_t GATTS_CHAR_UUID_TEST_D = 0xFF04;
static const uint8_t char_prop_read = ESP_GATT_CHAR_PROP_BIT_READ; // 读属性
static const uint8_t char_prop_write = ESP_GATT_CHAR_PROP_BIT_WRITE; // 写属性
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_write_notify = ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_WRITE | ESP_GATT_CHAR_PROP_BIT_NOTIFY; // 读写通知属性
static uint8_t char_value[4] = {0x11, 0x22, 0x33, 0x44}; // 特征值初始值
static const uint8_t char_d_ccc[2] = {0x00, 0x00}; // 客户端配置描述符初始值 0x0000关闭通知和指示 0x0001开启通知,0x0002开启指示。
完整属性表:
c
/* 完整的数据库描述 - 用于将属性添加到数据库 */
static const esp_gatts_attr_db_t gatt_db[HRS_IDX_NB] =
{
// 服务声明
[IDX_SVC] =
{{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&primary_service_uuid, ESP_GATT_PERM_READ,
sizeof(uint16_t), sizeof(GATTS_SERVICE_UUID_TEST), (uint8_t *)&GATTS_SERVICE_UUID_TEST}},
/* 特征 A 声明 */
[IDX_CHAR_A] =
{{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}},
/* 特征 A 值 */
[IDX_CHAR_VAL_A] =
{{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&GATTS_CHAR_UUID_TEST_A, ESP_GATT_PERM_READ,
500, sizeof(char_value), (uint8_t *)char_value}},
/* 特征 B 声明 */
[IDX_CHAR_B] =
{{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&character_declaration_uuid, ESP_GATT_PERM_READ,
sizeof(uint8_t), sizeof(char_prop_write), (uint8_t *)&char_prop_write}},
/* 特征 B 值 */
[IDX_CHAR_VAL_B] =
{{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&GATTS_CHAR_UUID_TEST_B, ESP_GATT_PERM_WRITE,
500, 0, NULL}},
/* 特征 C 声明 */
[IDX_CHAR_C] =
{{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&character_declaration_uuid, ESP_GATT_PERM_READ,
sizeof(uint8_t), sizeof(char_prop_read_write), (uint8_t *)&char_prop_read_write}},
/* 特征 C 值 */
[IDX_CHAR_VAL_C] =
{{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&GATTS_CHAR_UUID_TEST_C, ESP_GATT_PERM_READ|ESP_GATT_PERM_WRITE,
500, sizeof(char_value), (uint8_t *)char_value}},
/* 特征 D 声明 */
[IDX_CHAR_D] =
{{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&character_declaration_uuid, ESP_GATT_PERM_READ,
sizeof(uint8_t), sizeof(char_prop_read_write), (uint8_t *)&char_prop_read_write_notify}},
/* 特征 D 值 */
[IDX_CHAR_VAL_D] =
{{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&GATTS_CHAR_UUID_TEST_D, ESP_GATT_PERM_READ|ESP_GATT_PERM_WRITE,
500, sizeof(char_value), (uint8_t *)char_value}},
/* 特征 D 描述符 */
[IDX_CHAR_CFG_D] =
{{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&character_client_config_uuid, ESP_GATT_PERM_READ|ESP_GATT_PERM_WRITE,
sizeof(uint16_t), sizeof(char_d_ccc), (uint8_t *)char_d_ccc}},
};
每个属性条目的参数含义:
{{自动响应}, {UUID长度, UUID值, 权限, 最大长度, 当前长度, 值指针}}
| 参数 | 说明 |
|---|---|
ESP_GATT_AUTO_RSP |
BLE 协议栈自动响应读写请求 |
ESP_GATT_RSP_BY_APP |
需要应用程序手动调用 esp_ble_gatts_send_response() |
ESP_GATT_PERM_READ |
允许读 |
ESP_GATT_PERM_WRITE |
允许写 |
500 |
最大传输长度,对应 MTU 设置 |
六、GATTS 事件回调:BLE 服务的灵魂
所有服务交互都通过 GATTS 事件回调驱动:
c
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:{
ESP_LOGI(GATTS_TABLE_TAG, "gatts_if %d, app_id %d", gatts_if, param->reg.app_id);
// 设置设备名称
esp_err_t set_dev_name_ret = esp_ble_gap_set_device_name(SAMPLE_DEVICE_NAME);
if (set_dev_name_ret){
ESP_LOGE(GATTS_TABLE_TAG, "set device name failed, error code = %x", set_dev_name_ret);
}
// 配置广播数据
esp_err_t raw_adv_ret = esp_ble_gap_config_adv_data_raw(raw_adv_data, sizeof(raw_adv_data));
if (raw_adv_ret){
ESP_LOGE(GATTS_TABLE_TAG, "config raw adv data failed, error code = %x ", raw_adv_ret);
}
adv_config_done |= ADV_CONFIG_FLAG;
// 配置扫描响应数据
esp_err_t raw_scan_ret = esp_ble_gap_config_scan_rsp_data_raw(raw_scan_rsp_data, sizeof(raw_scan_rsp_data));
if (raw_scan_ret){
ESP_LOGE(GATTS_TABLE_TAG, "config raw scan rsp data failed, error code = %x", raw_scan_ret);
}
adv_config_done |= SCAN_RSP_CONFIG_FLAG;
// 创建属性表
esp_err_t create_attr_ret = esp_ble_gatts_create_attr_tab(gatt_db, gatts_if, HRS_IDX_NB, SVC_INST_ID);
if (create_attr_ret){
ESP_LOGE(GATTS_TABLE_TAG, "create attr table failed, error code = %x", create_attr_ret);
}
}
break;
case ESP_GATTS_READ_EVT:
ESP_LOGI(GATTS_TABLE_TAG, "ESP_GATTS_READ_EVT");
break;
case ESP_GATTS_WRITE_EVT:{
ESP_LOGI(GATTS_TABLE_TAG, "ESP_GATTS_WRITE_EVT");
if (!param->write.is_prep){
// 处理非准备写入事件
ESP_LOGI(GATTS_TABLE_TAG, "prep write, handle = %d, value len = %d, value :", param->write.handle, param->write.len);
ESP_LOG_BUFFER_HEX(GATTS_TABLE_TAG, param->write.value, param->write.len);
if (handle_table[IDX_CHAR_CFG_D] == param->write.handle && param->write.len == 2){
uint16_t descr_value = param->write.value[1]<<8 | param->write.value[0];
if (descr_value == 0x0001){
ESP_LOGI(GATTS_TABLE_TAG, "notify enable");
}else if (descr_value == 0x0002){
ESP_LOGI(GATTS_TABLE_TAG, "indicate enable");
}else if (descr_value == 0x0000){
ESP_LOGI(GATTS_TABLE_TAG, "notify/indicate disable ");
}
}
}else{
// 处理准备写入事件,适合大数据分段写入
ESP_LOGI(GATTS_TABLE_TAG, "prepare write, handle = %d, value len = %d", param->write.handle, param->write.len);
ESP_LOG_BUFFER_HEX(GATTS_TABLE_TAG, param->write.value, param->write.len);
}
// 如果需要响应,则发送响应
if (param->write.need_rsp){
esp_ble_gatts_send_response(gatts_if, param->write.conn_id, param->write.trans_id, ESP_GATT_OK, NULL);
}
}
break;
case ESP_GATTS_EXEC_WRITE_EVT:
// 如果客户端执行了ESP_GATTS_WRITE_EVT分段写入,再触发ESP_GATTS_EXEC_WRITE_EVT则是提交所有数据
ESP_LOGI(GATTS_TABLE_TAG, "ESP_GATTS_EXEC_WRITE_EVT");
if (param->exec_write.exec_write_flag == ESP_GATT_PREP_WRITE_EXEC){
//此处可以执行数据提交操作
}
break;
case ESP_GATTS_MTU_EVT:
ESP_LOGI(GATTS_TABLE_TAG, "ESP_GATTS_MTU_EVT, MTU %d", param->mtu.mtu);
break;
case ESP_GATTS_CONF_EVT:
ESP_LOGI(GATTS_TABLE_TAG, "ESP_GATTS_CONF_EVT, status = %d, attr_handle %d", param->conf.status, param->conf.handle);
break;
case ESP_GATTS_START_EVT:
ESP_LOGI(GATTS_TABLE_TAG, "SERVICE_START_EVT, status %d, service_handle %d", param->start.status, param->start.service_handle);
break;
case ESP_GATTS_CONNECT_EVT:
ESP_LOGI(GATTS_TABLE_TAG, "ESP_GATTS_CONNECT_EVT, conn_id = %d", param->connect.conn_id);
g_gatts_if = gatts_if;
gatt_conn_id = param->connect.conn_id;
is_connect = true;
ESP_LOG_BUFFER_HEX(GATTS_TABLE_TAG, param->connect.remote_bda, 6);
// 更新连接参数
esp_ble_conn_update_params_t conn_params = {0};
memcpy(conn_params.bda, param->connect.remote_bda, sizeof(esp_bd_addr_t));
/* 对于 iOS 系统,请参考 Apple 官方文档关于 BLE 连接参数限制。 */
conn_params.latency = 0;
conn_params.max_int = 0x20; // max_int = 0x20*1.25ms = 40ms
conn_params.min_int = 0x10; // min_int = 0x10*1.25ms = 20ms
conn_params.timeout = 400; // timeout = 400*10ms = 4000ms
// 开始向对等设备发送更新连接参数请求
esp_ble_gap_update_conn_params(&conn_params);
break;
case ESP_GATTS_DISCONNECT_EVT:
is_connect = false;
ESP_LOGI(GATTS_TABLE_TAG, "ESP_GATTS_DISCONNECT_EVT, reason = 0x%x", param->disconnect.reason);
esp_ble_gap_start_advertising(&adv_params);
break;
case ESP_GATTS_CREAT_ATTR_TAB_EVT:{
if (param->add_attr_tab.status != ESP_GATT_OK){
ESP_LOGE(GATTS_TABLE_TAG, "create attribute table failed, error code=0x%x", param->add_attr_tab.status);
}
else if (param->add_attr_tab.num_handle != HRS_IDX_NB){
ESP_LOGE(GATTS_TABLE_TAG, "create attribute table abnormally, num_handle (%d) \
doesn't equal to HRS_IDX_NB(%d)", param->add_attr_tab.num_handle, HRS_IDX_NB);
}
else {
ESP_LOGI(GATTS_TABLE_TAG, "create attribute table successfully, the number handle = %d",param->add_attr_tab.num_handle);
memcpy(handle_table, param->add_attr_tab.handles, sizeof(handle_table));
esp_ble_gatts_start_service(handle_table[IDX_SVC]);
}
break;
}
case ESP_GATTS_STOP_EVT:
case ESP_GATTS_OPEN_EVT:
case ESP_GATTS_CANCEL_OPEN_EVT:
case ESP_GATTS_CLOSE_EVT:
case ESP_GATTS_LISTEN_EVT:
case ESP_GATTS_CONGEST_EVT:
case ESP_GATTS_UNREG_EVT:
case ESP_GATTS_DELETE_EVT:
default:
break;
}
}
七、通知功能:设备主动推送数据
通知是 BLE 的精华------服务器端主动给客户端推数据,不需要客户端轮询。
用定时器每秒推送一次通知:
c
// 定时器回调函数
void timer_callback(void *arg) {
// 修改特征值
char_value[0]++;
char_value[1]++;
char_value[2]++;
char_value[3]++;
if(is_connect){
uint8_t notify_data[15];
for (int i = 0; i < sizeof(notify_data); ++i)
{
notify_data[i] = i;
}
ESP_LOGI(GATTS_TABLE_TAG, "ESP_GATTS_CONNECT_EVT, g_gatts_if = %d conn_id = %d", g_gatts_if,gatt_conn_id);
//用于发送指示(Indicate)或通知(Notify)。通知和指示的区别在于,指示需要客户端确认,而通知不需要,也就是最后一个参数true/false。
esp_ble_gatts_send_indicate(g_gatts_if, gatt_conn_id, handle_table[IDX_CHAR_VAL_D],
sizeof(notify_data), notify_data, false);
}
}
// 定时器初始化函数
static void timer_init(void)
{
const esp_timer_create_args_t periodic_timer_args = {
.callback = &timer_callback,
.name = "periodic_timer"
};
esp_timer_handle_t periodic_timer;
ESP_ERROR_CHECK(esp_timer_create(&periodic_timer_args, &periodic_timer));
ESP_ERROR_CHECK(esp_timer_start_periodic(periodic_timer, 1000000)); // 1秒
}
通知 vs 指示:
| 通知 (Notify) | 指示 (Indicate) | |
|---|---|---|
need_confirm |
false |
true |
| 是否需要客户端确认 | 不需要 | 需要 |
| 可靠性 | 可能丢失 | 保证送达 |
| 速度 | 更快 | 稍慢 |
八、手机端测试
推荐使用 LightBlue (iOS/Android 都有)或 nRF Connect 进行测试。
连接设备后可以看到:
示例图片
![]() |
![]() |
![]() |
![]() |
![]() |
显示 "Unknown Service" 是因为 0x00FF 是我们自定义的 UUID,第三方 APP 不认识。如果是自己开发的 APP,可以根据 UUID 显示具体名称。
九、完整知识结构
BLE 应用开发
广播
服务与特征
iBeacon 广播
自定义广播
结构化/原始字节
广播参数
间隔/类型/通道/过滤
服务声明
UUID 标识
只读特征
如灯颜色
只写特征
如灯开关
读写特征
如灯亮度
读写+通知特征
如故障报警
描述符
2902 通知开关
十、总结
BLE 开发的核心套路
初始化 NVS
↓
释放经典蓝牙内存(省内存)
↓
初始化蓝牙控制器 → 启用 BLE 模式
↓
初始化 Bluedroid → 启用
↓
注册 GAP 回调(管广播和连接)
注册 GATTS 回调(管服务和数据)
↓
注册 GATT 应用 → 触发 REG_EVT
↓
创建属性表 → 启动服务 → 开始广播
↓
等待连接 → 处理读/写/通知
关键 API 速查
| API | 功能 |
|---|---|
esp_bt_controller_init/enable |
初始化蓝牙控制器 |
esp_bluedroid_init/enable |
初始化协议栈 |
esp_ble_gap_register_callback |
注册 GAP 回调 |
esp_ble_gatts_register_callback |
注册 GATTS 回调 |
esp_ble_gatts_app_register |
注册 GATT 应用 |
esp_ble_gatts_create_attr_tab |
创建属性表 |
esp_ble_gatts_start_service |
启动服务 |
esp_ble_gap_start_advertising |
开始广播 |
esp_ble_gatts_set_attr_value |
更新特征值 |
esp_ble_gatts_send_indicate |
发送通知/指示 |
踩坑备忘
- menuconfig 必须开启 BLE 4.2,否则编译报错
- 广播数据不能超过 31 字节,装不下就分到扫描响应包里
- 每个特征 = 声明 + 值(+ 可选描述符),漏了任何一个都不行
- 通知需要描述符配合,客户端必须先写 0x0001 到 2902 描述符才能收到通知
- 断开连接后要重新开始广播,否则手机找不到设备
ESP_GATT_AUTO_RSP可以省很多事,协议栈自动处理读写响应
一个类比帮你记住 GATT
把 BLE 设备想象成一个餐厅:
- 服务 (Service) = 菜单分类(中餐、西餐、饮品)
- 特征 (Characteristic) = 具体菜品(宫保鸡丁、牛排、可乐)
- 属性 (Attribute) = 菜品的详细信息(价格、配料、图片)
- 描述符 (Descriptor) = 备注选项(加辣、少糖、要发票)
- 读操作 = 看菜单
- 写操作 = 点菜
- 通知 = 服务员主动告诉你"您的菜好了"
BLE 的内容确实比 Wi-Fi 复杂不少,但只要抓住 GAP(管连接)和 GATT(管数据)这两个核心,其他都是细节。但是我还是觉得有些难,大家遇到不懂的问题多问问AI,写代码多用AI工具解放双手





