news 2026/10/26 1:54:09

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

作者头像

张小明

前端开发工程师

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

前三篇把 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 Reference(ID 2、Input)。可读、可写、可通知,权限最全。

第二个实例(0x0128-0x0130)是键盘 LED 位图输出报表:声明、值、Report Reference(ID 1、Output)。没有 CCCD,权限里读和两种写齐全——主机既要能写 LED 状态,也要能读回当前状态。

第三个实例(0x0131-0x0133)是 Feature 报表:声明、值、Report Reference(ID 1、Feature)。只有读写,没有通知,也没有 CCCD。

三个实例的身份证摆在一起,能读出一个重要规则:Output 报表和 Feature 报表的 Report ID 都是 1,ID 重复了,但完全合法。因为报表身份是类型加 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 之后直接跳到 0x0120,0x0123 跳到 0x0128,0x0135 跳到 0x0138,0x0139 跳到 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 状态,句柄重排后缓存全部指向错误的属性,表现为订阅失效、写入错乱,主机端只能重新做完整服务发现才能恢复。示例数据库在服务内的功能块之间预留了句柄空洞,新特征原地插入即可,前后属性的句柄纹丝不动。工程实践是按功能块分配句柄段、每段留足余量,保证整个产品生命周期内句柄地图稳定。


版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 1:53:49

Collab-Overcooked: Benchmarking and Evaluating Large Language Models as Collaborative Agents

### 摘要 基于大语言模型(LLMs)的智能体系统在超越传统自然语言处理任务的现实应用中取得了巨大进展。本文提出了一种新的由大语言模型驱动的多智能体系统(LLM-MAS)基准测试平台 Collab-Overcooked,它基于广受欢迎的 Overcooked-AI 游戏构建,在交互式环境中设置了更具实…

作者头像 李华
网站建设 2026/10/26 1:53:35

DeepCritic: Deliberate Critique with Large Language Models for Math Reasoning (arXiv:2505.00662)

“DeepCritic: Deliberate Critique with Large Language Models”由杨文科、陈景文等人撰写。文章提出了一种两阶段框架DeepCritic,用于提升大语言模型在数学推理步骤中的批判能力,通过实验验证了该框架在错误识别基准测试中的卓越性能,为大语言模型的自动监督和推理能力提…

作者头像 李华
网站建设 2026/10/26 1:53:04

qPCR引物设计

一、获取已知引物-- PrimerBank 链接&#xff1a;PrimerBank 1、选择Keyword&#xff1b;物种可选所有种类或人或鼠&#xff1b;输入目标基因名&#xff1b; 点击Submit。 注&#xff1a;若是不确定准确人源基因名&#xff0c;可在HGNC&#xff08;人类基因官方命名库&#…

作者头像 李华
网站建设 2026/10/26 1:52:58

VS Code 新特性: 原生支持覆盖输入模式(Overtype Mode)

VS Code 在新版本中正式原生支持 Overtype&#xff08;覆盖/改写模式&#xff09;&#xff0c;在此之前该能力只能依靠 Vim 键盘映射插件实现。什么是 Overtype 覆盖模式&#xff1f; 绝大多数编辑器默认是 Insert&#xff08;插入&#xff09;模式&#xff0c;也就是我们日常打…

作者头像 李华