FIDL 概览

本文档简要介绍了 Fuchsia 接口定义语言 (FIDL),该语言用于描述在 Fuchsia 上运行的程序所使用的进程间通信 (IPC) 协议。本概览介绍了 FIDL 背后的概念。熟悉这些概念的开发者可以按照教程开始编写代码,也可以通过阅读语言或绑定参考文档来深入了解。

什么是 FIDL?

虽然“FIDL”代表“Fuchsia 接口定义语言”,但该词本身可用于指代许多不同的概念:

  • FIDL 传输格式:FIDL 传输格式指定了 FIDL 消息在内存中的表示方式,以便通过 IPC 进行传输
  • FIDL 语言:FIDL 语言是在 .fidl 文件中描述协议的语法
  • FIDL 编译器:FIDL 编译器会生成供程序使用和实现协议的代码
  • FIDL 绑定:FIDL 绑定是特定于语言的运行时支持库和代码生成器,可提供用于操作 FIDL 数据结构和协议的 API。

FIDL 的主要工作是让各种客户端和服务能够互操作。 通过将 IPC 机制的实现与其定义分离开来,有助于实现客户端多样性,并通过自动代码生成来简化客户端多样性。

FIDL 语言提供了一种熟悉的(虽然经过简化)类似 C 的声明语法,使服务提供商能够准确定义其协议。基本数据类型(如整数、浮点数和字符串)可以组织成更复杂的聚合结构和联合。固定数组和动态大小的向量可以由基本类型和聚合类型构造,并且这些类型都可以组合成更复杂的数据结构。

由于客户端实现目标语言(C、C++、Rust、Dart 等)的数量众多,我们不想让服务开发者为每种语言都提供协议实现。

这时,FIDL 工具链就派上用场了。服务的开发者只需创建一个 .fidl 定义文件,用于定义协议。然后,FIDL 编译器会使用此文件以任何受支持的目标语言生成客户端和服务器代码。

图:用 C++ 编写的服务器与用多种语言编写的客户端进行通信

在许多情况下,服务器只有一个实现(例如,特定服务可能在 C++ 中实现),而客户端可能有任意数量的实现,并且可以使用多种语言。

请注意,Fuchsia 操作系统本身并不了解 FIDL。FIDL 绑定在 Fuchsia 中使用标准通道通信机制。FIDL 绑定和库会针对该通道的使用方式强制执行一组语义行为和持久性格式。

FIDL 架构

从开发者的角度来看,主要组件如下:

  • FIDL 定义文件 - 这是一个文本文件(按照惯例,以 .fidl 结尾),用于定义值和协议(方法及其参数),
  • 客户端代码 - 由 FIDL 编译器 (fidlc) 工具链针对每种特定目标语言生成,以及
  • 服务器代码 - 同样由 FIDL 编译器工具链生成。

以一个非常简单的 FIDL 定义文件为例,假设有一个“回显”服务,无论客户端向服务器发送什么内容,服务器都会将其回显给客户端。

为清晰起见,我们添加了行号,这些行号不属于 .fidl 文件。

1   library fidl.examples.echo;
2
3   @discoverable
4   protocol Echo {
5       EchoString(struct {
6           value string:optional;
7       }) -> (struct {
8           response string:optional;
9       });
10  };

我们来逐行分析一下。

第 1 行:library 关键字用于为此协议定义命名空间。不同库中的 FIDL 协议可能具有相同的名称,因此命名空间用于区分它们。

第 3 行:@discoverable 属性表示应使以下协议可供客户端连接。

第 4 行:protocol 关键字引入了协议的名称,此处称为 Echo。

第 5-9 行:方法、其参数和返回值。这一行有两个不寻常之处:

  • 请注意声明 string:optional(对于 value 和 response)。string 部分表示参数是字符串(字符序列),而 optional 约束表示参数是可选的。
  • 这些参数封装在 struct 中,后者是包含方法参数的顶级类型。
  • -> 部分表示返回值,它出现在方法声明之后,而不是之前。与 C++ 或 Java 不同,一个方法可以返回多个值。

上述 FIDL 文件声明了一个名为 Echo 的协议,该协议包含一个名为 EchoString 的方法,该方法接受可为 null 的字符串并返回可为 null 的字符串。

上面的简单示例仅使用了一种数据类型,即 string,它既是方法的输入,也是方法的输出。

可能的 FIDL 数据类型非常灵活:

type MyRequest = struct {
    serial uint32;
    key string;
    options vector<uint32>;
};

