C+
Systems · View as Markdown

FFI: calling C

C+ emits standard object files. The system linker stitches them with anything clang would. The language-level interop primitive is extern fn.

Declaring symbols

extern fn malloc(n: usize) -> *u8;
extern fn free(p: *u8);
extern fn printf(fmt: *u8, ...) -> i32;   // varargs OK on extern only

C-string literals: c"..."

C wants a NUL-terminated char*. A c"..." literal is exactly that: a bare *u8 pointing at a NUL-terminated .rodata blob.

extern fn printf(fmt: *u8, ...) -> i32;

fn main() -> i32 {
    printf(c"hello, %d\n", 42 as i32);   // c"..." is a *u8
    let banner: *u8 = c"=== ready ===\n";
    printf(banner);
    return 0;
}

A c"..." is a *u8 (not the fat-pointer str); forming it is unremarkable, since it is just a pointer to static data. The risky step is dereferencing a raw pointer, and that step is self-flagging: a *p deref is visible in the source on its own. The NUL is appended for you. For an owned, length-carrying string use Text or str; c"..." is specifically the C-interop shape.

Raw pointers

*T is an 8-byte opaque address. It is Copy. Every operation on it is self-flagging — there is no wrapper block, because each risky form is already visible in the source:

let p: *u8 = malloc(64 as usize);
p[0] = 65 as u8;                  // store
let b: u8 = p[1];                 // load
let q: *u8 = p + 1;               // pointer arithmetic (strides by sizeof(T))
free(p);

Raw pointers are outside the borrow checker, by design. The compiler tracks nothing about a *T's lifetime: you can return one, store it in a global, or alias it freely. That is the escape hatch that makes FFI possible, and the flip side is that the validity obligation is entirely yours. A pointer into a value that has since dropped is a use-after-free the language will not catch. The *p deref you write — visible on its own line, with no wrapper hiding it — is exactly where you acknowledge taking on that obligation (see Ownership, "what the compiler checks, and what it trusts").

Raw pointers also have a few blessed helper methods:

if p.is_null() { return 1; }
if p.is_not_null() { p.write_zeroed(); }

is_null() / is_not_null() are plain bit-pattern checks. write_zeroed() carries the validity obligation because it writes through the pointer.

No wrapper block — every UB-capable op is self-flagging

There is no unsafe block and no unsafe fn. Every operation that can cause undefined behaviour is already visible in the syntax, so it needs no enclosing marker: a pointer dereference or index is *p / p[i] (the only meaning of *), making a pointer is x as *T, a pointer-to-integer cast is the loud #addr(p) intrinsic, and a foreign call cannot appear without a preceding extern fn declaration. The act of writing the operation is the marker.

The word null never appears. At an FFI boundary, a null pointer is written explicitly:

let p: *u8 = 0 as *u8;

#[repr(C)]: stable C layout

#[repr(C)]
struct NSRect {
    origin: NSPoint,
    size: NSSize,
}

Promises field order is preserved and that padding and alignment match the platform C ABI. Always use it on structs that cross an extern fn boundary by value.

For a concrete cross-language proof, see C ABI consumer. It builds a C+ library, generates a C header, links it from a C program, and exercises scalar, aggregate, enum, raw pointer, and function-pointer ABI classes.

#[link_name = "..."]: multiple signatures, one symbol

When one C symbol has several typed shapes (the Objective-C objc_msgSend pattern):

#[link_name = "objc_msgSend"] extern fn msg_void(recv: *u8, sel: *u8);
#[link_name = "objc_msgSend"] extern fn msg_get_str(recv: *u8, sel: *u8) -> *u8;

Both resolve to _objc_msgSend at link time.

Objective-C interop

Objective-C is the one non-C-shaped ABI that C+ treats as a first-class systems target, because AppKit, Foundation, Metal, and MPS all sit behind it on macOS. Object handles are opaque *u8, selectors are data, and message sends are foreign calls. The direct compiler intrinsics are:

let sel: *u8 = #selector("setTitle:");
let title: *u8 = #msg_send(button, "title") -> *u8;
#msg_send(button, "setEnabled:", true);

#selector("name") registers and caches the SEL. #msg_send(recv, "sel", ...) -> T emits a typed objc_msgSend call with the return type you spell at the call site. Most application code should import the typed packages instead: vendor/appkit wraps Cocoa and vendor/metal wraps Metal/MPS, keeping the raw message-send details at the edge.

Two ABI gotchas worth memorising

Variadic functions must be declared variadic. If the C header says int fcntl(int fd, int cmd, ...); the C+ extern must be variadic too. On AArch64-darwin, named args go in registers but varargs go on the stack, so a fixed-arity declaration silently passes garbage:

extern fn fcntl(fd: i32, cmd: i32, ...) -> i32;       // ✅
extern fn fcntl(fd: i32, cmd: i32, arg: i32) -> i32;  // ❌ no-ops, returns 0

Pointer/integer casts go through usize. Turning a pointer into an integer is the loud #addr(p) intrinsic, which yields a usize; you narrow from there, never straight from the pointer:

let n: usize = #addr(p);
let i: i32   = n as i32;
let bad: i32 = p as i32;   // ❌ E0315 — cannot cast a pointer to i32