Systems Programming · Cross Compilation
Floating-Point ABI Variants and the Linker Errors They Cause
Soft, softfp, and hard float on 32-bit ARM decide where arguments travel rather than what arithmetic runs, and only the static linker notices when a build mixes them.
The same C function, compiled for the same Cortex-A9 with the same optimisation level, can produce two objects that return identical results when called from code built the same way and completely wrong results when called from code built differently. Nothing in the arithmetic changed. What changed is where the arguments were placed: in r0–r3 as raw bit patterns, or in s0–s15 as the VFP register file the floating-point unit reads directly. A function that expects the second convention and is handed the first does not trap. It reads whatever happens to be in the VFP registers and returns a number.
That is the failure the -mfloat-abi option controls, and it is the single most consequential ABI choice on 32-bit ARM. It is also the reason the same architecture has two long-lived distribution ports, armel and armhf, with no binary compatibility between them.
Two decisions hidden behind three names
The option takes three values, and they encode two independent decisions: whether arithmetic is executed by hardware instructions or by library routines, and whether arguments are passed in floating-point registers or in general-purpose registers.
soft answers no to both. The compiler calls helper routines in libgcc for every addition and multiplication, and arguments travel in r0–r3 or on the stack as bit patterns. It works on any ARM core, including those with no floating-point unit, and it is slow in a way that is proportional to the number of operations rather than to their complexity.
softfp answers yes to hardware instructions and no to register passing. The compiler emits VFP arithmetic, but arguments still arrive in general-purpose registers and are moved into the FPU with vmov before use. The point of this combination is binary compatibility: code compiled with softfp interoperates with code compiled with soft, because the calling convention is identical, while still using the FPU for the arithmetic.
hard answers yes to both. Arguments arrive in s0–s15, doubles in d0–d7 on a core with the minimum VFPv3 register set, and results come back in s0 or d0. This is the fastest arrangement and the one the armhf port uses.
The hardware/software distinction is invisible to the ABI. The register-passing distinction is the ABI. A build that changes -mfpu or adds -mfloat-abi=softfp to a soft build stays compatible; a build that switches to hard does not.
What the calling convention actually specifies
The base AAPCS defines floating-point arguments as ordinary words passed in r0–r3, with a double occupying two consecutive registers and requiring the pair to start on an even register. The VFP variant replaces that rule for non-variadic functions: single-precision arguments go in the lowest available s registers, doubles in d registers, and a structure containing only floats or doubles is passed in registers where it fits.
Variadic functions are explicitly excluded from the variant. The base standard applies to the variable part of the argument list, so printf("%f", x) passes the double in r2/r3 or on the stack even when everything else in the program is hard-float. This carve-out is the source of a specific and confusing symptom: a program whose own arithmetic is entirely correct can print garbage, because the call to printf crossed from hard-float code into a variadic callee that reads the value from the general-purpose registers.
Return values follow the same split. A float returned from a hard-float function arrives in s0; from a soft-float function it arrives in r0 as a bit pattern. Mixed code that calls through a function pointer has no type information at the call site beyond what the compiler assumed, so the mismatch is resolved at run time by reading the wrong register rather than at link time.
Where the choice is recorded and which tool checks it
The choice reaches the object file in two places. In the ELF header, e_flags carries EF_ARM_ABI_FLOAT_HARD at 0x400 or EF_ARM_ABI_FLOAT_SOFT at 0x200. In the .ARM.attributes section, the Tag_ABI_VFP_args attribute records the convention in a more granular form, distinguishing the base standard from the VFP variant.
arm-linux-gnueabihf-gcc -mfloat-abi=hard -mfpu=vfpv3-d16 -c math.c
arm-linux-gnueabi-gcc -mfloat-abi=softfp -mfpu=vfpv3-d16 -c math.c
readelf -h math.o | grep Flags
readelf -A math.o | grep -i vfp
GNU ld compares those flags when it merges inputs and refuses a link that mixes them. The message names the offending input and the output: an object using VFP register arguments cannot be linked into an output that does not, and the reverse is reported separately. The check is per input file, so a single prebuilt static library built with the other convention stops the link as soon as any symbol from it is pulled in.
The dynamic loader performs no such check. ld.so validates the ELF class, byte order, and machine number, then maps the library and applies relocations. e_flags is not consulted for the float convention, and the Tag_ABI_VFP_args attribute is not present in a stripped shared object at run time in any case. A binary linked successfully can therefore load a shared library built for the wrong convention and call into it without any diagnostic. Every call that passes or returns a floating-point value then reads the wrong register, and the failure appears as wrong numbers in an otherwise functioning program.
Why the split exists at all
The soft-float convention predates widespread hardware floating point on ARM and remains the right answer for cores without an FPU: the ARM7TDMI and the Cortex-M0 have no floating-point unit, and the convention lets one toolchain and one set of libraries serve them.
The hard-float convention exists because the base convention costs a register move on every call even when the FPU is present, and because it forces floating-point arguments through the integer register file, which has four argument registers and is already contended. Moving them to the FPU register file is a straightforward win for code that passes floats often.
The incompatibility is the price of that win. Distributions that adopted hard float rebuilt their entire archive rather than mixing conventions, because a single library built the other way breaks any program that links it. That is why armel and armhf are separate ports with separate package sets rather than two variants of one port, and why a vendor-supplied binary blob compiled for armel forces everything that links it to armel as well, regardless of what the rest of the system uses. The same reasoning applies to any prebuilt library obtained without source.
Architectures where the question disappears
AArch64 has one floating-point convention. Floating-point and vector arguments go in v0–v7, variadic arguments included, and there is no soft-float variant of the standard ABI in ordinary use. A 64-bit ARM binary therefore cannot suffer this class of mismatch, which is one reason cross builds for aarch64-linux-gnu have fewer ABI footguns than their 32-bit counterparts.
RISC-V exposes the same decision through its ABI names rather than through a separate flag. ilp32, ilp32f, and ilp32d select the integer-only, single-precision, and double-precision conventions for 32-bit code; lp64, lp64f, and lp64d do the same for 64-bit code. The names appear in the triple or in -mabi, and the same rule applies: objects built for different ABI names do not link, and shared libraries built for different names load and misbehave.
MIPS has an analogous split between soft-float and hard-float variants of its 32-bit ABI, with the same consequence for binary compatibility. On x86-32 the base System V ABI passes floating-point arguments on the stack regardless of whether the arithmetic uses x87 or SSE, so switching -mfpmath changes code generation without changing the convention — a useful illustration that “uses the FPU” and “passes floats in FPU registers” are separate properties.
Choosing a convention for a new target
Three inputs decide the choice, and only the first is about the compiler.
The core decides whether hardware floating point is available at all. A target without an FPU must use soft; using hard on such a core produces objects that call into an FPU that is not there and fault at the first operation.
The existing software decides what can be linked. If the target’s C library, its kernel’s VDSO, and any vendor binaries were built for one convention, the build must use that convention. Rebuilding the C library to switch conventions means rebuilding the sysroot, which puts the decision back at toolchain bootstrap time rather than in a compiler flag.
The FPU variant decides which registers exist. -mfpu selects the register file: vfpv3-d16 names a unit with 16 double registers, neon and later variants expose 32, and a target whose part has only the first 16 faults on any instruction that names d16–d31. The argument registers d0–d7 exist on every VFP variant, so the convention itself is portable across them, but code generated for the larger file is not. Nothing in the linker’s float-ABI check compares -mfpu; a build compiled with an FPU the part does not have links cleanly and dies on the target.
The triple and the flag must agree. Selecting arm-linux-gnueabi with -mfloat-abi=hard produces objects that follow the VFP convention while the sysroot behind that triple holds a C library built for the base convention. If the library’s e_flags are intact, the link fails on the first conflicting input; if a vendor-supplied library was stripped of them, the link succeeds and the first call into libm crosses the boundary. The triple is a promise about the whole environment, not a label on the compiler, as reading a target triple describes. A hard-float build belongs on a hard-float triple with a hard-float sysroot, and any part of that trio that disagrees is where the wrong numbers will come from.
References
- Procedure Call Standard for the Arm Architecture (AAPCS) — Arm Limited, IHI 0042
- ELF for the Arm Architecture (AAELF32) — Arm Limited, IHI 0044
- GCC documentation: ARM Options — GNU Project