第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 注册流程
- 分配特征数组(静态或动态)。
- 调用协议栈 API 注册服务(如
ble_gatts_add_service)。 - 依次添加特征,
ble_gatts_add_characteristic返回句柄。 - 填充本地数据库结构体,关联句柄与值缓冲区。
- 为支持通知的特征添加 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_addr | uint8_t[6] | 客户端蓝牙地址 |
| addr_type | uint8_t | 地址类型(公开/随机) |
| ccc_map | uint16_t | 位图,第 i 位为 1 表示对应特征通知使能 |
| valid | bool | 条目是否有效 |
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结果缓存一帧,减少协议栈查询。
实现要点: 注意区分“CCC 描述符硬件句柄”与“应用层缓存的使能位”。多客户端时缓存必须按地址隔离,持久化只保存增量变化,避免每次写入都擦写 NVS。
调试技巧: 在 CCC 写入回调中打印客户端地址和特征句柄,同时在通知发送前打印“can_notify”结果,可以快速定位通知未使能的原因。
兼容性提醒: 不同 BLE 协议栈管理 CCC 的方式有差异。NimBLE 自动为每个特征生成 CCC 描述符(句柄+1),而有些栈需要手动添加。查阅 SDK 文档确认句柄偏移。