C Interop in OCaml

2026-06-07 · 13 min

OCaml values and C values are different shapes in memory, and every FFI mechanism is a way of crossing that boundary without corrupting either side. This builds a binding by hand with the raw C stubs API, then the same binding with ctypes, then covers callbacks, bigarrays, and where the boundary actually costs you time.

§ 01

The shape of an OCaml value, briefly

Every OCaml value is either an unboxed integer (tagged, one bit stolen for the tag) or a pointer to a heap block with a header. C sees neither tag automatically - value is just intnat, and it is your job to check the tag before touching it as either.

Is_block(v) true if v is a pointer, false if it is an immediate int
Is_long(v) true if v is an immediate int
Long_val(v) unwrap an immediate OCaml int to a C long
Val_long(n) wrap a C long into an immediate OCaml int
Val_unit the value (), represented as Val_long(0)
§ 02

A stub library, by hand

Binding one C function: a checksum over a byte string. The project layout.

myproj/
├── checksum_stubs.c
├── checksum.ml
├── checksum.mli
└── dune

The C side. CAMLparam/CAMLreturn register the function\'s OCaml arguments and return value with the GC for the duration of the call - required on every function that can trigger a collection, which allocation, and only allocation, does.

#include <caml/mlvalues.h>
#include <caml/alloc.h>
#include <caml/memory.h>
#include <string.h>
CAMLprim value caml_checksum(value v_data) {
CAMLparam1(v_data);
const unsigned char *data = (const unsigned char *) String_val(v_data);
size_t len = caml_string_length(v_data);
unsigned long sum = 0;
for (size_t i = 0; i < len; i++) sum += data[i];
CAMLreturn(Val_long(sum));
}

The .mli, which is what makes this feel like an ordinary OCaml function to a caller.

val checksum : string -> int

The .ml. external names the C symbol; the string after the type is the name registered with caml_named_value / looked up by the runtime - it does not have to match the OCaml name.

external checksum : string -> int = "caml_checksum"

dune wires the C file in via the foreign_stubs field on the library stanza.

(library
(name checksum)
(foreign_stubs
(language c)
(names checksum_stubs)))

Build and use it.

$ dune build
$ dune utop .

From the toplevel.

# Checksum.checksum "hello";;
- : int = 532
§ 03

Why CAMLparam and CAMLreturn are not optional

OCaml\'s GC can move blocks - the value passed in is a location that is only valid until the next allocation. Without registering it, a collection triggered by an allocation inside the stub can move the block out from under a local C variable still pointing at the old address, corrupting or crashing the program on what looks like unrelated code far away.

Wrong: a C string extracted before CAMLparam registers the value, held across a call that allocates. This compiles, often runs fine in testing, and segfaults intermittently in production once the heap is large enough that a collection actually happens mid-call.

CAMLprim value caml_bad_example(value v_str) {
const char *s = String_val(v_str); /* not yet GC-safe */
value v_extra = caml_alloc(16, 0); /* can move v_str */
/* s may now point into freed or relocated memory */
return caml_copy_string(s);
}

Right: register first, and re-derive any raw pointer after anything that could allocate, since the earlier pointer is no longer trustworthy even if it happens to still look valid.

CAMLprim value caml_good_example(value v_str) {
CAMLparam1(v_str);
CAMLlocal1(v_extra);
v_extra = caml_alloc(16, 0);
const char *s = String_val(v_str); /* re-derived after the allocation */
CAMLreturn(caml_copy_string(s));
}

CAMLlocalN declares local OCaml values the same way CAMLparamN declares arguments - any value stored in a C local that outlives an allocation needs one.

CAMLparam0 .. CAMLparam5 register up to 5 argument values
CAMLxparam1 .. 5 register more, when there are >5 args
CAMLlocal1 .. 5 declare local OCaml values
CAMLlocalN(arr, n) declare an array of n local values
CAMLreturn(v) unregister everything and return v
CAMLreturn0 same, for a function returning unit/void

A function taking more than five arguments - bytecode calling convention passes them as an array, native calling convention passes them positionally, and you provide both entry points.

CAMLprim value caml_six_args_bytecode(value *argv, int argn) {
return caml_six_args_native(argv[0], argv[1], argv[2],
argv[3], argv[4], argv[5]);
}
CAMLprim value caml_six_args_native(
value a, value b, value c, value d, value e, value f) {
CAMLparam5(a, b, c, d, e);
CAMLxparam1(f);
/* ... */
CAMLreturn(Val_unit);
}

