(十)BLE GATT与ATT深度解剖-从属性表到CCCD-订阅收不到数据的终极答案

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_CCCCONFIG_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 只说"我有一张表,你拿编号访问",但如果全世界的设备都这么裸奔,会有三个致命问题:

  1. 手机怎么知道你这张表里哪一格是"电量"、哪一格是"心率"? 没有约定,每个厂商一套私货,互操作性归零。
  2. 表的结构没有任何章法,服务发现算法无从优化,遍历成本失控。
  3. 权限粒度太粗,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 Request 0x12;是否采用 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, &params);
    } else {
        /* 多连接场景:NULL 表示向所有订阅了该特征的连接推送,
         * 协议栈自动跳过未订阅者,无需自己遍历 */
        err = bt_gatt_notify_cb(NULL, &params);
    }

    /* 返回值只说明"是否成功入队"(-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 的那一项,而不是 0x29020x2803

坑 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协议栈协议分层架构设计详解

(四)BLE的广播及连接-通俗易懂

(五)图文结合-详解BLE连接原理及过程

(六)BLE安全指南:别让"配对降级"和硬件I/O毁了安全等级(BLE SMP)

(七) 深入探讨BLE MAC 地址的隐私博弈

(八)BLE MTU 全栈解析:从 20 字节瓶颈到 160KB/s(九)一文吃透 BLE:从低功耗原理到协议栈与实战概念

参考资料:

相关推荐
星源~4 小时前
zephyr-Linux环境下搭建步骤
linux·mcu·嵌入式开发·zephyr
YONYON-R&D1 天前
[特殊字符] 解决:蓝牙鼠标从其他电脑切回后“能扫到但连不上”
ble·蓝牙鼠标
dozenyaoyida9 天前
Zephyr BLE 上行链路源码拆解:一个空中报文怎么从 radio 跑到应用回调(NCS v3.2.1)
controller·嵌入式开发·ble·host·zephyr·协议栈·软链路层
dozenyaoyida11 天前
Zephyr BLE GATT Server 注册到收发调用链源码解析(NCS v3.2.1)
ble·zephyr·gatt·ble协议栈
K成长日志14 天前
BLE不可连接状态--广播态
物联网·网络协议·蓝牙·低功耗·iot·ble
K成长日志1 个月前
BLE链路层--比特流处理
网络·物联网·网络协议·蓝牙·iot·ble·无线
iini1 个月前
nRF Connect SDK 开发新体验:VS Code + Claude Code + DeepSeek + Nordic MCP 全流程(蓝牙开发实例)
agent·ai编程·nrf connect sdk·zephyr·蓝牙开发·deepseek·mcp·claude code·nordic mcp
K成长日志1 个月前
BLE链路层空口包--数据物理信道PDU
网络·物联网·网络协议·嵌入式·蓝牙·iot·ble
fitpolo2 个月前
详解Zephyr设备树与设备驱动模型
zephyr