RFC-0097:FIDL 工具链

RFC-0097:FIDL 工具链
状态已接受
区域
  • FIDL
说明

规范 FIDL 工具链的说明。

Gerrit 更改
作者
审核人
提交日期(年-月-日)2021-04-27
审核日期(年-月-日)2021-05-26

摘要

我们介绍了 FIDL 工具链需要满足的要求,并提供了有关如何分解此问题的指导。

虽然具体实现方案不在本 RFC 的范围内,但预计 Fuchsia 源代码树中的工具(例如 fidlcfidlgen_gobanjo)和 build 规则(例如 fidl_library.gni)会不断发展,以满足此处列出的要求。

此外,Fuchsia 源代码树之外的 FIDL 工具链应符合此处列出的要求。(此 RFC 在 Fuchsia 之外不具有任何权威性,因此我们无法强制要求遵守,但当然可以强烈建议遵守。)

术语

在开始之前,我们先定义几个术语。FIDL 工具链的简化视图可总结如下:

简化的工具链

FIDL 语言fidlc 体现,它表示前端编译器(简称前端)。所有语言验证都在此处进行。

前端会为编译的每个 FIDL 库生成一个中间表示法(称为 JSON IR)。尽管名称如此,但中间表示形式不一定需要以 JSON 文件形式表示。

然后,一个或多个后端会处理 JSON IR 以生成输出。请注意,从 FIDL 工具链的角度来看,JSON IR 的任何使用者都是后端。

最常见的情况是,生成的输出是目标语言(例如 C++ 或 Rust)的代码,这使得您可以操纵类型、与协议互动、打开服务和使用常量。这类后端称为 FIDL 绑定1,它们生成的代码应遵循绑定规范。我们通常使用简写 fidlgen(或 fidlgen_<suffix>,例如 fidlgen_rustfidlgen_dart)来指代生成 FIDL 绑定的后端。我们将网域对象称为目标语言中用于表示 FIDL 类型的一组类和类型。例如,FIDL 枚举 fuchsia.fonts/Slant 将在 C++ 中(作为 enum class)或在 Go 中(作为 type Slant uint32)具有相应的网域对象。

还有各种各样的其他后端,每种后端都有自己的需求和特点。例如:fidldoc 会生成 FIDL 文档,例如 fuchsia.fonts 页面;fidl_api_summarize 会生成 FIDL 库的 API 摘要;fidlcat 使用 JSON IR 来提供运行时自省。从 FIDL 工具链的角度来看,fidlcat 工具是一个后端,尽管这只是该工具实际功能的一小部分。

设计初衷

FIDL 也在不断发展。其工具链的表达能力已通过测试。 一直未能充分满足新要求。需要新的扩展工具链。

我们首先介绍其中一些新要求,然后介绍支持所有这些要求的方法。

整个计划视图

目前,FIDL 工具链假定后端按库运行,因此只需要相应库的 JSON IR 即可运行。

越来越多的后端需要访问多个 JSON IR 才能满足其需求。

例如,fidldoc 需要一次性获取所有要记录的库的 JSON IR,才能生成全局索引。fidlcat 也面临同样的问题,需要查看所有库才能正常运行。measure-tape 需要通过生成卷尺的目标类型以传递方式可访问的库的 JSON IR。

渗透元数据

某些后端需要有关库的特殊元数据才能运行。 通常,此元数据需要从库依赖树的叶节点(即“基础库”)开始迭代计算,然后元数据会渗透到根节点(即正在编译的库)。

例如,[fidlgen_rust] 想知道某个类型是否可能包含浮点数,以确定哪些特征可以安全地派生。不包含任何浮点数的 struct 可能具有 Eq;不包含任何浮点数的 strict union 可能具有 Eq;但目前没有浮点数字段的 flexible table 不能具有 Eq,因为提供 Eq 会违反源代码兼容性规则。

另一个示例来自 fidlgen_cpp,它会生成非所有权网域对象。如果这些网域对象的内嵌部分是值(即不是资源),则可以安全地复制它们。同样,计算此元数据(我们称之为“内联资源性”)需要从叶节点到根节点迭代计算此值。

