3. GATT 服务架构:Service、Characteristic、Descriptor 定义与 UUID 分配策略
好,咱们进入第三章。这一章我打算把 GATT 服务的骨架彻底拆开给你看。
Service、Characteristic、Descriptor,这三个词你肯定听过。但很多开发者做了好几年 BLE,其实没搞明白它们到底怎么搭在一起工作的。说白了,GATT 就是一个“属性数据库”,你的设备有什么能力、能提供什么数据,全写在这个数据库里。
3.1 三层结构:从 Service 到 Descriptor
GATT 的结构是树形的。顶层是 Service,中间是 Characteristic,底层是 Descriptor。我习惯把它想象成一个文件柜:
- Service —— 相当于一个抽屉,代表一类功能。比如“电池服务”、“设备信息服务”。
- Characteristic —— 抽屉里的文件夹,代表一个具体的数据点。比如“电池电量百分比”。
- Descriptor —— 文件夹里的标签,描述这个数据怎么读、怎么写、有没有通知。
嗯,这里要注意:一个 Service 可以包含多个 Characteristic,一个 Characteristic 又可以包含多个 Descriptor。但 Descriptor 不能独立存在,它必须依附于某个 Characteristic。
核心原则: 每个 Characteristic 至少有一个 Descriptor —— 就是那个 Client Characteristic Configuration Descriptor (CCCD),用来控制通知和指示的开关。没有它,你的设备就没法主动给手机发数据。
我在项目中遇到过好几次,新手把 CCCD 漏掉了,结果手机端死活收不到通知。查了半天,发现是 Descriptor 没加。这种坑,踩一次就记住了。
3.2 Service 的定义与分类
Service 是功能的逻辑分组。比如你的设备有温度传感器,那就定义一个“温度服务”。
Service 分两种:
| 类型 | 说明 | UUID 示例 |
|---|---|---|
| Primary Service | 主要服务,代表设备的核心功能 | 0x180A (Device Information) |
| Secondary Service | 辅助服务,被 Primary Service 引用 | 自定义 UUID |
我个人习惯:能用标准 Service 就用标准的。比如电池服务用 0x180F,设备信息服务用 0x180A。这样手机端的通用 App 不用写额外代码就能识别。
小技巧: 如果你的设备有多个传感器,建议每个传感器单独定义一个 Service。比如“温度服务”、“湿度服务”、“气压服务”。这样手机端可以按需订阅,不用一次读一堆没用的数据。
3.3 Characteristic:数据的载体
Characteristic 是 GATT 里最核心的东西。它包含三个部分:
- 声明(Declaration) —— 固定格式,告诉对端这个 Characteristic 的属性、值句柄、UUID。
- 值(Value) —— 实际的数据内容。
- 描述符(Descriptor) —— 附加信息,比如 CCCD、用户描述等。
Characteristic 的属性(Properties)决定了你能对它做什么:
| 属性 | 值 | 说明 |
|---|---|---|
| Broadcast | 0x01 | 允许广播 |
| Read | 0x02 | 手机可以读 |
| Write Without Response | 0x04 | 写操作不需要确认 |
| Write | 0x08 | 写操作需要确认 |
| Notify | 0x10 | 设备主动通知手机 |
| Indicate | 0x20 | 设备主动指示(带确认) |
嗯,这里有个坑:Notify 和 Indicate 的区别。Notify 发出去就不管了,手机收没收到不知道。Indicate 会等手机回复确认。Indicate 更可靠,但功耗更高。我在做血糖仪项目时,关键数据用 Indicate,普通状态用 Notify。你想想看,血糖值报错了可是要出人命的。
3.4 Descriptor:细节的补充
Descriptor 是 Characteristic 的“说明书”。最常用的几个:
- CCCD (0x2902) —— 控制通知/指示的开关。必须加。
- Characteristic User Description (0x2901) —— 给这个数据起个名字,比如“温度值”。
- Characteristic Presentation Format (0x2904) —— 描述数据的格式,比如单位是摄氏度还是华氏度。
- Characteristic Extended Properties (0x2900) —— 扩展属性,很少用。
警告: 不要滥用 Descriptor。每个 Descriptor 都会占用 ATT 句柄资源。BLE 协议栈的句柄池是有限的,尤其是像 nRF52 这种芯片,默认只有几十个句柄。我曾经见过一个项目,为了“好看”给每个 Characteristic 加了五六个 Descriptor,结果服务都注册不进去。
3.5 UUID 分配策略
UUID 是 GATT 里每个属性的身份证。16 位 UUID 是蓝牙 SIG 定义的,128 位 UUID 是自定义的。
我建议你按这个策略来:
- 能用标准 UUID 就用标准的 —— 比如电池、设备信息、心率这些。手机端不用额外开发。
- 自定义服务用 128 位 UUID —— 格式:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx。
- 同一个产品线用同一个 Base UUID —— 比如你的公司 UUID 是 12345678-1234-1234-1234-123456789abc,那所有自定义 Service 和 Characteristic 都基于这个 Base UUID 来变。
举个例子:
// Base UUID
#define BASE_UUID "12345678-1234-1234-1234-123456789abc"
// 温度服务:替换前 4 字节
#define SERVICE_TEMPERATURE_UUID "0001" BASE_UUID[4..]
// 温度值 Characteristic
#define CHAR_TEMPERATURE_VALUE_UUID "0002" BASE_UUID[4..]
// 温度单位 Descriptor
#define DESC_TEMPERATURE_UNIT_UUID "0003" BASE_UUID[4..]
这样做的好处是:UUID 短、好管理、不容易冲突。我在做智能家居项目时,一个网关要管理 20 多种设备,全靠这种 Base UUID 策略才没乱套。
3.6 实战:定义一个低功耗温度服务
咱们来写一个实际的例子。假设你要做一个低功耗温度计,GATT 服务定义如下:
// 温度服务 UUID (自定义 128 位)
#define TEMPERATURE_SERVICE_UUID "00010000-1234-1234-1234-123456789abc"
// 温度值 Characteristic UUID
#define TEMPERATURE_VALUE_UUID "00010001-1234-1234-1234-123456789abc"
// 温度单位 Descriptor UUID
#define TEMPERATURE_UNIT_UUID "00010002-1234-1234-1234-123456789abc"
// 服务定义结构
typedef struct {
uint16_t service_handle;
uint16_t char_value_handle;
uint16_t char_cccd_handle;
uint16_t char_unit_handle;
} temperature_service_t;
// 初始化服务
void temperature_service_init(temperature_service_t *svc) {
// 1. 注册 Service
sd_ble_gatts_service_add(BLE_GATTS_SRVC_TYPE_PRIMARY,
&(ble_uuid_t){.uuid = 0x0001, .type = BLE_UUID_TYPE_VENDOR_BEGIN},
&svc->service_handle);
// 2. 注册 Characteristic
ble_gatts_char_md_t char_md = {0};
char_md.char_props.notify = 1;
char_md.char_props.read = 1;
char_md.p_cccd_user_desc = NULL;
char_md.p_char_user_desc = NULL;
// 3. 注册 Descriptor (CCCD)
ble_gatts_attr_md_t cccd_md = {0};
cccd_md.vloc = BLE_GATTS_VLOC_STACK;
// ... 实际代码省略,重点是结构
}
嗯,这里要注意:在低功耗设计中,尽量少用 Read 属性,多用 Notify。为什么呢?因为 Read 需要手机主动发起请求,设备要一直保持接收状态。而 Notify 是设备主动推送,发完就可以继续睡觉。省电。
低功耗核心原则: 让手机来读,不如设备主动推。每次 Read 交互都要唤醒设备,而 Notify 可以批量发送后立即休眠。
3.7 避坑指南
最后,我总结几个我踩过的坑:
- UUID 冲突 —— 别用网上随便抄的 128 位 UUID,很可能跟别人的产品撞了。用在线 UUID 生成器生成一个专属的。
- 句柄不够用 —— 每个 Service、Characteristic、Descriptor 都要占一个句柄。设计时先算好总数,别超了。
- CCCD 漏加 —— 没有 CCCD,Notify 和 Indicate 都用不了。这是新手最容易犯的错误。
- 属性设置错误 —— 比如把 Write 和 Write Without Response 搞混了。前者要等确认,后者不用。功耗差很多。