4. Attribute Table 设计:Handle 分配、Attribute Type、Permissions、Value 存储

好,咱们进入 Attribute Table 设计。这是 GATT 服务开发里最核心、也最容易踩坑的地方。说白了,你整个蓝牙服务的骨架,就是一张 Attribute Table。我见过太多开发者,代码写得飞起,结果 Attribute Table 一塌糊涂,连手机都连不上。嗯,咱们今天就把这张表彻底讲透。

4.1 Handle 分配:从 0x0001 开始的编号游戏

每个 Attribute 都有一个唯一的 Handle,16 位无符号整数,从 0x0001 开始递增。你可能会问:为什么从 1 开始?因为 0x0000 是保留值,表示“无效 Handle”。

我个人习惯,在设计阶段就先把 Handle 范围规划好。比如一个服务占 10 个 Handle,我就预留 0x0010-0x0019。为什么?因为后期加特性时,你不想把整个表推倒重来。

小技巧:Handle 分配建议按服务分组。比如 Service A 用 0x0010-0x001F,Service B 用 0x0020-0x002F。这样调试时一眼就能看出哪个 Handle 属于哪个服务。

Handle 的分配规则其实很简单:

  • 每个 Attribute 一个 Handle,不能重复
  • 通常从 0x0001 开始连续分配
  • 但允许有空洞(比如预留 Handle)
  • Handle 值越小,优先级越高(某些协议栈会据此优化查找)

我记得有一次,客户的产品在连接后频繁断连。查了半天,发现是 Handle 分配混乱,导致 Attribute 查找时越界。从那以后,我每次写代码前都会先画一张 Handle 分配表。

4.2 Attribute Type:UUID 就是你的身份证

每个 Attribute 都有一个 Type,用 UUID 表示。16-bit UUID 用于标准 Profile(比如 Battery Service 是 0x180F),128-bit UUID 用于自定义服务。

这里有个坑:很多人以为 UUID 随便写一个就行。其实不然。128-bit UUID 有格式要求:

// 标准格式:8-4-4-4-12
// 例如:0000180F-0000-1000-8000-00805F9B34FB
// 自定义时,建议用在线生成器,保证唯一性

你想想看,如果两个产品用了同一个 UUID,手机端会怎么处理?轻则服务识别错误,重则直接崩溃。我曾经见过一个案例,两个不同公司的产品用了相同的 128-bit UUID,结果手机 App 把 A 产品的服务当成了 B 产品的,数据全乱了。

UUID 类型 长度 用途 示例
16-bit 2 字节 标准 Profile/Service 0x180F (Battery Service)
32-bit 4 字节 Bluetooth SIG 定义 0x0000180F (实际很少用)
128-bit 16 字节 自定义服务/特性 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

4.3 Permissions:谁可以碰我的数据?

Permissions 决定了 Attribute 的访问控制。这是安全的关键。我见过很多开发者,所有 Attribute 都设成 Read+Write,结果被黑客轻松读取敏感数据。

Permissions 主要分三类:

  • Access Permissions:Read、Write、Read+Write、None
  • Authentication Permissions:是否需要配对/绑定
  • Authorization Permissions:是否需要用户确认
注意:不要把敏感数据的 Permission 设成 Read+Write without authentication。比如设备密钥、固件版本(如果涉及安全)等,至少需要 MITM 保护。

我曾经在调试一个智能门锁时,发现它的开锁指令 Characteristic 竟然允许未配对设备写入。你想想看,这意味着任何蓝牙设备都能开门。嗯,这个 Bug 差点导致产品召回。

Permissions 的典型配置:

// 示例:Nordic nRF5 SDK 中的 Permission 设置
// 公开可读,但需要配对才能写
ble_gatts_attr_md_t attr_md = {0};
attr_md.read_perm = SEC_OPEN;      // 任何人都可以读
attr_md.write_perm = SEC_JUST_WORKS; // 需要配对才能写

