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
- Write functions with no reliance on an OS: no files, no stdout, no
main. - Provide your own memory, since there is no system allocator underneath.
- 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, files, clock or sockets. Files and clocks live behind
std.Ionow, and freestanding has noIoimplementation to hand it. There is nothing to lower onto, so reaching for them is a compile error rather than a runtime surprise. - No OS heap. There is no
mmapunderneath.std.heap.page_allocatorstill works, because on wasm it is backed by@wasmMemoryGrowrather than by the OS, and the general-purpose allocator layers on top of it.
The snippet takes the simpler route and hands 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 fornmore pages and returns the previous size, or-1if 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.