Skip to content
← Back to explainers

Rust explainer

What an ABI is, and why FFI exists

About 11 min to read. Allow extra time to work through examples.

An API describes source-level names and types; an ABI describes how compiled code calls functions and lays out data. Why Rust's default ABI is unspecified, and why extern "C" (calling convention) and #[repr(C)] (data layout) answer separate questions.

This article uses Rust. You can make it your practice mode to browse related content across the site.

This article explains two pieces of syntax that are common in Rust code that exchanges functions and structs with C: extern "C" and #[repr(C)]. Both depend on one distinction, API versus ABI. The FFI exercises later in this track apply it. (FFI, the foreign function interface, is the mechanism for calling code written in another language, or being called by it.)

API and ABI

An API (application programming interface) is the source-level contract: a C header or a Rust pub fn signature says that a function is called strlen, takes a pointer to characters and returns a length. A compiler that builds both the caller and the callee from source can check calls against that contract.

An ABI (application binary interface) is the contract between pieces of code that are already compiled, possibly by different compilers. It covers details that the source signature does not spell out:

  • which registers or stack slots hold each argument;
  • where the return value goes: a register, a pair of registers, or memory whose address the caller passes in as a hidden argument (common for large types);
  • how the stack is laid out and aligned, and who cleans it up after the call;
  • which registers the callee must preserve and which it may overwrite;
  • how data types are laid out in memory: field order, size, alignment and padding;
  • the symbol name the linker sees for each function.

The ABI depends on the target, the combination of processor architecture and operating system (for example x86-64 Linux or ARM64 macOS). Two sides can agree on the API and still disagree on the ABI. The compiler cannot see the other side's machine code, so such a mismatch is usually not a compile error. The program has undefined behavior: a callee may read arguments from the wrong registers, return a wrong value, corrupt the stack or crash somewhere else later.

Rust's default ABI is unspecified

An ordinary Rust function uses the "Rust" ABI, which is what you get when you write fn without extern. Rust does not specify this ABI, and the default layout of structs and enums (called repr(Rust)) is mostly unspecified as well. That leaves the compiler free to:

  • reorder struct fields, for example to reduce padding;
  • choose how enums store their variants, including using invalid bit patterns of a field (a niche) to store the discriminant;
  • pass a value in registers in one compiler version and through memory in another;
  • change any of these choices between compiler versions or targets.

Here is a concrete case. The values shown are what rustc 1.98.1 produces on a 64-bit target; only the repr(C) result is guaranteed.

use std::mem::size_of;

#[allow(dead_code)]
struct Mixed { a: u8, b: u32, c: u8 }

#[allow(dead_code)]
#[repr(C)]
struct MixedC { a: u8, b: u32, c: u8 }

fn main() {
    // Free layout: the compiler placed the two u8 fields together.
    println!("{}", size_of::<Mixed>());  // 8 with this compiler
    // C layout: declaration order, with padding after `a` and after `c`.
    println!("{}", size_of::<MixedC>()); // 12
}

A few layouts are guaranteed despite this freedom. For example, the standard library documents that Option<&T>, Option<Box<T>> and Option<extern "C" fn()> have the same size as the type inside the Option, with None represented as a null value.

Because the default ABI and layout may change, they are only safe to rely on when the same compiler builds both sides, which is the normal case for Rust crates in one build. Anything that crosses a language boundary, or links against code built separately, needs a convention both sides document. Most operating systems and languages already document a C ABI for each target, so C's conventions are the usual shared choice.

extern "C" and #[repr(C)] answer different questions

#[repr(C)]                    // data layout: C rules for Point
#[derive(Clone, Copy)]
pub struct Point { pub x: f64, pub y: f64 }

#[unsafe(no_mangle)]          // symbol name: exactly `scale_point`
pub extern "C" fn scale_point(p: Point, k: f64) -> Point { // calling convention: C
    Point { x: p.x * k, y: p.y * k }
}

fn main() {
    let q = scale_point(Point { x: 1.0, y: 2.0 }, 3.0);
    assert_eq!((q.x, q.y), (3.0, 6.0));
}

extern "C" sets the calling convention: which registers and stack slots carry p, k and the result. #[repr(C)] sets the data layout of Point: fields in declaration order, with C's size, alignment and padding rules. Neither attribute implies the other, and neither one changes the symbol name (see the next section).

If Point lacked #[repr(C)], the function would use the C calling convention for a value whose layout C does not know. Rustc warns about this (improper_ctypes_definitions: "extern fn uses type Point, which is not FFI-safe"). It is only a warning, so the code still builds, and it can appear to work until a layout choice changes. Treat that warning as an error in FFI code.

The other direction is harmless but does nothing for FFI by itself: a #[repr(C)] struct passed to an ordinary Rust function has a fixed layout, but the call still uses the unspecified Rust ABI. A fixed layout is still useful inside Rust, for example when reading a binary format or sharing memory through a pointer.

So a function that C calls with a struct argument needs both: the function is extern "C", and every struct it exchanges by value or through a pointer is #[repr(C)] (or another FFI-safe representation).

