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:
--enable-native-access=ALL-UNNAMED.Enable-Native-Access manifest attribute in an uberjar.Without either setting, the JDK warns about native access.
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.
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/libLoad 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:
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/libOn 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.
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.
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:
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.
defcfnUse 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.
Use type keywords that denote C types in function signatures.
| Type | Meaning |
|---|---|
:void | No return value. Do not use it as an argument type. |
:int, :int32 | Signed 32-bit integer. |
:uint, :uint32 | Unsigned 32-bit integer. |
:long, :int64 | Signed 64-bit integer. |
:ulong, :uint64 | Unsigned 64-bit integer represented by a Clojure long. |
:int16 | Signed 16-bit integer. |
:uint16 | Unsigned 16-bit integer. |
:int8, :byte, :char | Signed 8-bit integer. |
:uint8 | Unsigned 8-bit integer. |
:size_t | Unsigned 64-bit size. |
:ssize_t | Signed 64-bit size. |
:float | 32-bit floating-point number. |
:double | 64-bit floating-point number. |
:bool | One-byte C boolean. |
:pointer | A pointer, see Use native memory. |
:string | Pointer 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.
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.
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 value | Variadic type |
|---|---|
Integer, pointer, or nil | 64-bit integer |
| Floating-point number or ratio | double |
| String | NUL-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.
Pointers refer to native memory. The API rejects these values before a call to C:
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
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.
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 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.
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.
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.
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
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 :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.
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, 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.
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:
:float or :double arguments in any combination, or 4 of the
same floating-point type.: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.
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:
ffi/with-open. It closes the arena when the body
returns. Do not return a promise that still uses the arena.Pointer: an address, a size and the arena that owns it.Buffer. An arena holds its buffers until it
closes. shared-arena is the same as confined-arena.read-array returns a typed array and write-array takes one. The
eight-byte types use a BigInt64Array.byte-buffer returns a Buffer view.got more than 1 or got fewer than 2, without the exact count.cfn rejects these signatures when the binding is created:
:pointer and pass the layout
through memory.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.
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.
In a native image, callbacks have these limits:
:double arguments
among them.:float.: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.
Use Oracle GraalVM 25 or newer to build a native image with babashka.ffi.
The library includes call trampolines and reachability metadata.
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"}}}}}
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
Compile your namespaces ahead of time into target/classes.
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:
Add path/to/ffi/src-java/babashka/ffi/impl/Libffi.java to the javac
command in step 2.
Set BABASHKA_FEATURE_LIBFFI=true in the environment of native-image.
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.
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
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |