Skip to content

C and C++ Interop

NativeBrowserNode8 min read

Dream calls C and C++ directly on native and wasm32 builds, including Dream Task workers. A package puts portable C or C++ sources in native/, then writes a thin Dream wrapper: one declaration per native function or method, plus whatever idiomatic Dream API it wants on top. Consumers just import the package.

At a glance

@c("m", "cbrt")
extern fun cube_root(x: double): double;
  • Declare C functions with @c and call them like Dream functions.
  • Package C sources in native/; they build for native and WebAssembly targets.
  • C is bound with @c externs.
  • C++ is bound with @cpp declarations. The compiler generates a small C++ shim that implements them, and the C++ compiler checks each declaration against the real header.
  • The compiler never parses headers. Declarations are ordinary Dream source, so completion, hover, and go-to-definition work on them.
  • Native builds can link package sources or host system libraries. WASM builds compile portable package sources into the same guest module; WASI libc/libm bindings (@c("c", ...) / @c("m", ...)) also work. Host libraries such as system libsqlite3 cannot be linked into it. Unused bindings are pruned before checking target availability.

For native builds, Zig (installed by dreamer toolchain install cc) compiles the C/C++ and links it with the Dream object. DREAM_CXX / CXX override the native C++ compiler. WASM package builds use the pinned clang.

Runnable samples: sample/native_c/ and sample/native_cpp/.

Explore this topic

Package layout

ticker/
├── dream.toml            # [package] name = "ticker"
├── src/ticker.dream      # the thin wrapper
└── native/
    ├── include/ticker.h  # on the include path
    ├── ticker.c
    └── vendor/lz4.c      # mixed C and C++ is fine

Everything under native/ compiles as one native set named after the package (kv-store → kv_store). native/include/ is on the include path. .c files compile as gnu11, and .cpp / .cc / .cxx files as gnu++20. libc++ is linked only when the set contains C++.

A set is built only when the program calls into it. Native objects live in target/<profile>/native-c/<set>/ and are rebuilt when a source is newer. WASM package bitcode is rebuilt beside the module in its .wasm-c/ directory.

dream.toml

A [native.<set>] table is optional. It adds build settings to the implicit set, or declares an extra set with explicit sources:

[package]
name = "kvstore"
links = "kv"                  # optional: at most one package in the graph may provide "kv"

[native.kvstore]
cflags  = ["-fno-exceptions"]
defines = ["KV_THREADSAFE=1"]

[native.kvstore.macos]
frameworks = ["Security"]

[native.kvstore.linux]
libs = ["dl"]
Key Meaning
sources Extra source files, relative to the package root
include Extra include directories
defines -D definitions
cflags Extra compiler flags
frameworks macOS frameworks to link
libs System libraries to link (-l)

Target subtables (macos, linux, windows, wasm) take the same keys and are appended for the build target. A WASM build never inherits the host OS overlay. A set with no sources for the selected target, or a source that does not exist, is an error. So are an unknown key, two packages declaring the same set name, and two packages declaring the same [package] links value. Each of these errors names the manifests involved.

WebAssembly

The existing native/ directory and @c / @cpp declarations also work for portable WASM packages. Add guest-only flags with [native.<set>.wasm]. Use usize / isize for C size_t / ptrdiff_t; long in Dream is always 64 bits. Scalar signature mismatches between a binding and its compiled package implementation produce a build diagnostic.

Portable package sources become part of your WebAssembly program. Dream can optimize calls between your code and the package, though a particular speed improvement is not guaranteed. A prebuilt desktop library cannot be used this way.

C++ uses the WASI SDK 33 exception-enabled libc++, libc++abi and libunwind. Generated @cpp shims preserve exception-to-Result conversion. Global constructors run after Dream heap initialization; exit hooks run registered destructors and flush stdio after main completes. The JS loader supports output during constructors in both full and selective runtimes.

