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.
Prerequisites
- [ ] The WASI SDK unpacked somewhere stable, e.g.
/opt/wasi-sdk - [ ]
wasmtimeto run the result - [ ] A C project that builds natively with clang or gcc
- [ ]
wasm-objdumpfor 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.
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.
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.
Related
- WASI target builds & runtimes — the model this toolchain targets.
- C/C++ to Wasm with Emscripten — the browser-targeted alternative.
- Migrating legacy C code to WebAssembly — the same POSIX triage, applied to a port.
- Granting filesystem access with WASI preopens — making
fopenwork.
← Back to WASI Target Builds & Runtimes