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) and wasm-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.

The same kernel built three ways A small colour-conversion kernel compiled with Emscripten's default runtime, with the WASI SDK and wasi-libc, and freestanding with no libc. The freestanding build has no imports and no glue and is a small fraction of the size. Emscripten default libc + JS runtime glue imports from env.* ~14 KB .wasm + ~12 KB JS full C environment WASI SDK + wasi-libc libc linked statically imports from wasi_snapshot_preview1 ~9 KB .wasm, needs a WASI host portable programs freestanding (-nostdlib) only your functions zero imports ~0.4 KB .wasm, no glue kernels and small libraries

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.

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.

One call into a freestanding module JavaScript resets the bump allocator, allocates input and output buffers in linear memory, copies the RGBA pixels in, calls the exported kernel, and copies the grayscale result out. No imports are called at any point. JavaScript module exports linear memory reset(); alloc(n*4); alloc(n) copy RGBA pixels to src rgba_to_gray(src, dst, n) read src slice dst into a new array

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 a memset.
  • undefined symbol: __heap_base. It is defined only when the linker lays out a heap; make sure you are linking with wasm-ld (through clang), not assembling objects by hand.
  • Results wrong only for large images. unsigned long is 32 bits on wasm32; 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.

Size and startup of the same kernel, by build style The grayscale kernel built with Emscripten, with the WASI SDK and freestanding. Execution speed was identical; total download and instantiation time differ by orders of magnitude. total bytes to download (.wasm + JS glue) Emscripten -O3 default 26,100 bytes WASI SDK + shim 9,200 bytes freestanding -nostdlib 378 bytes

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.

← Back to Linking Wasm Objects with wasm-ld