C allocation uses the same linear memory and heap as Dream, with C-compatible alignment. Pointers and callbacks remain within that module. Stored callbacks must be invoked and released on the instance that created them. Owned pointers, UTF-8 strings, array data, unmanaged structs, and callbacks use the same bindings as native builds.

Supported libc services include stdout/stderr, realtime/monotonic clocks and an empty process environment. OS-specific functions or unresolved library symbols fail during the build, instead of becoming broken host imports. Use @js for browser/Node platform services. In Task / shared-memory modules, each instance gets initialized, aligned C thread-local storage. A worker pool retains its thread-local values between dispatches. Global constructors run once after the primary instance's TLS is ready. Workers come from Dream's Task runtime; WASI pthread_create is not supplied. Use @native, @node or @web to restrict a binding to specific runtimes.

Development installs need the full WASI sysroot next to the installed build tools; update it with scripts/fetch-dev-llvm.sh or scripts/fetch-dev-llvm.ps1 on Windows. Release packaging includes clang, its resource headers, and the wasm32 WASI headers/libraries needed by package builds.

C: @c

import system.interop;

module ticker;

import system;

@c
extern fun ticker_new(name: string): CPtr;

@c
extern fun ticker_run(t: CPtr, ticks: int): int;

@c("m", "cbrt")
extern fun cube_root(x: double): double;
  • Bare @c binds to the declaring package's own native/ set. The Dream name is the C symbol.
  • @c("lib") names another library, and @c("lib", "symbol") also renames the symbol. A library that is not a native set is linked from the system (-l<lib>); see Linking system libraries.
  • @c cannot be combined with @js, @runtime, or @intrinsic.

Types at the boundary

Dream C
int, long, float, double int32_t, int64_t, float, double
isize, usize intptr_t, uintptr_t (usize also matches size_t)
bool, char, byte bool, char, uint8_t
string parameter const char*: NUL-terminated UTF-8, valid for the call
string result const char*, copied into a Dream string; C keeps ownership
CPtr void* / T*
Option<string>, Option<CPtr> a pointer where NULL is None
T[] (numbers, byte, plain-data structs) T* to the first element, valid for the call
plain-data value struct (see Structs) the C struct by value, or T* as ref x: T
ref x: T T*: C writes through it (ref p: CPtr is T**)
fun(...) a C function pointer (see Callbacks)
NativeCallback<F> a (fn, void* user_data) pair

By-value char converts to C’s narrow char, including fields of structs passed by value. Pointers and ref arguments expose Dream storage: a char occupies four bytes, so use int32_t* for a character slot and byte[] for a raw C character buffer. | Option<fun(...)>, Option<NativeCallback<F>> | a nullable function pointer | | OwnedCPtr result with @owned("free_fn") | void* that Dream frees with free_fn |

Every @c call goes through a small C shim the compiler generates and compiles with the pinned clang, so the platform C ABI (struct passing and return, bool/char widths, calling conventions) is clang's, not a hand-written copy.

Classes, unions, and arrays of managed elements are rejected in @c signatures. They are heap references that C does not understand. Wrap them in a Dream function that passes C what it needs.

A NULL returned where the declaration says string traps with a message naming the function. Declare the result as Option<string> when C may return NULL.

Arrays pass only the pointer. When C needs the length, declare it as a separate parameter and pass xs.length:

import system.interop;

@c
extern fun checksum(data: byte[], len: long): int;

public fun fingerprint(data: byte[]): int {
    return checksum(data, data.length);
}

CPtr

CPtr (in system) is an opaque C pointer. It has CPtr.null(), is_null(), ==, address(): usize, and offset(bytes: isize). Its single address field is target-sized. Use it for handles, and use ref for C's out-parameters:

import system.interop;

@c("sqlite3")
extern fun sqlite3_open(path: string, ref db: CPtr): int;

let db = CPtr.null();
if sqlite3_open("app.db", ref db) != 0 {
    ...
}

Ffi.read_ptr, Ffi.read_int, Ffi.read_long, Ffi.read_double, and Ffi.read_cstring read through pointers C hands to Dream (char** rows and similar).

