FFI Basics: extern "C", #[no_mangle], #[repr(C)], and the C ABI
22 min read
The Foreign Function Interface (FFI) is how Rust talks to code written in other languages — and how other languages call into Rust. The lingua franca of that conversation is the C ABI: a stable, language-neutral calling convention that almost every toolchain on the planet understands. This page covers the four building blocks for exporting Rust to that world — extern "C", #[no_mangle], #[repr(C)], and how the result actually gets linked.
Quick Overview
Section titled “Quick Overview”For a TypeScript/JavaScript developer, the closest thing you already know is a Node.js native addon — a compiled .node file (originally C++) that require() loads and calls like any other module. Under the hood, that addon and Node agree on a binary contract: how arguments are passed, how the function is named in the compiled object file, and how structs are laid out in memory. That contract is the Application Binary Interface (ABI), and the most widely-supported one is the C ABI.
Rust does not use the C ABI by default. Its own ABI is unstable and unspecified (the compiler is free to reorder struct fields, change calling conventions between releases, and mangle function names). To make a Rust function callable from C — or from Python’s ctypes, Node’s FFI bindings, Go’s cgo, a game engine, or the OS dynamic linker — you opt in explicitly with three annotations:
extern "C"— use C’s calling convention (how args/return values move through registers and the stack).#[unsafe(no_mangle)]— keep the function’s symbol name exactly as written, so the linker can find it.#[repr(C)]— give a struct or enum C’s memory layout, so both sides agree on where each field lives.
Note: This page is about FFI fundamentals and the export direction (Rust as the library). Calling C from Rust —
build.rs, thecccrate,CString/CStr— is covered in calling-c.md. Auto-generating bindings from a C header is in bindgen.md. The raw-pointer mechanics these examples rely on are in raw-pointers.md, and the meaning of theunsafekeyword itself is in unsafe-intro.md.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”JavaScript has no native FFI of its own. When a Node.js project needs to reach into compiled native code — say, to call into a fast image codec or a system library — it loads a native addon built against Node’s N-API. The JavaScript you write looks innocent:
// app.ts — using a precompiled native addon// The .node file is a compiled dynamic library that exports C-compatible symbols.import { createRequire } from "node:module";const require = createRequire(import.meta.url);
// Node's loader (process.dlopen) opens the shared library and wires up its exports.const native = require("./build/Release/imageproc.node") as { grayscale(buffer: Buffer): Buffer;};
const input: Buffer = Buffer.from([135, 206, 235, 255]); // one RGBA pixelconst output = native.grayscale(input);console.log(output); // <Buffer bc bc bc ff>A few things are quietly true here that you may never have had to think about:
require("...node")ultimately callsprocess.dlopen, the same OS facility C programs use to load a.so/.dylib/.dll. Node v22 has no built-in FFI module — there is norequire("node:ffi"); the boundary always goes through a compiled addon.- The addon and Node agree on a binary layout for every value crossing the boundary. The JS
numberyou pass becomes a Cdouble; aBufferbecomes a pointer plus a length. - If the addon were compiled with a function named differently than what Node expects, the load would fail with an “undefined symbol” error — a linker problem, not a JavaScript one.
That binary contract is exactly what Rust’s FFI annotations let you produce, without writing any C++.
Rust Equivalent
Section titled “Rust Equivalent”Here is a small but realistic Rust library that exports functions over the C ABI: a color utility that any C-speaking caller — including a Node addon, a Python script, or a C program — can link against.
use std::os::raw::c_double;
// `#[repr(C)]` gives this struct the SAME memory layout a C compiler would use:// fields in declaration order, with C's alignment and padding rules. Without it,// Rust may reorder the fields, so C code reading this struct would see garbage.#[repr(C)]pub struct Color { pub r: u8, pub g: u8, pub b: u8, pub a: u8,}
// `extern "C"` = use the C calling convention.// `#[unsafe(no_mangle)]` = export the symbol under the literal name `color_luminance`,// so the linker (and the foreign caller) can find it.#[unsafe(no_mangle)]pub extern "C" fn color_luminance(c: Color) -> c_double { // Rec. 601 luminance weights. 0.299 * c.r as f64 + 0.587 * c.g as f64 + 0.114 * c.b as f64}
#[unsafe(no_mangle)]pub extern "C" fn color_opaque(c: Color) -> bool { c.a == 255}To produce a shared library a C program can link against, declare the crate’s output type in Cargo.toml:
[package]name = "colorlib"version = "0.1.0"edition = "2024"
[lib]# cdylib -> a C-compatible dynamic library (.so / .dylib / .dll) for runtime loading# staticlib -> a C-compatible static archive (.a / .lib) for compile-time linking# rlib -> the normal Rust library, so other Rust crates can still use this onecrate-type = ["cdylib", "staticlib", "rlib"]Note: The current stable toolchain is Rust 1.96.0 on the 2024 edition;
cargo newselects it automatically. Two edition-2024 details matter for FFI:#[no_mangle]must now be written#[unsafe(no_mangle)], andexternblocks must be writtenunsafe extern. Both are explained below.
Building it produces a real, loadable library, and the C-ABI symbols are visible in it:
$ cargo build --release Compiling colorlib v0.1.0 (/tmp/colorlib) Finished `release` profile [optimized] target(s) in 0.62s
$ nm -gU target/release/libcolorlib.dylib | grep color_0000000000000358 T _color_luminance00000000000003c4 T _color_opaqueTip:
nmlists the symbols in a compiled object. TheTmeans “defined in the text (code) section.” On macOS, C symbols carry a leading underscore (_color_luminance); on Linux there is no underscore. The point is that the names survive exactly as you wrote them — that is what#[unsafe(no_mangle)]buys you.
And it really works across the language boundary. Here is a C program calling straight into the Rust library:
#include <stdio.h>#include <stdint.h>#include <stdbool.h>
/* Mirror the #[repr(C)] struct on the C side, field-for-field. */typedef struct { uint8_t r, g, b, a; } Color;
/* Declare the Rust functions with their C ABI signatures. */extern double color_luminance(Color c);extern bool color_opaque(Color c);
int main(void) { Color sky = { 135, 206, 235, 255 }; printf("luminance = %.3f\n", color_luminance(sky)); printf("opaque = %s\n", color_opaque(sky) ? "true" : "false"); return 0;}$ cc main.c -L target/release -lcolorlib -o demo$ DYLD_LIBRARY_PATH=target/release ./demoluminance = 188.077opaque = trueThe C compiler had no idea Rust was involved. It saw two functions with a C calling convention, a struct with a C layout, and stable symbol names — that is the whole trick.
Detailed Explanation
Section titled “Detailed Explanation”Three orthogonal things must agree across an FFI boundary, and each annotation fixes exactly one of them.
1. The calling convention — extern "C"
Section titled “1. The calling convention — extern "C"”When you call a function, the compiler decides how the arguments travel: which CPU registers, what order, how the return value comes back, who cleans up the stack. That set of rules is the calling convention, and it is part of the ABI.
Rust’s default extern "Rust" convention is deliberately unstable — the compiler may change it between releases to enable optimizations. C’s convention has been stable for decades and is what every other language’s FFI assumes. Writing extern "C" on a function (or function pointer) tells Rust: “use C’s rules here.”
// Default Rust ABI: not callable from C, may change between compiler versions.fn rust_add(a: i32, b: i32) -> i32 { a + b }
// C ABI: stable, language-neutral.extern "C" fn c_add(a: i32, b: i32) -> i32 { a + b }The string is the ABI name. "C" is the portable choice. (Others exist — "system", "stdcall" on 32-bit Windows, and "C-unwind" for boundaries that intentionally propagate panics/exceptions — but "C" is the one you reach for first.)
Warning: A plain
extern "C"boundary must never let a Rust panic unwind across it. Unwinding into C is undefined behavior. Catch panics at the boundary (shown in the Real-World Example) or use the"C-unwind"ABI when you specifically want unwinding to cross.
2. The symbol name — #[unsafe(no_mangle)]
Section titled “2. The symbol name — #[unsafe(no_mangle)]”Rust mangles symbol names by default: it encodes the module path, generics, and a hash into the compiled name so that two functions named parse in different modules don’t collide. Great for Rust; useless for a foreign linker that is looking for a function literally called color_luminance.
#[no_mangle] (written #[unsafe(no_mangle)] in edition 2024) disables mangling for that item, so the symbol name in the object file equals the source name. The contrast is stark — a mangled internal function isn’t even exported from a cdylib, while the no_mangle one is:
#[unsafe(no_mangle)]pub extern "C" fn unmangled_add(a: i32, b: i32) -> i32 { a + b}
// No #[no_mangle]: the symbol is mangled (and not a C-ABI export).pub fn rust_internal_add(a: i32, b: i32) -> i32 { a + b}$ nm -gU target/release/libcolorlib.dylib | grep -i add0000000000000358 T _unmangled_addOnly _unmangled_add appears. The mangled rust_internal_add is invisible to a C linker.
Note: Why is it spelled
unsafe(no_mangle)? Because forcing a specific symbol name can cause name collisions at link time — if twono_manglesymbols share a name, you get undefined behavior, not a friendly error. Edition 2024 makes you acknowledge that risk with theunsafe(...)wrapper. This is purely an attribute; it does not introduce anunsafe {}block. See unsafe-intro.md for whatunsafedoes and does not mean.
3. The data layout — #[repr(C)]
Section titled “3. The data layout — #[repr(C)]”This is the one that bites people. Rust’s default struct layout (repr(Rust)) is unspecified: the compiler reorders fields to minimize padding and may change the layout at any time. C’s layout is fixed: fields in declaration order, padded to each field’s alignment. If you pass a default-layout struct to C, the two sides disagree about where each field lives.
The difference is observable. These two structs have identical fields:
struct DefaultLayout { flag: bool, // 1 byte id: u64, // 8 bytes code: u16, // 2 bytes}
#[repr(C)]struct CLayout { flag: bool, id: u64, code: u16,}
fn main() { use std::mem::{size_of, align_of, offset_of}; println!("DefaultLayout: size={}, align={}", size_of::<DefaultLayout>(), align_of::<DefaultLayout>()); println!("CLayout: size={}, align={}", size_of::<CLayout>(), align_of::<CLayout>()); println!("Default offsets: flag={}, id={}, code={}", offset_of!(DefaultLayout, flag), offset_of!(DefaultLayout, id), offset_of!(DefaultLayout, code)); println!("C offsets: flag={}, id={}, code={}", offset_of!(CLayout, flag), offset_of!(CLayout, id), offset_of!(CLayout, code));}DefaultLayout: size=16, align=8CLayout: size=24, align=8Default offsets: flag=10, id=0, code=8C offsets: flag=0, id=8, code=16Read those offsets carefully. In the default layout, Rust put id first (offset 0) and flag last (offset 10) to pack the struct into 16 bytes. A C program expecting flag at offset 0 would read the wrong bytes. With #[repr(C)], flag is at offset 0 and the layout is the (larger, 24-byte) one C produces — exactly what the C side expects. The C-compatible layout is sometimes less efficient; that is the price of a stable, shared layout.
#[repr(C)] applies to enums too. A field-less enum becomes a plain C integer, and you can pin the size with #[repr(i32)], #[repr(u8)], etc.:
#[repr(C)]pub enum Status { Ok = 0, InvalidInput = 1, Overflow = 2,}There is also #[repr(transparent)] for single-field newtypes: it guarantees the wrapper has the exact same ABI as its one field, which is how you build type-safe handles that cross FFI for free:
#[repr(transparent)]struct Handle(u64); // ABI-identical to a bare u64, but a distinct Rust typeLinking: how the pieces actually join
Section titled “Linking: how the pieces actually join”crate-type in Cargo.toml decides what artifact Cargo emits:
crate-type | Produces (macOS / Linux / Windows) | When to use it |
|---|---|---|
cdylib | .dylib / .so / .dll | Runtime loading: Node addons, Python ctypes, plugins |
staticlib | .a / .a / .lib | Compile-time linking into a larger C/C++ binary |
rlib | .rlib | Normal Rust-to-Rust dependency (the default) |
A cdylib is loaded at runtime by the dynamic linker (dlopen / LoadLibrary — the same process.dlopen Node uses for addons). A staticlib is baked into another program at build time. You can request several at once, which is why the example above lists ["cdylib", "staticlib", "rlib"].
Key Differences
Section titled “Key Differences”| Concept | TypeScript / JavaScript | Rust |
|---|---|---|
| Built-in FFI | None; goes through a compiled N-API addon | First-class: extern "C", #[repr(C)], no separate runtime needed |
| Default ABI | N/A (engine-internal) | extern "Rust" — unstable, must opt into "C" for FFI |
| Symbol names | Managed by the addon’s C++ build | Mangled by default; #[unsafe(no_mangle)] to keep the literal name |
| Struct memory layout | Engine-defined object shapes (hidden classes) | repr(Rust) reorders fields; #[repr(C)] for a fixed C layout |
| Numbers crossing over | number (always f64) marshaled by the addon | Pick the exact C type: c_int, c_double, u8, etc. |
| Memory ownership | GC owns everything; addon must be careful | Explicit: you decide who frees what across the boundary |
| Error signaling | Throw / reject | Return codes / out-params; panics must not cross a "C" boundary |
The deepest difference is ownership and safety. In Node, the garbage collector and the addon coordinate to keep memory alive. In Rust FFI, you are the contract: the borrow checker stops at the boundary, so passing a pointer to C means manually guaranteeing it stays valid and is freed exactly once. The C ABI is fast precisely because it does none of this for you. The discipline of wrapping that rawness in a safe API is covered in safety-abstractions.md.
Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: Forgetting unsafe(...) on the attribute (edition 2024)
Section titled “Pitfall 1: Forgetting unsafe(...) on the attribute (edition 2024)”If you copy a pre-2024 example, you’ll write the bare attribute and hit a hard error:
#[no_mangle] // does not compile under edition 2024pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b}The real compiler output:
error: unsafe attribute used without unsafe --> src/main.rs:1:3 |1 | #[no_mangle] | ^^^^^^^^^ usage of unsafe attribute |help: wrap the attribute in `unsafe(...)` |1 | #[unsafe(no_mangle)] | +++++++ +The fix is exactly what the compiler suggests: #[unsafe(no_mangle)].
Pitfall 2: Forgetting unsafe on an extern block (edition 2024)
Section titled “Pitfall 2: Forgetting unsafe on an extern block (edition 2024)”The same edition-2024 change applies when you declare foreign functions:
extern "C" { // does not compile under edition 2024 fn abs(input: i32) -> i32;}fn main() { let n = unsafe { abs(-42) }; println!("{n}");}error: extern blocks must be unsafe --> src/main.rs:1:1 |1 | / extern "C" {2 | | fn abs(input: i32) -> i32;3 | | } | |_^The fix is unsafe extern "C" { ... }. (The consuming side — declaring and calling C functions — is the subject of calling-c.md; the syntax is shown here only so you recognize the error.)
Pitfall 3: Passing a default-layout struct to C
Section titled “Pitfall 3: Passing a default-layout struct to C”Omitting #[repr(C)] compiles fine — Rust has no idea the struct will leave the language — but the C side reads scrambled fields, as the offset experiment above showed. There is no compiler error; you get silently wrong data or a crash. Any type that crosses an FFI boundary needs an explicit repr.
Pitfall 4: Letting a panic unwind into C
Section titled “Pitfall 4: Letting a panic unwind into C”A panic! (or an arithmetic overflow in debug, an unwrap on None, an out-of-bounds index) inside an extern "C" function that unwinds across the boundary is undefined behavior. It will not look like a JavaScript exception bubbling up — it can corrupt the C caller’s stack. Catch it with std::panic::catch_unwind and return an error code instead (next section).
Pitfall 5: Expecting the borrow checker to follow your pointer into C
Section titled “Pitfall 5: Expecting the borrow checker to follow your pointer into C”Once you hand C a *const T or *mut T, Rust’s lifetime analysis is over. Nothing stops C from holding the pointer after the Rust value is dropped (a use-after-free) or freeing it twice. The C ABI carries no ownership information. Designing the ownership contract is on you — see raw-pointers.md and safety-abstractions.md.
Best Practices
Section titled “Best Practices”- Annotate every exported item with all three:
#[unsafe(no_mangle)] pub extern "C" fnfor functions,#[repr(C)]for any struct/enum that crosses over. Missing one is a silent layout or linkage bug. - Use the
c_*type aliases fromstd::os::raw(or the re-exports instd::ffi) —c_int,c_uint,c_double,c_char— instead of guessing thatintisi32. They track the target platform’s real C types. - Catch panics at the boundary. Wrap the body of each
extern "C"function instd::panic::catch_unwindand translate failures into return codes, or compile withpanic = "abort"so a panic terminates cleanly instead of unwinding. - Prefer return codes and out-parameters over rich return types. The C ABI can’t carry a Rust
ResultorOption; encode success/failure as a#[repr(C)]enum or an integer and write results through*mut Tout-params. - Keep the FFI surface tiny and
unsafe-free for callers. Expose a minimal set of C-ABI functions, and build the ergonomic, safe Rust API in a separate layer (the unsafe-inside / safe-outside pattern in safety-abstractions.md). - Name exported symbols defensively. Because
no_manglesymbols can collide globally, prefix them (colorlib_luminance, notluminance) the way C libraries do. - Verify your exports. A quick
nm -gU <lib>(Unix) ordumpbin /exports(Windows) confirms the symbols you expect are present and unmangled before you ship.
Real-World Example
Section titled “Real-World Example”A production C-ABI library wraps every entry point so that errors come back as status codes, results travel through out-parameters, and no panic can ever escape into the caller. Here is a small numeric utility that does all three:
use std::os::raw::c_int;use std::panic;
// A C-compatible status enum, laid out as a plain integer.#[repr(C)]pub enum Status { Ok = 0, Overflow = 1, Panicked = 2,}
// Add two integers with overflow checking. The result is written through `out`;// the return value reports success or failure. This is the classic C pattern// (return code + out-param) because the C ABI can't carry a Rust `Result`.#[unsafe(no_mangle)]pub extern "C" fn checked_add(a: c_int, b: c_int, out: *mut c_int) -> Status { // catch_unwind stops any panic from unwinding into the C caller (which is UB). let result = panic::catch_unwind(|| { a.checked_add(b).ok_or(Status::Overflow) });
match result { Ok(Ok(sum)) => { // SAFETY: the caller contract is that `out` is a valid, writable pointer. unsafe { *out = sum; } Status::Ok } Ok(Err(status)) => status, // arithmetic overflow Err(_) => Status::Panicked, // a panic was caught at the boundary }}[package]name = "mathlib"version = "0.1.0"edition = "2024"
[lib]crate-type = ["cdylib", "rlib"]The C consumer mirrors the enum and passes the address of a local for the result:
#include <stdio.h>
typedef enum { OK = 0, OVERFLOW = 1, PANICKED = 2 } Status;extern Status checked_add(int a, int b, int *out);
int main(void) { int out = 0; Status s = checked_add(2000000000, 2000000000, &out); /* overflows int */ printf("status=%d out=%d\n", s, out);
s = checked_add(20, 22, &out); printf("status=%d out=%d\n", s, out); return 0;}Building and running shows the contract honored in both directions:
$ cargo build --release Finished `release` profile [optimized] target(s) in 0.18s$ cc main.c -L target/release -lmathlib -o demo$ DYLD_LIBRARY_PATH=target/release ./demostatus=1 out=0status=0 out=42The first call adds 2_000_000_000 + 2_000_000_000, which overflows a 32-bit int, so checked_add returns Status::Overflow (status 1) and leaves out untouched. The second call succeeds with status 0 and out = 42. Neither path can crash the C caller: overflow and panics alike become ordinary status codes the caller can branch on, which is the entire reason for the catch_unwind wrapper and the return-code design.
Note: This same return-code-plus-out-param shape is what binding generators and higher-level wrappers expect. When you later wrap a C library from Rust (bindgen.md) or expose Rust to Node (napi.md), you’ll see the framework hiding exactly this dance behind ergonomic types.
Further Reading
Section titled “Further Reading”- The Rustonomicon — FFI — the canonical guide to Rust’s foreign function interface.
- Rust Reference — Type layout &
repr— exactly whatrepr(Rust),repr(C), andrepr(transparent)guarantee. - Rust Reference — External blocks & ABIs — the list of supported ABI strings, including
"C-unwind". std::os::rawandstd::ffi— the C type aliases and string types.- Sibling pages: unsafe-intro.md · unsafe-rust.md · raw-pointers.md · calling-c.md · bindgen.md · napi.md · neon.md · safety-abstractions.md · when-to-use.md
- Foundations: 01 — Getting Started · 02 — Basics (the numeric types you’ll map to C types).
- Next steps: when FFI is for speed, weigh it against pure-Rust optimization in 21 — Performance.
Exercises
Section titled “Exercises”Exercise 1 — Export your first C-ABI function
Section titled “Exercise 1 — Export your first C-ABI function”Difficulty: Beginner
Objective: Get the three annotations under your fingers and confirm the symbol is exported.
Instructions: Create a library crate. Write an extern "C" function square(n: i32) -> i32 that returns n * n, exported with an unmangled symbol. Build it as a cdylib and use nm (Unix) or dumpbin /exports (Windows) to confirm square appears in the output.
Solution
#[unsafe(no_mangle)]pub extern "C" fn square(n: i32) -> i32 { n * n}[lib]crate-type = ["cdylib", "rlib"]$ cargo build --release Finished `release` profile [optimized] target(s) in 0.14s$ nm -gU target/release/libsquarelib.dylib | grep square0000000000000358 T _squareThe T _square line confirms the function is exported under its literal name. Drop the #[unsafe(no_mangle)] and rebuild: square disappears from the nm output, replaced by a mangled symbol.
Exercise 2 — A #[repr(C)] struct across the boundary
Section titled “Exercise 2 — A #[repr(C)] struct across the boundary”Difficulty: Intermediate
Objective: Pass a struct by value to a C-ABI function and prove the layout agrees by calling it from C.
Instructions: Define a #[repr(C)] struct Point { x: f64, y: f64 } and an extern "C" function point_distance(a: Point, b: Point) -> f64 returning the Euclidean distance. Write a short C program that declares a matching struct, passes {0,0} and {3,4}, and prints the result (expect 5.0).
Solution
use std::os::raw::c_double;
#[repr(C)]pub struct Point { pub x: c_double, pub y: c_double,}
#[unsafe(no_mangle)]pub extern "C" fn point_distance(a: Point, b: Point) -> c_double { let dx = a.x - b.x; let dy = a.y - b.y; (dx * dx + dy * dy).sqrt()}#include <stdio.h>typedef struct { double x, y; } Point;extern double point_distance(Point a, Point b);int main(void) { Point a = {0.0, 0.0}, b = {3.0, 4.0}; printf("distance = %.1f\n", point_distance(a, b)); return 0;}$ cargo build --release && cc main.c -L target/release -lgeolib -o demo$ DYLD_LIBRARY_PATH=target/release ./demodistance = 5.0Try removing #[repr(C)]: for a two-f64 struct the layout happens to coincide, so it may still work — proving nothing. The danger appears with mixed-size fields (as in the DefaultLayout/CLayout experiment), which is exactly why you annotate every boundary struct rather than relying on luck.
Exercise 3 — A safe-from-panic counter with a status enum
Section titled “Exercise 3 — A safe-from-panic counter with a status enum”Difficulty: Advanced
Objective: Combine #[repr(C)] enums, raw-pointer out-params, and panic safety into one production-shaped function.
Instructions: Write extern "C" fn count_words(s: *const u8, len: usize, out: *mut c_int) -> ParseStatus. It receives a UTF-8 byte buffer (pointer + length, the way C passes strings), counts whitespace-separated words, writes the count through out, and returns a #[repr(C)] ParseStatus enum: Ok, Empty (only whitespace), or NotANumber (null pointer or invalid UTF-8). Call it from C with "the quick brown fox" (expect 4 words) and " " (expect the empty status).
Solution
use std::os::raw::c_int;
#[repr(C)]pub enum ParseStatus { Ok = 0, Empty = 1, NotANumber = 2,}
#[unsafe(no_mangle)]pub extern "C" fn count_words(s: *const u8, len: usize, out: *mut c_int) -> ParseStatus { if s.is_null() || out.is_null() { return ParseStatus::NotANumber; } // SAFETY: the caller guarantees `s` points to `len` valid, initialized bytes. let bytes = unsafe { std::slice::from_raw_parts(s, len) }; let text = match std::str::from_utf8(bytes) { Ok(t) => t, Err(_) => return ParseStatus::NotANumber, }; if text.trim().is_empty() { return ParseStatus::Empty; } let count = text.split_whitespace().count() as c_int; // SAFETY: `out` was null-checked above and is the caller's writable slot. unsafe { *out = count; } ParseStatus::Ok}#include <stdio.h>#include <string.h>typedef enum { OK = 0, EMPTY = 1, NOT_A_NUMBER = 2 } ParseStatus;extern ParseStatus count_words(const char *s, size_t len, int *out);int main(void) { const char *sentence = "the quick brown fox"; int out = 0; ParseStatus st = count_words(sentence, strlen(sentence), &out); printf("status=%d words=%d\n", st, out); st = count_words(" ", 3, &out); printf("status=%d\n", st); return 0;}$ cargo build --release && cc main.c -L target/release -ltextlib -o demo$ DYLD_LIBRARY_PATH=target/release ./demostatus=0 words=4status=1This single function shows the whole FFI export toolkit at once: a #[repr(C)] enum for status, a pointer-plus-length string (the universal C convention), null checks before every dereference, std::slice::from_raw_parts to rebuild a safe slice inside one tight unsafe block, and graceful handling of invalid input. Wrapping such functions so Rust callers never touch the raw pointers is the topic of safety-abstractions.md.