编译一个程序需要编译器,但编译器本身又是一个程序,需要被另一个程序编译出来;编译器需要依赖 C 标准库才能正常运作,但 C 标准库自己又需要被编译器编译出来。在从零开始构建的系统中,这就是一个先有鸡还是先有蛋的问题。

为了解决这个问题,我们需要进行交叉编译

交叉编译主要有三个概念:

  • Build 指构建程序用的机器
  • Host 指构建出的程序运行的机器
  • Target 指编译器产生代码的目标机器

为了构建 LFS,我们要进行以下三个阶段:

  1. 用宿主机的编译器编译出一个运行在宿主机上没有 C 标准库支持的残缺交叉编译器。

  2. 用交叉编译器,在宿主机上为新系统编译出标准库和完整版的编译器。我们不能用宿主机的编译器来编译标准库,因为这样会使标准库内部链接一些宿主机的代码,造成污染。标准库必须由新系统的编译器来编译

  3. 在新系统上,用完整版编译器重新编译自己,得到不依赖宿主系统的独立编译器。

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

LFS 基于 Autoconf 构建系统,它使用形如 x86_64-lfs-linux-gnu 的三元组来表示目标系统类型。当我们执行完第一步之后,宿主机就会出现两个编译器:gccx86_64-lfs-linux-gnu-gcc。后期我们执行 ./configure --host=x86_64-lfs-linux-gnu 时,autoconf 就会去寻找 x86_64-lfs-linux-gnu 开头的编译器、链接器和汇编器,确保构建出的程序是为新系统准备的。

LFS 的大多数软件是基于 make 编译和安装的,完整的安装过程有以下六步:

  1. 下载软件包,一般是一个压缩包,包含软件的源码
  2. tar -xf 解压软件包并 cd 进入源代码目录
  3. (某些软件包需要)创建 build 目录作为构建目录,并 cd 进入
  4. configure 生成自定义的 Makefile
  5. make 编译程序
  6. make install 安装程序

我们在实战中了解这些概念。

编译交叉工具链

交叉工具链是在宿主机运行,为新系统编译程序的工具集合。在 LFS 中,交叉工具链有五个程序:

  1. Binutils,提供汇编器 as 和链接器 ld
  2. GCC,编译器
  3. Linux API Header,导出内核 API 供 Glibc 使用
  4. Glibc,C 标准库
  5. Libstdc++,C++ 标准库

交叉工具链会被放在 $LFS/tools 中,与其他后续构建的系统程序分开。

Binutils - Pass 1

这是一切的基础,我们第一个构建的程序。Glibc 和 GCC 的 configure 都会检查汇编器和链接器,如果没有它们,编译器就构建不起来。

我们在 Chapter 1 就下载好了 Binutils 的软件包,接下来是第二步解压文件:

tar -xf binutils-2.46.0.tar.xz
cd binutils-2.46.0

第三步,创建构建目录:

mkdir build
cd build

第四步,./configure

../configure --prefix=$LFS/tools \
             --with-sysroot=$LFS \
             --target=$LFS_TGT   \
             --disable-nls       \
             --enable-gprofng=no \
             --disable-werror    \
             --enable-new-dtags  \
             --enable-default-hash-style=gnu
checking build system type... x86_64-pc-linux-gnu
checking host system type... x86_64-pc-linux-gnu
checking target system type... x86_64-lfs-linux-gnu
checking for a BSD-compatible install... /usr/bin/install -c
checking whether ln works... yes
checking whether ln -s works... yes
...

这个 configure 做了几个重要的设置:

  • --prefix=$LFS/tools 表示要将程序安装到 $LFS/tools
  • --with-sysroot=$LFS 表示交叉编译时在 $LFS 中寻找目标系统的库
  • --target=$LFS_TGT 表示程序是为 $LFS_TGT 编译的。我们在 Chapter 1 最后准备工作环节定义了其值为 $(uname -m)-lfs-linux-gnu

