GATT/ATT 全栈解剖:从属性表到 CCCD,一张"订阅收不到数据"的终极答案
文章目录
- [GATT/ATT 全栈解剖:从属性表到 CCCD,一张"订阅收不到数据"的终极答案](#GATT/ATT 全栈解剖:从属性表到 CCCD,一张"订阅收不到数据"的终极答案)
-
- [1. 概述:App 收不到 Notify 的那天,你在查什么?](#1. 概述:App 收不到 Notify 的那天,你在查什么?)
- [2. ATT:把数据交互降维成一张"属性表"](#2. ATT:把数据交互降维成一张"属性表")
-
- [2.1 设计哲学:轻状态、扁平化、句柄寻址](#2.1 设计哲学:轻状态、扁平化、句柄寻址)
- [2.2 属性四元组:Handle / Type / Permissions / Value](#2.2 属性四元组:Handle / Type / Permissions / Value)
- [2.3 ATT 报文六大家族:一次交互的完整生命周期](#2.3 ATT 报文六大家族:一次交互的完整生命周期)
- [3. GATT:属性之上的"组织学"](#3. GATT:属性之上的"组织学")
-
- [3.1 为什么需要 GATT?ATT 裸奔的三个问题](#3.1 为什么需要 GATT?ATT 裸奔的三个问题)
- [3.2 Service / Characteristic / Descriptor 的真实属性表布局](#3.2 Service / Characteristic / Descriptor 的真实属性表布局)
- [3.3 一图看懂服务发现:客户端眼中的"表遍历"](#3.3 一图看懂服务发现:客户端眼中的"表遍历")
- [4. CCCD 深度解剖:那个被无数人写错的 0x2902](#4. CCCD 深度解剖:那个被无数人写错的 0x2902)
-
- [4.1 CCCD 的位域设计:2 个 bit 撑起整个订阅体系](#4.1 CCCD 的位域设计:2 个 bit 撑起整个订阅体系)
- [4.2 一个特性多个客户端:CCCD 的"每连接独立"设计](#4.2 一个特性多个客户端:CCCD 的"每连接独立"设计)
- [4.3 Notify 与 Indicate:可靠性与吞吐的取舍](#4.3 Notify 与 Indicate:可靠性与吞吐的取舍)
- [5. 订阅收不到数据:一条完整的故障因果链](#5. 订阅收不到数据:一条完整的故障因果链)
-
- [5.1 因果链逐环排查(7 个断点)](#5.1 因果链逐环排查(7 个断点))
- [5.2 两个最经典的翻车现场](#5.2 两个最经典的翻车现场)
- [6. 实战:Zephyr 自定义服务完整实现](#6. 实战:Zephyr 自定义服务完整实现)
-
- [6.1 服务定义与 CCCD 回调(完整代码)](#6.1 服务定义与 CCCD 回调(完整代码))
- [6.2 订阅感知与数据推送(完整代码)](#6.2 订阅感知与数据推送(完整代码))
- [6.3 工程配置 prj.conf](#6.3 工程配置 prj.conf)
- [7. 生产级话题:CCCD 持久化与重连恢复](#7. 生产级话题:CCCD 持久化与重连恢复)
-
- [7.1 规范的要求:bonded 设备的订阅必须"记住"](#7.1 规范的要求:bonded 设备的订阅必须"记住")
- [7.2 Zephyr 的实现:settings 子系统与恢复时序的坑](#7.2 Zephyr 的实现:settings 子系统与恢复时序的坑)
- [8. 避坑指南:5 个新手必踩的坑](#8. 避坑指南:5 个新手必踩的坑)
- [9. 总结](#9. 总结)
本文是 BLE 系列第十篇。上一篇 MTU 讲的是"管道有多粗",这一篇讲"管道里运的是什么、怎么运、为什么你的手机收不到"。
1. 概述:App 收不到 Notify 的那天,你在查什么?
先讲一个真实度 100% 的场景。你做了一台 BLE 设备,固件烧好,App 连上,服务发现正常,特征也找到了。App 调了 subscribe(),你甚至用 nRF Connect 手动往 CCCD 写了 0x0001------写入成功,协议栈没报错。然后设备端开始 notify(),手机端一片死寂。
你开始怀疑人生:是 MTU 没协商?是 UUID 写错?还是协议栈有 bug?
这类问题很少是"通知"本身出了毛病------大多数情况下,Notify 的发送与接收根本没问题,真正断掉的是订阅、Handle、权限、连接状态或发送资源这些前置环节 。而要理清这些环节,你需要一张关于 GATT/ATT 这一层设计逻辑 的完整认知地图。MTU 是管道粗细(见第八篇),而 ATT 是仓库的货架编号系统,GATT 是仓库的货架分区方案,CCCD 是客户端在货架上留的一张"有货喊我"的便签。便签贴错了位置、贴了但仓库断电没保存、或者仓库根本没看到你贴------数据就"消失"了。
这篇文章把这三层一次性讲透:先讲 ATT 的设计哲学,再讲 GATT 如何在属性表上搭出 Service/Characteristic,然后把 CCCD 拆到 bit 级,最后给出一条可落地的故障因果链和 Zephyr/NCS 平台的完整工程化示例代码(含通往生产级的升级路径)。
阅读约定(三层标注法) :本文严格区分三个叙事层级------【SPEC】蓝牙核心规范 层面的事实(CCCD=0x2902、0x0001=Notify 这类全世界统一的规则);【STACK】协议栈实现 层(Zephyr 的
BT_GATT_CCC、CONFIG_BT_SETTINGS这些具体实现的行为);【APP】应用/OS 层(Android/iOS API 的设计取舍)。规范的归规范,实现的归实现------后文你会看到,不少"BLE 玄学"其实是把这三层混为一谈的产物。
2. ATT:把数据交互降维成一张"属性表"
2.1 设计哲学:轻状态、扁平化、句柄寻址
要理解 GATT,必须先回到它的地基 ATT(Attribute Protocol,属性协议) 。ATT 的设计哲学可以用三个词概括:轻状态、扁平化、句柄寻址。
所谓"轻状态"要精确理解:ATT 的事务当然有过程状态(Request→Response、Indication→Confirmation、Prepare Write→Execute Write),但它把需要维护的协议状态压缩到了极低水平------一张属性表几乎就是服务器的全部状态,没有会话、没有协商好的上下文。
为什么这么设计?回忆一下 BLE 的出身------它服务的是纽扣电池供电的传感器。协议栈跑在几十 KB RAM 的芯片上,任何"连接上下文"、"会话状态"、"复杂的数据结构"都是奢侈品。于是 SIG 的架构师做了一个极其务实的选择:
服务器不需要理解数据的业务语义,它只需要维护一张带编号的表格,客户端要什么,报编号就行。
这就是整篇文章最重要的一个比喻:
ATT 服务器就是一家"无人值守的自助仓库":仓库管理员(服务器)根本不关心你存的是什么,它只保证------每个储物格有唯一房号(Handle),每个格子贴着类别标签(UUID),每个格子有门禁权限(Permissions),格子里放着东西(Value)。客户端(取货人)拿着房号来,权限对得上,东西就拿走。管理员全程不需要"理解"货物。
这个设计直接决定了 BLE 数据交互的一切特性:快、省内存、但也意味着------所有"语义"都必须靠约定,而约定稍有偏差,通信就静默失败。
比喻的边界:说服务器"不理解",指的是不理解业务语义 (0x64 是温度还是电量,它不知道);但 Type、Permission 这些"元规则"它是严格执行的------CCCD、特征声明这类属性,协议栈要按类型做特殊处理。
2.2 属性四元组:Handle / Type / Permissions / Value
ATT 服务器中的每个属性(Attribute)由四部分组成,这是理解一切的原子单位:
| 组成 | 大小 | 作用 | 仓库比喻 |
|---|---|---|---|
| Handle | 16 bit | 属性在表中的唯一索引,如 0x000B |
储物格房号 |
| Type (UUID) | 16 / 32 / 128 bit | 声明这个属性"是什么类型"(32-bit 形式在 ATT PDU 中会被转换为 128-bit 传输) | 格子上的类别标签 |
| Permissions | 若干 bit | 读/写权限,加密/认证要求 | 门禁卡级别 |
| Value | ≤512 字节 | 单个 Attribute Value 的最大长度------注意不是业务数据的总量上限,更长的数据要靠 Read Blob / Long Write 分段存取;也可放描述其他属性的元数据 | 格子里的货物 |
几个容易被忽略、但面试和生产都常考的细节:
- Handle 是"地址"不是"语义" 。典型的静态属性表里 Handle 按 1 递增,看起来连续,但那只是当前实现的巧合------不同协议栈、动态注册、服务增删都可能产生空洞。客户端永远不要用"Handle+1"去猜属性,只能通过服务发现流程(见第五篇)拿到的句柄来访问。
- Value 字段很"贪婪" :它既可以放用户数据(温度值),也可以放描述其他属性的元数据。这句话是理解下一节 GATT 的钥匙------Service 和 Characteristic 的"声明",本身就是一些 Value 里装着元数据的普通属性。
- UUID 是类型不是身份 。
0x2A37表示"这是个心率测量值",它标识类型;真正区分"第几条属性"的是 Handle。很多初学者把 UUID 当 ID 用,一多实例就翻车。
2.3 ATT 报文六大家族:一次交互的完整生命周期
ATT 的 PDU 可以按"是否需要回应 + 通信方向"归纳为六类交互模型------注意这是交互类型的分类,每一类下面还包含多个具体的 Opcode(仅 Read 系就有 Read Request、Read Blob Request、Read By Type Request 等一大家子)。这六类构成了一张完整的生命周期图:
| 家族 | 方向 | 是否要回应 | 典型例子 |
|---|---|---|---|
| Request | Client → Server | ✅ 必须 Response | Read Request、Write Request |
| Response | Server → Client | --- | 对应 Request 的应答 |
| Command | Client → Server | ❌ 不需要 | Write Command(写完就走) |
| Notification | Server → Client | ❌ 不需要 | Handle Value Notification |
| Indication | Server → Client | ✅ 必须 Confirmation | Handle Value Indication |
| Confirmation | Client → Server | --- | Indication 的回执 |
用代码视角看,每个 ATT PDU 的第一个字节就是 Opcode:高 2 bit 携带 Command / 认证签名两个标志,低 6 bit 是方法编号(Method):
c
/* ATT Opcode 结构:bit5..0 = 方法编号,bit6 = Command 标志,bit7 = 认证签名标志
* 规范定义(Core Spec Vol 3, Part F, 3.4.8)【SPEC】:
* Method = Opcode & 0x3F (操作类型)
* Command = Opcode & 0x40 (bit6=1 → 不需要回应的 Command)
* Auth Sig = Opcode & 0x80 (bit7=1 → 带认证签名)
* 所以同一个"写"操作有三种 Opcode:
* 0x12 = Write Request (Method 0x12,bit6=0 → 要 Response)
* 0x52 = Write Command (0x12 | 0x40 → 不要 Response,写完就走)
* 0xD2 = Signed Write Command (0x52 | 0x80 → 认证签名的写命令)
*/
#define ATT_OP_WRITE_REQ 0x12 /* 客户端写,要回执:可靠性优先 */
#define ATT_OP_WRITE_CMD 0x52 /* 客户端写,不要回执:速度优先 */
#define ATT_OP_SIGNED_WRITE_CMD 0xD2 /* 认证签名的写命令(较罕见) */
#define ATT_OP_NOTIFY 0x1B /* 服务器推送,不要确认 */
#define ATT_OP_INDICATE 0x1D /* 服务器推送,要确认 */
#define ATT_OP_CONFIRM 0x1E /* Indication 的回执 */
一个实用的抓包口诀 :在 Wireshark 里看到 0x12(Write Request)或 0x52(Write Command)写到 CCCD → 这是订阅;看到 0x1B 从设备流出 → 这是 Notify;0xD2 是 Signed Write Command------用 ATT 签名机制在未加密的连接上提供写入的认证性,现代应用里较少见(通常直接升级为加密连接更省事)。
这解释了一个吞吐设计的经典结论:Notification 的 ATT 开销只有 3 字节(1 字节 Opcode + 2 字节 Handle),这就是第八篇里"默认 MTU=23 时有效载荷 20 字节"的由来------23 − 3 = 20。
还有一个高频面试题藏在表里:为什么 Indication 发得慢,Notify 却可以连发? 准确的答案是:同一时刻只允许存在一条"未确认"(outstanding)的 Indication【SPEC】------服务器发出一条 Indication 后必须停下来等客户端的 Confirmation,收到后才能发下一条,这是协议规定的停等串行约束(stop-and-wait);而 Notification 没有这个约束,在发送缓冲允许的范围内可以连续排队。至于一条 Indication 实际耗时多久,取决于连接参数、链路层调度和控制器实现,并不严格等于"一个连接间隔一条"------很多资料(包括 Nordic 官方文档)那么说是工程近似,严谨的心智模型是"一条 outstanding Indication"。
3. GATT:属性之上的"组织学"
3.1 为什么需要 GATT?ATT 裸奔的三个问题
ATT 只说"我有一张表,你拿编号访问",但如果全世界的设备都这么裸奔,会有三个致命问题:
- 手机怎么知道你这张表里哪一格是"电量"、哪一格是"心率"? 没有约定,每个厂商一套私货,互操作性归零。
- 表的结构没有任何章法,服务发现算法无从优化,遍历成本失控。
- 权限粒度太粗,ATT 只知道"这个格子要加密",但"这是特征值还是描述符"它完全不关心。
GATT 就是打在 ATT 这张平表上的一套"组织架构图" 。注意,GATT 没有引入任何新的传输机制,它在 ATT 之上规定了两件事:一是属性数据库的组织结构 (Service/Characteristic/Descriptor 如何布局),二是一组标准操作语义(发现、读、写、订阅等 GATT Procedures)【SPEC】。结构层面的约定只有三个角色:
- Service(服务) :用 Type=
0x2800(Primary)或0x2801(Secondary)的属性"声明",其 Value 就是服务 UUID。它是相关特征的分组。 - Characteristic(特征) :用 Type=
0x2803的属性"声明",其 Value 里打包了三样东西------属性权限 + 特征值 Handle + 特征值 UUID;紧接着的那个属性才是特征值本体。 - Descriptor(描述符) :特征值之后附加的属性,补充元数据。其中最著名的就是本文主角 CCCD(
0x2902)。
3.2 Service / Characteristic / Descriptor 的真实属性表布局
把上面的约定落到纸上,一张真实设备的属性表长这样(以一个标准电池服务 + 一个自定义透传服务为例):
| Handle | Type (UUID) | Value 内容 | 角色 |
|---|---|---|---|
| 0x0001 | 0x2800 | 0x180F (Battery Service) | 服务声明 |
| 0x0002 | 0x2803 | 权限+Handle=0x0003+UUID 0x2A19 | 特征声明 |
| 0x0003 | 0x2A19 | 0x64 (=100%) | 特征值:电量 |
| 0x0004 | 0x2902 | 0x0000 | CCCD(订阅开关) |
| 0x0005 | 0x2800 | 0x9F1F...(自定义 128-bit) | 服务声明 |
| 0x0006 | 0x2803 | 权限+Handle=0x0007+UUID ...01 | 特征声明 |
| 0x0007 | ...01 | --- | 特征值:透传 RX |
| 0x0008 | 0x2803 | 权限+Handle=0x0009+UUID ...02 | 特征声明 |
| 0x0009 | ...02 | --- | 特征值:透传 TX(Notify) |
| 0x000A | 0x2902 | 0x0000 | CCCD(订阅开关) |

这张表请你截图保存 ,因为第 5 节的故障排查,全部建立在对这张表的空间想象力之上。注意两个细节:在上面的布局里,CCCD(0x0004)通常紧随 特征值(0x0003)之后------但这是静态属性表的常见实现布局,不是协议承诺,客户端应通过描述符发现流程拿到 CCCD Handle,别依赖"CCCD = 值 Handle + 1"的巧合【SPEC vs STACK】;bt_gatt_notify() 要传的正是特征值 Handle,而不是 CCCD Handle------搞混这两个 Handle 是新手第一大事故源。
3.3 一图看懂服务发现:客户端眼中的"表遍历"
客户端(手机)连上后做的第一件事是服务发现:从 0x0001 开始,用 Read By Group Type Request 找所有 0x2800,再对每个服务用 Read By Type Request 找 0x2803,解析出特征值 Handle,再顺着读特征值后面的描述符。你代码里注册服务的顺序,直接决定了手机发现的顺序和 Handle 分配 ------这也是为什么 Zephyr 的 BT_GATT_SERVICE_DEFINE 宏必须按"服务声明 → 特征声明 → 特征值 → CCCD"的顺序写,乱序虽然能编译,但会把手机端的发现逻辑带进沟里。

4. CCCD 深度解剖:那个被无数人写错的 0x2902
4.1 CCCD 的位域设计:2 个 bit 撑起整个订阅体系
CCCD(Client Characteristic Configuration Descriptor)是本系列目前出场率最高、翻车率也最高的一个属性。它的物理形态小得可怜:
- UUID 固定为
0x2902,SIG 统一分配; - Value 长度固定 2 字节,但真正有意义的只有低 2 位;
- 必须同时可读、可写------客户端要写它来订阅,也要能读回来确认当前状态。
位域如下:
| bit | 含义 | 客户端写入值 |
|---|---|---|
| bit0 | Notify 使能 | 0x0001 |
| bit1 | Indicate 使能 | 0x0002 |
| bit0+bit1 | 同时开启 | 0x0003 |
| --- | 全关(退订) | 0x0000 |

到这里可以给订阅下一个精确的定义了:从协议层看,订阅的核心动作就是客户端通过 ATT 往服务端某个 CCCD 属性写一个 2 字节的小整数 ------没有魔法,没有会话,没有注册表。但要说"仅此而已"并不完整【APP】:在完整的 GATT 客户端实现里,订阅还包含客户端本地的一整套状态------value handle、CCCD handle、通知回调、订阅对象,乃至重连后的自动重订阅策略(Zephyr 的 bt_gatt_subscribe_params 就同时持有这些字段)。"CCCD 已写入"不等于"客户端订阅状态机就绪",排错时这两层状态都要看。
理解了这一点,很多玄学瞬间变成常识:
- 服务端代码里那个
ccc_cfg_changed回调,本质是协议栈发现"有个客户端写了我管的 CCCD 属性"后顺手通知你一声; - App 端
subscribe()封装得再花哨,底层也只是拼了一个写 CCCD 的 ATT 写操作(标准做法是 Write Request0x12;是否采用 Write Command 取决于各端实现【STACK】),Payload 就 3 字节(Opcode + CCCD Handle + 0x0001); - 如果 App 没写 CCCD,服务端再怎么 notify 都不会发 ------因为协议栈检查 CCCD 值为 0,直接把通知吞掉(老 Nordic SoftDevice 返回
NRF_ERROR_INVALID_STATE,Zephyr 的bt_gatt_notify()则静默跳过未订阅连接)。
4.2 一个特性多个客户端:CCCD 的"每连接独立"设计
CCCD 全名里的 Client Characteristic Configuration 点明了它的语义:这是每个客户端独立一份 的配置。手机 A 订阅了你的传感器,手机 B 没订阅,那么协议栈内部要为同一个 CCCD 属性维护两份值------A 的连接里是 0x0001,B 的连接里是 0x0000。
这个设计决策影响深远:
- 协议栈必须按连接(Connection)而非全局来存储 CCCD 状态,内存开销随并发中心数增长;
- 连接断开后,当前连接实例的运行时 CCCD 状态随之结束。对未绑定设备,重连后一切归零,必须重新订阅;对绑定设备,服务器可以按 System Attributes 机制恢复之前的配置(是否真正落盘、何时落盘,取决于协议栈实现,详见第 7 节)【SPEC vs STACK】;
- 绑定(Bonded)设备则要复杂得多------对 bonded client,服务器应能在后续连接中恢复该客户端的 GATT 配置(含 CCC);至于用什么介质保存、何时保存,属于协议栈实现细节【SPEC vs STACK】。Zephyr 用 settings 子系统实现,并在重连加密后自动恢复。这个"自动恢复"正是第 7 节那个著名时序坑的根源。
4.3 Notify 与 Indicate:可靠性与吞吐的取舍
| 维度 | Notification | Indication |
|---|---|---|
| ATT Opcode | 0x1B | 0x1D |
| 客户端回执 | 不需要 | 必须 Confirmation(0x1E) |
| 链路层重传兜底 | ✅ 有(连接态) | ✅ 有 |
| 应用层确认 | ❌ | ✅ |
| 单连接间隔可发数 | 多个(受 MTU/缓冲限制) | 同时只允许 1 条未确认 |
| 典型场景 | 传感器流数据、透传 | 关键状态变更(如 Service Changed) |
记住一个工程结论:高吞吐场景(OTA、透传、波形流)永远选 Notify;只有"丢一条就出事故"的稀疏事件才用 Indicate 。两者都开(0x0003)通常没有意义,反而白白占用一条宝贵的停等指示通道。
再补一个容易被忽略的可靠性边界:链路层的 ACK/重传只保证"包可靠交付到对方协议栈",不构成应用层的端到端确认。Notification 进入协议栈发送队列 ≠ 对端 App 一定收到,更不等于对端处理成功。所以别把 Notify 当成应用层的可靠消息机制------需要应用层可靠性时,要么用 Indication,要么在业务协议里自己做序号 + 应答。
5. 订阅收不到数据:一条完整的故障因果链
有了前四节的地图,现在可以把"手机收不到 Notify"这个 BLE 世界最高频的灵异事件,拆解成一条逐环可查 的因果链。一帧 Notify 要想到达手机,必须满足以下全部条件,任何一环断掉,现象都是"静默无数据":
5.1 因果链逐环排查(7 个断点)

断点①:App 根本没写 CCCD。 最隐蔽的一种:很多 App 框架(如某些封装库)的 subscribe() 只注册了本地监听,没有真正发 Write 0x0001 (Android 的 setCharacteristicNotification(true) 就是这种假订阅------它只开本地接收开关,你必须再对描述符 writeDescriptor())。服务端回调没触发,就是这一环。
断点②:CCCD 写了,但服务端写回调没拦住。 如果你在 Zephyr 的 BT_GATT_CCC 里给了自定义回调却没注意返回值,或在 Nordic 老架构里 on_write 事件判断错了 Handle(拿成特征值 Handle),订阅状态实际没生效。
断点③:notify 的 Handle 指错了。 回忆 3.2 的属性表:bt_gatt_notify() 要传特征值 Handle (0x0009),不是 CCCD Handle(0x000A),更不是特征声明 Handle(0x0008)。传错后 Zephyr 会返回 -EINVAL 或静默失败,老 SoftDevice 返回 BLE_ERROR_GATTS_INVALID_ATTR_TYPE。签名 attrs 数组下标也是重灾区 ------&attrs[N] 里的 N 是属性表全局下标,不是服务内相对下标。
断点④:权限与安全等级不匹配。 CCCD 的写权限若设了加密要求(如 BT_GATT_PERM_WRITE_ENCRYPT),而连接还是明文,手机写 CCCD 会被协议栈直接拒绝(Insufficient Authentication)。现象是"订阅调用报错"或"写了但没生效"。这与第六篇 SMP直接相关:先提升安全等级,再订阅。
断点⑤:重连后 CCCD 归零。 未绑定设备断线重连,CCCD 状态随连接一起消失,App 若只"连上就等数据"而不重新订阅,自然一片死寂。这是产品化后"偶发收不到数据"工单的最大来源(详细机制见第 7 节)。
断点⑥:MTU 或发送缓冲问题。 通知包超过当前 ATT_MTU − 3 字节,或协议栈发送缓冲(tx buffer)耗尽,notify 会失败。注意区分两个信号【STACK】:这些 API 的返回值 告诉你"是否成功入队"(Zephyr 返回负值即失败,如 -ENOMEM 缓冲耗尽);而 bt_gatt_notify_cb() 的完成回调没有错误参数 ,它只表示发送已按协议栈定义的完成时机处理完毕(可感知数据传到空口的时机),不等于手机收到了。永远检查返回值,别当 void 用。
断点⑥:手机端的 GATT 缓存过期。 这是最隐蔽的一环:手机会缓存你的属性表布局(Handle、UUID、服务结构)。固件 OTA 之后如果属性表变了而没走 Service Changed 流程,App 拿着旧缓存去订阅------轻则订阅到错位的旧 Handle,重则写入已不存在的 Handle 直接失败。CCCD 写"成功"和缓存"正确"是两回事,这也是 7.2 末尾 Service Changed / GATT Caching 话题存在的原因。
5.2 两个最经典的翻车现场
现场一:Android------本地通知注册 ≠ 远端 CCCD 订阅。 很多教程把这两个 API 混为一谈,但 Android 是有意把职责拆开的【APP】:setCharacteristicNotification(char, true) 本来就不是"订阅",它的职责是注册本地通知接收 (打开手机 GATT 客户端内部的通知通道),不产生任何空中包;真正修改远端 CCCD 的是另一步 gatt.writeDescriptor(cccdDescriptor, 0x0001)。漏掉第二步,App 就"自以为订阅了"。用抓包(后续抓包篇详谈)一眼就能识别:空中没有 0x12/0x52 写 CCCD 的包,服务端回调永远不会触发。
现场二:iOS 的订阅时机。 iOS CoreBluetooth 的 setNotifyValue:YES 会自动写 CCCD,但它要求服务发现完成之后 才能调。如果你的外设在发现流程还没走完时就开始 notify,或 App 在 didDiscoverCharacteristicsFor 之前订阅,都会静默失败。另外 iOS 对 CCCD 写入有严格的"必须加密"场景(如苹果 ANCS),权限配置不当会连订阅都发不出去。
6. 实战:Zephyr 自定义服务完整实现
理论讲完,上完整可跑的工程化示例代码(设计按生产思路组织,但保留了演示级的简化假设,升级路径在 6.2 末尾讨论)。场景:一台 nRF52/nRF54 传感器设备,暴露一个自定义环境服务,含可读写的配置特征 和可订阅的传感器数据特征(Notify)。
6.1 服务定义与 CCCD 回调(完整代码)
c
/* my_lbs.c ------ 自定义 GATT 服务:Light & Sensor Service */
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/uuid.h>
LOG_MODULE_REGISTER(my_lbs, LOG_LEVEL_INF);
/* ---- 订阅状态标志:由 CCCD 回调维护 ---- */
/* 注意:这只是聚合状态的"最近一次记录",发送前仍应用
* bt_gatt_is_subscribed() 查询协议栈维护的订阅状态 ------
* 它按连接/对端核对协议栈内保存的 CCC 配置 */
static volatile bool sensor_notify_enabled;
/* ---- 自定义 128-bit UUID(基准 UUID + 16-bit 短码,见第九篇 UUID 的带宽战争)---- */
#define BT_UUID_LBS_SVC BT_UUID_128_ENCODE(0x9f1f0001, 0x1cc4, 0xe7c1, 0xc757, 0xf1267dd021e8)
#define BT_UUID_SENSOR_CHRC BT_UUID_128_ENCODE(0x9f1f0002, 0x1cc4, 0xe7c1, 0xc757, 0xf1267dd021e8)
#define BT_UUID_LED_CHRC BT_UUID_128_ENCODE(0x9f1f0003, 0x1cc4, 0xe7c1, 0xc757, 0xf1267dd021e8)
/* ---- 特征值存储区 ---- */
static uint8_t led_state; /* LED 开关,App 可写 */
static uint16_t sensor_value = 25; /* 传感器读数,App 订阅后接收 */
/* ---- CCCD 回调:客户端写 0x2902 属性时被协议栈调用 ---- */
static void ccc_sensor_cfg_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
bool new_state = (value == BT_GATT_CCC_NOTIFY); /* 只认 0x0001:开启 Notify */
LOG_INF("CCCD written: 0x%04x -> notify %s", value, new_state ? "ON" : "OFF");
sensor_notify_enabled = new_state;
/* 【STACK】多连接语义:value 是所有已连接 peer 的聚合订阅状态
* (等效于"是否至少还有一个订阅者"),不是"某个具体连接写了什么";
* 由断连触发的回调,也只发生在聚合状态变化时。
* → 适合做"有无订阅者"的资源启停判断(如采样任务的开关);
* → 要知道具体哪个 peer 订阅,用 bt_gatt_is_subscribed() 按连接查 */
}
/* ---- LED 特征写回调:App 写入开关状态 ---- */
static ssize_t write_led(struct bt_conn *conn, const struct bt_gatt_attr *attr,
const void *buf, uint16_t len, uint16_t offset, uint8_t flags)
{
if (offset + len > sizeof(led_state)) { /* 长度校验:防越界写 */
return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN);
}
if (len == 0) { /* 空写拒绝 */
return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN);
}
memcpy(&led_state, buf, len);
LOG_INF("LED set to %u", led_state);
return len; /* 返回接收长度,协议栈回 Write Response */
}
/* ---- LED 特征读回调 ---- */
static ssize_t read_led(struct bt_conn *conn, const struct bt_gatt_attr *attr,
void *buf, uint16_t len, uint16_t offset)
{
return bt_gatt_attr_read(conn, attr, buf, len, offset, &led_state,
sizeof(led_state));
}
/* ---- 服务定义:宏展开为静态属性表,顺序就是 Handle 分配顺序 ---- */
BT_GATT_SERVICE_DEFINE(my_lbs_svc,
BT_GATT_PRIMARY_SERVICE(BT_UUID_DECLARE_128(BT_UUID_LBS_SVC)),
/* 特征1:LED 控制(读+写),无 CCCD ------ 客户端主动拉取,无需订阅 */
BT_GATT_CHARACTERISTIC(BT_UUID_DECLARE_128(BT_UUID_LED_CHRC),
BT_GATT_CHRC_READ | BT_GATT_CHRC_WRITE,
BT_GATT_PERM_READ | BT_GATT_PERM_WRITE,
read_led, write_led, &led_state),
/* 特征2:传感器数据(Notify)------ 必须紧跟 BT_GATT_CCC!
* CCCD 宏展开的属性紧随特征值之后(本例布局中 Handle = 特征值 + 1,
* 但这是实现布局的巧合,客户端永远要通过发现流程获取 Handle) */
BT_GATT_CHARACTERISTIC(BT_UUID_DECLARE_128(BT_UUID_SENSOR_CHRC),
BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_READ, /* 特征值本身只读 */
NULL, NULL, &sensor_value),
BT_GATT_CCC(ccc_sensor_cfg_changed,
BT_GATT_PERM_READ | BT_GATT_PERM_WRITE), /* CCCD 必须可读可写 */
);
/* ---- 对外暴露"传感器特征值"属性指针 ----
* 为什么不直接 extern 属性数组?因为 BT_GATT_SERVICE_DEFINE 宏生成的
* 数组符号是带 attr_ 前缀的内部命名(本例为 attr_my_lbs_svc),跨模块
* 直接依赖这种宏生成符号极其脆弱(宏实现一变、服务一挪就编译失败)。
* 正确姿势:在本文件内封装访问接口,只对外暴露稳定的函数符号 */
const struct bt_gatt_attr *my_lbs_sensor_value_attr(void)
{
/* my_lbs_svc 是宏生成的服务结构体,attrs 成员指向属性表:
* [0]服务声明 [1]LED声明 [2]LED值 [3]SENSOR声明 [4]SENSOR值 [5]CCCD
* notify 的目标是 attrs[4](特征值),不是 attrs[5](CCCD)!
* Nordic 官方 LBS 示例用的正是这种 &xxx_svc.attrs[n] 访问方式 */
return &my_lbs_svc.attrs[4];
}
四处"生产级细节"请重点看注释:写回调的长度/偏移校验 (不校验就是缓冲区溢出漏洞)、CCCD 紧随特征值的布局与"notify 用特征值属性"的关系 (下标知识只留在本文件里)、getter 封装 (不跨模块依赖宏生成的内部符号)、回调里不做重活------Zephyr 文档明确警告:read/write 回调运行在 RX 线程,阻塞会拖死整个 ATT 收发。
6.2 订阅感知与数据推送(完整代码)
c
/* sensor_push.c ------ 传感器数据推送:工程化示例写法 */
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/conn.h>
LOG_MODULE_REGISTER(sensor_push, LOG_LEVEL_DBG);
/* 服务模块(6.1)暴露的稳定接口:拿到"传感器特征值"的属性指针。
* 跨模块依赖宏生成的数组符号(attr_my_lbs_svc)或裸下标是脆弱设计,
* 所以用 getter 解耦 ------ 下标知识只留在服务所在的 .c 文件里 */
extern const struct bt_gatt_attr *my_lbs_sensor_value_attr(void);
/* 发送完成回调【STACK】:表示这条通知的发送已按协议栈定义的完成时机
* 处理完毕(可用于获知数据传到空口的时机);不能解读为"手机 App
* 已收到并处理"------ 它上面还隔着对端协议栈与 App 回调好几层状态机。
* 可用于发送侧流控,不可用于应用层可靠性判断 */
static void notify_completed(struct bt_conn *conn, void *user_data)
{
ARG_UNUSED(conn);
uint16_t seq = (uint16_t)(uintptr_t)user_data; /* 回调无错误参数,
只用 user_data 回传序号做日志对账 */
LOG_DBG("notify #%u flushed", seq);
}
/* 推送一帧传感器数据:conn 为 NULL 时向所有满足 CCC 订阅条件的
* 已连接 peer 推送(注意:这是 GATT 多连接推送语义,
* 不是 BLE 广播/Advertising 意义上的 Broadcast)。
* 关键设计①:params 用局部变量------多连接/多发送上下文共享全局
* params 会有数据竞争,生产代码禁止;
* 关键设计②:载荷用 static 缓冲------Zephyr 不拷贝 data 指针,
* 栈变量会在发送完成前出作用域,变成野指针!
* 注意:static 载荷仍假设"推送速率远低于空口速率"(上一帧已发完
* 才来下一帧);高速率/多数据源场景请升级为 k_msgq 发送队列 +
* 每帧独立缓冲 + 专用 TX worker,本例从简 */
int sensor_push(struct bt_conn *conn, uint16_t value)
{
static uint16_t le_val; /* static:生命周期覆盖整个发送过程 */
static uint16_t seq; /* 单调递增序号,便于与空口抓包对账 */
int err;
le_val = sys_cpu_to_le16(value); /* ATT 载荷统一小端 */
seq++;
struct bt_gatt_notify_params params = {
.attr = my_lbs_sensor_value_attr(), /* 目标:特征值属性(来自 getter) */
.data = &le_val,
.len = sizeof(le_val),
.func = notify_completed, /* 感知"已发出",可做发送侧流控 */
.user_data = (void *)(uintptr_t)seq,
};
if (conn) {
/* 单连接推送:先查询协议栈维护的订阅状态 ------
* 它按连接/对端核对协议栈内保存的 CCC 配置,天然处理
* "订阅过但已断开""重连未恢复"等边界情况 */
if (!bt_gatt_is_subscribed(conn, params.attr, BT_GATT_CCC_NOTIFY)) {
return -ENOTCONN; /* 未订阅:直接拒绝,别浪费空口 */
}
err = bt_gatt_notify_cb(conn, ¶ms);
} else {
/* 多连接场景:NULL 表示向所有订阅了该特征的连接推送,
* 协议栈自动跳过未订阅者,无需自己遍历 */
err = bt_gatt_notify_cb(NULL, ¶ms);
}
/* 返回值只说明"是否成功入队"(-ENOMEM = 发送缓冲耗尽等),
* 真正的空口完成时机由 notify_completed 回调告知 */
return err;
}
/* ---- 采集与推送调度 ---- */
static void sensor_work_handler(struct k_work *work);
static K_WORK_DEFINE(sensor_work, sensor_work_handler);
static void sensor_timer_handler(struct k_timer *timer);
static K_TIMER_DEFINE(sensor_timer, sensor_timer_handler, NULL);
/* 传感器抽象:真实项目在这里替换为 I2C/SPI 实际读取,
* 本例用自增模拟采样值(0,1,2,3...),与发送逻辑解耦 */
static uint16_t sensor_read(void)
{
static uint16_t simulated;
return simulated++;
}
static void sensor_timer_handler(struct k_timer *timer)
{
k_work_submit(&sensor_work); /* 定时器上下文只投递,不直接发 */
}
static void sensor_work_handler(struct k_work *work)
{
ARG_UNUSED(work);
uint16_t sample = sensor_read(); /* 读传感器 */
int err = sensor_push(NULL, sample); /* 推送给所有订阅者 */
if (err && err != -ENOTCONN) {
/* -ENOMEM 等常见于发送缓冲耗尽:说明连接间隔内发送量
* 超过带宽,应加大采样间隔、降低数据率或加大缓冲 */
LOG_WRN("notify enqueue failed: %d", err);
}
}
void sensor_push_start(void)
{
k_timer_start(&sensor_timer, K_SECONDS(1), K_SECONDS(1));
}
这段代码里藏了四个值得展开的取舍:bt_gatt_notify_cb(NULL, ...) 的多连接推送语义 (传 NULL 时协议栈替你筛选满足订阅条件的 peer------注意这是 GATT 推送,不是 BLE 广播意义上的 Broadcast);bt_gatt_is_subscribed() 查询的是协议栈维护的、按连接/对端保存的 CCC 配置状态 ------本质上,CCCD 状态不是一个全局 bool,而是 (连接/对端, 特征, 配置值) 的三元关系 ,这正是它比应用层标志位可靠的原因;bt_gatt_notify_cb() 的完成回调只表示发送已按协议栈定义的完成时机处理完毕 (可获知数据传到空口的时机),可做发送侧流控,但与"手机 App 已收到并处理"隔着好几层状态机;以及载荷缓冲的生命周期管理 ------Zephyr 不拷贝 data 指针,栈上缓冲会变野指针,本例用 static 缓冲加低速率假设,更严谨的架构是"消息队列 + 每帧独立缓冲 + 专用 TX worker"。
6.3 工程配置 prj.conf
ini
# ---- BLE 基础 ----
CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y # 外设角色:GATT Server
CONFIG_BT_DEVICE_NAME="LBS-DEMO"
# ---- GATT 相关 ----
CONFIG_BT_GATT_CLIENT=n # 纯外设可关客户端省内存
CONFIG_BT_ATT_TX_BUFFER_COUNT=6 # Notify 缓冲数,吞吐场景适当加大
CONFIG_BT_ATT_PREPARE_COUNT=2 # Queued Write 支持(App 长包写入)
# ---- 订阅持久化(第 7 节主题:支持 bonded 设备重连恢复订阅)----
CONFIG_SETTINGS=y
CONFIG_BT_SETTINGS=y
CONFIG_FLASH=y
CONFIG_FLASH_PAGE_LAYOUT=y
CONFIG_FLASH_MAP=y
CONFIG_NVS=y # nRF54L 系列改用 CONFIG_ZMS=y
7. 生产级话题:CCCD 持久化与重连恢复
7.1 规范的要求:bonded 设备的订阅必须"记住"
第 4.2 节说过,CCCD 状态是每连接独立的,断开后当前连接实例的运行时状态即告结束。但蓝牙核心规范同时规定【SPEC】:对于需要跨连接保持客户端配置的 bonded 场景,GATT 服务器要按 System Attributes 机制保存并恢复 CCCD 值。注意区分层级:这是规范层面的要求;至于这些值是否真正落盘、何时落盘,属于各协议栈的实现细节【STACK】------规范关心"恢复后行为正确",实现决定"用 flash 还是用别的办法"。
设计意图很现实:设想一个电池供电的温湿度计,手机 App 订阅了数据,然后手机离开范围断连。半小时后手机回来重连------如果订阅丢了,用户必须手动刷新一次 App 才能恢复数据,这种产品会被打一星。规范用"服务器负责记住订阅"解决了这个问题:重连、加密,一切如初。
7.2 Zephyr 的实现:settings 子系统与恢复时序的坑
Zephyr 把这件事交给 settings 子系统:CONFIG_BT_SETTINGS=y 后,协议栈会在绑定建立、CCCD 变化等时机把数据写入 NVS/ZMS 分区,应用必须负责在 bt_enable() 之后调用 settings_load() 完成加载。这里有两个真实生产事故级别的坑:
坑一:忘了 settings_load(),订阅和绑定全军覆没。 Kconfig 开了不等于生效,Zephyr 要求应用显式调用【STACK】:
c
/* main.c ------ 顺序错了就是玄学 bug */
int main(void)
{
int err = bt_enable(NULL);
if (err) { return err; }
/* 必须在 bt_enable() 成功后调用【STACK】:
* 加载所有已注册 settings handler 的持久化数据(含蓝牙的
* 绑定密钥与 CCCD 记录)。两个附加要求:
* ① 需要恢复 CCC 的 GATT 服务必须在此之前完成注册,
* 否则恢复时找不到归属服务,CCCD 记录会被丢弃;
* ② 漏掉这个调用 → 重启后 bonded 设备"忘记"订阅 */
settings_load();
sensor_push_start();
return 0;
}
坑二:服务端"已订阅"和客户端"已准备好"是两个不同的状态机。 这是 Nordic DevZone 上的一个经典案例:bonded 设备重连、链路加密完成的瞬间,Zephyr 会自动从 flash 恢复 CCCD 值 ------服务端的状态机是"连接 → 加密 → 恢复 CCC → 允许 notify";而中心端有自己独立的状态机:"连接 → 服务发现 → 注册本地通知回调(bt_gatt_subscribe)"。两个状态机并行推进、互不等待 ,服务端恢复 CCC 的时刻完全可能早于中心端注册回调。此时若在 CCCD 回调里"订阅一开启就立刻发欢迎数据",中心端 App 的本地回调还没就绪,这一帧在应用层直接被丢弃,而且每次重连都必现 。这是一个值得升华的结论:"服务器认为客户端已订阅" ≠ "客户端 App 已准备好接收通知"------race 发生在两层状态机之间,而不是任何一层的内部。
解法有三种,按工程推荐度排序:订阅恢复后延迟一小段时间再发首帧 (如 Nordic 桌面键盘用的 CONFIG_DESKTOP_HIDS_FIRST_REPORT_DELAY = 1000ms);把"欢迎帧"改成可读特征值 让中心端发现服务后主动来读;或依赖中心端完成服务发现后再触发数据流。

顺带一提,这里要区分两个相关但不同的问题:**CCCD 持久化(bonded 客户端配置的恢复)**解决的是"重连后订阅还在不在";而 Service Changed 特征 + GATT Caching(Database Hash) 解决的是"客户端缓存的属性数据库还正不正确"。当你的固件升级后数据库发生了变化(新增/删除服务或特征、Handle 与结构改变),就要靠 Service Changed 通知手机刷新缓存的表------这正是第六篇提过的 Service Changed Indication 的职责,也是 DFU/OTA 篇(规划中的第十四篇)会展开的前置知识。
8. 避坑指南:5 个新手必踩的坑
坑 1:notify 传错 Handle。 最常见是把 CCCD Handle 或特征声明 Handle 传给了 bt_gatt_notify()。自检方法:打印 attrs 数组,确认你传的下标对应 Type 为特征值 UUID 的那一项,而不是 0x2902 或 0x2803。
坑 2:read/write 回调里做重活。 Zephyr 的 ATT 回调运行在 RX 线程,在里面 k_sleep、刷 flash、等信号量,会阻塞整个协议栈收发,引发 supervision timeout 断连。回调里只做"取数据、置标志",重活丢 workqueue。
坑 3:CCCD 权限与加密等级打架。 CCCD 设了 BT_GATT_PERM_WRITE_ENCRYPT 但 App 在配对加密前就订阅,写入会被拒。要么先触发安全等级提升再订阅(外设端用 bt_conn_set_security()),要么想清楚哪些特征需要加密保护(参考第六篇的安全等级矩阵)。
坑 4:把 subscribe() 的 App 端 API 当成空中订阅。 Android 的 setCharacteristicNotification(true) 不发任何空口包;必须 writeDescriptor() 写 0x0001。排查时抓一个包(Write Request 到 CCCD Handle)就能定位。
坑 5:重启后"失忆"。 开了 CONFIG_BT_SETTINGS 却忘调 settings_load(),或者 flash 分区表里没有 storage 分区------重启后绑定和订阅全丢,用户感知是"设备每次断电都要重新配对"。自检:启动日志里搜 bt_settings 的加载记录,并用 settings shell 命令(若开了 CONFIG_SETTINGS_SHELL)直接查看已存条目。
9. 总结
一句话心法:GATT/ATT 的一切交互,都是"对一张带编号的表做读写";从协议层看,订阅就是往一个叫 CCCD 的表项里写了个 2 字节整数------但要记住,客户端本地还有一套订阅状态机,服务器端还有一份按连接保存的 CCC 配置。 把属性表的空间结构(3.2 节那张表)和 CCCD 的生命周期(写入 → 每连接独立 → 断开结束运行时状态 → bonded 走恢复)装进脑子,大量常见的"收不到数据"问题都能在抓包之前,通过 CCCD 状态、Handle、权限、连接状态和发送返回值定位。
觉得有用的话:
- 👍 点赞是对系列更新的最大鼓励,你的点赞决定我摸鱼还是爆肝下一篇;
- ⭐ 收藏本文,调试 GATT 订阅问题时回来对照 5.1 的因果链,效率翻倍;
- 💬 评论区聊聊:你踩过的最离谱的 BLE 订阅坑是什么?是被 Android 假订阅坑过,还是被 CCCD 持久化时序搞疯?下期我可以在文章里翻牌几个典型案例;
- 🔔 关注我,BLE 系列持续更新中,第十一篇 PHY 实测见。
相关推荐(本系列前作):
(六)BLE安全指南:别让"配对降级"和硬件I/O毁了安全等级(BLE SMP)
(八)BLE MTU 全栈解析:从 20 字节瓶颈到 160KB/s(九)一文吃透 BLE:从低功耗原理到协议栈与实战概念
参考资料:
- Bluetooth Core Specification, Vol 3 (Host), Part F (ATT) & Part G (GATT)
- Nordic Developer Academy --- Bluetooth LE Fundamentals, Lesson 4: Services and Characteristics
- Zephyr Project --- Bluetooth GATT API Reference
- Nordic DevZone --- BLE subscriptions for bonded devices
- Memfault Interrupt --- Bluetooth Low Energy: A Primer
