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 的分配规则其实很简单:
- 每个 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:是否需要用户确认
我曾经在调试一个智能门锁时,发现它的开锁指令 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 的存储方式,直接影响性能和内存占用。主要有三种方式:
- 静态存储:Value 在编译时就确定,比如设备名称、外观等。放在 Flash 里,不占 RAM。
- 动态存储:Value 在运行时变化,比如电池电量、传感器数据。放在 RAM 里,需要实时更新。
- 回调存储:Value 不实际存储,每次读写都通过回调函数处理。适合大数据量或需要实时计算的情况。
我个人推荐:能用静态就用静态,能省 RAM 就省 RAM。蓝牙协议栈本身已经够吃内存了,别再浪费在 Attribute Value 上。
Value 的长度也需要注意:
- MTU 大小决定了单次传输的最大数据量(默认 23 字节,可协商到 512+)
- 超过 MTU 的 Value 需要分片传输(Long Read/Write)
- 建议单个 Characteristic Value 不超过 MTU-3 字节(3 字节是 Attribute Opcode + Handle 开销)
下面这张图,是我每次培训必画的 Attribute Table 结构图。它把 Handle、Type、Permissions、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 字节)。这个结构很容易写错,我建议你每次写完都对照蓝牙规范检查一遍。
最后,Value 存储的选择:
- Service Declaration 和 Characteristic Declaration:静态存储,编译时确定
- Battery Level Value:动态存储,因为电量会变化
- User Description:静态存储,字符串固定
这样设计下来,你的 Attribute Table 就清晰了。调试时,用蓝牙抓包工具一看 Handle 和 UUID,就能快速定位问题。说白了,Attribute Table 就是你的蓝牙服务的“地图”,地图画好了,后面开发就顺了。