Skip to main content

Embedding Reed in Rust with Binaryen

Binaryen is a compiler and toolchain library for WebAssembly, best known through wasm-opt. Tools that generate, optimize, instrument, or merge wasm modules use it to work with a real module rather than with raw bytes.

Building a function with Binaryen's C API means constructing an expression tree by hand: BinaryenLocalGet, BinaryenBinary, BinaryenReturn, threading operand arrays through each call. That is precise and completely explicit, which is what you want for a transform pass, and tedious when what you actually wanted was return a * b + 1.

reedc-binaryen closes that gap. Write the logic in Reed, get a Binaryen module back — with Reed's GC structs and arrays intact.

reedc-binaryen unit.reed --emit rust -o unit.rs
note

reedc-binaryen is a separate binary from reedc, because linking Binaryen means compiling its C++ through cmake. Ordinary compilation (reedc build, reedc check, reedc fmt) needs none of that and stays in reedc. Build this one with mise run binaryen:build; it needs cmake and a C++20 toolchain, and the vendored Binaryen comes from a git submodule (git submodule update --init vendor/binaryen).

A first unit

global Counter: mut i32 = 100;

// `pub` is what gets a call builder in the generated Rust -- see below. It is
// orthogonal to `export`, which is what the host may call.
pub func helper(a: i32, b: i32) -> i32 {
  return a * b + 1;
}

export func check as "check";
func check() -> i32 {
  Counter = Counter + helper(6, 7);
  return Counter;
}

export func scale as "scale";
func scale(v: i32) -> i32 {
  return v * 3;
}

reedc-binaryen unit.reed --emit rust -o unit.rs writes a Rust module containing:

  • WASM, the compiled module as a &'static [u8].
  • module(), returning a BinaryenModuleRef with the catalog feature set already applied.
  • merge_into(host), copying the unit's functions, globals, tags, tables, and memories into a module you are building.
  • EXPORTS, each export's Reed-level signature — func(i32) -> i32, in Reed's own type spelling rather than a wasm type index.
  • consts, a module of Rust consts for every param, const, and enum member the unit declared.
  • calls, a call builder per pub function.

The consts module is there because those values exist nowhere else the embedder can reach: expansion resolves a compile-time value away before anything is emitted, so there is no i32.const in the wasm to read back. Yet they are exactly the constants a host has to agree with the module about — a buffer size a param fixed, an enum discriminant a switch dispatches on. The alternative is hardcoding a duplicate that silently drifts.

GC types stay GC types

This is the whole reason the crate exists. Reed's structs and arrays are WebAssembly GC types, and Binaryen represents them natively:

struct Point { x: mut i32, y: mut i32 }

export func check as "check";
func check() -> i32 {
  let p: &Point = new Point { x: 3, y: 4 };
  p->x = p->x + 10;
  return p->x + p->y;
}

That returns 17, and the module Binaryen hands back contains a real struct.new and struct.get — no linear memory, no allocator, no invented object layout. A pub function taking &Point has the wasm signature (ref $Point) -> i32, which is what a caller must actually push.

Optimizing

reedc-binaryen exposes Binaryen's optimizer with wasm-opt's own spellings:

reedc-binaryen unit.reed -O2 -o unit.wasm # -O0 (default), -O1, -O2, -O3, -Os, -Oz
reedc-binaryen unit.reed --pass dce --pass merge-blocks -o unit.wasm
reedc-binaryen unit.reed --list-passes # every pass --pass accepts

The default is -O0, so what you get is exactly what the compiler emitted. That matters because reedc's output is annotated (spec section 15) and worth reading; an optimizer pass is a deliberate choice, not something applied behind your back.

An unknown --pass name is refused before compilation starts. That is not politeness: Binaryen's own pass registry calls Fatal() on a name it does not know, which prints and exits the process.

Merging into your own module

use reed_binaryen_sys as bn;

mod unit; // generated by `reedc-binaryen --emit rust`

fn build() -> bn::BinaryenModuleRef {
unsafe {
let host = bn::BinaryenModuleCreate();
bn::BinaryenModuleSetFeatures(host, bn::catalog_features());

assert!(unit::merge_into(host), "a name collided with the host's own");

// Call a `pub` function from hand-written Binaryen IR.
let six = bn::BinaryenConst(host, bn::BinaryenLiteralInt32(6));
let seven = bn::BinaryenConst(host, bn::BinaryenLiteralInt32(7));
let call = unit::calls::helper(host, [six, seven]);
let body = bn::BinaryenReturn(host, call);

let name = std::ffi::CString::new("driver").unwrap();
bn::BinaryenAddFunction(
host,
name.as_ptr(),
bn::BinaryenTypeNone(),
bn::BinaryenTypeInt32(),
std::ptr::null_mut(),
0,
body,
);
host
}
}

