Skip to content
Zig and WASM: your best friend here is the compiler

Zig and WASM: your best friend here is the compiler

Published: at 01:37 PMSuggest Changes

Some days ago, MJ Grzymek published an interesting piece on his blog about how Zig can be compiled in WASM and used for efficient web development. It reminded me I want to write about Zig and WASM for months.

Not because I’m in love with this language, I find it messy (and I’m not a low level guy). But its compiler is dope! Notably, it allows you to compile C/C++ code to WASM/WASI. And that could be a game changer!

Zig compiler can compile C/C++ too

Once installed, zig help command will show you this message:

info: Usage: zig [command] [options]

Commands:

  build            Build project from build.zig
  init-exe         Initialize a `zig build` application in the cwd
  init-lib         Initialize a `zig build` library in the cwd

  ast-check        Look for simple compile errors in any set of files
  build-exe        Create executable from source or object files
  build-lib        Create library from source or object files
  build-obj        Create object from source or object files
  fmt              Reformat Zig source into canonical form
  run              Create executable and run immediately
  test             Create and run a test build
  translate-c      Convert C code to Zig code

  ar               Use Zig as a drop-in archiver
  cc               Use Zig as a drop-in C compiler
  c++              Use Zig as a drop-in C++ compiler
  ...

As you can see here, zig can be used as a drop-in C/C++ compiler.

For example, you can create a guess.c file:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main() {
    int number, guess, attempts = 0;
    srand(time(NULL));
    number = rand() % 421 + 1;

    printf("Guess a number between 1 and 421\n");
    do {
        scanf("%d", &guess);
        attempts++;
        if (guess > number) {
            printf("Lower number please!\n");
        } else if (guess < number) {
            printf("Higher number please!\n");
        } else {
            printf("You guessed %d it in %d attempts\n", number, attempts);
        }
    } while (guess != number);

    return 0;
}

Then compile it with zig:

zig cc guess.c -o guess
./guess

And it works! You can do the same with a C++ code in an analyze.cpp file:

#include <string>
#include <sstream>
#include <iostream>

int main() {
    std::string line;
    int lineCount = 0, charCount = 0, charNoSpacesCount = 0, wordCount = 0;

    while (std::getline(std::cin, line)) {
        lineCount++;
        charCount += line.length();

        for (char c : line) {
            if (c != ' ') {
                charNoSpacesCount++;
            }
        }

        std::stringstream ss(line);
        std::string word;
        while (ss >> word) {
            wordCount++;
        }
    }

    // Display results, formatted as JSON
    std::cout << "{" << std::endl;
    std::cout << "  \"Line count\": " << lineCount << "," << std::endl;
    std::cout << "  \"Character count (including spaces)\": " << charCount << "," << std::endl;
    std::cout << "  \"Character count (excluding spaces)\": " << charNoSpacesCount << "," << std::endl;
    std::cout << "  \"Word count\": " << wordCount << std::endl;
    std::cout << "}" << std::endl;

    return 0;
}

Compile it with zig and run it:

zig c++ analyze.cpp -o analyze
./analyze analyze.cpp | jq

It will show you the number of lines, characters, words, JSON formatted.

Compile (some of) your C/C++ code to WASM/WASI

But the zig compiler also have a -target option, which can be used to compile previous C/C++ code to WASM/WASI with no changes.

Thus, it can run on any platform with a WASM runtime, such as Wasmtime:

zig cc -target wasm32-wasi guess.c -o guess.wasm
zig c++ -target wasm32-wasi analyze.cpp -o analyze.wasm
wasmtime guess.wasm
wasmtime analyze.wasm < analyze.cpp | jq

Note there are some limitations, as I wasn’t able to manipulate files. It’s why I relied on stdin in the previous example. So, the following C++ code compiles and runs, but not with the wasm32-wasi target:

#include <iostream>
#include <fstream>

