Ch22.1: Triplets

中文(简体)

1. One String, Four Fields

Definition. A triplet names a platform with four fields: <isa>-<vendor>-<os>-<libc>. The instruction set architecture comes first, then the vendor, then the operating system (or kernel), then the C library. The vendor is optional and defaults to pc or unknown. The C library may also be omitted when the operating system supplies it as part of the platform.

The name is a convention, not a law, but it is the convention every toolchain uses. config.guess guesses it for the machine you are on, config.sub canonicalizes aliases, gcc -dumpmachine prints the compiler's default, and clang -print-target-triple prints its host triple. When the fields are wrong, the build fails in confusing places: the compiler finds the wrong headers, links the wrong startup object, or emits for the wrong instruction set.

Common triplets and what they name:

Triplet Platform Note
x86_64-linux-gnu 64-bit x86, Linux, glibc The default on most desktop distributions
x86_64-linux-musl 64-bit x86, Linux, musl A different platform from -gnu; static binaries are common
x86_64-linux-android30 64-bit x86, Android API level 30, bionic Not binary-compatible with desktop Linux
aarch64-linux-gnu 64-bit ARM, Linux, glibc The usual ARM64 Linux server target
arm-linux-gnueabihf 32-bit ARM, Linux, glibc, hard float The eabihf suffix fixes the floating-point ABI
loongarch64-linux-gnu 64-bit LoongArch, Linux, glibc Loongson's architecture
x86_64-windows-gnu 64-bit x86, Windows, mingw-w64 The GNU toolchain for Windows
x86_64-windows-msvc 64-bit x86, Windows, MSVC CRT Not link-compatible with -gnu
x86_64-freebsd 64-bit x86, FreeBSD The libc field is omitted; the OS owns it
x86_64-darwin 64-bit x86, macOS Also omits libc; the SDK supplies it
wasm32-wasip1 32-bit WebAssembly, WASI preview 1 The target used in Ch21.3
wasm32-unknown-unknown 32-bit WebAssembly, no OS Freestanding: no WASI, no libc
x86_64-elf 64-bit x86, no OS Freestanding; you supply everything
i586-msdosdjgpp 32-bit x86, MS-DOS, DJGPP A reminder that "OS" is not always a modern kernel

The table is the whole argument in one place. x86_64-linux-gnu and x86_64-linux-musl run on the same kernel and the same CPU, but a dynamically linked binary built against glibc does not run on a musl system without a compatibility layer, and vice versa. x86_64-linux-gnu and x86_64-linux-android30 are the same story at a larger scale: same instruction set, same Linux kernel underneath, different C library (bionic) and a different OS ABI, therefore a different platform. This is why "it is Linux" is not a portability statement.

A common mistake is to rank the fields. An ISA change is treated as a "real" port; an OS or libc change is treated as a detail. The triplet does not work that way. The fields are independent axes, and a difference on any axis produces a different platform. The number of fields that differ does not measure the distance.

Pair What differs What is shared Same platform?
x86_64-linux-gnu vs x86_64-linux-musl libc ISA, Linux kernel No
x86_64-linux-gnu vs x86_64-linux-android30 libc, OS ABI ISA, Linux kernel No
x86_64-linux-gnu vs aarch64-windows-msvc ISA, OS, libc Nothing that matters to the binary No

The first pair differs only in the last field, and the last pair differs in every field. For the linker and the loader, the result is the same: a different target, a different sysroot, a different set of start-up objects, a different C library and a different dynamic loader. You do not get to skip the port because the CPU is the same. The distance between x86_64-linux-gnu and x86_64-linux-musl is just as big as the distance between x86_64-linux-gnu and aarch64-windows-msvc in the only sense that matters here: neither binary is the other, and each needs its own triplet, sysroot and build.

Android is the same point with a larger blast radius. x86_64-linux-android30 is not "Linux with a different libc" in the way a desktop distribution is; it is a different platform with bionic, a different linker, the Android app sandbox and a different API surface. A desktop x86_64-linux-gnu binary does not run there, and a libc.so from one does not satisfy the other. "It is the same CPU" and "it is the same kernel" are not portability arguments; they are two of the four fields.

