Compiling C to WASM Without Emscripten: A Step-by-Step Guide

Table of contents


cover llvm wasm

When tasked with running C/C++ code in WebAssembly, the first tool you’ll hear about is Emscripten. It’s not just a compiler but a comprehensive toolkit that bundles C/C++ code, adds a JavaScript runtime, and even implements parts of the standard library. This makes it incredibly convenient, especially since earlier LLVM versions couldn’t produce wasm binaries. However, what if your code is designed specifically for the browser? Or if you want to deeply understand wasm+js interactions? Let’s strip away the extras and start with the basics.

Installing LLVM

We’ll use LLVM (which powers Emscripten under the hood) for compilation. Follow the official guide to install pre-built LLVM binaries for your system.

You can find pre-built releases here. Download and extract the files, ensuring the bin folder contains the clang compiler. For macOS, install LLVM 16.0.2 via Homebrew:

brew install llvm

Add the LLVM /path/to/LLVM/bin directory to your PATH for easy access. Verify the installation:

$ clang --version

Homebrew clang version 16.0.2
Target: x86_64-apple-darwin22.4.0
Thread model: posix
InstalledDir: /usr/local/Cellar/llvm/16.0.2/bin

Writing main.c

Let’s create a simple add(x, y) function to return the sum of two numbers:

double add(double x, double y) {
    return x + y;
}

Compiling main.c

Compile the code with:

clang --target=wasm32 -nostdlib -Wl,--no-entry -Wl,--export-all -o main.wasm main.c

Flags explained:

Running main.wasm in the Browser

Create a basic index.html to load the module:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title></title>
  </head>
  <body>
    <script type="module">
      WebAssembly.instantiateStreaming(fetch("main.wasm")).then((source) => {
        const add = source.instance.exports.add;
        console.info(`add(1.1, 1.9) = ${add(1.1, 1.9)}`);
      });
    </script>
  </body>
</html>

Browsers block loading local WASM files, so use a local server:

npx serve

Open http://localhost:3000 and check the console:

add(1.1, 1.9) = 3

Basic Size Optimization

The initial main.wasm is 484 bytes. Adding -Oz reduces it to 367 bytes. Next, strip unused symbols with -Wl,--strip-all to shrink it to 272 bytes.

Using --export-all exposes unnecessary symbols. Replace it with --export-dynamic and explicitly export the add function:

__attribute__((visibility("default")))
double add(double x, double y) {
    return x + y;
}

Recompile with:

clang -Oz --target=wasm32 -nostdlib -Wl,--strip-all -Wl,--no-entry -Wl,--export-dynamic -o main.wasm main.c

The final main.wasm is 55 bytes! The exports now include only add:

img_1.png
Success! Only our C function remains.

This 55-byte module is smaller than an equivalent AssemblyScript example (which includes a sourceMap, resulting in 96 bytes).

Conclusion

We’ve set up LLVM, compiled C code into a minimal, compact WebAssembly module, and called it from JavaScript. We explored compilation flags and linker options for size optimization.

In the next tutorial, we’ll write tests, call JavaScript functions from C, and use WebAssembly built-in instructions without <math.h>.

Hope this guide worked for you!

← Prev.
13 Game
Next →
Setting Up Obsidian Synchronization Between iOS and Windows: A Personal Journey