# 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.
## Whistler vs alternatives
### vs `clang -target bpf`
Whistler is built for the shape of eBPF programs: small, verifier-constrained,
repetitive, pattern-driven.
- Smaller toolchain, tighter feedback loop. eBPF programs are usually tens
to hundreds of instructions. Whistler compiles them without pulling in the
full C/LLVM pipeline.
- A language matched to the domain. eBPF requires explicit bounds checks,
map lookups, helper calls, stack shaping, and verifier-visible control flow.
Whistler exposes those concepts directly instead of encoding them through C.
- Real metaprogramming. eBPF code is full of recurring patterns: parse
headers, validate packet bounds, look up state, fast-path success, bail out
early. In Whistler, those become hygienic macros and compile-time
abstractions instead of preprocessor tricks.
- Compiler-aware abstractions. Struct accessors, protocol helpers, and map
operations are part of the language surface, so the compiler optimizes
them intentionally rather than recovering patterns after C lowering.
- Automatic CO-RE support. Whistler preserves struct identity through the
compiler pipeline and emits CO-RE relocations automatically. Portable
struct access requires no extra ceremony in your code.
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 |
| Compile-time language | Common Lisp | cpp | Python | Rust | n/a |
| 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 |
| Lines of compiler code | ~5,500 | ~50,000+ | n/a | ~20,000+ | n/a |
## How Whistler works
Your Whistler source files are valid Common Lisp. When you compile one, all
of Common Lisp is available at compile time: real macros with hygiene, a REPL
for interactive development, and a full language for code generation. At
runtime, the output is pure eBPF bytecode, indistinguishable from clang's.
Built-in macros erase BPF ceremony:
```lisp
;; incf on a map handles lookup, null check, atomic increment,
;; and initialization for new keys. All in one form.
(incf (getmap pkt-count 0))
;; with-tcp does bounds check, EtherType, and protocol check
;; as flat guards. No parse overhead.
(with-tcp (data data-end tcp)
(when (= (tcp-dst-port tcp) 8080)
(return XDP_DROP)))
;; if-let binds a map lookup result and branches on it.
(if-let (val (map-lookup my-map key))
(atomic-add val 0 1) ; key exists
(setf (getmap my-map key) 1)) ; key is new
```
The last expression in a `defprog` body is implicitly returned. No
`(return XDP_PASS)` needed at the end.
The compiler is self-contained Common Lisp. The ELF writer is hand-rolled
(~400 lines). It needs no libelf, libbpf, or kernel headers. The output is
a standard BPF ELF loadable by bpftool, libbpf, or `ip link`.
`--gen c`, `--gen go`, `--gen rust`, `--gen python`, `--gen lisp` generate
matching struct definitions from your `defstruct` declarations. One source of
truth for BPF and userspace.
`whistler/loader` loads `.bpf.o` into the kernel, attaches probes, reads maps,
and consumes ring buffers from pure CL. Or use `with-bpf-session` to compile
and load BPF inline, in one Lisp form.
The whole project is ~7,000 lines. Compiler, loader, and session runtime. It
reads in an afternoon. When the verifier rejects your program, you read the
compiler to understand what bytecode it generated and why.
## Getting started
### 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)
- `readelf` / `llvm-objdump` (optional, for inspecting output)
### Build
```sh
make # build standalone binary
make test # run test suite (requires FiveAM)
make repl # interactive REPL with Whistler loaded
```
### 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
# Attach an XDP program to an interface
ip link set dev eth0 xdp obj count.bpf.o sec xdp
# Or load with bpftool
bpftool prog load count.bpf.o /sys/fs/bpf/count
# Monitor maps
bpftool map dump name pkt_count
# Detach
ip link set dev eth0 xdp off
```
### Permissions
BPF programs require elevated privileges to load. Instead of running as
root, grant capabilities to your SBCL binary:
```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` definitions used in the BPF program. One source of truth
for both sides:
```sh
# Generate C header
./whistler compile probes.lisp --gen c # → probes.h
# Generate for multiple languages at once
./whistler compile probes.lisp --gen c go rust python lisp
# Generate all supported languages
./whistler compile probes.lisp --gen all
```
Supported: C, Go, Rust, Python, Common Lisp (`--gen lisp`). Array fields
map to native syntax (`uint8_t field[16]` in C, `[16]uint8` in Go,
`[u8; 16]` in Rust). Struct layouts are guaranteed to match because they
derive from the same `defstruct`.
## Language reference
### Program structure
Every Whistler source file is a Common Lisp file. Programs use two top-level
macros:
```lisp
(in-package #:whistler)
;; Declare BPF maps
(defmap my-map :type :hash
:key-size 4 :value-size 8 :max-entries 1024)
;; Declare BPF programs
(defprog my-prog (:type :xdp :section "xdp" :license "GPL")
;; program body
(return XDP_PASS))
```
Map types: `:hash`, `:array`, `:percpu-hash`, `:percpu-array`, `:ringbuf`,
`:prog-array` (for tail calls), `:lpm-trie` (for CIDR matching)
Program types: `:xdp`, `:socket-filter`, `:tracepoint`, `:kprobe`
### Types
Types determine memory access width. Variables default to `u64` when no type
is specified:
| Type | Width | BPF size |
|------|-------|----------|
| `u8` / `i8` | 1 byte | `BPF_B` |
| `u16` / `i16` | 2 bytes | `BPF_H` |
| `u32` / `i32` | 4 bytes | `BPF_W` |
| `u64` / `i64` | 8 bytes | `BPF_DW` |
### Variable bindings
```lisp
;; let evaluates all inits before binding (standard CL semantics).
(let ((x (load u32 ptr 0))
(y (load u32 ptr 4)))
body...)
;; let* binds sequentially. Each init references prior bindings.
(let* ((x 42)
(y (+ x 1))
(ptr (map-lookup my-map x)))
body...)
;; Use (declare (type ...)) for sub-64-bit narrowing, same as CL.
(let ((port (load u16 tcp-ptr 2))
(flags (tcp-flags tcp-ptr)))
(declare (type u16 port) (type u8 flags))
...)
;; setf mutates a bound variable. Supports multi-pair like CL.
(setf x (+ x 1))
(setf (my-struct-a ptr) 1
(my-struct-b ptr) 2)
```
Types default to `u64` when omitted. Use `(declare (type ...))` for sub-64-bit
narrowing where it matters (map keys, struct fields, protocol fields). This is
the same form CL uses. The SSA pipeline's type narrowing pass further optimizes
ALU operations to 32-bit when safe.
### Control flow
```lisp
(if (> x 10) then-expr else-expr)
(when (= flags +tcp-syn+)
body...)
(unless packet-valid
(return XDP_DROP))
(cond
((= proto +ip-proto-tcp+) (handle-tcp))
((= proto +ip-proto-udp+) (handle-udp))
(t (return XDP_PASS)))
;; Short-circuit boolean operators
(and (> x 0) (< x 100)) ; returns 0 or last truthy value
(or cached (map-lookup m key))
(progn expr1 expr2 ...) ; sequential execution, returns last
(return value) ; set R0 and exit
```
### Arithmetic and logic
```lisp
;; Arithmetic (64-bit ALU, n-ary)
(+ a b c) (- a b) (* a b) (/ a b) (mod a b)
;; Bitwise
(logand a b) (logior a b) (logxor a b)
(<< val shift) (>> val shift) (>>> val shift) ; arithmetic right shift
;; Comparison (returns 0 or 1)
(= a b) (/= a b) (> a b) (>= a b) (< a b) (<= a b)
(s> a b) (s>= a b) (s< a b) (s<= a b) ; signed comparison
(not expr) ; 0→1, nonzero→0
```
### Memory access
```lisp
;; Load from pointer
(load u32 ptr offset) ; *(u32 *)(ptr + offset)
;; Store to pointer
(store u32 ptr offset val) ; *(u32 *)(ptr + offset) = val
;; Load from XDP context (struct xdp_md)
(ctx-load u32 0) ; ctx->data
(ctx-load u32 4) ; ctx->data_end
;; Atomic operations
(atomic-add ptr offset val) ; lock *(u64 *)(ptr + offset) += val
;; Get pointer to stack variable (for passing to helpers)
(stack-addr var)
;; Type narrowing
(cast u16 expr) ; mask to 0xffff
(cast u32 expr) ; zero-extend 32-bit
```
### Structs and CO-RE
Define structs with C-compatible layout. `defstruct` generates CL-style
accessor functions and `setf` expanders. Scalar field accesses emit CO-RE
relocations, enabling cross-kernel portability.
```lisp
;; Define a struct. Generates accessors and setf expanders.
(defstruct ct-key
(src-addr u32)
(dst-addr u32)
(src-port u16)
(dst-port u16))
;; Allocate on stack, set fields with setf
(let ((key (make-ct-key)))
(setf (ct-key-src-addr key) src-ip)
(setf (ct-key-dst-addr key) dst-ip)
;; Read a field
(ct-key-src-port key))
```
#### Array fields
Structs support fixed-size array fields with indexed access:
```lisp
(defstruct my-event
(pid u32)
(data (array u8 16)))
;; Indexed read/write. Constant indices fold to fixed offsets.
(my-event-data evt 5) ; read element 5
(setf (my-event-data evt 5) (cast u8 val)) ; write element 5
;; Pointer accessor, for passing array field addresses to BPF helpers
(get-current-comm (my-event-data-ptr evt) 16)
```
#### sizeof
```lisp
(sizeof my-event) ; → struct byte size (constant)
(probe-read-user buf (sizeof ffi-cif) ptr) ; no more magic numbers
(ringbuf-reserve events (sizeof my-event) 0)
```
### Memory operations
```lisp
;; Fill memory (widened stores: 16 bytes of 0xFF = 2 u64 stores, not 16 u8)
(memset ptr offset value nbytes)
;; Copy memory (wide load/store pairs)
(memcpy dst dst-offset src src-offset nbytes)
```
All offsets and sizes must be compile-time constants.
### User-space iteration
Iterate over user-space arrays without manual pointer arithmetic:
```lisp
;; Array of pointers (e.g. ffi_type **): null-checked, each ptr bound
(do-user-ptrs (atype-ptr arg-types-ptr nargs +max-args+ :index i)
(probe-read-user buf (sizeof ffi-type) atype-ptr)
(use-field buf i))
;; Array of structs (e.g. struct event[]): each element read into buffer
(do-user-array (entry my-struct entries-ptr count +max-entries+ :index i)
(my-struct-field entry))
;; Array of scalars (e.g. u32[]): each value bound directly
(do-user-array (val u32 array-ptr count +max-count+)
(when (> val threshold) ...))
```
Both require a compile-time `max-count` for the BPF verifier and a runtime
`count` for the bound. Supply `:index name` to use the loop index.
### Ring buffer
```lisp
;; Reserve, execute body, auto-submit on normal exit
(with-ringbuf (event events (sizeof my-event))
(setf (my-event-type event) 1)
...)
;; No manual ringbuf-reserve / ringbuf-submit needed
```
### Process metadata
```lisp
;; Fill pid, uid, timestamp, and comm in one form
(fill-process-info event
:pid-field my-event-pid
:uid-field my-event-uid
:timestamp-field my-event-timestamp
:comm-field my-event-comm-ptr)
```
### pt_regs access (x86-64)
Portable access to function arguments in uprobe/kprobe programs, matching
C's `PT_REGS_PARM1()` etc.:
```lisp
(pt-regs-parm1) ; first arg (rdi)
(pt-regs-parm2) ; second arg (rsi)
(pt-regs-parm3) ; third arg (rdx)
(pt-regs-parm4) ; fourth arg (rcx)
(pt-regs-parm5) ; fifth arg (r8)
(pt-regs-parm6) ; sixth arg (r9)
(pt-regs-ret) ; return value (rax)
```
### Tracepoints
Auto-resolve tracepoint field offsets from the running kernel at compile time:
```lisp
;; Reads /sys/kernel/tracing/events/sched/sched_switch/format
(deftracepoint sched/sched-switch prev-pid prev-state next-pid)
;; Generates: (tp-prev-pid) → (ctx-load u32 24)
;; (tp-prev-state) → (ctx-load u64 32)
;; (tp-next-pid) → (ctx-load u32 56)
```
No hardcoded offsets. Field positions come from your kernel's tracefs.
### Kernel struct import
Import kernel struct definitions from vmlinux BTF at compile time:
```lisp
;; Reads /sys/kernel/btf/vmlinux
(import-kernel-struct task_struct pid tgid flags)
;; Generates: (task-struct-pid ptr) → (load u32 ptr 2768)
;; (task-struct-tgid ptr) → (load u32 ptr 2772)
;; +task-struct-size+ → 9856
```
Field offsets come from your kernel's BTF, bypassing kernel headers and
vmlinux.h entirely.
### Map operations
```lisp
;; High-level interface (like gethash / (setf (gethash ...)) / remhash)
(getmap map-name key) ; lookup + deref, 0 if not found
(setf (getmap map-name key) val) ; insert or update (BPF_ANY)
(remmap map-name key) ; delete
;; Atomic increment (handles both array and hash maps)
(incf (getmap map-name key))
(incf (getmap map-name key) delta) ; increment by delta
;; Low-level interface (returns raw pointers, supports flags)
(map-lookup map-name key-var) ; → pointer or 0 (NULL)
(map-update map-name key-var val-var flags)
(map-delete map-name key-var)
```
Key and value arguments must be variables (the compiler takes their stack
address to pass to the BPF helper).
### Byte order
Network protocols use big-endian. eBPF runs on the host (usually little-endian).
```lisp
(ntohs expr) ; network-to-host 16-bit byte swap
(ntohl expr) ; network-to-host 32-bit byte swap
(htons expr) ; host-to-network 16-bit (same operation)
(htonl expr) ; host-to-network 32-bit
```
### Bounded loops
eBPF requires provably bounded iteration. The count must be a compile-time
constant.
```lisp
(dotimes (i 16)
;; i is bound as u32, counts from 0 to 15
body...)
```
### BPF helper calls
BPF helpers are called directly by name in function position (Lisp-2 style):
```lisp
;; Call any BPF helper by name (up to 5 arguments)
(ktime-get-ns)
(trace-printk fmt-ptr fmt-len arg1)
(redirect ifindex flags)
(get-smp-processor-id)
```
### Inline assembly
Escape hatch for instructions the compiler doesn't cover:
```lisp
(asm opcode dst-reg src-reg offset immediate)
```
### Tail calls
BPF tail calls transfer execution to another program in a program array map.
If the target index is invalid or no program is loaded, execution continues
normally (no crash).
```lisp
(defmap jump-table :type :prog-array
:key-size 4 :value-size 4 :max-entries 256)
;; Dispatch to protocol-specific handler
(tail-call jump-table protocol-index)
;; Falls through here if no handler loaded for the index
XDP_PASS
```
### Multi-program ELF
Multiple `defprog` forms compile into a single ELF object with separate
sections. All programs share the same maps.
```lisp
(defmap stats :type :array :key-size 4 :value-size 8 :max-entries 2)
(defprog dispatcher (:section "xdp" :license "GPL")
(tail-call jump-table 0)
XDP_PASS)
(defprog handler (:section "xdp/handler" :license "GPL")
(incf (getmap stats 0))
XDP_PASS)
(compile-to-elf "output.bpf.o") ; both programs in one ELF
```
### Protocol headers
Whistler includes compile-time protocol header definitions. These expand to
`(load TYPE ptr OFFSET)` with automatic byte-order conversion, at zero runtime
cost.
```lisp
;; Named field access (all compile-time macros)
(eth-type data) ; EtherType with ntohs
(ipv4-src-addr ip-ptr) ; source IP (network order, for map keys)
(ipv4-protocol ip-ptr) ; IP protocol number
(tcp-dst-port tcp-ptr) ; destination port with ntohs
(tcp-flags tcp-ptr) ; TCP flags byte
(udp-src-port udp-ptr) ; source port with ntohs
;; Parsing macros with automatic bounds checking
(with-packet (data data-end :min-len 64)
body...)
(with-ipv4 (data data-end ip)
;; ip is bound to the start of the IPv4 header
;; bounds check and EtherType check done automatically
body...)
(with-tcp (data data-end tcp)
;; tcp is bound to the start of the TCP header
;; bounds check + EtherType + protocol check done automatically
body...)
```
Define your own protocol headers:
```lisp
(defheader my-proto
(field-a :offset 0 :type u32)
(field-b :offset 4 :type u16 :net-order t))
;; Generates: (my-proto-field-a ptr) and (my-proto-field-b ptr)
```
### Macros
Since Whistler source files are Common Lisp, you define macros with
`defmacro`. They expand at compile time before eBPF code generation.
```lisp
;; Define reusable patterns
(defmacro bump-counter (map idx)
`(incf (getmap ,map ,idx)))
;; Use them. Expands to the same code you'd write by hand.
(bump-counter stats 0)
;; Generate code programmatically
(defmacro check-ports (tcp &rest ports)
`(or ,@(mapcar (lambda (p) `(= (tcp-dst-port ,tcp) ,p)) ports)))
(when (check-ports tcp 80 443 8080)
(return XDP_DROP))
```
### Constants
Use `defconstant` for compile-time constants. They are inlined as immediates
in the generated bytecode.
```lisp
(defconstant +threshold+ 1000)
;; In the program body, +threshold+ becomes: mov64 reg, 1000
(if (> count +threshold+) ...)
```
Built-in constants: `XDP_ABORTED`, `XDP_DROP`, `XDP_PASS`, `XDP_TX`,
`XDP_REDIRECT`, `BPF_ANY`, `BPF_NOEXIST`, `BPF_EXIST`, `NULL`.
## 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`)
A pure Common Lisp BPF loader with zero C dependencies. It loads `.bpf.o`
files, creates maps, attaches probes, and consumes ring buffers from SBCL:
```lisp
(asdf:load-system "whistler/loader")
(whistler/loader:with-bpf-object (obj "my-probes.bpf.o")
(whistler/loader:attach-obj-kprobe obj "trace_execve" "__x64_sys_execve")
(let* ((map (whistler/loader:bpf-object-map obj "stats"))
(val (whistler/loader:map-lookup map #(0 0 0 0))))
(when val
(format t "count: ~d~%" (whistler/loader:decode-int-value val)))))
```
### 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.
### Struct decode/encode
`whistler:defstruct` generates both BPF macros and a CL struct with
byte-level codec. One definition serves both kernel and userspace:
```lisp
(whistler:defstruct my-event
(pid u32) (comm (array u8 16)) (data u64))
;; BPF side: (make-my-event), (my-event-pid ptr), (setf (my-event-pid ptr) val)
;; CL side: (decode-my-event bytes) → my-event-record struct
;; (my-event-record-pid rec), (my-event-record-comm rec)
;; (encode-my-event rec) → bytes (round-trips perfectly)
```
See `examples/ffi-call-tracker.lisp` for a complete standalone example.
## 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.