The C ABI: the universal handshake
Every systems language can call libc with no bridge and no marshalling because they all speak one frozen contract - the C ABI - and the deepest thing that contract does *not* carry is memory ownership.
There is a language none of these seven were written in that all seven can speak fluently. It has no compiler of its own, no syntax, no source files. It is the C ABI - the Application Binary Interface - and it is the reason a Zig program, a Hare program, an Odin program, and a Forth program can all reach into the same libc that a C program calls, with no wrapper layer, no serialization, and no copies. They are not interoperating through some neutral exchange format. They are all impersonating C.
For a memory-focused site this is the most important fact about the whole family, because the C ABI is fundamentally a set of agreements about where bytes go: which register holds the first argument, how a struct is laid out and padded, who cleans up the stack, what a pointer is. Get those agreements right and two separately-compiled blobs from two different languages run as one program. Get them wrong and you get silent corruption, because the ABI is enforced by nobody - it is a convention frozen into compiled machine code, with no runtime check at the seam. And the one thing the handshake conspicuously refuses to negotiate is the question this entire site is about: when a pointer crosses the boundary, who frees it?
What "the C ABI" actually means
People say "the C ABI" as if it were one document. It is closer to a stack of agreements, most of them defined not by the C standard but by the platform:
- The calling convention. Which arguments go in which registers, which spill to the stack, who pops the stack on return, how the return value comes back, which registers the callee must preserve. On x86-64 Unix this is the System V AMD64 convention (integer args in
rdi, rsi, rdx, rcx, r8, r9, return inrax); on 64-bit Windows it is the Microsoft x64 convention (rcx, rdx, r8, r9). These are OS/architecture documents, not C documents. - Type sizes and alignment - the data model. How wide is
long? On 64-bit Linux/macOS,longis 8 bytes (LP64); on 64-bit Windows it is 4 bytes (LLP64). How is astructpadded? What is the alignment of adouble? - Struct and union layout. Fields in declaration order, with platform-defined padding between them so each field meets its alignment. Two languages must agree on this byte-for-byte or a struct passed across the line is garbage.
- Symbol naming and linkage. How a function's name appears in the object file's symbol table so the linker can match a call site to a definition.
What makes this "the C ABI" rather than "the platform ABI" is historical: C got there first and got there everywhere. Because Unix - and later essentially every OS kernel - exposed its services as C functions, the platform's stable, documented, never-breaking interface became the C calling convention plus C struct layout. C is the ABI not because the C committee designed one, but because the operating system's syscall and library surface is described in C, and everyone else has to call it.
/* The whole C side of the contract, in one declaration.
`puts` lives in libc; its ABI is "SysV AMD64, takes a pointer, returns int."
No source needed -- just agree on the signature and the convention. */
#include <stdio.h> /* puts */
#include <string.h> /* strdup (POSIX) */
#include <stdlib.h> /* free */
int main(void) {
puts("Hello from libc"); /* native: this IS the C ABI */
char *dup = strdup("owned by C"); /* libc malloc()s a copy for us */
if (dup == NULL) return 1; /* allocation can fail */
puts(dup);
free(dup); /* SAME malloc/free heap, by us */
return 0;
}
/* build: cc main.c -o app */
For C there is no "foreign" anything - puts and strdup are the interface. But notice the memory rule hiding in plain sight, because the other six all have to reproduce it: strdup returns a pointer it malloced, and the matching free must come from the same C heap. Hold that thought. It is the moral of every example below.
C++: a near-superset that has to un-mangle itself
C++ shares C's machine model and, with a small ceremony, C's ABI. The friction is name mangling. To support overloading, namespaces, and templates, a C++ compiler encodes a function's full signature into its linker symbol - foo(int) and foo(double) become two different mangled names. A C function named puts has the plain symbol puts, so a naive C++ declaration would look for a mangled name that does not exist.
extern "C" is the fix: it tells the compiler to give a declaration C linkage - no mangling, C calling convention - so the symbol resolves against the real C library. This single keyword is what makes C++ the most seamless C consumer of the bunch, and it is why nearly every C header on the planet wraps its declarations in #ifdef __cplusplus / extern "C" { ... } so it can be #included from both languages.
#include <cstdio> // std::puts
#include <cstring> // ::strdup (POSIX)
#include <cstdlib> // std::free
#include <memory> // std::unique_ptr
// extern "C" turns OFF 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 so RAII releases it on the *C heap*.
// The custom deleter is std::free -- using `delete` here is undefined.
std::unique_ptr<char, decltype(&std::free)> dup{
::strdup("owned by C"), std::free};
if (!dup) return 1;
std::puts(dup.get()); // hand the raw char* back to C
return 0; // ~unique_ptr calls std::free(dup)
}
// build: c++ -std=c++20 main.cpp -o app
The memory lesson is razor-sharp in C++ precisely because C++ has its own allocator (new/delete). You must not delete a pointer that C malloced - new/delete and malloc/free are potentially different heaps, and mixing them is undefined behavior. The idiomatic move is a unique_ptr whose deleter is std::free, so RAII returns the block to C's heap, exactly once, automatically. The ABI let the pointer cross; the ownership did not, so you re-establish it by hand.
Zig: the headers become a namespace at comptime
Zig's flagship feature is its C interop, and it attacks the boundary from two directions. To call C, @cImport runs the C compiler at compile time, parses the headers you @cInclude, and translates the declarations into an ordinary Zig namespace - no bindings to hand-write, no .h-to-Zig step you maintain. To expose Zig to C, you mark functions with the C calling convention and export them.
const std = @import("std");
// @cImport runs the C preprocessor/translator at COMPILE TIME and exposes
// the C declarations as a normal Zig namespace -- no hand-written bindings.
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 discard
// strdup returns [*c]u8 backed by C's malloc -> free with C's free,
// NOT a Zig allocator. `orelse` 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 next to the call
_ = c.puts(dup); // pass the C pointer straight back
}
// build: zig build-exe main.zig -lc (-lc links libc)
To go the other way and hand a function to C, Zig uses an explicit calling convention plus export:
// Callable from C as `int zig_add(int, int);`
// `callconv(.c)` selects the C ABI; `export` gives it an unmangled symbol.
// (`export` already implies the C convention, so callconv is shown for clarity.)
export fn zig_add(a: c_int, b: c_int) callconv(.c) c_int {
return a + b;
}
Zig's discipline around the boundary is the whole point of Zig: allocation is explicit everywhere, and the FFI line is no exception. strdup's result is C-heap memory, so it is freed with c.free, never a std.mem.Allocator. Zig will not slip a hidden allocation or a hidden free into the handshake - the c.* types ([*c]u8, c_int) exist specifically to make "this came from C, treat it with C's rules" visible in the type itself. (Recent Zig is migrating @cImport toward the translate-c build step, but the underlying contract - callconv(.c), export, extern, and C-ABI types - is unchanged.)
Hare: bind the bare symbol, borrow the convention
Hare is the most deliberately C-like of the modern set: a tiny runtime, a single global heap, manual alloc/free, no GC. Its FFI matches that minimalism. You declare a C function with @symbol("...") to bind the exact symbol name the linker should resolve, and the types::c module supplies ABI-correct C types and helpers like c::nulstr to borrow a NUL-terminated string.
use types::c; // C-compatible types & null-terminated string helpers
// Declare the C symbols by their exact linker name.
export @symbol("puts") fn c_puts(s: *const c::char) int;
export @symbol("strdup") fn c_strdup(s: *const c::char) *c::char;
export @symbol("free") fn c_free(p: nullable *opaque) void;
export fn main() void = {
// c::nulstr borrows the literal as a C string and ABORTS unless the
// literal is already NUL-terminated -- hence the explicit \0.
const hello = c::nulstr("Hello from libc\0");
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's memory story makes the cross-boundary trap unusually concrete. Hare has its own alloc/free managing its own heap. A pointer from strdup lives on C's malloc heap, a separate pool. Hand that foreign pointer to Hare's built-in free and you are returning memory to an allocator that never gave it out - heap corruption, the quiet kind that shows up three functions later. So the c_free you bound by symbol name is the only correct release. The ABI made the call possible; the ownership convention is something you carry in your head and encode with defer.
Odin: the C ABI is the native tongue
Odin treats C interop as a first-class, everyday thing rather than a special mode. Its cstring type is a NUL-terminated C char *; the standard library ships ready-made bindings in core:c/libc; and to bind any other C library you write a foreign import block that names the library and declares its procedures, with --- marking each as an external definition. The name you give the import is only a link target - the declared procedures are ordinary package-level identifiers, called by their bare names.
package main
// Odin ships ready-made libc bindings in `core:c/libc` (puts, free, ...),
// but strdup is POSIX, not in core, so here we bind the symbols ourselves.
// The name after `foreign import` is just a link target, NOT a namespace:
// procedures declared in the block are called by their bare names.
foreign import libc "system:c"
@(default_calling_convention="c")
foreign libc {
puts :: proc(s: cstring) -> i32 ---
strdup :: proc(s: cstring) -> cstring --- // C's char * == Odin cstring
free :: proc(p: rawptr) ---
}
main :: proc() {
puts("Hello from libc") // cstring literal, C ABI, no wrapper
// strdup's result comes from C's malloc; release it with this free,
// NOT Odin's context.allocator free() -- different heaps entirely.
dup := strdup("owned by C")
defer free(rawptr(dup)) // cstring coerces to rawptr for free
puts(dup) // hand the C pointer back to puts
}
// build: odin run . (Odin links libc by default)
Odin's memory model leans on an implicit context.allocator threaded through every procedure - new, make, free, and delete all target it. That is exactly why the FFI rule needs stating: strdup does not allocate through context.allocator, it allocates through C's malloc, so it must be freed with the C free bound in the foreign block, never Odin's free/delete. The foreign import block is Odin's spelling of the same agreement everyone signs: these symbols obey the C ABI (default_calling_convention="c"), and the bytes they hand back obey C's ownership rules.
Forth: no standard FFI, the same boundary anyway
Forth predates C and standardizes no FFI at all - its memory model is a bump allocator (HERE/ALLOT) with dynamic ALLOCATE/FREE as an optional add-on. So calling C is necessarily a system-specific extension. The most common real-world idiom, gforth's c-library word set, compiles a tiny C stub that bridges Forth's data stack to the C calling convention; each c-function line gives the C name and a stack-effect signature so the bridge knows how to pass arguments and collect the return.
\ Standard Forth defines no FFI; this is gforth's c-library word set,
\ which compiles a small C stub to call libc over the C ABI.
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\" (not plain S") processes the \0 escape into a real NUL terminator,
\ which the C functions require; plain S" would store a literal backslash-0.
S\" Hello from libc\0" drop c-puts drop \ pass a NUL-terminated addr
\ 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's
Even at this far edge of the family the rule holds: strdup's address comes from C's malloc, so it goes to the C c-free word, never to Forth's own FREE. Forth has its own dynamic-memory pool entirely separate from libc's, and the two must never be crossed. The ABI is the only thing the two languages share; ownership is, once again, manual and yours.
HolyC: same machine model, no library to call
HolyC, Terry A. Davis's dialect and the native language of his single-developer operating system TempleOS, compiles to the same machine model as C - the same flat bytes, the same pointers, the same calling-convention shape. But it is the one language here with nothing to FFI into, and the reason is illuminating. TempleOS is a single-binary, ring-0 system with no C library and no dynamic linker: there is no separate libc living in another module, no symbol table to resolve against at link time. HolyC's JIT compiles every function into one shared global namespace, so a "call to puts" and a "call to your own function" are the same mechanism. There is no boundary because there is no second party.
The faithful equivalent of the strdup/free dance is therefore to build it yourself on TempleOS's per-task heap. We write c here because HolyC is C-shaped; the difference is MAlloc/Free (TempleOS intrinsics) where C says malloc/free:
// HolyC IS C semantically, but TempleOS has NO libc and NO linker --
// there is no external puts/strdup to FFI into. The closest idiom is
// HolyC's own ABI-compatible builtins on the per-task heap.
// (TempleOS already ships StrNew(), which is exactly this; we spell it
// out as StrNew2 to show the alloc/copy/free mechanics explicitly.)
U8 *StrNew2(U8 *s) { // hand-rolled strdup on the TempleOS heap
I64 n = StrLen(s) + 1;
U8 *dup = MAlloc(n); // from THIS task's data heap (uninitialized;
// CAlloc is the zeroing variant)
MemCpy(dup, s, n); // copy bytes including 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
The ownership rule survives the absence of a boundary: one MAlloc, one Free, on the same per-task heap. (TempleOS also reclaims a task's entire heap when the task dies, a coarse automatic arena - but that is a separate story.) HolyC is the limiting case that proves the rule: when there is no foreign code, there is no ABI to speak, yet the same flat-memory model and the same manual alloc/free discipline are still exactly what you program against.
Why the handshake refuses to carry ownership
Step back and the pattern across all seven is striking. The C ABI is generous about mechanism - registers, struct layout, calling convention, symbol names - and that generosity is what lets seven unrelated compilers produce code that runs together. But it is silent about lifetime. A char * crossing the boundary is sixty-four bits of address and nothing else. It does not say which heap it came from. It does not say who is responsible for it. It does not say when it dies. That information is precisely what C pointers never carried in the first place - and the FFI faithfully transmits a C pointer's total ignorance along with its address.
That is why every example above ends with the same ceremony: the language that receives the pointer must reach back across the boundary to free it with the originating allocator. C++ wraps it in a unique_ptr<char, decltype(&std::free)>. Zig and Hare and Odin and Forth all keep a defer-style C free next to the C call. Each of these languages has its own allocator - Zig's explicit Allocator, Hare's global heap, Odin's context.allocator, Forth's ALLOCATE pool - and the cardinal sin is freeing C's memory with it. The ABI gave you the pointer; it did not transfer the deed.
So the C ABI is the universal handshake in the most literal sense: it is the part of the greeting two strangers can agree on instantly - how to grip, how hard, how long - while everything that requires trust over time, namely who owns what until when, stays outside the handshake and must be arranged by other means. That division is the entire reason this family of languages exists. They all kept C's transparent, predictable machine model so they could keep speaking the ABI; and then each one built its own answer - RAII, explicit allocators, context, slices, arenas - to the question the ABI deliberately leaves open. The handshake ties the ecosystem together. The ownership story is what each language brings to it.