ii. 工具链技术说明

本节综合地解释构建方法中的逻辑和技术细节。不要试图立刻理解本节的所有内容。在实际完成一次系统构建后,可以更容易地理解本节。在整个构建过程中,您随时可以重新阅读本节。

第 5 章第 6 章的总目标是构造一个临时环境,它包含一组可靠的,能够与宿主系统完全分离的工具。这样,通过使用 chroot 命令,其余各章中执行的命令就被限制在这个临时环境中。这确保我们能够干净、顺利地构建 LFS 系统。整个构建过程被的设计目标是尽量降低新读者可能面临的风险,同时提供尽可能多的教育价值。

构建过程是基于交叉编译过程的。交叉编译通常被用于为一台与本机完全不同的计算机构建编译器及其工具链。这对于 LFS 并不严格必要,因为新系统运行的机器就是构建它时使用的。但是,交叉编译拥有一项重要优势:任何交叉编译产生的程序都不可能依赖于宿主环境。

关于交叉编译

[注意]

注意

LFS 手册并不是 (也不包含) 一份通用的,构建交叉 (或本地) 工具链的指南。除非您完全明白自己在干什么,请勿使用手册中的命令构建交叉工具链并用于构建 LFS 以外的用途。

已知安装第二遍的 GCC 会破坏交叉工具链。我们认为这并不是 bug,因为第二遍的 GCC 是本书中最后一个交叉编译的软件包,因此除非在未来我们确实需要在第二遍的 GCC 之后交叉编译某个软件包,我们不会修复这个问题。

交叉编译涉及一些概念,值得专门用一节讨论。尽管您可以在初次阅读时跳过本节,但在之后重新阅读本节,能帮助您更全面地理解构建过程。

首先我们定义讨论交叉编译时常用的术语。

build

指构建程序时使用的机器。注意在某些其他章节,这台机器被称为host(宿主)。

host

指将来会运行被构建的程序的机器。注意这里说的host与其他章节使用的“宿主”(host) 一词不同。

target

只有编译器使用这个术语。编译器为这台机器产生代码。它可能和 build 与 host 都不同。

例如,我们考虑下列场景 (有时称为Canadian Cross)。我们仅在一台运行缓慢的机器上有编译器,称这台机器为 A,这个编译器为 ccA。我们还有一台运行较快的机器 (B),但它没有安装编译器,而我们希望为另一台缓慢的机器 (C) 生成代码。如果要为 C 构建编译器,可以通过三个阶段完成:

阶段 Build Host Target 操作描述
1 A A B 在机器 A 上,使用 ccA 构建交叉编译器 cc1
2 A B C 在机器 A 上,使用 cc1 构建交叉编译器 cc2
3 B C C 在机器 B 上,使用 cc2 构建交叉编译器 ccC

这样,我们可以为机器 C 使用 cc2 在快速的机器 B 上构建所有其他程序。需要注意的是,除非 B 能运行为 C 编译的程序,则在 C 上实际运行它们之前,无法测试它们的功能。例如,如果要测试 ccC,我们可能需要增加第四个阶段:

阶段 Build Host Target 操作描述
4 C C C 在机器 C 上,用 ccC 重新构建它本身,并测试

在上面的例子中,只有 cc1 和 cc2 是交叉编译器,它们为与它们本身运行的机器不同的机器产生代码。而另外的编译器 ccA 和 ccC 为它们本身运行的机器产生代码,它们称为本地编译器。

LFS 的交叉编译实现

[注意]

注意

本书中涉及交叉编译的软件包都使用基于 autoconf 的构建系统。基于 autoconf 的构建系统使用形如 CPU-供应商-内核-操作系统,称为三元组的名称表示目标系统类型。由于供应商字段通常无关紧要,autoconf 允许省略它。

好奇的读者可能会问,为什么一个三元组却包含四个部分。这是由于内核和操作系统两个字段起源于一个系统字段。至今,一些系统仍然用三字段的格式准确描述,例如,x86_64-unknown-freebsd。但是对于其他一些系统,即使两个系统使用相同的内核,它们也可能截然不同,以至于不能使用相同的三元组。例如,运行在智能手机的 Android 和运行在 ARM64 服务器的 Ubuntu 完全不同,尽管它们使用相同类型的 CPU (ARM64) 和相同的内核 (Linux)。

在没有仿真中间层的情况下,显然不能在智能手机上运行用于服务器的可执行文件,反之亦然。因此,系统 字段被拆分为内核和操作系统两部分,以准确区分这些系统。对于本例,Android 被表示为 aarch64-unknown-linux-android,而 Ubuntu 被表示为 aarch64-unknown-linux-gnu

三元组这个词汇被沿用下来。有一种简单方法可以获得您的机器的三元组,即运行许多软件包附带的 config.guess 脚本。解压缩 binutils 源码,然后输入 ./config.guess 运行脚本,并观察输出。例如,对于 32 位 Intel 处理器,输出应该是 i686-pc-linux-gnu。而在 64 位系统上输出应该是 x86_64-pc-linux-gnu。在许多 Linux 系统上,更简单的 gcc -dumpmachine 命令也会输出类似的信息。

