3.5 实现GATT与ATT模型下的特征值读写操作

在BLE(Bluetooth Low Energy)协议栈中,GATT(Generic Attribute Profile)与ATT(Attribute Protocol)共同构成了应用层数据交互的核心机制。GATT定义了服务(Service)、特征值(Characteristic)和描述符(Descriptor)的组织结构与访问语义;ATT则定义了底层属性读写、通知与指示的具体报文格式与状态机行为。本节不讨论协议理论,而是聚焦于ESP32平台下基于ESP-IDF框架的真实工程实现:如何通过注册自定义GATT服务、配置特征值属性、编写事件回调函数,并协同FreeRTOS任务调度,完成稳定、可验证的双向数据读写闭环。

整个实现过程并非简单的API调用堆砌,而是一套需严格遵循BLE状态时序、内存生命周期管理与多任务协作逻辑的系统工程。以下内容将从服务端(Peripheral)与客户端(Central)双视角出发,逐层拆解关键配置项背后的硬件约束、协议要求与软件设计权衡。

3.5.1 GATT服务端:特征值声明与属性表构建

ESP-IDF中GATT服务的注册采用“服务声明 → 特征值声明 → 描述符声明 → 属性表填充”的线性流程。该流程最终映射为一个连续的 esp_gattc_attr_db_t 数组,其索引即为后续读写操作中使用的句柄(Handle)。 句柄不是随意分配的编号,而是属性在GATT数据库中的物理偏移地址,必须全局唯一且严格递增。 这一特性直接决定了服务端与客户端代码中对同一特征值的引用必须保持句柄一致,否则读写将指向错误内存位置。

以本项目中控制LED状态的特征值为例,其GATT服务结构定义如下:

// gatts_table.h - GATT服务属性表声明
#define GATTS_SERVICE_UUID_TEST        0x00FF
#define GATTS_CHAR_UUID_LED_STATE      0xFF01
#define GATTS_DESCR_UUID_LED_CONFIG    0x2902 // Client Characteristic Configuration Descriptor (CCCD)

// 属性表索引定义(关键!决定句柄值)
#define TEST_SVC_IDX_SVC               0
#define TEST_SVC_IDX_CHAR_LED          2
#define TEST_SVC_IDX_CHAR_LED_VAL      3
#define TEST_SVC_IDX_CHAR_LED_CFG      4
#define TEST_SVC_IDX_CHAR_LED_DESC     5

该定义对应如下属性表初始化( gatts_demo.c ):

// gatts_demo.c - 属性表初始化片段
static const uint16_t gatts_service_handle = 0;
static esp_gatts_attr_db_t gatt_db[GATTS_IDX_NB] = {
    // [0] Service Declaration
    [TEST_SVC_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}},

    // [1] Included Service (none in this demo)

    // [2] Characteristic Declaration
    [TEST_SVC_IDX_CHAR_LED] =
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&CHARACTERISTIC_UUID, ESP_GATT_PERM_READ,
      sizeof(uint8_t), sizeof(uint8_t), (uint8_t *)&char_prop_read_write_notify}},

    // [3] Characteristic Value (LED State)
    [TEST_SVC_IDX_CHAR_LED_VAL] =
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&GATTS_CHAR_UUID_LED_STATE, 
      ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
      GATTS_DEMO_CHAR_VAL_LEN_MAX, sizeof(led_state), (uint8_t *)&led_state}},

    // [4] Client Characteristic Configuration Descriptor (CCCD)
    [TEST_SVC_IDX_CHAR_LED_CFG] =
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&GATTS_DESCR_UUID_LED_CONFIG, 
      ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
      sizeof(uint16_t), sizeof(notify_en), (uint8_t *)&notify_en}},

    // [5] Characteristic User Description (optional)
    [TEST_SVC_IDX_CHAR_LED_DESC] =
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&CHARACTERISTIC_USER_DESCRIPTION_UUID, 
      ESP_GATT_PERM_READ,
      sizeof(char_user_desc), sizeof(char_user_desc), (uint8_t *)char_user_desc}},
};