第五步,编译程序:

make

我用了 2 分 38 秒来编译 Binutils,LFS 把这个时间作为 SBU,但实际上的耗时和用 SBU 计算的结果有些差异,对于多核并行编译的情况其实很普遍。

第六步,安装程序:

make install

至此,Binutils 就安装完毕了。后续几乎所有软件的构建过程都是这六步。

GCC - Pass 1

GCC 的编译除 Binutils 外,还依赖三个包:

  1. GMP (GNU Multiple Precision Arithmetic Library),是一个任意精度整数运算库。普通的程序受限于 CPU 的位数,但 GMP 可以处理任意精度的数据而不会溢出。后两个包都以它为基础。
  2. MPFR (GNU Multiple Precision Floating-Point Reliable Library),基于 GMP,是一个多精度浮点数运算库,以 IEEE 754 为标准。不同 CPU 对于浮点数的溢出、舍入等处理逻辑可能不同,但 MPFR 可以确保浮点数在不同平台上计算出的结果完全一致。GCC 在常量折叠时需要在编译阶段把源码中一些算数表达式的结果算出来,MPFR 可以保证编译器算出的结果和目标机器算出的结果一致。
  3. MPC (GNU Multiple Precision Complex Library),基于 MPFR,是一个任意精度复数运算库。GCC 集成了很多数学函数,如 sin()cos()exp() 的优化,GCC 可以基于欧拉公式等数学公式来简化表达式,为此需要可靠的复数运算操作。

所以编译 GCC 之前,我们需要先进入 GCC 的源码树,将这三个包的源码树移到这里并重命名去除版本号:

tar -xf ../mpfr-4.2.2.tar.xz
mv -v mpfr-4.2.2 mpfr
tar -xf ../gmp-6.3.0.tar.xz
mv -v gmp-6.3.0 gmp
tar -xf ../mpc-1.3.1.tar.gz
mv -v mpc-1.3.1 mpc

对于 x86_64 平台,没有 32 位的系统需要区分,所有库全部安装在 /lib 目录下。

GCC 用 t-linux64 文件来指定 64 位库的位置,我们用 sed 将其改成 /lib

sed -e '/m64=/s/lib64/lib/' -i.orig gcc/config/i386/t-linux64

这条命令为原 t-linux64 文件创建一个备份:t-linux64.orig,并把原文件中 m64= 行的 lib64 替换成 lib

Installing GCC: Configuration 的第五段开头强烈建议 GCC 要在一个新目录中进行构建,源代码树中的构建是不支持的。所以和构建 Binutils 一样,先创建 build 目录,接着在 build 目录中运行 configure:

../configure                  \
    --target=$LFS_TGT         \
    --prefix=$LFS/tools       \
    --with-glibc-version=2.43 \
    --with-sysroot=$LFS       \
    --with-newlib             \
    --without-headers         \
    --enable-default-pie      \
    --enable-default-ssp      \
    --disable-nls             \
    --disable-shared          \
    --disable-multilib        \
    --disable-threads         \
    --disable-libatomic       \
    --disable-libgomp         \
    --disable-libquadmath     \
    --disable-libssp          \
    --disable-libvtv          \
    --disable-libstdcxx       \
    --enable-languages=c,c++

其中有几个重要的设置:

  • --with-newlib 由于现在没有 C 标准库,我们需要暂时不编译含 C 标准库调用的代码。
  • --disable-shared 禁用 GCC 的动态链接,因为动态链接需要系统还没有安装的 Glibc 支持。此时 GCC 会链接它的内部库。
  • --disable-threads, --disable-libatomic, --disable-libgomp, --disable-libquadmath, --disable-libssp, --disable-libvtv, --disable-libstdcxx 禁用这些库的支持,因为我们并没有这些库,带上它们会导致编译失败。

执行完成后,makemake install 就可以完成残缺编译器的编译和安装。

