FFI Boundary Semantics
This chapter defines the Foreign Function Interface (FFI) boundary between Clef code and external C libraries on a target that links a host C runtime (Lane 1, hosted). It establishes the null-safety contract, the opaque-handle representation of a C binding’s pointer, and the normative requirements for binding generation tools like Farscape. It is the C instance of the foreign-boundary family; the JavaScript instance is JavaScript Boundary Semantics, and the two share the family invariant stated in §1.1. The FFI boundary exists whenever a C runtime is linked, and its presence is independent of whether the target is freestanding:
- Hosted — libc is linked dynamically; the FFI boundary of this chapter applies as written.
- Freestanding with static libc — libc is linked statically and its resources are accessed directly, with no dynamic linker in the image. The FFI boundary still exists and this chapter still applies; static coupling removes the dynamic-binding step (a supply-chain consideration developed in Getting to the Heart of Unikernels, out of scope here).
- Bare (no C runtime) — no libc is linked at all, so there is no FFI boundary and nothing in this chapter applies. Interior memory follows the lifetime lattice defined in
closure-representation.md§3.3.
Interior Clef has no raw pointer type. nativeptr<'T>, voidptr, and nativeint-as-pointer are not denotable in Clef source, at the FFI boundary or anywhere else. A C binding that returns a pointer marshals that pointer through an opaque handle, CHandle<'T>: the handle is non-arithmetic and non-dereferenceable in Clef source, and its only use is to be passed back across the boundary to another C binding. The interior pointer mechanism is the flat closure; a register is the width-typed Mmio handle.
1. Null Safety Principle
1.1 Core Invariant
Null exists ONLY at the FFI boundary. Within Clef code,
CHandle<'T>andFnPtr<'F>are NEVER null.
This invariant is fundamental to Clef’s memory safety guarantees. Unlike C where any pointer may be null, Clef enforces non-nullability at the type level. A C binding hands back an opaque handle rather than a raw pointer, so interior code never holds a dereferenceable address.
The statement above is the C instance of a family invariant that holds at every foreign boundary: a boundary’s absence sentinels exist only in its boundary conversions, and no foreign sentinel is representable in interior Clef. The C boundary’s sentinel is NULL, converted through Option as this chapter specifies. The JavaScript boundary’s absence alphabet has three states, converted as JavaScript Boundary Semantics §5 specifies. Each boundary chapter confines its own sentinels; interior code is identical under both.
1.2 Rationale
Null pointer dereferences are a leading cause of crashes and security vulnerabilities in native code. By eliminating null from the type system’s interior, Clef provides:
- Compile-time safety: The type checker ensures handles handed back from C are always valid
- Explicit optionality:
Option<CHandle<'T>>makes nullability visible in the type signature - Clean FFI boundary: Null handling is isolated to the interface with C code
- No runtime null checks: Interior code needs no defensive null checks
1.3 The FFI Boundary
The FFI boundary is the interface between Clef code and external C functions. At this boundary:
Outgoing (F# → C):
Option<CHandle<'T>>converts to nullable C pointerNone→NULLSome handle→ the pointer the handle carries
Incoming (C → F#): Nullable C pointer converts to
Option<CHandle<'T>>NULL→None- Non-null →
Some handle
┌─────────────────────────────────────────────────────────┐
│ Clef World │
│ │
│ CHandle<'T> - NEVER null, opaque │
│ FnPtr<'F> - NEVER null │
│ Option<CHandle<'T>> - explicit nullability │
│ Option<FnPtr<'F>> - explicit nullability │
└─────────────────────────────────────────────────────────┘
↕ FFI Boundary
┌─────────────────────────────────────────────────────────┐
│ C World │
│ │
│ T* - may be NULL │
│ void (*f)(...) - may be NULL │
└─────────────────────────────────────────────────────────┘2. Pointer Types at FFI Boundary
2.1 Non-Nullable Handles
| Clef Type | C Equivalent | Semantics |
|---|---|---|
CHandle<'T> | T* (non-null) | Opaque typed handle, guaranteed valid; non-arithmetic, non-dereferenceable in Clef source |
CHandle<unit> | void* (non-null) | Opaque untyped handle, guaranteed valid |
FnPtr<'F> | Function pointer (non-null) | Function pointer, guaranteed valid |
A CHandle<'T> carries a C pointer across the boundary but exposes no pointer operations in Clef source: no arithmetic, no dereference, no conversion to an integer. Its only role is to be handed back to another C binding. These types have no null representation, and attempting to construct a null value is a compile-time error.
2.2 Nullable Handles (FFI Only)
| Clef Type | C Equivalent | Semantics |
|---|---|---|
Option<CHandle<'T>> | T* (nullable) | May be null, explicit handling required |
Option<CHandle<unit>> | void* (nullable) | Untyped nullable handle |
Option<FnPtr<'F>> | Function pointer (nullable) | May be null callback |
Option wrapping is used ONLY at FFI boundaries where C semantics require nullable pointers.
2.3 Memory Layout
Option<CHandle<'T>> has the same memory layout as CHandle<'T> (a single platform word). The compiler uses the null pointer optimization:
Noneis represented as the bit pattern0(null)Some handleis represented as the carried pointer value itself
This ensures zero overhead for Option-wrapped handles at the FFI boundary.
3. FnPtr Intrinsics
3.1 FnPtr Type
FnPtr<'F> is a function pointer type where 'F is the full function signature:
FnPtr<unit -> unit> // void (*)(void)
FnPtr<int -> int> // int (*)(int)
FnPtr<CHandle<byte> -> int -> int> // int (*)(char*, int)
FnPtr<Option<CHandle<int>> -> unit> // void (*)(int*) -- nullable param
The type parameter 'F MUST be a function type ('a -> 'b). Using a non-function type is a compile-time error.
3.2 FnPtr.fromSymbol
Declares an external symbol to be resolved by the linker.
Signature:
FnPtr.fromSymbol<'F> : string -> FnPtr<'F>Semantics:
- The string argument MUST be a compile-time constant (string literal)
- Returns a non-null function pointer (linker guarantees symbol exists)
- Symbol resolution occurs at link time, not runtime
Example:
// Declare external C functions
let private strlen_ptr = FnPtr.fromSymbol<CHandle<byte> -> int> "strlen"
let private gtk_init_ptr =
FnPtr.fromSymbol<Option<CHandle<int>> -> Option<CHandle<CHandle<byte>>> -> unit> "gtk_init"Code Generation:
The middle end emits portable dialects only: an external func.func declaration for the symbol, and the symbol as a func.constant referencing that declaration — a first-class function value in the portable dialect, with no cast. Each target pathway realizes it through its standard lowerings: the LLVM pathway (CPU/MCU) lowers the declaration to an llvm.func and the constant to an llvm.mlir.addressof, which is the address the C side receives; other pathways realize it in their own terms. The conversion of a function value to an address is therefore a pathway commitment made at the extern boundary, never a middle-end operation.
3.3 FnPtr.invoke
Calls a function through a function pointer.
Signature:
FnPtr.invoke : FnPtr<'F> -> 'FSemantics:
- Invokes the function with the provided arguments
- Arguments matching
Option<CHandle<'T>>are marshalled (None → NULL) - Return values matching
Option<CHandle<'T>>are marshalled (NULL → None)
Example:
// Call external function
let len = FnPtr.invoke strlen_ptr myStringPtr
// Call with nullable arguments (None → NULL)
FnPtr.invoke gtk_init_ptr None None3.4 FnPtr.ofFunction
Converts a top-level F# function to a function pointer (for callbacks).
Signature:
FnPtr.ofFunction : 'F -> FnPtr<'F>Constraints:
- The argument MUST be a reference to a module-level
letbinding - Lambdas and closures are REJECTED at compile time
- The function must not capture any environment
Rationale: C callbacks expect stable function addresses. Closures capture environment with unpredictable lifetime. Compile-time enforcement prevents subtle bugs.
Example:
// OK - top-level function
let myCallback (x: int) : int = x + 1
let callbackPtr = FnPtr.ofFunction myCallback
// ERROR - lambda (even without captures)
let ptr = FnPtr.ofFunction (fun x -> x + 1) // Compile error
// ERROR - closure with captures
let multiplier = 2
let ptr = FnPtr.ofFunction (fun x -> x * multiplier) // Compile error
3.5 Removed Intrinsics
The following intrinsics are NOT available in Clef:
: UseFnPtr.nullOption<FnPtr<'F>>withNoneinstead: Use pattern matching onFnPtr.isNullOption<FnPtr<'F>>instead
Migration:
// Old (NOT SUPPORTED):
let maybeCallback = FnPtr.null<int -> unit> ()
if not (FnPtr.isNull maybeCallback) then
FnPtr.invoke maybeCallback 42
// New (CORRECT):
let maybeCallback : Option<FnPtr<int -> unit>> = None
match maybeCallback with
| Some cb -> FnPtr.invoke cb 42
| None -> ()4. Option↔NULL Marshalling
4.1 Parameter Marshalling (F# → C)
When a function parameter has type Option<CHandle<'T>> or Option<FnPtr<'F>>:
| F# Value | C Value |
|---|---|
None | NULL (0) |
Some handle | the pointer the handle carries |
Optimization: When the argument is a compile-time None literal, the compiler directly emits null without runtime checks.
4.2 Return Value Marshalling (C → F#)
When a function return type is Option<CHandle<'T>> or Option<FnPtr<'F>>:
| C Value | F# Value |
|---|---|
NULL (0) | None |
| Non-null | Some handle |
Code Generation (portable dialects):
The compare-against-null and the select are expressed in portable ops. The returned pointer is a platform word, typed index; the null check is arith.cmpi; the choice between None and Some is scf.select (or arith.select).
// C function returns nullable pointer (platform word, carried as index)
%result = func.call @may_return_null() : () -> index
// Marshal to Option
%zero = arith.constant 0 : index
%is_null = arith.cmpi eq, %result, %zero : index
%option = arith.select %is_null, %none_value, %some_result : ...LLVM-pathway lowering example: on the CPU/MCU pathway the same marshalling lowers to llvm.call ... -> !llvm.ptr, llvm.icmp "eq", and llvm.select. That form is one target pathway, not what the middle end emits.
4.3 Non-Marshalled Types
Types NOT wrapped in Option are passed directly without marshalling:
CHandle<'T>: passed as-is (must be non-null)FnPtr<'F>: passed as-is (must be non-null)int,float, etc.: passed as-is (value types)
5. Farscape Binding Generation Contract
This section defines normative requirements for Farscape and other binding generation tools.
Informative. The C++ Binding via Farscape guide describes how these requirements are applied in practice.
5.1 C Nullability Annotation Mapping
Farscape MUST interpret C nullability annotations as follows:
| C Annotation | Platform | Clef Output |
|---|---|---|
_Nonnull | Clang/Apple | CHandle<'T> |
_Nullable | Clang/Apple | Option<CHandle<'T>> |
_Null_unspecified | Clang/Apple | See default policy |
__attribute__((nonnull)) | GCC | CHandle<'T> |
_In_ | Windows SAL | CHandle<'T> |
_In_opt_ | Windows SAL | Option<CHandle<'T>> |
_Out_ | Windows SAL | CHandle<'T> |
_Out_opt_ | Windows SAL | Option<CHandle<'T>> |
5.2 Default Policy (Unannotated Pointers)
When C code lacks nullability annotations, Farscape MUST apply these defaults:
Function Parameters:
- Default:
CHandle<'T>(assume non-null) - Override to
Option<CHandle<'T>>when documentation indicates nullable
Function Return Values:
- Default:
CHandle<'T>(assume non-null) - Override to
Option<CHandle<'T>>for functions documented to return NULL on error
Rationale: Most C APIs expect non-null parameters and return non-null on success. Defaulting to non-null reduces Option ceremony while the override mechanism handles exceptions.
5.3 Generated Binding Structure
Farscape-generated bindings MUST follow this pattern:
module LibraryName.Bindings
// External function declarations (private)
let private function_name_ptr =
FnPtr.fromSymbol<param_types -> return_type> "c_function_name"
// High-level F# API (public)
let functionName (param1: type1) (param2: type2) : returnType =
FnPtr.invoke function_name_ptr param1 param25.4 Ambiguity Handling
When nullability is ambiguous, Farscape SHOULD:
- Emit a warning indicating the assumption made
- Support a hints file for manual override
- Document the default in generated binding comments
Hints File Format (example):
[gtk_window_new]
return = "nonnull" # Override: gtk_window_new never returns NULL
[g_object_get_data]
return = "nullable" # Override: may return NULL if key not found
5.5 Callback Function Types
For C functions accepting callbacks, Farscape MUST:
- Generate
FnPtr<'F>for non-null callback parameters - Generate
Option<FnPtr<'F>>for nullable callback parameters - Document that callbacks must be top-level functions (no closures)
6. Examples
6.1 GTK Bindings
module Platform.GTK
// External declarations
let private gtk_init_ptr =
FnPtr.fromSymbol<Option<CHandle<int>> -> Option<CHandle<CHandle<byte>>> -> unit> "gtk_init"
let private gtk_window_new_ptr =
FnPtr.fromSymbol<int -> CHandle<GtkWindow>> "gtk_window_new"
let private gtk_widget_show_all_ptr =
FnPtr.fromSymbol<CHandle<GtkWidget> -> unit> "gtk_widget_show_all"
let private gtk_main_ptr =
FnPtr.fromSymbol<unit -> unit> "gtk_main"
// High-level API
let gtkInit () =
FnPtr.invoke gtk_init_ptr None None
let gtkWindowNew (windowType: GtkWindowType) : CHandle<GtkWindow> =
FnPtr.invoke gtk_window_new_ptr (int windowType)
let gtkWidgetShowAll (widget: CHandle<GtkWidget>) : unit =
FnPtr.invoke gtk_widget_show_all_ptr widget
let gtkMain () =
FnPtr.invoke gtk_main_ptr ()6.2 libc Bindings
This example is hosted-libc (Lane 1). malloc/free exist only where a C runtime with a heap is linked. On a no-heap target these particular bindings do not exist, and interior memory follows the lifetime lattice of closure-representation.md §3.3: a value that classifies as genuinely-dynamic on a no-heap target is a compile-time lifetime error rather than a call to malloc. A bare target with no C runtime at all has no FFI boundary of any kind.
module Platform.Libc
// strlen: never returns null, never accepts null
let private strlen_ptr =
FnPtr.fromSymbol<CHandle<byte> -> int> "strlen"
// malloc: may return NULL on failure
let private malloc_ptr =
FnPtr.fromSymbol<int -> Option<CHandle<unit>>> "malloc"
// free: accepts NULL (no-op)
let private free_ptr =
FnPtr.fromSymbol<Option<CHandle<unit>> -> unit> "free"
// High-level API
let strlen (s: CHandle<byte>) : int =
FnPtr.invoke strlen_ptr s
let malloc (size: int) : Option<CHandle<unit>> =
FnPtr.invoke malloc_ptr size
let free (handle: Option<CHandle<unit>>) : unit =
FnPtr.invoke free_ptr handle6.3 Callback Pattern
module Platform.GLib
// Type alias for GLib callback
type GSourceFunc = int -> int // gboolean (*)(gpointer) simplified
// g_idle_add accepts non-null callback; user_data is a nullable gpointer (void*)
let private g_idle_add_ptr =
FnPtr.fromSymbol<FnPtr<GSourceFunc> -> Option<CHandle<unit>> -> uint32> "g_idle_add"
// User's callback (must be top-level)
let myIdleCallback (userData: int) : int =
// Do work...
0 // Return FALSE to remove source
// Register callback (no user_data -> None -> NULL)
let sourceId =
let callbackPtr = FnPtr.ofFunction myIdleCallback
FnPtr.invoke g_idle_add_ptr callbackPtr None7. Normative Summary
- A C binding’s pointer marshals through the opaque
CHandle<'T>; interior Clef has no raw pointer type, andnativeptr<'T>/voidptr/nativeint-as-pointer are not denotable anywhere.CHandle<'T>andFnPtr<'F>are NEVER null within Clef code Option<CHandle<'T>>andOption<FnPtr<'F>>represent nullable pointers at the FFI boundaryFnPtr.fromSymboldeclares linker-resolved external symbolsFnPtr.invokecalls through function pointers with automatic Option↔NULL marshallingFnPtr.ofFunctionconverts top-level functions only (no closures)- Farscape MUST follow the nullability annotation mapping and default policies defined herein
- This chapter applies wherever a host C runtime is linked, whether dynamically (hosted) or statically (freestanding with static libc); a bare target with no C runtime has no FFI boundary, and its interior memory follows the lifetime lattice of
closure-representation.md§3.3