此处需重点理解三个核心参数:

  • perm (权限位) ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE 表明该特征值支持读写。若仅需通知(Notify),则需额外设置 ESP_GATT_PERM_NOTIFY ,并确保CCCD(索引4)存在且可写。权限位直接由BLE控制器硬件校验,非法访问将被拒绝并返回 GATT_INSUF_AUTHORIZATION 错误。
  • max_length length GATTS_DEMO_CHAR_VAL_LEN_MAX 定义该特征值最大允许长度(本例为1字节), sizeof(led_state) 为其当前有效长度。当客户端执行写操作时,BLE协议栈会依据 max_length 截断或拒绝超长数据,避免缓冲区溢出。
  • p_value 指针 :指向 led_state 变量的地址。这是GATT服务端响应读请求的直接数据源,也是写请求的目标内存。 所有读写操作均作用于该指针所指向的RAM区域,因此必须确保该变量生命周期覆盖整个GATT服务运行期,不可为栈变量或临时对象。

服务端启动时,通过 esp_ble_gatts_create_attr_tab() 将此表注册至协议栈,并在 ESP_GATTS_CREATE_EVT 事件中获取服务句柄。随后调用 esp_ble_gatts_start_service() 激活服务。此时,GATT数据库已加载至BLE子系统,等待客户端连接与访问。

3.5.2 服务端事件处理:读写请求的同步与异步分发

GATT服务端不主动轮询数据,而是完全事件驱动。所有客户端发起的读、写、通知使能等操作,均触发 ESP_GATTS_READ_EVT ESP_GATTS_WRITE_EVT 等事件,由注册的 gatts_event_handler 统一捕获。事件处理的核心挑战在于: 如何在保证协议栈实时响应的前提下,将底层硬件操作(如GPIO翻转)安全地交由FreeRTOS任务执行?

写操作:从事件到硬件动作的原子性保障

当客户端向 TEST_SVC_IDX_CHAR_LED_VAL (句柄=3)写入新值时, ESP_GATTS_WRITE_EVT 事件被触发。事件结构体 esp_ble_gatts_cb_param_t 中包含 write.handle (目标句柄)、 write.value_len (数据长度)及 write.value (数据指针)。标准处理流程如下:

case ESP_GATTS_WRITE_EVT: {
    if (param->write.handle == led_handle_table[TEST_SVC_IDX_CHAR_LED_VAL]) {
        // 1. 数据有效性校验(长度、取值范围)
        if (param->write.value_len == 1 && (param->write.value[0] == 0 || param->write.value[0] == 1)) {
            // 2. 原子性更新共享状态变量
            led_state = param->write.value[0];

            // 3. 触发硬件动作:使用xTaskNotifyFromISR唤醒LED控制任务
            BaseType_t xHigherPriorityTaskWoken = pdFALSE;
            vTaskNotifyGiveFromISR(led_control_task_handle, &xHigherPriorityTaskWoken);
            portYIELD_FROM_ISR(xHigherPriorityTaskWoken);

            // 4. 可选:向客户端发送写响应(若未启用ESP_GATT_AUTO_RSP)
            // esp_ble_gatts_send_response(gatts_if, param->write.conn_id, param->write.trans_id, ESP_GATT_OK, NULL);
        }
    }
    break;
}

此处的关键设计点在于第3步: 绝不直接在中断上下文(GATT事件回调本质是中断服务)中执行耗时的GPIO操作。 vTaskNotifyGiveFromISR() 是FreeRTOS提供的安全机制,它仅向指定任务发送一个通知信号,开销极小,符合中断服务函数(ISR)的实时性要求。真正的LED状态切换逻辑(如 gpio_set_level(GPIO_NUM_2, led_state) )被封装在独立的 led_control_task 中,该任务通过 ulTaskNotifyTake() 阻塞等待通知,收到后立即执行硬件操作并可能进行状态同步。

这种“事件中断→轻量通知→任务执行”的模式,彻底解耦了协议栈与硬件驱动,既满足BLE协议对事件响应的毫秒级要求,又保障了硬件操作的可靠性与可调试性。

