A Lisp compiling to eBPF.
Whistler is a Common Lisp dialect for writing eBPF programs. It compiles s-expressions to eBPF bytecode and emits valid ELF object files your kernel loads directly. The compilation pipeline has zero dependency on C, clang, or LLVM.
(defmap pkt-count :type :array :key-size 4 :value-size 8 :max-entries 1) (defprog count-packets (:type :xdp :license "GPL") (incf (getmap pkt-count 0)) XDP_PASS)
This compiles to 11 eBPF instructions and a valid BPF ELF object file.
Why Whistler
eBPF programs are small, verifier-constrained, and pattern-driven. Whistler is built for that shape:
- No C toolchain. The compiler is self-contained Common Lisp (~7,000 lines). No LLVM, no kernel headers, no libelf. The ELF writer is hand-rolled.
- Real metaprogramming. eBPF code is full of recurring patterns: parse headers, validate packet bounds, look up state. In Whistler, those become hygienic macros instead of preprocessor tricks.
- Compiler-aware abstractions. Struct accessors, protocol helpers, and map operations are part of the language, so the compiler optimizes them intentionally rather than recovering patterns after C lowering.
- Automatic CO-RE. Struct identity is preserved through the pipeline. CO-RE relocations are emitted automatically.
- Interactive development. Full REPL. Compile, load, attach, inspect maps, iterate — all from one Lisp image.
If you already have a C/libbpf workflow, Whistler is not trying to replace it wholesale. It targets cases where you want a language and compiler designed around eBPF itself.
Side-by-side
| Whistler | C + clang | BCC (Python) | Aya (Rust) | bpftrace | |
|---|---|---|---|---|---|
| Toolchain size | ~3 MB (SBCL) | ~200 MB | ~100 MB | ~500 MB | ~50 MB |
| Compile-time metaprogramming | Full CL macros | #define |
Python strings | proc_macro |
none |
| Output | ELF .o | ELF .o | JIT loaded | ELF .o | JIT loaded |
| Self-contained compiler | yes | no (needs LLVM) | no (needs kernel headers) | no (needs LLVM) | no |
| Interactive development | REPL | no | yes | no | yes |
| Code quality vs clang -O2 | matches or beats | baseline | n/a | comparable | n/a |
Getting started
Requirements
- SBCL (Steel Bank Common Lisp) 2.0+
- Linux with kernel 5.3+ (for bounded loop support)
- FiveAM (for tests only)
Build
make # build standalone binary make test # run test suite (requires FiveAM) make repl # interactive REPL with Whistler loaded
Compile an example
# Using the REPL: make repl * (load "examples/synflood-xdp.lisp") * (compile-to-elf "synflood.bpf.o") Compiled 1 program (74 instructions total), 2 maps → synflood.bpf.o # Or from the command line: ./whistler compile examples/count-xdp.lisp -o count.bpf.o
Load into the kernel
ip link set dev eth0 xdp obj count.bpf.o sec xdp bpftool map dump name pkt_count ip link set dev eth0 xdp off
Permissions
# Allow BPF program loading and perf event attachment sudo setcap cap_bpf,cap_perfmon+ep /usr/bin/sbcl # Allow reading tracepoint format files (for deftracepoint) sudo chmod a+r /sys/kernel/tracing/events/sched/sched_switch/format
Generate userspace headers
Whistler generates matching struct definitions for your userland code from
the same defstruct declarations used in the BPF program:
./whistler compile probes.lisp --gen c # C header ./whistler compile probes.lisp --gen c go rust python lisp # multiple ./whistler compile probes.lisp --gen all # all supported
Examples
Packet counter
Whistler: 11 instructions. clang -O2: 11 instructions.
| Whistler | C + clang |
|---|---|
(defmap pkt-count :type :array :key-size 4 :value-size 8 :max-entries 1) (defprog count-packets (:type :xdp :license "GPL") (incf (getmap pkt-count 0)) XDP_PASS) |
#include <linux/bpf.h> #include <bpf/bpf_helpers.h> char __license[] SEC("license") = "GPL"; struct { __uint(type, BPF_MAP_TYPE_ARRAY); __type(key, __u32); __type(value, __u64); __uint(max_entries, 1); } pkt_count SEC(".maps"); SEC("xdp") int count_packets(struct xdp_md *ctx) { __u32 key = 0; __u64 *val = bpf_map_lookup_elem( &pkt_count, &key); if (val) __sync_fetch_and_add(val, 1); return XDP_PASS; } |
Port blocker
Whistler: 25 instructions. clang -O2: 26 instructions.
| Whistler | C + clang |
|---|---|
(defmap drop-count :type :array :key-size 4 :value-size 8 :max-entries 1) (defprog drop-port (:type :xdp :license "GPL") (with-tcp (data data-end tcp) (when (= (tcp-dst-port tcp) 9999) (incf (getmap drop-count 0)) (return XDP_DROP))) XDP_PASS) |
#include <linux/bpf.h> #include <linux/if_ether.h> #include <linux/ip.h> #include <linux/tcp.h> #include <bpf/bpf_helpers.h> #include <bpf/bpf_endian.h> char __license[] SEC("license") = "GPL"; struct { __uint(type, BPF_MAP_TYPE_ARRAY); __type(key, __u32); __type(value, __u64); __uint(max_entries, 1); } drop_count SEC(".maps"); SEC("xdp") int drop_port(struct xdp_md *ctx) { void *data = (void *)(long)ctx->data; void *end = (void *)(long)ctx->data_end; if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct tcphdr) > end) return XDP_PASS; struct ethhdr *eth = data; if (eth->h_proto != htons(ETH_P_IP)) return XDP_PASS; struct iphdr *ip = data + sizeof(*eth); if (ip->protocol != IPPROTO_TCP) return XDP_PASS; struct tcphdr *tcp = (void *)ip + sizeof(*ip); if (ntohs(tcp->dest) == 9999) { __u32 key = 0; __u64 *val = bpf_map_lookup_elem( &drop_count, &key); if (val) __sync_fetch_and_add(val, 1); return XDP_DROP; } return XDP_PASS; } |
SYN flood mitigation
Whistler: 65 instructions. clang -O2: 68 instructions.
| Whistler | C + clang |
|---|---|
(defmap syn-counter :type :hash :key-size 4 :value-size 8 :max-entries 32768) (defmap syn-stats :type :array :key-size 4 :value-size 8 :max-entries 3) (defconstant +syn-threshold+ 100) (defprog synflood (:type :xdp :license "GPL") (with-tcp (data data-end tcp) (when (= (logand (tcp-flags tcp) #x12) +tcp-syn+) (incf (getmap syn-stats 0)) (let ((src (ipv4-src-addr (+ data +eth-hdr-len+)))) (if-let (p (map-lookup syn-counter src)) (if (> (load u64 p 0) +syn-threshold+) (progn (incf (getmap syn-stats 1)) (return XDP_DROP)) (atomic-add p 0 1)) (progn (incf (getmap syn-stats 2)) (setf (getmap syn-counter src) 1)))))) XDP_PASS) |
#include <linux/bpf.h> #include <linux/if_ether.h> #include <linux/ip.h> #include <linux/tcp.h> #include <bpf/bpf_helpers.h> #include <bpf/bpf_endian.h> char __license[] SEC("license") = "GPL"; struct { __uint(type, BPF_MAP_TYPE_HASH); __type(key, __u32); __type(value, __u64); __uint(max_entries, 32768); } syn_counter SEC(".maps"); struct { __uint(type, BPF_MAP_TYPE_ARRAY); __type(key, __u32); __type(value, __u64); __uint(max_entries, 3); } syn_stats SEC(".maps"); static void bump_stat(void *map, __u32 idx) { __u64 *val = bpf_map_lookup_elem( map, &idx); if (val) __sync_fetch_and_add(val, 1); } #define SYN_THRESHOLD 100 #define TCP_SYN 0x02 #define TCP_ACK 0x10 SEC("xdp") int synflood(struct xdp_md *ctx) { void *data = (void *)(long)ctx->data; void *end = (void *)(long)ctx->data_end; if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct tcphdr) > end) return XDP_PASS; struct ethhdr *eth = data; if (eth->h_proto != htons(ETH_P_IP)) return XDP_PASS; struct iphdr *ip = data + sizeof(*eth); if (ip->protocol != IPPROTO_TCP) return XDP_PASS; struct tcphdr *tcp = (void *)ip + sizeof(*ip); __u8 flags = ((__u8 *)tcp)[13]; if (!(flags & TCP_SYN) || (flags & TCP_ACK)) return XDP_PASS; bump_stat(&syn_stats, 0); __u32 src = ip->saddr; __u64 *count = bpf_map_lookup_elem( &syn_counter, &src); if (count) { if (*count > SYN_THRESHOLD) { bump_stat(&syn_stats, 1); return XDP_DROP; } __sync_fetch_and_add(count, 1); } else { bump_stat(&syn_stats, 2); __u64 init = 1; bpf_map_update_elem(&syn_counter, &src, &init, BPF_ANY); } return XDP_PASS; } |
Userspace loader
whistler/loader is a pure Common Lisp BPF loader — no libbpf, no CFFI.
It loads .bpf.o files, creates maps, attaches probes, and consumes ring
buffers from SBCL.
Inline BPF sessions
Write BPF programs and userspace code in the same Lisp form. The BPF code compiles at macroexpand time, and the bytecode is embedded as a literal:
(whistler/loader:with-bpf-session () ;; Kernel side, compiled to eBPF at macroexpand time (bpf:map counter :type :hash :key-size 4 :value-size 8 :max-entries 1024) (bpf:prog trace (:type :kprobe :section "kprobe/__x64_sys_execve" :license "GPL") (incf (getmap counter 0)) 0) ;; Userspace side, normal CL at runtime (bpf:attach trace "__x64_sys_execve") (loop (sleep 1) (format t "count: ~d~%" (bpf:map-ref counter 0))))
One file, one language. No intermediate artifacts or separate build steps.
Documentation
The full language reference, compilation model, and API details are in doc/MANUAL.md.
Author
Whistler was created by Anthony Green.
License
MIT
The compiler itself is MIT-licensed. BPF programs compiled by Whistler
typically use license "GPL" in their defprog because the kernel requires
GPL for BPF programs calling GPL-only helpers.