Compilers · Linkers and Loaders

STT_GNU_IFUNC: Choosing an Implementation at Load Time

How a symbol can name a function that returns the address to call, the constraints the resolver runs under, and why the technique trades startup determinism for dispatch without indirection.

Ordinary dynamic linking resolves a symbol to the address of a definition. An indirect function symbol resolves to the address of a function that returns the address of a definition. The distinction sounds like a small indirection and is in fact a way to make dispatch free at call time: the resolution happens once, before any call, and every caller is patched to jump directly to whichever implementation the resolver chose.

The technique exists because CPU dispatch has an awkward shape. A library wants to ship several implementations of a routine — one using an instruction set extension, one portable — and select at run time based on what the processor supports. The naive solution is a function pointer initialised at startup, which costs an indirect call at every use and prevents inlining. IFUNC moves the decision into the linker’s resolution step, so the call site ends up with a direct call to the chosen implementation.

The symbol type and what the resolver returns

A symbol of type STT_GNU_IFUNC has, in its st_value field, the address of a resolver function rather than the address of the implementation. The resolver takes no arguments and returns a function pointer. When the dynamic linker resolves a reference to the symbol, it calls the resolver and uses the returned address as the symbol’s value.

The static linker treats the symbol the same way at link time. When it finds an IFUNC definition in an object it is linking, it can call the resolver immediately — at link time, on the build machine — and patch the reference with the result. That is legal only if the resolver’s choice depends solely on properties of the build machine and not on the machine that will run the program, which is a strong assumption and the reason cross-compiled builds disable the optimisation.

The call site after resolution is indistinguishable from an ordinary call to a defined function. There is no function pointer, no PLT indirection beyond what any external call has, and no branch on a feature flag. The dispatch cost is paid entirely at resolution, which is the whole point.

The pattern that makes this work in C without assembly is a set of declarations that the compiler and linker understand as aliases of the resolver’s result. A source file defines the portable implementation under a private name, defines an IFUNC-named entry point whose resolver returns the right address, and the rest of the program calls the public name.

/* Three implementations, one public name.  The resolver runs at load time
   (or at static link time) and its return value becomes the symbol's value. */
#include <string.h>

static size_t sum_scalar(const unsigned char *p, size_t n) {
    size_t s = 0;
    for (size_t i = 0; i < n; i++) s += p[i];
    return s;
}

__attribute__((target("avx2")))
static size_t sum_avx2(const unsigned char *p, size_t n) {
    /* A vector implementation would go here; kept scalar for brevity. */
    return sum_scalar(p, n);
}

static void *resolve_sum(void) {
    if (__builtin_cpu_supports("avx2"))
        return (void *)sum_avx2;
    return (void *)sum_scalar;
}

size_t sum(const unsigned char *p, size_t n)
    __attribute__((ifunc("resolve_sum")));

/* Elsewhere in the program: */
size_t total(const unsigned char *buf, size_t len) { return sum(buf, len); }

The ifunc attribute is a GCC extension; the same effect is available in assembly by declaring a symbol of type gnu_indirect_function, and the linker accepts either. The resolver’s return type is void * rather than a typed function pointer because the attribute’s contract is defined in terms of an address, and casting through void * is the portable way to satisfy it.

What the resolver may and may not do

The resolver runs during relocation processing, before constructors and before main. That timing imposes constraints that are not obvious from the source.

It must not depend on any state that has not been initialised yet. Calling malloc is unsafe, because the allocator’s own initialisation may not have run, and the resolver may be invoked before the library that provides it is ready. Reading an environment variable is unsafe for the same reason: the C library’s environment table is built during its startup, which may be after the resolver runs. The resolver should be a pure function of CPU features and, at most, of data that the loader itself has already established.

It must not call a function that is itself resolved through the PLT if that function’s resolution could re-enter the resolver. Re-entrancy during relocation is the failure mode that produces the hardest-to-diagnose crashes: a resolver that calls a helper which calls back into the same symbol’s resolution can recurse or deadlock, depending on the loader’s locking.

It must return an address that is valid for the lifetime of the process. A resolver that returns a pointer into a temporary buffer, or a pointer to a function it generated at run time, is returning something the loader will treat as a code address and call. Returning a non-executable address produces a fault at the first call, not at resolution, which separates the symptom from the cause.

It may be called more than once. Nothing in the model promises a single invocation: the static linker may call it at build time, the dynamic linker may call it at load time, and a resolver that memoises its result in a static variable must ensure the variable is initialised before it is read. The usual pattern is to compute on each call and return a constant, so that a second invocation is harmless.