Two related representations:

  • #[repr(transparent)] applies to a struct with at most one field that is not a zero-sized type with alignment 1, plus any number of such zero-sized fields. The struct has the same layout and ABI as that one field, so a wrapper type such as struct Fd(i32) can cross the boundary wherever an i32 can.
  • #[repr(u8)], #[repr(i32)] and similar on a fieldless enum fix the integer type used for the discriminant. #[repr(C)] on such an enum instead uses the size of the target's C enum. Either way, a C side that produces an integer value with no matching variant must not have it read as the Rust enum: that is undefined behavior. Receive an integer and convert it with a check.

Symbol names

Linkers find functions by symbol name. Rust mangles names: it encodes the crate, the module path, generic arguments and a crate hash into each symbol, so a::parse and b::parse do not collide and so Vec::<u8>::push and Vec::<i32>::push, which are separate machine-code functions, get separate symbols. With rustc 1.98.1 on ARM64 macOS, a function mangled_c in a crate named t produced a symbol of the form _RNvCs…_1t9mangled_c, where the elided part is a hash (tools such as nm show it as __RNvCs…, with the extra leading underscore macOS adds, described below). Older compilers used a different scheme whose symbols begin with _ZN. The exact form is a compiler detail; do not hard-code it.

C compilers do not mangle in this way. A C function strlen gets the symbol strlen, or _strlen on targets such as macOS that add a leading underscore; that prefix is part of the target's ABI.

extern "C" does not turn mangling off. The function mangled_c above was declared as pub extern "C" fn mangled_c(x: i32) -> i32, and it still got a mangled symbol. To export a Rust function under its plain name, add #[unsafe(no_mangle)] as in the scale_point example. The attribute is written with unsafe(...) in the 2024 edition because two symbols with the same unmangled name can clash or replace each other at link time, which the compiler cannot check. Do not put no_mangle on a function that is generic over types or constants. Each instantiation is a separate function, so one plain name cannot identify them all. Rustc warns (no_mangle_generic_items: "functions generic over types or consts must be mangled"). With rustc 1.98.1, using such a function with two different types in one crate then fails to build (for a function named generic_fn, the error is "symbol generic_fn is already defined"), and a generic function that is never used exports nothing.

Calling conventions you may see

ABI string Meaning
"Rust" The default for fn. Unspecified; do not use it across a language boundary.
"C" The target's C calling convention. The usual choice for FFI.
"system" The convention of the target's operating system APIs. Same as "C" except on 32-bit x86 Windows, where it is "stdcall". Use it for Windows API declarations.
"stdcall", "fastcall", "thiscall" 32-bit x86 conventions used by older Windows APIs and by MSVC C++ member functions ("thiscall").
"sysv64", "win64" The x86-64 System V and x86-64 Windows conventions, named explicitly rather than chosen by the target.
"aapcs" The 32-bit ARM procedure call standard.

Conventions tied to one architecture are not portable. On an ARM64 macOS target, for example, rustc 1.98.1 rejects extern "stdcall" and extern "sysv64" with error E0570 ("not a supported ABI for the current target"). Prefer "C", or "system" for Windows APIs, unless a library documents a specific convention.

Panics and the -unwind variants

When a Rust panic runs with the default panic = "unwind" setting, it unwinds: it walks back up the call stack, running destructors, until something catches it or the thread ends. A C++ exception unwinds in a similar way. Whether unwinding may cross an FFI boundary depends on the ABI string.

  • "C", "system" and the other plain conventions do not allow it. Since Rust 1.81, a Rust panic that tries to unwind out of an extern "C" function defined in Rust aborts the process instead. That is defined behavior. The reverse is not: if a foreign function declared with extern "C" unwinds into Rust (for example a C++ function that throws), the behavior is undefined.
  • "C-unwind", "system-unwind" and similar variants allow it. A Rust panic can then propagate into foreign frames, and a foreign exception can propagate into Rust, provided the code on both sides is built to support unwinding through it. Rust can catch a Rust panic with std::panic::catch_unwind; what happens when a foreign exception reaches catch_unwind is not something to rely on.

For a Rust function that C calls, decide what a panic should do. If aborting the process is acceptable, plain extern "C" already guarantees that. If it is not, make the function unable to panic, or call std::panic::catch_unwind inside it and turn a caught panic into an error code. (A build with panic = "abort" aborts on every panic, so there is nothing to catch.)

What this explains in the rest of the track

The common thread is that an FFI declaration is a promise the compiler cannot verify against the other side's machine code.

  • Calling C functions through safe Rust wrappers: a declaration in an unsafe extern block is trusted as written. A wrong parameter type or count is undefined behavior, and the compiler has nothing to compare it with. Types such as c_int are aliases chosen per target to match the C type, which is why they are used instead of a fixed Rust integer type.
  • Export Rust functions with C-compatible names and signatures: exporting needs extern "C" for the call, #[unsafe(no_mangle)] for the name, and FFI-safe types, and must decide how panics are handled.
  • C strings and ownership across FFI: &str and slices are wide pointers (a data pointer plus a length) with no C equivalent, so they cross as a separate pointer and length, or as a null-terminated C string.
  • Nullable pointer optimization, callbacks and qsort: Option<extern "C" fn(...)> is a valid nullable C function pointer because a Rust function pointer can never be null, so None can use the null value. A raw pointer can already be null, so Option<*const T> gets no such guarantee and is larger; use *const T and check for null instead.
  • Opaque handles and bounded C formatting: exposing a Rust value to C through a pointer to a type C never looks inside, and the panic rules above.

step 21 of 28

Macros, FFI and Type-Driven Design

Up next Calling C functions through safe Rust wrappers