make install 帮我们安装了一些系统头文件,通常情况下,一个 limits.h 会包含相应系统的 limits.h 头文件,对于我们来说是 $LFS/usr/include/limits.h。但目前这个文件并不存在,所以安装的内部头文件散落在各处。后续我们需要完整的内部头文件,所以需要将这些散落的头文件拼起来:

cd ..
cat gcc/limitx.h gcc/glimits.h gcc/limity.h > \
  `dirname $($LFS_TGT-gcc -print-libgcc-file-name)`/include/limits.h

Linux API Header

Linux API Header 在内核中,我们需要解压进入内核源码树 linux-6.18.10

内核的 Makefile 实现了一个 mrproper 的目标,用来恢复源码树到原始状态。我们首先 make 它来确定源码树是干净的:

make mrproper

Linux 内核文档中的 Exporting kernel headers for use by userspace 建议用 make headers_install 从源码中导出内核头文件,但这个 make 目标需要用到 rsync,而我们并没有安装。所以我们用另一个 make 目标 headers

make headers

导出的头文件放在 ./usr 中。我们最终需要的位置是 $LFS/usr,现在把它们复制过去:

find usr/include -type f ! -name '*.h' -delete
cp -rv usr/include $LFS/usr

Glibc

Linux Standard Base (LSB)10.1. Program Interpreter/Dynamic Linker 这一节规定,系统的链接器必须在 /lib64/ld-lsb-x86-64.so.3

The Program Interpreter shall be /lib64/ld-lsb-x86-64.so.3.

对于 32 位系统,这个地址是 /lib/ld-lsb.so.3

但在 LFS 中,动态链接器的实际路径在 /lib/ld-linux-x86-64.so.2。所以为了兼容 LSB 标准,必须为这两个 LSB 路径创建到实际链接器路径的软链接:

case $(uname -m) in
    i?86)   ln -sfv ld-linux.so.2 $LFS/lib/ld-lsb.so.3
    ;;
    x86_64) ln -sfv ../lib/ld-linux-x86-64.so.2 $LFS/lib64
            ln -sfv ../lib/ld-linux-x86-64.so.2 $LFS/lib64/ld-lsb-x86-64.so.3
    ;;
esac

这里用了 ../lib 而不是 $LFS/lib 来指定路径,因为后者会导致我们在 chroot 后依然使用 $LFS/lib 而不是 /lib 绝对路径。写 ../lib 意味着去上一级目录的 lib 文件夹找,这个相对位置永远是正确的。

同时,Glibc 使用与 FHS 不兼容的 /var/db 目录存放一些运行时数据,运行一个补丁使其行为和 FHS 兼容:

patch -Np1 -i ../glibc-fhs-1.patch

回顾所有的二进制文件都应该安装在 /usr 而不是根目录下。Glibc 会安装一些系统工具如 ldconfigsln,它们默认被安装在 /sbin ,但我们需要它们被安装到 /usr/sbin。修改 configparms 来实现这一点:

echo "rootsbindir=/usr/sbin" > configparms

这样就把默认的 sbin 目录从 /sbin 改成了 /usr/sbin

接下来创建一个 build 目录,执行 configure

../configure                             \
      --prefix=/usr                      \
      --host=$LFS_TGT                    \
      --build=$(../scripts/config.guess) \
      --disable-nscd                     \
      libc_cv_slibdir=/usr/lib           \
      --enable-kernel=5.4

Glibc 必须由新系统的编译器编译。所以添加 --host=$LFS_TGT--build=$(../scripts/config.guess) 参数来指定编译器为刚刚构建的交叉编译器。

生成 Makefile 后,用 make 编译 Glibc。

但是在安装环节,我们不能简单的 make install,因为默认的安装路径是宿主机的根目录 /。前两个工具都是安装在宿主机上的,这没问题,但 Glibc 要安装在 LFS 的根目录中,所以得用 DESTDIR 来指定安装路径为 $LFS

make DESTDIR=$LFS install

