本文档简要介绍了 Fuchsia 接口定义语言 (FIDL)。FIDL 用于描述 Fuchsia 上运行的程序使用的进程间通信 (IPC) 协议。本概览介绍了 FIDL 背后的概念,熟悉这些概念的开发者可以按照教程开始编写代码,也可以通过阅读语言或绑定参考文档深入了解。
什么是 FIDL?
虽然“FIDL”表示“紫红色接口定义语言”,但该词本身可以指代许多不同的概念:
- 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++ 实现),而客户端可能有多种实现版本,并且数量不限。
请注意,Fucsia 操作系统本身并不了解 FIDL。FIDL 绑定使用 Fuchsia 中的标准通道通信机制。FIDL 绑定和库针对如何使用相应通道强制执行一组语义行为和持久性格式。
FIDL 架构
从开发者的角度来看,主要组件如下:
- FIDL 定义文件 - 这是一个文本文件(按照惯例以
.fidl
结尾),用于定义值、协议(带参数的方法)、 - 客户端代码 - 由 FIDL 编译器 (
fidlc
) 工具链针对每个特定的目标语言生成,以及 - 服务器代码 - 也由 FIDL 编译器工具链生成。
举一个非常简单的 FIDL 定义文件示例,考虑使用“echo”服务:无论客户端向服务器发送什么内容,服务器都只会将其回显回客户端。
为清楚起见,添加了行号,它们不属于
.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 的消息传递,我们需要将其分成两层,并澄清一些定义。
在底部(操作系统层),有一个异步通信方案,其面向发送方和接收方的独立进度:
- sender - 发起消息的一方,
- receiver - 接收消息的一方,
发送消息属于非阻塞操作:发送者发送消息,然后可以自由继续处理,而无论接收者在执行什么操作。
接收者可以视需要进行屏蔽以等待消息。
顶层实现 FIDL 消息,并使用底部(异步)层。它会处理客户端和服务器:
- 客户端 - 发出请求的一方(服务器);
- 服务器 - 处理请求的一方(代表客户端)。
在我们讨论消息本身时,“发送者”和“接收者”这两个术语是有道理的。底层通信方案并不关心我们分配给双方的角色,只关注发送和接收的角色。
在我们讨论各方所扮演的角色时,“客户端”和“服务器”这两个术语是有意义的。特别是,一个客户端可以在同一时间成为发送方,在不同的时间作为接收方;对于服务器而言,这一点是一样的。
实际上,就客户端 / 服务器交互而言,这意味着有多种模型:
- 阻塞调用 - 客户端将数据发送到服务器,等待回复
- “触发后不理” - 客户端向服务器发送内容,没有响应
- 回调或异步调用 - 客户端向服务器发送消息,但不会阻塞;回复会在稍后异步传送
- event - 服务器向客户端发送消息,但客户端未要求提供数据
第一个是同步的,其余是异步的。下面我们将依次介绍这些内容
客户端发送至服务器,等待回复
此模型是大多数编程语言中可用的传统“阻塞调用”或“函数调用”,只不过调用是通过通道完成的,因此可能会因传输层错误而失败。
从客户端的角度来看,它由一个阻塞的调用组成,同时服务器会执行一些处理。
以下是分步说明:
- 客户端进行调用(可选择包含数据)并执行阻塞操作。
- 服务器接收客户端的调用(和可选数据),并执行一定量的处理。
- 该消息由服务器自行决定是否回复客户端(提供可选数据)。
- 服务器的回复会使客户端解除屏蔽。
要通过异步消息传递方案实现此同步消息传递模型非常简单。您应该还记得,客户端到服务器和服务器到客户端的消息传输在协议的底层都是异步的。同步发生在客户端,方法是让客户端阻塞,直到服务器的消息到达。
基本上,在此模型中,客户端和服务器已达成共识:
- 数据流由客户端启动
- 客户最多只能有一条待处理的消息
- 服务器应仅在响应客户端的消息时向客户端发送消息
- 客户端应等待服务器响应,然后才能继续。
这种屏蔽模型通常用于客户端需要先收到对其当前请求的回复,然后才能继续。
例如,客户端可能会从服务器请求数据,并且在数据到达之前无法执行任何其他有用的处理。
或者,客户端可能需要按特定顺序执行步骤,因此必须确保每个步骤都先完成,然后再启动下一个步骤。如果出现错误,客户端可能需要根据操作的进度执行纠正操作,这是与每个步骤同步完成的另一个原因。
客户端向服务器发送消息,无回复
这种模型也称为“火并忘记”。客户端会将消息发送到服务器,然后继续执行其操作。与阻塞模型相比,客户端不会阻塞,也不需要响应。
当客户端不需要(或无法)同步对其请求的处理时,便可使用此模型。
一个典型的例子就是日志记录系统。客户端会将日志记录信息发送到日志记录服务器(上图中的圆圈“1”和“2”),但没有理由阻止它。服务器端可能会出现很多问题:
- 服务器正忙,目前无法处理写入请求,
- 媒体已满,服务器无法写入数据
- 如果服务器遇到了故障,
- 依此类推。
但是,客户端无法采取任何措施,因此阻塞只会产生更多问题。
客户端向服务器发送消息,但未阻止
此模型与下一个模型(“服务器向客户端发送消息,而不请求客户端数据”)类似。
在当前模型中,客户端向服务器发送消息,但不会阻止。但是,客户端希望服务器返回某种响应,但这里的关键在于,它与请求不同步。
这使得客户端 / 服务器交互具有极大的灵活性。
虽然同步模型会强制客户端等待服务器回复,但当前模型在服务器处理请求时释放客户端以执行其他操作:
此图与上图中的细微区别在于,在圆圈“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)
- 基于端口:收到的消息将传送到端口或将来 (Rust)
- 同步调用:等待回复并返回(Go、C++ 单元测试)
绑定提供以下部分或全部主要操作:
- 编码:就地将原生数据结构转换为线上格式(结合验证)
- 解码:就地将传输格式数据转换为原生数据结构(结合验证)
- 复制/移至惯用形式:将原生数据结构的内容复制到惯用数据结构,同时移动句柄
- 复制/移至原生表单:将惯用数据结构的内容复制到原生数据结构中,同时移动句柄
- 克隆:复制原生或惯用数据结构(不包含仅移动类型)
- 调用:调用协议方法
客户端实现
无论目标语言是什么,fidlc
FIDL 编译器都会生成具有以下基本结构的客户端代码。
第一部分包括管理和后台处理,并且包括:
- 我们提供了连接到服务器的一些方法
- 将启动异步(“后台”)消息处理循环
- 异步调用样式和事件样式方法(如果有)会绑定到消息循环
第二部分包含适用于目标语言的传统函数调用或消防和忘记样式方法的实现。一般来说,这包括:
- 创建 Callable API 和声明
- 为每个 API 生成代码,这些代码将调用中的数据编组为适合传输到服务器的 FIDL 格式的缓冲区
- 生成代码以将数据传输到服务器
- 对于函数调用样式的调用,将生成用于实现以下目的的代码:
- 等待服务器响应
- 从 FIDL 格式的缓冲区对数据进行解组,以及
- 通过 API 函数返回数据。
显然,确切的步骤可能会因语言实现差异而有所不同,但这只是基本步骤。
服务器实现
fidlc
FIDL 编译器也会为给定的目标语言生成服务器代码。与客户端代码一样,无论目标语言为何,此代码都具有通用结构。代码如下:
- 会创建一个客户端可以连接的对象
- 会启动一个主处理循环,从而:
- 等待消息
- 通过调用实现函数来处理消息
- 如果指定,则会向客户端发出异步回调以返回输出
在接下来的章节中,我们将详细介绍每种语言的客户端和服务器代码实现。
为何使用 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),但其设计是以紫红色为优先的。
- 提供用于创建、发送、接收和使用消息的便捷 API。
- 执行充分的验证以保持协议不变(但不超过此)。
效率
- 和使用手动数据结构一样高效(速度和内存)。
- 有线格式使用未压缩的原生数据类型(采用小字节序和正确对齐),以支持就地访问消息内容。
- 如果消息大小是静态已知或有界限的,则无需动态内存分配即可生成或使用消息。
- 使用仅移动语义明确处理所有权。
- 数据结构打包顺序是规范的、明确的,并且具有最小内边距。
- 避免使用后向修补指针。
- 避免开销很高的验证。
- 避免可能溢出的计算。
- 利用协议请求的流水线处理异步操作。
- 结构体的大小是固定的;大小可变的数据是以外行方式存储的。
- 结构不是自描述的;FIDL 文件描述的是其内容。
- 不对结构进行版本控制,但可以使用新方法来扩展协议,以改进协议。
工效学设计
- 由 Fuchsia 团队维护的编程语言绑定:
- C、新 C++、高级 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 库。例如,作者可以在公共源代码库中传播 FIDL 库,或将其作为 SDK 的一部分进行分发。
使用方只需将 FIDL 编译器指向包含库(及其依赖项)的 FIDL 文件的目录,即可为该库生成代码。有关如何实现这一点的准确细节通常由使用方的构建系统来解决。
消耗 FIDL
基于 FIDL 的协议的使用方使用 FIDL 编译器生成适用于其语言运行时专用绑定的代码。对于某些语言运行时,使用方可以选择几种不同风格的生成代码,所有这些代码都可以在传输格式级别(但可能不在源代码级别)互操作。
在 Fuchsia 世界构建环境中,系统会由每个库的各个 FIDL 构建目标,自动为所有相关语言从 FIDL 库生成代码。
在 Fuchsia SDK 环境中,通过 FIDL 库生成代码将在编译使用 FIDL 库的应用时完成。
使用入门
如果您想详细了解如何使用 FIDL,“指南”部分提供了许多开发者指南和教程供您尝试。如果您在 Fuchsia 上进行开发,并希望了解如何使用现有 FIDL API 的绑定,请参阅 FIDL 绑定参考文档。最后,如果您想详细了解 FIDL 或想要贡献内容,请查看 FIDL 语言参考文档或贡献文档。