The app sandbox is the other half of the Android answer. A desktop program is a process launched from a filesystem with a shared /usr; an Android app is an APK installed into a sandbox with its own UID, a private data directory, a restricted linker namespace, SELinux and seccomp policy, and a permission list. Native code inside the APK is normally loaded as a library into that sandboxed process, not executed as an ordinary Unix program. Even a binary that could satisfy bionic's ABI would still have to be packaged and launched through the Android execution model. A GNU/Linux binary does not run on Android because both the platform contract and the execution model differ: libc is the visible part, the sandbox is the rest.

The C library field is where most confusion lives. gnu means glibc, musl means musl, android means bionic, msvc means the Microsoft CRT, gnu on Windows means mingw-w64. The same word can mean a different ABI on a different OS, which is exactly why the full triplet is required.

The escape hatch: a Windows PE on both sides

There is a pragmatic way to get one binary that runs on both GNU/Linux and Android on the same architecture: build a Windows PE and run it under Wine. This is not hypothetical: Wine runs on Android today, and ARM64 Windows .exe files run on ARM64 phones. Wine implements the Windows ABI on top of the host OS. On GNU/Linux it maps Win32/Win64 calls to POSIX and the X11/Wayland stack; on Android it maps them to bionic and the Android surface. The PE file is the same. The host libc, the dynamic loader and the ELF ABI stop being part of the contract.

On ARM this means a Windows on ARM (ARM64) .exe, not an x86 binary. The CPU executes the same ARM64 instructions on both sides; Wine supplies the Windows API and the PE loader, and the Android build supplies the Android windowing and permission layer. The same ARM64 PE can run on an ARM64 Linux desktop and an ARM64 Android device.

The same ARM64 PE is not tied to Linux or Android. It runs natively on Windows on ARM, and under Wine on any other ARM64 host with a working Wine port: Apple Silicon macOS, FreeBSD/ARM64, and other ARM64 Unix systems. The host only has to provide a Wine port and the CPU; the Windows ABI comes from Wine. This is the inverse of the ELF situation, where the binary is tied to one libc and one loader. The PE is the portable artifact; the host is the adapter.

This is the origin of the joke that Win32 is the only stable ABI on Linux. The GNU/Linux userland ABI is a moving target: glibc symbol versions, libstdc++/libc++ ABI changes, distribution packaging, and the ELF loader. Win32 has been frozen for decades, and Wine keeps implementing it. If you want one binary to work for years across many distributions, a Windows PE is often more portable than an ELF built against today's glibc.

The Windows Phone era produced the sharpest version of this joke. A $1,000 premium Lumia ran Windows and could not run a Windows .exe at all, because the walled garden had no Win32 subsystem. A $39.99 Android phone bought at Walmart for an elderly relative can install clang, point it at Microsoft's own Windows sysroot headers and libraries, build a PE, and run it natively under Wine. The expensive phone was Windows and could not run Windows programs; the cheap phone is not Windows and can. That was not a hardware limit. It was the business model: the store was the point, and arbitrary Win32 programs were what the walled garden had to prevent.

Wine does not make the Android sandbox disappear. The Wine runtime still runs inside an Android app, with the app's UID, permissions, SELinux policy and seccomp filter. What it removes is the ABI gap: the same PE file and the same Windows API calls work on both hosts. That is enough to make "one binary for GNU/Linux and Android" a practical statement rather than a wish.

2. Build, Host, Target

For GCC, a compiler build is described by three triplets, and they answer three different questions:

GCC prints all three when you ask for its version. The Target: line is what it compiles for, Host: is what it runs on, and Build: is what built it:

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

In the GCC model, a compiler that runs on the same platform it targets is a native compiler for that platform, even if it was built somewhere else. This is the point that is easy to miss: "native" describes the host/target relationship, not the build/host relationship. A compiler built on x86_64 Linux, for x86_64 Linux, is native. A compiler built on x86_64 Linux, for AArch64 Linux, is a cross compiler, and when you copy it to an AArch64 machine it becomes a native compiler there without being rebuilt.

Clang is different, and section 3 returns to it: clang does not fix its target at build time, so the same binary can run natively and emit for another platform at the same time.

3. The GCC Build Shapes and Their Hierarchy

The GCC manual separates two questions that are often collapsed into one word. The first is the relationship between build and host; the second is the relationship between host and target. The first question defines the Canadian category. The second question defines whether the result is native or cross. The two questions are independent.

Canadian means build ≠ host. That is the whole definition. A Canadian build is one where the machine that produces the compiler is not the machine that will run it.

