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_intis a type alias rather thani32because the C ABI defines it per platform, and hard-coding a Rust type would break on some target you have not tried (18.22). -
&strand 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 andOption<*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.