Register both entry points on the OCaml side - bytecode arity, then native symbol.

external six_args : int -> int -> int -> int -> int -> int -> unit
= "caml_six_args_bytecode" "caml_six_args_native"
§ 04

Allocating and returning compound values

Building an OCaml tuple from C - caml_alloc_tuple, then Store_field to fill each slot. Store_field exists specifically so the write is tracked correctly for the generational GC\'s write barrier.

CAMLprim value caml_make_pair(value v_a, value v_b) {
CAMLparam2(v_a, v_b);
CAMLlocal1(v_pair);
v_pair = caml_alloc_tuple(2);
Store_field(v_pair, 0, v_a);
Store_field(v_pair, 1, v_b);
CAMLreturn(v_pair);
}

Returning a string and a float, the two most common non-trivial conversions - copy_string allocates and copies, so the C buffer does not need to outlive the call.

CAMLprim value caml_describe(value v_n) {
CAMLparam1(v_n);
CAMLlocal2(v_str, v_pair);
long n = Long_val(v_n);
char buf[64];
snprintf(buf, sizeof(buf), "value = %ld", n);
v_str = caml_copy_string(buf);
v_pair = caml_alloc_tuple(2);
Store_field(v_pair, 0, v_str);
Store_field(v_pair, 1, caml_copy_double((double) n * 1.5));
CAMLreturn(v_pair);
}

An OCaml option, built from the same tagged-block representation the compiler itself uses - None is the immediate 0, Some x is a one-field block tagged 0.

CAMLprim value caml_safe_div(value v_a, value v_b) {
CAMLparam2(v_a, v_b);
CAMLlocal1(v_result);
long b = Long_val(v_b);
if (b == 0) {
CAMLreturn(Val_int(0)); /* None */
}
v_result = caml_alloc(1, 0); /* Some */
Store_field(v_result, 0, Val_long(Long_val(v_a) / b));
CAMLreturn(v_result);
}

Matches this .mli exactly - representation compatibility is not checked by the compiler, only by you.

val safe_div : int -> int -> int option
§ 05

Exceptions across the boundary

Raising a standard OCaml exception from C.

CAMLprim value caml_checked_index(value v_arr, value v_i) {
CAMLparam2(v_arr, v_i);
long i = Long_val(v_i);
if (i < 0 || i >= Wosize_val(v_arr)) {
caml_invalid_argument("index out of bounds");
}
CAMLreturn(Field(v_arr, i));
}

The built-in raisers worth knowing - each one longjmps out of the stub, so nothing after the call runs, which is exactly like an OCaml raise.

caml_failwith(msg) raises Failure
caml_invalid_argument(msg) raises Invalid_argument
caml_raise_out_of_memory() raises Out_of_memory
caml_raise_not_found() raises Not_found
caml_raise_with_string(exn, s) raises a custom exception with a string arg

A custom exception, registered on the OCaml side and looked up by name from C.

(* checksum.ml *)
exception Bad_input of string
let () = Callback.register_exception "checksum.bad_input" (Bad_input "")

Raising it from C - caml_named_value looks up whatever Callback.register or register_exception published under that name.

CAMLprim value caml_validate(value v_s) {
CAMLparam1(v_s);
if (caml_string_length(v_s) == 0) {
value *exn = caml_named_value("checksum.bad_input");
caml_raise_with_string(*exn, "empty input");
}
CAMLreturn(Val_unit);
}
§ 06

ctypes: the same binding without writing C

ctypes describes a C function\'s signature as an OCaml value and generates the marshalling at either OCaml or C stub compile time - no header parsing, no C stub file for a plain function binding.

Install both the library and the code-generation backend.

$ opam install ctypes ctypes-foreign

Binding a C library function directly via dlopen/dlsym - no stub file at all. This is the fastest path for a small number of functions.

