sysp: A Systems Lisp That Compiles to C
2025
Tic-tac-toe in sysp, compiled to C then to WebAssembly via Emscripten + raylib.
The Gap
I love Lisp. I also love writing code that runs fast and talks to hardware. These two interests don't usually coexist. Common Lisp has a runtime. Scheme has a GC. Clojure runs on the JVM. Rust has macros but they're not homoiconic. C has zero overhead but the language is a fixed grammar you can't extend.
I drew a lot of inspiration from Carp, which compiles Lisp to C, but I decided to take it in a different direction. I decided to focus on readable and debuggable generated C, allowing it to integrate into existing workflows. And if you get tired of the language, you still have a usable C codebase. I wrote the compiler in Common Lisp, which makes the eventual self-hosting version much easier to implement. In the future, I plan on adding CUDA and SIMD, making this a Lisp built for everything from embedded development to high performance computing.
I wrote sysp as a ~2400 line Common Lisp compiler targeting a Clojure-flavored Lisp. Designed from the start as a metaprogramming layer for numerical code.
The Compilation Model
The pipeline is straightforward: .sysp source gets read as s-expressions, macro-expanded, type-checked, and emitted as C. Then any C compiler turns it into a binary. This is the whole story.
Why C as the target instead of LLVM IR or machine code directly? A few reasons:
- C is portable. The same generated code compiles with gcc, clang, MSVC, tcc, or Emscripten. I get every platform and architecture for free without writing a single backend. The tic-tac-toe demo above runs natively on Linux and in your browser as WebAssembly from the same source.
- C is readable. The generated code isn't an opaque bytecode or SSA IR - it's human-readable C that you can inspect, debug, optimize, or hand-edit if needed. For HPC work especially, this is critical. You can verify that your macro-generated kernels actually produce the specialization you intended, step through the generated code with a C debugger, and inspect assembly listings.
- C is a thin abstraction. The generated code maps 1:1 to what you'd write by hand. No hidden allocations, no runtime dispatch tables, no compression layers.
- I get the host compiler's optimizer for free. No need to implement constant folding, inlining, or register allocation. Decades of optimization work in gcc/clang/MSVC just applies.
- C becomes a gateway. CUDA, OpenCL, and platform-specific intrinsics all have C interfaces. Generating C means I can generate specialized code for GPUs and CPUs without reimplementing backends.
The compiler itself is written in Common Lisp because CL is the best language for writing compilers that manipulate tree-structured data. The bootstrap compiler reads sysp source, walks the AST, and emits C strings. No intermediate representation, no optimization passes on my side. Just direct translation.
Here's what the compilation looks like in practice. This sysp code:
(defn add [a :int, b :int] :int
(+ a b))
(defn main [] :int
(println (add 3 4))
0)
Produces this C:
int add(int a, int b) {
return (a + b);
}
int main(void) {
printf("%d\n", add(3, 4));
return 0;
}
Readable C you can inspect and debug directly.
The Type System
sysp has a static type system with three layers that work together:
Explicit annotations. You can fully annotate everything, C-style. Types go after parameters with keyword syntax:
(defn distance [x :float, y :float] :float
(sqrt (+ (* x x) (* y y))))
Hindley-Milner inference. You can also leave types off and let the compiler figure it out through unification. If you write (+ a b), the compiler knows a and b are numeric. If you call a typed function, the argument types propagate back. This matters for numerical code where you want to write generic kernels once, then specialize them for different floating-point precisions:
;; Compiler infers: a is int, b is int, return is int
(defn add [a, b] :int
(+ a b))
;; Compiler infers: x is int, return is int
(defn double [n]
(+ n n))
The Value escape hatch. For when you actually want dynamic typing (building lists at runtime, metaprogramming artifacts, heterogeneous data), there's a Value tagged union type with refcounted cons cells:
;; Dynamic cons cells, refcounted automatically
(let xs (cons 1 (cons 2 (cons 3 '()))))
(let head (car xs))
(let tail (cdr xs))
Compiles to a tagged union in C with reference counting. The generated C:
// Generated C for the Value type
typedef enum { VAL_NIL, VAL_INT, VAL_FLOAT, VAL_STR, VAL_SYM, VAL_CONS } ValueTag;
typedef struct {
ValueTag tag;
union { int as_int; double as_float; const char* as_str; Cons* as_cons; };
} Value;
struct Cons { Value car; Value cdr; int refcount; };
You pay for dynamic types only when you use them. Typed functions compile to plain C with no boxing or tag checks.
Beyond primitives, sysp has structs, enums, vectors, tuples, arrays, and first-class functions:
(struct Point [x :int, y :int])
(enum Color [RED 0] [GREEN 1] [BLUE 2])
(let p (Point 10 20))
(let v (vector 1 2 3 4 5))
(let t (tuple 42 3.14))
Lambdas. Functions are values. You can store them in variables, pass them to other functions, and return them. They compile to C function pointers or inlined code depending on usage:
;; Lambda stored in a variable
(let double (lambda [x :int] :int (* x 2)))
;; Higher-order function: takes a function as argument
(defn apply-twice [f :fn(:int):int, x :int] :int
(f (f x)))
(let result (apply-twice double 3)) ;; 12
;; Lambdas as callbacks to C libraries
(let comparator (lambda [a :ptr-void, b :ptr-void] :int
(- (deref (cast :ptr-int a)) (deref (cast :ptr-int b)))))
(qsort arr n sizeof-int comparator)
This compiles to plain function pointers in C. No heap allocation, no closure objects, no runtime overhead for the common case.
The Macro System
This is the part that makes sysp a Lisp and not just "C with parentheses." The macro system operates on the AST before type checking or code generation. Homoiconic macros let you build specialized code at compile time, which is exactly what you need for numerical kernels and performance-critical code. It has three key pieces:
Quasiquote with unquote. Template-based code generation using backtick, tilde for unquote, and ~@ for splice:
(defmacro swap! [a b]
(let g (gensym))
`(do
(let ~g ~a)
(set! ~a ~b)
(set! ~b ~g)))
This expands (swap! x y) into a block with a fresh temporary variable. The generated C is exactly what you'd write by hand, and you can read it:
// (swap! x y) compiles to:
{ int _g1 = x; x = y; y = _g1; }
Compile-time functions. defmacro handles simple template expansions well, but real macros often need recursion, shared helpers, or multi-step computation over the syntax tree. That's what defn-ct is for: it defines functions that exist only at compile time and can be called from macros (or from each other). This is where numerical code generation happens - you write a function that takes tensor dimensions or loop counts as arguments, then generates optimized code at compile time:
;; Runs at compile time: recursively builds nested additions
(defn-ct expand-sum [items]
(if (nil? (cdr items))
(car items)
else
`(+ ~(car items) ~(expand-sum (cdr items)))))
;; (sum [1 2 3 4 5]) expands to (+ 1 (+ 2 (+ 3 (+ 4 + 5))))
(defmacro sum [args]
(expand-sum args))
The generated C for (sum [1 2 3 4 5]) is just:
int result = (1 + (2 + (3 + (4 + 5))));
No runtime function calls or loops. All the work happens at compile time.
Conditional code generation. Since compile-time functions can inspect their arguments, you get conditional compilation for free:
(defmacro maybe-negate [flag x]
(if (sym-eq? flag 'true)
`(- ~x)
x))
(let neg (maybe-negate true 42)) ;; compiles to: int neg = (-42);
(let pos (maybe-negate false 42)) ;; compiles to: int pos = 42;
FFI: Calling C Directly
There's no FFI layer. You just declare C functions and call them. The extern form tells the compiler the type signature; since we're emitting C anyway, the linker handles the rest:
(include "raylib.h")
(foreign-struct Color [r :u8, g :u8, b :u8, a :u8])
(foreign-struct Vector2 [x :f32, y :f32])
(extern InitWindow [width :int, height :int, title :str] :void)
(extern DrawLine [x1 :int, y1 :int, x2 :int, y2 :int, color :Color] :void)
(extern GetMousePosition [] :Vector2)
foreign-struct registers a C struct's field layout without emitting a typedef (since the header already defines it). extern registers a function signature. After that, you call these like any sysp function:
(InitWindow 800 600 "my game")
(let mouse (GetMousePosition))
(let col (cast :int (/ (get mouse x) 200.0)))
Any library with a C header is immediately usable. The function calls are direct C calls.
Proof of Concept: Tic-Tac-Toe
To prove sysp can handle a real program, I wrote a complete tic-tac-toe game with raylib graphics in ~150 lines. It has enums for game state, struct construction for raylib types, mouse input handling, and a game loop with rendering. Here's the core of it:
(enum Square [EMPTY 0] [X_PIECE 1] [O_PIECE 2])
(enum Player [PLAYER_X 0] [PLAYER_O 1])
(enum GameState [PLAYING 0] [WON 1] [DRAW 2])
(let WINDOW_SIZE :int 600)
(let CELL_SIZE :int 200)
(defn check-winner [board :ptr-int] :int
;; Check rows
(for [i 0 3]
(let idx (* i 3))
(when (and (!= (array-ref board idx) EMPTY)
(== (array-ref board idx) (array-ref board (+ idx 1)))
(== (array-ref board idx) (array-ref board (+ idx 2))))
(return (array-ref board idx))))
;; Check columns
(for [j 0 3]
(when (and (!= (array-ref board j) EMPTY)
(== (array-ref board j) (array-ref board (+ j 3)))
(== (array-ref board j) (array-ref board (+ j 6))))
(return (array-ref board j))))
;; Check diagonals
(when (and (!= (array-ref board 0) EMPTY)
(== (array-ref board 0) (array-ref board 4))
(== (array-ref board 0) (array-ref board 8)))
(return (array-ref board 0)))
EMPTY)
The main loop handles input before drawing, with game-over and playing states cleanly separated:
(defn main [] :int
(SetConfigFlags FLAG_MSAA)
(InitWindow WINDOW_SIZE WINDOW_SIZE "sysp - Tic Tac Toe")
(SetTargetFPS 60)
(let board (make-array :int 9))
(let-mut turn :int PLAYER_X)
(let-mut state :int PLAYING)
(let-mut winner :int EMPTY)
(while (not (WindowShouldClose))
;; Input: one branch per frame
(if (== state PLAYING)
(when (IsMouseButtonPressed LEFT_CLICK)
(let mouse (GetMousePosition))
(let col (cast :int (/ (get mouse x) (cast :f32 CELL_SIZE))))
(let row (cast :int (/ (get mouse y) (cast :f32 CELL_SIZE))))
...)
else
(when (IsMouseButtonPressed LEFT_CLICK)
...reset...))
;; Draw
(BeginDrawing)
(ClearBackground black)
(draw-grid white)
(for [i 0 9]
(cond
[(== (array-ref board i) X_PIECE) (draw-x col row red)]
[(== (array-ref board i) O_PIECE) (draw-o col row blue)]))
(EndDrawing))
(CloseWindow)
0)
Compiles to a single C file linking against raylib. The same source compiles to WebAssembly via Emscripten (the demo above).
Current State and What's Next
sysp is a working compiler. It handles structs, enums, vectors, tuples, lambdas, arrays, pointers, casting, pattern matching, for/while loops, mutability tracking, refcounted dynamic values, a full macro system with quasiquote, and Hindley-Milner type inference.
What's still in progress:
- Monomorphization. The type inference generates constraints, but I haven't yet implemented specializing generic functions into concrete typed versions. Right now you need to annotate polymorphic call sites.
- Closures that escape. Lambdas work for local use, but capturing variables into heap-allocated closures that outlive their scope needs more work on the lifetime model.
- CUDA backend. Generate GPU kernels from sysp source, initially through CUDA C or OpenCL.
- SIMD intrinsics. Portable generation of packed operations (AVX, NEON, etc.) through macros.
- Self-hosting. The compiler is written in Common Lisp. Eventually it should compile itself.
Lisp's expressiveness without a runtime. The language is the compile-time layer. The executable is C.