第三章 Profile 与 Service 设计:从标准到自定义的实战之路
聊到 GATT 服务模组联调,有个坎儿你迟早得迈过去——Profile 和 Service 的设计。说白了,这就是蓝牙设备之间“说什么话、怎么说话”的约定。我刚开始做蓝牙项目时,总觉得照着标准抄就行,结果联调时设备死活不认,后来才发现是 Service 声明周期没管好。今天咱们就把这块掰开揉碎,从标准 Profile 讲到自定义设计,再聊聊那些容易踩的坑。
3.1 标准 Profile 解析:HID 与 Battery Service 的实战拆解
标准 Profile 是蓝牙 SIG 定义好的“通用语言”。你想想看,如果每个鼠标厂商都自己定义一套按键上报方式,那鼠标和电脑永远没法配对。所以 SIG 定了 HID(Human Interface Device)Profile,让所有输入设备都按同一套规则来。
3.1.1 HID Profile 的核心结构
HID Profile 其实是个“套娃”结构。它包含一个 HID Service,这个 Service 下面又挂着 Report Map、Report、Protocol Mode 等 Characteristic。我当年调试一个蓝牙键盘时,发现按键上报总丢包,查了半天——原来是 Report Characteristic 的 CCCD(Client Characteristic Configuration Descriptor)没配好,主机根本没订阅通知。
HID Service 关键 Characteristic 清单:
- Report Map:描述设备有哪些输入/输出/功能报告,相当于设备的“说明书”
- Report:实际传输数据的通道,分 Input Report(按键/鼠标移动)和 Output Report(LED 状态)
- Protocol Mode:告诉主机当前用 Boot Protocol 还是 Report Protocol
- HID Information:包含版本号、国家代码等元数据
这里有个细节:HID 的 Report Map 是用二进制描述符写的,不是人类可读的文本。我建议你调试时先用工具把 Report Map 解析成可读格式,不然光看十六进制能看花眼。
3.1.2 Battery Service:看似简单,实则暗藏玄机
Battery Service 是另一个常见标准 Profile。它只有一个 Battery Level Characteristic,用 0-100 表示电量百分比。听起来简单吧?但我在项目中遇到过一个问题:设备上报的电量总是 0%,但实际电池是满的。后来发现是 Battery Level 的 属性(Properties) 没配对——只配了 Read,没配 Notify。主机读一次拿到 100%,但后续电量变化时设备不会主动通知,主机就以为一直是 100%。
| Characteristic | UUID | 属性 | 说明 |
|---|---|---|---|
| Battery Level | 0x2A19 | Read, Notify | 电量百分比(0-100) |
| Battery Power State | 0x2A1A | Read, Notify | 充电状态、电源状态等 |
我的经验:Battery Service 的 Notify 属性一定要配。否则主机只能轮询读取,既费电又实时性差。我曾经见过一个产品,就因为没配 Notify,用户总抱怨“电量显示不准”。
3.2 自定义 Service 设计原则:从需求到实现的完整路径
标准 Profile 够用吗?大多数时候不够。比如你要做一个智能水杯,需要上报水温、水量、饮水提醒——这些 SIG 可没定义。这时候就得自己设计 Service 了。
3.2.1 设计前的三个灵魂拷问
每次设计自定义 Service 前,我都会问自己三个问题:
- 这个数据需要实时上报吗?——决定用 Notify 还是 Read
- 数据是单向还是双向?——决定 Characteristic 的 Write 属性要不要配
- 数据需要分组吗?——决定拆成多个 Service 还是塞进一个 Service
举个例子。我做过一个智能门锁项目,需要上报门锁状态(开/关)、电池电量、用户指纹 ID。一开始我把所有数据塞进一个 Service,结果发现门锁状态需要高实时性(Notify),电池电量可以轮询(Read),指纹 ID 只在验证时用(Write)。混在一起后,主机订阅了 Notify,结果每次电量变化也触发通知,白白浪费带宽。后来拆成三个 Service,各管各的,清爽多了。
3.2.2 UUID 的选择:别用 16 位 UUID 糊弄
自定义 Service 必须用 128 位 UUID。16 位 UUID 是 SIG 保留的,你用了就是侵权。我见过有团队直接复制网上例程的 UUID,结果和别人的产品冲突,联调时两个设备互相认错 Service。
避坑指南:128 位 UUID 可以用在线生成器生成,但一定要保存好。我曾经因为 UUID 丢了,不得不反编译固件才找回来。建议在项目文档里专门列一张 UUID 表。
3.2.3 Characteristic 的属性设计:Read/Write/Notify 的黄金组合
每个 Characteristic 的属性决定了主机怎么和它交互。我总结了一个“属性搭配口诀”:
- 只读数据:Read + Notify(比如传感器值)
- 只写数据:Write Without Response(比如配置命令)
- 读写数据:Read + Write(比如设备名称)
- 控制类数据:Write + Indicate(比如开关指令,需要确认)
这里有个坑:Notify 和 Indicate 的区别。Notify 不要求主机确认,适合高频数据(比如心率);Indicate 要求主机确认,适合重要数据(比如报警)。我建议:能用 Notify 就别用 Indicate,因为 Indicate 的确认机制会降低吞吐量。
3.3 Service 生命周期:从注册到销毁的完整管理
Service 不是注册完就完事了。它的生命周期包括注册、启动、运行、停止、销毁五个阶段。每个阶段都有对应的回调函数,你得在这些回调里做正确的事。
3.3.1 注册阶段:别在初始化里做耗时操作
Service 注册通常在蓝牙协议栈初始化时完成。我见过有人把文件系统挂载、网络连接等耗时操作放在 Service 注册回调里,结果导致蓝牙初始化超时。正确的做法是:注册时只做内存分配和变量初始化,其他操作放到 Service 启动回调里。
// 错误示例:在注册回调里做耗时操作
static void battery_service_registered(void) {
// 挂载文件系统 —— 这会导致蓝牙初始化阻塞
mount_filesystem();
}
// 正确示例:注册回调只做轻量操作
static void battery_service_registered(void) {
battery_level = 100;
is_charging = false;
}
static void battery_service_started(void) {
// 启动后才做耗时操作
start_adc_conversion();
}
3.3.2 运行阶段:处理好 CCCD 的变化
Service 运行后,主机可能会订阅或取消订阅 Notify/Indicate。这时候会触发 CCCD 写回调。我建议你在回调里记录当前订阅状态,避免在没订阅时还拼命发通知——既浪费带宽又耗电。
CCCD 管理的最佳实践:
- 用位掩码记录每个 Characteristic 的订阅状态
- 在 Notify 发送前检查订阅状态
- 主机取消订阅后,停止对应的数据采集(比如关闭 ADC)
3.3.3 停止与销毁:别忘了释放资源
Service 停止时,要关闭所有打开的硬件资源(比如定时器、ADC、GPIO)。销毁时,要释放动态分配的内存。我见过一个产品,每次 OTA 升级后蓝牙连不上,查了三天才发现是 Service 销毁时没释放内存,导致内存泄漏。
3.4 知识体系总览:一张图看懂 Profile 与 Service 设计
下面这张图总结了本章的核心逻辑。你可以把它当作设计时的检查清单。
3.5 实战避坑:我踩过的三个大坑
最后分享几个我亲身踩过的坑,希望能帮你省点 debug 时间。
坑一:Service 注册顺序导致主机扫描不到
有一次我调一个多 Service 设备,主机始终只发现第一个 Service。查了半天,发现是 Service 注册顺序和 GATT 数据库初始化顺序不一致。后来我强制规定:所有 Service 必须在 GATT 数据库启动前注册完毕。
坑二:Characteristic 的 Value 长度没对齐
自定义 Service 里有个 3 字节的数据,我直接定义成 uint8_t[3]。结果主机读回来总是多一个字节。后来发现蓝牙协议栈要求 Characteristic Value 长度必须是偶数。改成 uint8_t[4] 后问题解决。
坑三:Notify 发送频率过高导致协议栈崩溃
一个传感器每秒产生 100 个数据点,我全用 Notify 发出去。结果协议栈直接挂掉。后来加了数据缓存和限速逻辑,每秒最多发 10 个通知,问题解决。记住:BLE 的带宽有限,别把 Notify 当串口用。