Chapter 41Cross Compilation And Wasm

Cross-Compilation & WASM

Overview

Having tightened our feedback loop with profiling and safeguards, 40 we are ready to ship those binaries to other platforms. This chapter walks through target discovery, native cross-compilation, and the essentials for emitting WASI modules, using the same CLI instrumentation we relied on earlier. #entry points and command structure

The very next chapter turns these mechanics into a full WASI project, so treat this as your hands-on preflight. 42

Learning Goals

  • Interpret target triples and query Zig’s built-in metadata for alternate architectures. Query.zig
  • Cross-compile native executables with zig build-exe and verify artifacts without leaving Linux.
  • Produce WASI binaries that share the same source as native code, ready for the project build pipeline. #Command-line-flags

Mapping Target Triples

Zig’s @import("builtin") exposes the compiler’s current idea of the world, while std.Target.Query.parse lets you inspect hypothetical targets without building them. Target.zig

This is the foundation for tailoring build graphs or ENT files before you touch zig build.

Understanding the Target Structure

Before parsing target triples, it’s valuable to understand how Zig represents compilation targets internally. The following diagram shows the complete std.Target structure:

graph TB subgraph "std.Target Structure" TARGET["std.Target"] CPU["cpu: Cpu"] OS["os: Os"] ABI["abi: Abi"] OFMT["ofmt: ObjectFormat"] DYNLINKER["dynamic_linker: DynamicLinker"] TARGET --> CPU TARGET --> OS TARGET --> ABI TARGET --> OFMT TARGET --> DYNLINKER end subgraph "Cpu Components" CPU --> ARCH["arch: Cpu.Arch"] CPU --> MODEL["model: *const Cpu.Model"] CPU --> FEATURES["features: Feature.Set"] ARCH --> ARCHEX["x86_64, aarch64, wasm32, etc"] MODEL --> MODELEX["generic, native, specific variants"] FEATURES --> FEATEX["CPU feature flags"] end subgraph "Os Components" OS --> OSTAG["tag: Os.Tag"] OS --> VERSION["version_range: VersionRange"] OSTAG --> OSEX["linux, windows, macos, wasi, etc"] VERSION --> VERUNION["linux: LinuxVersionRange<br/>windows: WindowsVersion.Range<br/>semver: SemanticVersion.Range<br/>none: void"] end subgraph "Abi and Format" ABI --> ABIEX["gnu, musl, msvc, none, etc"] OFMT --> OFMTEX["elf, macho, coff, wasm, c, spirv"] end

This structure reveals how target triples map to concrete configuration. When you specify -target wasm32-wasi, you’re setting CPU architecture to wasm32, OS tag to wasi, and implicitly ObjectFormat to wasm. The triple x86_64-windows-gnu maps to arch x86_64, OS windows, ABI gnu, and format coff (Windows PE).

Each component affects code generation: the CPU arch determines instruction sets and calling conventions, the OS tag selects system call interfaces and runtime expectations, the ABI specifies calling conventions and name mangling, and the ObjectFormat chooses the linker (ELF for Linux, Mach-O for Darwin, COFF for Windows, WASM for web/WASI). Understanding this mapping helps you decode std.Target.Query.parse results, predict cross-compilation behavior, and troubleshoot target-specific issues. The CPU features field captures architecture-specific capabilities (AVX on x86_64, SIMD on ARM) that the optimizer uses for code generation.

Target Resolution Flow

Target queries (user input) get resolved into concrete targets through a systematic process:

graph TB subgraph "Resolution Flow" QUERY["std.Target.Query<br/>user input with defaults"] RESOLVE["resolveTargetQuery()"] TARGET["std.Target<br/>fully resolved"] QUERY --> RESOLVE RESOLVE --> TARGET end subgraph "Query Sources" CMDLINE["-target flag<br/>command line"] DEFAULT["native detection<br/>std.zig.system"] MODULE["Module.resolved_target"] CMDLINE --> QUERY DEFAULT --> QUERY end subgraph "Native Detection" DETECT["std.zig.system detection"] CPUDETECT["CPU: cpuid, /proc/cpuinfo"] OSDETECT["OS: uname, NT version"] ABIDETECT["ABI: ldd, platform defaults"] DETECT --> CPUDETECT DETECT --> OSDETECT DETECT --> ABIDETECT end TARGET --> COMP["Compilation.root_mod<br/>.resolved_target.result"]

