【HIDS】规范精讲[2]:一枚 Report 的诞生——HID 服务端特征全解剖

上一篇把 HID over GATT 的底层契约讲清楚了,这篇进入服务的内部。一个 HID 设备连上主机后,主机要读什么、订什么、写什么,全由服务端的十几个特征决定。这些特征各有各的脾气:有的全服务只能有一份,有的能开无数个实例;有的必须挂订阅描述符,有的挂了反而违规。把每个特征的定位、约束和相互关系吃透,HID 服务端的实现和调试才算真正入门。


目录

[一、一场从 USB 到 GATT 的翻译工程](#一、一场从 USB 到 GATT 的翻译工程)

二、服务的骨架:一张全景表和五个条件号

三、双模式设计:正常模式与安全模式

[四、Report 特征:一条铁路跑三种列车](#四、Report 特征:一条铁路跑三种列车)

[4.1 通知的长度天花板](#4.1 通知的长度天花板)

[4.2 身份证:Report Reference 描述符](#4.2 身份证:Report Reference 描述符)

[4.3 谁能挂 CCCD:一条铁律](#4.3 谁能挂 CCCD:一条铁律)

[五、Report Map:设备的自述书](#五、Report Map:设备的自述书)

[5.1 External Report Reference:指向外部世界的路标](#5.1 External Report Reference:指向外部世界的路标)

[六、Boot 三兄弟:固定格式的应急通道](#六、Boot 三兄弟:固定格式的应急通道)

[七、名片与开关:HID Information 和 HID Control Point](#七、名片与开关:HID Information 和 HID Control Point)

[7.1 出厂铭牌:HID Information](#7.1 出厂铭牌:HID Information)

[7.2 单向开关:HID Control Point](#7.2 单向开关:HID Control Point)

[八、SCI 双子星:协商档位的说明书与挡位表](#八、SCI 双子星:协商档位的说明书与挡位表)

九、空口:推送通道与写入通道的流量画像

十、代码视角:把特征表变成实现清单

十一、把整张图拼起来

十二、测验


一、一场从 USB 到 GATT 的翻译工程

HID 不是蓝牙的原创。键盘、鼠标怎么上报按键、怎么描述自己,USB 时代已经定了一套完整的规范。BLE 的 HID Service 本质上做了一次翻译:把 USB HID 的概念逐个映射成 GATT 的特征和描述符。规范里有一张图把这次翻译画得非常透彻:

左边是 USB HID 世界:一份 Report Descriptor 描述了设备的全部数据格式,包括顶层 Collection、Usage 标签、Report ID、报文格式和类型。右边是 HID Service 世界,翻译规则分三步。

(1)第一步,Report Descriptor 整体翻译成 Report Map 特征值。USB 里的那份描述符原封不动地装进 GATT 特征,格式不变,只是换了存放位置。

(2)第二步, USB **的每一条 Report 翻译成一个 Report 特征实例。**区分它们靠 Report Reference 描述符里的两个字段:Report ID 和 Report Type。这相当于给每条数据通道发了张身份证。

(3)第三步,有个特殊情形:如果 Report Map 里描述了某个报表,但数据不走 HID Service 自己的 Report 特征,而是走别的服务里的特征(比如电池服务里的某个特征),就需要 External Report Reference 描述符出马,把外部特征的 UUID 登记在案,主机顺着这个 UUID 就能找到数据真正的出入口。

这个三方结构是整个 HID Service 的骨架,后面所有特征都围着它转。

二、服务的骨架:一张全景表和五个条件号

HID Service 的服务 UUID 固定为 0x1812。规范只有一句:

The service UUID shall be set to ‹‹HID Service››. The UUID assigned to ‹‹HID Service›› is defined in 4.

真正的内容在特征全景表里。这张表信息密度极高,值得完整看一遍:

表给了五个条件号,这是读懂全表的钥匙:

  • C.1:如果支持 Report 特征,至少要支持一种报表类型。也就是说 Report 这个特征本身是可选的,但一旦要做,就不能只做个空壳。

  • C.2:键盘类设备强制支持 Boot Keyboard 的输入和输出特征,非键盘设备禁止支持。

  • C.3:鼠标类设备强制支持 Boot Mouse Input Report,非鼠标设备禁止支持。

  • C.4:支持 Boot Protocol Mode 的设备强制实现 Protocol Mode 特征,否则可选。

  • C.5:支持 HID SCI 特性的设备强制实现 SCI Information 和 SCI Mode 两个特征,否则禁止。

这张表藏着 HID Service 设计的一个鲜明特点:强制项极少,条件项极多。无条件强制的只有 Report Map、HID Information、HID Control Point 三个特征。其他全部看设备身份说话------你是键盘才有 Boot 键盘特征,你是鼠标才有 Boot 鼠标特征,你支持 SCI 才有 SCI 特征。这种设计让一个遥控器和一只游戏手柄可以共享同一套服务框架,又各自干干净净,不带用不上的累赘。

还有一个容易忽略的注释:表里所有特征的安全权限都是 None,意思是 HID Service 自身不强制任何安全等级。但注释同时说,利用这个服务的上层 Profile 可以在它之上叠加更严格的安全要求。实现时别把 None 理解成不需要加密配对------手机平台普遍要求 HID 连接走加密链路,这是上层的要求,不与服务冲突。

表头里还有一句总纲:除非特别说明,每个特征在一份 HID Service 里只允许一个实例。唯一的例外就是 Report,它可以按需开出任意多个实例。这个一多对比决定了实现架构------其他特征可以做成服务的固定成员,Report 必须做成可注册的动态列表,服务端框架从第一天就要按多实例设计。

声明层的结构也值得看一眼。规范给每个特征都配了标准的属性表声明模板:句柄占位、特征声明属性、特征属性位、特征值句柄、特征 UUID,五件套一个不少。这些声明本身没有悬念,真正决定行为的是属性位组合和挂载的描述符,也就是前面那两张属性表的全部内容。

三、双模式设计:正常模式与安全模式

HID 设备有两种工作模式,规范给了一张对照图:

Report Protocol Mode 是正常工作模式,数据格式由 Report Map 自由定义,功能丰富的设备可以同时暴露键盘、鼠标、多媒体键等多种报表。Boot Protocol Mode 则像系统的安全模式:格式固定、长度固定,只有最基础的键盘和鼠标报文。

**为什么要有 Boot 模式?**想想电脑装机时的场景:操作系统还没加载完,USB HID 的驱动还没起来,但 BIOS 需要响应键盘输入。BIOS 不可能去解析每家厂商自定义的 Report Map,它只认一种固定格式。蓝牙的世界同理------主机在协议栈尚未完全就绪、或者不想解析复杂 Report Map 的场合,可以切到 Boot 模式,用约定俗成的固定格式收数据。

模式切换靠 Protocol Mode 特征控制:

0x00 是 Boot 模式,0x01 是 Report 模式(也是每个 HID 设备的默认模式),其余值保留。规范特别强调,设备只有在收到写入 0x00 之后才允许进入 Boot 模式------主机不下指令,设备不能自己切。而且每次连接建立后,模式必须重置回默认值 Report 模式,上一次连接切过的模式不留到下一次。

读写这个特征的动作,规范直接类比为 USB 的 Get Protocol 和 Set Protocol 请求。读用 Read Characteristic Value 或 Read Using Characteristic UUID,写只用 Write Without Response------切换模式这种高频低风险操作,不值得为一来一回的响应浪费时间。

四、Report 特征:一条铁路跑三种列车

Report 是 HID Service 的数据主角,也是唯一允许多实例的特征。同一个服务里可以挂多个 Report 特征,分别承载不同 Report ID、不同类型的报表。

它的属性位不是写死的,而是随承载的报表类型变化:

三种类型的属性差异值得逐行琢磨:

Input Report:Read 强制、Notify 强制、Write 可选、Write Without Response 禁止。输入报表是设备的看家本领------按键、位移、传感器数据,靠通知实时推给主机。

Output Report:Read、Write、Write Without Response 全强制,Notify 禁止。输出报表是主机写给设备的------键盘 LED、指示灯状态,方向是主机到设备,自然用不上通知。

Feature Report:只有 Read 和 Write,双向无响应写入都被禁止。Feature 报表是配置类数据,低频且重要,值得用带确认的写。

这张表背后还藏着一层 USB 翻译逻辑。USB HID 有 Control 和 Interrupt 两个端点,低时延数据走 Interrupt,控制类请求走 Control。到了 GATT 世界,L2CAP 只有一条固定通道,端点的概念没了,规范用属性位组合来还原端点的功能划分:

读 Input 报表对应 USB 的 Get_Report(Input),写对应 Set_Report(Input),通知对应 Data Input。Output 报表的无响应写对应 USB 的 Data Output。每一条 GATT 操作都能在 USB HID 里找到出处,主机的 HID 类驱动因此可以几乎无改动地复用------这就是 HID over GATT 兼容性设计的精髓。

4.1 通知的长度天花板

关于 Input 报表的通知,规范有一条必须划重点的注释:

Note: Notification of characteristic values can contain at most ATT_MTU-3 bytes of data by definition. Data beyond ATT_MTU-3 bytes long is not included in a notification, and must instead be read using the GATT Read Long Characteristic Value sub-procedure.

ATT 默认 MTU 是 23 字节,通知最多只能装 20 字节有效数据。报表超长怎么办?规范给的兜底路径是主机再发一次 Read Long。但这条路有个致命隐患:报表数据是活的,主机读长值的过程中设备端数据可能已经变了,读回来的数据是过期的,中间那段变化直接丢失。

所以规范用 strongly recommended 给出了真正的解法:设备端支持足够大的 ATT_MTU,让最大的报表也能在一次通知里装完。工程上这条建议要当成强制来执行------现代手机平台的 MTU 普遍能协商到 185、247 甚至 517 字节,设备端主动请求或接受大 MTU,是保证 HID 数据完整性的第一道闸门。

4.2 身份证:Report Reference 描述符

每个 Report 特征必须挂一个 Report Reference 描述符,声明自己承载的是哪条报表:

第一个字节是 Report ID,第二个字节是 Report Type:0x01 输入、0x02 输出、0x03 特征,0x00 被明确列为 Prohibited,永远不能出现。

Report ID 有一条使用规则:同一类型存在多个 Report 特征实例时,Report ID 必须非零。反过来理解,如果某类型只有一个实例,Report ID 可以是 0。这个设计的意义在于主机侧的解析------Report Map 里每条报表定义都带 Report ID,主机靠 ID 把 ATT 层的特征实例和描述符层的数据格式一一对上号。ID 一旦配错或重复,主机就会把按键数据解析成乱码。

4.3 谁能挂 CCCD:一条铁律

Client Characteristic Configuration 描述符的挂载规则是单向的:

A Client Characteristic Configuration descriptor shall be included in each Report characteristic definition where the data contained in the Report characteristic value refers to an Input Report.

No Client Characteristic Configuration descriptor shall exist for a Report characteristic containing Output Report data or Feature Report data.

Input 报表必须有 CCCD,Output 和 Feature 报表禁止有。这不是偏好,是结构性的规定。CCCD 是订阅通知的开关,只有需要推数据的特征才需要订阅;给 Output 特征挂 CCCD 等于宣布要通知一条方向相反的数据,属性表里 Notify 已经被禁止,CCCD 自然无意义。蓝牙认证测试里这类结构性错误会被直接抓出来。

五、Report Map:设备的自述书

Report Map 特征只有 Read 一个属性,全服务仅一份,长度上限 512 字节。它的值就是 USB HID 的 Report Descriptor 原文------一份用 HID 描述符语言写成的自我介绍,里面说清设备有哪些功能(键盘、鼠标、音量键)、每条报表长什么样(哪个字节是修饰键、哪个比特是左键)、每条报表的 Report ID 和类型。

主机读完这份自述书,不需要任何先验知识就能解析设备发出的每一条报表。这也是 HID 生态即插即用的根基:新键盘插上就能用,因为描述自己的语言是全世界统一的。

描述符里 512 字节的限制值得留意。功能简单的设备几十字节就够了,但多合一设备(键盘加触摸板加媒体键加自定义宏)的描述符可能逼近上限。设计 Report Map 时既要完整描述功能,也要控制体积,超出 512 字节的部分主机读不到,等于白写。

5.1 External Report Reference:指向外部世界的路标

Report Map 的可选描述符里有一个特殊角色,规范的声明和取值定义合在一起看:

External Report Reference 描述符的值是一个 2 字节的特征 UUID。它的用途:当 Report Map 里描述的某条报表,数据实际由 HID Service 之外的特征传输时,把这个外部特征的 UUID 登记在这里,主机读到它就知道去哪个服务收发这条报表。

典型场景是厂商自定义的数据通道------比如设备的扩展传感器数据,协议上属于 HID 报表体系,但实现上放在了自家的私有服务里。没有这个描述符,主机解析 Report Map 时会认为这条报表的数据走标准 Report 特征,结果找不到对应的实例,数据链路直接断裂。

六、Boot 三兄弟:固定格式的应急通道

Boot 模式的数据传输由三个专用特征承担:Boot Keyboard Input Report、Boot Keyboard Output Report、Boot Mouse Input Report。它们与 Report 特征的最大区别是格式写死在 USB HID Usage Tables 里,不需要 Report Map 描述,也不需要 Report Reference 身份证。

(1)Boot Keyboard Input Report:Read 和 Notify 强制,Write 可选。格式是固定的键盘报文------第一个字节修饰键(Ctrl、Shift、Alt、GUI 四组共 8 个比特),第二个字节保留,后六个字节是被按下的键码,最多同时上报六键。

(2)Boot Keyboard Output Report:Read、Write、Write Without Response 全强制。典型的用途就是 LED------大写锁定、数字锁定、滚动锁定三个比特。

(3)Boot Mouse Input Report:Read 和 Notify 强制。固定格式承载按键状态和位移增量。

三个特征的条件号不同:键盘的两个是 C.2(键盘设备强制),鼠标的一个是 C.3(鼠标设备强制)。一个设备可以是纯键盘、纯鼠标,或键盘加鼠标的复合体,但不该出现的 Boot 特征必须排除------一只鼠标的 HID Service 里出现 Boot Keyboard Output Report 就是违规。

CCC 的规则与 Report 特征一致:两个 Input 特征必须挂 CCCD,Output 特征禁止挂。

把 Boot 键盘输入报文的格式落实到字节,长这样:

复制代码
def build_boot_keyboard_report(modifiers: int, keycodes: list) -> bytes:
    # 固定 8 字节:修饰键 + 保留 + 6 个键码槽位
    assert len(keycodes) <= 6
    kc = (keycodes + [0] * 6)[:6]
    return bytes([modifiers, 0x00] + kc)

# 按下左 Shift(a=0x02 修饰键) + A(0x04)
report = build_boot_keyboard_report(0x02, [0x04])
# 发送到 Boot Keyboard Input Report 特征,走 Notify

鼠标报文同理是按键比特加两个(或三个)位移字节。正因为格式全球统一,Boot 模式才能作为所有主机的最小公约数存在。也要看到它的局限:八键无冲、无媒体键、无自定义数据,正常使用迟早要回到 Report 模式。Boot 模式是逃生门,不是主通道。

七、名片与开关: HID Information 和 HID Control Point

7.1 出厂铭牌: HID Information

这个特征只读、仅一份,是设备的出厂铭牌:

四个字节里有三块信息。bcdHID 是设备实现的 USB HID 规范版本号,用 BCD 编码,比如 0x0111 表示 1.1.1 版。bCountryCode 是本地化国家码,绝大多数硬件不本地化,填 0x00。

Flags 字段的四个比特各有深意。Bit 0 RemoteWake 表示设备能否唤醒主机------键盘按个键把休眠的电脑叫醒,靠的就是这个能力声明。Bit 1 NormallyConnectable 表示设备在已绑定未连接的状态下会不会发广播------主机据此判断能不能随时主动回连。Bit 2 和 Bit 3 是 v1.1 新增的 SCI 支持声明,分别表示设备是否支持 SCI 特性和 SCI 低功耗模式。

规范特别强调这个特征的值是静态的,主机可以在绑定关系存续期间放心缓存。还要求设备上所有 HID Service 实例的 HID Information 必须完全一致------一台设备暴露多个 HID 服务时,铭牌不能自相矛盾。

7.2 单向开关: HID Control Point

这个特征只写不读,仅支持 Write Without Response 一个属性,是主机发给设备的指令通道:

0x00 和 0x01 是一对经典指令:Suspend 告诉设备主机要挂起了,设备应该关闭 LED、降低功耗、抑制报表上报;Exit Suspend 则是解除挂起恢复工作。这对指令源自传统 HID Profile 的电源管理设计------主机休眠了,鼠标乱动不该点亮任何东西。

0x02 到 0x05 是 v1.1 新增的四条 SCI 模式切换指令:Default、Fast、Low Power、Full Range 四种档位。主机根据使用场景动态调档------打字时用 Default 省电,游戏时切 Fast 抢延迟,静置时进 Low Power 熬夜待机。

这个特征有两个容易踩坑的设计细节。其一,指令没有响应码,写入即生效,设备不回任何 ATT 层的确认(Write Without Response 本身就没有响应)。其二,设备可以用另一种方式间接应答:如果 SCI Mode 特征的通知已订阅且模式切换成功,设备发一条 SCI Mode 值变化的通知,主机收到它就知道切换落地了。这种用数据特征的变化通知来确认控制指令的设计,在 GATT 世界里很常见,值得记住。

Suspend 这对指令的设备侧行为也值得展开。主机发出 Suspend 后,设备应当进入低功耗姿态:关掉 LED、停止或抑制非必要报表、放慢轮询。但有一条底线不能破------设备的检测能力不能关。无线键盘在挂起状态按下任意键,除了正常上报按键,往往还承担着唤醒主机的职责,这时 RemoteWake 能力声明(HID Information 的 Bit 0)就有了用武之地。规范把电源管理的主动权完全放在主机侧,设备只执行不决策,收到 Exit Suspend 才允许满血复活。实现时常见的错误是设备自作主张在空闲若干秒后自行深度休眠、不再响应------省电没错,但必须让主机知情,或者至少保证射频监听不中断,否则主机下一次写入会石沉大海。

八、SCI 双子星:协商档位的说明书与挡位表

v1.1 新增的 SCI 体系由两个特征支撑。

HID SCI Information 是只读的静态说明书,告诉主机自己支持哪些连接间隔档位:

结构是开头的两个字节加若干组三元组。第一个字段是设备支持的最小连接间隔,单位 125 微秒,取值 0x03 到 0x3C,对应 375 微秒到 7.5 毫秒。接着是组数 Num Groups,每组由 Min、Max、Stride 三个 uint16 组成,描述一段连续可用的连接间隔范围和分辨率。

这套分组设计比想象中实用。设备的射频和时钟能力往往不是全域连续的------某些硬件能跑 1 毫秒但跑不了 1.5 毫秒,另一些档位只能按 2 毫秒步进。分组描述让主机挑档位时一步命中设备真实支持的值,避免反复下发连接参数更新请求被拒。规范还说明这些值与控制器命令 LE Read Minimum Supported Connection Interval 的返回参数同一定义,设备可以直接把控制器的能力透传上来,也可以按自身的时延需求调整。

举个具体的编码例子。假设某设备的档位策略是:最低能跑 0.75 毫秒,支持两段范围------0.75 到 7.5 毫秒之间按 0.25 毫秒步进,以及 15 到 30 毫秒之间按 5 毫秒步进,那么它的特征值应编码为:最小间隔字节填 0x06(0.75 毫秒除以 125 微秒),组数填 0x02,第一组 Min 等于 6、Max 等于 60、Stride 等于 2,第二组 Min 等于 120、Max 等于 240、Stride 等于 40,全部按小端 uint16 排列。主机拿到这串字节,可用档位一目了然,协商一次成功。这也解释了为什么这个特征允许被缓存------档位表描述的是硬件能力,整个绑定周期内基本不会变。

HID SCI Mode 则是当前的挡位表,可读可订阅:

0x00 是 None,0x02 到 0x05 对应 Default、Fast、Low Power、Full Range 四档。每条连接建立时必须重置为 None,表示本次连接尚未启用 SCI------模式不跨连接记忆,主机每次连接后想用 SCI 都要重新走一遍 Control Point 指令。

挡位变化的时序也有讲究:特征值的变化必须发生在 SCI 模式切换真正完成之后。切换失败,特征值保持原样。这个先后顺序保证了主机读到或收到通知的任何新值都是已经生效的事实,而不是一个计划。订阅了通知的主机无需轮询,挡位一变就知道。

SCI Mode 的连接重置规则还隐含了一条主机的最佳实践:每次连接建立后,主机应当先读一次 SCI Mode 确认是 None,再决定是否订阅通知、是否发档位切换指令。如果主机在设备还没就绪时就下发 Enable SCI 指令,设备可能因底层连接参数协商尚未完成而拒绝或静默失败,主机端表现为指令发了没效果,排查起来非常费时。正确的节奏是连接、加密、发现服务、订阅 SCI Mode 通知、读 SCI Information 拿档位表、最后发档位指令,一步不乱。

九、空口:推送通道与写入通道的流量画像

HID 的数据面本质上就是两条 GATT 通道:设备到主机的通知流(Input 报表),主机到设备的无响应写流(Output 报表和控制指令)。一份真实的业务抓包恰好把这两条通道的流量形态展示得淋漓尽致。下图统计了约两分半钟内两种报文的时间分布:

上图是通知报文的分布,下图是无响应写报文的分布。三个特征一眼可辨。

第一,两条通道的负载极不均衡。通知 467 包,无响应写 4253 包,差出一个数量级。这个业务的流向以主机下发为主------对应到 HID 场景,Output 报表密集的设备(比如带灯效的键盘)也会呈现类似的写入洪流。

第二,流量的突发性。约 118 秒到 135 秒之间出现持续高强度双向突发,通知每 bin 高达约 20 包、写命令冲到每 bin 250 包以上,此前则是稀疏的间歇交互。这正是 HID 类业务的真实节奏:静默期居多,交互瞬间爆发。服务端要按峰值设计缓冲和调度,不能按均值。

第三,爆发期的承载能力。这份链路在连接初期就把链路层 PDU 协商到 251 字节、ATT MTU 协商到更大值,爆发期每个包都能装满有效载荷。对照前文 ATT_MTU-3 的通知天花板,MTU 协商的价值在这里得到直观体现------MTU 越大,同样数据量所需的包数越少,单位时间的吞吐越高,报表延迟越低。

**第四,写命令全部采用无响应形态。**整个抓包期间数千条写指令没有一条带响应回执,可靠性完全依赖链路层重传。这与 HID Control Point、Protocol Mode、Output 报表全部强制或仅允许 Write Without Response 的设计一脉相承------GATT 层的可靠性预算就这样被规范和实现共同压到了链路层。

还可以顺手验证一个前文结论:整段抓包里 MTU 交换只在连接初期发生了一次,此后再无变更。这说明 MTU 是会话级的会话参数,连接建立时定一次,之后所有通知和写操作的载荷上限就此锁定。对 HID 设备的启示是,MTU 策略没有试错机会,连接窗口里那一次交换就是全部机会,设备端固件要在连接建立的第一时间准备好自己的 MTU 诉求。

十、代码视角:把特征表变成实现清单

HID 服务端的骨架可以用一段代码完整表达。下面用 Python 描述特征注册和几个核心数据的解析逻辑:

复制代码
import struct

# HID Service 特征 UUID(16 位短 UUID 低段)
HIDS_UUID        = 0x1812
REPORT_MAP       = 0x2A4B   # Report Map,仅一份,Read,<=512 字节
HID_INFO         = 0x2A4C   # HID Information,仅一份,Read
HID_CTRL_POINT   = 0x2A4E   # HID Control Point,仅一份,WriteWithoutResponse
PROTOCOL_MODE    = 0x2A4E + 0x11  # 示意,实际以 SIG 分配为准
REPORT           = 0x2A4D   # Report,可多实例
BOOT_KB_IN       = 0x2A22   # Boot Keyboard Input Report
BOOT_KB_OUT      = 0x2A32   # Boot Keyboard Output Report
BOOT_MOUSE_IN    = 0x2A33   # Boot Mouse Input Report

REPORT_TYPE_INPUT, REPORT_TYPE_OUTPUT, REPORT_TYPE_FEATURE = 1, 2, 3

def build_report_reference(report_id: int, report_type: int) -> bytes:
    # Report Reference 描述符:1 字节 ID + 1 字节 Type
    # Type 禁止为 0;同类型多实例时 ID 必须非零
    assert report_type in (REPORT_TYPE_INPUT, REPORT_TYPE_OUTPUT, REPORT_TYPE_FEATURE)
    if report_id == 0:
        assert count_instances(report_type) == 1, 'multi-instance requires nonzero ID'
    return struct.pack('<BB', report_id, report_type)

def parse_report_map(raw: bytes) -> list:
    # Report Map 即 USB HID Report Descriptor 原文
    # 逐项解析 short item:高 4 位 tag,低 2 位 size
    items, i = [], 0
    while i < len(raw):
        head = raw[i]; i += 1
        size = head & 0x03
        if size == 3:            # 11 表示 4 字节
            size = 4
        tag = head & 0xFC
        value = int.from_bytes(raw[i:i + size], 'little')  # 小端
        i += size
        items.append((tag, value))
    return items

def build_hid_info(bcd_hid: int, country: int,
                   remote_wake: bool, normally_connectable: bool,
                   sci_supported: bool, sci_low_power: bool) -> bytes:
    flags = (remote_wake          & 1) | \
            ((normally_connectable & 1) << 1) | \
            ((sci_supported       & 1) << 2) | \
            ((sci_low_power       & 1) << 3)
    # bcdHID 小端 2 字节 + 国家码 1 字节 + Flags 1 字节
    return struct.pack('<HBB', bcd_hid, country, flags)

def on_control_point_write(value: int):
    if value == 0x00:
        enter_suspend()          # 关灯、降频、抑制报表
    elif value == 0x01:
        exit_suspend()
    elif 0x02 <= value <= 0x05:
        # SCI 四档:Default/Fast/Low Power/Full Range
        # 切换成功后:更新本地模式,并若已订阅则 notify SCI Mode 新值
        ok = request_sci_mode(value - 0x02)
        if ok and sci_mode_notifying():
            notify(SCI_MODE_HANDLE, pack_sci_mode(current_sci_mode()))
    # 0x06-0xFF 为 RFU:忽略,不报错

这段骨架体现了三条实现纪律:描述符和特征值的每个字段都按小端打包;Report Reference 的合法性在注册时校验而不是运行时;Control Point 收到 RFU 值静默忽略。真实的工程还要加上 MTU 协商后的报表分包检查、Boot 模式与 Report 模式的互斥切换、Suspend 状态下的报表抑制等状态机,但万变不离这张特征表。

十一 、把整张图拼起来

回头看,HID Service 的十几个特征各司其职又环环相扣。Report Map 是自述书,主机靠它从零开始理解设备;Report 特征是承运车队,每辆车挂的 Report Reference 就是行车证;CCCD 只装在回程车(Input)上,去程车(Output)和调度车(Feature)没有订阅一说。Protocol Mode 是模式开关,Boot 三兄弟是格式固定的应急通道。HID Information 是出厂铭牌,静态可缓存;HID Control Point 是单向指令口,发令不回执,靠 SCI Mode 的通知来间接确认。SCI 双子星一个列出可用的档位表,一个实时报出当前挡位。

设计一个 HID 设备时,这套结构可以直接变成 checklist:确定设备身份(键盘、鼠标、复合),决定 Boot 特征的有无;枚举所有报表类型和 Report ID,规划 Report 实例;评估报表最大长度,锁定 MTU 策略;判断是否支持 SCI,决定 SCI 特征和 Flags 声明。每一项的答案都能从这篇文章的对应位置找到依据。

调试阶段还有一张高频问题速查表。现象是主机解析报表乱码,优先查 Report Map 与 Report Reference 的 ID 映射是否一一对应,其次是主机解析端的小端假设是否成立。现象是设备发的报表主机收不到,先看 CCCD 是否写入 0x0001、Input 报表是否真的走了 Notify,再看链路是否加密(很多主机订阅了但要求加密后才收货)。现象是控制指令时灵时不灵,查一下是否误用了带响应写(特征只声明了 Write Without Response,带响应写会被拒绝),以及 Suspend 状态下设备是否还在正常监听。现象是 SCI 档位切不动,按第八节的节奏图核对时序,确认指令发在订阅和读档位表之后。把这张表贴在工位上,能省掉大部分 HID 现场支持的时间。

十二、测验

1. HID over GATT 中,为什么 Input Report 特征必须挂 CCCD,而 Output Report 和 Feature Report 特征禁止挂 CCCD?(蓝牙协议栈岗位面试真题,某芯片原厂 BLE 软件团队)

答:

CCCD 是客户端订阅通知的开关,而通知只能由服务端发起推送。HID 的数据流向由报表类型决定:Input Report 从设备流向主机,必须靠通知实时推送,因此 CCCD 必须存在且规范要求订阅后值一变就通知;Output Report 从主机流向设备,Feature Report 是双向的低频配置读写,两者都不存在服务端主动推送的场景,规范直接规定这两个类型的 Report 特征不允许存在 CCCD。这是结构性约束而非建议,蓝牙认证测试会检查特征声明与报表类型的一致性,挂错 CCCD 属于违规实现。

2. 一只复合键盘同时带多 媒体音量 键,它的 HID Service 至少需要几个 Report 特征实例?Report ID 应如何分配?

答:

至少两个。键盘按键和多媒体键是两条格式不同的 Input 报表,各自需要独立的 Report 特征实例承载。由于 Input 类型出现了多个实例,每条报表的 Report ID 必须非零,且在 Report Map 中与对应报表定义一一匹配,例如键盘报文用 ID 1、多媒体键报文用 ID 2。主机解析 Report Map 后按 ID 建立映射表,收到通知时虽无 ID 字段,但凭收到通知的特征句柄反查该实例 Report Reference 里的 ID,即可找到正确的数据格式完成解析。若两个实例误用同一个 ID,解析会串道,表现为按键触发错误的功能。

3. 主机通过 HID Control Point 发送了 Enable SCI Fast mode 指令 ,由于 Control Point 没有任何响应码,主机如何确认切换已生效?

答:

规范设计的确认路径是 SCI Mode 特征的变化通知。设备在 SCI 模式切换真正完成后才更新 SCI Mode 特征值,若主机已订阅该特征的通知,设备会在值变化时发出通知,主机收到新值即知切换成功且已生效。规范明确要求特征值变化必须发生在模式切换成功之后,切换失败则值不得改变,因此这个通知是事实确认而非意图宣告。若主机未订阅通知,也可以主动读 SCI Mode 特征轮询确认。这个设计同时解释了为什么 SCI Mode 特征的 CCCD 对支持 SCI 的主机来说实际上应当订阅------它是唯一的异步确认通道。


相关推荐
syagain_zsx2 小时前
BRpc 搭建简单 rpc 服务器
服务器·c++·网络协议·rpc·protobuf·库
Funlang2 小时前
HTTP 服务端,能写业务了
网络·网络协议·http
情 九12 小时前
Dify HTTP 请求节点实战:发送 JSON、解析响应与处理异常
网络协议·http·json·dify
ITxiaobing202316 小时前
海外广告投放的 IP 定位服务怎么选?一份基于实测数据的选型参考
网络协议·tcp/ip·php
承渊政道19 小时前
Linux网络学习【传输层协议UDP】
linux·网络·网络协议·学习·ubuntu·udp
Chery114020 小时前
DPE5500 SPI以太网协议栈芯片详解:10/100M以太网、硬件TCP/IP与国产替代方案
arm开发·网络协议·tcp/ip
zxanz121 小时前
SSL 证书该怎么申请?操作复杂吗?
网络·网络协议·ssl
syagain_zsx1 天前
WebSocket 教程:websocketpp 写 echo 服务端
linux·网络·c++·websocket·网络协议·工具库
大泽九章1 天前
HTTP协议安全入门:从请求走私到CRLF
网络协议·安全·http