您还需要注意平台的动态链接器的名称,它又被称为动态加载器 (不要和 Binutils 中的普通链接器 ld 混淆)。动态链接器由 Glibc 提供,它寻找并加载程序所需的共享库,为程序运行做好准备,然后运行程序。在 32 位 Intel 机器上动态链接器的名称是 ld-linux.so.2 (在 64 位系统上是 ld-linux-x86-64.so.2)。为了准确确定动态链接器名称,可以从宿主系统找一个二进制可执行文件,然后执行:readelf -l <二进制文件名> | grep interpreter 并观察输出。可以在 Glibc wiki 页面找到包含所有平台的权威参考。

在进行交叉编译时需要注意两个关键问题:

  • 在生成和处理应该在 host 执行的机器码时,必须使用交叉工具链。注意构建过程仍然可能调用 build 的本地工具链以生成应该在 build 执行的机器码,例如构建系统可能首先用本地工具链编译一个生成器,然后用该生成器生成一个 C 源代码文件,最后再用交叉工具链编译生成的代码,使其能在 host 运行。

    在使用基于 autoconf 的构建系统时,可以通过 --host 选项指定 host 三元组以满足上述需求。该选项使得构建系统使用带有 <the host triplet> (host 三元组) 前缀的工具链组件生成和处理用于 host 的机器码;例如,使用 <the host triplet>-gcc 作为编译器,使用 <the host triplet>-readelf 作为 readelf 工具。

  • 构建系统不应试图执行任何应该在 host 运行的机器码。例如,在本地编译某个工具时,可能通过运行它并传递 --help 选项,再处理其输出以生成它的手册页,但在交叉编译时通常不能这么做,因为该工具可能根本无法在 build 运行:显然不可能在 x86 CPU 上运行 ARM64 机器码 (如果没有模拟器的话)。

    在使用基于 autoconf 的构建系统时,可以通过所谓的交叉编译模式满足这一需求,在此模式下需要在构建时运行为 host 编译的机器码的可选功能会被全部禁用。在显式指定了 host 三元组的情况下,交叉编译模式当且仅当 configure 无法运行已经为 host 编译的样品程序,或通过 --build 显式指定了和 host 三元组不同的 build 三元组时被启用。

为了为 LFS 临时系统交叉编译软件包,首先微调系统三元组,将其"vendor" 字段改为 "lfs" 后写入LFS_TGT 环境变量,并将该变量值通过 --host 指定为 host 三元组,这样在生成和处理作为 LFS 临时系统一部分的机器码时,构建系统就会使用交叉工具链。此外,我们还需要启用交叉编译模式:host,即 LFS 临时系统所用的机器码尽管能够在当前 CPU 上运行,却可能使用 build (宿主发行版) 没有提供的库;即使库恰好存在,这些机器码也可能使用不存在或定义不同的代码或数据。在为 LFS 临时系统进行交叉编译时,我们不能期望 configure 通过运行样品程序来检测这一问题:样品程序只引用 libc 中的少数组件,而宿主发行版的 libc 很可能提供了这些组件 (或许,除非在宿主发行版使用了不同的 libc 实现,如 Musl),故样品程序不会像那些真正有功能的程序大概会发生的一样运行失败。故我们必须显式指定 build 三元组,以启用交叉编译模式。我们指定的三元组就是默认值,即 config.guess 输出的,未经修改的系统三元组,但正如前文所述,交叉编译模式依赖于是否显式指定了该三元组。

在构建交叉链接器和交叉编译器时,我们指定 --with-sysroot 选项,以告知它们应该在何处搜索为 host 生成代码所需的文件。这几乎保证了第 6 章中构建的其他程序不会连接到 build 中的库。这里使用几乎这个词是因为 libtool,一个包装编译器和链接器的兼容层,可能自作聪明地传递一些导致链接器能找到 build 库的选项。为了防止这一问题,我们需要删除 libtool 档案 (.la) 文件,并修复 Binutils 代码随附的过时的 libtool 副本。

阶段 Build Host Target 操作描述
1 pc pc lfs 在 pc 上使用 cc-pc 构建交叉编译器 cc1
2 pc lfs lfs 在 pc 上使用 cc1 构建 cc-lfs
3 lfs lfs lfs 在 lfs 上使用 cc-lfs 重新构建 (同时可以测试) 它本身

在上表中,在 pc 上 意味着命令在已经安装好的发行版中执行。在 lfs 上 意味着命令在 chroot 环境中执行。

现在,关于交叉编译,还有更多要处理的问题:C 语言并不仅仅是一个编译器;它还规定了一个标准库。在本书中,我们使用 GNU C 运行库,即 glibc (除此之外,还有名为 "musl" 的另一种 C 运行库实现)。它必须为 lfs 目标机器使用交叉编译器 cc1 编译。但是,编译器本身使用一个库,实现汇编指令集并不支持的一些复杂指令。这个内部库称为 libgcc,它必须链接到 glibc 库才能实现完整功能。另外,C++ 标准库 (libstdc++) 也必须链接到 glibc。为了解决这个”先有鸡还是先有蛋“的问题,只能先构建一个降级的 cc1,它的 libgcc 缺失线程和异常等功能,再用这个降级的编译器构建 glibc (这不会导致 glibc 缺失功能),再构建 libstdc++。但是这种方法构建的 libstdc++ 会缺失一些依赖于 libgcc 的功能。