读操作:状态快照与一致性保证

读请求( ESP_GATTS_READ_EVT )的处理更为直接,但需警惕并发修改风险。当客户端读取 TEST_SVC_IDX_CHAR_LED_VAL 时,服务端只需将 led_state 的当前值复制到响应缓冲区。由于 led_state 是单字节变量,其读取本身是原子操作,无需额外同步:

case ESP_GATTS_READ_EVT: {
    if (param->read.handle == led_handle_table[TEST_SVC_IDX_CHAR_LED_VAL]) {
        // 直接返回当前状态快照
        esp_gatt_rsp_t rsp;
        memset(&rsp, 0, sizeof(esp_gatt_rsp_t));
        rsp.attr_value.handle = param->read.handle;
        rsp.attr_value.len = 1;
        rsp.attr_value.value[0] = led_state;
        esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &rsp);
    }
    break;
}

然而,在更复杂的场景中(如多字节传感器数据),若读取期间 led_state 正被其他任务修改,可能导致返回部分旧值、部分新值的“撕裂”现象。此时需引入临界区保护( portENTER_CRITICAL() / portEXIT_CRITICAL() )或使用FreeRTOS队列进行状态发布,确保读取的是完整、一致的状态快照。

3.5.3 客户端:连接建立、服务发现与特征值访问

ESP32作为BLE Central,其客户端逻辑围绕 esp_ble_gattc_app_register() 注册的应用展开。核心流程为:扫描→连接→服务发现→特征值查找→读写操作。本节聚焦于服务发现后的特征值访问环节。

特征值句柄解析:服务端与客户端的句柄映射

服务端定义的句柄(如 TEST_SVC_IDX_CHAR_LED_VAL = 3 )是其本地属性表索引,而客户端通过 ESP_GATTC_SEARCH_RES_EVT 事件获得的服务信息中,特征值句柄是远程设备GATT数据库中的实际地址。二者数值通常不同(如字幕中提及“服务端下标12,客户端下标7”),原因在于:

  • 服务端属性表包含服务声明、包含服务、特征值声明、特征值值、CCCD等多个条目,其句柄按声明顺序累加。
  • 客户端在服务发现过程中,协议栈会遍历远程GATT数据库,为每个发现的属性分配一个连续的本地句柄( handle 字段),该句柄仅在本次连接生命周期内有效。

因此,客户端必须在 ESP_GATTC_SEARCH_RES_EVT 事件中,根据UUID匹配找到目标特征值,并记录其动态分配的句柄:

case ESP_GATTC_SEARCH_RES_EVT: {
    // 在服务发现结果中查找LED状态特征值
    if (memcmp(&param->search_res.uuid, &led_state_uuid, sizeof(esp_bt_uuid_t)) == 0) {
        led_char_handle = param->search_res.handle; // 记录客户端视角的句柄
        ESP_LOGI(GATTC_TAG, "Found LED Char Handle: 0x%04x", led_char_handle);
    }
    break;
}

后续所有读写操作均使用此 led_char_handle ,而非服务端定义的索引。

同步读取:阻塞式API的工程实践

客户端读取特征值最直接的方式是调用 esp_ble_gattc_read_char() 。该API为同步阻塞式,调用后立即返回操作状态,并在 ESP_GATTC_READ_CHAR_EVT 事件中返回读取结果。其典型用法如下:

// 在客户端任务中执行读取
esp_err_t ret = esp_ble_gattc_read_char(gattc_if, conn_id, led_char_handle,
                                        ESP_GATT_AUTH_REQ_NONE);
if (ret != ESP_OK) {
    ESP_LOGE(GATTC_TAG, "Read char failed, error code = %x", ret);
    return;
}

// 阻塞等待读取完成事件(简化示例,实际应设超时)
while (!read_done_flag) {
    vTaskDelay(10 / portTICK_PERIOD_MS);
}

ESP_GATTC_READ_CHAR_EVT 事件处理中, param->read.value 即为读取到的数据:

case ESP_GATTC_READ_CHAR_EVT: {
    if (param->read.status == ESP_GATT_OK && param->read.handle == led_char_handle) {
        ESP_LOGI(GATTC_TAG, "Read LED state: %d, len: %d", param->read.value[0], param->read.value_len);
        // 更新本地状态显示或触发业务逻辑
        current_led_state = param->read.value[0];
        read_done_flag = true;
    }
    break;
}

同步读取的优势在于逻辑清晰、易于调试;其缺点是阻塞调用线程,若需高频率读取(如1Hz以上),建议改用异步方式(通过事件通知)或在独立任务中轮询,避免影响其他业务。

异步写入与通知使能:构建低延迟控制环

对于LED控制这类需要快速响应的场景,客户端写入应尽可能减少延迟。 esp_ble_gattc_write_char() 同样提供同步与异步两种模式。异步模式( ESP_GATT_WRITE_TYPE_NO_RSP )不等待远程确认,适合对可靠性要求不高但对延迟极度敏感的场景:

// 异步写入LED状态(无响应)
esp_ble_gattc_write_char(gattc_if, conn_id, led_char_handle,
                         sizeof(uint8_t), &new_state, 
                         ESP_GATT_WRITE_TYPE_NO_RSP, 
                         ESP_GATT_AUTH_REQ_NONE);

若需确保写入成功,可选用带响应模式( ESP_GATT_WRITE_TYPE_RSP ),并在 ESP_GATTC_WRITE_CHAR_EVT 事件中检查 status 字段。

此外,客户端常需使能服务端的通知(Notify)功能,以便在LED状态被服务端内部逻辑(如定时任务)改变时,自动接收更新。这需向CCCD(Client Characteristic Configuration Descriptor)写入特定值:

// 使能Notify(写入0x0001)
uint16_t notify_en = 0x0001;
esp_ble_gattc_write_char_descr(gattc_if, conn_id, cccd_handle,
                               sizeof(notify_en), (uint8_t*)&notify_en,
                               ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE);

使能后,服务端调用 esp_ble_gatts_send_indicate() esp_ble_gatts_send_notify() 即可向客户端推送最新状态,客户端在 ESP_GATTC_NOTIFY_EVT 事件中接收。

3.5.4 多任务协同:周期性状态更新与读写节奏控制

字幕中提及“服务端3秒修改一次,客户端1秒读取一次”,这揭示了一个典型的嵌入式BLE应用模式:服务端内置状态机(如定时LED闪烁),客户端周期性轮询或依赖通知获取状态。实现此模式需协调FreeRTOS任务、GATT事件与硬件定时器。

服务端:独立任务驱动状态变更

服务端不应在GATT事件回调中启动长时间延时(如 vTaskDelay() ),否则会阻塞整个BLE协议栈事件处理。正确做法是创建一个独立的FreeRTOS任务,负责周期性更新 led_state 并触发GATT通知:

// 服务端LED控制任务
void led_control_task(void *pvParameters) {
    TickType_t xLastWakeTime = xTaskGetTickCount();
    const TickType_t xFrequency = pdMS_TO_TICKS(3000); // 3秒周期

    while (1) {
        // 1. 更新LED状态(翻转)
        led_state = !led_state;

        // 2. 控制硬件(可选,若LED由服务端直接驱动)
        gpio_set_level(GPIO_NUM_2, led_state);

        // 3. 若客户端已使能Notify,则发送通知
        if (notify_en) {
            esp_ble_gatts_send_notify(gatts_if, conn_id, led_handle_table[TEST_SVC_IDX_CHAR_LED_VAL],
                                      sizeof(led_state), &led_state);
        }

        // 4. 等待下一个周期(使用vTaskDelayUntil确保精确周期)
        vTaskDelayUntil(&xLastWakeTime, xFrequency);
    }
}

vTaskDelayUntil() 是关键,它确保任务每次执行间隔严格等于设定周期,不受任务自身执行时间影响,避免因 vTaskDelay() 导致的周期漂移。

客户端:读取任务与通知接收的共存

