第22.1章:三元组

English

1. 一个字符串,四个字段

定义。三元组用四个字段给平台命名: <指令集>-<厂商>-<操作系统>-<C 库>。 先是指令集体系结构,然后是厂商,然后是操作系统(或内核),最后是 C 库。厂商可以省略, 默认是 pc 或 unknown。当操作系统把 C 库当作平台的一部分提供时, C 库字段也可以省略。

这个命名是约定,不是法律,但它是所有工具链共用的约定。config.guess 猜测你 所在机器的三元组,config.sub 把别名规范化,gcc -dumpmachine 打印编译器的默认三元组,clang -print-target-triple 打印它的宿主三元组。 字段错了,构建就会在意想不到的地方失败:编译器找到错误的头文件、链接错误的启动对象, 或者为错误的指令集生成代码。

常见三元组及其含义:

三元组 平台 说明
x86_64-linux-gnu 64 位 x86,Linux,glibc 大多数桌面发行版的默认值
x86_64-linux-musl 64 位 x86,Linux,musl 和 -gnu 是两个平台;静态二进制很常见
x86_64-linux-android30 64 位 x86,Android API level 30,bionic 和桌面 Linux 不二进制兼容
aarch64-linux-gnu 64 位 ARM,Linux,glibc 常见的 ARM64 Linux 服务器目标
arm-linux-gnueabihf 32 位 ARM,Linux,glibc,硬浮点 eabihf 后缀固定浮点 ABI
loongarch64-linux-gnu 64 位龙架构,Linux,glibc 龙芯的体系结构
x86_64-windows-gnu 64 位 x86,Windows,mingw-w64 Windows 上的 GNU 工具链
x86_64-windows-msvc 64 位 x86,Windows,MSVC CRT 和 -gnu 不链接兼容
x86_64-freebsd 64 位 x86,FreeBSD C 库字段省略;操作系统自己提供
x86_64-darwin 64 位 x86,macOS 同样省略 C 库;由 SDK 提供
wasm32-wasip1 32 位 WebAssembly,WASI preview 1 第21.3章使用的目标
wasm32-unknown-unknown 32 位 WebAssembly,没有操作系统 独立:没有 WASI,没有 libc
x86_64-elf 64 位 x86,没有操作系统 独立;所有东西都由你提供
i586-msdosdjgpp 32 位 x86,MS-DOS,DJGPP 提醒你"操作系统"不一定是现代内核

这张表本身就是完整的论证。x86_64-linux-gnu 和 x86_64-linux-musl 跑在同一个内核、同一个 CPU 上,但动态链接到 glibc 的二进制在没有兼容层时不能跑在 musl 系统上, 反过来也一样。x86_64-linux-gnu 和 x86_64-linux-android30 是同一件事的放大版:相同的指令集,底下都是 Linux 内核,不同的 C 库(bionic),不同的 OS ABI, 因此是不同的平台。这就是为什么"它是 Linux"不是一句可移植性声明。

一个常见错误是给这些字段排高低。指令集变了,人们当成"真正的"移植;操作系统或 C 库变了, 人们当成细节。三元组不是这样工作的。这些字段是互相独立的轴,任何一个轴上的差异都会产生 不同的平台。差异字段的数量并不衡量距离。

对比 不同之处 相同之处 同一个平台?
x86_64-linux-gnu vs x86_64-linux-musl libc 指令集、Linux 内核 否
x86_64-linux-gnu vs x86_64-linux-android30 libc、OS ABI 指令集、Linux 内核 否
x86_64-linux-gnu vs aarch64-windows-msvc 指令集、操作系统、libc 对二进制来说没有 否