Target queries come from three sources: command-line -target flags (explicit user choice), native detection when no target is specified (reads host CPU via cpuid or /proc/cpuinfo, OS via uname or NT APIs, and ABI via ldd or platform defaults), or module configuration in build scripts.

The resolveTargetQuery() function converts queries (which may contain "native" or "default" placeholders) into fully concrete std.Target instances by filling in all missing details. This resolution happens during compilation initialization before any code generation occurs.

When you omit -target, Zig automatically detects your host system and builds a native target. When you specify a partial triple like wasm32-wasi, resolution fills in the ABI (typically musl for WASI) and object format (wasm). The resolved target then flows into the compilation module where it controls every aspect of code generation, from instruction selection to runtime library choices.

Example: comparing host and cross targets from code

The sample introspects the host triple and then parses two cross targets, printing the resolved architecture, OS, and ABI.

Zig
// Import standard library for target querying and printing
const std = @import("std");
// Import builtin module to access compile-time host target information
const builtin = @import("builtin");

/// Entry point that demonstrates target discovery and cross-platform metadata inspection.
/// This example shows how to introspect both the host compilation target and parse
/// hypothetical cross-compilation targets without actually building for them.
pub fn main() void {
    // Print the host target triple (architecture-OS-ABI) by accessing builtin.target
    // This shows the platform Zig is currently compiling for
    std.debug.print(
        "host triple: {s}-{s}-{s}\n",
        .{
            @tagName(builtin.target.cpu.arch),
            @tagName(builtin.target.os.tag),
            @tagName(builtin.target.abi),
        },
    );

    // Display the pointer width for the host target
    // @bitSizeOf(usize) returns the size in bits of a pointer on the current platform
    std.debug.print("pointer width: {d} bits\n", .{@bitSizeOf(usize)});

    // Parse a WASI target query from a target triple string
    // This demonstrates how to inspect cross-compilation targets programmatically
    const wasm_query = std.Target.Query.parse(.{ .arch_os_abi = "wasm32-wasi" }) catch unreachable;
    describeQuery("wasm32-wasi", wasm_query);

    // Parse a Windows target query to show another cross-compilation scenario
    // The triple format follows: architecture-OS-ABI
    const windows_query = std.Target.Query.parse(.{ .arch_os_abi = "x86_64-windows-gnu" }) catch unreachable;
    describeQuery("x86_64-windows-gnu", windows_query);

    // Print whether the host target is configured for single-threaded execution
    // This compile-time constant affects runtime library behavior
    std.debug.print("single-threaded: {}\n", .{builtin.single_threaded});
}

/// Prints the resolved architecture, OS, and ABI for a given target query.
/// This helper demonstrates how to extract and display target metadata, using
/// the host target as a fallback when the query doesn't specify certain fields.
fn describeQuery(label: []const u8, query: std.Target.Query) void {
    std.debug.print(
        "query {s}: arch={s} os={s} abi={s}\n",
        .{
            label,
            // Fall back to host architecture if query doesn't specify one
            @tagName((query.cpu_arch orelse builtin.target.cpu.arch)),
            // Fall back to host OS if query doesn't specify one
            @tagName((query.os_tag orelse builtin.target.os.tag)),
            // Fall back to host ABI if query doesn't specify one
            @tagName((query.abi orelse builtin.target.abi)),
        },
    );
}
Run
Shell
$ zig run 01_target_matrix.zig
Output
Shell
host triple: x86_64-linux-gnu
pointer width: 64 bits
query wasm32-wasi: arch=wasm32 os=wasi abi=gnu
query x86_64-windows-gnu: arch=x86_64 os=windows abi=gnu
single-threaded: false

