Skip to main contentStatic Libraries, Dynamic Libraries, and ABI | IoT Worker

Static Libraries, Dynamic Libraries, and ABI

The previous FFI articles focused on function-level boundaries: extern "C", strings, memory ownership, callbacks, and void *ctx.

When delivering Rust to a C project, there is another layer:

What file does the C project link?
What symbols are declared in the header?
Can those symbols keep a stable ABI?
After Rust internals change, does C need to change?

C developers know .a, .so, .h, and ABI. Rust delivered to C should return to those concepts instead of exposing Cargo internals.

C Callers See a Library and a Header

Inside Rust, the crate can have modules, generics, traits, Result, String, and Vec.

C callers should not see those. They should see:

libdevice.a or libdevice.so
device.h
link flags
runtime library search path
version and ABI notes

The header declares C ABI functions:

#include <stddef.h>
#include <stdint.h>

typedef enum {
    DEVICE_OK = 0,
    DEVICE_NULL = 1,
    DEVICE_FAILED = 2,
} device_status_t;

device_status_t device_checksum(const uint8_t *data, size_t len, uint16_t *out);

Rust exports the matching symbol:

#[repr(C)]
pub enum DeviceStatus {
    Ok = 0,
    Null = 1,
    Failed = 2,
}

#[unsafe(no_mangle)]
pub extern "C" fn device_checksum(
    data: *const u8,
    len: usize,
    out: *mut u16,
) -> DeviceStatus {
    if data.is_null() || out.is_null() {
        return DeviceStatus::Null;
    }

    let data = unsafe { std::slice::from_raw_parts(data, len) };
    let value = data.iter().map(|b| *b as u16).sum();

    unsafe {
        *out = value;
    }

    DeviceStatus::Ok
}

That is the boundary: C sees a stable ABI; Rust internals stay Rust-like.

staticlib Builds a Static Library

To produce a static library for C, set:

[lib]
crate-type = ["staticlib"]

The build produces something like:

target/release/libdevice.a

C links it as an ordinary static library:

cc main.c -L target/release -ldevice -o app

Static library properties:

linked into the final executable
fewer runtime .so deployment concerns
upgrading the library usually requires relinking the application
the final binary may be larger
system libraries required by Rust dependencies still need link handling

staticlib fits existing C applications, single-binary delivery, and environments where deploying dynamic libraries is inconvenient.

cdylib Builds a C-Loadable Dynamic Library

To produce .so, .dylib, or .dll, use cdylib:

[lib]
crate-type = ["cdylib"]

The build may produce:

target/release/libdevice.so

C links it with:

cc main.c -L target/release -ldevice -o app

At runtime, the dynamic linker must find the library:

LD_LIBRARY_PATH=target/release ./app

Dynamic library properties:

loaded by the application at runtime
can be upgraded independently, but the ABI must remain compatible
requires library search path, rpath, and installation handling
more sensitive to exported symbols and versioning

For Rust libraries consumed by C, cdylib is usually the right dynamic-library crate type. Rust dylib is for the Rust compiler ecosystem and is not the default choice for a stable C ABI.

Do Not Treat Rust ABI as the Contract

Normal Rust functions, traits, generics, String, Vec, and Result are not C ABI contracts.

This is not a stable C-callable interface:

pub fn parse(data: &[u8]) -> Result<Vec<u8>, ParseError> {
    // ...
    Ok(Vec::new())
}

The C boundary should look more like:

#[unsafe(no_mangle)]
pub extern "C" fn device_parse(
    data: *const u8,
    len: usize,
    out: *mut DeviceBuffer,
) -> DeviceStatus {
    // boundary checks, conversion, error mapping
    DeviceStatus::Ok
}

The first rule is:

Rust internal APIs may change
the C ABI wrapper stays stable

If every Rust refactor forces a C header change, the boundary is in the wrong place.

Control Exported Symbols

C linkers look for symbol names. Rust normally mangles symbol names, so exported C functions must be explicit:

#[unsafe(no_mangle)]
pub extern "C" fn device_version() -> u32 {
    1
}

This exports device_version, not a Rust-mangled name.

But do not mark every internal function as no_mangle pub extern "C". More exported symbols mean a larger ABI promise. Prefer a small C API surface:

device_create
device_destroy
device_read
device_write
device_last_error

Internal Rust functions should remain normal fn or pub(crate), not part of the C ABI.

For dynamic libraries that need strict symbol control, later you can use linker version scripts, export lists, or platform-specific settings. That is release engineering, but the principle is the same: smaller exported surfaces are easier to maintain.

The Header Is the Written ABI

Rust compiling successfully does not mean C callers will use the library correctly.

The header should state the contract:

/*
 * Returns DEVICE_OK on success.
 * data may be NULL only when len is 0.
 * out must not be NULL.
 * The function does not retain data or out after returning.
 */
device_status_t device_checksum(const uint8_t *data, size_t len, uint16_t *out);

For C callers, the header and comments are the API. They must state:

  • whether pointers may be NULL
  • what length units mean
  • who provides output parameters
  • how return values are interpreted
  • whether pointers are retained
  • whether the function is thread-safe
  • which version introduced the function

Do not expect C users to read Rust source code to infer the contract.

ABI Stability Is Not Source Compatibility

An unchanged C header does not always mean ABI compatibility.

These changes may break ABI:

  • reordering fields in a #[repr(C)] struct
  • changing enum representation or values
  • changing function parameter types
  • changing the mapping between size_t and fixed-width integers
  • changing who allocates or frees memory
  • changing exported symbol names
  • changing callback thread or reentrancy rules

#[repr(C)] only asks Rust to use C-style layout on the Rust side. You still must keep it consistent with the C header.

Add ABI tests when needed:

#[test]
fn layout_matches_c_contract() {
    assert_eq!(std::mem::size_of::<DeviceStatus>(), 4);
    assert_eq!(std::mem::align_of::<DeviceHeader>(), 4);
}

Stricter projects may use bindgen/cbindgen, C compile tests, ABI check tools, or exported-symbol list comparisons.

Version API and ABI Separately

Rust crate versions serve the Rust package ecosystem. C ABI often needs its own compatibility signal.

Dynamic libraries often track:

source version: 1.4.2
C API version: device_api_version()
dynamic library soname: libdevice.so.1
header macro: DEVICE_API_VERSION

For example:

#[unsafe(no_mangle)]
pub extern "C" fn device_api_version() -> u32 {
    1
}

C can check during initialization:

if (device_api_version() != DEVICE_API_VERSION) {
    /* reject incompatible library */
}

The versioning policy does not have to be complex, but it must exist. This is especially true for dynamic libraries used by multiple C programs or scripts.

What to Keep

When Rust is delivered to C, the main issue is not whether Cargo can compile. The deliverable is:

staticlib      -> .a for C static linking
cdylib         -> .so/.dylib/.dll for C dynamic linking/loading
dylib          -> Rust-facing, not the default C ABI choice
extern "C"     -> C calling convention
no_mangle      -> stable exported symbol name
#[repr(C)]     -> C ABI layout boundary
header file    -> the real contract for C callers
ABI stability  -> signatures, layouts, symbols, ownership, and thread rules cannot drift casually

The best structure is two layers: Rust internals can evolve, while the outer C ABI remains small and stable.

The next article covers build.rs, #[link], and pkg-config. Library form answers what artifact exists; build scripts answer how Rust or C compilers find and link it.