DEV Community

KATO Kanryu
KATO Kanryu

Posted on

Why I designed my compiler to emit only LLVM IR instead of binaries: Hitchhiking Clang and zero-allocation iterators

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-implib directly to Clang.
  • Cross-language LTO & Inlining: When compiling C++ code alongside the emitted .ll with clang++ -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 wasm32 IR allows direct compilation via clang --target=wasm32 -nostdlib into standalone .wasm without requiring heavy Emscripten toolchains.
  • User-controlled linking: Complex tasks like binding Windows .rc resources, 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:

  1. Pass 1 (Size Probe): The compiler issues a probe call InitIterator(nil) to retrieve the byte size of the iterator state.
  2. Stack Allocation: The compiler allocates the exact buffer on the caller's stack frame using alloca.
  3. Pass 2 (Initialization): InitIterator(buf) initializes the state in place.
  4. 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 null environment 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_ptr passing env_ptr as 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)