⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1503+1f1bee62eOn an older Zig?

Recipe: A Freestanding Module

The problem

You want the smallest possible module with no dependency on a system interface: something a JavaScript host or a native embedder drives, where WASI’s files and clocks would only be dead weight. That is wasm32-freestanding. It gives you wasm and nothing else.

The plan

  1. Write functions with no reliance on an OS: no files, no stdout, no main.
  2. Provide your own memory, since there is no system allocator underneath.
  3. Build for wasm32-freestanding.
const std = @import("std");

// Freestanding has no OS heap to fall back on, so give the allocator a fixed
// backing store carved from the module's own linear memory.
var heap: [64 * 1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&heap);

export fn sumRange(n: u32) u64 {
    var total: u64 = 0;
    var i: u32 = 1;
    while (i <= n) : (i += 1) total += i;
    return total;
}

// Ask the engine for one more 64 KiB page of linear memory and report the
// previous size in pages. A wasm builtin: it needs no OS underneath.
export fn growOnePage() i32 {
    return @wasmMemoryGrow(0, 1);
}

test sumRange {
    try std.testing.expectEqual(@as(u64, 55), sumRange(10));
}

test "a fixed buffer allocator needs no OS" {
    const a = fba.allocator();
    const s = try a.alloc(u8, 8);
    defer a.free(s);
    try std.testing.expectEqual(@as(usize, 8), s.len);
}

The tests here run because the guide builds every snippet for wasm32-wasi so it can verify them. The source is unchanged for freestanding: nothing in it touches WASI. Only the build command’s target differs, and the next section is that command.

Building freestanding

zig build-exe freestanding.zig \
  -target wasm32-freestanding \
  -O ReleaseSmall -fno-entry \
  --export=sumRange --export=growOnePage

Same reactor flags as the JavaScript recipe. The only change is -target wasm32-freestanding in place of wasm32-wasi. That single swap removes the WASI import layer entirely: the module imports nothing at all and depends on no runtime beyond a bare wasm engine.

What freestanding takes away

Everything OS-shaped, because there is no OS:

  • No main / _start. There is no entry point to call. The host reaches in through the exports.
  • No stdout, no files, no clock, no sockets. std.fs, std.time, and friends have nothing to lower onto. Reaching for them is a compile error, not a runtime surprise.
  • No system allocator. std.heap.page_allocator and the general-purpose allocator assume an OS. You bring memory yourself.

The snippet handles the allocator by handing a FixedBufferAllocator a 64 KiB static array. That array lives in linear memory, which is the only memory there is. It never calls the OS because there is no OS to call.

The memory builtins

Freestanding does not mean you are stuck with the memory you started with. Two wasm builtins manage linear memory directly, with no runtime underneath:

  • @wasmMemorySize(0) returns the current size in 64 KiB pages.
  • @wasmMemoryGrow(0, n) asks the engine for n more pages and returns the previous size, or -1 if the engine refuses.

growOnePage in the snippet is the second one. A real allocator calls it when its buffer is exhausted, then carves the fresh pages into allocations. This is the layer beneath the fixed buffer: where the bytes ultimately come from when a freestanding module needs more than it was compiled with.

When to choose it over WASI

Reach for freestanding when a host is already there to give the module whatever it needs, and you want the module itself to carry nothing extra. A browser is such a host, and so is a native program embedding wasmtime. Reach for WASI instead when the module is a program in its own right that has to run from a shell and do OS-shaped work. The command recipe is that case; this one is its opposite.