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 的交叉编译实现

[注意]

注意

几乎所有构建系统都使用形如 CPU-供应商-内核-操作系统,称为三元组的名称表示目标机器。好奇的读者可能感到奇怪,为什么一个三元组却包含四个部分。这是历史遗留的:最早,三个部分就足以无歧义地描述一台机器。但是随着新的机器和系统不断出现,最终证明三个部分是不够的。然而,三元组这个术语保留了下来。有一种简单方法可以获得您的机器的三元组,即运行许多软件包附带的 config.guess 脚本。解压缩 Binutils 源码,然后运行脚本:./config.guess,观察输出。例如,对于 32 位 Intel 处理器,输出应该是 i686-pc-linux-gnu,而对于 64 位系统输出应该是 x86_64-pc-linux-gnu

另外注意平台的动态链接器的名称,它又被称为动态加载器 (不要和 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" 域进行修改。我们还会在构建交叉链接器和交叉编译器时使用 --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。它必须为 lfs 目标机器使用交叉编译器 cc1 编译。但是,编译器本身使用一个库,实现汇编指令集并不支持的一些复杂指令。这个内部库称为 libgcc,它必须链接到 glibc 库才能实现完整功能!另外,C++ 标准库 (libstdc++) 也必须链接到 glibc。为了解决这个”先有鸡还是先有蛋“的问题,只能先构建一个降级的 cc1,它的 libgcc 缺失线程和异常等功能,再用这个降级的编译器构建 glibc (这不会导致 glibc 缺失功能),再构建 libstdc++。但是这种方法构建的 libstdc++ 和 libgcc 一样,会缺失一些功能。

讨论还没有结束:上面一段的结论是 cc1 无法构建功能完整的 libstdc++,但这是我们在阶段 2 构建 C/C++ 库时唯一可用的编译器!当然,在阶段 2 中构建的编译器 cc-lfs 将会可以构建这些库,但是 (1) GCC 构建系统不知道这个编译器在 pc 上可以使用,而且 (2) 它是一个本地编译器,因此在 pc 上使用它可能产生链接到 pc (宿主系统) 库的风险。因此我们必须在进入 chroot 后再次构建 libstdc++。

构建过程的其他细节

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

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

Binutils 将汇编器和链接器安装在两个位置,一个是 $LFS/tools/bin,另一个是 $LFS/tools/$LFS_TGT/bin。这两个位置中的工具互为硬链接。链接器的一项重要属性是它搜索库的顺序,通过向 ld 命令加入 --verbose 参数,可以得到关于搜索路径的详细信息。例如,ld --verbose | grep SEARCH 会输出当前的搜索路径及其顺序。此外,通过编译一个样品 (dummy) 程序并向链接器 ld 传递 --verbose 参数,可以知道哪些文件被链接。例如,gcc dummy.c -Wl,--verbose 2>&1 | grep succeeded 将显示所有在链接过程中被成功打开的文件。

下一步安装 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

通过向 gcc 传递 -v 参数,可以知道在编译样品程序时发生的细节。例如,gcc -v dummy.c 会输出预处理、编译和汇编阶段中的详细信息,包括 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,构建时忽略 libstdc++ 和其他不重要的库。由于 GCC 配置脚本的一些奇怪逻辑,CC_FOR_TARGET 变量在 host 系统和 target 相同,但与 build 不同时,被设定为 cc。因此我们必须显式地在配置选项中指定 CC_FOR_TARGET=$LFS_TGT-gcc

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