// 需要 MITM 保护的敏感数据
attr_md.read_perm = SEC_MITM;
attr_md.write_perm = SEC_MITM;

4.4 Value 存储:数据到底放哪儿?

Attribute Value 的存储方式,直接影响性能和内存占用。主要有三种方式:

  1. 静态存储:Value 在编译时就确定,比如设备名称、外观等。放在 Flash 里,不占 RAM。
  2. 动态存储:Value 在运行时变化,比如电池电量、传感器数据。放在 RAM 里,需要实时更新。
  3. 回调存储:Value 不实际存储,每次读写都通过回调函数处理。适合大数据量或需要实时计算的情况。

我个人推荐:能用静态就用静态,能省 RAM 就省 RAM。蓝牙协议栈本身已经够吃内存了,别再浪费在 Attribute Value 上。

经验之谈:对于频繁变化的 Value(比如每秒更新一次的传感器数据),建议用回调存储。这样协议栈不会帮你缓存,减少了内存拷贝,也避免了数据不一致的问题。

Value 的长度也需要注意:

  • MTU 大小决定了单次传输的最大数据量(默认 23 字节,可协商到 512+)
  • 超过 MTU 的 Value 需要分片传输(Long Read/Write)
  • 建议单个 Characteristic Value 不超过 MTU-3 字节(3 字节是 Attribute Opcode + Handle 开销)

下面这张图,是我每次培训必画的 Attribute Table 结构图。它把 Handle、Type、Permissions、Value 的关系讲得清清楚楚。

Attribute Table 结构图 Handle Attribute Type (UUID) Permissions Value 存储 0x0010 0x180F (Battery) Read only 静态 (Flash) 0x0011 0x2803 (Decl.) Read only 静态 (Flash) 0x0012 自定义 UUID Read + Write 动态 (RAM) 0x0013 0x2902 (CCCD) Read + Write 动态 (RAM) 关键说明: 1. Handle 连续分配,每个 Attribute 唯一 2. Type 用 UUID 标识,标准/自定义区分 3. Permissions 控制访问权限,安全关键 4. Value 存储方式影响性能和内存

4.5 实战:设计一个 Battery Service 的 Attribute Table

咱们拿 Battery Service 举个例子。标准 Battery Service 包含:

  • Primary Service Declaration (0x2800) + Battery Service UUID (0x180F)
  • Characteristic Declaration (0x2803) + Battery Level UUID (0x2A19)
  • Characteristic Value Declaration + Battery Level Value
  • Characteristic User Description Descriptor (0x2901) + "Battery Level"

对应的 Attribute Table 设计:

Handle  | Type       | Permissions      | Value
--------|------------|------------------|----------------------
0x0010  | 0x2800     | Read             | 0x180F (Battery Service)
0x0011  | 0x2803     | Read             | 0x12, 0x2A19 (Properties + Value Handle + UUID)
0x0012  | 0x2A19     | Read + Notify    | 0-100 (电池电量)
0x0013  | 0x2901     | Read             | "Battery Level"

嗯,这里要注意:Characteristic Declaration 的 Value 里包含了三个字段:Properties(1 字节)、Value Handle(2 字节)、UUID(2 或 16 字节)。这个结构很容易写错,我建议你每次写完都对照蓝牙规范检查一遍。

避坑指南:我曾经在写 Battery Service 时,把 Characteristic Declaration 的 Value Handle 写成了 0x0011(自己的 Handle),结果手机端一直读不到电池电量。正确的做法是:Value Handle 指向 Characteristic Value 的 Handle,也就是 0x0012。

最后,Value 存储的选择:

  • Service Declaration 和 Characteristic Declaration:静态存储,编译时确定
  • Battery Level Value:动态存储,因为电量会变化
  • User Description:静态存储,字符串固定

这样设计下来,你的 Attribute Table 就清晰了。调试时,用蓝牙抓包工具一看 Handle 和 UUID,就能快速定位问题。说白了,Attribute Table 就是你的蓝牙服务的“地图”,地图画好了,后面开发就顺了。