核心驱动程序内部原理

Surface 系统聚合器模块 (SSAM) 核心和 Surface 串行 Hub (SSH) 驱动程序的架构概述。有关 API 文档,请参阅

概述

SSAM 核心实现结构分层,在某种程度上遵循 SSH 协议结构

底层数据包传输在数据包传输层 (PTL)中实现,直接构建在内核的串行设备 (serdev) 基础设施之上。顾名思义,此层处理数据包传输逻辑,并处理诸如数据包验证、数据包确认 (ACKing)、数据包(重传)超时以及将数据包有效负载中继到更高级别层之类的事情。

在此之上是请求传输层 (RTL)。此层以命令类型的数据包有效负载为中心,即请求(从主机发送到 EC)、EC 对这些请求的响应以及事件(从 EC 发送到主机)。它专门区分事件与请求响应,将响应与其对应的请求相匹配,并实现请求超时。

控制器层构建在此之上,并且基本上决定了如何处理请求响应,尤其是事件。它提供了一个事件通知程序系统,处理事件激活/停用,为事件和异步请求完成提供了一个工作队列,并且还管理构建命令消息所需的 消息计数器 (SEQ, RQID)。基本上,此层为 SAM EC 提供了一个基本接口,供其他内核驱动程序使用。

虽然控制器层已经为其他内核驱动程序提供了一个接口,但客户端总线扩展了此接口,以通过 struct ssam_devicestruct ssam_device_driver 提供对本机 SSAM 设备(即未在 ACPI 中定义且未实现为平台设备的设备)的支持,从而简化了客户端设备和客户端驱动程序的管理。

有关客户端设备/驱动程序 API 以及其他内核驱动程序的接口选项的文档,请参阅 编写客户端驱动程序。建议在继续阅读下面的架构概述之前,先熟悉该章节和 Surface 串行 Hub 协议

数据包传输层

数据包传输层由 struct ssh_ptl 表示,并且围绕以下关键概念构建

数据包

数据包是 SSH 协议的基本传输单元。它们由数据包传输层管理,数据包传输层本质上是驱动程序的最低层,并由 SSAM 核心的其他组件构建。要由 SSAM 核心传输的数据包由 struct ssh_packet 表示(相反,核心接收的数据包没有任何特定结构,并且完全通过原始 struct ssh_frame 管理)。

此结构包含在传输层内管理数据包所需的字段,以及对包含要传输的数据(即包装在 struct ssh_frame 中的消息)的缓冲区的引用。最值得注意的是,它包含一个内部引用计数,用于管理其生命周期(可通过 ssh_packet_get()ssh_packet_put() 访问)。当此计数器达到零时,将执行通过其 struct ssh_packet_ops 引用提供给数据包的 release() 回调,然后可以释放数据包或其封闭结构(例如,struct ssh_request)。

除了 release 回调之外,struct ssh_packet_ops 引用还提供了一个 complete() 回调,该回调在数据包完成后运行,并提供此完成的状态,即成功时为零,如果发生错误,则为负 errno 值。一旦数据包已提交到数据包传输层,始终保证在 release() 回调之前执行 complete() 回调,即数据包始终会在释放之前完成,无论是成功、发生错误还是由于取消而完成。

数据包的状态通过其 state 标志 (enum ssh_packet_flags) 管理,该标志还包含数据包类型。特别是,以下位值得注意

  • SSH_PACKET_SF_LOCKED_BIT:当即将完成(通过错误或成功)时,会设置此位。它表示不应再获取数据包的任何进一步引用,并且应尽快删除任何现有引用。设置此位的进程负责从数据包队列和挂起集中删除对此数据包的任何引用。

  • SSH_PACKET_SF_COMPLETED_BIT:此位由运行 complete() 回调的进程设置,用于确保此回调仅运行一次。

  • SSH_PACKET_SF_QUEUED_BIT:当数据包在数据包队列中排队时,会设置此位;当数据包从数据包队列中出队时,会清除此位。

  • SSH_PACKET_SF_PENDING_BIT:当数据包添加到挂起集中时,会设置此位;当数据包从挂起集中删除时,会清除此位。

数据包队列

数据包队列是数据包传输层中的两个基本集合中的第一个。它是一个优先级队列,其中各个数据包的优先级基于数据包类型(主要)和尝试次数(次要)。有关优先级值的更多详细信息,请参阅 SSH_PACKET_PRIORITY()

