Chapter 22

Talking to C

Goose compiles to C, so calling C is not a foreign-function interface in the usual sense. A Goose call to a C function compiles to a plain C call, with no marshalling and no wrapper. This chapter shows the three ways to reach C code, which Goose values can cross the boundary, and how to go the other way and call Goose from a C program.

#extern fn22.1

A function declared with extern fn and no body is a C function. The Goose signature describes it. If the C name differs from the Goose name, give it after an =:

extern fn cube_root(x: f64) -> f64 = "cbrt";
extern fn hypot(x: f64, y: f64) -> f64;

fn main() {
    print(cube_root(27.0), " ", hypot(3.0, 4.0));
}
3.0000000000000004 5.0

Both functions come from the C maths library, which every Goose program links. The standard library's math and os modules are written this way.

#What can cross22.2

Only values with a plain C shape can be passed to or returned from C:

Goose C i32, f64, bool, u8 ... int32_t, double, bool, uint8_t ... Stats (a flat struct) a packed struct, by value Stats& Stats * const u8[:] struct { const uint8_t *data; int64_t len; } u8[>..]& a builder C appends to anything else, such as optionals or variable-size values, is rejected at the declaration
Goose types and the C shapes they cross as. Anything without a plain C shape is rejected where the extern fn is declared.

Results are a single scalar or flat fixed-size value, or nothing. Anything else is rejected at the declaration, before any call is compiled:

struct Label { text: u8[] }

extern fn show(l: Label);

fn main() {}
error: extern fn show: parameter l of type Label cannot cross to C: only fixed-size values cross by value

Writability is part of the signature too. A parameter declared u8[:] promises C may write through it, so it cannot be given a string literal. Declare it const u8[:] if C only reads:

extern fn shout(s: u8[:]);

fn main() { shout("honk"); }
error: extern fn shout: parameter s of type u8[:] takes a writable value; a read-only one needs the parameter declared const (§9.5)

C must not keep a pointer it was given after the call returns. Goose cannot check what C does, so this is the one place where the safety of a program depends on code the compiler cannot see.

#A program's own C22.3

C code that belongs to your program goes in files listed in a sidecar, main.build beside main.goose. goose run compiles it into a library that the in-process run loads, and goose build links it into the executable. Here is a small C file that summarizes an array of weights and hashes a string:

#include <stdint.h>

typedef struct { const int32_t *data; int64_t len; } i32_slice;
typedef struct { const uint8_t *data; int64_t len; } u8_slice;
typedef struct __attribute__((packed)) {
    int32_t lo, hi;
    double mean;
} Stats;

void weigh_all(i32_slice xs, Stats *out) {
    int64_t sum = 0;
    out->lo = out->hi = xs.data[0];
    for (int64_t i = 0; i < xs.len; i++) {
        if (xs.data[i] < out->lo) out->lo = xs.data[i];
        if (xs.data[i] > out->hi) out->hi = xs.data[i];
        sum += xs.data[i];
    }
    out->mean = (double)sum / (double)xs.len;
}

uint32_t checksum(u8_slice s) {
    uint32_t h = 0;
    for (int64_t i = 0; i < s.len; i++) h = h * 31 + s.data[i];
    return h;
}

The sidecar names the C files. It uses Goose-like syntax:

c_sources = ["impl.c"];

And the Goose side declares the functions:

struct Stats { lo: i32, hi: i32, mean: f64 }

extern fn weigh_all(xs: i32[:], out: Stats&);
extern fn checksum(s: const u8[:]) -> u32;

fn main() {
    let weights: i32[5] = [45, 52, 38, 41, 60];
    var s = Stats { 0, 0, 0.0 };
    weigh_all(weights, s);
    print(s, " ", checksum("goose"));
}
Stats { 38, 60, 47.2 } 98539801

Two details matter. The C struct is declared packed, because Goose structs have no padding (Chapter 10). Here the fields happen to line up anyway, but the attribute makes the C layout match whatever the fields are. And the slice arrives as a two-field struct passed by value.

A sidecar can also list include directories, defines and system libraries, with per-platform variants, and a package can build its C with CMake instead. The official docs/packages.md has the details.

#Shared libraries, bound at startup22.4

An extern "library" { … } block declares functions to be looked up in a shared library when the program starts. No C is compiled at all, so this works even with no C compiler installed:

extern "libm.so.6" {
    fn cbrt(x: f64) -> f64;
    fn root(x: f64) -> f64 = "sqrt";
}

fn main() { print(cbrt(8.0), " ", root(2.0)); }
2.0 1.4142135623730951

A plain name such as "SDL2" is turned into the platform's file name (SDL2.dll, libSDL2.dylib or libSDL2.so), and a dotted name like the one above is used as written. Because the right name often differs between platforms, the sidecar can map names per platform: dylibs_linux = ["SDL2=libSDL2-2.0.so.0"];. A missing library or symbol is reported when the program starts, naming what it tried.

When a library hands back a pointer to memory it owns, such as a const char *, declare it as a u64: a handle that Goose can pass back but not read through. The ffi module's cstr and bytes copy what the pointer points at into Goose storage.

#Calling Goose from C22.5

export fn goes the other way: it makes a Goose function callable from C. Compile the program with --header and the compiler writes a header for a C host program, and C code with no main of its own:

var calls: i32 = 0;

export fn honk_count(times: i32) -> i32 {
    calls += 1;
    return times * 2 + calls;
}

export fn greeting() -> const u8[:] { return "Hellope from Goose" }
$ goose --standalone --header main.h -o main.c main.goose
$ cc main.c host.c -o host -lm -lpthread

The host calls goose_init once, which sets up the runtime, initializes the globals and runs the Goose main if there is one. A const u8[:] result comes back as a pointer, with its length written through an extra argument. The bytes are not NUL-terminated.

#include <stdio.h>
#include "main.h"

int main(int argc, char **argv) {
    goose_init(argc, argv);
    int64_t len;
    const uint8_t *msg = greeting(&len);
    fwrite(msg, 1, (size_t)len, stdout);
    int first = honk_count(3);
    int second = honk_count(3);
    printf("\n%d %d\n", first, second);
    return 0;
}
Hellope from Goose
7 8

--standalone puts the runtime inside the generated C file, so the C compiler needs nothing else. Exported functions must be called from the thread that called goose_init.

#Exercises22.6

  1. Declare extern fn floor(x: f64) -> f64; and extern fn fmod(x: f64, y: f64) -> f64; and compare fmod(-7.0, 3.0) with Goose's own -7.0 % 3.0 and -7 % 3.

  2. Write a C function void count_vowels(u8_slice s, int64_t *out) in a sidecar file, and call it from Goose on a few words.

  3. Why can C not be given a Goose? or a u8[] directly? What would you pass instead?