第22.2章:系统根
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 在哪里"只有两个合法答案,也只有两个:
-
宿主(hosted)。系统根里有目标平台真正的 C 库:
x86_64-linux-gnu配 glibc、aarch64-linux-musl配 musl、aarch64-linux-android30配 bionic、x86_64-w64-mingw32配 mingw-w64 CRT、wasm32-wasip1配 wasi-libc。编译器按宿主目标构建和配置,__STDC_HOSTED__是1。 -
独立(freestanding)。没有 libc,而且本来就不该有:
x86_64-elf、arm-none-eabi、wasm32-unknown-unknown、riscv64-none-elf。程序自己提供启动代码;如果编译器生成了对memcpy之类的调用,程序自己提供它们;I/O 也自己提供。编译器用--without-headers和--disable-hosted-libstdcxx构建,不带宿主 C++ 库,结果是独立工具链。 第20章讲的是这个模型在程序一侧怎么写。
错误在于把两者混在一起。一个写着 -linux-gnu 的三元组是在承诺目标有 glibc 和
Linux ABI。如果你为这个三元组构建编译器却没有系统根,构建可能成功,而每一次链接都会失败。
反过来,写着 -elf 或 -none- 的三元组按定义没有 libc;给它加一个
Linux 系统根不会让它变成 Linux,只会把不匹配藏起来,直到某处调用了目标根本没有的系统调用。
有些嵌入式目标使用 newlib,它是一个带移植层的小型 C 库。从 C 的意义上说它是 宿主目标,但它不是 POSIX,也不是 glibc。它仍然属于系统根,编译器仍然只提供自己的运行时。 "独立"意味着完全没有 libc,而不是"一个小 libc"。
4. 系统根从哪里来
实际来源有三种,它们失败的方式各不相同:
- 从源码构建 libc。Linux 目标的常规路径:安装内核头文件,用第一阶段的交叉 编译器构建 glibc 或 musl,安装到系统根,然后针对它重新构建 GCC。这样系统根就是你选定的 ABI,代价是构建时间长。
- 从 SDK 或操作系统镜像里提取。Android NDK 自带包含 bionic 头文件和 stub 库的 系统根。Windows SDK 自带 MSVC CRT 和导入库。macOS SDK 自带 libSystem 和框架。这更快, 但你被绑在厂商的布局和许可证上。
-
复用另一套工具链的系统根。如果已经有一套 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,它产出的二进制会
链接成功,然后无法运行。