I recently decided to build a compiler as concisely as possible. After reading through an introduction to LLVM with C++ that took over 50 pages just to build an extremely simple expression compiler, something seemed off. These problems are decades old, and the tooling should be expressive, mature, and concise with minimal boilerplate.
Fortunately, modern components exist to achieve exactly this. In this post, I'll show you how I built a minimal compiler using a modern stack that can serve as a foundation for your own journey into compiler development.
Note: I am not a compiler expert. I am documenting my practical journey for fellow beginners. Please let me know how to improve this in the comments on social media.
Table of contents
Open Table of contents
Motivation
I have been building custom Linux images from scratch, and compilers are a significant part of that process. Consequently, I started studying LLVM as an alternative to traditional GCC.
LLVM is more than just a compiler; it is a modular system offering many standalone libraries. It is also a monorepo containing multiple projects. Beyond the compiler itself, it includes projects for the C++ standard library, a C standard library, and more.
This modular, library-focused design caught my eye, and I realized I could leverage it to quickly build a compiler.
Goal
The goal of this exercise is to build a tool that can emit x86_64 object files for expressions like (a + b) * c + d. It supports addition and multiplication of symbols, using parentheses to control operator precedence. Constants are not supported. Only integer symbols are allowed.
The object file produced by this compiler will refer to external symbols, which must be provided by another object file (e.g., one compiled from C).
The generated code will be wrapped in a function with a hardcoded name, allowing the main program to invoke it reliably.
Any deviation from these assumptions will result in a linker error.
Prerequisites
I didn't have much compiler knowledge before picking this up. I took a course on compilers and built a toy compiler for a toy VM 10 years ago, and I'm familiar with terms like LL and LR parsers, but I no longer have a good understanding of those. Understanding them is certainly necessary for building a serious compiler, but not this one.
To go through the project below, you only need to vaguely understand the role of a lexer and a parser in compiler design. I won't expand on it here, so if you don't know what these are, take a quick break to look them up and then come back. There are many great explanations out there; I wouldn't be adding any value here.
GitHub
The GitHub repo is here: https://github.com/popovicu/llvm-go-codegen/
It serves as the source of truth for the code below. The code is replicated here purely for convenience, but check GitHub if you feel something is off.
The project should be easily reproducible in almost any UNIX-like environment, including Linux. Let's take a detailed look below.
Note: To keep things simple, I assume this project is built on x86_64 and that the generated code targets this architecture. Minor tweaks would be necessary to run it on other architectures.
Implementation
Let's first discuss the tech stack and the build system. They're very relevant for making the project easy to reproduce.
Build system
I almost always use Bazel as the build system. It is fast, thoroughly tested in the field, modular, and generally assumes very little about your machine. One of the biggest selling points of Bazel is reproducibility, which is very important to me given that readers will likely want to reproduce this on their machines.
Check out this post if you need an intro to Bazel. It's slightly outdated, and I will publish an updated version soon with more modern features that make Bazel scale from toy projects all the way to web-scale projects. To use Bazel, you just need to download a single file, as described in that post, no installations required.
One important piece of my philosophy here is that this project will dynamically fetch everything you need to build it while building, without polluting your system. The bulk of the code here is in Go, and this project doesn't even assume that you have Go installed. Bazel will dynamically fetch it as part of the build flow and will respect your machine. It will only use the fetched Go toolchain within the context of this project and will not pollute your system's installations.
Programming language
This is where I spent most of my time thinking, as it's hard to choose the language(s) to make a project like this super concise. However, as I mentioned in the intro, I was frustrated with the approach some texts take, writing a ton of C++ code before anything interesting happens.
Initially, I discovered that OCaml is absolutely great for writing parsers. Check out the following post on how something like 20 lines of OCaml code got me a working parser:
Here is the source for the expression compiler frontend I mentioned. Coding this with AI in OCaml was surprisingly intuitive, and actually enjoyable.
— Uros Popovic (@popovicu94) January 16, 2026
It shows the lexer and parser specs, as well as the AST structure with pretty-printing and serialization.
This feeds into the C… pic.twitter.com/gIl2gSbddR
However, integrating OCaml in Bazel is not straightforward. There are some existing modules, but they don't look very polished at the moment. I wanted to make it extremely easy for readers to build this example. Furthermore, the LLVM integration with OCaml could be much better, in my opinion.
I ended up switching to trusty Go. As mentioned above, the Go pieces of the project are perfectly reproducible across many systems with minimal assumptions.
Furthermore, Go can easily integrate with the C API of LLVM via cgo, and it has plenty of high-level libraries for processing text and parsing in general.
Therefore, the choice here was to do pretty much everything in Go and delegate to C only when necessary to use the LLVM API.
The code
Let's start with the main program, which shows three separate parts of this project:
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: compile <expression> [output.o]")
fmt.Fprintln(os.Stderr, "Example: compile \"(a + b) * c + d\" expr.o")
os.Exit(1)
}
input := os.Args[1]
outputFile := "expr.o"
if len(os.Args) > 2 {
outputFile = os.Args[2]
}
ast, err := Parse(input)
if err != nil {
fmt.Fprintf(os.Stderr, "Parse error: %v\n", err)
os.Exit(1)
}
fmt.Println(ast.PP())
ctx := NewCodegenContext("expr")
defer ctx.Dispose()
if err := ctx.Compile(ast); err != nil {
fmt.Fprintf(os.Stderr, "Codegen error: %v\n", err)
os.Exit(1)
}
if err := ctx.EmitObjectFile(outputFile); err != nil {
fmt.Fprintf(os.Stderr, "Emit error: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Wrote %s\n", outputFile)
}
First, there is a parser that produces the in-memory representation of the AST (which can also be pretty-printed as an S-expression, e.g., (a + b) * c will print as (* (+ a b) c)).
Next, code generation is invoked via LLVM. This is where the AST is transformed into LLVM data structures, and LLVM is invoked to generate CPU instructions. Lastly, an object file is generated that can be linked into an application.
The AST representation is very easy to understand and should be self-explanatory:
package main
type Expr interface {
expr()
PP() string // Pretty print in S-expression form
}
type Sym struct {
Name string
}
func (s *Sym) expr() {}
func (s *Sym) PP() string {
return s.Name
}
type Add struct {
Left Expr
Right Expr
}
func (a *Add) expr() {}
func (a *Add) PP() string {
return "(+ " + a.Left.PP() + " " + a.Right.PP() + ")"
}
type Mul struct {
Left Expr
Right Expr
}
func (m *Mul) expr() {}
func (m *Mul) PP() string {
return "(* " + m.Left.PP() + " " + m.Right.PP() + ")"
}
Next, the code generation in codegen.go does exactly what was described in the introduction. It creates a function with a hardcoded name which runs the expression on externally defined symbols:
func (ctx *CodegenContext) codegenExpr(e Expr) C.LLVMValueRef {
switch v := e.(type) {
case *Sym:
cname := C.CString(v.Name)
defer C.free(unsafe.Pointer(cname))
global := ctx.getOrCreateGlobal(v.Name)
return C.LLVMBuildLoad2(ctx.builder, C.LLVMInt32Type(), global, cname)
case *Add:
lhs := ctx.codegenExpr(v.Left)
rhs := ctx.codegenExpr(v.Right)
addName := C.CString("add")
defer C.free(unsafe.Pointer(addName))
return C.LLVMBuildAdd(ctx.builder, lhs, rhs, addName)
case *Mul:
lhs := ctx.codegenExpr(v.Left)
rhs := ctx.codegenExpr(v.Right)
mulName := C.CString("mul")
defer C.free(unsafe.Pointer(mulName))
return C.LLVMBuildMul(ctx.builder, lhs, rhs, mulName)
default:
panic(fmt.Sprintf("unknown expression type: %T", e))
}
}
func (ctx *CodegenContext) Compile(e Expr) error {
// Create function: i32 compiled_expression(void)
fnName := C.CString("compiled_expression")
defer C.free(unsafe.Pointer(fnName))
fnType := C.LLVMFunctionType(C.LLVMInt32Type(), nil, 0, 0)
fn := C.LLVMAddFunction(ctx.mod, fnName, fnType)
C.LLVMSetLinkage(fn, C.LLVMExternalLinkage)
entryName := C.CString("entry")
defer C.free(unsafe.Pointer(entryName))
entry := C.LLVMAppendBasicBlock(fn, entryName)
C.LLVMPositionBuilderAtEnd(ctx.builder, entry)
result := ctx.codegenExpr(e)
C.LLVMBuildRet(ctx.builder, result)
return nil
}
The LLVM API is connected via C bindings. Anytime C.something is invoked, it means that the symbol something is referenced in the final file. There are a few more helper functions in that file, but this is the core of the logic.
The parsing itself to obtain the AST is achieved via the participle library. With this library, structs are annotated and the parsing logic runs behind the scenes more or less automatically. Here are the structs:
// Grammar types for Participle parsing
// Precedence: * binds tighter than +
// Both are left-associative
// AddExpr handles addition (lowest precedence)
type AddExpr struct {
Left *MulExpr `@@`
Right []*AddExprOp `@@*`
}
type AddExprOp struct {
Op string `@"+"`
Right *MulExpr `@@`
}
// MulExpr handles multiplication (higher precedence than +)
type MulExpr struct {
Left *Unary `@@`
Right []*MulExprOp `@@*`
}
type MulExprOp struct {
Op string `@"*"`
Right *Unary `@@`
}
// Unary handles atoms and parenthesized expressions
type Unary struct {
Symbol *string `@Ident`
SubExpr *AddExpr `| "(" @@ ")"`
}
The logic for running this library on top of these structs and getting the clean in-memory AST defined above works like this:
var exprLexer = lexer.MustSimple([]lexer.SimpleRule{
{Name: "whitespace", Pattern: `[ \t\n]+`},
{Name: "Ident", Pattern: `[a-zA-Z][a-zA-Z0-9_]*`},
{Name: "Punct", Pattern: `[+*()]`},
})
var parser = participle.MustBuild[AddExpr](
participle.Lexer(exprLexer),
participle.Elide("whitespace"),
)
func Parse(input string) (Expr, error) {
parsed, err := parser.ParseString("", input)
if err != nil {
return nil, err
}
return parsed.ToAST(), nil
}
This should cover all the important parts of the logic. As I mentioned, there are some more helpers in the full code, but that code is easy to understand and available on GitHub.
Now for the key piece that binds this all together.
Building and running with Bazel
The one thing that holds the project together is Bazel. I will explain exactly how this works, but let's do the fun part first: running the compiler.
Let's begin by running the build:
bazel build //cmd/compile
Note: If you're getting linker errors (like I sometimes get on my system, which I've messed up in many ways), you may need to run it with a different linker:
bazel build --linkopt=-fuse-ld=gold --host_linkopt=-fuse-ld=gold //cmd/compile
You will notice that the first build is very heavyweight and slow. The reason is that Bazel will dynamically fetch and build the LLVM libraries on the fly! This happens only the first time. Further changes to the Go code will use the cached result of the LLVM builds. This is the one bit where there are some assumptions about your machine: a working C/C++ compiler must be present.
After the build is done, you'll see something like this:
INFO: Found 1 target...
Target //cmd/compile:compile up-to-date:
bazel-bin/cmd/compile/compile_/compile
You can check the output file type:
file bazel-bin/cmd/compile/compile_/compile
bazel-bin/cmd/compile/compile_/compile: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, Go BuildID=redacted, BuildID[sha1]=b78919aff001d8366249403a2544fba2d833084f, stripped
Due to the C dependency and the complexity of compiling with dynamic linking, this binary isn't fully portable. I'll cover the details some other time. It will work on your system, though!
You can go ahead and directly invoke this executable:
bazel-bin/cmd/compile/compile_/compile "(a + b) * c + d" /tmp/output.o
(+ (* (+ a b) c) d)
Wrote /tmp/output.o
objdump can show the generated code:
objdump -d /tmp/output.o
/tmp/output.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <compiled_expression>:
0: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # 7 <compiled_expression+0x7>
7: 8b 00 mov (%rax),%eax
9: 48 8b 0d 00 00 00 00 mov 0x0(%rip),%rcx # 10 <compiled_expression+0x10>
10: 03 01 add (%rcx),%eax
12: 48 8b 0d 00 00 00 00 mov 0x0(%rip),%rcx # 19 <compiled_expression+0x19>
19: 0f af 01 imul (%rcx),%eax
1c: 48 8b 0d 00 00 00 00 mov 0x0(%rip),%rcx # 23 <compiled_expression+0x23>
23: 03 01 add (%rcx),%eax
25: c3 ret
The binary could ideally be invoked via bazel run for development purposes, but running the ELF file directly also works. It doesn't make a difference here.
Now let's look at MODULE.bazel to figure out how this dependency on LLVM is declared in the first place:
module(
name = "go-codegen",
version = "0.0.1",
)
bazel_dep(name = "rules_go", version = "0.59.0")
bazel_dep(name = "rules_cc", version = "0.2.14")
bazel_dep(name = "gazelle", version = "0.47.0")
bazel_dep(name = "llvm-project", version = "17.0.3.bcr.4")
go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.24.0")
go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
use_repo(
go_deps,
"com_github_alecthomas_participle_v2"
)
A single line opens the door to the usage of LLVM in this project!
bazel_dep(name = "llvm-project", version = "17.0.3.bcr.4")
Let's look at cmd/compile/BUILD:
load("@rules_go//go:def.bzl", "go_binary")
go_binary(
name = "compile",
visibility = ["//visibility:public"],
srcs = [
"ast.go",
"codegen.go",
"compile.go",
"parsing.go",
],
deps = [
"//remote/participle",
"//remote/participle:lexer",
],
cgo = True,
cdeps = [
"@llvm-project//llvm:Core",
"@llvm-project//llvm:Analysis",
"@llvm-project//llvm:Target",
"@llvm-project//llvm:X86CodeGen",
],
)
That's it! That's all it takes for Bazel to bring the LLVM libraries into this project.
Before it looks too good to be true: the Bazel "bindings" aren't 100% supported. LLVM is primarily built with CMake. However, some folks do keep these Bazel targets maintained in the official LLVM monorepo as a best-effort community initiative. I would expect things to break occasionally, but for the purposes of this project, everything worked very well.
Conclusion
Some corners were cut when making this little compiler, but I think it still illustrates powerful concepts. First, the LLVM project is transparently brought into the mix. Go is leveraged to parse the code, and cgo provides easy access to the LLVM C bindings.
While this project is indeed just a toy compiler, I hope it gives you enough pieces to get started building something more serious. Unfortunately, my own knowledge of compilers pretty much ends here, and I look forward to studying this area more together with you.
Lastly, the code itself may seem a bit robotic, and that is because AI was heavily used to generate the end result. Please do not overindex on that: the main point of this exercise is to prototype a high-level approach and get you started with something reproducible as fast as possible.
I hope this was fun and useful.
Please consider following me on X and LinkedIn for further updates.