所有要由传输层传输的数据包都必须通过 ssh_ptl_submit() 提交到此队列。请注意,这包括由传输层本身发送的控制数据包。在内部,由于超时或 EC 发送的 NAK 数据包,数据数据包可以重新提交到此队列。

挂起集

挂起集是数据包传输层中的两个基本集合中的第二个。它存储对已传输但等待 EC 确认(例如,相应的 ACK 数据包)的数据包的引用。

请注意,如果由于数据包确认超时或 NAK 而重新提交了数据包,则该数据包可能同时处于挂起和排队状态。在这种重新提交时,不会从挂起集中删除数据包。

发射器线程

发射器线程负责有关数据包传输的大部分实际工作。在每次迭代中,它(等待并)检查队列中的下一个数据包(如果有)是否可以传输,如果可以,则从队列中删除它并增加其传输尝试次数的计数器,即尝试次数。如果数据包是排序的,即需要 EC 的 ACK,则将数据包添加到挂起集中。接下来,数据包的数据将提交到 serdev 子系统。如果在提交期间发生错误或超时,则发射器线程会完成数据包,并相应地设置回调的状态值。如果数据包是未排序的,即不需要 EC 的 ACK,则发射器线程会完成数据包并显示成功。

排序的数据包的传输受到并发挂起数据包数量的限制,即限制可以并行等待 EC 的 ACK 的数据包数量。此限制当前设置为 1(有关此限制背后的原因,请参阅 Surface 串行 Hub 协议)。控制数据包(即 ACK 和 NAK)始终可以传输。

接收器线程

从 EC 接收的任何数据都会放入 FIFO 缓冲区以供进一步处理。此处理发生在接收器线程上。接收器线程将接收到的消息解析和验证到其 struct ssh_frame 和相应的有效负载中。它为接收到的消息准备并提交必要的 ACK(以及验证错误或无效数据 NAK)数据包。

此线程还处理进一步的处理,例如将 ACK 消息与相应的挂起数据包(通过序列 ID)匹配并完成它,以及启动重新提交接收到 NAK 消息时所有当前挂起的数据包(在 NAK 的情况下重新提交类似于由于超时而重新提交,有关详细信息,请参见下文)。请注意,排序的数据包的成功完成始终会在接收器线程上运行(而任何指示失败的完成都将在发生失败的进程中运行)。

任何有效负载数据都通过回调转发到下一个上层,即请求传输层。

超时清理程序

数据包确认超时是排序的数据包的每个数据包超时,在各个数据包开始(重新)传输时启动(即,此超时在发射器线程上的每次传输尝试时都会启用)。它用于触发重新提交,或者,当超过尝试次数时,触发相关数据包的取消。

此超时通过专用的清理程序任务处理,该任务本质上是一个工作项,在设置为超时的下一个数据包时(重新)计划运行。然后,工作项检查挂起数据包集中是否有任何数据包已超过超时时间,并且,如果还有剩余数据包,则将自身重新计划到下一个适当的时间点。

如果清理程序检测到超时,则如果数据包仍有一些剩余尝试次数,则会重新提交数据包,否则会以 -ETIMEDOUT 作为状态完成。请注意,在这种情况下以及由接收 NAK 触发的重新提交,意味着数据包已添加到队列中,并且尝试次数现在已递增,从而产生更高的优先级。在下一次传输尝试之前,将禁用数据包的超时,并且数据包将保留在挂起集中。

请注意,由于传输和数据包确认超时,数据包传输层始终保证取得进展,即使只是通过超时数据包,也不会完全阻塞。

并发和锁定

数据包传输层中有两个主要锁:一个锁保护对数据包队列的访问,另一个锁保护对挂起集的访问。只能在各个锁下访问和修改这些集合。如果需要访问两个集合,则必须在队列锁之前获取挂起锁,以避免死锁。

除了保护集合之外,在初始数据包提交之后,某些数据包字段只能在一个锁下访问。具体来说,只能在保持队列锁时访问数据包优先级,并且只能在保持挂起锁时访问数据包时间戳。

数据包传输层的其他部分是独立保护的。状态标志由原子位操作管理,如果必要,则由内存屏障管理。对超时清理程序工作项和到期日期的修改由它们自己的锁保护。

