Skip to content

← Macros, FFI and Type-Driven Design step 24 of 28

Hard Framework

Edition 2024: #[unsafe(no_mangle)] and calling Rust from C in one file

Export two Rust functions to the C world, then call them back through the C ABI from inside the same file.

pub fn run(xs: Vec<i32>) -> (i64, i32)

Exporting Rust to C is half of all real FFI work — embedding Rust in an existing C, Python, Ruby or Node codebase — and the half that tutorials skip because it usually needs a second language and a build system. Here it does not: if a function publishes a real linker symbol, and you declare that symbol and call it, the call goes out through the genuine C ABI and comes back. You can watch it happen.

Publishing a symbol

#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_sum(ptr: *const i32, len: usize) -> i64 { ... }

Two attributes-worth of meaning:

  • extern "C" fixes the calling convention (18.21), so C-compiled code knows where to put the arguments.
  • #[unsafe(no_mangle)] suppresses Rust’s name mangling, so the symbol in the object file is literally rust_sum rather than _ZN4demo8rust_sum17h…E.

#[unsafe(export_name = "cracked_clamp")] does the same job but lets you choose the name, which is how you give a C-facing API a prefix without renaming the Rust function.

Why unsafe(...) around the attribute

In edition 2024, a bare #[no_mangle] is a hard error:

error: unsafe attribute used without unsafe

no_mangle, export_name and link_section must be written inside an unsafe(...) wrapper. The rationale is worth understanding rather than memorising, because it is not the usual “this might read bad memory” kind of unsafe.

These attributes operate in the global linker namespace, where the compiler cannot check for collisions. Two crates in your dependency graph that both export init produce a symbol clash the compiler never sees. Worse, an exported malloc silently replaces the system one, process-wide — for your code, for your dependencies, and for the C libraries linked into the process. There is no diagnostic. unsafe(...) is where you acknowledge that you are writing into a namespace shared by the entire program.

rustc’s unsafe_attr_outside_unsafe lint automates the migration for pre-2024 code.

Two more rules while you are here:

  • no_mangle on a generic function is meaningless and rustc’s no_mangle_generic_items lint says so — there is no single symbol to emit, because there is no single function until it is monomorphised.
  • Forget extern "C" on a no_mangle function and clippy’s no_mangle_with_rust_abi catches it. You would have published a plain symbol using the unstable Rust convention: the worst of both worlds, and silently wrong.

E0428: you cannot declare and define in the same module

This one is mandatory, not stylistic. Write

#[unsafe(no_mangle)]
pub unsafe extern "C" fn rust_sum(...) -> i64 { ... }

unsafe extern "C" {
    pub fn rust_sum(ptr: *const i32, len: usize) -> i64;   // E0428
}

and you get:

error[E0428]: the name `rust_sum` is defined multiple times

From Rust’s point of view these are two items with the same name in one module, which is simply illegal — the fact that one is a definition and the other a declaration of the same underlying symbol is a linker-level truth the module system does not model.

Two ways out, and you will use both here:

  1. Put the declaration in a nested module. mod c_side { unsafe extern "C" { … } }. Different module, no collision, and the linker still resolves the symbol because symbols are global.
  2. Use #[unsafe(export_name = "…")] so the Rust name and the symbol name differ. Then declare the symbol name, which no Rust item is using.

Panics are not an option

An exported extern "C" function must be panic-free, because a panic unwinding out of an extern "C" boundary aborts the process (18.21). Not an error, not a caught exception — SIGABRT.

That is why rust_clamp checks for an inverted range and returns early instead of calling v.clamp(lo, hi) unconditionally: clamp panics when lo > hi, and a panic here would kill the process. In production code the belt-and-braces version wraps the whole body in std::panic::catch_unwind (18.27).

FFI-safe types only

Everything crossing the boundary must have a defined C representation. rustc’s improper_ctypes_definitions lint checks the ones you define, and it is warn-by-default — so under clippy -D warnings it is effectively a hard error here. *const i32, usize, i64 and c_int are all fine. A &str, a tuple or a Vec would not be (18.25).

Note the shape of rust_sum‘s signature: a pointer plus a length, which is exactly how you spell “slice” in C. Reconstructing the slice on the Rust side is std::slice::from_raw_parts, and the null-and-zero case has to be handled explicitly because C callers pass null for empty far more often than Rust ones do.

::: question rust_sum is unsafe fn, but its body still needs an unsafe { } block around from_raw_parts. Why twice?

Because edition 2024 changed what unsafe fn means, and the change is an improvement.

Before, the body of an unsafe fn was implicitly an unsafe block: you could do anything anywhere inside it without further ceremony. That conflated two different things — “this function has a precondition callers must satisfy” and “this function performs unsafe operations” — and it meant a 200-line unsafe fn gave you no visual indication of which three lines were actually dangerous.

In edition 2024, unsafe_op_in_unsafe_fn is deny by default. The unsafe in the signature now says only “callers must uphold my contract”; performing an unsafe operation still needs a block, still wants a // SAFETY: comment, and is still visible when you skim.

So the two unsafes mean genuinely different things: one is a promise you extract from your callers, the other is a promise you make to the compiler. :::

Your job

The starter has a bare #[no_mangle] and an extern block in the wrong module. Fix both.