Creating C Extension Libraries¶
This guide walks through building a Kaappi library that wraps C code
via the FFI. The same pattern is used by kaappi-net, kaappi-crypto,
kaappi-pg, kaappi-sqlite, and kaappi-redis.
Extensions can also be written in Zig, which offers memory safety and built-in cross-compilation.
Calling C directly¶
Before packaging anything, know that you can call C functions from any
script or REPL session with (kaappi ffi) — no library structure needed:
(import (kaappi ffi))
;; Open a shared library
(define libm (ffi-open "libm.dylib")) ;; macOS
;; (define libm (ffi-open "libm.so.6")) ;; Linux
;; Bind a C function: (ffi-fn lib "name" (param-types ...) return-type)
(define c-sqrt (ffi-fn libm "sqrt" '(double) 'double))
(define c-pow (ffi-fn libm "pow" '(double double) 'double))
(c-sqrt 2.0) ;=> 1.4142135623730951
(c-pow 2.0 10.0) ;=> 1024.0
;; Clean up
(ffi-close libm)
The rest of this guide covers wrapping your own C code as a reusable, installable library. See the FFI reference for the full API.
Directory layout¶
kaappi-mylib/
├── Makefile # builds the shared library
├── kaappi.pkg # package manifest for thottam
├── csrc/
│ └── kaappi_mylib.c # C implementation
├── lib/kaappi/
│ ├── mylib.sld # high-level Scheme API
│ └── mylib/
│ └── ffi.sld # low-level FFI bindings
└── tests/
└── test-mylib.scm # tests
Step 1: Write the C code¶
Create csrc/kaappi_mylib.c. Functions that will be called from Scheme
must use C-compatible types and follow the naming convention
k<libname>_<function>:
#include <string.h>
#include <math.h>
/* (double, double) -> double */
double kmylib_distance(double x, double y) {
return sqrt(x * x + y * y);
}
/* (string) -> int */
int kmylib_length(const char *s) {
return (int)strlen(s);
}
/* (int, int) -> int */
int kmylib_add(int a, int b) {
return a + b;
}
Supported C types¶
| C type | FFI symbol | Scheme type |
|---|---|---|
int |
'int |
fixnum |
long |
'long |
fixnum |
double |
'double |
flonum |
float |
'float |
flonum |
const char * |
'string |
string |
void * |
'pointer |
fixnum (address) |
void |
'void |
void |
bool |
'bool |
boolean |
int8_t ... int64_t |
'int8 ... 'int64 |
fixnum |
uint8_t ... uint64_t |
'uint8 ... 'uint64 |
fixnum |
size_t |
'size_t |
fixnum |
char |
'char |
fixnum |
Functions can take up to 5 parameters.
Step 2: Write the Makefile¶
UNAME := $(shell uname)
ifeq ($(UNAME), Darwin)
DYLIB_EXT := dylib
DYLIB_FLAG := -dynamiclib
else
DYLIB_EXT := so
DYLIB_FLAG := -shared -fPIC
endif
TARGET := libkaappi_mylib.$(DYLIB_EXT)
all: $(TARGET)
$(TARGET): csrc/kaappi_mylib.c
$(CC) $(DYLIB_FLAG) -o $@ $< -O2 -Wall -lm
clean:
rm -f $(TARGET)
.PHONY: all clean
If your C code depends on an external library (e.g., OpenSSL, libpq),
add it via pkg-config:
SSL_CFLAGS := $(shell pkg-config --cflags openssl 2>/dev/null)
SSL_LIBS := $(shell pkg-config --libs openssl 2>/dev/null || echo "-lssl -lcrypto")
$(TARGET): csrc/kaappi_mylib.c
$(CC) $(DYLIB_FLAG) -o $@ $< $(SSL_CFLAGS) $(SSL_LIBS) -O2 -Wall
Step 3: Write the FFI wrapper¶
Create lib/kaappi/mylib/ffi.sld — the low-level bindings that map
each C function to a Scheme procedure:
(define-library (kaappi mylib ffi)
(import (scheme base) (kaappi ffi))
(export %distance %length %add)
(begin
(define %lib (ffi-open "libkaappi_mylib"))
(define %distance (ffi-fn %lib "kmylib_distance" '(double double) 'double))
(define %length (ffi-fn %lib "kmylib_length" '(string) 'int))
(define %add (ffi-fn %lib "kmylib_add" '(int int) 'int))))
ffi-open searches for the shared library in this order:
1. The given path as-is
2. With .dylib (macOS) or .so (Linux) appended
3. ~/.kaappi/lib/ (where thottam installs packages)
Step 4: Write the high-level API¶
Create lib/kaappi/mylib.sld — the public API that users import.
This layer adds error handling, validation, and Scheme-idiomatic names:
(define-library (kaappi mylib)
(import (scheme base)
(kaappi mylib ffi))
(export distance string-byte-length add)
(begin
(define (distance x y) (%distance (exact->inexact x) (exact->inexact y)))
(define (string-byte-length s) (%length s))
(define (add a b) (%add a b))))
Users import only the high-level library:
Step 5: Create the package manifest¶
Create kaappi.pkg at the repo root:
If your library depends on other Kaappi packages:
If your package is hosted outside the official kaappi org, add a source:
field so users can install it directly:
Step 6: Test¶
A simple test file:
(import (scheme base) (scheme write) (kaappi mylib))
(display (distance 3 4)) (newline) ; 5.0
(display (string-byte-length "hi")) (newline) ; 2
(display (add 40 2)) (newline) ; 42
Step 7: Publish¶
Push to GitHub as kaappi/<package-name>, then users install with:
Thottam clones the repo, runs make, and copies the .dylib/.so and
.sld files to ~/.kaappi/lib/. After installation, --lib-path is
not needed.
FFI callbacks¶
Pass Scheme procedures to C functions that expect function pointers:
(define cb (ffi-callback
(lambda (a b) (- a b))
'(pointer pointer)
'int))
;; Pass cb to a C sorting function, etc.
(ffi-callback-release cb) ;; free when done
Errors raised inside an ffi-callback body are captured and re-raised
on the Scheme side when the C call returns. This means guard and
with-exception-handler work as expected around FFI calls that invoke
Scheme callbacks.
FFI type mismatches, arity errors, and range violations produce descriptive error messages that name the FFI function and expected type, making debugging straightforward.
Error handling pattern¶
C functions typically return negative values on error. Wrap them in Scheme with error checks:
/* C side */
static int last_err = 0;
int kmylib_connect(const char *host, int port) {
int fd = do_connect(host, port);
if (fd < 0) last_err = errno;
return fd;
}
int kmylib_last_error(void) { return last_err; }
;; Scheme side
(define %connect (ffi-fn lib "kmylib_connect" '(string int) 'int))
(define %last-error (ffi-fn lib "kmylib_last_error" '() 'int))
(define (connect host port)
(let ((fd (%connect host port)))
(if (< fd 0)
(error "connect failed" host port (%last-error))
fd)))
File descriptors and fibers¶
A C function that returns a file descriptor — the connect wrapper
above, an accept loop, a pipe — composes with Kaappi's fiber scheduler
through fd->port: it wraps
the raw fd as a binary port whose reads and writes suspend the calling
fiber (not the OS thread) whenever they would block. No C changes are
needed; this is how kaappi-net serves many connections from a single
thread:
(define conn (fd->port (connect "example.com" 80)))
(spawn (lambda ()
(write-bytevector request conn)
(flush-output-port conn)
(let ((reply (read-bytevector 4096 conn))) ; parks this fiber only
(close-port conn) ; also closes the fd
(process reply))))
The port owns the descriptor after wrapping: let close-port close it
rather than closing the fd through your own C path.
Next: Zig Extensions