第一对只在最后一个字段不同,最后一对每个字段都不同。对链接器和加载器来说,结果是一样的: 不同的目标、不同的系统根、不同的启动对象、不同的 C 库和不同的动态加载器。你不能因为 CPU 相同就跳过移植。就这里唯一有意义的意义而言,x86_64-linux-gnu 和 x86_64-linux-musl 之间的距离,和 x86_64-linux-gnu 与 aarch64-windows-msvc 之间的距离一样大:两个二进制互不通用,各自需要自己的 三元组、系统根和构建。

Android 是同一个论点,只是影响范围更大。x86_64-linux-android30 不是桌面发行版 意义上的"换了个 libc 的 Linux";它是一个不同的平台,有 bionic、不同的链接器、Android 应用沙盒 和不同的 API 表面。桌面 x86_64-linux-gnu 二进制不能在那里运行,一边的 libc.so 也不能满足另一边。"CPU 相同"和"内核相同"不是可移植性论证; 它们只是四个字段中的两个。

应用沙盒是 Android 答案的另一半。桌面程序是从文件系统启动的进程,共享 /usr; Android 应用是安装进沙盒的 APK,有自己的 UID、私有数据目录、受限的链接器命名空间、SELinux 和 seccomp 策略,以及权限列表。APK 里的原生代码通常是作为库加载进那个沙盒进程的,不是作为 普通 Unix 程序执行的。即使一个二进制能满足 bionic 的 ABI,它仍然必须被打包,并通过 Android 的执行模型启动。GNU/Linux 二进制不能在 Android 上运行,是因为平台契约和执行模型都不同: libc 是可见的那部分,沙盒是剩下的部分。

最容易混淆的是 C 库字段。gnu 指 glibc,musl 指 musl, android 指 bionic,msvc 指微软 CRT,Windows 上的 gnu 指 mingw-w64。同一个词在不同操作系统上可能意味着不同的 ABI, 这正是必须写完整三元组的原因。

逃生通道:两边都跑同一个 Windows PE

有一个务实的办法可以让同一个二进制在同一体系结构的 GNU/Linux 和 Android 上都运行: 构建一个 Windows PE,用 Wine 运行。这不是理论。Wine 今天就在 Android 上运行,ARM64 的 Windows .exe 可以在 ARM64 手机上跑。Wine 在宿主操作系统之上实现 Windows ABI。 在 GNU/Linux 上,它把 Win32/Win64 调用映射到 POSIX 和 X11/Wayland;在 Android 上,它映射到 bionic 和 Android 的窗口系统。PE 文件是同一个。宿主 libc、动态加载器和 ELF ABI 不再属于 契约的一部分。

在 ARM 上,这意味着 Windows on ARM(ARM64)的 .exe,不是 x86 二进制。两边 CPU 执行相同的 ARM64 指令;Wine 提供 Windows API 和 PE 加载器,Android 构建提供 Android 的窗口 和权限层。同一个 ARM64 PE 可以运行在 ARM64 Linux 桌面和 ARM64 Android 设备上。

同一个 ARM64 PE 并不绑定 Linux 或 Android。它在 Windows on ARM 上原生运行;在其他任何有 Wine 移植的 ARM64 宿主上,它通过 Wine 运行:Apple Silicon macOS、FreeBSD/ARM64,以及其他 ARM64 Unix 系统。宿主只需要提供 Wine 移植和 CPU;Windows ABI 由 Wine 提供。这和 ELF 的 处境正好相反:ELF 绑定一个 libc 和一个加载器,PE 是可移植产物,宿主是适配器。

这就是"Win32 是 Linux 上唯一稳定的 ABI"这个笑话的来源。GNU/Linux 的用户态 ABI 是一个移动 目标:glibc 符号版本、libstdc++/libc++ ABI 变化、发行版打包,以及 ELF 加载器。Win32 已经 冻结了几十年,而 Wine 一直在实现它。如果你想要一个二进制在多年、多个发行版上都能用, Windows PE 往往比针对今天 glibc 构建的 ELF 更可移植。

