Liking cljdoc? Tell your friends :D

babashka.ffi

babashka.ffi calls functions in native shared libraries. The API is experimental. The library is built into babashka and also runs on the JVM.

On the JVM, enable native access with either of these settings:

  • Start the JVM with --enable-native-access=ALL-UNNAMED.
  • Set the Enable-Native-Access manifest attribute in an uberjar.

Without either setting, the JDK warns about native access.

Contents

Quickstart

Load a library and bind a function:

(require '[babashka.ffi :as ffi :refer [defcfn]])

(def zlib (ffi/load-system-library "z"))
(def zlib-version (ffi/cfn zlib "zlibVersion" [] :string))

(zlib-version)
;;=> "1.3.1"

load-system-library adds the platform file name. For example, "z" becomes libz.dylib, libz.so, or z.dll, depending on the operating system.

Load a library

Use load-system-library for a short library name:

(ffi/load-system-library "z")

On Linux, this function also searches for versioned names such as libz.so.1, in the LD_LIBRARY_PATH directories and the directories listed below.

Use load-library for an exact file name or path:

(ffi/load-library "/exact/path/libfoo.so")

load-library does not change the candidate names.

Pass a vector to try multiple candidates in order:

(ffi/load-library ["libfoo.so.3" "libfoo.so"])

Pass a map to select candidates for each operating system:

(ffi/load-library
 {:mac ["/opt/homebrew/opt/openssl@3/lib/libcrypto.3.dylib"
        "/usr/local/opt/openssl@3/lib/libcrypto.3.dylib"]
  :linux "libcrypto.so.3"
  :windows "libcrypto-3-x64.dll"})

The supported keys are :mac, :linux, and :windows. You can use :darwin instead of :mac.

Both functions first ask the operating system to load the library. If this fails for a bare name, they search these directories:

macOS:

  • /opt/homebrew/lib
  • /usr/local/lib
  • /opt/local/lib
  • /usr/lib

Load a macOS framework by its full path:

(ffi/load-library "/System/Library/Frameworks/CoreServices.framework/CoreServices")

The path is not a file. System frameworks live in the dyld shared cache, so fs/exists? returns false for the path and load-library succeeds.

Linux:

  • the directories in LD_LIBRARY_PATH
  • /usr/local/lib
  • /usr/lib64
  • /usr/lib
  • /usr/lib/x86_64-linux-gnu for x86_64 systems
  • /usr/lib/aarch64-linux-gnu for AArch64 systems
  • /lib64
  • /lib
  • /lib/x86_64-linux-gnu or /lib/aarch64-linux-gnu, for systems where /lib is not merged into /usr/lib

On Windows, add the directory that contains the DLL to PATH before you start babashka. Alternatively, pass the full DLL path to load-library. Windows uses its DLL search path to find other DLL files that the loaded DLL needs.

On FreeBSD, babashka runs as a Linux binary through the Linuxulator. The Linuxulator translates /usr/lib64 and /lib64 to /compat/linux/usr/lib64 and /compat/linux/lib64. As a result, the load functions find libraries in those translated directories.

Both functions return a library map. The :path value contains the loaded candidate:

(def zlib (ffi/load-system-library "z"))
(:path zlib)
;;=> "libz.dylib"

Pass this map to cfn to limit the search to that library and its dependencies:

(def zlib-version (ffi/cfn zlib "zlibVersion" [] :string))

Without a library map, cfn searches all loaded libraries and the default system lookup. find-symbol follows the same rules.

Use find-symbol to access a symbol without a function binding:

(ffi/find-symbol "zlibVersion")
;;=> a pointer

Pass the result to a C function that accepts a function or data pointer. You can also pass it to cfn to bind it.

If find-symbol cannot find the symbol, it returns nil.

Pass a library map to limit the search to that library and its dependencies:

(ffi/find-symbol zlib "zlibVersion")

If the selected library and a dependency export the same symbol, the search returns the symbol from the selected library.

The search can also find symbols from the dependencies. For example, (ffi/find-symbol zlib "strlen") returns the address of the C library's strlen.

Bind a function

Use cfn to create a Clojure function:

(def z-error (ffi/cfn zlib "zError" [:int] :string))
(z-error -3)
;;=> "data error"

The arguments to cfn are the C symbol, argument types, and return type. The symbol lookup occurs on the first call.

Bind an address

cfn also accepts a function address instead of a name:

(def c-abs (ffi/cfn (ffi/find-symbol "abs") [:int] :int))
(c-abs -42)
;;=> 42

Use this form for a function without an exported name.

Function addresses can come from find-symbol, C return values, struct fields, or callback.

cfn rejects address zero. A function lookup usually returns zero when it cannot find the requested function.

NOTE Make sure that the address points to a function with the declared signature. An incorrect address or signature can stop the process.

Use defcfn to define and bind a function:

(defcfn zlib-version "zlibVersion" [] :string)
(zlib-version)
;;=> "1.3.1"

You can add a docstring and an attribute map before the C symbol:

(defcfn zlib-version
  "Returns the zlib version."
  {:added "1.0"}
  "zlibVersion" [] :string)

The :library key in the attribute map selects the library for the binding:

(def sqlite (delay (ffi/load-library (extract-bundled-library!))))

(defcfn sqlite3-open {:library sqlite}
  "sqlite3_open" [:string :pointer] :int)

If you ship a library with your application, use :library.

Without this key, the binding searches all loaded libraries and then the system.

A system library with the same name can then supply the symbol. As a result, the application can call a version that you did not select.

:library accepts one of these values:

  • A library map
  • A function that returns a library map
  • A delay, atom, or var that holds a library map.

At the first call, the binding gets the library and resolves the symbol. The binding keeps the function address.

A function or delay can refer to a library that loads later. Changes to the library value after the first call do not change the binding.

Define a wrapper with defcfn

Use the wrapper form of defcfn to define a raw binding and a wrapper together:

(defcfn open-db
  "sqlite3_open_v2" [:string :pointer :int :string] :int
  open-native
  [filename flags]
  (with-open [arena (ffi/confined-arena)]
    (let [pdb  (ffi/alloc arena :pointer)
          code (open-native filename pdb flags nil)]
      (if (zero? code)
        (ffi/read pdb :pointer)
        (throw (ex-info "open failed" {:code code}))))))

The symbol after the return type names the raw binding. Only the wrapper body can use this name. The raw name does not become a var in the namespace.

The forms after the raw name are a normal fn tail. The wrapper can have multiple arities. Its argument lists can differ from the C function.

The wrapper form requires a static argument type vector. The plain form also accepts a dynamic expression.

Types

Use type keywords that denote C types in function signatures.

TypeMeaning
:voidNo return value. Do not use it as an argument type.
:int, :int32Signed 32-bit integer.
:uint, :uint32Unsigned 32-bit integer.
:long, :int64Signed 64-bit integer.
:ulong, :uint64Unsigned 64-bit integer represented by a Clojure long.
:int16Signed 16-bit integer.
:uint16Unsigned 16-bit integer.
:int8, :byte, :charSigned 8-bit integer.
:uint8Unsigned 8-bit integer.
:size_tUnsigned 64-bit size.
:ssize_tSigned 64-bit size.
:float32-bit floating-point number.
:double64-bit floating-point number.
:boolOne-byte C boolean.
:pointerA pointer, see Use native memory.
:stringPointer to a NUL-terminated UTF-8 string.

:long and :ulong are always 64-bit types. A C long is 32 bits on Windows. Use the type that matches the C declaration.

A :bool argument uses Clojure truthiness. A :bool return value is true or false:

(defcfn window-should-close? "WindowShouldClose" [] :bool)
(when-not (window-should-close?) ...)

A :uint8 return value is a number. In Clojure, both 0 and 1 are truthy.

Use :int for predicates declared to return C int, such as isalpha. Test the result with zero?. A nonzero result means true:

(defcfn isalpha "isalpha" [:int] :int)
(not (zero? (isalpha 97))) ;;=> true

Use :bool only for C boolean values. It reads one byte, so it can return false for a nonzero int whose low byte is zero.

A :string argument points to temporary memory. The pointer is valid only until the C function returns.

If the C function stores the pointer, allocate the string with string->ptr in an arena (see Arenas). Keep the arena open while C uses the pointer.

A :string return value reads the pointer as UTF-8. A NULL return value becomes nil.

Pass a struct by value

Use a layout instead of a type keyword to pass or return a struct by value. A struct layout has the form [:struct fields]. Each field is a [name type] pair in C declaration order. The order determines the memory offsets, and the name determines the map key. A field type is a type keyword or another layout. Pass struct values as maps:

(defcfn c-div "div" [:int :int] [:struct [[:quot :int] [:rem :int]]])
(c-div 7 2)
;;=> {:quot 3 :rem 1}

Layouts nest, and so do their values:

(def point [:struct [[:x :int] [:y :int]]])
(def rect [:struct [[:lo point] [:hi point]]])
(defcfn rect-grow "rect_grow" [rect :int] rect)
(rect-grow {:lo {:x 1 :y 1} :hi {:x 5 :y 5}} 2)
;;=> {:lo {:x -1 :y -1} :hi {:x 7 :y 7}}

A struct value must contain each layout field and no other field. A missing or unknown field causes an error.

sizeof and alignof accept a layout. sizeof accounts for the alignment of each struct field. In this example, the struct has seven padding bytes between :c and :d:

(ffi/sizeof [:struct [[:c :char] [:d :double]]])
;;=> 16
(ffi/alignof [:struct [[:c :char] [:d :double]]])
;;=> 8

To map a struct to a value of your own, wrap the binding:

(defn body-position [id]
  (let [{:keys [x y z]} (c-body-position id)]
    (vec3 x y z)))

On the JVM, struct calls use the FFM linker. Native images require libffi for these calls. Binding fails if libffi computes a different struct layout.

Every babashka binary includes libffi, except the musl static binary and a build made with BABASHKA_LIBFFI=none. bb describe shows the version under :libffi/version. In a binary without libffi, a struct binding causes an error.

Structs are not supported in variadic signatures.

A :string struct field follows the same pointer-lifetime rules as a :string argument.

Call a variadic function

Put :& after the fixed argument types:

(defcfn c-open "open" [:string :int :&] :int)

(c-open path O_RDONLY)
(c-open path flags 0644)

The values after the fixed arguments determine the variadic types:

Clojure valueVariadic type
Integer, pointer, or nil64-bit integer
Floating-point number or ratiodouble
StringNUL-terminated C string

The fixed arguments and variadic values must match the C function contract. For example, a printf format must match its values.

(defcfn c-printf "printf" [:string :&] :int)
(c-printf "%s: %.0f\n" "count" 42.0)

Declare types after :& to fix the tail types and require an exact arity:

(defcfn log-line "printf" [:string :& :int :string] :int)
(log-line "%d: %s\n" 42 "started")

Declare the types C receives after promotion. Use :double, never :float, and :int or wider, never a narrower integer. If a type would be promoted, the binding reports the type to declare.

Use native memory

Pointers refer to native memory. The API rejects these values before a call to C:

  • a heap segment without a C address
  • memory from a closed arena
  • memory from a confined arena created by another thread

On the JVM, a pointer is a java.lang.foreign.MemorySegment. Babashka does not expose this class to scripts because it increases the binary size. Use size, address, slice, reinterpret, and pointer? to work with a MemorySegment instead.

NOTE Do not pass a confined segment from another thread. C can bypass the thread-access restriction.

alloc returns a segment with a size. Access outside a nonzero segment throws an IndexOutOfBoundsException.

C does not report the size of a returned pointer. The pointer has size zero. Memory access functions reject these pointers.

Before you access the memory, specify its size with reinterpret:

;; C returned p without a size. The struct has 16 bytes.
(ffi/read (ffi/reinterpret p 16) :int 8)

alloc 0 and an end-of-block slice also have size zero. ptr->string reads zero-size pointers until the first NUL byte. For pointers with a nonzero size, it reads within that size. Declare a C string return type as :string.

Use size to get the segment size. Use address to convert a pointer to a long. Use segment to convert a raw address to a pointer. Use slice to select part of a segment. The + function does not support pointers.

(ffi/size p)             ;;=> 16
(ffi/address p)          ;;=> 4438706736
(ffi/segment 4438706736) ;;=> a pointer of size 0
(ffi/segment addr 16)    ;;=> a pointer of size 16
(ffi/slice p 8)          ;;=> the rest of p from byte 8
(ffi/reinterpret p 64)   ;;=> p with size 64

A C pointer argument accepts a pointer or nil. A nil value is NULL. Pointer arguments reject numbers. ffi/null is the NULL pointer:

(ffi/null? ffi/null)
;;=> true

Every allocation belongs to an arena. The arena controls the lifetime of the memory. alloc always takes an arena:

(with-open [arena (ffi/confined-arena)]
  (let [p (ffi/alloc arena 16)]
    (ffi/write p :int 42)
    (ffi/read p :int)))
;;=> 42

alloc also takes a type keyword or a struct layout instead of an integer byte count. It uses the natural alignment of the type or layout:

(ffi/alloc arena :pointer)                          ; 8 bytes
(ffi/alloc arena [:struct [[:x :int] [:y :int]]])   ; 8 bytes, aligned for the struct

Memory allocated by C

A C function can return memory that the caller has to release. Give that pointer to an arena, with the library's own deallocator as the cleanup function. The arena calls the deallocator when it closes:

(defcfn duckdb-free "duckdb_free" [:pointer] :void)

(with-open [arena (ffi/confined-arena)]
  (let [p (ffi/reinterpret (c-value-varchar res 0 0) 64 arena duckdb-free)]
    (ffi/ptr->string p)))

Use the deallocator that the library documents, such as duckdb_free or sqlite3_free. A library allocates from its own heap. On Windows a library built against another C runtime has a different heap, and the wrong deallocator corrupts it.

For memory that the C allocator returned, and a library that documents no deallocator of its own, bind free:

(defcfn c-free "free" [:pointer] :void)

NOTE After the deallocator runs, do not use the pointer. This can corrupt memory or stop the process.

Arenas

An arena owns its allocated memory. When the arena closes, it releases this memory.

Create an arena in with-open to close it automatically:

(with-open [arena (ffi/confined-arena)]
  (let [p (ffi/alloc arena :int)
        q (ffi/alloc arena 256)]
    (ffi/write p :int 42)
    (ffi/read p :int)))
;;=> 42

The arena releases p and q when the body ends. It also releases them if the body throws. After release, memory access throws an IllegalStateException. C functions reject pointers from a closed arena.

NOTE Do not close the arena while C uses its memory. C can continue to use the memory after the arena closes.

alloc chooses the correct alignment for a type or layout. For a byte count, it uses 16-byte alignment. Specify another alignment only when the C API requires it:

(ffi/alloc arena 4096 64)   ; 4096 bytes on a 64-byte boundary

Use confined-arena for memory that should only be accessible from one thread. Use shared-arena for memory that should be accessible from multiple threads. Both arena types work with with-open.

NOTE Do not close the shared arena while another thread is in a C call that uses its memory. The call can continue after the arena closes.

The garbage collector releases an auto-arena after it becomes unreachable. While C uses its pointers, keep the arena reachable.

A global-arena exists until the process stops. You cannot close an automatic or global arena.

read and write accept an optional byte offset, and it is always the last argument:

(ffi/write p :int 42)        ; at offset 0
(ffi/write p :double 1.5 8)  ; at offset 8

(ffi/read p :int)
(ffi/read p :double 8)

Use byte offsets to access a buffer directly. For a struct or an array, describe the memory with a layout and use place to select its members. See Read and write a struct.

read supports each listed type except :void. write also excludes :string. Write a string address as :pointer.

Use read-array and write-array to copy elements of one scalar type between native memory and a Java primitive array. The copy uses memcpy and does not decode each element. Both functions accept an optional byte offset:

(ffi/write-array p :byte (byte-array [1 2 3 4]))
(ffi/read-array p :byte 4)
;;=> byte array [1 2 3 4]
(ffi/write-array p :int (int-array [1 2 3 4]))
(ffi/read-array p :int 4)
;;=> int array [1 2 3 4]
(ffi/read-array p :double 512 4096)   ; 512 doubles from byte offset 4096

The type gives the element width and nothing else. :int, :uint and :int32 fill an int[] with the bits as they are, so a :uint above Integer/MAX_VALUE reads as a negative int. The eight-byte types fill a long[], and :pointer fills a long[] of addresses. For pointers, use read with [:array :pointer n], which returns a vector of pointers. For elements decoded the way read decodes them, or for an array of structs, use read with an [:array t n] layout.

Use copy to copy bytes between two pointers, and clone to allocate a copy in an arena. Without a count, copy copies the byte size of the source, and the destination must be at least that large. To copy into the middle of a pointer, slice it first. The source comes first, as in fs/copy:

(ffi/copy src dst)                      ; all of src
(ffi/copy src (ffi/slice dst 16) 8)     ; 8 bytes, at byte offset 16 in dst
(ffi/clone arena src)                   ; a new pointer with the same bytes

Both need pointers with a size. Use reinterpret to specify a size for a pointer returned by C.

Use byte-buffer to create a zero-copy java.nio.ByteBuffer view of native memory:

(ffi/byte-buffer p 4096)

The buffer and native memory share the same bytes.

NOTE After you release the native memory, do not use the buffer. An invalid memory access can stop the process.

Use sizeof to get the size of a type:

(ffi/sizeof :pointer)
;;=> 8

Use string->ptr to allocate a C string in an arena:

(with-open [arena (ffi/confined-arena)]
  (ffi/ptr->string (ffi/string->ptr arena "hello")))
;;=> "hello"

ptr->string reads a string at the specified address. It returns nil for the NULL address. A pointer returned by C has no size, so ptr->string reads until the first NUL byte. This is the behavior of a :string return type:

(ffi/ptr->string (duckdb-value-varchar res col row))

If you know the size of the buffer, give a limit in bytes. If the buffer has no NUL byte within the limit, the function throws an error instead of reading past the buffer:

(ffi/ptr->string p 4096)

A limit only narrows the read. A pointer with a known size keeps that size, even when the limit is larger.

If memory contains a string pointer, use read with :string:

(ffi/read pointer-slot :string)

This operation first reads the pointer from pointer-slot. Then it reads the string at that pointer.

NOTE Use only valid addresses and offsets. An invalid memory access can stop the process.

Read and write a struct

read and write support struct layouts in place of type keywords. read returns the struct as a map. write accepts a map:

(def point [:struct [[:x :int] [:y :int]]])

(with-open [arena (ffi/confined-arena)]
  (let [p (ffi/alloc arena point)]
    (ffi/write p point {:x 3 :y 4})
    (ffi/read p point)))
;;=> {:x 3, :y 4}

A C function can fill a struct through an out parameter. The next example shows the required calls:

(defcfn fill-point "fill_point" [:pointer :int :int] :void)

(with-open [arena (ffi/confined-arena)]
  (let [out (ffi/alloc arena point)]
    (fill-point out 7 11)
    (ffi/read out point)))
;;=> {:x 7, :y 11}

Use place for one member. It takes the layout and a member name, or a path of member names and array indices into nested layouts, and returns a place: the member resolved once. read and write take a place where they take a type, so the offset and the type come from the layout and there is nothing to compute. Make a place once and keep it, as with cfn.

Use a place for a member. Use the layout size for the stride over an array of structs.

(def bone [:struct [[:name [:array :char 32]] [:parent :int]]])
(def parent (ffi/place bone :parent))

(ffi/read p parent)                                   ;=> 7
(ffi/write p parent 3)

(ffi/read p (ffi/place outer [:msgs 1 :data :result]))   ; through an array and a union
(ffi/write p (ffi/place outer [:msgs 1]) {:msg 1 :easy nil :data [:result 0]})   ; a whole nested struct
(ffi/read arr (ffi/place point :y) (* i (ffi/sizeof point)))   ; the byte offset still composes: a stride

A path that names nothing is an error when the place is made. A layout is closed, so a missing member is a mistake in the program.

Without a path, the place is the whole layout. (ffi/read p (ffi/place point)) is (ffi/read p point) with its lookup done once. On the JVM that lookup is most of the cost of reading a small struct, so a struct read in a loop is worth hoisting there; in babashka the decode dominates and the gain is small. The layout itself stays the form for a one-off:

(def point-at (ffi/place point))
(ffi/read p point-at)        ;=> {:x 1, :y 2}

The byte offset selects one element of an array of structs:

(ffi/read arr point (* i (ffi/sizeof point)))

Layouts nest, and a nested struct is a nested map.

A :string field stores a pointer to bytes outside the struct. The bytes must remain valid after write returns. Allocate the bytes and write the pointer:

(def named [:struct [[:id :int] [:name :string]]])

(with-open [arena (ffi/confined-arena)]
  (let [p (ffi/alloc arena named)]
    (ffi/write p named {:id 7 :name (ffi/string->ptr arena "seven")})
    (ffi/read p named)))
;;=> {:id 7, :name "seven"}

read copies the bytes into a string without allocating native memory.

Fixed arrays

A C struct often holds a fixed array: char name[32], int32_t v[4], double m[2][2]. The layout for one is [:array elem n]. elem is a type keyword or a layout, so an array can hold structs or other arrays:

(def bone [:struct [[:name [:array :char 32]] [:parent :int]]])   ; raylib BoneInfo
(def quad [:struct [[:v [:array :int 4]]]])
(def mat2 [:struct [[:m [:array [:array :double 2] 2]]]])

read returns an array as a vector. write accepts a vector, a list, or a Java array with exactly n elements. A value with another length is an error, just as a struct value with a missing field is an error:

(with-open [arena (ffi/confined-arena)]
  (let [p (ffi/alloc arena quad)]
    (ffi/write p quad {:v [1 2 3 4]})
    (ffi/read p quad)))
;;=> {:v [1 2 3 4]}

A char array reads as a vector of bytes. C uses char for both strings and raw bytes. To read the string in a fixed-width field, read that field with a limit:

(with-open [arena (ffi/confined-arena)]
  (let [p (ffi/alloc arena bone)]
    (fill-bone p)
    (ffi/ptr->string (ffi/slice p 0 32) 32)))
;;=> "spine"

The function throws if the field contains no NUL byte within the limit.

C never passes an array by value. A parameter declared as an array is a pointer to its first element, so declare :pointer for it. A struct that holds an array is passed by value in the normal way, and cfn rejects a bare array layout in a signature.

Unions

Use [:union [[name type] ...]] to describe a C union. Its size includes space for its largest member and padding for the required alignment:

(def curl-msg
  [:struct [[:msg :int]
            [:easy :pointer]
            [:data [:union [[:whatever :pointer] [:result :int]]]]]])

Use the C API's rules to select the active union member. For example, CURLMsg identifies the active member in a separate field. Use place to read that member:

(def CURLMSG_DONE 1)   ; curl/multi.h

(def msg-kind (ffi/place curl-msg :msg))
(def msg-result (ffi/place curl-msg [:data :result]))   ; the path names the member, so the type is known

(when (= (ffi/read p msg-kind) CURLMSG_DONE)
  (ffi/read p msg-result))

Reading the union itself returns a pointer with the union's size. Read a member from this pointer with its type: (ffi/read data :int).

write takes a union as a pair of the member name and its value:

(ffi/write p curl-msg {:msg 1 :easy nil :data [:result 0]})

To write one member directly, use its type. Every member starts at offset 0:

(ffi/write data :int 0)

A union is not passed by value in a signature, alone or inside a struct. Declare :pointer and read it from memory.

Out parameters

Allocate memory for a C out parameter. Then pass its address to the C function:

(defcfn sqlite3-open "sqlite3_open" [:string :pointer] :int)

(def database
  (with-open [arena (ffi/confined-arena)]
    (let [database-pointer (ffi/alloc arena :pointer)]
      (sqlite3-open "example.db" database-pointer)
      (ffi/read database-pointer :pointer))))

The returned database pointer is managed by SQLite. Define the related close function and call it when you finish with the database:

(defcfn sqlite3-close "sqlite3_close" [:pointer] :int)
(sqlite3-close database)
;;=> 0

Create a callback

Use callback to pass a Clojure function to C:

(with-open [arena (ffi/confined-arena)]
  (let [comparator
        (ffi/callback
         arena
         (fn [left-pointer right-pointer]
           (compare (ffi/read (ffi/reinterpret left-pointer 4) :int)
                    (ffi/read (ffi/reinterpret right-pointer 4) :int)))
         [:pointer :pointer]
         :int)]
    (qsort values 5 4 comparator)))

callback returns a function pointer. The arena owns the pointer, exactly as it owns the memory that alloc returns. The pointer is valid until the arena releases it.

Choose the arena for the thread that calls back:

  • A confined arena accepts a call from its own thread only. If C calls back during a call that you make, use this arena. The comparison function above uses this pattern. This arena is the cheapest one.
  • A shared arena allows C to invoke the callback from any thread, including a thread that your code did not create. Use it for asynchronous callbacks, such as event-loop notifications.
  • A global arena never releases the pointer. Use one for a callback that lives as long as the process, such as a signal handler.
  • An automatic arena releases the pointer when the pointer itself becomes unreachable. The garbage collector cannot see the copy that C holds. Use this arena only when your reference outlives every call that C makes.

A :pointer callback argument comes from C and has size zero.

Before you read the memory, specify its size with reinterpret.

If C keeps the callback, keep its pointer. Before the arena releases the callback, unregister it.

A :bool callback argument becomes true or false.

NOTE Do not let a callback throw an exception. Catch exceptions inside the callback, or the process can stop.

Performance and limits

The host determines how babashka.ffi calls C and how much each call costs. Binding metadata shows which backend it uses:

(meta (ffi/cfn "abs" [:int] :int))
;;=> #:babashka.ffi{:backend :trampoline}

On the JVM

On the JVM, the FFM linker supports every signature, including structs by value. It creates a downcall handle for each signature, and the JIT compiles the handle. This path has no fixed signature limits.

A primitive call costs about 3 to 6 nanoseconds after JVM warmup. Creating a binding takes about 70 microseconds. Create bindings once and reuse them.

Declare variadic tail types to avoid about 55 nanoseconds of inference per call. An inferred tail creates a binding on the first call with each new shape.

Struct bindings support concurrent calls from multiple threads and reentrant calls.

In a babashka native binary

An image cannot compile a downcall handle at run time, so babashka carries a fixed set of call signatures compiled ahead of time. A signature in that set calls through a trampoline in about 30 nanoseconds. The set covers:

  • A function with up to 6 arguments.
  • Up to 3 :float or :double arguments in any combination, or 4 of the same floating-point type.
  • A function with only integer or pointer arguments, up to 10 arguments.
  • A function that returns :float, up to 4 arguments.

Argument order does not change this set.

On macOS on AArch64, a signature with a type narrower than 8 bytes after the eighth argument calls through libffi. A trampoline passes each argument as 8 bytes, and that platform gives an argument on the stack its own width.

Every shape in the set adds compiled code to the babashka binary. If a shape you need is missing, or a call you make often falls back to libffi, open an issue. The set can grow.

Everything else calls through libffi: a fixed signature outside the set, every variadic call, and every struct call. A libffi call takes about 1 microsecond.

In a build without libffi, binding fails for variadic signatures and fixed signatures outside this set.

These figures include only the call itself. In babashka, the interpreter usually costs more. A loop with recur adds roughly 30 nanoseconds per iteration before C runs.

On Node.js

Run scripts on Node.js 26.1 or newer with nbb:

nbb --classpath src examples/sqlite.cljc

To compile with ClojureScript or shadow-cljs, use JDK 25 or newer. For ClojureScript :advanced builds, set :infer-externs true.

The binding metadata names the backend :node. These parts differ from the JVM:

  • Close an arena with ffi/with-open. It closes the arena when the body returns. Do not return a promise that still uses the arena.
  • A pointer is a Pointer: an address, a size and the arena that owns it.
  • An allocation is a zeroed Buffer. An arena holds its buffers until it closes. shared-arena is the same as confined-arena.
  • A 64-bit integer returns as a number when it is a safe integer, otherwise as a bigint. Arguments accept either.
  • An unsigned 64-bit value reads as unsigned.
  • read-array returns a typed array and write-array takes one. The eight-byte types use a BigInt64Array.
  • byte-buffer returns a Buffer view.
  • C must call a callback on the JavaScript thread. The function must not throw and must not return a promise.
  • A binding with up to 4 arguments reports a wrong argument count as got more than 1 or got fewer than 2, without the exact count.

cfn rejects these signatures when the binding is created:

  • A struct by value in a signature. Declare :pointer and pass the layout through memory.
  • A variadic signature.
  • A function pointer as the symbol. Bind a function by name.

On an Apple M-series machine, under nbb, a scalar call costs about 150 nanoseconds and a scalar read about 550. A call from plain JavaScript costs about 12 nanoseconds.

String arguments

A :string argument copies the string into native memory for the call and releases it afterwards. That copy costs about 300 nanoseconds on the JVM and in babashka, several times the call itself.

If a loop passes the same string repeatedly, convert it once with string->ptr and declare the parameter as :pointer:

(def strlen (ffi/cfn "strlen" [:pointer] :size_t))

(with-open [arena (ffi/confined-arena)]
  (let [p (ffi/string->ptr arena "hello world")]
    (dotimes [_ 1000000] (strlen p))))

In a babashka binary, each call takes 379 nanoseconds through :string and 85 nanoseconds through a pointer created once. An empty loop takes 26 nanoseconds per iteration.

Callbacks

In a native image, callbacks have these limits:

  • A callback can have up to 4 arguments, with up to 2 :double arguments among them.
  • A callback with only integer and pointer arguments can have up to 6. FSEvents calls back with 6 and GLFW's key callback with 5.
  • A callback cannot use :float.
  • A return type can be :void, an integer type, :pointer, or :double. For :pointer, return a pointer, or nil for null.

If a C API needs a callback shape outside this set, open an issue.

Build your own native image

Use Oracle GraalVM 25 or newer to build a native image with babashka.ffi. The library includes call trampolines and reachability metadata.

  1. Add the library, the GraalVM SDK and graal-build-time to deps.edn:

    {:deps {io.github.babashka/ffi {:git/url "https://github.com/babashka/ffi"
                                    :git/sha "..."}
            com.github.clj-easy/graal-build-time {:mvn/version "1.0.5"}}
     :aliases {:native {:extra-deps {org.graalvm.sdk/nativeimage {:mvn/version "25.0.2"}}}}}
    
  2. Compile the Java trampoline classes from source. For a git dependency, use the sources in the library checkout under ~/.gitlibs. For a Maven dependency, first extract the sources from the jar:

    unzip -o "$(clojure -Spath -A:native | tr ':' '\n' | grep 'ffi-.*\.jar')" \
      'babashka/ffi/impl/*.java' -d src-java
    

    Replace path/to/ffi/src-java below with the source directory. For sources extracted from the jar, use src-java.

    javac --release 25 -cp "$(clojure -Spath -A:native)" -d target/classes \
      path/to/ffi/src-java/babashka/ffi/impl/FfiTrampoline.java \
      path/to/ffi/src-java/babashka/ffi/impl/FfiTrampolineOrdered.java
    
  3. Compile your namespaces ahead of time into target/classes.

  4. Build the image:

    native-image \
      -cp "$(clojure -Spath -A:native):target/classes" \
      --features=clj_easy.graal_build_time.InitClojureClasses \
      -H:+ForeignAPISupport \
      --enable-native-access=ALL-UNNAMED \
      --no-fallback \
      -o my-program my.main
    

Use graal-build-time to initialize babashka.ffi and the other Clojure namespaces at build time.

See script/native_test.clj for a complete build example.

For a Windows image, add these options in step 4:

-H:+UnlockExperimentalVMOptions
-H:ConfigurationResourceRoots=babashka/ffi/native-image-windows

Windows assigns argument registers by position, so its image uses FfiTrampolineOrdered, a trampoline per argument order. Other images use FfiTrampoline and leave the other class out. The options register the callback shapes that only Windows needs.

The limits in In a babashka native binary and Callbacks apply. This build does not link libffi. Binding fails for structs passed by value, variadic signatures, and fixed signatures outside the trampoline set.

To link libffi, as babashka does in its own build:

  1. Add path/to/ffi/src-java/babashka/ffi/impl/Libffi.java to the javac command in step 2.

  2. Set BABASHKA_FEATURE_LIBFFI=true in the environment of native-image.

  3. Add these options in step 4. The last one can also be the path of a static libffi archive:

    -EBABASHKA_FEATURE_LIBFFI
    -H:+UnlockExperimentalVMOptions
    -H:NativeLinkerOption=-lffi
    

babashka.ffi loads the libffi bindings only when the variable is true while the image is built. With the variable set and no libffi to link, the image does not link.

On macOS on AArch64, a signature with a type narrower than 8 bytes after the eighth argument does not use a trampoline. Without libffi it throws when the binding is made.

Examples

The examples directory contains complete programs for SQLite, CPython, libffi, and raylib, with a note on running each on either host.

These libraries use babashka.ffi:

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close