C Interop / FFI
The reason all seven languages live in the same family is the C ABI: a stable, OS-level contract for how functions pass arguments, return values, and lay out structs. Because every one of them can speak it, they can all call straight into libc (and any C library) with no marshalling, no runtime bridge, and no copies. The task is identical everywhere - call C's puts, plus a libc function that allocates (strdup) - and the deep lesson is memory ownership across the boundary: a pointer that crosses the FFI line carries no lifetime with it, so when C mallocs the result of strdup, you must call C's free on it, never your own language's allocator.
#include <stdio.h> /* puts */
#include <string.h> /* strdup (POSIX) */
#include <stdlib.h> /* free */
int main(void) {
/* Native: no "FFI" at all -- this IS C, calling libc directly. */
puts("Hello from libc"); /* writes the string + a newline */
/* strdup malloc()s a copy and returns ownership to the caller. */
char *dup = strdup("owned by C"); /* heap copy via C's malloc */
if (dup == NULL) return 1; /* allocation can fail */
puts(dup); /* use it... then release it */
free(dup); /* the SAME malloc/free heap */
return 0;
}
/* build: cc main.c -o app */For C there is no boundary to cross - puts and strdup are the C ABI. The point worth stressing is the ownership rule the other six must obey: strdup returns a char* that it malloced, so the matching free must come from the same C heap. Every language below ultimately reproduces this exact strdup/free pairing.
#include <cstdio> // std::puts
#include <cstring> // ::strdup (POSIX)
#include <cstdlib> // std::free
#include <memory> // std::unique_ptr
// extern "C" turns OFF C++ name-mangling so the linker finds the C symbol.
extern "C" char *strdup(const char *);
int main() {
std::puts("Hello from libc"); // C function, C linkage
// Wrap the C-owned pointer in a unique_ptr whose deleter is C's free,
// so RAII releases it on the *C heap* -- never with delete.
std::unique_ptr<char, decltype(&std::free)> dup{
::strdup("owned by C"), std::free};
if (!dup) return 1;
std::puts(dup.get()); // pass the raw char* back to C
return 0; // ~unique_ptr calls std::free(dup)
}
// build: c++ -std=c++20 main.cpp -o app
C++ already shares C's ABI, but its compiler mangles names; extern "C" declares C linkage so the C symbol resolves. The memory lesson is sharp here: you must not delete a C malloced pointer, so the idiom is a std::unique_ptr<char, decltype(&std::free)> whose custom deleter is std::free - RAII then returns the block to C's heap automatically and exactly once.
// HolyC IS C semantically, but TempleOS has NO libc and NO linker --
// there is no external `puts`/`strdup` to call. The closest idiom is
// HolyC's own ABI-compatible builtins on the per-task heap.
U8 *StrNew2(U8 *s) { // hand-rolled strdup using TempleOS heap
I64 n = StrLen(s) + 1;
U8 *dup = MAlloc(n); // from THIS task's data heap
MemCpy(dup, s, n); // copy bytes incl. the NUL terminator
return dup; // caller owns it -> must Free
}
Print("Hello from libc\n"); // "libc" here is the TempleOS runtime
U8 *dup = StrNew2("owned by C");
Print("%s\n", dup);
Free(dup); // one MAlloc, one Free, same task heap
HolyC compiles to the same machine model as C, but TempleOS is a single-binary, ring-0 OS with no C library and no dynamic linking, so there is nothing external to FFI into - calls resolve against the JIT'd global namespace instead. The faithful equivalent is to reimplement strdup over MAlloc/Free on the per-task heap; the alloc/free pairing is identical to C's, just without a separate libc heap.
const std = @import("std");
// @cImport runs the C preprocessor at COMPILE TIME and exposes the
// declarations as a normal Zig namespace -- no bindings to hand-write.
const c = @cImport({
@cInclude("stdio.h"); // puts
@cInclude("string.h"); // strdup
@cInclude("stdlib.h"); // free
});
pub fn main() void {
_ = c.puts("Hello from libc"); // C function; returns c_int we ignore
// strdup returns [*c]u8 backed by C's malloc -> free with C's free,
// NOT a Zig allocator. The optional handles the null-on-failure case.
const dup: [*c]u8 = c.strdup("owned by C") orelse return;
defer c.free(dup); // pair the C free right beside the call
_ = c.puts(dup); // pass the C pointer straight back
}
// build: zig build-exe main.zig -lc (-lc links libc)Zig's @cImport invokes the C compiler at comptime and translates the headers into a real Zig namespace, so c.puts/c.strdup are called directly over the C ABI with -lc. Memory discipline is explicit: strdup's result is C-heap memory, so it is released with c.free (not a std.mem.Allocator), and defer c.free(dup) keeps that C free next to the C alloc - Zig never sneaks a hidden allocation into the boundary.
use types::c; // C-compatible types & null-terminated string helpers
// Declare (import) the external C symbols: a bodyless fn with @symbol,
// and NO `export` -- `export` would instead DEFINE the symbol here.
@symbol("puts") fn c_puts(s: *const c::char) int;
@symbol("strdup") fn c_strdup(s: *const c::char) *c::char;
@symbol("free") fn c_free(p: nullable *opaque) void;
export fn main() void = {
// c::nulstr ABORTS unless the literal is NUL-terminated, so the \0
// is required -- a Hare string literal has no implicit trailing NUL.
const hello = c::nulstr("Hello from libc\0"); // borrow a C string
c_puts(hello);
// strdup uses C's malloc, so it must be released with C's free --
// Hare's own free() manages a DIFFERENT heap and would corrupt it.
const dup = c_strdup(c::nulstr("owned by C\0"));
defer c_free(dup); // matching C free
c_puts(dup);
};
// build: hare build -lc main.ha (link libc)Hare imports a C function by declaring a bodyless fn tagged with @symbol("...") so the linker binds to the exact C name - note there is no export (that keyword would make Hare define the symbol instead of borrowing it). types::c supplies ABI-correct types plus c::nulstr, which converts a Hare string to a C string but aborts unless the input is NUL-terminated, so each literal carries an explicit \0 (Hare string literals have no implicit trailing NUL). The critical memory rule: a pointer from strdup lives on C's malloc heap, so it is freed with the C free you declared - calling Hare's built-in free on it would hand foreign memory to the wrong allocator.
package main
import "core:c/libc" // Odin ships ISO-C bindings (puts, malloc, free)
// strdup is POSIX, not ISO C, so it is NOT in core:c/libc -- bind it
// ourselves via foreign import against the system C library.
foreign import libc_sys "system:c"
@(default_calling_convention="c")
foreign libc_sys {
strdup :: proc(s: cstring) -> [^]u8 ---
}
main :: proc() {
libc.puts("Hello from libc") // cstring literal, C ABI, no wrapper
// strdup returns a [^]u8 from C's malloc; release it with libc.free,
// NOT Odin's context.allocator free() -- different heaps entirely.
dup := strdup("owned by C")
defer libc.free(dup) // pair the C free with the C alloc
libc.puts(cstring(dup)) // hand the C pointer back to puts
}
// build: odin run . (Odin links libc by default)Odin speaks the C ABI natively: the cstring type is a NUL-terminated C pointer, and core:c/libc ships prebuilt bindings for ISO C (puts, malloc, free). strdup is POSIX rather than ISO C, so it is not in core:c/libc; you declare it yourself with foreign import "system:c", which is exactly how Odin binds any C library. The memory point is the same trap as everywhere - strdup allocates on C's heap, so it must be freed with libc.free, never with Odin's free/delete (which target context.allocator); defer libc.free(dup) keeps the pairing honest.
\ Classic Forth has no standardized FFI; the closest portable idiom is
\ a system-specific library word set. Below uses gforth's c-library:
c-library libc
\c #include <string.h>
\c #include <stdio.h>
\c #include <stdlib.h>
c-function c-puts puts a -- n \ ( c-addr -- int )
c-function c-strdup strdup a -- a \ ( c-addr -- c-addr )
c-function c-free free a -- \ ( c-addr -- )
end-c-library
S\" Hello from libc\0" drop c-puts drop \ S\" processes \0 -> real NUL
\ strdup mallocs via C: keep the addr, print it, then C-free it.
S\" owned by C\0" drop c-strdup ( -- c-addr )
DUP c-puts drop ( c-addr -- c-addr )
c-free \ release on C's heap, not Forth'sStandard Forth defines no FFI, so this shows the closest real idiom - gforth's c-library/c-function word set, which compiles a tiny C stub to call puts, strdup, and free over the C ABI (each c-function line gives the C name and a stack-effect type signature). Note the S\" (s-backslash-quote) word, not plain S": only S\" interprets the \0 escape, so the buffer actually ends in a NUL byte - puts needs a NUL-terminated C string, and plain S" would store a literal backslash-zero instead. The memory rule survives even here: strdup's address comes from C's malloc, so it is handed to the C c-free word, never to Forth's own ALLOCATE/FREE pool.