Systems Programming · Cross Compilation
Reading a Target Triple as an ABI Contract
What each field of a target triple selects, how GCC and Clang disagree about defaults, and why two triples naming the same CPU can produce objects that refuse to link.
Two static libraries are built for arm-linux-gnueabi and arm-linux-gnueabihf. Both contain ARM instructions. Both will run on the same Cortex-A9. Linking them together fails with a complaint that one object uses VFP register arguments while the other does not, and if the linker is forced past that check, the resulting program passes floating-point arguments in the wrong registers and returns garbage. The CPU was never the unit of compatibility. The triple was.
A target triple names a bundle: an instruction set, a vendor convention, an operating-system interface, and a set of calling-convention decisions that were frozen into the ABI at some point in the past. Two toolchains that agree on the CPU but disagree on the bundle produce objects that are individually correct and mutually unusable. Reading the triple correctly means knowing which of its parts are descriptive labels and which are hard constraints.
Four fields, and which one carries the convention
The conventional form is arch-vendor-os-abi, as in aarch64-unknown-linux-gnu, x86_64-w64-mingw32, arm-linux-gnueabihf, riscv64-unknown-elf, or thumbv7em-none-eabihf. Fields may be dropped, and implementations differ in how they fill the gap. The vendor slot is frequently unknown, none, or pc and carries no mechanical meaning in most GNU targets; w64 in x86_64-w64-mingw32 is a historical label for the MinGW-w64 runtime, not a statement about a vendor in any commercial sense.
The architecture field is the only one that maps to an instruction set, and even it is coarse. arm covers everything from ARMv4T to ARMv7-A in the 32-bit A-profile line. The microarchitecture level comes from compiler flags such as -march, -mcpu, -mthumb, and -mfpu, not from the triple. That split is the root of a common category of misbuild: a triple that is right for the board and flags that are wrong for the part.
The OS field does more work than its name suggests. linux-gnu means a Linux kernel interface with the GNU C library and its dynamic linker; linux-musl means the same kernel interface with musl; linux-android means the Android-specific loader and header set; none means there is no operating system at all and the toolchain must not assume one. That last distinction changes which headers exist, which startup files are linked, and whether a main function is required at all.
The ABI field is where the calling convention lives. gnueabihf is not “GNU EABI, hard float” as a description of intent; it is a promise that float and double arguments travel in VFP registers, that the stack is eight-byte aligned at public interfaces, and that the ELF header carries the flag saying so. gnueabi is the soft-float variant of the same promise. gnuabi64 on MIPS selects 64-bit argument passing on a 32-bit-capable architecture; gnux32 on x86-64 selects the ILP32 variant with 64-bit registers and 32-bit pointers. Each of these is a different contract with a different set of compatible libraries.
Canonicalisation matters because a triple is a lookup key. config.sub in Autoconf expands aliases: x86_64-pc-linux-gnu and amd64-linux normalise to the same canonical string, armv7l is treated as an arm variant, and i386 and i686 converge. Two systems that spell the same target differently will still find the same directory only if both run the same canonicalisation. A cross toolchain built with an uncanonicalised name installs into a directory whose name does not match what the build system later computes, and the search for headers quietly falls back to the host.
What the name selects before a single source line is read
The triple is consumed at three points, and each one fails in a different way.
The driver uses it to pick a default set of preprocessor definitions. __ARM_PCS_VFP is defined for arm-linux-gnueabihf and not for arm-linux-gnueabi; _WIN32 and __MINGW32__ appear for the MinGW target; __linux__ and __unix__ appear for both Linux variants. Code that tests these macros is testing the triple indirectly, which is why a header that branches on __ARM_PCS_VFP compiles differently under the two float ABIs without any flag being passed.
The linker uses it to choose an emulation mode and a default linker script. The script sets the load address, the section layout, and the entry symbol. ld invoked through a target-prefixed driver such as aarch64-linux-gnu-ld has its emulation compiled in; invoking plain ld with -m selects it per call. Getting this wrong produces a link that succeeds and a binary whose load addresses are those of a different operating system, which then fails at exec time rather than at build time.
The compiler uses it to find its own private directories: the target include directory, the target library directory, the crt*.o startup objects, and the multilib subtree that matches the current flags. On a toolchain that was configured with --target=aarch64-linux-gnu, the driver reports these paths through -print-search-dirs and -print-sysroot. If -print-sysroot prints an empty line, the toolchain has no notion of a target root, and headers will come from whatever the include search order happens to reach — normally the host’s. The sysroot mechanics that make this path meaningful are covered in what a sysroot actually restricts.
Where the choice becomes visible in the output
An ELF object records the target in three places, and each answers a different question.
The ELF identification bytes give the word size and byte order: EI_CLASS is 1 for 32-bit and 2 for 64-bit, EI_DATA is 1 for little-endian and 2 for big-endian. These must agree with how the loader will interpret every subsequent field; nothing in the file is self-describing beyond this, so a byte-order mismatch is total corruption rather than a per-field error.
The e_machine field names the architecture as a number: 40 for EM_ARM, 62 for EM_X86_64, 183 for EM_AARCH64, 243 for EM_RISCV. A loader refuses a file whose machine number it does not implement, which is why the first symptom of a wrong-architecture build is usually Exec format error at exec time rather than a build failure.
The target-specific flags carry the ABI distinctions the machine number cannot express. On 32-bit ARM, e_flags holds EF_ARM_ABI_FLOAT_HARD at 0x400 and EF_ARM_ABI_FLOAT_SOFT at 0x200; a hard-float object and a soft-float object differ in this word and nowhere else in the header. The .ARM.attributes section repeats the information in a more granular form, including the VFP argument-passing tag, and the linker reads it when deciding whether two inputs are compatible. readelf -h and readelf -A print both, and checking them is the fastest way to confirm what a toolchain actually produced rather than what its name suggests.
The same CPU under two spellings
Several families of triple denote the same instruction set while requiring incompatible libraries. The distinctions are worth naming because they are routinely conflated.
arm-linux-gnueabi against arm-linux-gnueabihf is the float ABI split, described in floating-point ABI variants. mips-linux-gnu against mipsel-linux-gnu is byte order. x86_64-linux-gnu against x86_64-linux-gnux32 is the data model: the same registers, the same instruction encoding, but 32-bit pointers and a different set of system call numbers. arm-linux-gnueabihf against armeb-linux-gnueabihf is again byte order, and the eb suffix sits inside the architecture field rather than in a separate one.
The practical consequence is that a distribution’s library directory is named after the triple. Debian’s multiarch layout places target libraries under /usr/lib/<triple>/, so libc.so.6 for the hard-float ARM target lives in a path that encodes the ABI. A toolchain pointed at a sysroot built for arm-linux-gnueabi will find no libc.so under the gnueabihf path, and the link error will name a missing library rather than a mismatched ABI. That is the benign version of the failure. The malign version is a sysroot that contains both, where the wrong one is selected by search order and every object links cleanly into a binary that crashes on the first variadic call.
GCC bakes the triple in; Clang takes it as an argument
GCC’s target is fixed when the compiler is configured. A cross compiler is a separate binary, normally named with the triple as a prefix, and gcc -dumpmachine prints the triple it was built for. There is no supported way to retarget it at invocation time; -m32 and -m64 within one target family are multilib selections, not target changes. This is why cross toolchains are distributed as directories of prefixed binaries rather than as a flag.
Clang accepts --target=<triple> per invocation and can emit for many architectures from one binary. The flag changes code generation, predefined macros, and the default linker emulation, but it does not conjure a sysroot. Without --sysroot or an explicit --gcc-toolchain, Clang will use the host’s C library headers while generating target code, and the result is a build that compiles and fails at the first header whose contents depend on the target. The two models produce the same objects when configured equivalently, but they fail differently: GCC fails at configure time with a missing cross compiler, Clang fails at compile time with a header that references a symbol the target libc does not have.
What the triple does not say
A triple is silent about three things that decide whether a binary actually runs.
It does not name a C library version. Two systems both called x86_64-linux-gnu can differ in the minimum symbol version their libc.so.6 provides, and an object linked against the newer one will fail to start on the older one with a version error rather than a missing-file error. It does not name a kernel version, so system calls added after the target’s kernel was frozen will link and then return ENOSYS. And it does not name a CPU feature level, so -march=armv8-a+crc compiled under a perfectly correct triple will execute an illegal instruction on a part that lacks the extension.
Build systems that care about any of these therefore carry more than a triple: a sysroot path, a minimum library version, and a flag set. The triple remains the key that selects the toolchain, and treating it as the whole description of the target is the mistake that produces builds which are internally consistent and externally wrong.
References
- Autoconf manual: Specifying Target Triples — GNU Project
- Clang documentation: Cross-compilation using Clang — LLVM project
- System V Application Binary Interface: ELF — AT&T / SCO
- Procedure Call Standard for the Arm Architecture (AAPCS) — Arm Limited, IHI 0042