open Ctypes
open Foreign
let strlen = foreign "strlen" (string @-> returning size_t)
(* # strlen "hello";; - : Unsigned.size_t = 5u *)

@-> chains argument types; returning closes the signature. The types themselves are ordinary OCaml values from the Ctypes module, not a DSL needing its own parser.

void int float double
char short long llong
size_t string bool
ptr t (a pointer to t)
string_opt (nullable, marshals to string option)

A more realistic binding: libm\'s pow, with the dune stanza that links against it.

let pow = foreign "pow" (double @-> double @-> returning double)
(* # pow 2.0 10.0;; - : float = 1024. *)

dune, for the dynamic-binding form - foreign only needs the C library findable at runtime, via ctypes-foreign\'s C stub linked against libdl/libffi.

(executable
(name main)
(libraries ctypes ctypes-foreign)
(c_library_flags (-lm)))
§ 07

ctypes.foreign_stubs: generated C, for real deployment

foreign above resolves symbols at runtime, which needs libffi and is slower per call. For a library you actually ship, generate real C stubs at build time instead - same binding description, compiled rather than interpreted.

The binding description, written once, shared between the two generators below.

(* types_stubs.ml *)
module Types (F : Ctypes.TYPE) = struct
open F
end
(* function_stubs.ml *)
module Functions (F : Ctypes.FOREIGN) = struct
open F
let pow = foreign "pow" (double @-> double @-> returning double)
end

A generator executable that emits the C stub file, run at build time by a dune rule rather than by hand.

(* generate_stubs.ml *)
let () =
let generate_ml, generate_c =
match Sys.argv.(1) with
| "ml" -> true, false
| "c" -> false, true
| _ -> failwith "usage: generate_stubs (ml|c)"
in
ignore generate_ml; ignore generate_c

This is enough machinery that most projects reach for ctypes.stubs\' packaged dune rules instead of writing the generator loop by hand - the dune-configurator and ctypes.stubs opam packages wire this exact pattern up as a reusable stanza.

$ opam install ctypes.stubs
§ 08

Passing structs

A C struct, described field by field - the field order and types have to match the C header exactly, since nothing checks this against the real struct layout.

open Ctypes
type point
let point : point structure typ = structure "point"
let px = field point "x" double
let py = field point "y" double
let () = seal point

Constructing and reading one - make allocates, getf/setf read and write named fields.

let p = make point in
setf p px 3.0;
setf p py 4.0;
let dist = sqrt (getf p px ** 2.0 +. getf p py ** 2.0) in
(* dist = 5.0 *)

Binding a function taking the struct by pointer, which is how most real C APIs pass a struct - ptr point rather than point structure typ directly.

let point_distance =
foreign "point_distance" (ptr point @-> ptr point @-> returning double)
let d = point_distance (addr p1) (addr p2)
§ 09

Callbacks: OCaml functions called from C

Describing a callback\'s C signature the same way as any other function - a function pointer, quantified over its own type.

let comparator = double @-> double @-> returning int
let qsort_ =
foreign "qsort"
(ptr double @-> size_t @-> size_t @->
funptr comparator @-> returning void)

Calling into libc\'s qsort with an OCaml comparison function - funptr wraps the OCaml closure as a real C function pointer C code can call back into.

let arr = CArray.of_list double [ 5.0; 1.0; 4.0; 2.0; 3.0 ] in
qsort_ (CArray.start arr) (Unsigned.Size_t.of_int 5)
(Unsigned.Size_t.of_int (sizeof double))
(fun a b -> if a < b then -1 else if a > b then 1 else 0);
(* arr is now sorted in place *)

The raw-stubs equivalent, for when the callback needs to be registered ahead of time rather than passed per call - Callback.register publishes an OCaml function under a name C can look up.

(* on the OCaml side *)
let log_line s = print_endline s
let () = Callback.register "log_line" log_line

Calling it from C - caml_callback invokes the registered closure, and needs the same GC bookkeeping as any allocating call.

#include <caml/callback.h>
void call_ocaml_logger(const char *msg) {
static const value *closure = NULL;
if (closure == NULL) closure = caml_named_value("log_line");
caml_callback(*closure, caml_copy_string(msg));
}

Caching the closure lookup in a static, as above, matters - caml_named_value does a hash lookup every call otherwise, which is measurable if the callback fires in a hot loop.

(* the static NULL-check-then-cache pattern above is the standard idiom - *)
(* caml_named_value is not free, and this callback may fire per element *)
§ 10

Bigarrays: sharing memory instead of copying it

A string or bytes binding copies on every call. A Bigarray is backed by a C-allocated buffer that both sides read and write directly, with no copy - the right choice for numeric data crossing the boundary repeatedly.

Creating one and getting a raw pointer to its backing store from C.

let arr =
Bigarray.Array1.create Bigarray.float64 Bigarray.c_layout 1_000_000
for i = 0 to 999_999 do
Bigarray.Array1.unsafe_set arr i (float_of_int i)
done

The C side sees a bigarray argument as a pointer plus dimensions via the Bigarray macros - no per-element marshalling, the C code operates on the same bytes OCaml wrote.

#include <caml/bigarray.h>
CAMLprim value caml_sum_bigarray(value v_arr) {
CAMLparam1(v_arr);
double *data = (double *) Caml_ba_data_val(v_arr);
intnat len = Caml_ba_array_val(v_arr)->dim[0];
double sum = 0.0;
for (intnat i = 0; i < len; i++) sum += data[i];
CAMLreturn(caml_copy_double(sum));
}

The ctypes equivalent - bigarray t describes the dimensioned type, and passes the same underlying pointer with no copy on the OCaml side either.

open Ctypes
let sum_array =
foreign "sum_array"
(bigarray genarray [| -1 |] Bigarray.float64 @-> returning double)

A C library that allocates its own buffer and hands ownership to OCaml - wrap it as a Bigarray backed by that pointer rather than copying into a fresh one.

CAMLprim value caml_wrap_external_buffer(value v_ptr, value v_len) {
CAMLparam2(v_ptr, v_len);
double *data = (double *) Nativeint_val(v_ptr);
intnat dims[1] = { Long_val(v_len) };
CAMLreturn(caml_ba_alloc(CAML_BA_FLOAT64 | CAML_BA_C_LAYOUT, 1, data, dims));
}
§ 11

Threads and the runtime lock

Only one thread may hold the OCaml runtime lock at a time. A C call that blocks - disk I/O, a network call, a long pure-C computation - should release it first, or every other OCaml thread stalls for the duration even though none of them touch the same data.

Releasing the lock around a blocking call and reacquiring it before touching any OCaml value again - nothing between the two macros may read or write an OCaml value.

CAMLprim value caml_slow_computation(value v_n) {
CAMLparam1(v_n);
long n = Long_val(v_n);
long result;
caml_release_runtime_system();
result = expensive_pure_c_function(n); /* no OCaml value touched here */
caml_acquire_runtime_system();
CAMLreturn(Val_long(result));
}

ctypes\' release_runtime_lock combinator does the same thing declaratively, wrapping a foreign binding rather than needing a hand-written stub.

let slow_computation =
foreign ~release_runtime_lock:true
"expensive_pure_c_function" (long @-> returning long)
§ 12

Profiling the boundary

A quick check of whether a binding is the bottleneck at all - time the same workload with the C call replaced by a no-op, and compare.

let time label f =
let t0 = Unix.gettimeofday () in
let r = f () in
Printf.printf "%s: %.4fs\n" label (Unix.gettimeofday () -. t0);
r
let () =
ignore (time "with checksum" (fun () -> checksum_all 1_000_000 data));
ignore (time "no-op baseline" (fun () -> noop_all 1_000_000 data))

Watch allocation around the call site with Gc.minor_words - a binding that copies a large string on every call shows up here directly, as words allocated with no corresponding OCaml-level allocation in the caller.

let before = Gc.minor_words () in
ignore (checksum big_string);
Printf.printf "allocated: %.0f words\n" (Gc.minor_words () -. before)

perf works across the boundary exactly like any other native code, since C stubs compile to ordinary machine code with no runtime distinction from OCaml-generated code once built.

$ perf record -g -- ./main.exe
$ perf report

Landmarks, an OCaml-side sampling profiler, can wrap a C-calling region as its own labeled span, which keeps the boundary visible in the same flamegraph as the surrounding OCaml code rather than disappearing into a generic C symbol.

let () = Landmark.enter checksum_landmark in
let r = Checksum.checksum data in
Landmark.exit checksum_landmark;
r

The rule the whole boundary reduces to: never hold a raw pointer derived from an OCaml value across anything that can allocate, register every argument and local with CAMLparam/CAMLlocal, and reach for Bigarray the moment the same buffer crosses more than once. Everything ctypes generates is doing exactly this under the hood - it just means you do not have to get it right by hand for a plain function binding.