上述代码声明了一个名为 MyRequest 的结构体,其中包含三个成员:一个名为 serial 的无符号 32 位整数、一个名为 key 的字符串,以及一个名为 options 的无符号 32 位整数矢量

消息传递模型

为了理解 FIDL 的消息传递,我们需要将内容分为两层,并澄清一些定义。

在底部(操作系统层),有一个异步通信方案,旨在实现发送器和接收器的独立进度:

  • 发件人 - 发送消息的一方
  • receiver - 接收消息的一方

发送消息是一种非阻塞操作:发送方发送消息,然后可以自由地继续处理,而不管接收方在做什么。

如果需要,接收器可以阻塞以等待消息。

顶层实现 FIDL 消息,并使用底层(异步)。它涉及客户端和服务器:

  • 客户端 - 发出请求(向服务器)的一方,
  • 服务器 - 处理请求(代表客户端)的一方。

在讨论消息本身时,“发送方”和“接收方”这两个术语是有意义的,因为底层通信方案不关心我们为各方分配的角色,只关心一方在发送,另一方在接收。

在讨论各方扮演的角色时,“客户端”和“服务器”这两个术语很有意义。具体而言,客户端可以在某个时间作为发送方,在另一个时间作为接收方;服务器也是如此。

实际上,在客户端/服务器交互的上下文中,这意味着有几种模型:

  1. 阻塞调用 - 客户端发送到服务器,等待回复
  2. 即发即弃 - 客户端发送到服务器,不期望回复
  3. 回调或异步调用 - 客户端向服务器发送请求,但不会阻塞;稍后会异步传送回复
  4. event - 服务器在客户端未请求数据的情况下向客户端发送数据

第一个是同步的,其余是异步的。我们将依次讨论这些内容。

客户端发送到服务器,等待回复

此模型是大多数编程语言中可用的传统“阻塞调用”或“函数调用”,但调用是通过通道完成的,因此可能会因传输级错误而失败。

从客户端的角度来看,它由一个阻塞调用组成,而服务器会执行一些处理。

图:客户端和服务器

以下是分步说明:

  1. 客户端发出调用(可选择包含数据)并阻塞。
  2. 服务器接收客户端的调用(和可选数据),并执行一定量的处理。
  3. 服务器自行决定是否回复客户端(可选择是否附带数据)。
  4. 服务器的回复会使客户端解除屏蔽。

通过异步消息传递方案实现此同步消息传递模型非常简单。请注意,在协议的底层,客户端到服务器和服务器到客户端的消息传输都是异步的。同步发生在客户端,通过让客户端阻塞直到服务器的消息到达来实现。

基本上,在此模型中,客户端和服务器已达成协议:

  • 数据流由客户端发起,
  • 客户端最多只能有一条未完成的消息,
  • 服务器只能在响应客户端消息时向客户端发送消息
  • 客户端应等待服务器响应,然后再继续。

这种阻塞模型通常用于客户端需要先获得当前请求的回复才能继续的情况。

例如,客户端可能会向服务器请求数据,但在数据到达之前无法执行任何其他有用的处理。

或者,客户端可能需要按特定顺序执行步骤,因此必须确保每个步骤都完成后再启动下一个步骤。如果发生错误,客户端可能需要执行纠正措施,具体取决于操作已进行到哪一步,这也是与每个步骤的完成情况保持同步的另一个原因。

客户端发送到服务器,无回复

此模型也称为“即发即弃”。在此模式中,客户端将消息发送到服务器,然后继续执行其操作。与阻塞模型相反,客户端不会阻塞,也不会等待响应。

此模型适用于客户端不需要(或无法)同步到其请求的处理过程的情况。

图:即发即弃;客户端发送到服务器,但不期望回复

典型的例子是日志记录系统。客户端会将日志记录信息发送到日志记录服务器(上图中的圆圈“1”和“2”),但没有理由进行屏蔽。服务器端可能会出现很多问题:

  1. 服务器正忙,目前无法处理写入请求,
  2. 媒体已满,服务器无法写入数据,
  3. 服务器出现故障
  4. 依此类推。

但是,客户端无法对这些问题执行任何操作,因此屏蔽只会产生更多问题。

客户端向服务器发送,但不屏蔽

此模型与下一个模型(“服务器向客户端发送数据,而无需客户端请求数据”)类似。

在当前模型中,客户端向服务器发送消息,但不会阻塞。不过,客户端会期望从服务器收到某种响应,但关键在于,这种响应与请求并非同步。