客户端可同时运行两个任务:一个负责周期性读取( read_task ),另一个专门处理通知接收( notify_task )。二者通过共享变量或消息队列通信:

// 客户端读取任务(1秒周期)
void read_task(void *pvParameters) {
    TickType_t xLastWakeTime = xTaskGetTickCount();
    const TickType_t xFrequency = pdMS_TO_TICKS(1000);

    while (1) {
        // 执行读取操作(同步或异步)
        perform_led_read();

        vTaskDelayUntil(&xLastWakeTime, xFrequency);
    }
}

// 客户端通知接收任务(由ESP_GATTC_NOTIFY_EVT事件触发)
void notify_task(void *pvParameters) {
    while (1) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // 等待通知信号

        // 此处处理接收到的LED状态更新
        ESP_LOGI(GATTC_TAG, "Received LED Notify: %d", notified_led_state);
        // 更新UI或触发其他业务
    }
}

ESP_GATTC_NOTIFY_EVT 事件处理中,调用 xTaskNotifyGive(notify_task_handle) 即可唤醒 notify_task ,实现事件驱动的高效响应。

3.5.5 调试技巧与常见问题排查

在实际开发中,GATT读写失败往往源于细微的配置偏差或时序问题。以下是基于字幕中调试过程提炼的实用经验:

编译与烧录陷阱:文件变更检测失效

字幕中反复强调“修改文件名”、“敲两下保存”以强制重新编译,这直指ESP-IDF构建系统的缓存机制。 idf.py build 默认仅编译被修改的源文件及其依赖项。若新增C文件(如 led.c )但未在 CMakeLists.txt 中正确声明,或修改了头文件但未触发依赖重建,编译器可能忽略变更,导致烧录旧固件。 解决方案:
- 新增源文件后,务必在组件的 CMakeLists.txt 中添加 set(SRCS "led.c") 并确保 register_component() 调用。
- 怀疑编译缓存时,执行 idf.py fullclean 彻底清除构建目录,再 idf.py build
- 使用 idf.py monitor 观察串口日志,确认打印信息与最新代码一致。

句柄不匹配:服务端与客户端的“失联”

字幕中明确指出服务端句柄为12、客户端为7,若代码中硬编码错误句柄,读写必然失败。 验证方法:
- 服务端:在 ESP_GATTS_CREAT_EVT 后,打印 esp_ble_gatts_get_attr_count() 及各属性句柄。
- 客户端:在 ESP_GATTC_SEARCH_CMPL_EVT 后,遍历 search_res 列表,打印所有发现的UUID与对应句柄,确认目标特征值是否在其中。

读取数据不全:日志截断与缓冲区溢出

字幕提到“打印不全”,常见原因有:
- 串口日志缓冲区不足 :ESP-IDF默认日志输出使用Ring Buffer,若日志速率过高,旧日志被覆盖。可通过 menuconfig → Component config → Log output → Default log buffer size 增大缓冲区。
- printf 格式化字符串错误 :如 ESP_LOGI(..., "Value: %d", data) data uint8_t ,但 %d 期望 int ,可能导致栈错乱。应使用 %" PRIu8 " 或显式转换 (int)data
- GATT响应长度错误 :服务端 esp_ble_gatts_send_response() rsp.attr_value.len 若小于实际数据长度,客户端将只收到部分字节。

连接断开后的状态残留

字幕中出现“断开后出现问题”,这源于GATT连接断开时,客户端持有的句柄、连接ID等资源未被及时清理。 最佳实践:
- 在 ESP_GATTC_DISCONNECT_EVT 事件中,重置所有与该连接相关的句柄变量(如 led_char_handle = 0 )。
- 清空本地状态缓存(如 current_led_state = 0xFF 表示无效)。
- 若有正在运行的读取/通知任务,通过 vTaskDelete() 或信号量通知其停止。

我曾在某工业传感器项目中,因未在断连事件中重置CCCD使能标志,导致设备重连后通知功能异常,耗费两天定位。教训是: 所有GATT资源的生命周期必须与连接状态严格绑定,断连即销毁,重连即重建。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