Canadian is a parent category, not a synonym for Canadian native. It has two children, and one of those has a named special case:

Outside the Canadian category there are only two shapes:

Category Relationship What it means Typical case
Native build = host = target Everything happens on one platform Building a distro compiler on the distro
Cross build = host ≠ target The compiler runs on the build machine and emits for another platform x86_64-linux-gnu → aarch64-linux-gnu
Canadian build ≠ host Parent category; the build machine is not the host machine Any compiler produced on another machine
  Canadian native build ≠ host = target Another machine produces a native compiler for this machine Building an AArch64 Linux GCC on x86_64 Linux
  Canadian cross build ≠ host, host ≠ target Another machine produces a cross compiler for this machine x86_64 Linux builds an AArch64-hosted compiler targeting RISC-V
    Crossback build = target ≠ host Special case of Canadian cross: the build machine is also the target Building a Windows-hosted compiler on Linux, targeting Linux

The indentation in the table is the hierarchy. Crossback is under Canadian cross, and Canadian cross is under Canadian. A crossback is Canadian because build ≠ host, and it is a Canadian cross because host ≠ target. It is not a third sibling category.

Canadian itself is a historical joke about a chain of machines that crosses a border: one machine builds a compiler that runs on a second machine, which then produces code for a third. The essential property is only that the build machine is not the host machine. Whether the product is native or cross is a second, independent question.

Crossback is the case that surprises people by being common. Build equals target, host differs: you are on Linux, you build a compiler that runs on Windows, and that compiler produces Linux binaries. This is exactly what you do when you want a Windows-hosted compiler for testing a Linux target. GCC's manual calls it "crossback" or "crossed native backwards". It is the Canadian cross in which build and target coincide, and it is the second most common configuration in practice, after native.

A compiler distribution can be any of these. The important question for a user is only one: what are host and target? The build triple is history; it tells you how the artifact was produced, not what it can do.

The clang model: native and Canadian only

Everything above is the GCC model, where target is fixed by configure --target. Clang does not work that way. It is one binary with several backends, and the target is chosen at invocation with --target= and --sysroot=. There is no configure-time target, so there is no cross build of clang, and the Canadian native/cross split based on target does not apply either.

For clang there are only two build categories: native (build = host) and Canadian (build ≠ host). Cross compilation is not a build category at all; it is a native clang invoked with --target= and --sysroot=, so it is a special case of native.

When you cross-compile with clang, the compiler you are running is a native compiler. It was built for the machine it is running on; it emits for another target only because you asked it to. Crossback also collapses: if build ≠ host the build is Canadian, and the target is whatever you pass at invocation. There is no clang analogue of the GCC cross or crossback rows.

This is why 22.4 talks about build and host only. The GCC table above is the right mental model for GCC; it is the wrong model for clang.

4. Why a GCC Canadian Build Needs Two Compilers

Suppose you are on x86_64-linux-gnu and you want a compiler that runs on aarch64-linux-gnu and targets aarch64-linux-gnu. The obvious plan is "build GCC for AArch64". But you cannot run the AArch64 compiler on the x86_64 machine during the build. The compiler is a program, and its own build has to execute intermediate tools: generators, table builders, and the freshly built xgcc itself when it compiles the target runtime libraries.

So a GCC Canadian build uses two compilers at once:

In a Canadian native build, host and target are the same platform, so one cross compiler plays both roles. In a Canadian cross build, they differ, and you need two compilers: build→host for the compiler binary, build→target for the target runtime. In an ordinary Canadian cross both are cross compilers; in a crossback, build equals target, so the second is the native compiler. The GCC build system names these CC_FOR_BUILD, CC and CC_FOR_TARGET, and getting them mixed up is the usual reason a Canadian build produces a binary that cannot execute on either machine.

This is not an exotic corner. A new CPU architecture has no native compiler until somebody Canadian-builds one. Every distribution's first compiler for a new architecture, and every vendor's SDK for a new board, is produced this way. The first native compiler is always the output of a foreign build.

5. Further Reading

The Chinese article 《编译器的构建基本原理》 develops the same material with diagrams and a walkthrough of the GCC and LLVM cases. It translates triplet as 三连音; this book uses 三元组 for the same term. The article is also the source of the practical warning that a Canadian build needs both a native and a cross compiler, and that the LLVM case cannot avoid building a GCC cross toolchain first.