Two things about merge_into are worth knowing:

It does not republish the unit's exports. An embedder merging Reed into a larger module usually wants to call those functions, not to publish them to the host — and republishing would collide with the host's own export names. EXPORTS lists them so you can re-export whichever you want with BinaryenAddFunctionExport.

A name collision is refused, not attempted. Binaryen's addFunction calls Fatal() on a duplicate name, so a Reed unit that happened to share one function name with its host would kill your process. The merge checks every name first and returns false without touching the destination. From Rust, Unit::merge_into reports the colliding names, and Module::rename_function renames one along with every reference to it.

Why no index remapping

Binaryen's IR references every entity by name. A merge therefore copies items and is done; there is nothing to renumber, and no way for a missed reference to silently point at the wrong function.

That is worth stating explicitly because the crate this one replaced could not do it. waffle indexes entities densely, so merging meant rewriting every entity reference in every function body — 328 lines of it, plus a test that re-derived waffle's entity-carrying operator set from its own source, because a missed remap still validates and just calls the wrong thing.

One consequence to be aware of: names are load-bearing, so the encoder keeps the name section. Binaryen omits it by default, and without it $helper becomes function index 3 and every name-based lookup afterwards finds nothing — with no error anywhere.

Reading a module back

reedc-binaryen unit.reed --emit wat # Binaryen's rendering
reedc-binaryen unit.reed --check # the unit's surface, writing nothing

--emit wat gives Binaryen's text, not reedc's. The comments that make reedc's output readable are not in the binary and cannot come back; use reedc build for those.

--check reports what the unit contains: its exports, its pub functions with Reed-level signatures, and its compile-time values. It does not report whether the unit is representable — there is nothing to report, because Binaryen accepts everything reedc emits.

pub is the key, not export

A call builder is generated for each pub function, not for each export. The two answer different questions:

  • export is what the host may call from outside the finished module.
  • pub is what the author marked as their surface for other code.

An embedder splicing a unit into a larger module wants the second, and those functions usually are not exported — exporting them would publish them to the host too.

One kind of pub function gets no call builder, and the generated file says so rather than leaving a gap: an imported function, whose body belongs to the host and which is reachable through the import rather than through this unit.

@inline functions get a builder here

Under reedc build, an @inline function is elided: every call is replaced by its body, and the definition is not emitted at all (spec 5.1). That leaves no call target, so a pub @inline function could not have a call builder — it was reported as skipped instead, which meant a function the author had explicitly marked as their surface was unreachable from Rust.

This backend emits the definition as well, and attaches Binaryen's own always-inline hint (binaryen.inline) to it. So:

  • the builder exists, because there is a real function to call;
  • the inlining guarantee is intact, because reedc has already replaced every call inside the unit, and Binaryen's inliner is told to inline the remaining ones — including a call an embedder makes through the generated builder.

The hint is honoured when the inlining pass runs: -O2 and above, or an explicit run_passes(&["inlining-optimizing"]). At the default OptLevel::None no pass runs at all, so the call stays a call — which is the right behaviour for a module the caller asked not to optimize.

Two details worth knowing if you are reading Binaryen's source alongside this:

  • Binaryen has two inline hints and its optimizer reads only one. metadata.code.inline (the Compilation Hints proposal) is a hint for VMs; binaryen.inline is the one passes/Inlining.cpp consults, and it honours "always inline" before any size check. Setting the standard-looking one instead encodes a perfectly valid custom section and changes nothing about what the optimizer does.
  • Nothing about reedc build's output changed. The WAT the compiler writes still elides these functions; only this backend opts into emitting them, because only here does "unreachable within this module" differ from "unreachable".

Compile-time parameters from Rust

A param can be set when the unit is compiled:

let mut defines = std::collections::HashMap::new();
defines.insert("BufferSize".to_string(), "4096".to_string());

let unit = reedc_binaryen::compile_str_with(
source,
&reedc_binaryen::Options {
compiler: reedc_core::Options { defines, ..Default::default() },
..Default::default()
},
)?;

The value is resolved at compile time, so it is baked into the module — changing it means compiling again. Unit::comptime_values reports what each one ended up as, so a host can agree with the module rather than duplicating the number.

What is not supported

Very little, and none of it is about GC:

  • --tail-calls works, unlike under the waffle bridge: Binaryen has return_call.
  • Every fixture in reedc's conformance suite translates, is optimized at -O2, and returns an identical result afterwards (327 of 327, tests/check_binaryen_corpus.py).
  • The playground cannot reach any of this. reedc-binaryen links a native library, and the playground is a wasm32 build with no library to link.