这在客户端/服务器交互中提供了极大的灵活性。

同步模型会强制客户端等待服务器回复,而本模型则允许客户端在服务器处理请求时执行其他操作:

图:客户端发送到服务器,但直到稍后才阻塞

此图与上图的细微差别在于,在圆圈“1”之后,客户端仍在运行。客户端会选择何时放弃 CPU;它与消息不同步。

这里实际上有两种子情况:一种是客户端仅收到一个响应,另一种是客户端可以收到多个响应。(客户端收到零个响应的模型是“即发即弃”模型,我们之前讨论过。)

单个请求,单个响应

单响应情况最接近同步模型:客户端发送消息,最终服务器回复。例如,当您知道客户端在等待服务器回复时可以执行有用的工作时,您可以使用此模型,而不是多线程。

单请求、多响应

多响应情况可用于“订阅”模型。客户端的消息会“准备”服务器,例如,请求在发生任何情况时发送通知。

然后,客户端会继续执行其业务。

一段时间后,服务器注意到客户端关注的条件已发生,因此向客户端发送消息。从客户端/服务器的角度来看,此消息是一个“回复”,客户端会异步接收该消息以响应其请求。

图:客户端发送到服务器,服务器多次回复

当另一个感兴趣的事件发生时,服务器没有理由不能发送另一条消息;这是该模型的“多重响应”版本。请注意,第二个(及后续)响应是在客户端未发送任何其他消息的情况下发送的。

请注意,客户端无需等待服务器向其发送消息。在上图中,我们展示了客户端在圆圈“3”之前处于阻塞状态,但客户端也可能一直在运行。

服务器发送给客户端,无需客户端请求数据

此模型也称为“事件”模型。

图:从服务器发送到客户端的主动发送消息

在这种模型中,客户端准备接收来自服务器的消息,但不知道何时会收到消息。这些消息不仅对客户端而言是异步的,而且(从客户端/服务器的角度来看)也是“主动发送”的,因为客户端没有明确请求这些消息(就像在上述上一个模型中那样)。

客户端指定一个函数(“事件处理函数”),以便在消息从服务器到达时调用,但除此之外,客户端会继续执行其业务。

消息由服务器自行决定(上图中的圆圈“1”和“2”)异步发送到客户端,并由客户端的指定函数处理。

请注意,在发送消息时,客户端可能已经在运行(如圆圈“1”所示),或者客户端可能无事可做,正在等待发送消息(如圆圈“2”所示)。

客户端不一定要等待消息。

异步消息传递复杂性

将异步消息传递分为上述(有些随意)类别是为了展示典型的使用模式,但并非详尽无遗。

在最常见的异步消息传递情况下,您有零条或多条客户端消息与零条或多条服务器回复松散关联。正是这种“松散关联”增加了设计流程的复杂性。

FIDL 中的 IPC 模型

现在,我们已经了解了 IPC 模型以及它们如何与 FIDL 的异步消息传递进行交互,接下来看看它们是如何定义的。

我们将向协议定义文件添加其他模型(即发即弃、异步调用或事件):

1   library fidl.examples.echo;
2
3   @discoverable
4   protocol Echo {
5       EchoString(struct {
6           value string:optional;
7       }) -> (struct {
8           response string:optional;
9       });
10
11      SendString(struct { value string:optional; });
12
13      ->ReceiveString(struct { response string:optional; });
14  };

第 5-9 行是我们在上面讨论过的 EchoString 方法,它是一个传统的函数调用消息,其中客户端使用可选字符串调用 EchoString,然后阻塞,等待服务器使用另一个可选字符串进行回复。

第 11 行是 SendString 方法。它没有 -> 返回声明,这使其成为“即发即弃”模型(仅发送),因为我们已告知 FIDL 编译器,此特定方法没有与之关联的返回值。

请注意,这里的关键不是缺少返回参数,而是缺少返回声明 - 在 SendString 后面放置“-> ()”会将含义从声明“即发即弃”样式的方法更改为声明没有任何返回参数的函数调用样式的方法。

第 13 行是 ReceiveString 方法。它略有不同,第一部分没有方法名称,而是在 -> 运算符之后给出。这会告知 FIDL 编译器,这是一个“异步调用”或“事件”模型声明。

FIDL 绑定