数据包到数据包传输层的引用 (ptl) 有点特殊。它要么在提交上层请求时设置,要么在首次提交数据包时设置(如果没有上层请求)。设置后,它不会更改其值。可能与提交并发运行的函数(即取消)不能依赖于 ptl 引用设置为设置。在这些函数中对它的访问由 READ_ONCE() 保护,而 ptl 的设置同样受到 WRITE_ONCE() 的对称性保护。

某些数据包字段可以在保护它们的各个锁之外读取,特别是用于跟踪的优先级和状态。在这些情况下,通过采用 WRITE_ONCE()READ_ONCE() 来确保正确的访问。仅当陈旧值不重要时,才允许此类只读访问。

关于更高级别层的接口,数据包提交 (ssh_ptl_submit())、数据包取消 (ssh_ptl_cancel())、数据接收 (ssh_ptl_rx_rcvbuf()) 和层关闭 (ssh_ptl_shutdown()) 始终可以相对于彼此并发执行。请注意,数据包提交不能与同一数据包本身并发运行。同样,关闭和数据接收也不能与它们自己并发运行(但可以彼此并发运行)。

请求传输层

请求传输层由 struct ssh_rtl 表示,并构建在数据包传输层之上。它处理请求,即由主机发送的包含 struct ssh_command 作为帧有效负载的 SSH 数据包。此层将对请求的响应与事件分开,事件也由 EC 通过 struct ssh_command 有效负载发送。虽然响应在此层中处理,但事件通过相应的回调中继到下一个上层,即控制器层。请求传输层围绕以下关键概念构建

请求

请求是具有命令类型有效负载的数据包,从主机发送到 EC 以从 EC 查询数据或触发其上的操作(或同时执行两者)。它们由 struct ssh_request 表示,包装底层 struct ssh_packet 存储其消息数据(即带有命令有效负载的 SSH 帧)。请注意,所有顶级表示形式(例如,struct ssam_request_sync)都构建在此结构之上。

由于 struct ssh_request 扩展了 struct ssh_packet,因此其生命周期也由数据包结构中的引用计数器管理(可以通过 ssh_request_get()ssh_request_put() 访问)。一旦计数器达到零,将调用请求的 struct ssh_request_ops 引用的 release() 回调。

请求可以有一个可选的响应,该响应同样通过带有命令类型有效负载的 SSH 消息发送(从 EC 到主机)。构造请求的一方必须知道是否期望响应,并在提供给 ssh_request_init() 的请求标志中标记这一点,以便请求传输层可以等待此响应。

struct ssh_packet 类似,struct ssh_request 也有一个通过其请求操作引用提供的 complete() 回调,并且保证在通过 ssh_rtl_submit() 提交到请求传输层后先完成然后再释放。对于没有响应的请求,一旦底层数据包已由数据包传输层成功传输(即,从数据包完成回调中),将发生成功完成。对于有响应的请求,一旦已接收到响应并通过其请求 ID 将响应与请求匹配(这发生在数据包层的数据接收回调(在接收器线程上运行)中),将发生成功完成。如果请求因错误而完成,则状态值将设置为相应的(负)errno 值。

请求的状态再次通过其 state 标志 (enum ssh_request_flags) 管理,该标志还编码请求类型。特别是,以下位值得注意

  • SSH_REQUEST_SF_LOCKED_BIT:当即将完成(通过错误或成功)时,会设置此位。它表示不应再获取请求的任何进一步引用,并且应尽快删除任何现有引用。设置此位的进程负责从请求队列和挂起集中删除对此请求的任何引用。

  • SSH_REQUEST_SF_COMPLETED_BIT:此位由运行 complete() 回调的进程设置,用于确保此回调仅运行一次。

  • SSH_REQUEST_SF_QUEUED_BIT:当请求在请求队列中排队时,会设置此位;当请求从队列中移除时,会清除此位。

  • SSH_REQUEST_SF_PENDING_BIT:当请求添加到挂起集合时,会设置此位;当请求从挂起集合中移除时,会清除此位。

请求队列

请求队列是请求传输层中的两个基本集合中的第一个。与数据包传输层的数据包队列相反,它不是优先级队列,而是遵循简单的先进先出原则。

