资讯动态

【HIDS】规范精讲[4]:一张句柄地图走天下——示例 GATT 数据库逐行拆解

发布时间:2026/10/26 1:54:12 来源:尧图企业网站定制
前三篇把 HID Service 的契约、特征、SCI 调速都讲透了这篇收尾。规范附了一份完整的示例 GATT 数据库把一个键盘设备的所有属性从句柄 0x0100 一直排到 0x0143。这份示例是整个规范的收官之作也是最有工程味道的部分——前文所有抽象规则在这里全部落地成一行行具体的属性。把这张表逐行走一遍等于把前三篇的知识在沙盘上推演了一遍。目录一、数据库是什么一栋编了门牌号的大楼二、引用文献HID 格式的主权归属三、示例数据库全貌先看全景再进细节四、逐段走读从大门到后门4.1 服务入口与一个前向引用4.2 铭牌与 Boot 通道4.3 Report Map 与外部报表路标4.4 三个 Report 实例身份证的实战4.5 控制面收尾4.6 句柄空洞留给明天的余地五、空口主机是怎么走这张地图的六、代码视角把表变成内存里的数组七、收个尾八、测验一、数据库是什么一栋编了门牌号的大楼先打个比方。GATT 数据库就是一栋大楼每个属性是一间房句柄是门牌号属性类型是房间用途属性值是房间里的东西。主机第一次进楼要按门牌号挨个敲门问询这间是干什么的、那间能不能进。门牌号规划得好不好直接决定了主机问完一圈要敲多少次门。HID Service 在数据库里的位置由服务声明属性标记规范开头的术语表里有两个新词值得留意大部分缩写是老熟人了真正有信息量的是 ECV 和 RCV——扩展连接间隔值和取整连接间隔值。这两个词来自最新版核心规范是连接间隔表达机制的新演进当连接间隔超出传统字段的表示范围或需要更高精度时用扩展值和取整值来编码。它们出现在这份规范的术语表里说明 SCI 体系的设计已经考虑了与核心规范新特性的衔接——连接间隔的可调范围未来会向两端继续延伸HID 设备的参数协商要有承接能力。读规范读术语表读的就是这种伏笔。二、引用文献HID格式的主权归属规范末尾列了五份引用文献看似形式主义实则划清了势力范围。USB-IF 的两份文档格外醒目HID Usage Tables 和 Device Class Definition for HID。前者定义了键码、鼠标按键、消费类控制等各种用途的编号体系后者定义了 Report Descriptor 的描述符语言。这两份文档是 USB-IF 的财产蓝牙 SIG 只是引用而非修订。这个主权划分解释了一个现象Report Map 里的内容格式问题蓝牙规范一律不答让你去查 USB 文档。工程师调试 HID 解析问题时翻错文档是常见误区——蓝牙侧只管传输和特征组织数据格式的主法庭在 USB-IF 那边。引用清单同时锁定了核心规范 6.2 以上版本和已分配编号文档前者提供 ATT/GATT/链路层的全部机制后者提供 0x1812、0x2A4D 这些短 UUID 的官方登记。五份文献各司其职合起来才是 HID over GATT 的完整技术栈。三、示例数据库全貌先看全景再进细节示例数据库表的四列从左到右是句柄、属性类型、类型说明、属性值。属性类型分三类Declaration 标记的行是特征声明Value 标记的行是特征值Descriptor 标记的行是描述符。一个特征至少占两行声明加值带描述符就占三行以上。这个三明治结构是 GATT 的基本功但真正值得细品的是每一行的具体取值。属性值列里的字母串是属性权限的速记r 代表可读w 代表可写n 代表可通知连续的 w 分别对应带响应写和无响应写两种形态。比如 HID Information 只有 r全表最简Boot Keyboard Output Report 的字符串里同时出现读、写、无响应写三种权限Feature 报表只有 rw没有 n——这正好验证了上一篇讲的铁律Output 和 Feature 报表禁止挂 CCCD自然也没有通知权限。四、逐段走读从大门到后门4.1 服务入口与一个前向引用0x0100 是主服务声明值是 HID Service 的 UUID整栋 HID 大楼从这个门牌开始。紧接着 0x0101 是一个 Include 声明值指向 Battery Service。这里有个耐人寻味的细节Battery Service 的实际定义排在整张表的最后句柄 0x0140。也就是说 Include 声明引用了一个尚未定义的服务。这完全合法——GATT 数据库在构建时一次性分配好所有句柄Include 的值里写的是目标服务的句柄范围和 UUID主机遍历时顺藤摸瓜就能找到。工程上的启示是数据库的排布顺序和逻辑依赖关系是两回事声明在前、定义在后不影响语义。Include Battery Service 的动机在后面揭晓先记住这个伏笔。4.2 铭牌与 Boot 通道0x0102 和 0x0103 是 HID Information 的声明和值只读三字节的出厂铭牌。0x0104 到 0x0106 是 Boot Keyboard Input Report 三件套声明可读可写可通知、值、CCCD 描述符初始值 0x0000通知默认关闭。0x0107 和 0x0108 是 Boot Keyboard Output Report注意它没有 CCCD——输出报表方向是主机到设备没有可订阅的东西。这段布局印证了一个设计原则Boot 特征虽然格式原始但该有的描述符一个不少。主机在 Boot 模式下也要订阅通知CCCD 的初始值 0x0000 明确了每次连接的通知起点是关——主机想收数据必须先写 CCCD 开闸。4.3 Report Map 与外部报表路标0x0109 和 0x010A 是 Report Map值就是 USB HID 的 Report Descriptor 原文。0x010B 挂了一个 External Report Reference 描述符值指向 Battery Level 特征。这个描述符是全表最精妙的一笔单独展开讲。前文说过Report Map 里可能描述某些报表的数据不走 HID Service 自己的 Report 特征传输External Report Reference 负责把外部特征的 UUID 登记在案。示例里登记的是 Battery Level——设备的电量值在 HID 的报表体系里被定义为一条输入报表。但登记只是链路的一半。主机顺着 UUID 找到 Battery Service 里的 Battery Level 特征后怎么确认这条外部通道承载的是哪条报表答案在表的最后一行Battery Level 特征下面挂了一个 Report Reference 描述符值是 ID 3、Input。外部特征的报表身份由外部服务自己声明。两边一对上闭环就形成了Report Map 用描述符语言定义了报表格式External Report Reference 指出数据在外部Battery Service 里的 Report Reference 声明自己就是那条报表Battery Level 的通知就是 HID 输入报表的传输。电量这个最普通的 BLE 数据就此获得了双重身份——它既是电池服务的特征值又是 HID 报表流里的一员。主机的 HID 类驱动可以用统一的方式消费它不需要为电量单开一套解析逻辑。这个机制的价值在于任何服务只要挂上 Report Reference 描述符就能接入 HID 报表体系为未来的传感器类数据扩展留了无限空间。4.4 三个 Report 实例身份证的实战0x0120 到 0x0133 是三个 Report 特征实例身份卡各不相同。第一个实例0x0120-0x0123是协议模式键盘输入报表声明、值、CCCD、Report ReferenceID 2、Input。可读、可写、可通知权限最全。第二个实例0x0128-0x0130是键盘 LED 位图输出报表声明、值、Report ReferenceID 1、Output。没有 CCCD权限里读和两种写齐全——主机既要能写 LED 状态也要能读回当前状态。第三个实例0x0131-0x0133是 Feature 报表声明、值、Report ReferenceID 1、Feature。只有读写没有通知也没有 CCCD。三个实例的身份证摆在一起能读出一个重要规则Output 报表和 Feature 报表的 Report ID 都是 1ID 重复了但完全合法。因为报表身份是类型加 ID 的二元组——USB HID 的 Get_Report 和 Set_Report 请求里类型和 ID 打包在同一个字段里传输类型不同ID 再相同也不会混淆。HIDS 的 Report Reference 把类型和 ID 并排放在两个字节里正是这个二元组语义的忠实翻译。理解了这一点Report ID 的分配自由度会大很多输入报表空间里 ID 必须互异输出和特征报表各自有独立的 ID 空间。4.5 控制面收尾0x0134 和 0x0135 是 HID Control Point声明和值。0x0138 和 0x0139 是 Protocol Mode。这两个特征在上一篇都细讲过示例库里它们的权限配置再次印证Control Point 是纯粹的指令入口Protocol Mode 可读可写。示例没有包含 SCI 特征因为这是个基础键盘——SCI 是可选特性不支持就不声明、不占句柄条件号 C.5 说的就是这个取舍。4.6 句柄空洞留给明天的余地细心的读者会发现句柄不是连续的0x010B 之后直接跳到 0x01200x0123 跳到 0x01280x0135 跳到 0x01380x0139 跳到 0x0140。这些空洞不是浪费是预留。为什么预留设想固件升级要给设备加一个新 Report 实例或一个 Boot Mouse 特征。如果句柄已经排满新属性要么插队导致后面所有句柄重排要么追加到表尾打乱服务的连续性。句柄重排的代价极高——很多主机平台会在绑定期间缓存特征句柄重排后缓存全部失效轻则重发现一轮重则数据写到错误的门牌。预留空洞让新特征原地插入句柄地图稳定如初。这是嵌入式 GATT 开发的实战经验按功能块分配句柄段每段留足余量一次规划管三代固件。五、空口主机是怎么走这张地图的数据库是静态的地图主机的发现过程是动态的问路。一份真实抓包记录了完整的 GATT 发现事务序列把它按时间铺开四种颜色对应四类发现事务。第一批问路出现在 0 秒附近Read By Group Type 扫出全部主服务Read By Type 逐个枚举特征声明Find By Type Value 按指定 UUID 精确查找服务Find Information 则负责枚举描述符。这四类事务正是走遍一张类似附录 A 的数据库所需的全套动作——服务层用 Read By Group Type特征层用 Read By Type描述符层用 Find Information层层递进。约 15 秒处出现了第二波问路这次的模式明显不同先 Find By Type Value 按一个 128 位 UUID 定位目标服务再在该服务的句柄范围内用 Find Information 扫描述符。这是典型的定点重发现——主机只关心某一个服务不重新扫全表。对照附录 A 的句柄规划定点查找的效率取决于服务的属性是否排在连续句柄段内连续则一次 Find Information 全部拿下碎片化则要多次往返。两个细节值得展开。其一第一波问路里主机先扫服务、再扫特征、最后扫描述符每层都要等上一层结果往返次数与数据库的属性总数成正比——这就是句柄空洞和精简属性的价值属性越少发现越快。其二抓包里 24 秒的跨度内问路流量其实很稀疏说明发现过程穿插在业务间隙进行主机并不会长时间霸占链路做纯发现。对设备端而言发现期间要保证每个 Read 响应都及时任何一次慢响应都会拖长整个开门流程。六、代码视角把表变成内存里的数组示例数据库落到固件里就是一个属性数组。下面用 Python 把表 A.1 的骨架复刻出来并实现一个简化版的 Read By Group Type 响应器import struct PROP_READ, PROP_WWR, PROP_WRITE, PROP_NOTIFY 0x02, 0x04, 0x08, 0x10 def uuid16(u): return struct.pack(H, u) # 短 UUID小端 def char_decl(props, value_handle, uuid_bytes): # 特征声明属性值权限位 值句柄(小端) UUID return bytes([props]) struct.pack(H, value_handle) uuid_bytes # 按示例数据库顺序登记属性(句柄, 类型UUID, 权限, 值) DB [ (0x0100, uuid16(0x2800), None, uuid16(0x1812)), # Primary Service (0x0102, uuid16(0x2803), None, char_decl(PROP_READ, 0x0103, uuid16(0x2A4C))), (0x0103, uuid16(0x2A4C), PROP_READ, b\x11\x01\x00), # HID Information: 1.1.1, 无本地化 (0x0104, uuid16(0x2803), None, char_decl(PROP_READ | PROP_WRITE | PROP_NOTIFY, 0x0105, uuid16(0x2A22))), (0x0106, uuid16(0x2902), PROP_READ | PROP_WRITE, b\x00\x00), # CCCD (0x0109, uuid16(0x2803), None, char_decl(PROP_READ, 0x010A, uuid16(0x2A4B))), (0x010A, uuid16(0x2A4B), PROP_READ, b...report descriptor...), (0x0120, uuid16(0x2803), None, char_decl(PROP_READ | PROP_WRITE | PROP_NOTIFY, 0x0121, uuid16(0x2A4D))), (0x0123, uuid16(0x2908), PROP_READ, bytes([0x02, 0x01])), # Report Ref: ID2 Input (0x0133, uuid16(0x2908), PROP_READ, bytes([0x01, 0x03])), # Report Ref: ID1 Feature (0x0140, uuid16(0x2800), None, uuid16(0x180F)), # Battery Service (0x0143, uuid16(0x2908), PROP_READ, bytes([0x03, 0x01])), # Report Ref: ID3 Input ] def read_by_group_type(start, end, svc_uuid): # 简化版返回句柄范围内匹配的服务声明 resp b for h, t, _, v in DB: if start h end and t uuid16(0x2800) and v svc_uuid: resp struct.pack(H, h) v return resp # 主机问路0x0001 到 0xFFFF 找 HID 服务 print(read_by_group_type(0x0001, 0xFFFF, uuid16(0x1812)).hex()) # 输出: 0100 1218 - 句柄0x0100起UUID 0x1812小端这段骨架展示了三个实现要点特征声明属性值是权限字节加值句柄加 UUID 的三元组全部小端Report Reference 描述符两个字节分别装 ID 和类型Read By Group Type 的响应是句柄加服务 UUID 的拼接。真实的协议栈当然比这复杂——要处理 128 位 UUID、动态属性、安全权限——但数据库的本质就是这张按句柄排序的静态表读请求进来按句柄二分查找写请求进来校验权限后更新值。七、收个尾到这里HID over GATT 的四篇拆解就完整了。从底层的连接契约到特征表的结构规则再到 SCI 的调速机制最后落到这份句柄地图。回头看附录这张表是全规范的浓缩三明治式的属性结构、条件号决定的特征取舍、外部报表的双端声明、Report Reference 的二元组身份、句柄空洞的规划智慧全都在一张表里和平共处。做 HID 设备开发动手写代码之前先把这张表在纸上画一遍句柄段怎么分、描述符挂哪里、哪些特征可以有通知画清楚了实现就顺了。八、测验1. 主机通过 GATT 发现HIDService 后还需要哪些发现事务才能拿到全部 Report Reference 描述符为什么 Read By Group Type 不够用某手机大厂BLE面试真题GATT 发现流程方向答还需要 Read By Type 事务枚举特征声明拿到每个特征的值句柄和 UUID再用 Find Information 事务在特征值句柄之后、下一特征声明之前扫描描述符。Read By Group Type 只能返回主服务声明粒度停留在服务层特征声明要用 Read By Type 按 Characteristic 类型过滤而 Report Reference 这类描述符既不是服务也不是特征声明只能靠 Find Information 按句柄范围枚举。三层事务各管一层这是 GATT 发现的基本分层任何一层都替代不了另一层。2. 示例数据库中Output报表和 Feature 报表的 Report ID 都是 1这会导致主机解析混淆吗报表的完整身份由什么构成答不会混淆。报表的完整身份是类型加 ID 的二元组而不是单独的 ID。USB HID 的 Get_Report、Set_Report 请求把报表类型放在高位字节、Report ID 放在低位字节类型不同即使 ID 相同也指向不同的报表。HIDS 的 Report Reference 描述符用两个字节分别承载 ID 和类型正是这个语义的翻译。因此 ID 的唯一性要求只在同类型报表之间成立输入报表各自 ID 互异输出和特征报表可以独立复用编号。混淆只会发生在把 ID 当全局唯一标识来用的错误设计里。3. 固件升级要在HIDService 中新增一个特征直接把新属性追加到服务末尾会导致什么问题示例数据库的句柄空洞给出了什么方案答如果新属性插在已有属性中间后续所有属性句柄都会重排。很多主机平台在绑定期间缓存特征句柄和 CCCD 状态句柄重排后缓存全部指向错误的属性表现为订阅失效、写入错乱主机端只能重新做完整服务发现才能恢复。示例数据库在服务内的功能块之间预留了句柄空洞新特征原地插入即可前后属性的句柄纹丝不动。工程实践是按功能块分配句柄段、每段留足余量保证整个产品生命周期内句柄地图稳定。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