The parser obeys the same syntax as -Dtarget or zig build-exe -target; recycle the output to seed build configurations before invoking the compiler.

Cross-Compiling Native Executables

With a triple in hand, cross-compiling is a matter of swapping the target flag. Zig 0.15.2 ships with self-contained libc integrations, so producing Windows or macOS binaries on Linux no longer requires additional SDKs. v0.15.2

Use file or similar tooling to confirm artifacts without booting another OS.

Example: to Windows from Linux

We keep the source identical, run it natively for sanity, then emit a Windows PE binary and inspect it in place.

Zig

// Import the standard library for printing and platform utilities
const std = @import("std");
// Import builtin to access compile-time target information
const builtin = @import("builtin");

// Entry point that demonstrates cross-compilation by displaying target platform information
pub fn main() void {
    // Print the target platform's CPU architecture, OS, and ABI
    // Uses builtin.target to access compile-time target information
    std.debug.print("hello from {s}-{s}-{s}!\n", .{
        @tagName(builtin.target.cpu.arch),
        @tagName(builtin.target.os.tag),
        @tagName(builtin.target.abi),
    });

    // Retrieve the platform-specific executable file extension (e.g., ".exe" on Windows, "" on Linux)
    const suffix = std.Target.Os.Tag.exeFileExt(builtin.target.os.tag, builtin.target.cpu.arch);
    std.debug.print("default executable suffix: {s}\n", .{suffix});
}
Run
Shell
$ zig run 02_cross_greeter.zig
Output
Shell
hello from x86_64-linux-gnu!
default executable suffix:
Cross-compile
Shell
$ zig build-exe 02_cross_greeter.zig -target x86_64-windows-gnu -OReleaseFast -femit-bin=greeter-windows.exe
$ file greeter-windows.exe
Output
Shell
greeter-windows.exe: PE32+ executable (console) x86-64, for MS Windows, 7 sections

Pair -target with -mcpu=baseline when you need portable binaries for older hardware; the std.Target.Query output above shows which CPU model Zig will assume.

Emitting WASI Modules

WebAssembly System Interface (WASI) builds share most of the native pipeline with a different object format. The same Zig source can print diagnostics on Linux and emit a .wasm payload when cross-compiled, thanks to shared libc pieces introduced in this release.

Object Formats and Linker Selection

Before generating WASI binaries, it’s important to understand how object formats determine compilation output. The following diagram shows the relationship between ABIs and object formats:

graph TB subgraph "Common ABIs" ABI["Abi enum"] ABI --> GNU["gnu<br/>GNU toolchain"] ABI --> MUSL["musl<br/>musl libc"] ABI --> MSVC["msvc<br/>Microsoft Visual C++"] ABI --> NONE["none<br/>freestanding"] ABI --> ANDROID["android, gnueabi, etc<br/>platform variants"] end subgraph "Object Formats" OFMT["ObjectFormat enum"] OFMT --> ELF["elf<br/>Linux, BSD"] OFMT --> MACHO["macho<br/>Darwin systems"] OFMT --> COFF["coff<br/>Windows PE"] OFMT --> WASM["wasm<br/>WebAssembly"] OFMT --> C["c<br/>C source output"] OFMT --> SPIRV["spirv<br/>Shaders"] end

The object format determines which linker implementation Zig uses to produce final binaries. ELF (Executable and Linkable Format) is used for Linux and BSD systems, producing .so shared libraries and standard executables. Mach-O targets Darwin systems (macOS, iOS), generating .dylib libraries and Mach executables. COFF (Common Object File Format) produces Windows PE binaries (.exe, .dll) when targeting Windows. WASM (WebAssembly) is a unique format that produces .wasm modules for web browsers and WASI runtimes. Unlike traditional formats, WASM modules are platform-independent bytecode designed for sandboxed execution. C and SPIRV are specialized: C outputs source code for integration with C build systems, while SPIRV produces GPU shader bytecode.