Windows Phone 时代给出了这个笑话最尖锐的版本。一台 1000 美元的旗舰 Lumia 运行 Windows, 却完全不能运行 Windows 的 .exe,因为围墙花园里没有 Win32 子系统。一台在 Walmart 给老人买的 39.99 美元 Android 手机,可以安装 clang,指向微软自己的 Windows 系统根头文件和库,构建一个 PE,再用 Wine 原生运行。昂贵的手机运行 Windows,却不能运行 Windows 程序;廉价的手机不运行 Windows,却能。这不是硬件限制,而是商业模式:商店才是目的, 任意 Win32 程序正是围墙花园必须阻止的东西。

Wine 不会让 Android 沙盒消失。Wine 运行时仍然运行在一个 Android 应用里,受应用的 UID、权限、 SELinux 策略和 seccomp 过滤器约束。它消除的是 ABI 差距:同一个 PE 文件、同一套 Windows API 调用,在两个宿主上都工作。这足以让"一个二进制同时跑 GNU/Linux 和 Android" 从愿望变成可实践的陈述。

2. 构建、宿主、目标

对 GCC 来说,一次编译器构建由三个三元组描述,它们回答三个不同的问题:

让 GCC 打印版本,它会给出这三个三元组。Target: 是它为谁编译, Host: 是它在哪里运行,Build: 是谁构建了它:

$ gcc -v
...
Target: x86_64-linux-gnu
...

在 GCC 模型里,一个运行平台和目标的平台相同的编译器,对该平台来说就是本地编译器, 即使它是在别处构建的。这一点很容易被忽略:"本地"描述的是宿主与目标的关系, 不是构建与宿主的关系。在 x86_64 Linux 上构建、为 x86_64 Linux 使用的编译器是本地编译器。 在 x86_64 Linux 上构建、为 AArch64 Linux 使用的编译器是交叉编译器;把它复制到 AArch64 机器上,它在那里就成了本地编译器,不需要重新构建。

Clang 不一样,第 3 节会回到这一点:clang 不在构建时固定目标,所以同一个二进制可以既本地运行, 又为另一个平台生成代码。

3. GCC 的构建形态及其层级

GCC 手册把两个经常被塞进同一个词里的问题分开。第一个是 build 与 host 的关系;第二个是 host 与 target 的关系。 第一个问题定义加拿大类别。第二个问题定义产物是本地还是交叉。两个问题互相独立。

加拿大编译就是 build ≠ host。定义只有这一句。加拿大构建, 就是生产编译器的机器不是运行编译器的机器。

加拿大编译是父类别,不是"加拿大本地编译"的同义词。它有两个子类,其中一个还有命名的特例:

加拿大类别之外只有两种形态:

类别 关系 含义 典型场景
本地编译(Native) build = host = target 一切都在同一个平台上发生 在发行版上构建发行版编译器
交叉编译(Cross) build = host ≠ target 编译器运行在构建机上,为另一个平台生成代码 x86_64-linux-gnu → aarch64-linux-gnu
加拿大编译(Canadian) build ≠ host 父类别;构建机不是宿主机器 任何在另一台机器上生产的编译器
  加拿大本地编译 build ≠ host = target 另一台机器为这台机器产生本地编译器 在 x86_64 Linux 上构建 AArch64 Linux GCC
  加拿大交叉编译 build ≠ host,host ≠ target 另一台机器为这台机器产生交叉编译器 x86_64 Linux 构建运行在 AArch64 上、目标为 RISC-V 的编译器
    反向编译 build = target ≠ host 加拿大交叉编译的特例:构建机同时也是目标机 在 Linux 上构建运行在 Windows 上、目标为 Linux 的编译器

表格里的缩进就是层级。反向编译在加拿大交叉编译下面,加拿大交叉编译在加拿大编译下面。 反向编译是加拿大编译,因为 build ≠ host;它是加拿大交叉编译,因为 host ≠ target。它不是第三个并列类别。

