3. Notification/Indication 使能不生效:CCCD 配置流程、权限检查与日志分析

Notification 和 Indication 不生效,这问题我碰到的次数太多了。说白了,就是手机收不到设备主动发上来的数据。很多开发者第一反应是“代码写错了”,其实大部分时候,是 CCCD 没配好,或者权限没开对。

今天咱们就把这个流程彻底捋一遍。你跟着我的思路走,以后遇到这类问题,十分钟内就能定位。

3.1 CCCD 到底是什么?

CCCD,全称是 Client Characteristic Configuration Descriptor。名字很长,但作用很简单——它是一个开关。这个开关放在服务端(比如你的蓝牙设备),由客户端(比如手机 App)来写。

客户端写 0x0001,表示“我要收 Notification”。
客户端写 0x0002,表示“我要收 Indication”。
客户端写 0x0000,表示“我不收了”。

嗯,就是这么直白。但为什么很多人配了还是不生效?

核心要点:CCCD 必须由客户端主动写入,服务端不能自己给自己开。很多设备端工程师在初始化时试图在本地把 CCCD 置为 0x0001,这是无效的。GATT 规范明确规定,CCCD 的写入权属于客户端。

3.2 客户端配置流程(标准步骤)

我习惯把客户端配置 CCCD 的流程拆成三步。你按这个顺序检查,基本不会漏。

  1. 发现服务与特征值
    客户端先做 Service Discovery,找到目标特征值的句柄(Handle),以及它下面的 CCCD 句柄。
  2. 写入 CCCD
    客户端向 CCCD 句柄写入 0x0001(Notification)或 0x0002(Indication)。注意,写入长度是 2 字节,小端序。
  3. 等待写入完成事件
    客户端收到写入响应后,才算配置完成。之后服务端才能发 Notification/Indication。

这里有个坑——很多 App 开发者在写入 CCCD 后立刻调用发送接口,但此时服务端还没收到配置变更通知。我建议你在写入完成后,等一个 GATT 回调确认,再开始收数据。

3.3 服务端权限检查(最容易忽略的地方)

客户端配好了,服务端也收不到?那问题大概率出在权限上。我列一个检查清单,你对着看。

检查项 正确配置 常见错误
特征值属性 Notify 或 Indicate 位必须置 1 只配了 Read/Write,没配 Notify
CCCD 权限 允许客户端写入(Write 权限) CCCD 被设为只读或不可写
服务端配置变更回调 实现了 CCCD 写入后的回调函数 回调里没更新内部状态
连接句柄绑定 每个连接独立维护 CCCD 状态 多个连接共用一个 CCCD 标志位

我曾经在一个项目里排查了整整两天,最后发现是特征值的属性字段少配了一个 NOTIFY 位。你想想看,服务端自己都不支持发送,客户端配了 CCCD 又有什么用?

特别注意:有些蓝牙协议栈(比如某些国产芯片的 SDK)在定义特征值时,属性字段和 CCCD 权限是分开配置的。你配了 Notify 属性,不代表 CCCD 自动就有了 Write 权限。这两个必须分别设置。

3.4 典型日志分析(实战派)

日志是最好的老师。我一般会同时抓三份日志:设备端日志、手机端 HCI 日志、以及 Sniffer 空口日志。下面我拿一个真实案例来讲。

案例:Notification 发不出去

设备端日志片段:

[GATT] CCCD write received, handle=0x0012, value=0x0001
[GATT] Notification enabled for handle 0x0012
[APP] Sending notification...
[GATT] Error: Notification not sent, CCCD not enabled for this connection

看到没?设备端明明打印了“Notification enabled”,但发送时又说“CCCD not enabled”。这矛盾了。

我当时的分析思路是这样的:

  • 第一行日志说明客户端确实写了 CCCD,值也是 0x0001,没问题。
  • 第二行是应用层自己打印的,可能只是标记了全局变量,但没绑定到具体连接。
  • 第四行报错,说明底层协议栈在发送时检查了连接句柄对应的 CCCD 状态,发现是关闭的。

问题找到了——应用层用一个全局变量 g_notify_enabled 来记录状态,但协议栈内部是按连接句柄来查的。当有多个连接时,全局变量被覆盖了。

我的建议:CCCD 状态一定要按连接句柄(conn_handle)来存储。用数组或哈希表,key 是 conn_handle,value 是 CCCD 值。别偷懒用全局变量。

Sniffer 日志怎么看?

如果你有 Sniffer,抓包后重点关注三个地方:

  1. Write Request:客户端向 CCCD 句柄发起的写入操作,数据应为 0x0100(小端序的 0x0001)。
  2. Write Response:服务端返回的确认包。如果没有这个包,说明写入没成功。
  3. Handle Value Notification:服务端主动发的数据包。如果前两步都对了,但这里没包,那就是服务端发送逻辑有问题。

我见过最离谱的情况是,客户端写的是 0x0001,但 Sniffer 显示写的是 0x0100。后来发现是 App 端字节序搞反了。嗯,这种问题光看代码很难发现,Sniffer 一抓就现原形。

3.5 整体流程示意图

下面这张图把整个 CCCD 配置到 Notification 发送的流程画清楚了。你保存下来,以后排查时对着看。

CCCD 配置与 Notification 发送流程 CCCD 配置与 Notification 发送流程 客户端 (App) 服务端 (设备) 1. 发现服务与特征值 2. 写入 CCCD (0x0001) 3. 更新 CCCD 状态 4. 返回 Write Response 5. 发送 Notification 关键检查点:CCCD 写入后必须收到 Write Response,服务端才能发 Notification Sniffer 抓包时重点看第 2、4、5 步的空中包

3.6 避坑指南(我踩过的坑)

最后,我把自己这些年踩过的坑总结一下。你遇到类似情况,直接对照排查。

  • 坑一:CCCD 写成功了,但服务端没触发回调。
    检查协议栈的 CCCD 写入回调注册接口。有些 SDK 需要你显式注册一个 gatt_cccd_write_callback,否则写入事件会被协议栈默默吃掉。
  • 坑二:Indication 的确认超时。
    Indication 需要客户端回复确认包。如果客户端没回,服务端会一直等,导致后续数据发不出去。我建议你在服务端加一个超时重传机制,3 秒没收到确认就重发。
  • 坑三:配对绑定后 CCCD 丢失。
    有些设备在配对绑定后会重新初始化 GATT 服务,导致之前配好的 CCCD 被清空。解决办法是在配对完成后,让客户端重新配置一次 CCCD。
  • 坑四:MTU 太小导致 Notification 发不出去。
    如果特征值的数据长度超过 MTU,协议栈会拒绝发送。先协商 MTU,再发数据。我一般把 MTU 设到 247 字节以上。

好了,CCCD 这块的内容就这么多。你只要把客户端写入流程、服务端权限检查、以及日志分析这三块吃透,Notification/Indication 不生效的问题基本都能搞定。下次遇到类似问题,别急着改代码,先抓日志,再查权限,最后看流程——按这个顺序来,效率最高。