最近,在讨论为库生成 ABI 指纹的新后端时,我们反复讨论了该功能应位于何处。目前,出于实际考虑,我们打算在 fidlc 编译器中托管此功能,但这个答案并不令人满意。

我们观察到,需要元数据渗透的功能要么被搁置,要么被绕过(通常以黑客方式),要么被转化为新的编译器功能,通常会过早地强制进行泛化(例如上文讨论的 ABI 指纹)。

此外,由于 Fuchsia FIDL 团队能够轻松更改编译器,因此我们在这方面比第三方更具优势。因此,可以说,目前工具的状态有损我们的开源原则,该原则旨在让所有后端处于公平竞争的环境中。

按目标语言和库后端选择

随着用于描述内核 API 的 FIDL 语言的出现,以及正在开发中的驱动程序 SDK 的推出,FIDL 的应用越来越广泛。

不过,如今的工具链中,“FIDL 语言”与适合处理特定库的“后端”混为一谈。当目标需要库 fuchsia.fonts 的 Rust 代码时,我们会调用 fidlgen_rust

这种方法过于简单,无法说明某些库需要专门的后端。例如,library zx;kazoo 处理。 这种按目标平台按库的 fidlgen 选择方式会产生进一步的影响。以枚举 zx/clock 为例,我们希望 kazoo 有一天能够生成当前手动编写的 zx_clock_t typedef,以及实现枚举成员的各种 #define。 如果 fuchsia.fonts 库依赖于 zx/clock,则意味着 fidlgen_cpp 需要了解 API 合约,以便生成可正确桥接 2 的代码生成和 kazoo 的绑定代码。

每个平台一个库

目前,我们对具有相同名称的 FIDL 库的多个定义没有明确的意见。虽然不建议这样做,但可以在源代码树的各个位置定义多个库 fuchsia.confusing,并独立使用所有这些不同的库。

更合理的做法是利用平台标识符概念,在 Fuchsia 源代码树中,该概念默认值为 fuchsia。这样一来,我们就可以保证并强制执行以下规则:永远不会存在两个名称相似的库定义。

考虑到此限制,我们将平台定义为共享同一平台标识符的 FIDL 库的集合。

不进行后期验证

目前,后端无法选择性地使用 FIDL 库。后端应处理任何有效的 JSON IR。此限制意味着我们避免在后端进行任何后期验证。可以考虑在后端添加验证,以识别尚未实现的 FIDL 功能;另一个验证示例是检查 fidldoc 中文档注释的有效性,并拒绝生成参考文档。(在这两个示例中,预期结果都是优雅降级。)