其中 $LFSchroot 后会作为新系统的根目录。

修改 ldd 脚本中硬编码的加载器路径:

sed '/RTLDLIST=/s@/usr@@g' -i $LFS/usr/bin/ldd

现在 Glibc 已经成功安装,但还需要做一些检查,比如确定动态链接器的地址是 /lib/ld-linux-x86-64.so.2,libc 的地址是 /lib/libc.so.6。用 GCC 编译一个合法的空程序来观察编译中的各种行为:

echo 'int main(){}' | $LFS_TGT-gcc -x c - -v -Wl,--verbose &> dummy.log

编译出的 a.out 是一个 ELF 文件,读取其 Header 来看看程序运行时的动态链接器路径:

readelf -l a.out | grep ': /lib'
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

/lib64 已经被软链接到了 /lib,所以动态链接器的地址是正确的。

接着确认启动文件的路径:

grep -E -o "$LFS/lib.*/S?crt[1in].*succeeded" dummy.log
/mnt/lfs/lib/../lib/Scrt1.o succeeded
/mnt/lfs/lib/../lib/crti.o succeeded
/mnt/lfs/lib/../lib/crtn.o succeeded

确认头文件的路径:

grep -B3 "^ $LFS/usr/include" dummy.log
#include <...> search starts here:
 /mnt/lfs/tools/lib/gcc/x86_64-lfs-linux-gnu/15.2.0/include
 /mnt/lfs/tools/lib/gcc/x86_64-lfs-linux-gnu/15.2.0/include-fixed
 /mnt/lfs/usr/include

确认新链接器的搜索路径:

grep 'SEARCH.*/usr/lib' dummy.log |sed 's|; |\n|g'
SEARCH_DIR("=/mnt/lfs/tools/x86_64-lfs-linux-gnu/lib64")
SEARCH_DIR("=/usr/local/lib64")
SEARCH_DIR("=/lib64")
SEARCH_DIR("=/usr/lib64")
SEARCH_DIR("=/mnt/lfs/tools/x86_64-lfs-linux-gnu/lib")
SEARCH_DIR("=/usr/local/lib")
SEARCH_DIR("=/lib")
SEARCH_DIR("=/usr/lib");

确认 libc 的路径:

grep "/lib.*/libc.so.6 " dummy.log
attempt to open /mnt/lfs/usr/lib/libc.so.6 succeeded

确认 GCC 使用的动态链接器路径:

grep found dummy.log
found ld-linux-x86-64.so.2 at /mnt/lfs/usr/lib/ld-linux-x86-64.so.2

确认所有路径正常后,删除测试文件,完成 Glibc 的安装。

Libstdc++

GCC 的一部分是 C++ 写的,为了编译完整的 GCC,必须要有 C++ 标准库,即 Libstdc++。

创建一个 build 目录,在其中执行 configure

../libstdc++-v3/configure      \
    --host=$LFS_TGT            \
    --build=$(../config.guess) \
    --prefix=/usr              \
    --disable-multilib         \
    --disable-nls              \
    --disable-libstdcxx-pch    \
    --with-gxx-include-dir=/tools/$LFS_TGT/include/c++/15.2.0

make 之后 make DESTDIR=$LFS install ,移除一些对交叉编译有害的 libtool 文件后就完成安装了。

至此,交叉工具链已构建完成。

交叉编译临时工具

现在已经完成了交叉工具链的安装,但距离成为一个独立的系统还有很长一段距离。这个系统没有我们耳熟能详的 cdlsgrep 等程序,也没有 make 这样用于 LFS 独立编译的工具。为了彻底和宿主机隔离,我们必须在 LFS 上安装这些必备的基础软件包,同时第二次编译 Binutils 和 GCC 使其成为完整编译器,以支撑后续 chroot 环境的运行。