所有要由请求传输层传输的请求必须通过 ssh_rtl_submit() 提交到此队列。提交后,请求不得重新提交,也不会在超时时自动重新提交。而是,请求以超时错误完成。如果需要,调用者可以创建并提交一个新请求进行另一次尝试,但不得再次提交同一请求。

挂起集合

挂起集合是请求传输层中的两个基本集合中的第二个。此集合存储对所有挂起请求的引用,即等待 EC 响应的请求(类似于数据包传输层的挂起集合对数据包所做的事情)。

发送器任务

当有新的请求可供传输时,会调度发送器任务。它检查请求队列中的下一个请求是否可以传输,如果可以,则将其底层数据包提交给数据包传输层。此检查确保同一时间只有有限数量的请求可以处于挂起状态,即等待响应。如果请求需要响应,则在提交其数据包之前,会将该请求添加到挂起集合中。

数据包完成回调

一旦请求的底层数据包完成,就会执行数据包完成回调。如果发生错误完成,则相应的请求将以该回调中提供的错误值完成。

成功完成数据包后,后续处理取决于请求。如果请求期望得到响应,则将其标记为已传输,并启动请求超时。如果请求不期望得到响应,则以成功完成。

数据接收回调

数据接收回调通知请求传输层,底层数据包传输层通过数据类型帧接收到数据。一般来说,这应该是指令类型的有效负载。

如果指令的请求 ID 是为事件保留的请求 ID 之一(1 到 SSH_NUM_EVENTS,包括这两个值),则将其转发到请求传输层中注册的事件回调。如果请求 ID 指示对请求的响应,则在挂起集合中查找相应的请求,如果找到并标记为已传输,则以成功完成。

超时清理器

请求-响应-超时是每个请求的超时,用于期望得到响应的请求。它用于确保请求不会无限期地等待 EC 的响应,并在底层数据包成功完成后启动。

此超时与数据包传输层上的数据包确认超时类似,是通过专用的清理器任务处理的。此任务本质上是一个工作项,(重新)调度为在下一个请求设置为超时时运行。然后,该工作项扫描挂起请求的集合,查找任何已超时的请求,并以 -ETIMEDOUT 作为状态完成它们。请求不会自动重新提交。而是,请求的发布者必须构造并提交一个新请求(如果需要)。

请注意,此超时与数据包传输和确认超时相结合,可确保请求层始终能够取得进展,即使只是通过超时数据包,也永远不会完全阻塞。

并发和锁定

与数据包传输层类似,请求传输层中有两个主要锁:一个保护对请求队列的访问,另一个保护对挂起集合的访问。这些集合只能在各自的锁下访问和修改。

请求传输层的其他部分是独立保护的。状态标志(再次)由原子位运算和(如果需要)内存屏障管理。对超时清理器工作项和到期日期的修改由其自己的锁保护。

某些请求字段可以在保护它们的各自锁之外读取,特别是用于跟踪的状态。在这些情况下,通过使用 WRITE_ONCE()READ_ONCE() 来确保正确的访问。只有在陈旧的值不重要时,才允许这种只读访问。

关于更高层的接口,请求提交(ssh_rtl_submit())、请求取消(ssh_rtl_cancel())和层关闭(ssh_rtl_shutdown())可以始终相对于彼此并发执行。请注意,请求提交不能与同一请求本身并发运行(并且每个请求也只能调用一次)。同样,关闭也不能与自身并发运行。

控制器层

控制器层扩展了请求传输层,为客户端驱动程序提供了一个易于使用的接口。它由 struct ssam_controller 和 SSH 驱动程序表示。虽然较低级别的传输层负责传输和处理数据包和请求,但控制器层承担更多的管理角色。具体来说,它处理设备初始化、电源管理和事件处理,包括通过(事件)完成系统(struct ssam_cplt)进行事件传递和注册。

事件注册

通常,在 EC 发送事件之前,主机必须显式请求事件(或者是一类事件)(HID 输入事件似乎是例外)。这是通过事件启用请求完成的(同样,一旦不再需要事件,应通过事件禁用请求禁用事件)。

用于启用(或禁用)事件的特定请求是通过事件注册表给出的,即此事件的管理机构(可以这样说),由 struct ssam_event_registry 表示。作为此请求的参数,必须提供要启用的事件的目标类别,以及(取决于事件注册表)实例 ID。如果注册表不使用此(可选)实例 ID,则该 ID 必须为零。目标类别和实例 ID 共同构成事件 ID,由 struct ssam_event_id 表示。简而言之,事件注册表和事件 ID 都是唯一标识相应类别的事件所必需的。

