Building C Code with the WASI SDK

This guide compiles an existing C project to a WASI target using the WASI SDK — a clang toolchain with a WASI sysroot — and shows which of its assumptions about the operating system will hold and which will not.

The WASI SDK is the natural route for C when the destination is a runtime rather than a browser. It is a smaller, simpler toolchain than Emscripten: no JavaScript glue, no virtual filesystem emulation, no runtime template. What it gives you instead is a libc (wasi-libc) whose I/O calls compile to WASI imports, so the binary runs under wasmtime with real files and real standard output.

Two C toolchains, two destinations Emscripten targets the browser and emits a JavaScript loader alongside the binary, emulating a filesystem and a POSIX environment. The WASI SDK targets a runtime and emits a single binary whose libc calls become WASI imports, with no JavaScript involved. the same C source Emscripten → the browser .wasm + .js loader virtual filesystem, emulated POSIX bigger, and it runs in a page WASI SDK → a runtime one .wasm, no glue wasi-libc calls become WASI imports smaller, and it needs a WASI host

Prerequisites

  • [ ] The WASI SDK unpacked somewhere stable, e.g. /opt/wasi-sdk
  • [ ] wasmtime to run the result
  • [ ] A C project that builds natively with clang or gcc
  • [ ] wasm-objdump for checking the produced imports and exports

Step-by-step procedure

1. Point clang at the WASI sysroot

export WASI_SDK=/opt/wasi-sdk
$WASI_SDK/bin/clang --target=wasm32-wasi \
  --sysroot=$WASI_SDK/share/wasi-sysroot \
  -O2 main.c -o tool.wasm

The --sysroot is what makes #include <stdio.h> resolve to wasi-libc rather than the host’s headers. Omitting it produces a confusing cascade of missing-symbol errors at link time.

2. Run it

wasmtime run --dir .::/work tool.wasm -- /work/input.bin

printf writes to standard output, fopen resolves inside the preopened directory, and argv arrives as expected.

3. Build a library rather than a command

For a module a host will call, suppress the entry point and export explicitly:

$WASI_SDK/bin/clang --target=wasm32-wasi \
  --sysroot=$WASI_SDK/share/wasi-sysroot \
  -O2 -mexec-model=reactor \
  -Wl,--export=process -Wl,--export=malloc -Wl,--export=free \
  lib.c -o lib.wasm

-mexec-model=reactor produces an initialiser rather than a _start, and each --export names a function the host may call. Anything not exported is subject to dead-code elimination.

4. Wire it into an existing build system

For a Makefile or CMake project, the toolchain is just a compiler prefix:

make CC=$WASI_SDK/bin/clang \
     CFLAGS="--target=wasm32-wasi --sysroot=$WASI_SDK/share/wasi-sysroot -O2"

CMake users can pass the SDK’s toolchain file with -DCMAKE_TOOLCHAIN_FILE=$WASI_SDK/share/cmake/wasi-sdk.cmake, which sets the same variables plus the right archiver and ranlib.

5. Check what you produced

wasm-objdump -x tool.wasm | sed -n '/^Export/,/^$/p'
wasm-objdump -x tool.wasm | grep -c wasi_snapshot_preview1

Expected output

A successful build is quiet; the interesting output is the artifact’s shape:

$ $WASI_SDK/bin/clang --target=wasm32-wasi --sysroot=$WASI_SDK/share/wasi-sysroot -O2 main.c -o tool.wasm
$ ls -l tool.wasm
-rw-r--r-- 1 dev dev 41230 tool.wasm

$ wasmtime run --dir .::/work tool.wasm -- /work/input.bin
read 4096 bytes from /work/input.bin
crc32 = 0x8f31a2b4

Forty kilobytes for a small program that reads a file and prints a number is typical, and most of it is libc rather than your code. An equivalent Emscripten build lands considerably larger once the glue is counted, which is the main reason to prefer this toolchain when the browser is not the target.

What wasi-libc does and does not provide File and stream functions compile and work against preopened directories. Process and signal functions are absent and fail to link. Networking functions are absent in preview 1, and threading requires runtime support that a command-line module does not have. works fopen · fread · fwrite · printf · malloc · qsort · math · time · getenv · stat within a preopen links, but constrained absolute paths outside a preopen · chdir · symlinks crossing the sandbox boundary absent — undefined symbol at link time fork · exec · signal · socket · pthread_create (without threads support) · dlopen

Gotchas

fatal error: 'stdio.h' file not found. The sysroot flag is missing or points at the wrong path. Verify $WASI_SDK/share/wasi-sysroot/include/stdio.h exists.

wasm-ld: error: undefined symbol: socket. Preview 1 has no networking. Guard the code with #ifdef __wasi__ and provide a stub, or move the networking to the host and pass buffers in.

Exports disappear from a reactor build. Each one needs -Wl,--export=name. Without it the linker removes anything unreachable from an entry point, which for a reactor is everything.

