ii. 工具链技术说明

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

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

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

关于交叉编译

[注意]

注意

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

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

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

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 源码树根目录的 shlib-versions 文件中找到。

在 LFS 的构建过程中,为了将本机伪装成交叉编译目标机器,我们在 LFS_TGT 变量中,将宿主系统三元组的 "vendor" 域修改为 "lfs"。改。我们还会在构建交叉链接器和交叉编译器时使用 --with-sysroot 选项,指定查找所需的 host 系统文件的位置。这保证在第 6 章中的其他程序在构建时不会链接到宿主 (build) 系统的库。前两个阶段是必要的,第三个阶段可以用于测试:

阶段 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++ 库时唯一可用的编译器。两项原因导致我们目前不能用第二阶段构建的编译器,cc-lfs,构建这些库。

  • 一般来说,cc-lfs 不能在 pc (宿主系统) 上运行。尽管 pc 和 lfs 的三元组互相兼容,为 lfs 构建的可执行文件会依赖于 glibc-2.38;而宿主系统可能使用不同的 libc 实现 (例如,musl) 或较旧的 glibc 版本 (例如,glibc-2.13)。

  • 即使 cc-lfs 能在 pc 上运行,在 pc 上使用它可能产生链接到 pc (宿主系统) 库的风险,因为 cc-lfs 是一个本地编译器。

因此在第二阶段构建 gcc 时,我们指示构建系统使用 cc1 再次构建 libgcc 和 libstdc++,但是将 libstdc++ 链接到刚刚重新构建的 libgcc,而不是旧的,功能降级的版本。这样重新构建的 libstdc++ 就会具有完整的功能。

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

构建过程的其他细节

交叉编译器会被安装在独立的 $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。在构建 Glibc 时需要着重考虑编译器,二进制工具,以及内核头文件。编译器一般不成问题,Glibc 总是使用传递给配置脚本的 --host 参数相关的编译器。例如,在我们的例子中,使用的编译器是 $LFS_TGT-gcc。但二进制工具和内核头文件的问题比较复杂。我们为了安全起见,使用配置脚本提供的开关以确保正确选择。在 configure 脚本运行完成后,可以检查 build 目录中的 config.make 文件,了解全部重要的细节。注意参数 CC="$LFS_TGT-gcc" (其中 $LFS_TGT 会被展开) 控制构建系统使用正确的二进制工具,而参数 -nostdinc-isystem 控制编译器的包含文件搜索路径。这些事项凸显了 Glibc 软件包的一个重要性质 —— 它的构建机制是相当自给自足的,通常不依赖于工具链默认值。

正如前文所述,接下来构建 C++ 标准库,然后是第 6 章中的其他程序,必须交叉编译这些程序才能打破构建时的循环依赖。在安装这些软件包时使用 DESTDIR 变量,以确保将它们安装到 LFS 文件系统中。

第 6 章的末尾,构建 LFS 本地编译器。首先使用和其他程序相同的 DESTDIR 第二次构建 binutils,然后第二次构建 GCC,构建时忽略一些不重要的库。由于 GCC 配置脚本的一些奇怪逻辑,CC_FOR_TARGET 变量在 host 系统和 target 相同,但与 build 不同时,被设定为 cc。因此我们必须显式地在配置选项中指定 CC_FOR_TARGET=$LFS_TGT-gcc

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