第3章 GATT层详解:Service与Characteristic定义、UUID分配、属性读写通知、体温计Profile设计
好,咱们接着聊。上一章我们把GAP层讲透了,这一章进入GATT层。说实话,GATT层才是蓝牙通信的灵魂所在。你想想看,两个设备连上了,然后呢?总得干点正事吧?GATT就是干这个的——它定义了数据怎么组织、怎么读写、怎么通知。
我在做体温计项目时,最头疼的就是GATT层的设计。一开始觉得不就是定义几个服务嘛,后来发现这里面的坑真不少。今天我把这些经验都倒出来,希望能帮你少走弯路。
3.1 Service与Characteristic:GATT的骨架
GATT层的数据模型,说白了就是一棵树。树根是Profile,树干是Service,树枝是Characteristic,树叶是Descriptor。我习惯这么记:Profile是整套协议,Service是功能模块,Characteristic是具体数据点。
举个例子,体温计Profile里有一个「体温测量服务」(Health Thermometer Service)。这个服务下面,会有一个「体温测量值」的Characteristic。嗯,就是这么个层级关系。
核心概念速记:
- Service:一组相关功能的集合。比如电池服务、设备信息服务
- Characteristic:一个具体的数据点。比如温度值、电量百分比
- Descriptor:对Characteristic的描述或配置。比如客户端特征配置描述符(CCCD)
每个Service和Characteristic都有一个UUID来标识。UUID就是身份证号,全球唯一。蓝牙SIG组织定义了一些标准UUID,比如体温计服务的UUID是0x1809。如果你自己定义私有服务,那就得用128位的UUID。
3.2 UUID分配:标准与自定义的选择
UUID分配这块,我建议你遵循一个原则:能用标准的就别自己造。为什么?因为标准UUID是16位的,省空间。自定义128位UUID,每次通信都要传16个字节,浪费带宽。
体温计相关的标准UUID有哪些?我整理了一个表,你直接拿去用:
| 名称 | UUID | 说明 |
|---|---|---|
| Health Thermometer Service | 0x1809 | 体温计服务 |
| Temperature Measurement | 0x2A1C | 温度测量值Characteristic |
| Temperature Type | 0x2A1D | 温度类型(口温/腋温/肛温) |
| Measurement Interval | 0x2A21 | 测量间隔 |
如果你需要自定义数据,比如设备固件版本、校准参数,那就用128位UUID。我一般这么生成:取公司名+功能名的MD5,然后按标准格式排列。注意,UUID的字节序是小端模式,这个坑我踩过。
避坑指南:我曾经在UUID字节序上栽过跟头。定义了一个128位UUID,结果手机端解析出来完全不对。后来发现是大小端搞反了。记住:蓝牙协议里,UUID的字节序是小端(Little Endian)。
3.3 属性读写通知:数据怎么流动
Characteristic定义好了,数据怎么传?GATT定义了四种操作方式:读、写、通知、指示。我一个个说。
读(Read):手机主动问,设备回答。适合查询静态数据,比如设备型号。但体温计这种实时数据,用读就不太合适——你总不能让手机一直轮询吧?
写(Write):手机给设备发数据。比如设置测量间隔、校准参数。写操作分两种:带响应的写(Write Request)和不带响应的写(Write Command)。我建议关键参数用带响应的写,确保设备收到了。
通知(Notify):设备主动推数据给手机,不需要手机确认。体温计测量完成,直接通过通知把温度值发出去。这是最常用的方式,效率高。
指示(Indicate):和通知类似,但手机必须回复确认。适合传输关键数据,比如报警信息。缺点是慢,因为要等确认。
这里有个关键点:通知和指示需要手机端先使能。怎么使能?通过CCCD(客户端特征配置描述符)。手机往CCCD里写0x0001,通知就开了;写0x0002,指示就开了。这个逻辑,我建议你在固件里一定要做校验,防止非法使能。
我的经验:体温数据用通知,配置参数用带响应的写。别问为什么,问就是我在项目里试过用指示传体温数据,结果手机端处理不过来,丢包了。通知虽然不保证送达,但体温数据是连续上报的,丢一帧没关系。
3.4 体温计Profile设计:从理论到实战
好了,理论讲完了,咱们动手设计一个体温计Profile。我直接给你一个完整的例子,这是我之前一个项目里用过的,稍作修改。
首先,定义Service:
// 体温计服务 UUID: 0x1809
// 这个服务包含三个Characteristic
// 1. 温度测量值 Characteristic
// UUID: 0x2A1C
// 属性: 通知
// 权限: 读(用于获取CCCD状态)
// 数据格式: 浮点数,单位摄氏度
// 2. 温度类型 Characteristic
// UUID: 0x2A1D
// 属性: 读
// 权限: 读
// 数据格式: 8位无符号整数
// 0x01 = 腋温, 0x02 = 口温, 0x03 = 肛温
// 3. 测量间隔 Characteristic
// UUID: 0x2A21
// 属性: 读 + 写
// 权限: 读 + 写
// 数据格式: 16位无符号整数,单位秒
代码实现上,我习惯用结构体来管理每个Characteristic:
typedef struct {
uint16_t handle; // 属性句柄
uint16_t cccd_handle; // CCCD句柄
uint8_t properties; // 属性标志
uint8_t uuid[16]; // UUID
uint8_t* value; // 数据指针
uint16_t value_len; // 数据长度
} characteristic_t;
// 温度测量值实例
characteristic_t temp_meas = {
.properties = GATT_CHAR_PROP_NOTIFY,
.uuid = {0x1C, 0x2A}, // 注意小端序
.value = &temp_value,
.value_len = 4
};
这里要注意handle的分配。每个Service、Characteristic、Descriptor都有一个唯一的handle。handle从0x0001开始递增。我建议你在设计阶段就规划好handle范围,比如Service用0x0010-0x001F,Characteristic用0x0020-0x002F,这样后期扩展方便。
实战要点:
- 体温数据用IEEE 11073格式编码,这是医疗设备的通用格式。简单说就是32位浮点数,但字节序有讲究
- 测量间隔的默认值设为60秒,用户可以通过手机App修改
- 温度类型在设备初始化时固定,不需要用户修改
- 记得在CCCD里做权限校验,防止非法使能
最后,说说Profile的注册流程。在蓝牙协议栈初始化时,你需要按顺序注册Service、Characteristic、Descriptor。我一般这么写:
// 伪代码,具体API看你的协议栈
void profile_init(void) {
// 1. 注册Service
service_handle = gatt_add_service(0x1809);
// 2. 注册温度测量值Characteristic
temp_handle = gatt_add_characteristic(
service_handle,
0x2A1C,
GATT_CHAR_PROP_NOTIFY,
GATT_PERM_READ
);
// 3. 添加CCCD
cccd_handle = gatt_add_descriptor(
temp_handle,
0x2902, // CCCD的标准UUID
GATT_PERM_READ | GATT_PERM_WRITE
);
// 4. 注册温度类型Characteristic
type_handle = gatt_add_characteristic(
service_handle,
0x2A1D,
GATT_CHAR_PROP_READ,
GATT_PERM_READ
);
// 5. 注册测量间隔Characteristic
interval_handle = gatt_add_characteristic(
service_handle,
0x2A21,
GATT_CHAR_PROP_READ | GATT_CHAR_PROP_WRITE,
GATT_PERM_READ | GATT_PERM_WRITE
);
}
嗯,到这里,GATT层的基础知识就讲完了。下一章我们会深入GATT层的回调机制,看看数据到底是怎么在设备和手机之间流转的。到时候我会分享一个我调试了三天才搞定的bug,保证让你印象深刻。