__main_void undefined. The source has no main but the build did not use -mexec-model=reactor. Add it, or provide a main.

The binary works under wasmtime and fails elsewhere. Check the import list — a runtime with an older WASI implementation may not provide everything wasi-libc emitted.

Exports are opt-in for a reactor With no entry point, the linker treats every function as unreachable unless it is named as an export. Each --export flag keeps one symbol; anything omitted is eliminated along with everything only it referenced. -Wl,--export=process kept, with everything it calls helper() — not named removed — even if JavaScript wanted it Check the result with wasm-objdump -x rather than assuming: a missing export is silent until the host tries to call it.

Performance note

Compute performance is the same as any other WebAssembly build of the same code: this is clang and LLVM producing the same instructions. Where a C program can lose time is in I/O granularity, because every fread that misses the buffer crosses into the runtime. wasi-libc buffers by default, so the common cases are fine, but code that calls fgetc in a loop or sets setvbuf(f, NULL, _IONBF, 0) will feel the difference sharply.

For size, -Oz plus a wasm-opt -Oz --strip-debug pass typically removes a third of the binary, and -Wl,--strip-all removes the name section if you do not need symbolic backtraces. Keep an unstripped copy: a trap in a stripped WASI binary reports numeric frames, exactly as in a browser.

Porting an autotools project

Most existing C projects build with autotools or CMake, and both cross-compile to this target with the usual cross-compilation mechanics rather than anything Wasm-specific. For autotools the essential argument is --host, which tells configure it is cross-compiling and stops it from trying to run the binaries it produces:

export WASI_SDK=/opt/wasi-sdk
./configure \
  --host=wasm32-wasi \
  CC="$WASI_SDK/bin/clang" \
  AR="$WASI_SDK/bin/llvm-ar" \
  RANLIB="$WASI_SDK/bin/llvm-ranlib" \
  CFLAGS="--target=wasm32-wasi --sysroot=$WASI_SDK/share/wasi-sysroot -O2"
make

Two failures are near-universal at this point and both have the same cause: configure scripts routinely test for a feature by compiling and running a small program, which cannot work when the host cannot execute the target’s binaries. The symptom is a check that reports the wrong answer or a configure run that fails outright. The fix is to pre-seed the answers with a cache file:

cat > wasi.cache <<'EOF'
ac_cv_func_fork=no
ac_cv_func_mmap_fixed_mapped=no
ac_cv_c_bigendian=no
EOF
./configure --host=wasm32-wasi --cache-file=wasi.cache ...

That is ordinary cross-compilation practice rather than a WebAssembly quirk, and the same file usually serves for every project in a codebase.

Deciding what to port and what to stub

The triage that saves the most time is deciding, before touching the build, which parts of the program are compute and which are system interaction. The compute half — parsing, transforming, encoding, numerics — ports with essentially no changes. The system half needs a decision per call: emulate it, remove it under a guard, or move it to the host.

A practical rule is to push system interaction to the edges and let the host do it. A library that takes a buffer and returns a buffer needs almost nothing from WASI; the same library that opens its own files, reads environment variables and writes logs needs a working preopen, an environment, and a stream — three more things that can differ between environments. Restructuring so the caller supplies the bytes is usually a modest change and removes most of the porting surface at once.

Where that is not possible — a library whose whole purpose is filesystem work — the WASI SDK’s emulation is good enough that a straightforward port normally succeeds, provided the code stays inside the capabilities preview 1 offers. It is the process and network calls that have no equivalent, and those need a design decision rather than a flag.

Frequently Asked Questions

Can I use the WASI SDK for a browser build? You can, but you will have to supply the WASI imports yourself in JavaScript, which is exactly the work Emscripten already does. For browser targets use Emscripten; for runtime targets use the WASI SDK.

Does C++ work? Yes, with clang++ and the same sysroot. Exceptions and RTTI depend on the SDK version and the runtime’s support for the exception-handling proposal, so check both before relying on them.

How do I link a third-party archive? Build it with the same toolchain — a native .a will not link. Most autotools projects accept CC and --host=wasm32-wasi, and the result links normally once every object is a Wasm object.

How do I handle a library that wants threads? Guard the threading path with #ifdef __wasi__ and provide a serial fallback. That is almost always less work than getting a threaded build running, and for a library whose parallelism is an optimisation rather than a semantic requirement it costs only throughput. Where the threading is essential, check whether your target runtime supports the threads proposal and whether the SDK build you have was compiled with pthread support — both have to be true, and neither is the default.

Can I link against a system library? Only if it was built for the same target. A native .so or .a cannot link into a Wasm module, so every dependency has to be rebuilt with the WASI SDK. For a project with a deep dependency tree that is often the largest part of the port, and it is worth surveying before starting: a library with a straightforward autotools build usually cross-compiles in minutes, while one with hand-written assembly or platform-specific system calls may not port at all without upstream changes.

Is the SDK version worth pinning? Yes, exactly as with any compiler.

← Back to WASI Target Builds & Runtimes