第3章 特征值存储与访问接口:本地数据库设计、客户端缓存、CCC描述符管理、持久化策略

3.1 概述

BLE GATT 服务的核心是特征(Characteristic),每个特征对应一段可被客户端读写或通知的值。在嵌入式设备中,特征值存储并非简单的变量——需要同时管理:

  • 本地数据库:记录特征 UUID、句柄、访问权限、值长度及最大长度。
  • 客户端缓存:存储已绑定客户端的 CCC(Client Characteristic Configuration)状态,减少重复配置。
  • CCC 描述符:每个支持通知/指示的特征必须维护一个 CCC 位,指示当前使能的客户端。
  • 持久化策略:设备重启后需恢复 CCC 状态,避免客户端再次订阅。

本章从原理出发,给出典型嵌入式 BLE 协议栈(如 NimBLE、ESP-IDF GATT)下的实现流程与代码示例。

3.2 本地数据库设计

3.2.1 数据结构定义

一个特征在数据库中以结构体存储,包含固定元信息和动态值指针:

typedef struct {
    uint16_t handle;            // 特征句柄
    uint16_t uuid16;            // 16-bit UUID(或使用 UUID128)
    uint8_t  properties;        // 属性:读/写/通知/指示
    uint8_t  permissions;       // 安全权限:加密、配对等
    uint16_t max_len;           // 最大允许长度
    uint16_t cur_len;           // 当前有效长度
    uint8_t *value;             // 指向动态分配的值缓冲区
    void    *ccc_handle;        // 指向关联的 CCC 描述符结构体
} gatt_char_db_t;

3.2.2 注册流程

  1. 分配特征数组(静态或动态)。
  2. 调用协议栈 API 注册服务(如 ble_gatts_add_service)。
  3. 依次添加特征,ble_gatts_add_characteristic 返回句柄。
  4. 填充本地数据库结构体,关联句柄与值缓冲区。
  5. 为支持通知的特征添加 CCC 描述符。

典型代码片段(基于 NimBLE):

static gatt_char_db_t char_db[CHAR_MAX];
static uint8_t         char_count = 0;

void gatt_db_init(void) {
    // 添加服务
    ble_gatts_add_service(BLE_GATT_SERVICE_TYPE_PRIMARY, &uuid_service);
    // 添加特征
    struct ble_gatts_char_cfg cfg = {0};
    cfg.uuid          = &uuid_char;
    cfg.access_cb     = char_access_cb;
    cfg.arg           = &char_db[char_count];
    cfg.flags         = BLE_GATT_CHAR_F_READ | BLE_GATT_CHAR_F_NOTIFY;
    ble_gatts_add_characteristic(&cfg);
    // 注册完成
    ble_gatts_svc_register();
    // 注册后获取句柄
    char_db[char_count].handle = cfg.handle_val;
    char_count++;
}

3.3 客户端缓存

设备可能连接多个客户端,每个客户端对同一个特征的 CCC 配置可能不同。缓存是将客户端标识(BD_ADDR)与 CCC 状态绑定,避免每次连接都重新配置。

3.3.1 缓存表设计

字段类型说明
peer_addruint8_t[6]客户端蓝牙地址
addr_typeuint8_t地址类型(公开/随机)
ccc_mapuint16_t位图,第 i 位为 1 表示对应特征通知使能
validbool条目是否有效

3.3.2 缓存查找逻辑

uint16_t gatt_cache_get_ccc(const uint8_t *addr, uint8_t addr_type) {
    for (int i = 0; i < CACHE_MAX; i++) {
        if (cache[i].valid &&
            memcmp(cache[i].peer_addr, addr, 6) == 0 &&
            cache[i].addr_type == addr_type) {
            return cache[i].ccc_map;
        }
    }
    return 0; // 未找到,默认全关
}

当客户端写入 CCC 时,同时更新缓存表。

3.4 CCC 描述符管理

每个支持通知/指示的特征在协议栈内部有一个 CCC 描述符(句柄+1)。但实际应用层需维护的状态包括:

  • 使能标志:0x0000(禁止)、0x0001(通知)、0x0002(指示)。
  • 多客户端场景:每个客户端独立记录,因此使用客户端缓存表。

3.4.1 CCC 写入回调

int char_access_cb(uint16_t conn_handle, uint16_t attr_handle,
                   struct ble_gatt_access_ctxt *ctxt, void *arg) {
    if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) {
        uint16_t ccc_val = 0;
        // 解析 CCC值
        // 更新客户端缓存
        // 若使能通知,标记该特征在缓存表中
    }
    return 0;
}

3.4.2 CCC 与通知触发

发送通知前必须检查:

