Linking C and Rust Objects into One Module
This page answers one task: use an existing C library from Rust code that compiles to WebAssembly, so both end up in a single
.wasm file with one memory and one set of exports — without switching the whole project to Emscripten.
Prerequisites
- [ ] Rust with the
wasm32-unknown-unknownorwasm32-wasip1target. - [ ] clang with WebAssembly support — the WASI SDK is the easiest source of a matching clang and C sysroot.
- [ ] A C library small enough to compile from source: a hash function, a codec, a parser.
How two languages become one module
Rust and C compilers both target WebAssembly through LLVM, and both produce object files in the same format: WebAssembly
object files with relocations and a symbol table, which wasm-ld knows how to combine. When rustc links a cdylib for a Wasm
target, it passes its own objects to wasm-ld. If a build script compiles C files into a static archive and tells cargo to link
it, those objects go into the same wasm-ld invocation, and the result is one module in which Rust functions can call C
functions directly, with no glue and no JavaScript in between.
The constraints come from what the C code expects at runtime. Pure computation — arithmetic on buffers passed in — links with
nothing else. As soon as the C code calls malloc, memcpy, printf or fopen, it needs a C standard library for the target.
On wasm32-wasip1, Rust already links wasi-libc, so C code that uses libc works. On wasm32-unknown-unknown there is no libc,
and the C code must either avoid it or be given minimal replacements.
Step 1 — compile the C code from a build script
The cc crate compiles C sources and emits the cargo directives to link them. It recognises Wasm targets and invokes clang:
# Cargo.toml
[build-dependencies]
cc = "1"
// build.rs
fn main() {
let target = std::env::var("TARGET").unwrap();
let mut build = cc::Build::new();
build.file("c/crc32c.c").include("c").opt_level(2).flag("-fvisibility=hidden");
if target == "wasm32-unknown-unknown" {
// no libc on this target: freestanding C, our own minimal headers
build.flag("-ffreestanding").flag("-nostdlib").include("c/freestanding");
}
build.compile("crc"); // produces libcrc.a and links it
println!("cargo:rerun-if-changed=c/");
}
Point cc at the right clang. Apple’s system clang has no WebAssembly backend, and many Linux distribution clangs lack the
sysroot. With the WASI SDK installed, set the compiler and sysroot explicitly:
export CC_wasm32_wasip1="$WASI_SDK_PATH/bin/clang"
export CFLAGS_wasm32_wasip1="--sysroot=$WASI_SDK_PATH/share/wasi-sysroot"
export CC_wasm32_unknown_unknown="$WASI_SDK_PATH/bin/clang"
Step 2 — declare and call the C functions from Rust
// c/crc32c.h
#include <stdint.h>
#include <stddef.h>
uint32_t crc32c(uint32_t seed, const uint8_t *data, size_t len);
// src/lib.rs
extern "C" {
fn crc32c(seed: u32, data: *const u8, len: usize) -> u32;
}
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn checksum(bytes: &[u8]) -> u32 {
unsafe { crc32c(0, bytes.as_ptr(), bytes.len()) }
}
The types must match the C ABI on wasm32: size_t is 32 bits, matching usize; long is 32 bits too, so use explicit
int64_t and i64 for 64-bit values. Generating declarations with bindgen --target=wasm32-unknown-unknown avoids
hand-written mistakes for larger headers. The call itself is an ordinary direct WebAssembly call instruction — no boundary,
no copying — because both functions live in the same module and the same memory.
Step 3 — provide what freestanding C needs
On wasm32-unknown-unknown, C code that uses even memcpy or memset will fail to link with undefined symbols, because there
is no libc. Two approaches work. If the code needs only memory functions, compile with bulk memory enabled so clang emits
memory.copy and memory.fill instructions directly, and provide the few remaining symbols from Rust:
// Rust already provides memcpy/memset/memmove via compiler-builtins on this target;
// allocation must be bridged explicitly if the C code calls malloc.
#[no_mangle]
pub extern "C" fn malloc(size: usize) -> *mut u8 {
let layout = std::alloc::Layout::from_size_align(size.max(1) + 8, 8).unwrap();
unsafe {
let p = std::alloc::alloc(layout);
*(p as *mut usize) = size; // remember the size for free
p.add(8)
}
}
#[no_mangle]
pub extern "C" fn free(ptr: *mut u8) {
if ptr.is_null() { return; }
unsafe {
let base = ptr.sub(8);
let size = *(base as *const usize);
std::alloc::dealloc(base, std::alloc::Layout::from_size_align(size.max(1) + 8, 8).unwrap());
}
}
That bridge makes C allocations come from Rust’s global allocator, so the module has one heap rather than two competing ones
writing into the same memory. If the C code needs more of libc — formatted output, string functions, file access — use
wasm32-wasip1, where wasi-libc provides it, or switch the C side’s allocator calls to an interface you control.
Step 4 — check the link for duplicates and leftovers
cargo build --release --target wasm32-unknown-unknown
wasm-objdump -x target/wasm32-unknown-unknown/release/app.wasm | grep -E '<(crc32c|malloc|free|memcpy)>'
wasm-objdump -x -j Import target/wasm32-unknown-unknown/release/app.wasm
The C function should appear once, as an internal function. The import section should contain only what you expect — the
wasm-bindgen placeholders, nothing from env such as env.malloc or env.printf. An unexpected env import is a C symbol
that was never defined; the linker turned it into an import rather than failing, which is covered in
fixing undefined symbol errors from wasm-ld.
Step 5 — optionally enable cross-language LTO
By default the C archive is optimized on its own and linked as finished code. With matching LLVM versions, the C code can instead be compiled to LLVM bitcode and optimized together with the Rust code, allowing inlining across the language boundary:
// build.rs — emit bitcode instead of machine code
build.flag("-flto=thin");
# .cargo/config.toml
[target.wasm32-unknown-unknown]
rustflags = ["-C", "linker-plugin-lto"]
This requires the clang that compiles the C code to use the same LLVM major version as rustc (rustc -vV shows it). Mismatches
fail at link time with a bitcode version error. The gain is largest when Rust calls small C functions in a hot loop, where
inlining removes the call; for coarse calls such as a whole-buffer checksum it is negligible.
Before reaching for LTO at all, check whether the interface is the problem. Calling C once per element from a Rust loop is the pattern LTO rescues; calling it once per buffer usually makes LTO unnecessary, and keeps the build simpler.
Expected output
import init, { checksum } from "./pkg/app.js";
await init();
console.log(checksum(new TextEncoder().encode("123456789")).toString(16)); // "e3069283"
e3069283 is the standard CRC-32C check value, confirming the C code ran correctly inside the Rust module.
Gotchas
error: unable to create target: 'No available targets are compatible with triple "wasm32-unknown-unknown"'. The clang in use has no WebAssembly backend. PointCC_wasm32_*at the WASI SDK’s clang.fatal error: 'stdlib.h' file not found. No sysroot for the target. Usewasm32-wasip1with the WASI SDK sysroot, or write freestanding C with your own minimal headers.- Two allocators corrupting each other. C code linked with its own
mallocimplementation alongside Rust’s allocator, both managing the same heap region. Route C allocations through Rust as in step 3. - The C code changes but the module does not. Cargo did not rerun the build script. Keep the
rerun-if-changedline pointing at the C directory, including headers. longtruncation. C code that assumeslongis 64 bits produces wrong results onwasm32. Audit forlongandunsigned longin arithmetic that can exceed 32 bits.
Performance note
For the CRC-32C library, the C implementation linked into the Rust module ran at the same speed as a native Rust port — about
1.1 GB/s with -O2 — and calling it per buffer cost nothing measurable compared with a Rust function. With cross-language LTO and
a per-byte update function called from a Rust loop, inlining raised throughput from 0.7 to 1.0 GB/s.
Frequently Asked Questions
Why not use Emscripten for mixed projects? Emscripten works well when the project is mostly C or C++. When it is mostly Rust with one C dependency, building through cargo keeps Rust’s tooling — wasm-bindgen, wasm-pack, Trunk — and avoids Emscripten’s JavaScript runtime.
Can the C code call Rust?
Yes — export a Rust function with #[no_mangle] pub extern "C" and declare it in a C header. The linker resolves it like any
other symbol.
Does the -sys crate ecosystem work this way?
Some -sys crates support Wasm targets through cc; many assume a native toolchain or system libraries and do not. Check whether
a pure-Rust alternative exists first, as discussed in
fixing crates that fail to compile for Wasm.
How do I debug the C part?
Compile the C sources with -g from the build script (build.debug(true)) and keep debug info through wasm-bindgen; DevTools and
the DWARF extension then step through C and Rust frames in the same call stack.
What about C++?
The same approach works with cc::Build::new().cpp(true), but C++ needs more runtime support — exceptions, RTTI, libc++ —
which wasm32-unknown-unknown lacks. Keep the C++ side exception-free, or use the WASI target.
Related
- Building C code with the WASI SDK — the toolchain providing clang and the sysroot.
- Building a Wasm module without libc — freestanding C in depth.
- Tuning LTO and codegen-units for Wasm — LTO on the Rust side.
- Choosing a Rust Wasm target triple — which target gives C a libc.
← Back to Linking Wasm Objects with wasm-ld