请注意,必须为启用事件请求提供另一个请求 ID 参数。此参数不影响要启用的事件类别,而是设置为 EC 发送的此类别的每个事件上的请求 ID (RQID)。它用于标识事件(因为仅为事件保留了有限数量的请求 ID,特别是 1 到 SSH_NUM_EVENTS,包括这两个值),并将事件映射到其特定类别。当前,控制器始终将此参数设置为 struct ssam_event_id 中指定的目标类别。

由于多个客户端驱动程序可能依赖于相同(或重叠)的事件类别,并且启用/禁用调用是严格的二进制(即开/关),因此控制器必须管理对这些事件的访问。它通过引用计数来实现,将计数器存储在基于 RB 树的映射中,并将事件注册表和 ID 作为键(没有已知的有效事件注册表和事件 ID 组合的列表)。有关详细信息,请参见 struct ssam_nfssam_nf_refcount_inc()ssam_nf_refcount_dec()

此管理与通过顶级 ssam_notifier_register()ssam_notifier_unregister() 函数进行的通知程序注册(在下一节中描述)一起完成。

事件传递

要接收事件,客户端驱动程序必须通过 ssam_notifier_register() 注册事件通知程序。这会增加该特定类别的事件的引用计数器(如上一节所述),在 EC 上启用该类别(如果尚未启用),并安装提供的通知程序回调。

通知程序回调存储在列表中,每个目标类别都有一个 (RCU) 列表(通过事件 ID 提供;注意:目标类别的数量是固定的)。除了通过事件 ID 给定的目标类别和实例 ID 之外,没有从事件注册表和事件 ID 的组合到事件类别可以提供的命令数据(目标 ID、目标类别、命令 ID 和实例 ID)的已知关联。

请注意,由于存储通知程序的方式(或者更确切地说,必须存储的方式),客户端驱动程序可能会收到它们未请求的事件,并且需要对此负责。具体来说,默认情况下,它们将收到来自同一目标类别的所有事件。为了简化处理此问题,可以在注册通知程序时请求按目标 ID(通过事件注册表提供)和实例 ID(通过事件 ID 提供)过滤事件。在执行通知程序时迭代通知程序时,会应用此筛选。

所有通知程序回调都在专用的工作队列(所谓的完成工作队列)上执行。在通过请求层中安装的回调(在数据包传输层的接收器线程上运行)收到事件后,它将被放入其各自的事件队列(struct ssam_event_queue)。从此事件队列中,该队列的完成工作项(在完成工作队列上运行)将拾取该事件并执行通知程序回调。这样做是为了避免阻塞接收器线程。

每个目标 ID 和目标类别的组合都有一个事件队列。这样做是为了确保对于相同目标 ID 和目标类别的事件,按顺序执行通知程序回调。对于具有不同目标 ID 和目标类别组合的事件,可以并行执行回调。

并发和锁定

控制器的大多数与并发相关的安全保证由较低级别的请求传输层提供。除此之外,事件(取消)注册由其自己的锁保护。

对控制器状态的访问由状态锁保护。此锁是读/写信号量。读取器部分可用于确保状态在依赖于状态保持不变的函数(例如 ssam_notifier_register()ssam_notifier_unregister()ssam_request_sync_submit() 及其派生函数)执行时不会更改,并且这种保证尚未通过其他方式(例如通过 ssam_client_bind()ssam_client_link())提供。写入器部分保护任何将更改状态的转换,即初始化、销毁、挂起和恢复。

可以在状态锁之外访问(只读)控制器状态,以针对无效的 API 使用进行冒烟测试(例如在 ssam_request_sync_submit() 中)。请注意,此类检查不应(也不会)防止所有无效用法,而是旨在帮助捕获它们。在这些情况下,通过使用 WRITE_ONCE()READ_ONCE() 来确保正确的变量访问。

假设已经满足了状态不发生变化的任何先决条件,则所有非初始化和非关闭函数都可以彼此并发运行。这包括 ssam_notifier_register()ssam_notifier_unregister()ssam_request_sync_submit() 以及所有构建在其之上的函数。