3、GATT操作流程:Service Discovery、Characteristic Read/Write、Notification与Indication机制、MTU协商

好,咱们进入GATT操作的核心环节。说白了,GATT就是蓝牙设备之间怎么“聊天”的规矩。你连上设备后,第一件事不是直接读数据,而是先搞清楚对方“长什么样”——这就是Service Discovery。

3.1 Service Discovery——先摸清对方底细

我刚开始做蓝牙项目时,犯过一个低级错误:连上设备后直接去读某个Characteristic的UUID,结果一直返回错误。后来才意识到,我连这个服务存不存在都没确认。嗯,这里要注意:GATT通信的第一步,永远是发现服务。

Service Discovery说白了就是主机(手机/模组)向从机(传感器/外设)发一个请求:“你有哪些Service?每个Service里有哪些Characteristic?”从机收到后,会把整个属性表一层层返回。

核心流程:

  • 主机发送 Exchange MTU Request(可选,但建议先做)
  • 主机发送 Discover All Primary Services 请求
  • 从机返回Service列表(UUID + 句柄范围)
  • 主机针对每个Service,发送 Discover All Characteristics 请求
  • 从机返回Characteristic列表(UUID + 属性 + 值句柄)
  • 主机可选发送 Discover All Characteristic Descriptors 获取CCCD等描述符

我记得有一次调试一个心率模组,Service Discovery返回了三个Service,但其中一个Service的UUID是自定义的。我查了半天文档才发现,那个是厂商私有的OTA升级服务。所以,不要假设设备只有标准服务。

我的习惯:在联调阶段,我会先用手机上的nRF Connect或LightBlue做一次完整的Service Discovery,截图保存。这样写代码时,UUID和句柄范围一目了然,省得反复查文档。

3.2 Characteristic Read/Write——读写操作的门道

Service Discovery做完后,你就知道每个Characteristic的句柄了。接下来就是读写操作。但这里有个坑:不是所有Characteristic都能读,也不是所有都能写。每个Characteristic的属性字段(Properties)决定了你能对它做什么。

属性位 含义 说明
0x02 Read 允许主机读取值
0x04 Write Without Response 主机写入,从机不回复确认
0x08 Write 主机写入,从机回复确认
0x10 Notify 从机主动通知,主机不确认
0x20 Indicate 从机主动指示,主机必须确认

读操作很简单:主机发 Read Request,从机回 Read Response。但写操作分两种,我重点说一下。

Write Request vs Write Command

  • Write Request(带响应):主机发数据,从机收到后必须回复 Write Response。适合写配置参数这种需要确认的场景。缺点是慢,一来一回。
  • Write Command(无响应):主机发数据,从机不回复。适合写大量数据(比如OTA固件包)。快,但不可靠——你根本不知道从机收到没有。

我曾经踩过的坑:有一次写一个温湿度传感器的配置参数,用了Write Command。结果参数写进去了,但传感器没重启,导致配置没生效。后来改成Write Request,等收到响应后再发重启指令,问题解决。所以,关键配置一定要用Write Request。

3.3 Notification与Indication——从机主动上报的两种姿势

读和写都是主机主动发起的。但很多场景下,我们希望从机主动上报数据——比如心率传感器每秒钟发一次心率值。这时候就要用到Notification和Indication。

两者的区别,说白了就是:Notification不确认,Indication要确认。

特性 Notification Indication
主机确认 不需要 必须回复确认
传输速度 快(无确认开销) 慢(每次都要等确认)
可靠性 低(可能丢包) 高(确认机制保证)
典型应用 高频传感器数据 关键事件/报警
CCCD配置 0x0001 0x0002

使用Notification/Indication前,主机必须先使能对应的CCCD(Client Characteristic Configuration Descriptor)。CCCD是一个描述符,句柄通常在Characteristic值句柄+1的位置。主机往CCCD写0x0001开启Notification,写0x0002开启Indication,写0x0000关闭。

使能Notification的代码片段(以ESP32为例):

// 假设已找到目标Characteristic的CCCD句柄
uint16_t cccd_handle = char_handle + 1;
uint8_t enable_notify[] = {0x01, 0x00}; // 小端模式

esp_ble_gattc_write_char(
    gattc_if,
    conn_id,
    cccd_handle,
    sizeof(enable_notify),
    enable_notify,
    ESP_GATT_WRITE_TYPE_REQ
);

我个人习惯是:高频数据用Notification,关键事件用Indication。比如一个加速度传感器,每秒输出100组数据,用Notification;但电池电量低于10%的报警,用Indication,确保主机一定收到。

3.4 MTU协商——别让数据包卡在门口

MTU(Maximum Transmission Unit)决定了GATT一次能传多少字节。默认MTU是23字节,其中3字节是协议头(操作码+句柄),所以实际有效载荷只有20字节。你想想看,如果一次要传一个100字节的固件包,得拆成5次发,效率极低。

MTU协商的过程很简单:

  1. 主机发 Exchange MTU Request,带上自己支持的MTU大小(比如247)
  2. 从机回 Exchange MTU Response,带上自己支持的MTU大小(比如512)
  3. 双方取较小值作为本次连接的MTU

举个例子:主机说“我能支持247”,从机说“我能支持512”,那最终MTU就是247。如果主机说247,从机说128,那最终就是128。

我的建议:MTU协商要在Service Discovery之前做。因为MTU变大后,Service Discovery返回的数据可以一次性打包,减少交互次数。我一般在连接建立后的第一个操作就是发MTU Request。

这里有个细节:MTU协商不是必须的。如果你只传几个字节的控制指令,默认23字节完全够用。但如果你要传传感器波形数据或OTA固件,一定要做MTU协商。我记得有一次联调一个音频模组,没做MTU协商,每次只能传20字节,一个音频帧要拆成十几包,延迟高得离谱。后来把MTU提到512,一包搞定。

3.5 整体流程梳理

把上面这些串起来,一个完整的GATT操作流程是这样的:

GATT操作完整流程图 1. 建立BLE连接 2. MTU协商(可选但推荐) 3. Service Discovery 4. 找到目标Characteristic 5a. Read/Write操作 5b. 使能CCCD 5c. 接收Notify/Indicate 6. 数据交互完成 7. 断开连接 说明 MTU协商建议在Discovery前做 可提高后续传输效率

嗯,这张图把整个流程串起来了。你实际开发时,按这个顺序走,基本不会出大问题。但要注意:每一步都要检查返回值。我见过太多人写完代码不检查GATT状态码,结果设备没连上都不知道。

避坑指南:我曾经在一个项目中,MTU协商成功后,Service Discovery返回的数据量突然变大,导致接收缓冲区溢出。后来发现是MTU变大后,从机把多个服务打包在一个ATT PDU里返回,而我的缓冲区还是按默认23字节分配的。所以,MTU协商后,记得同步更新接收缓冲区大小。