Where the choice is actually made

There are three distinct moments at which an IFUNC can be resolved, and which one applies depends on how the symbol is referenced.

At static link time, when the definition is in an object being linked into the output and the linker is configured to resolve IFUNCs eagerly, the resolver is executed on the build machine and the call site receives a direct call to the chosen implementation. This is the fastest result and the most fragile, because it bakes in the build machine’s capabilities. Cross-compilation and builds for distribution disable it for that reason.

At load time, for references from other modules or for symbols in shared objects, the dynamic linker calls the resolver while processing relocations. The result is written into the GOT slot, and all callers use it. This is the normal case for a shared library and the one that makes the technique worth using: the choice is made once per process, on the actual hardware, before any code runs.

At first call, if the reference is a PLT entry under lazy binding, the resolution is deferred until the call. The resolver still runs before the function body, so the semantics are unchanged, but the cost moves to the first call. IFUNC and lazy binding compose without special handling because both are expressed as a GOT slot that is filled later.

The interaction with LD_BIND_NOW is therefore neutral for IFUNC correctness: eager binding simply moves all resolutions earlier, which is also earlier than any constructor. A program that requires a constructor to have run before a resolver executes cannot be satisfied by any binding mode.

Failure modes that are specific to the mechanism

The most common failure is a resolver that reads a CPU feature through a mechanism that itself requires resolved symbols. On x86, the compiler’s feature-detection helper reads a variable that a constructor in the compiler’s runtime support library fills in. If the resolver runs before that code, the variable is still zero and every check reports the feature as absent. The program then runs correctly but slowly, and nothing indicates that dispatch failed. A resolver that must be certain should read the CPU identification directly, through an instruction that needs no library state.

The second is the versioning interaction. An IFUNC symbol can carry a version like any other, and the resolver’s result is bound to the version the consumer requested. A library that changes which implementation a version resolves to has changed behaviour for existing consumers without changing the soname, which is a compatibility violation the format does not detect.

The third is that tools do not always understand the symbol type. A binary analyser that lists a function’s address from the symbol table reports the resolver’s address, not the implementation’s, and a profiler that attributes samples by symbol name may attribute them to the public name even though the code executing belongs to a static function with a different name. Debugging a dispatch bug therefore requires knowing which implementation was selected, and the selection is not recorded anywhere in the process image.

The fourth is the ordering of resolvers among themselves. If resolver A returns the address of a function that is itself an IFUNC, the loader must resolve that symbol too, and the two resolutions are ordered by the loader’s traversal of relocation sections rather than by any declared dependency. A chain of IFUNCs where one resolver’s choice depends on another’s is a dependency the format cannot express.

Why the trade is worth it for libraries and not for programs

A library that ships IFUNC implementations pays for the mechanism in startup work: one resolver call per symbol, plus the relocation processing that follows. It gains a call site that is a direct call, which matters because the routine is likely called in loops where an indirect branch would defeat inlining and prediction.

An application that ships its own IFUNC has the same gain and a worse trade, because the application knows its target hardware at build time in most cases. The dispatch exists to serve binaries that must run on a range of machines, which is the distribution problem, not the single-binary problem. Compiling with the appropriate instruction set for a known target removes both the resolver and the need for it.

The mechanism also interacts with the linker’s assumptions about code identity. A symbol whose value is chosen at load time has no single value in the file: the address a caller ends up with is whatever the resolver returned when that caller’s slot was filled. Code that compares function pointers for equality therefore relies on the resolver returning the same address every time it is asked, which is a convention the loader does not check.

The broader pattern is that the ELF format keeps adding ways for a symbol’s value to be decided later: lazy binding defers the lookup, versioning narrows the candidates, and IFUNC defers the choice among implementations. Each addition moves work from the call site to the resolution step, and each one makes the resolution step a more delicate piece of code that must run correctly before anything else is ready. The resolver’s constraints — no allocation, no dependence on uninitialised state, no re-entrancy — are the accumulated cost of that direction, and they are why the technique belongs in libraries that have no alternative and not in code that could simply be compiled for its target. The resolution order that determines which module’s definition wins in the first place is described in Symbol Interposition and the Global Namespace, and the deferred-lookup machinery that IFUNC composes with is in Lazy Binding and the Procedure Linkage Table.

References

  1. System V Application Binary Interface: AMD64 Architecture Processor Supplement — x86-64 psABI working group
  2. elf(5) — Linux manual page — Linux man-pages project
  3. GNU Binutils: Linker Options — GNU Binutils project
  4. How to Write Shared Libraries — Ulrich Drepper