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.
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 intIs_long(v) true if v is an immediate intLong_val(v) unwrap an immediate OCaml int to a C longVal_long(n) wrap a C long into an immediate OCaml intVal_unit the value (), represented as Val_long(0)
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
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 valuesCAMLxparam1 .. 5 register more, when there are >5 argsCAMLlocal1 .. 5 declare local OCaml valuesCAMLlocalN(arr, n) declare an array of n local valuesCAMLreturn(v) unregister everything and return vCAMLreturn0 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"
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
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 Failurecaml_invalid_argument(msg) raises Invalid_argumentcaml_raise_out_of_memory() raises Out_of_memorycaml_raise_not_found() raises Not_foundcaml_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 stringlet () = 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);}
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 Ctypesopen Foreignlet 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 doublechar short long llongsize_t string boolptr 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)))
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) = structopen Fend(* function_stubs.ml *)module Functions (F : Ctypes.FOREIGN) = structopen Flet 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)"inignore 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
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 Ctypestype pointlet point : point structure typ = structure "point"let px = field point "x" doublelet py = field point "y" doublelet () = seal point
Constructing and reading one - make allocates, getf/setf read and write named fields.
let p = make point insetf 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)
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 intlet 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 ] inqsort_ (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 slet () = 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 *)
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_000for i = 0 to 999_999 doBigarray.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 Ctypeslet 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));}
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)
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 () inlet r = f () inPrintf.printf "%s: %.4fs\n" label (Unix.gettimeofday () -. t0);rlet () =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 () inignore (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 inlet r = Checksum.checksum data inLandmark.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.