第22.2章:系统根

English

1. 规则

规则。工具链需要目标平台的 C 库。要么你在 系统根里 提供它,要么目标就是独立的,工具链必须按独立目标来配置。其余部分—— 编译器运行时、C++ 标准库、sanitizer——由 GCC 或 LLVM 提供。

这正是大多数交叉编译教程漏掉的部分。它们告诉你装一个编译器、传 --target, 然后链接失败:cannot find crt1.o 或 cannot find -lc。 编译器的职责不是提供那些文件。编译器是代码生成器加一个驱动;操作系统的 ABI 在 C 库和 随它一起来的启动对象里。

这条规则有一个更强的形式,在自举一个新平台时尤其重要: 一个 C/C++ 系统根是构建编译器本身的最低要求。GCC 和 Clang 都是 C++ 程序。 在构建它们之前,构建机需要一个能用的 C++ 编译器、C++ 标准库、libc 和平台头文件。你要构建的 编译器可以面向一块独立板子,但编译器二进制本身仍然运行在宿主机器上,仍然要链接 C++ 运行时。

地基。平台上的其他所有语言工具链都建立在 C/C++ 系统根之上。 CPython 的解释器和扩展模块是 C 写的,链接 libc;Go 的 cgo 和平台相关包使用 C ABI 和 系统调用层;Rust 标准库链接 libc 并调用 C 链接器;Node.js 和 V8 是 C++。 它们都没有定义第二个系统根。如果 C/C++ 系统根缺失或者不对,它们同样无法为那个平台构建。

这就是为什么一个新操作系统的移植首先需要的不是 Python 移植或 Rust 移植,而是 C/C++ 工具链和 系统根。有了这些,其他运行时只是针对它们构建的普通程序,"移植 Python"就变成了构建一遍 CPython, 而不是定义一个新平台。C/C++ 层是平台契约;其他一切都是消费者。

在加拿大构建里,同一条规则适用两次。编译器二进制运行在宿主上,所以宿主系统根必须 完整到能链接一个 C++ 程序:libc、C++ 标准库和 C++ 头文件。目标系统根必须提供目标 运行时库所依赖的 C 库和头文件。两者都是 C/C++ 系统根,只是属于不同的三元组。

一旦看清楚,分工是很干净的:

组件 由谁提供 例子
C 库 系统根 glibc、musl、bionic、newlib、mingw-w64 CRT、Apple libSystem
启动对象 系统根(来自 libc) crt1.o、crti.o、crtn.o、Scrt1.o
内核头文件 系统根 linux/、asm/、asm-generic/
动态加载器 系统根(来自 libc) ld-linux-x86-64.so.2、ld-musl-aarch64.so.1
编译器运行时 GCC 或 LLVM libgcc、libgcc_eh、compiler-rt builtins
C++ 标准库 目标由 GCC 或 LLVM 提供;宿主的那份属于宿主 C/C++ 环境 libstdc++、libc++、libc++abi、libunwind
编译器头文件 GCC 或 LLVM stddef.h、stdarg.h、stdint.h、limits.h

C++ 标准库依赖 C 库,所以它在 libc 之后、针对系统根构建。但它仍然属于编译器发行版的一部分: 你随 GCC 安装 libstdc++,随 LLVM 安装 libc++,而不是随操作系统安装。 在加拿大构建里,这个区分决定了哪个产物必须为宿主构建、哪个必须为目标构建。

宿主侧是那条证明规则存在的例外。用来构建新编译器的 C++ 编译器和 C++ 标准库,属于 构建机已有的 C/C++ 环境。它们不是你要构建的那个编译器产出的。这就是为什么一台新构建机的第一个 要求是一个能用的 C/C++ 工具链,而不是新编译器的副本。

2. 系统根长什么样

系统根是一棵看起来像目标根文件系统的目录树。GCC 和 Clang 用的是同一个概念:把编译器指向一个目录, 驱动在这个目录里解析默认的头文件、库和程序搜索路径。常见的树就是:

/path/to/sysroot/
  bin/       target programs and helper tools
  include/   C and C++ headers
  lib/       libraries, startup objects and the dynamic loader
  share/     target data files

有些系统把 /usr 合并,把同样的目录放在 usr/ 下,所以编译器可能搜索 usr/include 和 usr/lib,而不是 include 和 lib。这是路径细节,不是另一种系统根格式。目标还可能增加 lib64、 libexec 或多架构子目录。根仍然是一个目录。

GCC 和 Clang 使用同一套根布局;两者都接受 --sysroot。它们的驱动可能有不同的 默认搜索路径,但那是驱动细节,不是另一种系统根格式。GCC 交叉驱动经常把系统根默认设为 $PREFIX/$TARGET;Clang 经常用显式的 --sysroot= 指向一个 SDK。 两者都指向一棵根树。如果编译器的搜索路径和磁盘上的文件不一致,链接就会失败;修复方式是检查 实际搜索路径,而不是重命名目录树。

系统根不是“GCC 布局”或“Clang 布局”。它是目标根。编译器的默认路径是驱动和目标配置的属性; --sysroot 只是改变这些路径相对哪个根解析。

