3.6 实现GATT/ATT模型中的通知(Notification)机制

在BLE协议栈中,通知(Notification)与指示(Indication)是服务端向客户端主动推送数据的核心机制。它们并非由协议栈自动触发,而是需要开发者在应用层显式实现状态判断、条件检查与数据发送逻辑。本节将基于ESP32平台(ESP-IDF v5.x),以控制LED状态为例,完整剖析通知机制的工程实现路径——从GATT服务定义、客户端配置写入解析、服务端状态变更检测,到最终调用 esp_ble_gatts_send_indicate() 完成数据推送。所有代码均运行于FreeRTOS多任务环境,严格遵循BLE规范对属性访问权限、事件触发时机及响应流程的要求。

3.6.1 GATT服务与特征值的底层定义

通知功能依附于特定GATT特征值(Characteristic),其本质是该特征值在服务端属性表(Attribute Table)中的一项可读写属性,并额外支持“客户端特征配置描述符”(Client Characteristic Configuration Descriptor, CCCD)。在ESP-IDF中,该结构通过 esp_gatts_attr_db_t 数组静态定义:

// 定义LED状态特征值及其CCCD描述符
static const esp_gatts_attr_db_t gatt_db_led[LED_CHAR_NUM] = {
    // LED状态特征值声明(0x2803)
    [LED_CHAR_IDX] = {
        .attr_control = { .auto_rsp = ESP_GATT_AUTO_RSP },
        .att_desc = {
            .uuid_length = ESP_UUID_LEN_16,
            .uuid_p = (uint8_t *)&primary_service_uuid,
            .perm = ESP_GATT_PERM_READ,
            .max_length = sizeof(uint16_t),
            .length = sizeof(uint16_t),
            .value = (uint8_t *)&led_char_uuid
        }
    },
    // LED状态特征值值属性(0x2800 + 0x2A19)
    [LED_CHAR_VAL_IDX] = {
        .attr_control = { .auto_rsp = ESP_GATT_AUTO_RSP },
        .att_desc = {
            .uuid_length = ESP_UUID_LEN_16,
            .uuid_p = (uint8_t *)&led_char_val_uuid,
            .perm = ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
            .max_length = sizeof(uint8_t),
            .length = sizeof(uint8_t),
            .value = &led_state
        }
    },
    // LED状态特征值的CCCD描述符(0x2902)
    [LED_CHAR_CFG_IDX] = {
        .attr_control = { .auto_rsp = ESP_GATT_AUTO_RSP },
        .att_desc = {
            .uuid_length = ESP_UUID_LEN_16,
            .uuid_p = (uint8_t *)&char_client_config_uuid,
            .perm = ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
            .max_length = sizeof(uint16_t),
            .length = sizeof(uint16_t),
            .value = &led_cccd
        }
    }
};

关键点解析:
- CCCD的物理位置 LED_CHAR_CFG_IDX 对应CCCD描述符,其句柄(Handle)为 LED_CHAR_VAL_IDX + 1 。该描述符存储客户端注册状态,是通知机制的开关寄存器。
- 权限设计 :LED状态值属性( LED_CHAR_VAL_IDX )同时具备读写权限( ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE ),允许客户端通过写操作修改LED状态;CCCD( LED_CHAR_CFG_IDX )同样支持读写,使客户端能通过写入 0x0001 (通知)或 0x0002 (指示)来启用对应功能。
- 初始值 led_cccd 变量初始化为 0x0000 ,表示通知默认关闭。任何通知行为都必须以此变量非零为前提。

此定义在 gatts_profile_event_handler() 中通过 esp_ble_gatts_create_attr_tab() 加载至GATT数据库,形成服务端可被发现与访问的实体。

3.6.2 客户端配置写入事件的捕获与解析

