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(适用于 valueresponse)。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 的消息传递,我们需要将内容分为两层,并明确一些定义。

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

  • 发件人 - 消息的始发方,
  • 接收方 - 接收消息的一方,

发送消息是一种非阻塞操作:发送者发送消息后,可以继续处理其他事务,而无需考虑接收者正在做什么。

接收器可以根据需要进行阻塞,以等待消息。

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

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

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

当我们讨论各方所扮演的角色时,“客户端”和“服务器”这两个术语就很有意义了。具体而言,客户端可以有时是发送者,有时是接收者;服务器也是如此。

实际上,在客户端 / 服务器互动的背景下,这意味着存在以下几种模型:

  1. 阻塞调用 - 客户端向服务器发送请求,等待回复
  2. 发送即忘 - 客户端发送到服务器,不期待回复
  3. 回调异步调用 - 客户端发送到服务器,但不阻塞;稍后异步传递回复
  4. event - 服务器发送给客户端,无需客户端请求数据

第一个是同步的,其余是异步的。我们将按顺序讨论这些问题。

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

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

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

图:客户端和服务器

以下是分步说明:

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

通过异步消息传递方案实现此同步消息传递模型非常简单。请注意,在协议的底层,客户端到服务器和服务器到客户端的消息传输都是异步的。同步在客户端进行,客户端会一直处于阻塞状态,直到服务器的消息到达。

基本上,在这种模式下,客户端和服务器已达成协议:

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

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

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

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

客户端向服务器发送请求,但未收到回复

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

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

图:Fire and Forget;客户端向服务器发送消息,但不期待回复

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

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

不过,客户端无法解决这些问题,因此阻塞只会带来更多问题。

客户端发送到服务器,但不阻塞

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

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

这使得客户端 / 服务器交互具有极大的灵活性。

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

图:客户端发送到服务器,但不会立即阻塞

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

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

单一请求,单一响应

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

单一请求,多次响应

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

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

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

图:客户端向服务器发送请求,服务器多次回复

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

请注意,客户端无需等待服务器向其发送消息。在上图中,我们在圆圈“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 后面添加“-> ()”会将含义从声明 fire-and-forget 样式的方法更改为声明没有任何返回实参的函数调用样式方法。

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

FIDL 绑定

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

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

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

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

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

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

客户端实现

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

第一部分包括管理和后台处理,具体如下:

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

第二部分包含传统函数调用或 fire and forget 样式方法的实现,具体取决于目标语言。 一般来说,这包括:

  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、New C++、High-Level C++(旧版)、Dart、Go、Rust
  • 考虑到我们将来可能需要支持其他语言,例如:
    • Java、JavaScript 等。
  • 绑定和生成的代码有原生风格或惯用风格,具体取决于预期应用。
  • 使用编译时代码生成来优化消息序列化、反序列化和验证。
  • FIDL 语法熟悉易懂,并且与编程语言无关。
  • FIDL 提供了一个库系统,可简化其他开发者的部署和使用。
  • FIDL 表达了系统 API 所需的最常见数据类型;它并不寻求提供所有编程语言提供的所有类型的全面的一对一映射。

工作流程

本部分总结了使用 FIDL 描述的 IPC 协议的作者、发布者和消费者的工作流程。

编写 FIDL

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

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

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

发布 FIDL

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

消费者只需将 FIDL 编译器指向包含库(及其依赖项)的 FIDL 文件的目录,即可为该库生成代码。具体实现细节通常由消费者的 build 系统处理。

使用 FIDL

基于 FIDL 的协议的消费者使用 FIDL 编译器生成适合用于其语言运行时特定绑定的代码。对于某些语言运行时,使用者可以选择几种不同的生成代码,这些代码在有线格式级别上都是可互操作的,但在源代码级别上可能不是。

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

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

使用入门

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