Building a Wasm Module Without libc
This page answers one task: build a WebAssembly module from C with no standard library at all — no Emscripten, no WASI SDK runtime — so that the output contains only your code, imports nothing, and weighs hundreds of bytes rather than tens of kilobytes.
Prerequisites
- [ ] clang 15+ with the WebAssembly backend (
clang --print-targets | grep wasm32) andwasm-ld. - [ ] A small, self-contained piece of C: a checksum, a colour conversion, a math kernel.
- [ ] WABT (
wasm-objdump,wasm2wat) to look at the result.
What a freestanding module is
Most C-to-WebAssembly toolchains link a C standard library — Emscripten’s musl-based libc, or wasi-libc — and a runtime that
connects it to the outside world through imports. That is essential for programs that print, open files or allocate heavily. It
is pure overhead for a kernel that receives a buffer, computes, and returns a number. Such code needs none of libc, and building it
freestanding — -nostdlib, --no-entry — produces a module containing only the functions you wrote, plus whatever the compiler
needs internally.
The result is the smallest kind of WebAssembly module there is, and also the simplest to integrate: no glue file, no import object, nothing to configure. JavaScript instantiates it with an empty import object and calls its exports. That makes freestanding modules good candidates for inlining into a page, as in inlining small Wasm modules as Base64.
Step 1 — write C that needs nothing
// rgb2gray.c — freestanding: no headers beyond the compiler's own
typedef unsigned char u8;
typedef unsigned int u32;
#define EXPORT(name) __attribute__((export_name(#name)))
EXPORT(rgba_to_gray)
void rgba_to_gray(const u8 *rgba, u8 *gray, u32 pixels) {
for (u32 i = 0; i < pixels; i++) {
u32 r = rgba[4 * i], g = rgba[4 * i + 1], b = rgba[4 * i + 2];
gray[i] = (u8)((77 * r + 150 * g + 29 * b) >> 8); // integer luma
}
}
No #include <stdint.h> — even that header comes from a libc sysroot on some installations. The compiler’s own builtin types and
headers (<stddef.h>, <stdint.h> from clang’s resource directory) are available without a sysroot if you prefer them.
Step 2 — compile and link with no runtime
clang --target=wasm32 -O3 -nostdlib -fvisibility=hidden \
-Wl,--no-entry -Wl,--strip-all -Wl,--initial-memory=2097152 \
-mbulk-memory \
rgb2gray.c -o rgb2gray.wasm
wc -c rgb2gray.wasm
378 rgb2gray.wasm
Each flag has a job. -nostdlib links no C library or startup files. --no-entry means there is no main or _start.
-fvisibility=hidden keeps non-exported functions internal so the linker can remove them. --strip-all drops the name section
for the smallest output (omit it while debugging). --initial-memory sizes the memory, here 2 MiB — enough for a 512×512 RGBA
image and its grayscale output. -mbulk-memory lets clang implement struct copies and memset-like loops with memory.copy and
memory.fill rather than calling functions that do not exist.
Step 3 — supply the few things the compiler may call
Even freestanding code can trigger calls the compiler emits on its own. Copying a large struct or zeroing an array produces a call to
memcpy or memset unless bulk memory is enabled; 64-bit division on some configurations calls __divdi3; and any dynamic
allocation needs malloc. Provide the ones you need, minimally:
// minimal runtime: a bump allocator over the heap
extern unsigned char __heap_base; // provided by wasm-ld
static unsigned long heap_top = (unsigned long)&__heap_base;
EXPORT(alloc)
void *alloc(unsigned long n) {
unsigned long p = (heap_top + 7) & ~7ul; // 8-byte align
unsigned long need = p + n;
unsigned long have = __builtin_wasm_memory_size(0) * 65536;
if (need > have) {
unsigned long pages = (need - have + 65535) / 65536;
if (__builtin_wasm_memory_grow(0, pages) == (unsigned long)-1) return 0;
}
heap_top = need;
return (void *)p;
}
EXPORT(reset)
void reset(void) { heap_top = (unsigned long)&__heap_base; }
__heap_base is a linker-defined symbol marking the first free address after the stack, and the __builtin_wasm_memory_*
intrinsics compile to the memory.size and memory.grow instructions. That is a complete allocator for a module used one call
at a time — allocate the buffers, run the kernel, reset. Its trade-offs are discussed in
implementing a bump allocator in Wasm.
Step 4 — call it from JavaScript with no glue
const { instance } = await WebAssembly.instantiateStreaming(fetch("rgb2gray.wasm"), {});
const { memory, alloc, reset, rgba_to_gray } = instance.exports;
function toGray(imageData) {
const n = imageData.width * imageData.height;
reset();
const src = alloc(n * 4), dst = alloc(n);
new Uint8Array(memory.buffer, src, n * 4).set(imageData.data);
rgba_to_gray(src, dst, n);
return new Uint8Array(memory.buffer, dst, n).slice(); // copy out before the next reset
}
The empty import object is the point: the module needs nothing from its host. Note the views are created after alloc, which
may have grown memory and replaced memory.buffer — the reason is explained in
why memory.grow invalidates pointers.
When freestanding stops being the right choice
Freestanding modules are excellent for what they are, and it is worth knowing where their limits lie before a small kernel grows into something that fights them. The first limit is the standard library itself. The moment the code needs formatted text, string-to-number conversion, sorting with a comparator, or math functions beyond the basic arithmetic instructions, you are re-implementing libc piece by piece. A few functions are fine; a dozen is a sign to link wasi-libc statically with the WASI SDK instead, which costs a few kilobytes and removes a category of bugs.
The second limit is memory management. A bump allocator that resets between calls is perfect for a stateless kernel. A module that keeps state between calls — a cache, a document model, a growing index — needs memory it can free and reuse, and a hand-written allocator for that is real work. The third is portability of the source: freestanding C that avoids libc entirely is easy to keep portable for one function and increasingly awkward as code from other projects is pulled in, because almost all C in the wild assumes a standard library.
So treat freestanding as the right tool for leaf code — kernels, checksums, conversions, small parsers with fixed-size buffers — and switch to a libc-backed build when the code stops being a leaf. Both can coexist: a larger module built with the WASI SDK can still contain freestanding-style kernels, and the size difference between the two styles is mostly libc, not your code.
Step 5 — read the output to confirm it is minimal
wasm-objdump -x rgb2gray.wasm | grep -E '^(Import|Export|Function)'
wasm2wat rgb2gray.wasm | head -30
Function[3]:
Export[4]:
No Import section at all, three functions, four exports (memory plus three functions). Reading the WAT of a module this size is
practical and instructive: you can see exactly what the compiler made of each line of C, which is the subject of
converting Wasm back to WAT with wasm2wat.
Expected output
Converting a 512×512 image:
const gray = toGray(ctx.getImageData(0, 0, 512, 512));
console.log(gray.length, gray[0]); // 262144, and the first pixel's luma
Gotchas
wasm-ld: error: undefined symbol: memset. The compiler emitted a call for a zeroing loop or struct initialisation. Add-mbulk-memory, or provide amemset.undefined symbol: __heap_base. It is defined only when the linker lays out a heap; make sure you are linking withwasm-ld(through clang), not assembling objects by hand.- Results wrong only for large images.
unsigned longis 32 bits onwasm32; a byte count above 4 GiB cannot exist anyway, but intermediate products in arithmetic can overflow. Use 64-bit types where products may exceed 32 bits. - The module grows on every call.
reset()was never called. A bump allocator only reclaims memory on reset.
Performance note
The freestanding grayscale kernel converted a 1920×1080 frame in 1.9 ms in Chrome — the same as the Emscripten build of the same C
at -O3 — while instantiating in 0.05 ms with no glue to load. Removing libc changes size and startup, not the speed of the code you
wrote.
Frequently Asked Questions
Can I use printf for debugging?
Not without a libc. Import a logging function instead — declare __attribute__((import_module("env"), import_name("log"))) void log_i32(int); and provide it in the import object during development.
Do I need --initial-memory at all?
Without it, the linker sizes memory to fit static data and the stack, often a couple of pages. That is fine if the module grows
memory itself, as the allocator above does; set it explicitly when you know the working set and want to avoid growth.
Is this the same as Rust’s no_std?
It is the C equivalent. A #![no_std] Rust crate with a small allocator produces similarly tiny modules.
What about floating-point math functions?
Basic arithmetic and sqrt compile to Wasm instructions. Functions like sin and exp come from libm, which you must provide —
small public-domain implementations are easy to vendor.
Can freestanding code use SIMD?
Yes. Add -msimd128 and use <wasm_simd128.h> from clang’s resource directory; see
writing SIMD from C with wasm_simd128.h.
Related
- Exporting symbols with wasm-ld flags — the export mechanics used here.
- Writing your first WAT module by hand — even smaller, with no compiler at all.
- Setting stack size and memory limits at link time — where
__heap_basecomes from. - Linking C and Rust objects into one module — freestanding C inside a larger module.
← Back to Linking Wasm Objects with wasm-ld