FFI — the C boundary
The decision this page settles: what crossing into or out of C costs, and which shape to use at the boundary. Signatures in ref.md; ownership fundamentals in ownership.md.
1. The declaration is the marker
There is no unsafe block. Every operation that can invoke undefined
behavior is already loud at the point of use: *p / p[i] are the only
derefs, x as *T is the only pointer constructor, #addr(p) is the loud
pointer→integer read, and a foreign call cannot appear without an
extern fn declaration in scope. The declaration is the audit point; the
call site stays bare.
extern fn malloc(n: usize) -> *u8;
extern fn free(p: *u8);
let p: *u8 = malloc(64 as usize);
p[0] = 65 as u8; // deref-write: visible
let q: *u8 = p + 1; // pointer arithmetic strides by sizeof(T)
free(p);
Null is 0 as *T on the way in, and p.is_null() / p.is_not_null() on
the way out — blessed on every raw pointer and fn-pointer, a single compare,
no memory access. Pointer ↔ integer conversion goes through usize, never
directly to a narrower type (E0315).
2. The three traps that outrank everything else
- Variadics must be declared variadic. On AArch64-Darwin, named
arguments ride registers and varargs ride the stack — a fixed-arity
declaration of
fcntlcompiles, calls, and passes garbage with no diagnostic.extern fn fcntl(fd: i32, cmd: i32, ...) -> i32;— the...is load-bearing. strdoes not cross. It is a fat pointer with no C counterpart — rejected inexportsignatures (E0410). Cross as*u8+usizeand rebuild the view on the other side (#str_from_raw_parts), or hand C a NUL-terminatedc"..."literal /#str_ptrof a NUL-terminated string.- Only
exportis callable from outside. Everything else has module-mangled names and an internal calling convention — an lldbcall, a C declaration, or a test harness reaching an internal symbol passes garbage silently. Need to drive an internal path from outside? Write the two-lineexportwrapper. That is what it is for.
3. Layout: making a struct C-true
#[repr(C)] struct NSPoint { x: f64, y: f64 } // C field order + padding
#[repr(C, packed)] struct Wire { kind: u8, len: u32 } // no padding: size 5, align 1
#[repr(C, packed = 2)] struct Legacy { a: u8, b: u32 } // #pragma pack(2)
#[repr(C)] struct Flags {
#[bits(3)] kind: u32, // bitfields, C's exact unit/straddle rules
#[bits(5)] level: u32,
#[bits(4)] delta: i32, // signed: sign-extends on read
}
#[repr(C)] union FloatBits { f: f32, bits: u32 } // one storage, typed views
Rules that follow from C's rules: a bitfield or under-aligned packed field
has no address — no ref, no #addr_of (E0926/E0927); copy to a local
first. Union members must be Copy (no tag means no destructor can run).
Unions are for binding headers; an either/or value in ordinary code is an
enum, which has a tag.
Payload-free enums cross with a pinned width:
#[repr(u8)] enum Mode { Off = 0, Slow = 10, Fast = 200 } // uint8_t on the wire
Discriminants take constant expressions and C's prev+1 rule. Tagged enums (payload-carrying) never cross the boundary — convert at the edge.
4. Ownership at the boundary
The borrow checker's writ ends at extern fn. What replaces it is a
convention you write down:
- A struct field holding a C pointer must declare its owner (E0510):
a
dropthat frees it, oropaquemeaning "C frees this, not me" (ownership.md §7). - A fn-pointer parameter follows the same grammar as everything else:
fn(R)borrows its argument,fn(take R)consumes it (E0312 keeps the two apart). Registering a C callback that will keep the payload means declaring the pointer type withtake. - By-value parameters borrow; only
takeconsumes — including in callbacks that C invokes. The over-release bugs all come from assuming the Rust rule; C+ is explicit instead.
The sanitizers cover what the checker can't: cpc build --asan (and
--ubsan, --tsan, --msan) instrument cpc-emitted code exactly as clang
instruments C, and they are the tool for the raw tier.
5. Calling out: symbols and linking
#[link_name = "objc_msgSend"] extern fn msg_ptr(r: *u8, s: *u8) -> *u8;
extern fn names are module-scoped in C+ — #[link_name] pins the actual
linker symbol, which is also how one symbol gets several typed C+ faces
(the objc_msgSend pattern). The libraries themselves come from the
manifest's [link] table — frameworks, libs, search-paths,
extra-objects — and a dependency's [link] travels with it
(packages.md §5).
For Objective-C, the language carries a typed tier so bindings don't hand-
roll casts: #selector("name") (cached SEL) and
#msg_send(recv, "sel", args...) -> RetTy. The objc / appkit / uikit
vendor packages are generated on top of exactly this.
6. Calling in: export
export fn probe_emit(name_ptr: *u8, name_len: usize) {
let name: str = { #str_from_raw_parts(name_ptr, name_len) };
events::emit(name);
return;
}
export extern fn app_main() -> i32 { // an app entry the platform shell calls
let app = runtime::App::new("Example");
app.window("main", main_screen);
let _run = app.run("main");
return 0;
}
export gives a function a stable, unmangled name, the C calling
convention, and a line in the generated header (target/<…>/<name>.h, or
cpc --emit-header). Signatures must be C-representable (E0410): integers,
floats, raw pointers, #[repr(C)] aggregates, repr'd enums — no str, no
tagged enums, no SIMD vectors (round-trip SIMD via [f32; N]).
export extern fn is the app-entry flavor: on external-builder platforms
(iOS, Android) the platform shell — main.m, JNI — calls it, and the build
system links your archive under it (packages.md §3).
7. Embedding data instead of loading it
#include_bytes("path") compiles a file into the binary as *[u8; N];
#compile_shader("k.metal", "msl") runs the platform shader compiler at
build time and embeds the result. Both remove a filesystem dependency from
the shipped artifact — the FFI-adjacent trick that most often replaces a C
asset pipeline.