int main(int argc, char* argv[]) {

    std::ifstream file(argv[1]);
    if(!file.is_open()) {
        std::cerr << "Error: could not open file\n";
        return 1;
    }

    std::string line;
    while(std::getline(file, line)) {
        std::cout << line << '\n';
    }

    file.close();
    return 0;
}

The following C code compiles in WASM, but doesn’t run, I get a could not open file error:

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char* argv[]) {

    FILE *file = fopen(argv[1], "r");
    if(file == NULL) {
        fprintf(stderr, "Error: could not open file\n");
        return 1;
    }

    char *line = NULL;
    size_t len = 0;
    ssize_t read;

    while((read = getline(&line, &len, file)) != -1) {
        printf("%s", line);
    }

    free(line);
    fclose(file);

    return 0;
}

As WASM/WASI (preview2 was recently announced) and zig compiler evolves, be sure it will be possible to do more and more things.

What about V?

I couldn’t end without trying to compile V code to WASM through zig. For the record, V compiler supports multiple backends (c, go, js, js_browser, js_node, js_freestanding) and wasm. Thus, the following hello.v file:

fn main() {
    println('Hello, WASM!')
}

Can be compiled to WASM:

v -backend wasm hello.v -o hello.wasm
wasmtime hello.wasm

But this doesn’t work with modules. For example, this analyze.v file:

import os
import json { encode }

struct Stats {
mut:
	line_count            int
	char_count            int // Including spaces
	char_no_spaces_count  int // Excluding spaces
	word_count            int
}

fn main() {
	mut stats := Stats{}

	for line in os.get_lines() {
		stats.line_count++
		stats.char_count += line.len
		stats.char_no_spaces_count += line.replace(' ', '').len

		words := line.split(' ').filter(it != '')
		stats.word_count += words.len
	}

	// Serialize `stats` to JSON
	json_str := encode(stats)
	println(json_str)
}

Compiles well in V, but not with a wasm backend. I tried with the c backend and then with zig cc… it leads to errors.

Go: the good balance for WASM/WASI?

What’s important here, is to see how WASM/WASI is gaining traction in multiple languages. Rust supports it as a target for a long time, there are movements from languages such as OCaml, Python, Ruby, etc.

Since last summer, and the 1.21 release, Go compiler natively supports WASM/WASI backend. And it works pretty well, it’s my personal choice for multiples WASM projects, easy to bootstrap.

For example, this analyze.go file:

package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"os"
	"strings"
)

type Statistics struct {
	LineCount             int `json:"Line count"`
	CharacterCount        int `json:"Character count (including spaces)"`
	CharacterCountNoSpace int `json:"Character count (excluding spaces)"`
	WordCount             int `json:"Word count"`
}

func main() {
	scanner := bufio.NewScanner(os.Stdin)

	var stats Statistics

	for scanner.Scan() {
		line := scanner.Text()
		stats.LineCount++
		stats.CharacterCount += len(line)
		stats.CharacterCountNoSpace += len(strings.ReplaceAll(line, " ", ""))
		stats.WordCount += len(strings.Fields(line))
	}

	if err := scanner.Err(); err != nil {
		fmt.Fprintf(os.Stderr, "reading standard input: %v", err)
	}

	jsonData, err := json.MarshalIndent(stats, "", "  ")
	if err != nil {
		fmt.Fprintf(os.Stderr, "error marshalling stats to JSON: %v", err)
		return
	}

	fmt.Println(string(jsonData))
}

Compiles and runs well in WASM:

GOOS=wasip1 GOARCH=wasm go build -o analyze.wasm analyze.go
wasmtime analyze.wasm < analyze.go | jq

Note that the built WASM file is 2.5MB, compared to 3.3/3.4MB with zig from C/C++. The binary was 48K in native C++, 177K in native V with JSON module.


Previous Post
A road story to my first Exherbo Linux packages
Next Post
Having fun with (Franken)PHP