加拿大编译这个名字是一个历史笑话:一串跨越国界的机器,第一台机器构建的 编译器运行在第二台机器上,第二台机器再为第三台机器生成代码。本质属性只有一条:构建机不是 宿主机器。产物是本地还是交叉,是第二个独立的问题。

反向编译是那种因为太常见而让人吃惊的情况。构建等于目标,宿主不同: 你在 Linux 上构建一个运行在 Windows 上的编译器,而这个编译器生成 Linux 二进制。 当你想在 Windows 上测试 Linux 目标时,做的正是这件事。GCC 手册把它叫 "crossback" 或 "crossed native backwards"。它是构建和目标重合的那种加拿大交叉编译,在实际使用中是仅次于 本地编译的第二常见配置。

一套编译器发行版可以是上面任何一种。对使用者来说,重要的问题只有一个:host 和 target 是什么?构建三元组是历史,它告诉你这个产物是怎么造出来的, 而不是它能做什么。

Clang 的模型:只有本地和加拿大

上面全部是 GCC 模型,target 由 configure --target 固定。 Clang 不是这样。它是一个带多个后端的二进制,目标在调用时用 --target= 和 --sysroot= 选择。没有配置期的 target,所以不存在 clang 的交叉构建, 按目标划分的加拿大本地/加拿大交叉也不适用。

Clang 只有两个构建类别:本地(build = host) 和加拿大(build ≠ host)。交叉编译根本不是构建类别; 它是本地 clang 加 --target= 和 --sysroot= 的调用方式, 所以是本地编译的特例。

用 clang 交叉编译时,你运行的就是一个本地编译器。它是为当前运行的机器构建的;它之所以为另一个 目标生成代码,只是因为你要求它这么做。反向编译也坍缩了:如果 build ≠ host, 构建就是加拿大编译,目标是你调用时传的任何值。GCC 的交叉编译和反向编译两行,在 clang 里 没有对应物。

这就是为什么 第22.4章只讲构建和宿主。 上面的 GCC 表格对 GCC 是正确的心智模型;对 clang 不是。

4. 为什么 GCC 的加拿大构建需要两个编译器

假设你在 x86_64-linux-gnu 上,想要一个运行在 aarch64-linux-gnu 上、目标也是 aarch64-linux-gnu 的编译器。显然的计划是"为 AArch64 构建 GCC"。 但你不能在构建期间于 x86_64 机器上运行 AArch64 编译器。编译器是一个程序,它自己的构建过程 必须执行中间工具:生成器、表构造器,以及刚构建出来、用来编译目标运行时库的 xgcc 本身。

所以 GCC 的加拿大构建同时使用两个编译器:

在加拿大本地构建里,宿主和目标平台相同,所以一个交叉编译器同时扮演两个角色。 在加拿大交叉构建里,两者不同,你需要两个编译器:build→host 用于编译器 二进制本身,build→target 用于目标运行时。普通加拿大交叉编译里两个都是交叉编译器; 反向编译里构建等于目标,所以第二个是本地编译器。GCC 的构建系统把它们叫 CC_FOR_BUILD、CC 和 CC_FOR_TARGET,把它们弄混, 通常就是加拿大构建产出一个在哪台机器上都不能执行的二进制的原因。

这不是什么罕见角落。一个新的 CPU 体系结构在有人加拿大构建出编译器之前,没有本地编译器。 每个发行版为新体系结构提供的第一个编译器,每个厂商为新板子提供的 SDK,都是这样造出来的。 第一个本地编译器永远是外来构建的产物。

5. 延伸阅读

中文文章 《编译器的构建基本原理》 用图和 GCC、LLVM 的实例展开了同样的材料。那篇文章把 triplet 译作"三连音", 本书统一用"三元组"。文章也是"加拿大构建同时需要本地和交叉两个编译器"以及"LLVM 绕不开先构建 GCC 交叉工具链"这两个实务警告的来源。