允许延迟验证会带来一种令人不快的远程中断(例如 https://fxbug.dev/42144169):在 FIDL 库作为 SDK 制品提供并集成到下游代码库的世界中,运行后端的开发者可能与 FIDL 库作者不同。因此,当 FIDL 库作者有能力纠正问题时,向使用 FIDL 库的开发者提供警告或错误,充其量只会让开发者感到沮丧,最坏的情况是会成为使用 FIDL 库的阻碍因素。

因此,禁止延迟验证的政策一直对 fidlc 编译器施加着“验证所有内容”的健康压力,并对后端施加着“支持所有内容”的健康压力。这在很大程度上避免了此类远程中断故障,但代价是出现了一个缺乏细微差别的位置(“后端没有验证”)。

限制对来源的访问权限

我们没有过多考虑长期后果,而是逐渐允许 JSON IR 复制其来源 FIDL 源的一部分。例如,随着更多复杂表达式的添加,我们公开了已解析的值,以允许后端发出常量,同时在 IR 中保留表达式本身(文本)。虽然拥有表达式文本有助于在生成的代码中生成有意义的注释,但它也为降低 SDK 发布商(即发布 FIDL 制品的人员)的隐私性打开了大门,因为他们无法轻松选择是否提供源代码。

不难想象,未来这条路径会导致更多 FIDL 源代码最终进入 IR,这不是理想的结果:这既是重复的,也可能导致隐私边界遭到潜在的侵犯。

相反,我们的目标是设计 FIDL 工具链,使其包含的源代码不超过必要的数量。对于确实需要源代码访问权限的极少数后端(例如 fidl-lsp),我们依赖于对 span 的引用。如需了解详情,请参阅设计部分

扩缩编译

为简单起见,fidlc 编译器最初设计为仅对源(即 .fidl 文件)进行操作。如果库具有依赖项,则库的编译需要以传递方式编译其所有依赖项。

例如,在编译 fuchsia.fonts 库时,我们还必须编译 fuchsia.memfuchsia.intl 库,依此类推。这意味着今天的编译完全低效。fuchsia.mem 等核心库会被多次重新编译。这种架构低效性从未成为问题:目前 SDK 中只有 64K 多行的 FIDL 源代码,并且传递依赖项相对较浅,因此这种低效性并不明显。

不过,在考虑“理想”的 FIDL 工具链时,我们希望与编译器设计中的标准实践保持一致。编译器通常会接收源文件等输入,并生成 x86 汇编等输出。随着代码库的增长,编译器需要能够提供某种形式的工作分区,这样对输入的小更新就不需要重新编译整个代码库。

javac 编译器为例:如果您更改了某个文件 SomeCode.javafor 循环的条件,那么为了能够再次运行程序,您需要重新编译数千个文件,这显然是不合理的。而是仅重新编译该单个文件,并可重复使用所有其他预编译的来源(作为 .class 文件)。

为了成功划分工作,一种标准方法是定义一个编译单元(例如 FIDL 的库),并生成中间结果(例如 JSON IR),这样编译过程的输入就是直接依赖项的来源和中间结果。这样一来,就可以将总编译时间(假设并行性无限)限制为最长的待编译依赖链。这还简化了构建规则,这是一个热门话题。

设计

我们将设计分为三个部分:

  1. 首先,指导原则可为设计选择提供依据,并确定所采取的方法和途径;
  2. 规范 FIDL 工具链的说明,作为如何分解 FIDL 构建以满足所述所有要求的示例;
  3. 最后,Fuchsia FIDL 团队将进行一些特定清理,以使核心工具与此 RFC 的指南保持一致。

指导原则

IR 应能轻松适应常见的后端

虽然复杂的后端应该可行(例如全程序视图),但必须设计 IR,以便仅通过处理正在处理的库的单个 IR 即可构建通用后端。

经验表明,大多数后端都比较简单。满足简单用例(而非专家用例)的需求可确保我们尽最大努力简化 IR,从而尽最大努力确保后端生态系统蓬勃发展。

为了举例说明这一原则,请考虑 fidlc 中进行的“类型形状”计算。可以考虑改为将此功能移至专用渗透后端。不过,这会强制所有生成目标代码的后端(主要用例)同时依赖于 IR 和此“类型形状”后端。

IR 应尽可能小

力求极简是轻松适应常见后端的重要对策,因为“包含所有内容”并认为工作已完成很容易(或很诱人)。

为了举例说明这一原则,请考虑 fidlc 中计算“声明顺序”的当前反模式。只有少数后端依赖于此顺序(C 系列,日常使用中更少),这会给编译器带来不必要的复杂性。它还模糊了为何需要此类命令,并经常引起混淆。这也很不灵活,因为后端应该独立于核心编译器发展,而这阻碍了在支持递归类型方面取得进展

IR 不得包含来源

IR 不应包含比轻松适应常见后端(例如名称)所需的更多来源。在适当的情况下,IR 可以提供源跨度引用。源范围引用是一个三元组:

(filename, start position, end position)

其中,位置是一个元组 (line number, character number)

后端不应依赖于对源代码的访问权限来运行。如果后端必须有权访问源代码才能运行(例如 fidl-lsp),则必须明确说明此要求,并且在无法访问源代码时能够正常失败。

选择这种分解方式,我们明确选择为 SDK 发布者(即发布 FIDL 制品的人员)提供是否包含源 FIDL 的选项。目前,这种选择并不完全由他们决定,因为部分来源最终会进入 IR。

除了名称之外,IR 中一个值得注意的来源部分是文档注释。根据规范,这些注释应属于 API 的一部分,也就是说,FIDL 库作者明确选择公开这些注释。此外,大多数后端都会使用这些文档注释(例如,在生成的代码中发出注释),因此符合可轻松适应常见后端的原则。这些文档注释不会以原始源代码的形式显示在注释中,而是会经过一些预处理(前导缩进、/// 和留白会被剪掉)。正如我们简要探讨的那样,我们打算在未来进一步处理文档注释。

后端一视同仁

FIDL 语言、其作为 fidlc 编译器的实现以及中间表示的定义应设计为允许包含性的后端生态系统,其中所有后端(无论是否作为 Fuchsia 项目的一部分构建)都处于同等地位。

在选择这条分界线时,我们明确选择避免为了 Fuchsia FIDL 团队拥有的后端短期需求而采取权宜之计,而是专注于 FIDL 生态系统的长期可行性。

后端不会出错

后端在处理有效的 IR 时必须成功。如果后端在其环境中遇到问题(例如文件系统访问错误)或 IR 无效,则可能会失败。如果后端无法处理符合 IR 架构的 IR,则不得因错误而失败。

在选择此分界线时,我们明确强制所有验证都发生在前端,即验证必须提升为 FIDL 语言限制。这一点很重要,原因有两个:

  1. 此规则的一个推论是,给定一个有效的 IR,可以使用与此 IR 兼容的所有后端。这意味着,作为 SDK 发布者,确保成功编译 FIDL 库可保证所有使用与发布者所用 FIDL 工具链版本兼容的 FIDL 工具链版本的消费者都能使用这些库。
  2. 从语言设计的角度来看,这一非常严格的要求是一种有益的强制函数,可确保语言设计符合后端的需求。例如,出于某种原因需要验证的细心后端所有者会向 Fuchsia FIDL 团队提出此问题(通过 fidl-dev@fuchsia.dev 或 RFC),以便将其纳入语言规范。这可能会改进该语言,让所有人都受益,或者可能会重新设计后端,使其更好地符合 FIDL 工具链原则。

为了说明这一原则,我们以 Rust 中的特征派生为例:Eq 特征无法针对包含浮点数的类型派生。在包含浮点数(float32float64)的 FIDL 类型中添加 @for_rust_has_floats 属性,然后在 fidlgen_rust 中利用此属性有条件地发出 Eq 特征,并验证该属性是否得到正确使用(类似于值资源区分),这似乎很有吸引力。但这种诱惑违背了该原则,因为它暗示 fidlgen_rust 可能会出错。在 fidlc 中验证此类小众属性也不可取,因为这会导致 FIDL 因大量特定于目标语言的问题而变得复杂。3

规范 FIDL 工具链

规范的 FIDL 工具链以库分解为中心,将具有两种 build 节点。

渗透构建节点

渗透节点会向工具提供库的来源和库的直接依赖项的对象文件,并生成最终结果和目标对象文件。

渗透节点

例如,大多数 fidlgen 后端目前都遵循这种模式:它们的来源是 JSON IR,最终结果是生成的代码。它们没有依赖对象文件 (DOF),也不会生成目标对象文件 (TOF)。

另一个示例是计划中的 ABI 指纹识别工具,该工具需要计算类型的结构属性。此工具将使用 JSON IR(源),并生成 ABI 摘要(最终结果)和随附的目标对象文件 (TOF)。当对具有依赖项的库进行操作时,它将使用这些库的 TOF(即其 DOF)以及 JSON IR 来生成最终结果。最终结果和 TOF 可能只是格式不同,因为一个旨在供人类阅读,另一个旨在供工具解析。

查看整个视图的构建节点

整个视图节点提供来源,包括可传递到达被调用工具的所有依赖库,并生成最终结果。

整个视图节点

例如,measure-tape 需要所有传递可达库的 IR 来定义正在编译的类型,并且自然会表示为整个视图节点。如今,fidlc 节点作为一个整体视图节点运行,因为它需要访问所有来源才能运行(详情请参阅扩展编译)。fidlcatfidldoc 都需要依赖于整个 Fuchsia 平台的完整视图。

虽然整个视图节点确实不如 percolating 节点高效,但我们可能并不希望重构所有工具以采用 percolating 方式运行,而是选择将一些复杂性推送到 build 系统中。

在 Fuchsia 源代码树 build 中,我们会生成一个 all_fidl_json.txt 文件。 明确了对整个视图节点的要求后,我们就可以更好地构建此汇总。例如,通过按平台整理此汇总数据,记录每个库的来源、JSON IR 和直接依赖项,我们可以轻松利用此汇总数据快速生成全视图工具所需的输入。开发者工具(例如 fidl-lspfidlbolt)也会利用此汇总数据。

工具选择

给定 build 节点中的工具选择应取决于目标(例如“生成低级 C++ 代码”)以及正在编译的库(例如“库 zx”)。我们将接受元组(目标生成、库)并返回工具(例如 kazoofidlgen_cpp)的总函数定义为工具链的全局配置。

例如,在 Fuchsia 源代码树中,我们希望看到以下配置:

(*, library zx)  kazoo
(low_level_cpp, not library zx)  fidlgen_llcpp
(high_level_cpp, not library zx)  fidlgen_hlcpp
(rust, not library zx)  fidlgen_rust
(docs, *)  fidldoc

使用统一的 C++ 绑定,此配置将更改为:

(*, library zx)  kazoo
(cpp, not library zx)  fidlgen_cpp
(rust, not library zx)  fidlgen_rust
(docs, *)  fidldoc

对增量编译的影响

在查看增量编译时(即通过将现有的已编译制品与新编译的制品相结合,以最少的作业量来响应源代码更改),此处描述的两种节点表现出截然不同的效果。

一般来说,当编译图中的一个或多个来源(也称为“来源集”)发生更改时,需要调用该节点。

渗透节点的源集比整个视图节点的源集小得多,其源集是直接“源”和目标对象文件 (TOF)。也就是说,如果利用了它们的渗透行为,它们会将源更改传播到 TOF 更改,而这反过来会更改依赖的渗透节点的源集。举例来说,假设 fidlgen_rust 后端经过扩充,还可以生成 TOF fuchsia.some.library.fidlgen_rust.tof。当某个库发生更改时,如果其 TOF 也发生更改,那么所有依赖库也需要更改,从而导致对 fidlgen_rust 后端的调用次数增加(依此类推)。

与渗透节点相比,全视图节点的来源集要广泛得多。 全视图节点大致分为两类:一类依赖于所有传递依赖项(例如 measure-tape),另一类依赖于平台中的所有库(例如 fidldoc)。因此,任何更改都可能会导致需要调用这些节点。

整个视图节点的增量编译成本是双重打击,这些节点需要更频繁地运行,并且由于它们会查看更多来源,因此需要完成更多工作。只需稍作处理(有人曾说过“只是一个简单的编程问题”),任何需要完整视图的后端都可以演变为作为渗透节点运行。考虑这种演变的一个健康压力是,它通常会带来很大的复杂性和维护负担,因此需要考虑增量编译的速度优势,并在成本难以承受时尝试这条道路。

除了工具链本身的增量编译成本之外,还需要考虑下游影响。由于大多数工具链都会生成源代码(例如 C++、Rust、Dart),因此它们往往更接近整个 build 图的根,这样一来,对工具链输出的任何更改(例如对生成的 C++ 标头的更改)都会对下游编译产生很大影响(例如,直接或间接依赖于此生成的 C++ 标头的所有代码)。因此,应尽量减少对生成的源代码的更改。例如,通过避免更改无意义的空格字符来规范生成器的输出,或者将输出与缓存版本进行比较,以避免使用相同的内容覆盖内容,从而避免仅更改时间戳(请参阅 GN 输出示例详情)。

清理旧版技术债务,并避免产生更多技术债务

根据本文所述的原则,我们将把 C 绑定和编码表生成移出 fidlc。由于历史构建复杂性,我们将这两个代系嵌入到了核心编译器中。

我们还计划从 IR 中移除“声明顺序”,而是将任何特殊排序下推到特定后端。

扩展编译中所述,FIDL 编译器 fidlc 将通过仅要求直接依赖项(可能是 JSON IR 本身)的输出,而不是所有传递依赖项的来源,来划分工作。

最后,我们将避免积累更多技术债务,而是专注于使我们的工作与此处所述的方向保持一致。举例来说,下一个正在考虑的后端 ABI 指纹识别将是一个渗透式后端,而不是嵌入在核心编译器中。

在先技术

与 C++ 编译进行比较/对比:C++ 编译器通常接受一个 C++ 源文件并生成一个对象文件。在称为“链接”的最终程序组装阶段,链接器会将所有目标文件合并为一个二进制文件。这种方法之所以有效,是因为在单独编译一个 C++ 源文件时,编译器会通过使用头文件来查看当前文件所依赖的其他 C++ 源文件中的外部函数的相关函数声明。同样,我们当前的 JSON IR 仅提供有关外部库类型的最少信息,类似于函数声明。

不过,如果需要更深入的优化,这种 C++ 编译模型的效果并不理想:当编译器只能查看声明时,它必须非常保守地对待函数的实际行为(例如,它是否总是终止?它是否会改变指针 X?它是否会保留指针 Y,从而使其逃逸?)。同样,在 FIDL 中,如果代码生成后端能够更详细地了解所引用的外部类型,则可能会生成更简洁、更优质的代码。对于资源性和来源兼容性,我们的要求导致后端无法生成正确的代码,除非它们知道所有被引用的外部类型的资源性。

为了解决这个问题,在 C++ 中,各种编译器实现开始将越来越多的辅助数据注入到对象文件中。例如,GCC 和 Clang 都开发了自己的可序列化 IR 格式,以更详细地表达这些 C++ 函数的行为,并将其与汇编代码一起打包。链接器会同时使用汇编代码和 IR,并生成更好的代码(称为链接时优化)。在 FIDL 中,由于各种后端可能需要有关外部类型的不同知识,因此将“辅助数据”与“对象文件”分离可能是有利的,即在主 JSON IR 旁边生成特定于后端的边车。确实,资源性是许多后端都需要的常见属性。但在未来,LLCPP 例如在生成用于关闭句柄的代码时,理想情况下也希望知道某个类型是否传递性地包含内联对象(编码和解码也是如此);Rust 希望确定某个类型是否传递性地包含浮点数,以便在更多情况下派生 Eq(不过需要编译器保证,以避免源代码兼容性问题)。

文档

此 RFC 可作为改进 FIDL 工具链文档的基础,建议工具链作者正确记录他们提供的 build 规则。

实现

如注释中所述。

性能

对性能没有影响,此 RFC 描述了已实现的(尽管不够简洁)要求和问题分解。

工效学设计

不适用。

向后兼容性

不适用。

安全注意事项

无安全注意事项。

隐私注意事项

由于更清晰地将来源与 IR 分开,因此隐私保护得到改进。

测试

对工具链进行标准测试。

缺点、替代方案和未知因素

如文本中所述。


  1. 从技术上讲,我们将代码生成工具、使用生成的代码所需的配套运行时库以及生成的代码统称为 FIDL 绑定。 

  2. C 系列 fidlgen 不会想要为 zx/clock 生成自己的网域对象,而是选择 #includekazoo 生成的头文件。同样,Rust fidlgen 会导入由 kazoo 生成的 zx 绑定,而不是根据 zx 库定义生成自己的网域对象。 

  3. 目前,我们无法证明向 FIDL 添加 @has_floats 属性(或 has_float 修饰符)是合理的,因为唯一用例是在 fidlgen_rust 中,即使在那里,它也不是一个严重的问题。如果这些情况发生变化(例如,多个其他后端存在类似的 PartialEq/Eq 问题),则可能需要进行更改。