bool gatt_can_notify(uint16_t conn_handle, uint16_t char_handle) {
    // 1. 根据 conn_handle 获取客户端地址
    // 2. 查询缓存表,检查对应特征位
    uint8_t addr[6]; uint8_t addr_type;
    ble_gap_conn_find(conn_handle, &addr, &addr_type);
    uint16_t ccc_map = gatt_cache_get_ccc(addr, addr_type);
    int idx = char_db_idx_by_handle(char_handle);
    return (ccc_map & (1 << idx)) != 0;
}

3.5 持久化策略

设备掉电后若丢失 CCC 状态,已绑定的客户端需要重新订阅,影响用户体验。常用方案:使用 NVS(Non-Volatile Storage)保存客户端缓存表。

3.5.1 存储结构

typedef struct __attribute__((packed)) {
    uint8_t  addr[6];
    uint8_t  addr_type;
    uint16_t ccc_map;
    uint16_t crc16;   // 校验
} persist_ccc_entry_t;

3.5.2 保存与恢复

void gatt_persist_save(void) {
    nvs_handle nvs;
    nvs_open("gatt_db", NVS_READWRITE, &nvs);
    for (int i = 0; i < CACHE_MAX; i++) {
        if (cache[i].valid) {
            char key[16];
            snprintf(key, sizeof(key), "ccc_%d", i);
            nvs_set_blob(nvs, key, &cache[i], sizeof(persist_ccc_entry_t));
        }
    }
    nvs_commit(nvs);
    nvs_close(nvs);
}

void gatt_persist_restore(void) {
    nvs_handle nvs;
    nvs_open("gatt_db", NVS_READONLY, &nvs);
    for (int i = 0; i < CACHE_MAX; i++) {
        char key[16];
        snprintf(key, sizeof(key), "ccc_%d", i);
        size_t len = sizeof(persist_ccc_entry_t);
        if (nvs_get_blob(nvs, key, &cache[i], &len) == ESP_OK) {
            cache[i].valid = true;
        }
    }
    nvs_close(nvs);
}

3.6 调试与验证

3.6.1 日志输出

// 每次 CCC 写入时打印
BLE_LOG("CCC write: addr=%02x:%02x:... char=%04x val=0x%04x\n",
        addr[0], addr[1], char_handle, ccc_val);
// 通知发送前打印
BLE_LOG("Notify check: char=%04x can=%d\n", char_handle, can);

3.6.2 抓包验证

  • 使用 nRF Connect 或 LightBlue 连接设备,观察特征是否能够使能通知。
  • 断开重连后,检查是否自动恢复 CCC(无需再次手动使能)。

3.7 常见错误

现象原因解决
通知无法触发CCC 描述符未正确添加或句柄偏移错误确认协议栈 API 在添加特征后自动添加 CCC;检查句柄计算
重新连接后 CCC 丢失未实现持久化或 NVS 路径错误添加 gatt_persist_restore 并在初始化时调用
多客户端互相影响缓存表使用全局位图而非独立条目按客户端地址分别存储 CCC 状态
NVS 写入频繁导致磨损每次 CCC 写入都触发持久化加入去抖定时器,或只在连接断开时保存

3.8 性能优化

  • 缓存查找:使用哈希表或二元组(地址+类型)作为键,避免线性遍历。
  • 持久化频率:使用 5 秒去抖,合并多次写入单次 blob。
  • 内存占用:特征值缓冲区使用静态数组而非动态分配,避免碎片。
  • CCC 检查:在通知高频发送路径中,将 gatt_can_notify 结果缓存一帧,减少协议栈查询。
特征值存储与访问架构图 特征值存储与访问架构 应用层 (GATT 回调) 本地数据库 句柄/权限/值缓冲 客户端缓存表 BD_ADDR → CCC 位图 CCC 描述符 attr_handle+1 持久化 (NVS) 掉电恢复 CCC 通知发送 检查 CCC 缓存 关键依赖边界 • 应用层通过回调与本地数据库、缓存表交互,不能直接操作 CCC 描述符句柄。 • 持久化层只缓存 CCC 状态,特征值本身由应用层维护,不自动恢复。
实现要点: 注意区分“CCC 描述符硬件句柄”与“应用层缓存的使能位”。多客户端时缓存必须按地址隔离,持久化只保存增量变化,避免每次写入都擦写 NVS。
调试技巧: 在 CCC 写入回调中打印客户端地址和特征句柄,同时在通知发送前打印“can_notify”结果,可以快速定位通知未使能的原因。
兼容性提醒: 不同 BLE 协议栈管理 CCC 的方式有差异。NimBLE 自动为每个特征生成 CCC 描述符(句柄+1),而有些栈需要手动添加。查阅 SDK 文档确认句柄偏移。