When you build for -target wasm32-wasi, Zig selects the WASM object format and invokes the WebAssembly linker (link/Wasm.zig), which handles WASM-specific concepts like function imports/exports, memory management, and table initialization. This is fundamentally different from the ELF linker (symbol resolution, relocations) or COFF linker (import tables, resource sections). The same source code compiles to different object formats transparently—your Zig code remains identical whether targeting native Linux (ELF), Windows (COFF), or WASI (WASM).

Example: single source, native run, WASI artifact

Our pipeline logs the execution stages and branches on builtin.target.os.tag so the WASI build announces its own entry point.

Zig

// Import standard library for debug printing capabilities
const std = @import("std");
// Import builtin module to access compile-time target information
const builtin = @import("builtin");

/// Prints a stage name to stderr for tracking execution flow.
/// This helper function demonstrates debug output in cross-platform contexts.
fn stage(name: []const u8) void {
    std.debug.print("stage: {s}\n", .{name});
}

/// Demonstrates conditional compilation based on target OS.
/// This example shows how Zig code can branch at compile-time depending on
/// whether it's compiled for WASI (WebAssembly System Interface) or native platforms.
/// The execution flow changes based on the target, illustrating cross-compilation capabilities.
pub fn main() void {
    // Simulate initial argument parsing stage
    stage("parse-args");
    // Simulate payload rendering stage
    stage("render-payload");

    // Compile-time branch: different entry points for WASI vs native targets
    // This demonstrates how Zig handles platform-specific code paths
    if (builtin.target.os.tag == .wasi) {
        stage("wasi-entry");
    } else {
        stage("native-entry");
    }

    // Print the actual OS tag name for the compilation target
    // @tagName converts the enum value to its string representation
    stage(@tagName(builtin.target.os.tag));
}
Run
Shell
$ zig run 03_wasi_pipeline.zig
Output
Shell
stage: parse-args
stage: render-payload
stage: native-entry
stage: linux
WASI build
Shell
$ zig build-exe 03_wasi_pipeline.zig -target wasm32-wasi -OReleaseSmall -femit-bin=wasi-pipeline.wasm
$ ls -lh wasi-pipeline.wasm
Output
Shell
-rwxr--r-- 1 zkevm zkevm 4.6K Nov  6 13:40 wasi-pipeline.wasm

Run the resulting module with your preferred runtime (Wasmtime, Wasmer, browsers) or hand it to the build graph from the next chapter. No source changes required.

Notes & Caveats

  • zig targets provides the authoritative matrix of supported triples. Script it to validate your build matrix before dispatching jobs.
  • Some targets default to ReleaseSmall-style safety. Explicitly set -Doptimize when you require consistent runtime checks across architectures. #releasefast
  • When cross-linking to glibc, populate ZIG_LIBC or use zig fetch to cache sysroot artifacts so the linker does not reach for host headers unexpectedly.

Exercises

  • Extend the greeter program with --cpu and --os flags, then emit binaries for x86_64-macos-gnu and aarch64-linux-musl and capture their sizes with ls -lh.
  • Modify the WASI pipeline to emit JSON via std.json.stringify, then run it in a WASI runtime and capture the output for regression tests. json.zig
  • Write a build.zig step that loops over a list of target triples and calls addExecutable once per target, using the std.Target.Query helper to print human-friendly labels. 22

Alternatives & Edge Cases:

  • LLVM-backed targets may still behave differently from Zig’s self-hosted codegen. Fall back to -fllvm when you hit nascent architectures.
  • WASI forbids many syscalls and dynamic allocation patterns. Keep logging terse or gated to avoid blowing the import budget.
  • Windows cross-compiles pick the GNU toolchain by default. Add -msvc or switch ABI if you intend to link against MSVC-provided libraries. 20

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.