Skip to content
← All articles

What an ABI is, and why FFI exists

API is a promise to a programmer, ABI is a promise to a machine. Why Rust's own ABI is deliberately unstable, and why extern "C" and #[repr(C)] are two independent things you almost always need both of.

Without one distinction, extern "C" and #[repr(C)] look like superstition — two incantations you copy from a Stack Overflow answer and never question. With it, every FFI rule in this track becomes something you can derive rather than memorise.

The distinction is API versus ABI.

API is a promise to a programmer. ABI is a promise to a machine.

An API is what a header file or a pub fn signature tells you: this function is called strlen, it takes a pointer to characters, it returns a length. That is enough for a human, and enough for a compiler that is going to compile both sides itself.

An ABI — Application Binary Interface — is what two already-compiled pieces of machine code need to agree on in order to call each other. It is far more specific, and none of it appears in the source:

  • Which registers hold which arguments, and in what order.
  • Where the return value goes — a register, a pair of registers, or a hidden pointer the caller passes in for large types.
  • How the stack is laid out: who pushes what, alignment, whether there is a red zone.
  • Which registers the callee must preserve and which it may clobber.
  • How a struct is laid out: field order, padding, alignment.
  • How the function’s name appears to the linker — its mangled symbol.

Two compilers can agree perfectly on the API and disagree on every one of those, and the result is not a compile error. It is a call that reads its arguments from the wrong registers and returns garbage, or corrupts the stack, or crashes somewhere unrelated ten milliseconds later.

Rust’s own ABI is deliberately unstable

This is the fact that makes everything else follow.

The default calling convention for a Rust function is extern "Rust", and it is unspecified. The compiler is free to:

  • reorder struct fields to minimise padding (and it does);
  • change how enums are laid out, including niche optimisations (18.26);
  • pass a small struct in registers today and on the stack tomorrow;
  • change any of this between releases, with no notice.

That freedom is bought deliberately. It is why Option<&T> is the same size as &T, why enum layouts are as compact as they are, why struct padding is minimal. Rust gives up cross-version binary compatibility and gets performance and type-system expressiveness in exchange.

The consequence is unavoidable: anything that crosses a language boundary must opt into a stable convention. Not because C is elegant, but because every platform on earth already defines a C ABI, documents it, and never changes it. C is the lingua franca by default, not by merit.

extern "C" and #[repr(C)] are independent

This is the single most useful sentence in the article.

extern "C" fn f(p: Point) -> Point   // stable CALLING CONVENTION
#[repr(C)] struct Point { x: f64 }    // stable DATA LAYOUT

extern "C" fixes how the call happens: registers, stack, return slot, name mangling. #[repr(C)] fixes how the type is laid out in memory: fields in declaration order, C’s padding and alignment rules, no reordering.

Neither implies the other. An extern "C" function taking a plain Rust struct has a stable calling convention and an unspecified argument layout, which is exactly as broken as it sounds. A #[repr(C)] struct passed to a Rust-ABI function has a stable layout that nothing needs.

You almost always want both, and forgetting #[repr(C)] is the commoner mistake because the code compiles and often even works — until the compiler decides to reorder two fields.

Related reprs worth knowing:

  • #[repr(transparent)] — a single-field wrapper that is ABI-identical to the field it wraps. This is what makes a newtype (18.28) free to send across the boundary.
  • #[repr(u8)], #[repr(i32)] on an enum — fixes the discriminant type, which is what makes a Rust enum interchangeable with a C enum (18.9).

Name mangling

Rust encodes module path, generics and a hash into every symbol name, because foo::bar::baz and qux::baz must not collide and because Vec<u8>::push and Vec<i32>::push are different functions. The result looks like _ZN4core3fmt3num3imp52_$LT$impl$u20$core..fmt..Display$u20$for$u20$u8$GT$3fmt17h…E.

C does not mangle. strlen is the symbol strlen (or _strlen on some platforms — the leading-underscore convention is itself part of the ABI).

So exporting a Rust function for C to call needs two things: extern "C" for the convention, and #[unsafe(no_mangle)] to publish the plain name (18.24). And no_mangle on a generic function is meaningless — there is no single symbol to emit, because there is no single function.

The conventions you will see

ABI string what it is
"C" the platform’s C convention. The default choice.
"system" "C" everywhere except 32-bit Windows, where it is "stdcall". Use it for OS APIs.
"stdcall", "fastcall", "thiscall" legacy 32-bit x86 conventions. Windows APIs, COM.
"sysv64" the x86-64 System V convention, named explicitly rather than by platform.
"win64" the x86-64 Windows convention.
"aapcs" the ARM procedure call standard.
"Rust" the default. Unstable. Never cross a boundary with it.

extern "system" versus extern "C" matters on exactly one target family, and if you are calling Win32 you want "system". Everywhere else they are identical, which is why so much code gets away with "C".

The -unwind variants

Each convention has a -unwind twin: extern "C-unwind", extern "system-unwind".

The difference is what happens when a panic (or a C++ exception) tries to propagate out through the boundary:

  • extern "C" — unwinding out of one of these aborts the process. Since Rust 1.81 this is defined behaviour, chosen deliberately: aborting is bad, but it is far better than the undefined behaviour it replaced.
  • extern "C-unwind" — unwinding is permitted to cross. This is what lets a Rust panic propagate through C++ frames that run destructors, and what lets a C++ exception pass through Rust.

The practical rule for exported functions: either be genuinely panic-free, or wrap the body in std::panic::catch_unwind. Item 18.27 has the full hazard list.

Why this makes the rest of the track derivable

Every FFI rule you are about to meet is a corollary of “the ABI is a contract that nothing checks”:

  • Declare a function with the wrong signature and it is instant undefined behaviour with no diagnostic — because the compiler trusts your declaration absolutely; it has no other source of truth (18.22).
  • c_int is a type alias rather than i32 because the C ABI defines it per platform, and hard-coding a Rust type would break on some target you have not tried (18.22).
  • &str and slices cannot cross a boundary because they are fat pointers — two words — and C has no such thing (18.25).
  • Option<fn> is the right nullable callback type and Option<*const T> is not, because the layout optimisation that makes them identical only applies to types with no valid null value (18.26).
  • An exported Rust function must be panic-free, because unwinding across an extern "C" boundary aborts.

None of that has to be memorised. It all falls out of one sentence: the ABI is a promise to a machine, and machines do not check promises.