3. 属性协议与句柄机制
好,咱们进入正题。GATT 协议说到底,就是定义了一套「谁可以读、谁可以写、什么时候通知」的规则。你想想看,蓝牙设备之间要交换数据,总得有个规矩吧?GATT 就是那个规矩。
我个人习惯把 GATT 数据操作分成两大类:主动操作和被动通知。主动操作就是 Client 去读或写 Server 的属性;被动通知就是 Server 主动告诉 Client「数据变了」。这两类操作背后,都离不开 CCCD 和 MTU 这两个关键配置。
核心要点:GATT 数据操作的本质是 Attribute Protocol (ATT) 的 PDU 交换。Read、Write、Notify、Indicate 分别对应不同的 ATT Opcode。理解这些 Opcode,你就掌握了 GATT 的底层语言。
3.1 Read 操作:从 Server 拉取数据
Read 是最基础的操作。Client 发送 Read Request,Server 返回 Read Response。就这么简单?嗯,其实有几个坑。
Read 的几种变体:
- Read by Type: 按 UUID 类型读取。比如你想读所有「设备名称」特征,就用这个。我在项目中遇到过,有些芯片对 Read by Type 的实现有 bug,返回的数据长度不对,导致解析失败。
- Read Long: 当特征值长度超过 MTU-1 时,需要分多次读取。每次读完后,Server 返回一部分数据,Client 再发 Read Blob Request 继续读。
- Read Multiple: 一次读取多个特征值。这个操作要求所有特征值句柄连续,否则会报错。
我的经验: 做 Read 操作时,一定要先做 MTU 交换(后面会讲)。否则默认 MTU 只有 23 字节,减去 3 字节 ATT 头,实际有效数据只有 20 字节。你读一个 100 字节的特征,要分 5 次,效率极低。
3.2 Write 操作:向 Server 写入数据
Write 操作比 Read 复杂一些,因为涉及「要不要回复」的问题。
Write 的三种模式:
| 操作类型 | ATT Opcode | 特点 | 适用场景 |
|---|---|---|---|
| Write Request/Response | 0x12 / 0x13 | 有确认,可靠 | 关键配置、控制指令 |
| Write Command | 0x52 | 无确认,不可靠 | 大量数据、实时性要求高 |
| Prepare Write + Execute Write | 0x16 / 0x18 | 长数据写入,原子操作 | 固件升级、大文件传输 |
你可能会问:什么时候用 Write Command?什么时候用 Write Request?
说白了,Write Command 就是「发出去就不管了」,适合传感器数据上报这种丢了也无所谓的场景。Write Request 则要求 Server 必须回复,适合配置参数这种必须确认的操作。
曾经踩过的坑: 有一次做智能锁项目,我用 Write Command 下发开锁指令。结果因为射频干扰,指令丢了,用户站在门口进不去。后来改成 Write Request + 超时重试,问题解决。所以,涉及安全或控制的写入,一定要用有确认的 Write Request。
3.3 Notify 与 Indicate:Server 主动推送
这是 GATT 最实用的功能。Server 不需要 Client 轮询,数据变了直接推过来。
Notify 和 Indicate 的区别:
- Notify: Server 发 Notification,Client 不需要确认。速度快,但不可靠。
- Indicate: Server 发 Indication,Client 必须回复 Confirmation。速度慢,但可靠。
我记得有个心率监测的项目,客户要求心率数据必须 100% 到达。我一开始用 Notify,结果测试发现偶尔丢包。换成 Indicate 后,虽然吞吐量降了一半,但数据一个都没丢。这就是取舍。
性能对比: 在 MTU=247 的条件下,Notify 的理论吞吐量约 58 KB/s,Indicate 约 29 KB/s。因为 Indicate 需要等待 Confirmation,相当于每个包多了一个 RTT 的延迟。
3.4 CCCD 配置:开关通知的钥匙
CCCD(Client Characteristic Configuration Descriptor)是每个支持 Notify/Indicate 的特征的「开关」。Client 通过写入 CCCD 来告诉 Server:「我要不要收通知」。
CCCD 的值含义:
| 值 | 含义 |
|---|---|
| 0x0000 | 关闭通知和指示 |
| 0x0001 | 启用 Notify |
| 0x0002 | 启用 Indicate |
| 0x0003 | 同时启用 Notify 和 Indicate(极少用) |
这里有个细节:CCCD 是每个连接独立的。也就是说,两个手机同时连接同一个设备,可以一个开通知,一个关通知。Server 端需要为每个连接维护 CCCD 状态。
实现建议: 我习惯在 Server 端用一个数组来存储每个连接的 CCCD 值。连接断开时清空对应条目。这样既省内存,又不会出现「断连重连后还保留旧配置」的问题。
3.5 MTU 交换:提升吞吐量的第一步
MTU(Maximum Transmission Unit)决定了每个 ATT 包能携带多少数据。默认 MTU 是 23 字节,减去 3 字节 ATT 头,有效载荷只有 20 字节。
MTU 交换的过程很简单:
- Client 发送 MTU Request,带上自己支持的 MTU 大小。
- Server 回复 MTU Response,带上自己支持的 MTU 大小。
- 双方取较小值作为本次连接的 MTU。
举个例子:Client 支持 512,Server 支持 247,那么最终 MTU = 247。
关键点: MTU 交换必须在任何数据操作之前完成。我见过很多开发者先读特征值,再做 MTU 交换,结果读到的数据不全。顺序很重要。
3.6 数据吞吐量优化:从 20KB/s 到 100KB/s
好,终于到了最实战的部分。怎么把蓝牙数据传输从「龟速」变成「正常速度」?
影响吞吐量的因素:
- MTU 大小: MTU 越大,单包传输的数据越多。建议至少协商到 247。
- 连接间隔: 连接间隔越小,单位时间内能发的包越多。但太小会增加功耗。
- PDU 大小: 每个连接事件能发多少个 PDU。这取决于芯片能力和协议栈配置。
- 操作类型: Notify 比 Indicate 快,Write Command 比 Write Request 快。
优化策略:
- 先做 MTU 交换: 把 MTU 提到 247 或更高。
- 缩短连接间隔: 在功耗允许的情况下,把连接间隔设为 7.5ms 或 10ms。
- 使用 Notify 而非 Indicate: 除非数据必须可靠,否则用 Notify。
- 使用 Write Command: 写数据时,用无确认的 Command 代替有确认的 Request。
- 数据打包: 把多个小数据合并成一个大数据包发送。
注意: 缩短连接间隔会显著增加功耗。如果你的设备是电池供电,建议在需要高速传输时动态调整连接间隔,传输完成后恢复默认值。我曾经有个项目,因为一直用 7.5ms 连接间隔,电池两天就没电了。
3.7 知识体系总览
下面这张图总结了 GATT 数据操作的核心流程和关系。你可以把它当作本章的「地图」。
这张图把本章的知识点串起来了。你可以看到,主动操作和被动通知是两条主线,CCCD 和 MTU 是支撑这两条线的底层配置。优化吞吐量时,需要综合考虑所有因素。
3.8 实战建议
最后,给你几个实战中可以直接用的建议:
- 初始化顺序: 连接建立后,先做 MTU 交换,再发现服务,最后配置 CCCD。顺序错了,数据可能收不到。
- CCCD 持久化: 如果设备需要保存 CCCD 状态(比如断连重连后保持通知状态),建议写入 Flash 或 NVS。但注意写入次数限制。
- 吞吐量测试: 用 Notify 发送 1000 个包,统计总耗时,计算实际吞吐量。不要只看理论值。
- 调试工具: 我习惯用 nRF Connect 或 LightBlue 抓包分析。看 ATT 层的 PDU 交换,能快速定位问题。
一个小技巧: 如果你发现 Notify 发送速度上不去,检查一下连接间隔。很多芯片默认连接间隔是 50ms,改成 10ms 后,吞吐量能提升 5 倍。当然,功耗也会增加,需要权衡。