Hi everyone,
Over the past few weeks, I’ve been building a compiled systems programming language inspired by Go's ergonomics, but designed with zero runtime overhead, no GC, and strict C-ABI compatibility.
Rather than building a full end-to-end compiler that outputs machine code or PE/ELF binaries directly, I made several deliberate architectural decisions to keep the compiler frontend minimal while maximizing flexibility. I wanted to share a few design patterns and lessons learned that proved effective.
1. The "Hitchhiking" Strategy: Stop at LLVM IR
A common temptation when building a compiler is trying to handle binary emission, object packing, and linking within the compiler itself. However, doing so immediately drags you into platform-specific rabbit holes (PE/COFF vs. ELF vs. Mach-O, debug formats, resource embedding, import libraries).
Instead, the compiler (hikec) is strictly scoped: it parses source files and emits a single, clean LLVM IR file (.ll).
What this unlocked:
-
Zero-cost toolchain integration: On Windows, generating a DLL with an import library (
.dll.a) for C++ clients required nothing more than passing-Wl,--out-implibdirectly to Clang. -
Cross-language LTO & Inlining: When compiling C++ code alongside the emitted
.llwithclang++ -flto, LLVM optimizes across the language boundary as a single unified IR graph, inlining language functions directly into C++ caller sites. -
WASM without WASI-SDK: Emitting
wasm32IR allows direct compilation viaclang --target=wasm32 -nostdlibinto standalone.wasmwithout requiring heavy Emscripten toolchains. -
User-controlled linking: Complex tasks like binding Windows
.rcresources, application manifests, or custom linker scripts remain completely under the user's control in their regular build pipelines.
2. 2-Pass Stack Iterators for Zero-Allocation for-range
Without a garbage collector, running for-range loops over custom dictionary/map types usually presents a dilemma: dynamic heap allocation for iterator state, or restricting traversal syntax.
To solve this, I implemented a 2-Pass stack iterator protocol resolved at compile time:
-
Pass 1 (Size Probe): The compiler issues a probe call
InitIterator(nil)to retrieve the byte size of the iterator state. -
Stack Allocation: The compiler allocates the exact buffer on the caller's stack frame using
alloca. -
Pass 2 (Initialization):
InitIterator(buf)initializes the state in place. -
Iteration:
Next(buf)yields key/value pointers until exhaustion.
This provides expressive Go-like for k, v := range map loops with guaranteed zero heap allocations.
3. Unified Fat-Pointer ABI for Functions & Closures
To support first-class functions, anonymous functions, and lexical closures with C-ABI compatibility, all callable values are compiled into a unified 2-word fat pointer:
$$\text{FuncValue} \implies { \text{i8* fn_ptr},\, \text{i8* env_ptr} }$$
- Top-level and stateless functions simply carry a
nullenvironment pointer. - Closures carry a pointer to a captured environment.
-
Escape Analysis: The semantic analyzer tracks whether captured variables outlive their enclosing stack frame. Only variables that actually escape are promoted to the heap (
malloc). - At the call site, the runtime invokes
fn_ptrpassingenv_ptras an implicit first argument. Stateless calls invoke zero-overhead dispatch thunks.
Takeaways
Decoupling the frontend (AST, Sema, Monomorphization, IR gen) from the backend optimization/linking pipeline drastically reduced implementation complexity while offering more flexibility to the end-user.
For those who have built custom languages or LLVM frontends: where do you typically draw the boundary between your compiler and existing toolchains?
For those interested in the implementation details, the codebase and examples are available on GitHub:
https://github.com/kanryu/hike-lang
Top comments (0)