FIDL 工具链会接收 FIDL 协议和类型定义(如上例所示),并以每种目标语言生成可“使用”这些协议的代码。生成的代码称为 FIDL 绑定,根据语言的不同,有多种形式:

  • 原生绑定:专为高度敏感的环境(例如设备驱动程序和高吞吐量服务器)而设计,利用就地访问,避免内存分配,但可能需要开发者对协议的限制有更多的了解。
  • 惯用绑定:旨在通过将数据从传输格式复制到更易于使用的数据类型(例如堆支持的字符串或向量)来提高开发者友好度,但效率也相应地有所降低。

绑定提供了多种调用协议方法的方式,具体取决于语言:

  • 发送/接收:直接向通道读取或写入消息,无内置等待循环 (C)
  • 基于回调:收到的消息会作为事件循环中的回调异步分派(C++、Dart)
  • 基于端口:收到的消息会递送到端口或 future (Rust)
  • 同步调用:等待回复并返回回复(C++、Rust)

绑定提供以下部分或全部主要操作:

  • 编码:将原生数据结构就地转换为传输格式(与验证相结合)
  • 解码:将传输格式数据就地转换为原生数据结构(与验证相结合)
  • 复制/移动到惯用形式:将原生数据结构的内容复制到惯用数据结构中,句柄会被移动
  • 复制/移动到原生形式:将惯用数据结构的内容复制到原生数据结构中,句柄会被移动
  • 克隆:复制原生或惯用数据结构(不包含仅移动类型)
  • Call:调用协议方法

客户端实现

无论目标语言是什么,fidlc FIDL 编译器都会生成具有以下基本结构的客户端代码。

第一部分包括管理和后台处理,由以下部分组成:

  1. 提供了一些连接到服务器的方法
  2. 启动异步(“后台”)消息处理循环
  3. 异步调用样式和事件样式方法(如有)会绑定到消息循环

第二部分由传统函数调用或“即发即弃”式方法的实现组成,具体取决于目标语言。一般来说,这包括:

  1. 创建可调用 API 和声明
  2. 为每个 API 生成代码,将调用中的数据编组到适合传输到服务器的 FIDL 格式缓冲区中
  3. 生成代码以将数据传输到服务器
  4. 对于函数调用样式的调用,生成代码以:
    1. 等待服务器响应
    2. 从 FIDL 格式的缓冲区中解组数据,
    3. 通过 API 函数返回数据。

显然,由于语言实现差异,确切的步骤可能会有所不同,但基本流程就是这样。

服务器实现

fidlc FIDL 编译器还会为给定的目标语言生成服务器代码。与客户端代码一样,无论目标语言是什么,此代码都具有通用结构。代码如下:

  1. 创建一个客户端可以连接到的对象,
  2. 启动一个主处理循环,该循环会:
    1. 等待消息
    2. 通过调用实现函数来处理消息
    3. 如果指定,则向客户端发出异步回调以返回输出

在接下来的章节中,我们将详细了解每种语言的客户端和服务器代码实现。

为何使用 FIDL?

Fuchsia 广泛依赖 IPC,因为大多数功能都是在内核之外的用户空间中实现的,包括设备驱动程序等特权组件。因此,IPC 机制必须高效、确定、稳健且易于使用:

IPC 效率与在进程之间生成、传输和使用消息所需的计算开销有关。IPC 将涉及系统操作的方方面面,因此必须高效。FIDL 编译器必须生成紧凑的代码,而不会产生过多的间接或隐藏成本。在最重要的地方,它应该至少与手动编写的代码一样好。

IPC 确定性是指在已知资源范围内执行事务的能力。IPC 将被文件系统等关键系统服务广泛使用,这些服务为许多客户端提供服务,并且必须以可预测的方式运行。FIDL 传输格式必须提供强大的静态保证,例如确保结构大小和布局不变,从而减少对动态内存分配或复杂验证规则的需求。

IPC 稳健性是指需要将 IPC 视为操作系统 ABI 的重要组成部分。保持二进制稳定性至关重要。协议演进机制的设计必须保守,以免违反现有服务及其客户端的不变性,尤其是在考虑确定性需求时。FIDL 绑定必须执行有效、轻量且严格的验证。

IPC 易用性是指 IPC 协议是操作系统 API 的重要组成部分。为开发者提供良好的工效学设计,以便通过 IPC 访问服务,这一点非常重要。FIDL 代码生成器消除了手动编写 IPC 绑定的负担。此外,FIDL 代码生成器可以生成不同的绑定,以满足不同受众群体及其习惯用法的需求。

目标

FIDL 专为优化这些特性而设计。具体而言,FIDL 的设计旨在实现以下目标:

明确性

  • 描述 Zircon 上的 IPC 所使用的数据结构和协议。
  • 针对进程间通信进行了优化。虽然 FIDL 也用于持久保存到磁盘和网络传输,但其设计并未针对这些次要用例进行优化。
  • 通过 Zircon 通道在同一设备上运行的进程之间高效传输由数据(字节)和功能(句柄)组成的消息。
  • 专为促进 Zircon 基元的有效使用而设计。 虽然 FIDL 也用于其他平台(例如通过 ffx),但其设计以 Fuchsia 为先。
  • 提供用于创建、发送、接收和使用消息的便捷 API。
  • 执行充分的验证以维护协议不变量(但仅此而已)。

效率

  • 与使用手动构建的数据结构一样高效(速度和内存)。
  • 传输格式使用未压缩的原生数据类型,采用小端序和正确的对齐方式,以支持对消息内容进行就地访问。
  • 如果消息的大小是静态已知或有界的,则无需动态内存分配即可生成或使用消息。
  • 使用仅移动语义显式处理所有权。
  • 数据结构打包顺序是规范、明确的,并且具有最小填充。
  • 避免回填指针。
  • 避免成本高昂的验证。
  • 避免可能溢出的计算。
  • 利用协议请求流水线进行异步操作。
  • 结构体的大小是固定的;大小可变的数据存储在行外。
  • 结构不是自描述的;FIDL 文件描述其内容。
  • 结构体没有版本控制,但可以通过新方法扩展协议以实现演进。

工效学设计

  • 由 Fuchsia 团队维护的编程语言绑定:
    • C、新 C++、高级 C++(旧)、Dart、Rust
  • 请注意,我们将来可能需要支持其他语言,例如:
    • Java、JavaScript 等。
  • 绑定和生成的代码有原生或惯用风格,具体取决于预期应用。
  • 使用编译时代码生成功能来优化消息序列化、反序列化和验证。
  • FIDL 语法简单易懂,且与编程语言无关。
  • FIDL 提供了一个库系统,可简化其他开发者的部署和使用。
  • FIDL 表达了系统 API 所需的最常见数据类型;它并不寻求提供所有编程语言提供的所有类型的全面一对一映射。

工作流程

本部分简要介绍了使用 FIDL 描述的 IPC 协议的作者、发布者和使用者的工作流程。

编写 FIDL

基于 FIDL 的协议的作者会创建一个或多个 *.fidl 文件,以描述其数据结构、协议和方法。

FIDL 文件由作者分组到一个或多个 FIDL 库中。每个库都代表一组逻辑上相关的功能,并具有唯一的库名称。同一库中的 FIDL 文件可以隐式访问同一库中的所有其他声明。构成库的 FIDL 文件中的声明顺序并不重要。

一个库的 FIDL 文件可以通过导入另一个 FIDL 模块来访问另一个 FIDL 库中的声明。导入其他 FIDL 库后,其符号便可供使用,从而可以构建派生自这些库的协议。导入的符号必须由库名称或别名限定,以防止命名空间冲突。

发布 FIDL

基于 FIDL 的协议的发布者负责向使用者提供 FIDL 库。例如,作者可以在公共源代码库中传播 FIDL 库,也可以将其作为 SDK 的一部分进行分发。

使用者只需将 FIDL 编译器指向包含库(及其依赖项)的 FIDL 文件的目录,即可为该库生成代码。具体如何执行此操作通常由使用者的构建系统处理。

使用 FIDL

基于 FIDL 的协议的使用方使用 FIDL 编译器生成适合与其语言运行时特定绑定一起使用的代码。对于某些语言运行时,使用者可以选择几种不同风格的生成代码,这些代码在传输格式级别都可以互操作,但在源代码级别可能无法互操作。

在 Fuchsia 世界构建环境中,每个库的各个 FIDL 构建目标都会自动为所有相关语言从 FIDL 库生成代码。

在 Fuchsia SDK 环境中,从 FIDL 库生成代码将作为编译使用这些库的应用的一部分来完成。

使用入门

如果您想详细了解如何使用 FIDL,“指南”部分提供了许多开发者指南和教程,您可以尝试一下。如果您在 Fuchsia 上进行开发,并且想了解如何为现有 FIDL API 使用绑定,可以参阅 FIDL 绑定参考文档。最后,如果您想详细了解 FIDL 或想做出贡献,请参阅 FIDL 语言参考文档或贡献文档。