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