Owned pointers

When C hands back a pointer the caller must free, return OwnedCPtr and name the C function that frees it with @owned("free_fn"). The object calls free_fn(ptr) when its last reference goes away. get() reads the pointer while the object keeps owning it; take() hands ownership back to you and leaves the object holding NULL.

import system.interop;

@c
@owned("ticker_free")
extern fun ticker_new(name: string): OwnedCPtr;

@c
extern fun ticker_run(t: CPtr, ticks: int): int;

let t = ticker_new("demo");
ticker_run(t.get(), 3); // ticker_free runs when `t` goes out of scope

free_fn must be a C identifier with the signature void free_fn(void*). A @c extern returning OwnedCPtr without @owned("free_fn"), or @owned("free_fn") on any other result, is an error.

Structs

A value struct whose fields are numbers, bool/char/byte, or other such structs maps to the C struct with the same fields in the same order. Pass it by value, return it by value, or pass it by ref, which C sees as T*. Mark structs @packed when the C header uses #pragma pack(1) / __attribute__((packed)). Otherwise fields are naturally aligned, as in C.

import system.interop;

public struct Vec2 {
    public x: double;
    public y: double;
}

@c
extern fun vec2_scale(v: Vec2, k: double): Vec2; // C: Vec2 vec2_scale(Vec2 v, double k)

@c
extern fun vec2_len(ref v: Vec2): double; // C: double vec2_len(const Vec2* v)

@c
extern fun vec2_origin(ref out: Vec2): void;

Strings

string parameters are copied into a NUL-terminated UTF-8 buffer that is freed after the call. @marshal(MarshalKind.LpWStr) passes UTF-16 instead (Windows LPWSTR):

import system.interop;

@c("user32", "MessageBoxW")
@marshal(MarshalKind.LpWStr)
extern fun MessageBoxW(hwnd: CPtr, text: string, caption: string, flags: int): int;

Externs use the platform C calling convention. @c_call(CCallConv.Cdecl) spells that out explicitly. @c_call(CCallConv.Stdcall) selects __stdcall on 32-bit x86 Windows and is the default convention everywhere else. Any other convention is a compile error.

Packages

  • Packages under dream_packages/ bring their own native/ sets. A consumer only imports the package; its sets are built and linked when the program calls into them.
  • Shim symbols are namespaced by native set, so two packages can each bind a C++ class called Store. Dream class names are program-wide, so give the Dream classes distinct names (@cpp("alpha.hpp", "alpha::Store") public class AlphaStore).
  • [package] links = "sqlite3" declares that a package provides a native library, and at most one package in the dependency graph may do so.
  • dreamer pack produces a single executable. Native sets are linked in statically.

Linking system libraries

On native targets, @c("lib", ...) with a library that is not a native set links -l<lib>. Search order:

  1. native/<lib> next to the source (vendored shared libraries).
  2. The directory containing the source, then the current working directory.
  3. Standard system directories:
  4. macOS: /opt/homebrew/lib, /usr/local/lib, /opt/local/lib, /usr/lib
  5. Linux: /usr/local/lib, /usr/lib/x86_64-linux-gnu, /usr/lib/aarch64-linux-gnu, /usr/lib64, /usr/lib, /lib
  6. Windows: %WINDIR%\System32
  7. The OS loader's own path (DYLD_FALLBACK_LIBRARY_PATH / LD_LIBRARY_PATH).

sample/sqlite/ binds the system libsqlite3 this way:

dream run sample/sqlite/db.dream

Compared to @js and @runtime

Concern @js("mod", "field") @runtime("name") @c / @cpp
Host JS (Node / browser) Dream runtime (WASM + native) Native and portable wasm32 package builds
Async async + Promise bridge async + host future Not supported; C runs on the caller's thread
Out-params Return a wrapper struct / tuple Return a wrapper struct / tuple ref parameters
Callbacks into Dream fun(...) values host-defined fun pointers, NativeCallback, std::function
Null Option Option Option