# Whistler
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.
```lisp
(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
For Common Lisp work, the intended default is the interactive loader workflow:
1. `make repl-loader`
2. Work in the `whistler-loader-user` package
3. Use `with-bpf-session` to compile, load, attach, and inspect from one image
Use the CLI when you want a `.bpf.o` artifact for CI, packaging, or external
tooling.
### Requirements
- [SBCL](http://www.sbcl.org/) (Steel Bank Common Lisp) 2.0+
- Linux with kernel 5.3+ (for bounded loop support)
- [FiveAM](https://github.com/lispci/fiveam) (for tests only)
### Build
```sh
make # build standalone binary
make test # run test suite (requires FiveAM)
make repl # compiler REPL in whistler-user
make repl-loader # compiler + loader REPL in whistler-loader-user
./whistler doctor # check local eBPF dev prerequisites
```
### Compile an example
```sh
# 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
```sh
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
```sh
# 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:
```sh
./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 |
|
```lisp
(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)
```
|
```c
#include
#include
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 |
|
```lisp
(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)
```
|
```c
#include
#include
#include
#include
#include
#include
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 |
|
```lisp
(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)
```
|
```c
#include
#include
#include
#include
#include
#include
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 programs (kprobe, uprobe,
tracepoint, XDP, TC, cgroup), 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:
```lisp
(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
[The Whistler Book](https://atgreen.github.io/Whistler/).
## Author
Whistler was created by [Anthony Green](https://github.com/atgreen).
## 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.