上一自然段的结论是 cc1 无法使用功能降级的 libgcc 构建功能完整的 libstdc++,但这是我们在阶段 2 构建 C/C++ 库时唯一可用的编译器。如同前文所述,我们不能在 pc (宿主发行版) 运行 cc-lfs,因为它可能使用 build (宿主发行版) 未提供的库,代码,或数据。因此在构建第二阶段的 gcc 时,我们覆盖库文件搜索路径,以将 libstdc++ 链接到刚刚重新构建的 libgcc,而不是旧的,功能降级的版本。这样重新构建的 libstdc++ 就会具有完整的功能。

第 8 章 (或者也可以称为“第三阶段”) 中,我们会构建 LFS 需要的所有软件包。即使某个软件包在之前的章节已被构建,我们仍然重新构建它。重新构建的最主要原因是将软件包稳定下来:如果我们在完整的 LFS 系统上重新安装一个 LFS 软件包,则重新安装到系统中的内容应该和第 8 章中初次安装的完全一致。第 6 章第 7 章中的临时软件包无法满足这一条件,因为它们的一些可选功能由于缺失依赖或使用交叉编译模式而被禁用。另外,进行重新构建还有一个次要原因,即运行软件包的测试套件。

构建过程的其他细节

交叉编译器会被安装在独立的 $LFS/tools 目录,因为它不属于最终构建的系统。

我们首先安装 Binutils。这是由于 GCC 和 Glibc 的 configure 脚本首先测试汇编器和链接器的一些特性,以决定启用或禁用一些软件特性。初看起来这并不重要,但没有正确配置的 GCC 或者 Glibc 会导致工具链中潜伏的故障。这些故障可能到整个构建过程快要结束时才突然爆发,不过在花费大量无用功之前,测试套件的失败通常可以将这类错误暴露出来。

Binutils 将汇编器和链接器安装在两个位置,一个是 $LFS/tools/bin,另一个是 $LFS/tools/$LFS_TGT/bin。这两个位置中的工具互为硬链接。链接器的一项重要属性是它搜索库的顺序,通过向 ld 命令加入 --verbose 参数,可以得到关于搜索路径的详细信息。例如,ld --verbose | grep SEARCH 会输出当前的搜索路径及其顺序。(注意这条命令只有在以 lfs 用户身份操作时才能正常工作。如果在阅读后续章节的过程中复习这里的内容,可能需要将 $LFS_TGT-ld 替换为 ld。)

下一步安装 GCC。在执行它的 configure 脚本时,您会看到类似下面这样的输出:

checking what assembler to use... /tools/i686-lfs-linux-gnu/bin/as
checking what linker to use... /mnt/lfs/tools/i686-lfs-linux-gnu/bin/ld

基于我们上面论述的原因,这些输出非常重要。这也说明 gcc 的配置脚本没有在 PATH 变量指定的目录中搜索工具。然而,在 gcc 的实际运行中,未必会使用同样的搜索路径。为了查询 gcc 会使用哪个链接器,需要执行以下命令:$LFS_TGT-gcc -print-prog-name=ld。(同样,如果在阅读后续章节的过程中复习这里的内容,可能需要移除命令中的 $LFS_TGT- 前缀。)

通过向 gcc 传递 -v 参数,可以知道在编译程序时发生的细节。例如,$LFS_TGT-gcc -v example.c (如果在复习这里的内容,可能需要移除 $LFS_TGT) 会输出预处理、编译和汇编阶段中的详细信息,包括 gcc 的包含文件搜索路径和顺序。

下一个步骤是:安装“净化的” (sanitized) Linux API 头文件。这些头文件允许 C 标准库 (glibc) 与 Linux 内核提供的各种特性交互。

下一步安装 Glibc。正如前文所述,我们使用 --host=$LFS_TGT 选项以保证构建系统使用那些带有 $LFS_TGT- 前缀的工具,并使用 --build=$(../scripts/config.guess) 选项启用交叉编译模式。最后,我们使用 DESTDIR 变量,将构建得到的 Glibc 强制安装到 LFS 文件系统中。

正如前文所述,接下来构建 C++ 标准库,然后是第 6 章中的其他程序,必须交叉编译这些程序才能打破构建时的循环依赖。构建并安装这些软件包的步骤类似构建并安装 glibc。

第 6 章的末尾,构建 LFS 本地编译器。首先使用和其他程序相同的 DESTDIR 第二次构建 binutils,然后第二次构建 GCC,构建时省略一些不重要的库。

第 7 章中,进入 chroot 环境后,临时性地安装工具链的正常工作所必须的程序。此后,核心工具链成为自包含的本地工具链。在第 8 章中,构建,测试,并最终安装所有软件包,它们组成功能完整的系统。