现在来到了交叉编译的第二阶段:“在 pc 上使用 cc1 构建 cc-lfs”。此时 Build 是 pc,Host 是 lfs,Target 是 lfs。这一节所有的软件包 configure 时都会带一个 --host=$LFS_TGT 参数,意味着我们虽然在宿主机编译程序,但它应该为 LFS 生成代码。

由于安装过程依然都是老六步,我们略去安装细节,主要研究这些软件包的作用。

编译与构建工具

M4 是一个宏处理器,它的工作是扫描文本,找到用户定义的宏,并把它们替换成更复杂的文本,在 LFS 中,它的作用主要是给 Autoconf 提供运行环境。Autoconf 本质上就是一套复杂的 M4 宏,用于生成 configure 脚本。它读取一个叫做 configure.ac 的文件,这个文件包含了很多 Autoconf 特有的 M4 宏,然后通过 m4 展开,最终生成 configure。LFS 的目标是建立一个完全独立的系统,如果新系统中没有 Autoconf ,就不具备在本地开发和重新编译软件的能力,这是和 LFS 的理念不符的。

Make 就是编译过程一直在用到的 make,编译工作离不开它。

Patch 用来给应用打补丁。后续安装的软件包都有一些漏洞或者我们需要调整的功能,用 patch -i <patch file> 就可以自动地修改代码。

Shell 与终端

Bash 是我们使用的 Shell。在准备编译 Bash 时,--without-bash-malloc 配置选项会被加上来禁止 Bash 用自己实现的 malloc,而转用更成熟、稳定的 Glibc 实现。这告诉我们编译系统时除了依赖地狱之外,还有更深层的包之间关系需要考虑,真正厘清程序之间的完整联系需要下很大功夫。

Ncurses 是字符屏幕处理函数库,Linux 终端中带彩色框、有菜单或者可以被鼠标点击的界面都是由它支持。比如 readline 依赖它来处理光标移动,Vim 用它来渲染代码和状态栏,而编译 Linux 内核时,make menuconfig 出现的蓝色菜单也是基于它开发的。

文本处理

Sed 是经典的流编辑器,LFS 的脚本和指令需要通过命令行自动修改源代码,而 Sed 可以接收文本流,根据 regex 匹配内容再进行替换。很多软件包的 Makefile 或 configure 在 LFS 这种纯净环境下会失效,如某个软件默认寻找 /usr/local/lib,但 LFS 要求使用 /usr/lib,此时会用到类似 sed -i 's|/usr/local|/usr|g' Makefile 的命令。

Gawk 是 GNU 对 awk 的实现。awk 不仅是一个命令,更是一个图灵完备的完整文本处理编程语言,可以完成任何复杂的文本处理工作。后续的很多测试脚本要用到它来解析编译器的输出,确保 Glibc 或 GCC 的配置符合预期。

Grep 包含 grep,很多 configure 脚本会用 grep 来探测宿主机的功能,比如检查 /proc/cpuinfo 来确认 CPU 是否包含特定指令集。后续进行一些编译后测试时,也会用到它来提取测试输出中我们需要的信息。

Diffutils 包括 diffcmp 等显示文件或目录差异的程序。准备编译时给它加上了 gl_cv_func_strcasecmp_works=y 选项,阻止其进行一项针对 strcasecmp 的检查,这项检查需要编译运行一个程序,但我们目前处在交叉编译阶段,不允许程序在宿主机上运行。这告诉我们在交叉编译的过程中一定要时刻了解自己要做什么,配置选项中哪些是对交叉编译环境不友好的,为此必须仔细理解每一个可选的编译选项。

文件与系统工具

Coreutils 涵盖了最常用的日常指令,包括:

  • 文件操作,如 lscpmvrmmkdirlnchownchmod
  • 文本处理,如 catheadtailsortcuttrwc
  • 系统管理,如 whoamihostnamedudfuptimeuname
  • Shell 辅助,如 echotruefalsepwdtestsleep

尽管如此,还是有一些常用的程序不包含在里面。grepsedawk 等因为自身足够复杂,所以独立成包;findxargs 属于 Findutils;而 diffcmp 等则包含在 Diffutils 中。

