Zig y WebAssembly: compilar a WASM sin Emscripten ni toolchain adicional

Compilar a WebAssembly desde C o C++ normalmente implica instalar Emscripten, configurar el toolchain, lidiar con las capas de compatibilidad que Emscripten añade para simular un entorno POSIX en el navegador. Con Zig, el target WASM es nativo: zig build -Dtarget=wasm32-freestanding y ya está. Sin Emscripten, sin toolchains adicionales, sin glue code que no controlas.

Los dos targets WASM de Zig

Zig ofrece dos targets principales para WebAssembly:

  • wasm32-freestanding: sin ninguna interfaz del sistema. El módulo WASM solo expone funciones que JavaScript puede llamar. Ideal para código que se integra en el navegador con una API mínima.
  • wasm32-wasi: con la interfaz WASI (WebAssembly System Interface). Permite acceso a ficheros, stdin/stdout y otras llamadas al sistema de forma portable. Útil para herramientas de línea de comandos que se ejecutan en entornos WASI como Wasmtime o Wasmer.

Hola mundo en WASM con Zig

El ejemplo más sencillo: una función que suma dos números, compilada a WASM y llamada desde JavaScript.

// suma.zig
export fn sumar(a: i32, b: i32) i32 {
    return a + b;
}
# Compilar a WASM
zig build-lib suma.zig -target wasm32-freestanding -dynamic -rdynamic
# Genera suma.wasm
// suma.html - llamar desde JavaScript
<script>
async function main() {
    const response = await fetch('suma.wasm');
    const buffer = await response.arrayBuffer();
    const { instance } = await WebAssembly.instantiate(buffer);

    const resultado = instance.exports.sumar(10, 32);
    console.log('10 + 32 =', resultado); // 42
}
main();
</script>

Pasar strings entre Zig y JavaScript

WASM solo trabaja con tipos numéricos. Para pasar strings hay que usar la memoria lineal del módulo WASM. Zig facilita esto exponiendo la memoria y funciones de asignación.

// wasm_lib.zig
const std = @import("std");

// Allocator que usa la memoria lineal de WASM
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();

// Aloca memoria para que JavaScript escriba datos
export fn alloar(len: usize) [*]u8 {
    const buf = allocator.alloc(u8, len) catch return @as([*]u8, undefined);
    return buf.ptr;
}

// Libera memoria previamente aloca
export fn liberar(ptr: [*]u8, len: usize) void {
    allocator.free(ptr[0..len]);
}

// Procesa un string que JavaScript ha escrito en la memoria WASM
export fn longitud_string(ptr: [*]const u8, len: usize) usize {
    const s = ptr[0..len];
    return s.len;
}
// JavaScript lado
const memory = instance.exports.memory;
const encoder = new TextEncoder();
const texto = encoder.encode("hola mundo");

// Alocar memoria en WASM
const ptr = instance.exports.alloar(texto.length);

// Escribir el string en la memoria WASM
new Uint8Array(memory.buffer, ptr, texto.length).set(texto);

// Llamar a la función
const len = instance.exports.longitud_string(ptr, texto.length);
console.log('Longitud:', len);

// Liberar
instance.exports.liberar(ptr, texto.length);

WASI: WebAssembly con acceso al sistema

Con el target wasm32-wasi, el módulo tiene acceso a una interfaz estándar del sistema. Puedes leer y escribir ficheros, usar stdin/stdout, y el mismo binario funciona en cualquier runtime WASI.

// hola_wasi.zig
const std = @import("std");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    try stdout.print("Hola desde WASI!n", .{});
}
# Compilar para WASI
zig build-exe hola_wasi.zig -target wasm32-wasi

# Ejecutar con wasmtime
wasmtime hola_wasi.wasm

Usar el sistema de build para WASM

Con build.zig puedes integrar la compilación a WASM como un paso más del build, junto con los tests y el ejecutable nativo:

const std = @import("std");

pub fn build(b: *std.Build) void {
    // Target nativo para tests y desarrollo
    const native_target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // Target WASM
    const wasm_target = b.resolveTargetQuery(.{
        .cpu_arch = .wasm32,
        .os_tag = .freestanding,
    });

    // Biblioteca WASM
    const wasm_lib = b.addSharedLibrary(.{
        .name = "mi-lib",
        .root_source_file = b.path("src/wasm_lib.zig"),
        .target = wasm_target,
        .optimize = optimize,
    });
    wasm_lib.rdynamic = true;

    const install_wasm = b.addInstallArtifact(wasm_lib, .{
        .dest_dir = .{ .override = .{ .custom = "www" } },
    });

    const wasm_step = b.step("wasm", "Compilar módulo WebAssembly");
    wasm_step.dependOn(&install_wasm.step);

    // Ejecutable nativo para desarrollo
    const exe = b.addExecutable(.{
        .name = "mi-programa",
        .root_source_file = b.path("src/main.zig"),
        .target = native_target,
        .optimize = optimize,
    });
    b.installArtifact(exe);
}

Comparación con Emscripten y wasm-pack

Herramienta

Instalación

Glue JS

Tamaño WASM

Emscripten (C/C++)

Compleja

Mucho

Grande

wasm-pack (Rust)

Moderada

Moderado

Mediano

Zig nativo

Solo Zig

Mínimo

Pequeño

Los binarios WASM de Zig son pequeños porque Zig no incluye runtime ni biblioteca estándar a no ser que los uses explícitamente. Un módulo wasm32-freestanding básico puede pesar pocos kilobytes.

Si te interesa cómo Zig gestiona el código nativo antes de llegar a WASM, el artículo sobre allocators explica el modelo de memoria que luego se mapea a la memoria lineal de WASM. Y para entender la cross-compilation, el artículo sobre cross-compilation con Zig cubre el sistema de targets en profundidad.

Imagen: Pexels / Christina Morillo

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP