RFC-0097:FIDL 工具链 | |
---|---|
状态 | 已接受 |
区域 |
|
说明 | 规范 FIDL 工具链的说明。 |
Gerrit 更改 | |
作者 | |
审核人 | |
提交日期(年-月-日) | 2021-04-27 |
审核日期(年-月-日) | 2021-05-26 |
摘要
我们将介绍 FIDL 工具链需要满足的要求,并就如何分解此问题提供指导。
虽然具体实现计划超出了本 RFC 的范围,但预计 Fuchsia 源代码树中的工具(例如 fidlc
、fidlgen_go
、banjo
)和构建规则(例如 fidl_library.gni)将会不断演变,以满足此处列出的要求。
此外,位于 Fuchsia 源代码树之外的 FIDL 工具链应符合此处列出的要求。(此 RFC 仅适用于 Fuchsia,因此我们无法强制要求遵从,但当然可以强烈建议遵从。)
术语
在开始之前,我们先定义一些术语。FIDL 工具链的简化视图可概括如下:
FIDL 语言由 fidlc
体现,它表示前端编译器(简称前端)。所有语言验证都在此处进行。
前端会为每个编译的 FIDL 库生成中间表示法(称为 JSON IR)。尽管如此,中间表示形式并不一定需要表示为 JSON 文件。
然后,一个或多个backends会处理 JSON IR,以生成输出。请注意,从 FIDL 工具链的角度来看,JSON IR 的任何使用方都是后端。
最常见的是,生成的输出是目标语言(例如 C++ 或 Rust)中的代码,这样便可以操作类型、与协议交互、打开服务和使用常量。这类后端称为 FIDL 绑定1,它们生成的代码应遵循绑定规范。我们通常使用缩写 fidlgen(或 fidlgen_<suffix>
,例如 fidlgen_rust
或 fidlgen_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
] 希望知道某个类型是否可能包含浮点数,以便确定哪些 trait 可以安全地派生。不包含任何浮点数的 struct
可以包含 Eq
;所有变体都不包含任何浮点数的 strict union
可以包含 Eq
;但目前没有浮点字段的 flexible table
不能包含 Eq
,因为提供 Eq
会违反源代码兼容性规则。
另一个示例来自 fidlgen_cpp
,它会生成非所有权网域对象。如果这些网域对象的内嵌部分是值(即不是资源),则可以安全地进行复制。同样,计算我们称为“内嵌资源性”的元数据需要从叶子到根迭代计算此值。
近期,在讨论为库生成 ABI 指纹的新后端时,我们就该功能应该位于何处进行了反复讨论。目前的想法是出于实际原因,将此功能托管在 fidlc
编译器中,但这个答案并不令人满意。
我们发现,需要元数据渗透的功能要么被搁置,要么被规避(通常是粗糙的),要么转换为新的编译器功能,通常会过早强制进行泛化(例如上面讨论的 ABI 指纹)。
此外,由于 Fuchsia FIDL 团队能够轻松更改编译器,因此我们在这方面比第三方更具优势。因此,我们可以说,工具的现状有悖于我们力求让所有后端都处于同等竞争环境的开源原则。
每个目标语言和每个库后端选择
随着 FIDL 语言被用于描述内核 API,以及正在开发中的驱动程序 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 协定,以便生成的绑定代码能够正确桥接其代码生成和 kazoo
的代码生成。2
每个平台一个库
目前,我们不强制要求 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.mem
和 fuchsia.intl
库,以此类推。这意味着,今天的编译完全没有效率。核心库(例如 fuchsia.mem)会被反复编译多次。这种架构效率低下从来都不是问题:目前,SDK 中只有 64K 多一点的 LoC FIDL 源代码,并且由于传递依赖项相对较浅,因此这种低效率不会明显感觉到。
不过,在构思“理想”FIDL 工具链时,我们希望遵循编译器设计方面的标准做法。传统上,编译器会接受源文件等输入,并生成 x86 汇编等输出。随着代码库不断增长,编译器还需要满足一个额外的要求,即能够对工作进行某种分区,以便对输入进行小更新时无需重新编译整个代码库。
例如,考虑一下 javac
编译器:如果您更改某个文件 SomeCode.java
中的 for
循环的条件,那么必须重新编译数千个文件才能再次运行程序,这显然是不合理的。而是只重新编译该单个文件,并可以重复使用所有其他预编译源代码(作为 .class
文件)。
为了成功划分工作,标准方法是定义一个编译单元(例如适用于 FIDL 的库),并生成中间结果(例如 JSON IR),以便编译过程的输入既是来源,也是直接依赖项的中间结果。这样,就可以将总编译时间(假设并行度无限)限制为编译时间最长的依赖项链。这还简化了构建规则,这是当下热门话题。
设计
我们将设计分为三个部分:
- 首先,指导原则,用于指导设计决策,并确定所采用的方法和路径;
- 规范 FIDL 工具链的说明,作为如何分解构建 FIDL 以考虑所述的所有要求的示例;
- 最后,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 语言限制。这一点很重要,原因有两个:
- 这条规则的一个推论是,如果有一个有效的 IR,则可以使用与此 IR 兼容的所有后端。这意味着,作为 SDK 发布商,确保成功编译 FIDL 库可确保所有使用与发布商使用的 FIDL 工具链兼容的版本的使用方都能使用这些库。
- 从语言设计的角度来看,这项非常严格的要求是一项有益的强制性函数,可确保语言设计符合后端的需求。例如,如果后端所有者出于某种原因需要进行验证,则会通过 fidl-dev@fuchsia.dev 或 RFC 向 Fuchsia FIDL 团队提出此问题,以便可能纳入语言规范。这可能会改进该语言(所有人都会受益),或者可能需要对后端进行改进,以更好地符合 FIDL 工具链原则。
为了说明这一原则,请考虑 Rust 中的 trait 派生:对于包含浮点数的类型,无法派生 Eq
trait。很容易就想为 FIDL 中包含浮点数 (float32
或 float64
) 的类型添加属性 @for_rust_has_floats
,然后在 fidlgen_rust
中利用此属性有条件地发出 Eq
trait,并验证该属性是否已正确使用(与值-资源区分类似)。但这种诱惑违背了这一原则,因为这意味着 fidlgen_rust
可能会出错。在 fidlc
中验证此类小众属性也不是理想之举,因为这会导致 FIDL 因各种特定于目标语言的问题而变得复杂。3
Canonical FIDL 工具链
规范的 FIDL 工具链以库分解为中心,并将包含两种 build 节点。
渗透构建节点
渗透节点会向工具提供库的源代码以及库直接依赖项的对象文件,并生成最终结果和目标对象文件。
例如,目前大多数 fidlgen 后端都遵循以下模式:其来源是 JSON IR,最终结果是生成的代码。它们没有依赖对象文件 (DOF),也不会生成目标对象文件 (TOF)。
另一个例子是计划中的 ABI 指纹工具,该工具需要计算类型的结构属性。此工具将使用 JSON IR(源代码),并生成 ABI 摘要(最终结果)和随附的目标对象文件 (TOF)。在对具有依赖项的库进行操作时,它将使用这些库的 TOF(即其 DOF)以及 JSON IR 来生成下一个最终结果。最终结果和 TOF 可能只是格式不同,因为一个是供人阅读的,另一个是供工具解析的。
整个视图构建节点
整个视图节点会提供源代码,包括对要调用的工具可传递到达的所有依赖库,并生成最终结果。
例如,measure-tape
需要定义要编译的类型所需的所有可传递到达的库的 IR,并且自然会表示为整个视图节点。目前,fidlc
节点作为整个视图节点运行,因为它需要访问所有来源才能运行(如需了解详情,请参阅扩缩编译)。fidlcat
和 fidldoc
都需要一个完整的视图,该视图依赖于正在编译的整个 Fuchsia 平台。
虽然整个视图节点的效率确实低于渗透节点,但我们可能不希望重构所有工具以渗透方式运行,而是选择将一些复杂性推送到构建系统中。
在 Fuchsia 源代码树 build 中,我们会生成 all_fidl_json.txt 文件。有了关于整个视图节点的更明确要求,我们可以更好地构建此汇总。例如,通过按平台整理此汇总,为每个库记录其源代码、JSON IR 和直接依赖项,我们可以轻松利用此汇总快速生成整个视图工具所需的输入。开发者工具(例如 fidl-lsp
或 fidlbolt
)也会利用此汇总数据。
工具选择
给定 build 节点中的工具选择应取决于目标(例如“生成低级 C++ 代码”)以及要编译的库(例如“库 zx”)。我们定义了接受元组(目标生成器、库)并返回工具(例如 kazoo
或 fidlgen_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),因此它往往更接近整个构建图的根,因此对工具链输出的任何更改(例如对生成的 C++ 头文件的更改)都会对下游编译产生重大影响(例如,所有代码都直接或间接依赖于此生成的 C++ 头文件)。因此,应尽量减少对生成的源代码所做的更改。例如,规范化生成器的输出(通过避免更改无意义的空白字符),或将输出与缓存版本进行比较,以避免使用相同的内容覆盖内容,从而避免仅更改时间戳(请参阅 GN 输出示例详情)。
清理旧有技术债务,并避免产生更多债务
根据此处介绍的原则,我们将从 fidlc
中移出 C 绑定和编码表生成功能。由于历史构建复杂性,我们在核心编译器中嵌入了这两代。
我们还计划从 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 分离更清晰,隐私保护得到了加强。
测试
工具链的标准测试。
缺点、替代方案和未知情况
如文中所述。
-
从技术层面讲,我们将代码生成工具、使用所生成代码所需的支持运行时库以及所生成的代码称为 FIDL 绑定。 ↩
-
C 系列 fidlgen 不希望为
zx/clock
生成自己的域对象,而是选择#include
从kazoo
生成的头文件。同样,Rust fidlgen 会导入由kazoo
生成的zx
绑定,而不是根据zx
库定义生成自己的领域对象。 ↩ -
目前,我们无法证明为 FIDL 添加
@has_floats
属性(或has_float
修饰符)的合理性,因为唯一的用例是在fidlgen_rust
中,即使在那里,这也不是一个严重问题。如果这些情况发生变化(例如,其他几个后端存在类似的PartialEq
/Eq
问题),则可能有必要这样做。 ↩