Findutils 包括 findxargs 等用于查找文件的程序。虽然现在 fd 基本可以替代 find,但对于一些硬编码 find 指令的程序这个包还是必须的。

File 通过读取文件开头的 Magic Number 来识别文件类型。没有它,我们就无法仅凭文件内容判断其格式。例如:

file /bin/ls
/bin/ls: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), ...
file image.jpg
image.jpg: JPEG image data, JFIF standard 1.01, aspect ratio, density 1x1, segment length 16, ...

压缩与归档

Tar 用来解压文件。大多数软件包都以 tar.gztar.xz 打包,我们需要它来解压这些软件包。

Gzip 包含 gzip,我们的源码大多是以 .tar.gz.tgz 的格式提供的,虽然通常运行 tar 来解压它们,但实际上 tar 内部也是调用 gzip 来进行流式解压。编译内核时,也需要它来压缩内核镜像 vmlinuz,其中 z 就是压缩的意思。

xz 是底层的解压缩工具,当运行 tar -xftar.xz 文件解压时实际上在调用 xz

Binutils - Pass 2

第一次编译的 Binutils 使用宿主机的 Glibc 作为标准库,而我们刚刚构建了 LFS 自己的 Glibc,现在可以第二次编译 Binutils 使其链接到 LFS 的 Glibc 了。

首先,Binutils 的构建依赖 libtool 来拷贝链接内部静态库,但源码包并不使用 libtool,可能会导致程序或库链接到宿主机的库。libtool 的核心脚本是 ltmain.sh,对于 2.46.0 版本,其第 6031 行:

add_dir="$add_dir -L$inst_prefix_dir$libdir"

会生成和宿主机相关联的代码。如果 inst_prefix_dir/mnt/lfs,而 libdir/usr/lib,它会生成 -L/mnt/lfs/usr/lib

为此,我们删除这行的 $add_dir:

sed '6031s/$add_dir//' -i ltmain.sh

在源码树创建一个 build 目录,准备编译:

../configure                   \
    --prefix=/usr              \
    --build=$(../config.guess) \
    --host=$LFS_TGT            \
    --disable-nls              \
    --enable-shared            \
    --enable-gprofng=no        \
    --disable-werror           \
    --enable-64-bit-bfd        \
    --enable-new-dtags         \
    --enable-default-hash-style=gnu

接着 makemake DESTDIR=$LFS install 就完成了安装。

GCC - Pass 2

回顾第一次编译的 GCC 是没有标准库的残缺版本。现在我们有了 Glibc,可以编译出完整版的 GCC 了。

首先解压同样的三个包并移动到 GCC 的源码树,再将默认的库路径改为 /lib

sed -e '/m64=/s/lib64/lib/' -i.orig gcc/config/i386/t-linux64

创建 build 目录,准备编译:

../configure                   \
    --build=$(../config.guess) \
    --host=$LFS_TGT            \
    --target=$LFS_TGT          \
    --prefix=/usr              \
    --with-build-sysroot=$LFS  \
    --enable-default-pie       \
    --enable-default-ssp       \
    --disable-nls              \
    --disable-multilib         \
    --disable-libatomic        \
    --disable-libgomp          \
    --disable-libquadmath      \
    --disable-libsanitizer     \
    --disable-libssp           \
    --disable-libvtv           \
    --enable-languages=c,c++   \
    LDFLAGS_FOR_TARGET=-L$PWD/$LFS_TGT/libgcc

其中 --with-build-sysroot=$LFS 指定构建时的 sysroot 为 $LFS,使编译器能找到 LFS 的头文件和库。这次的配置没有 --with-newlib,因为我们会使用 LFS 的 Glibc 来作为标准库。

配置完成后,makemake install 就完成安装了。至此,所有必须的软件包已经就绪,下一步就是久违的 chroot 进 LFS,彻底切断和宿主机的关联。