A string of recent experiments around observability and security for agentic AI systems led me down the eBPF rabbit-hole. When I emerged, I came back with a full optimizing compiler for a Common Lisp-based DSL for eBPF called Whistler.
Whistler lets you write shorter code, with less ceremony than eBPF C code, and still produce highly-optimized eBPF output, equivalent or better than clang. And Whistler generates those ELF eBPF files directly, without any of the eBPF clang+llvm toolchain.
In addition to generating object code files directly, and loading them in the traditional way, you can actually inline Whistler code directly in your Common Lisp programs and have them compiled/loaded/unloaded as part of your traditional REPL process, where no object file even lands on disk.
A taste#
Here’s a kprobe that counts every execve call on the system:
(with-bpf-session () (bpf:map counter :type :hash :key-size 4 :value-size 8 :max-entries 1 ) (bpf:prog trace ( :type :kprobe :section "kprobe/__x64_sys_execve" :license "GPL" ) (incf (getmap counter 0 )) 0 ) (bpf:attach trace "__x64_sys_execve" ) (loop ( sleep 1 ) ( format t "execve count: ~d~%" (bpf:map-ref counter 0 ))))
That’s a complete, runnable program. The bpf:prog body compiles to eBPF bytecode during macroexpansion. The bytecode is embedded as a literal in the expansion. At runtime, the map is created, the program is loaded into the kernel, and the probe is attached. The loop at the bottom is plain Common Lisp, polling the map every second.
A real-world example#
Here’s something more substantial — a uprobe that traces every ffi_call invocation in libffi, counting calls by program name and function signature:
(with-bpf-session () ;; BPF side — compiled to bytecode at macroexpand time (bpf:map stats :type :hash :key-size 40 :value-size 8 :max-entries 10240 ) (bpf:prog ffi_call_tracker ( :type :kprobe :section "uprobe/ffi_call" :license "GPL" ) ( let ((cif (make-ffi-cif)) (ft (make-ffi-type)) (key (make-stats-key))) (probe-read-user cif (sizeof ffi-cif) (pt-regs-parm1)) (probe-read-user ft (sizeof ffi-type) (ffi-cif-rtype cif)) (setf (stats-key-rtype key) (ffi-type-type-code ft) (stats-key-abi key) (ffi-cif-abi cif) (stats-key-nargs key) (ffi-cif-nargs cif)) (get-current-comm (stats-key-comm-ptr key) 16 ) (memset key 16 #xFF 16 ) (do-user-ptrs (atype-ptr (ffi-cif-arg-types cif) (ffi-cif-nargs cif) +max-args+ :index i) (probe-read-user ft (sizeof ffi-type) atype-ptr) (setf (stats-key-arg-types key i) (ffi-type-type-code ft))) (incf (getmap stats key))) 0 ) ;; Userspace side — normal CL code, runs at runtime (bpf:attach ffi_call_tracker "/lib64/libffi.so.8" "ffi_call" ) ( format t "Tracing ffi_call. Press Ctrl-C to dump stats.~%" ) (handler-case (loop ( sleep 1 )) (sb-sys:interactive-interrupt () ;; Iterate the map and print results ... )))
... continue reading