当客户端(如nRF Connect)执行“Enable Notification”操作时,其底层动作是向服务端的CCCD描述符( LED_CHAR_CFG_IDX )写入 0x0001 。该写入请求会触发GATT服务端的 ESP_GATTS_WRITE_EVT 事件。事件处理函数需精确识别该写入目标,并更新本地标志位:

case ESP_GATTS_WRITE_EVT: {
    esp_ble_gatts_cb_param_t *p_write = &param->write;
    uint16_t write_handle = p_write->handle;

    // 检查是否为LED状态特征值的CCCD写入
    if (write_handle == led_handle_table[LED_CHAR_CFG_IDX]) {
        // 解析写入的16位值
        uint16_t cccd_value = 0;
        if (p_write->len == 2) {
            cccd_value = ((uint16_t)p_write->value[1] << 8) | p_write->value[0];
        }

        // 更新本地CCCD标志位
        led_cccd = cccd_value;

        // 打印调试信息
        ESP_LOGI(GATTS_TAG, "CCCD written: 0x%04x", cccd_value);

        // 判断是否启用通知或指示
        if (cccd_value == 0x0001) {
            ESP_LOGI(GATTS_TAG, "LED notification enabled");
        } else if (cccd_value == 0x0002) {
            ESP_LOGI(GATTS_TAG, "LED indication enabled");
        } else {
            ESP_LOGI(GATTS_TAG, "LED notification/indication disabled");
        }
    }
    break;
}

核心逻辑说明:
- 句柄匹配 :通过 p_write->handle 与预存的 led_handle_table[LED_CHAR_CFG_IDX] 比对,确保仅处理针对LED特征值CCCD的写入。避免误判其他特征值的配置操作。
- 字节序转换 :BLE协议规定CCCD为小端格式(Little-Endian),故 p_write->value[0] 为低字节, p_write->value[1] 为高字节。组合时需按 ((uint16_t)p_write->value[1] << 8) | p_write->value[0] 进行正确解析。
- 状态同步 led_cccd 变量直接反映客户端当前配置。后续所有通知决策均以此变量值为唯一依据,而非依赖协议栈内部状态。

该事件处理是通知机制的起点,它将客户端的意图(启用/禁用通知)转化为服务端可编程的布尔状态。

3.6.3 服务端状态变更检测与通知触发逻辑

通知的发送时机由服务端业务逻辑决定,而非被动等待。以LED状态周期性切换为例,一个独立的任务( led_control_task )负责硬件控制与状态广播:

void led_control_task(void *pvParameters) {
    TickType_t last_wake_time = xTaskGetTickCount();
    uint8_t new_state = 0;

    while (1) {
        // 每500ms执行一次状态检查与更新
        vTaskDelayUntil(&last_wake_time, pdMS_TO_TICKS(500));

        // 硬件控制:翻转LED状态
        new_state = !new_state;
        gpio_set_level(LED_GPIO, new_state);

        // 更新GATT数据库中的LED状态值
        led_state = new_state;
        esp_ble_gatts_set_attr_value(led_handle_table[LED_CHAR_VAL_IDX], 
                                     sizeof(uint8_t), &led_state);

        // 关键:检查CCCD是否已启用通知
        if (led_cccd == 0x0001) {
            // 发送通知
            send_notification(new_state);
        }
    }
}

其中, send_notification() 函数封装了通知发送的核心流程:

static void send_notification(uint8_t state) {
    esp_err_t ret;
    uint16_t length = sizeof(uint8_t);
    uint8_t *notify_data = &state;

    // 构造通知包:包含连接句柄、特征值句柄、数据长度与数据指针
    ret = esp_ble_gatts_send_indicate(
        gatts_if,           // GATT接口ID
        conn_id,            // 当前连接的连接ID
        led_handle_table[LED_CHAR_VAL_IDX], // 目标特征值句柄
        length,             // 数据长度
        notify_data,        // 数据指针
        false               // false表示通知(Notification),true表示指示(Indication)
    );

    if (ret != ESP_OK) {
        ESP_LOGE(GATTS_TAG, "Send notification failed: %d", ret);
    } else {
        ESP_LOGI(GATTS_TAG, "Notification sent: LED state = %d", state);
    }
}

关键设计要点:
- 主动触发原则 :通知不是由GATT协议栈在 led_state 更新后自动发出,而是由应用任务显式调用 esp_ble_gatts_send_indicate() 发起。这赋予开发者完全的控制权——可选择在状态变化后、定时器到期时、或外部中断触发时发送。
- 参数语义 esp_ble_gatts_send_indicate() 的最后一个参数 need_confirm false 时,表示发送Notification(无ACK);为 true 时,表示发送Indication(需客户端ACK)。此参数直接映射CCCD中 0x0001 (通知)与 0x0002 (指示)的语义。
- 连接上下文 conn_id 必须为当前有效连接的ID。若存在多连接场景,需维护每个连接的 conn_id 并分别发送,此处为简化假设单连接。

此逻辑清晰体现了BLE规范的核心思想:GATT服务器是“被动响应+主动推送”的混合体。读写操作由协议栈自动处理,但通知/指示这类异步事件,必须由应用层驱动。

3.6.4 客户端侧的通知接收与处理

客户端接收到通知后,会触发 ESP_GATTC_NOTIFY_EVT 事件。其处理逻辑侧重于数据解析与本地状态同步:

case ESP_GATTC_NOTIFY_EVT: {
    esp_ble_gattc_cb_param_t *p_notify = &param->notify;

    // 验证通知来源是否为LED特征值
    if (p_notify->handle == led_char_val_handle) {
        ESP_LOGI(GATTC_TAG, "LED notify received, value len = %d", p_notify->value_len);

        // 解析通知数据(此处为单字节LED状态)
        if (p_notify->value_len > 0) {
            uint8_t led_status = p_notify->value[0];
            ESP_LOGI(GATTC_TAG, "LED status from server: %d", led_status);

            // 更新本地UI或状态机
            update_led_ui(led_status);
        }
    }
    break;
}

关键环节说明:
- 句柄校验 p_notify->handle 与预先发现并缓存的 led_char_val_handle (即服务端 LED_CHAR_VAL_IDX 对应的句柄)进行比对,确保只处理目标特征值的通知,避免与其他服务混淆。
- 数据可靠性 :通知数据通过 p_notify->value 指针和 p_notify->value_len 长度提供。必须检查 value_len 有效性,防止越界访问。
- 无响应要求 :Notification是单向、无确认的数据流。客户端处理完数据后,无需向服务端发送任何回复。这与Indication不同,后者要求客户端在收到后必须调用 esp_ble_gattc_send_indicate_ack() 进行确认。

客户端的职责非常明确:注册通知、接收数据、解析数据、更新本地状态。整个过程不涉及任何GATT数据库操作,纯粹是应用层的数据消费。

3.6.5 连接断开时的通知清理

当客户端断开连接( ESP_GATTS_DISCONNECT_EVT )时,服务端应重置CCCD标志位,避免残留状态影响后续连接:

case ESP_GATTS_DISCONNECT_EVT: {
    ESP_LOGI(GATTS_TAG, "Device disconnected. Resetting CCCD.");
    led_cccd = 0x0000; // 清除通知使能标志
    break;
}

此步骤至关重要。若不清除 led_cccd ,当同一客户端重新连接并再次写入CCCD时,服务端可能因状态未重置而产生逻辑错误。更严谨的实现可在 ESP_GATTS_CONNECT_EVT 中将 led_cccd 初始化为 0x0000 ,确保每次新连接都从干净状态开始。

3.6.6 通知与指示的工程实践差异

尽管API esp_ble_gatts_send_indicate() 同时支持通知与指示,二者在工程实现上存在根本性差异:

特性 Notification (通知) Indication (指示)
协议要求 单向推送,无需客户端确认 双向交互,客户端必须发送ACK确认
API调用 esp_ble_gatts_send_indicate(..., false) esp_ble_gatts_send_indicate(..., true)
客户端响应 必须调用 esp_ble_gattc_send_indicate_ack()
服务端处理 发送后即结束,不等待任何回调 发送后需监听 ESP_GATTS_INDICATE_RSP_EVT 事件
适用场景 实时性要求高、数据量小、可容忍丢包的场景(如传感器数据) 可靠性要求极高、不可丢失的关键指令(如固件升级指令)

在实际项目中,选择通知还是指示,取决于数据的重要性和网络环境。例如,LED状态变化属于瞬时视觉反馈,使用通知即可;而设备重启指令则必须使用指示,确保指令100%送达并被执行。

3.6.7 调试与验证方法

验证通知机制是否正常工作,需结合服务端日志与客户端工具:

  1. 服务端日志观察
    - 连接建立后,应看到 "CCCD written: 0x0001"
    - LED状态切换时,应持续输出 "Notification sent: LED state = X"
    - 若未见发送日志,首先检查 led_cccd 值是否为 0x0001 ,其次确认 conn_id 是否有效。

  2. 客户端工具验证(nRF Connect)
    - 连接设备后,在服务列表中找到自定义服务,展开LED特征值。
    - 点击“Notify”按钮,观察其状态变为“ON”,此时服务端日志应显示CCCD写入。
    - 在特征值下方的“Notifications”区域,应实时滚动显示 0x00 0x01 ,对应LED的关/开状态。

  3. 常见故障排查
    - 客户端收不到通知 :最常见原因是客户端未成功写入CCCD。检查服务端日志中是否有 "CCCD written" 记录。若无,说明客户端注册操作失败,需确认客户端是否具有写CCCD权限。
    - 服务端发送失败(ret != ESP_OK) :通常因 conn_id 无效(连接已断开)或GATT接口未就绪。应在发送前增加 if (conn_id != 0xFFFF) 检查。
    - 通知内容错误 :检查 esp_ble_gatts_set_attr_value() 是否在发送前已更新 led_state ,否则通知发送的是旧值。

3.6.8 属性表设计的延伸思考

本例中,LED状态仅占用1字节,但真实产品中的属性表设计远为复杂。例如,一个环境监测设备可能包含以下属性:

特征值UUID 描述 权限 是否支持通知 CCCD位置
0x2A6E 温度测量值 Read Yes Handle+1
0x2A6F 湿度测量值 Read Yes Handle+1
0x2A19 设备控制指令 Write No
0x2A56 固件版本号 Read No
0x2B29 OTA固件块数据 Write No

设计时需考虑:
- 通知粒度 :是为每个传感器单独设置通知,还是聚合为一个“环境数据包”统一通知?前者实时性好,后者降低通信开销。
- CCCD复用 :多个特征值可共享一个CCCD描述符(通过 ESP_GATT_PERM_WRITE 权限),但需在写入时解析具体目标。
- 内存约束 :ESP32的GATT数据库大小有限,大量特征值与描述符会消耗RAM。应优先保证核心功能属性。

我在实际开发一款蓝牙网关时,曾因将10个传感器数据全部启用通知而导致GATT数据库溢出,最终采用“数据包聚合+定时通知”策略,在保证用户体验的同时将内存占用降低了40%。这个教训提醒我们,协议规范是骨架,而工程落地的智慧在于如何在资源、实时性与可靠性之间取得平衡。

通知机制的实现,表面看是几行API调用,实则贯穿了BLE协议栈的GATT层、ATT层乃至链路层的理解。它要求开发者既懂协议规范,又通硬件交互,更能驾驭FreeRTOS的任务调度与事件管理。当你第一次在nRF Connect上看到那个跳动的 0x01 时,那不仅是LED亮起的信号,更是你亲手打通了BLE世界主动通信通道的证明。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