启动对象通常是第一个报错点。Linux 上动态链接的 C 程序需要 libc 的 crt1.o、 crti.o、crtn.o,加上编译器运行时的 crtbegin.o 和 crtend.o,再加 libc.so。静态程序需要 libc.a、 libm.a 以及启动对象的静态版本。少任何一个,链接都会在你的第一行代码之前失败。

3. 宿主还是独立

"libc 在哪里"只有两个合法答案,也只有两个:

错误在于把两者混在一起。一个写着 -linux-gnu 的三元组是在承诺目标有 glibc 和 Linux ABI。如果你为这个三元组构建编译器却没有系统根,构建可能成功,而每一次链接都会失败。 反过来,写着 -elf 或 -none- 的三元组按定义没有 libc;给它加一个 Linux 系统根不会让它变成 Linux,只会把不匹配藏起来,直到某处调用了目标根本没有的系统调用。

有些嵌入式目标使用 newlib,它是一个带移植层的小型 C 库。从 C 的意义上说它是 宿主目标,但它不是 POSIX,也不是 glibc。它仍然属于系统根,编译器仍然只提供自己的运行时。 "独立"意味着完全没有 libc,而不是"一个小 libc"。

4. 系统根从哪里来

实际来源有三种,它们失败的方式各不相同:

  1. 从源码构建 libc。Linux 目标的常规路径:安装内核头文件,用第一阶段的交叉 编译器构建 glibc 或 musl,安装到系统根,然后针对它重新构建 GCC。这样系统根就是你选定的 ABI,代价是构建时间长。
  2. 从 SDK 或操作系统镜像里提取。Android NDK 自带包含 bionic 头文件和 stub 库的 系统根。Windows SDK 自带 MSVC CRT 和导入库。macOS SDK 自带 libSystem 和框架。这更快, 但你被绑在厂商的布局和许可证上。
  3. 复用另一套工具链的系统根。如果已经有一套 GCC 交叉工具链,clang 可以通过 --gcc-toolchain=$PREFIX --sysroot=$SYSROOT 使用它。大多数人就是这样在不重构建 整个世界的情况下得到可用的 clang 交叉编译器。这一章的 LLVM 页也走这条路。

对加拿大构建来说,系统根必须为目标构建,而且必须出现在编译器将要记录的那个路径上。 GCC 的 --with-sysroot=$SYSROOT 把这个路径写死进编译器。如果你之后把工具链移到另一台 机器,而那个路径不存在,编译器能启动,然后找不到头文件。要么在宿主机上把系统根装到同样的路径, 要么每次调用都显式传 --sysroot。

构建期间还有第二个路径:--with-build-sysroot。它告诉构建系统,在编译器被生产出来的 过程中,目标头文件和库在构建机上的哪里。它不会被记录进编译器。把两者弄混,是交叉编译器 在 make 期间能用、安装后却不能用的一种常见原因。

5. 编译器自己的文件

"GCC 提供"和"LLVM 提供"具体指什么,值得说清楚,因为这些文件装在完全不同的位置:

GCC LLVM 为谁构建
libgcc.a、libgcc_s.so libclang_rt.builtins.a 目标
libstdc++.so、libstdc++.a libc++.so、libc++.a 目标
crtbegin.o、crtend.o crtbegin.o、crtend.o(compiler-rt 提供的目标上) 目标
libatomic、libgomp、libquadmath libomp、compiler-rt sanitizer 目标,可选
cc1、cc1plus、collect2、lto-wrapper clang、lld、llvm-ar、llvm-objcopy 宿主
编译器自身用的 libgcc 链接进工具里的 LLVM/Clang 库 宿主

这张表就是加拿大构建为什么不是"一次构建"的原因。宿主侧工具(cc1plus、 clang、ld)必须在宿主上运行。目标侧运行时(libgcc、 libstdc++)必须在目标上运行。系统根的 libc 必须匹配目标。一次 make 不可能在一台机器上同时完成这三件事;构建过程必须始终把三元组分清楚。

6. 验证系统根

在怪编译器之前,先问它认为自己是什么:

$TARGET-gcc -print-sysroot
$TARGET-gcc -print-search-dirs
$TARGET-gcc -v -E -x c /dev/null

clang --target=$TARGET --sysroot=$SYSROOT -print-search-dirs
clang --target=$TARGET --sysroot=$SYSROOT -v -E -x c /dev/null

-v -E 的输出会列出 include 搜索路径。检查前几项是否在系统根里,是否包含你期望的 头文件。然后用 -nostdlib -Wl,--verbose 编译一个最简单的文件,看链接器找到的是哪些启动对象、 哪个 libc。如果 -print-sysroot 是空的,编译器在用默认的 prefix 布局, 那可能仍然是对的;如果它打印出一个在当前机器上不存在的路径,那就是你的 bug。

对独立工具链,同样的命令应该显示没有 libc、没有系统根,而且只有当你传 -nostdlib 并提供自己的启动代码时链接才会成功。如果独立工具链悄悄找到了构建机的 glibc,它产出的二进制会 链接成功,然后无法运行。