云计算百科
云计算领域专业知识百科平台

ESP32蓝牙开发实战:从零搭建GATT服务器(基于ESP-IDF 4.4)

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这个例程的副本入手。但我的建议是,在复制后,立即执行以下几步“清洁工作”:

  • 重命名并理解文件结构:将示例中可能存在的main/gatt_server_service_table.c直接改名为main/ble_gatt_server.c。同时,在main/CMakeLists.txt中同步修改源文件名。这让你从心理上觉得这是“自己的项目”,而不是在修改别人的代码。
  • 审查并精简sdkconfig:运行idf.py menuconfig,重点检查两个地方:
    • 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中适当调大相关任务的栈大小,否则可能导致难以排查的崩溃。

  • 准备调试工具:除了串口日志,准备一个手机端的BLE调试App至关重要。我常用的是nRF Connect和LightBlue。它们能直观地展示设备的广播数据、扫描到的服务列表,并允许你手动读写特征值,是开发和测试阶段不可或缺的“另一只眼睛”。
  • 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)

    最有效的调试方法:分层日志与对比验证

  • 打开详细日志:在sdkconfig中设置CONFIG_LOG_DEFAULT_LEVEL_DEBUG,查看协议栈的每一步交互。
  • 使用手机App对比:用nRF Connect连接一个已知正常的BLE设备(比如小米手环),观察它的服务结构、广播数据,与你的设备对比。这是发现配置差异的最快方式。
  • 简化再复杂化:当功能不正常时,退回最简单的例程(只包含一个可读特征),确保它能工作。然后,像搭积木一样,一个一个地添加特征、描述符、通知功能,每加一步都测试验证。
  • 最后,代码的健壮性离不开异常处理。每一个esp_ble_开头的函数调用,都应该检查其返回值。重要的全局状态(如连接状态、CCCD状态)要考虑多任务访问的互斥保护,虽然在这个简单服务器中可能不必要,但在复杂的、包含Wi-Fi等功能的项目中,这能避免许多玄学问题。

    折腾ESP32蓝牙大半年,最大的体会是:理解协议本身比死记代码更重要。当你明白了GATT数据库是一个属性表,通知是CCCD这个“开关”控制的,很多问题就自然有了排查方向。希望这份结合了实战和原理的梳理,能让你在下次点亮ESP32蓝牙时,心里更有底。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » ESP32蓝牙开发实战:从零搭建GATT服务器(基于ESP-IDF 4.4)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!