Computer Architecture · Instruction Sets

ISA Families, Extension Levels and Binary Compatibility

How architectures partition optional instructions into named levels, how a binary discovers what the current processor implements, and where forward compatibility quietly breaks.

An instruction set that never changes is dead, and one that changes freely breaks every binary in existence. The compromise that every surviving architecture has converged on is a base that is frozen plus a set of optional extensions that are negotiated at run time. The negotiation is the interesting part, because it happens in several places at once — the operating system, the loader, the compiler’s target selection, and the library that was compiled for a wider feature set than the machine provides — and the failure modes of each are different.

Why the base must be frozen

A binary compiled for the base ISA must execute on every implementation of that ISA, including ones built decades later with execution units the compiler had never heard of. That promise is what makes a distributed binary possible at all: the packager cannot know which processor will run the code, so the code may use only the instructions that every processor is required to implement.

The frozen base is therefore deliberately conservative. It includes the integer operations, the load and store forms, the branches, and the system instructions needed to run a process. It excludes anything that is expensive to implement or that arrived after the base was defined: floating point on some early designs, atomics, vector operations, and every cryptographic instruction.

The cost of freezing is that the base accumulates inefficiencies that cannot be fixed in place. A twelve-bit immediate range, once published, cannot be widened, because widening it would change the meaning of existing encodings. The workaround is always a new instruction that does the job better, and the new instruction lives in the extension space described in Opcode Space Allocation and How Extensions Claim Encodings.

Levels, profiles and the meaning of a target

Between “the base” and “an arbitrary set of extensions” there is a middle layer: named groups of extensions that an implementation either satisfies or does not.

Arm defines architecture versions with letter suffixes, where each version adds a defined set of features to the previous one, and the ABI layer defines a baseline that a platform must provide. RISC-V defines named profiles, which are lists of extensions that a class of system must implement, plus a naming convention in which an ISA string such as rv64gc states exactly which extensions are present. x86 has no architectural level in the encoding but the ABI community defines microarchitecture levels — a base level and successive levels that add SSE, AVX, and later extensions — so that a distributor can build a binary for a stated minimum and know which machines can run it.

The value of a level is that it converts a large combinatorial space of extensions into a small ordered set. A compiler targeting a level emits only instructions the level guarantees, and a distributor publishes one binary per level rather than one per feature combination. The cost is that a level is a coarse instrument: a processor that implements nine of the ten extensions in a level cannot claim the level, and code that would run correctly on it must target the lower level.

The levels themselves do not appear in any instruction encoding, which is why a level is only as reliable as the documentation that maps it onto features. A binary built for a level is checked against the level at build time and never again; a run-time check tests individual features, not levels, so the two mechanisms answer different questions.

The naming convention matters more than it looks. RISC-V’s ISA strings are ordered by convention — base, then extensions in a canonical order — so that two strings can be compared for equality rather than parsed into a set. A string comparison is what a loader or a package manager can do cheaply, and the convention is what makes it valid.

Discovering features at run time

A process that wants to use an optional instruction must ask whether it is available, and the answer comes from a different mechanism on each family.

x86 provides the cpuid instruction, which returns feature bits in registers selected by a leaf number. The bits are organised by category, and a bit’s meaning is fixed once assigned: a bit that reports AVX2 support cannot later be reused for something else. The instruction is unprivileged, so a user process can execute it directly, which is why feature detection on x86 is a handful of instructions with no system call.

The complication is that cpuid reports what the hardware implements, not what the operating system has enabled. A processor may implement a vector extension whose state the kernel has not agreed to save across a context switch, in which case using it would corrupt another thread’s registers. x86 handles this by making the kernel advertise support through a cpuid bit and enable the state in an extended control register, and a conforming program must check both the feature bit and the enable bit. The check is easy to get wrong because the feature bit alone looks sufficient.

Arm and RISC-V expose features through the auxiliary vector, a list of values the kernel places on the stack at process start. On both, the vector carries hardware capability bits, one per single-letter extension: AT_HWCAP reports features the kernel is prepared to support. RISC-V reports its multi-letter extensions separately, through the riscv_hwprobe system call, because they do not fit the bit-per-extension layout. The auxiliary vector’s advantage over cpuid is that the kernel has already filtered the list: a feature appears only if the kernel is prepared to support it, so the program does not need a second check.

#include <sys/auxv.h>
#include <asm/hwcap.h>

/* Kernel-filtered: the bit is set only if the state is managed. */
static int have_sve(void)
{
    unsigned long hwcap = getauxval(AT_HWCAP);
    return (hwcap & HWCAP_SVE) != 0;
}

The function is the whole detection story on a Linux Arm system: one library call, one mask, and the answer is already reconciled with the kernel. The corresponding x86 code reads cpuid and then checks the operating-system enable bit, which is two mechanisms rather than one, and the difference is a direct consequence of cpuid being an unprivileged instruction that predates any notion of kernel-managed state.

Dispatch: one binary, several implementations

Detecting a feature is only useful if the program can act on the answer without a separate binary per feature set.

The mechanism that most C and C++ toolchains provide is a function that the loader resolves at first call. The program declares a dispatcher whose body is chosen from a set of implementations, each compiled for a different target, and the resolver runs the feature check once and patches the call site. The result is one binary that uses the widest implementation the machine supports, with the selection cost paid once rather than per call.

The scheme’s constraints are worth stating because they are not obvious from the syntax. The resolver runs in a context where the loader’s own locks may be held, so it must not call into the dynamic loader, allocate memory through a path that itself needs resolution, or take a lock that another resolver might hold. It must also be idempotent and thread-safe, since two threads may reach the call site simultaneously. A resolver that violates any of these deadlocks or crashes in a way that is hard to attribute to the feature check.

The alternative is explicit branching: check the feature once at startup, store the answer in a global, and branch on it in the hot function. The branch is predictable and costs little, but it exists in every call, and it prevents the compiler from inlining the chosen implementation into its caller. For a function called once per element of a large array, the branch is negligible; for a small function called from many places, the indirect call is a real cost.

Where forward compatibility breaks

The interesting failures are the ones where a binary built for a lower level runs on a higher one and misbehaves, or vice versa.

The first is the illegal-instruction fault. A binary that uses an extension the current processor lacks faults on the first execution of the instruction, and the fault is precise: the operating system can identify the instruction and deliver a signal. Recovery is possible only if the program installed a handler and can emulate or skip the instruction, which is what a binary translator or a JIT does. A statically compiled program has no such handler and dies, which is why distributing a binary above the platform’s baseline is a compatibility decision rather than a performance one.

The second is the reverse: a program that detects a feature and uses it, on a machine where the detection is correct but the feature’s behaviour differs in a detail the specification left implementation-defined. Vector length is the clearest case, and the three length models compared in SIMD Encoding are exactly the ones that produce this hazard. Code that assumes a particular vector width runs correctly on the machine it was tested on and produces wrong results or a fault on a machine with a different width, and the fault is not at the instruction that is wrong but somewhere downstream where the index arithmetic overflowed. Length-agnostic code must query the length rather than assume it, and the query is a system-level read that a compiler will not insert on its own.

The third is the ABI detail that is not in the instruction set at all. Two implementations of the same architectural level may differ in the size of a vector register’s state, in the alignment the stack requires, or in the set of registers a call may clobber. A library compiled against one ABI and loaded into a process built for another can corrupt state without any instruction being illegal. The architectural level guarantees instruction availability and says nothing about the calling convention, which is specified separately and versioned separately.

Compatibility as a layered contract

The layers are worth keeping distinct, because a failure in one is diagnosed differently from a failure in another.

The encoding layer defines which byte sequences are legal. A change here breaks disassemblers, emulators and binary translators, and the only safe changes are to previously reserved encodings.

The architectural layer defines which instructions an implementation must have. A change here breaks binaries that assumed a wider set, and the mitigation is levels and run-time detection.

The ABI layer defines how code calls code: register usage, stack layout, argument passing, and the meaning of a function’s symbol. A change here breaks linking and can break a running process, and the mitigation is symbol versioning and sonames rather than feature detection.

The system layer defines what the kernel will let a process do with the state. A change here breaks programs that used a feature the kernel did not enable, and the mitigation is the kernel’s filtering of the feature list.

A binary is compatible with a machine only if all four layers agree. The instruction set’s design determines how much of that agreement can be checked at run time and how much must be decided when the binary is built — and the trend across every surviving architecture is to move as much as possible into the run-time check, because the alternative is a separate build per machine.

References

  1. System V Application Binary Interface — System V ABI specification
  2. Arm Architecture Reference Manual for A-profile architecture — Arm Limited
  3. The RISC-V Instruction Set Manual, Volume II: Privileged Architecture — RISC-V International
  4. Intel 64 and IA-32 Architectures Software Developer Manuals, Volume 2A: CPUID — Intel Corporation