The magic behind modern compilers : A practical introduction to LLVM

New to the field and eager to learn how complex systems work smoothly. From building compilers to scalable systems, I’m solving problems as they come—whatever the domain—and sharing all the highs, lows, and lessons along the way!
TL;DR: This post is a practical, hands-on guide to using LLVM to build the back end of a programming language. If you've ever been curious about compilers but intimidated by optimizations and code generation, this is for you.
In this deep dive, we will:
Demystify what LLVM is and where it fits into the compiler pipeline.Explore LLVM IR, the "secret sauce" that makes it so powerful and portable.Walk step-by-step through a C++ example to generate IR for a simple add function.Use LLVM's tools to compile our IR into real, optimized x86-64 assembly.
By the end, you'll have a clear understanding of how to delegate the heavy lifting of code generation to LLVM, freeing you up to focus on designing your language.
There's a rite of passage for many of us interested in the systems world, a project that sits atop the bucket list: crafting a programming language from scratch. The initial journey is a creative thrill. You design a beautiful syntax, build a lexer to slice the code into tokens, and a parser to shape those tokens into a beautiful and logical Abstract Syntax Tree (AST). You're teaching the computer to understand your ideas. It feels like you have superpowers, until….
You hit the wall.
Development of a language can be broadly broken down into two parts
Frontend ( I have written a series of comprehensive blogs on the “frontend part)→Have a Look
Backend
A high level flow of writing a compiler often looks like :
- Defining the language (grammar) → Writing a lexer to break the incoming high level gibberish into tokens → Writing a parser to make sense out of the tokens by constructing a tree like structure →Also steps like semantic analysis , error checking, basically the “should not happen stuff” are also checked at this stage.→ post checking , creating an AST structure .
This constitutes the frontend part of the language , which though a challenge in itself , is a well trodden path. The real mountain lies in the back end. How do you translate your elegant, high-level AST into the raw, unforgiving machine instructions a CPU actually understands?That too ,which is different for every architecture styles (x_86 , ARM , RISC etc ) . This is where the magic—and the madness—happens. It’s a world of register allocation, instruction scheduling, and mind-bending optimization passes, all specific to every target architecture you want to support, from x86 to ARM and beyond. Reinventing this wheel isn't just hard; it's the reason most dream languages remain just that—a dream.
This is where something like LLVM comes in extremely handy. What if you could handle the creative part—the language design and front end—and delegate the soul-crushing optimization and code generation to a world-class expert? That expert is LLVM.
Simply put, LLVM isn't a monolithic compiler; it's a modular compiler construction kit. It provides a complete, battle-tested system for the "middle" (optimization) and "back end" (code generation) of a compiler. You teach your front end to speak LLVM's language, and in return, it gives you a passport to run highly-optimized code on virtually any platform. In this post, we're going to pull back the curtain and show you exactly how to leverage this incredible toolkit.

The Handoff Point: Developer’s Job vs. LLVM's Job
In the introduction, we hit a wall—that daunting chasm between our beautiful Abstract Syntax Tree (AST) and the raw machine code a CPU understands. We've established that LLVM is the expert that will get us across this chasm. But how does that partnership actually work? Where exactly does LLVM draw its line in the sand and say, "Okay, I'll take it from here"?
Understanding this handoff is the key to understanding the entire LLVM philosophy.
LLVM as a Hyper-Specialized Factory
The best way to think about LLVM is not as a single tool, but as a hyper-specialized, world-class factory. This factory does one thing with breathtaking skill: it takes a specific kind of raw material, processes it through a state-of-the-art assembly line, and produces pristine, highly-optimized finished goods.
The Finished Goods: Optimized, native machine code for almost any architecture you can name (x86, ARM, RISC-V, WebAssembly, and more).
The Raw Material: A special, universal blueprint called LLVM Intermediate Representation (IR).
This brings us to our role. The LLVM factory has an incredibly strict receiving department. It doesn't accept your language's source code. It doesn't accept the unique AST that your parser builds. It accepts one thing and one thing only: LLVM IR.
Our goal as a language designer is to be the supplier for this factory. We take the unique, custom-designed parts of our language (represented by our AST) and translate them into the standardized raw material that LLVM demands. The moment we finish generating the IR for a piece of our code, our main job is done. We hand that IR over to LLVM.
The Big Picture: Where the Handoff Happens
Let's visualize the entire pipeline, from your custom language syntax all the way to machine code, to see exactly where our responsibility ends and LLVM's begins. We'll use a simple line of code from our language, Sodum.
Let's say in Sodum, adding two numbers looks like this: int result = 5 + 10;
Here's the journey that line of code will take:
Phase 1: Your World (The Language Front End) Everything in this phase is your code, your responsibility.
Lexer: Your lexer scans the text and produces a stream of tokens: ,
intresult,=,5,+,10,;.Parser: Your parser consumes these tokens and builds your unique
SodumAST. It’s a tree structure in memory that logically represents the addition, the variable, and the assignment.
Phase 2: The Bridge (Translating Your AST to LLVM IR) This is the handoff. This is the last and most important part of your job.
IR Generation: Now, you write code that "walks" or "visits" the nodes of your AST.
When your code sees the
+node with its children5and10, you don't think about machine registers or CPU instructions.Instead, you make a function call to the LLVM C++ API, which looks conceptually like this:
builder.CreateAdd(llvm_value_5, llvm_value_10);You are describing the intent of your code (addition) using LLVM's vocabulary. The output of these API calls is a string or object representing the pure, abstract LLVM IR.
Phase 3: LLVM's World (The Middle and Back End) The handoff is complete. LLVM is now in total control.
Optimization: LLVM takes the simple IR you generated. It runs this IR through its legendary gauntlet of optimization passes, transforming it into a much more efficient, but logically identical, program.
Code Generation: The optimized IR is then passed to a target-specific back end. This is where LLVM, the expert, selects the best possible machine instructions for the job. It might turn your simple add into a clever
LEA(Load Effective Address) instruction on x86 because it's faster. You didn't have to know that. You just had to describe your intent in IR, and LLVM handled the expert-level implementation.
So, let's recap. By the time you finish reading this, you should understand that:
The high-level language creation process involves building a front end that produces an AST.
LLVM is essentially a powerful "IR-to-machine-code" factory.
It fits into the big picture after your parser has created an AST.
Our job is to write the translator code that walks our AST and generates LLVM IR.
Enough theory. The best way to truly understand the handoff is to do it. In the next section, we will take that exact Sodum expression, 5 + 10, and walk step-by-step through the C++ code required to generate the LLVM IR, compile it, and see the real machine code come out the other side.
In this section We will walk through an example of generating Assembly code for High Level gibberish
Step 1: Get Your Tools Ready
Alright, first things first. Before we write any C++ code, let's get our environment set up and make sure everything is working as expected.
A. Installation
Installing LLVM is usually a one-liner with your package manager.
- macOS:
#for mac
brew install llvm
#for linux (ubuntu/Debian based)
sudo apt-get install llvm clang lld
Note for Windows Users
The smoothest path on Windows is to use the Windows Subsystem for Linux (WSL). This lets you run a real Linux terminal on your machine, and the steps become identical to the Linux instructions.
Install WSL: Open PowerShell as an Administrator and run
wsl --install. Then restart.Follow the Ubuntu/Debian instructions inside the WSL terminal.
For a detailed guide on setting up WSL, the official Microsoft documentation is the best resource: https://learn.microsoft.com/en-us/windows/wsl/install
B. Verification
Now for the important part: a quick verification. Running these commands will confirm that the tools are installed and in your system's PATH.
#check for clang compiler
clang++ --version
#check for llvm code generator
llc --version
#check for llvm helper tool
llvm-config --version
If all three commands ran without a hitch, your setup is perfect. Now we're ready for the fun part: writing the code
Step 2: The Code - Acting as the AST Walker
Our C++ program, ir_generator.cpp, has one job: to act like our Custom language's code generator. Its goal is to take a high-level concept (a function that adds two numbers) and translate it into the universal blueprint that LLVM understands: the LLVM IR.
It does not create the .ll (the IR file) or .s(the assembly file) files directly. It simply prints the final IR text to the console. We'll use command-line tools in the next step to capture that output and process it.
Here is the complete C++ code. We'll dissect it right after.
/*
This file contains the llvm's c++ api that the developer calls as the AST is being walked
*/
// These are the core LLVM headers.
#include "llvm/IR/LLVMContext.h"
#include "llvm/IR/Module.h"
#include "llvm/IR/IRBuilder.h"
#include "llvm/IR/Verifier.h"
#include <memory>
#include <vector>
int main() {
// --- Part 1: Setup the LLVM "Universe" ---
// The Context is an opaque object that owns and manages core LLVM data structures.
auto TheContext = std::make_unique<llvm::LLVMContext>();
// The Module is LLVM's container for a single unit of compilation (like a .cpp file).
auto TheModule = std::make_unique<llvm::Module>("sodum_module", *TheContext);
// The Builder is a helper object that makes it easy to generate LLVM instructions.
auto TheBuilder = std::make_unique<llvm::IRBuilder<>>(*TheContext);
// --- Part 2: Define the Function's Signature ---
// We want to define `int add(int a, int b)`.
// In LLVM, this is `i32 add(i32, i32)`.
// First, get the type for a 32-bit integer.
llvm::Type *i32Type = TheBuilder->getInt32Ty();
// Create a vector of the argument types.
std::vector<llvm::Type *> argTypes = {i32Type, i32Type};
// Create the complete function type.
llvm::FunctionType *funcType = llvm::FunctionType::get(i32Type, argTypes, false);
// Now, create the actual function in our module with the name "add".
llvm::Function *TheFunction = llvm::Function::Create(
funcType, llvm::Function::ExternalLinkage, "add", TheModule.get());
// --- Part 3: Create the Function's Body ---
// A function is made of "Basic Blocks". We need one to start.
llvm::BasicBlock *entryBlock = llvm::BasicBlock::Create(*TheContext, "entry", TheFunction);
TheBuilder->SetInsertPoint(entryBlock); // Tell the builder we are now writing here.
// Get handles to the function's arguments so we can use them.
llvm::Value *Arg1 = TheFunction->getArg(0);
Arg1->setName("a");
llvm::Value *Arg2 = TheFunction->getArg(1);
Arg2->setName("b");
// THIS IS THE KEY INSTRUCTION: Create the 'add' operation.
llvm::Value *sum = TheBuilder->CreateAdd(Arg1, Arg2, "sum");
// Finally, create the 'return' instruction.
TheBuilder->CreateRet(sum);
// --- Part 4: Verify and Print ---
// Ask LLVM to verify our generated code is consistent.
llvm::verifyFunction(*TheFunction);
// Print the human-readable LLVM IR to the console.
TheModule->print(llvm::outs(), nullptr);
return 0;
}
Dissecting the Code →"AST Walk"
Let's imagine our program is "walking" an Abstract Syntax Tree for a function func add(a: int, b: int). Here’s how our C++ code simulates that process, calling the LLVM API at each step.
The Setup: The first three lines in
maincreate our canvas.TheModulerepresents the output file,TheContextholds global state, andTheBuilderis our paintbrush for creating instructions. This happens once.The Function Definition: When our walker sees a "Function Definition" node in the AST, it calls
llvm::Function::Create(...). This is our first major API call. We're telling theModule, "Declare a function named 'add' with this signature." No code for the function's body has been generated yet.The Function Body: This is the critical handoff. The walker steps inside the function body.
It first needs a place to write code, so it calls
llvm::BasicBlock::Create(...)to create an "entry" point.Then, it sees the core logic: an addition. This is the key API call:
TheBuilder->CreateAdd(Arg1, Arg2, "sum"). We are literally telling ourIRBuilderpaintbrush: "Draw an 'add' instruction right here, using the function's arguments as input."Finally, the walker sees the return statement and makes its last API call:
TheBuilder->CreateRet(sum). This tells the builder to draw a "return" instruction.
The Final Output: At the very end,
TheModule->print(...)simply dumps all the IR we've built to the console as human-readable text.
NOTE → This “tree walking” happens correctly , and does not cause any errors in calling llvm’s api , because we assume that after the AST had been generated by the developer , the semantic analysis , errors checking etc has already been done , ensuring the program is correct in terms of its behaviour and structure.
Step 3: From C++ to Assembly - The Payoff
Now, let's execute the full chain of commands to go from our C++ source to final, optimized assembly.
A. Compile our Generator
We'll use llvm-config to automatically provide the necessary compiler flags to link against the LLVM libraries.
clang++ ir_generator.cpp $(llvm-config --cxxflags --ldflags --system-libs --libs core) -o ir_generator
What do those flags mean?
The command clang++ ir_generator.cpp $(llvm-config ...) -o ir_generator might look complex, but it's a clever shortcut.
clang++ ir_generator.cpp ... -o ir_generatorThis is the standard part: compileir_generator.cppand name the output executableir_generator.$(llvm-config ...)This is the magic. The$(...)syntax tells the terminal to first run thellvm-configcommand and paste its text output directly onto the command line.llvm-configis a helper tool that knows exactly where your LLVM libraries and headers are installed.--cxxflags: Prints the compiler flags, mainly the-Ipaths needed to find LLVM's header files.--ldflags: Prints the linker flags, mainly the-Lpaths to find the LLVM library files.--libs core: Prints the specific library names to link against (like-lLLVMCore). We only need thecoreset of libraries for this example.--system-libs: Prints any extra system libraries that LLVM itself depends on.
In short, we're telling clang++: "Compile our file, and ask llvm-config for all the specific settings you need to correctly use the LLVM framework." This saves us from having to manually find and type out dozens of complex paths and library names.
B. Generate the LLVM IR
Run the executable and redirect its console output into a file named add.ll. This file is the physical artifact of the "handoff."
./ir_generator > add.ll
If you look inside add.ll (cat add.ll), you will see the pure, platform-agnostic IR:
; ModuleID = 'sodum_module'
source_filename = "sodum_module"
define i32 @add(i32 %a, i32 %b) {
entry:
%sum = add i32 %a, %b
ret i32 %sum
}
C. Generate the Final Assembly
Now, we hand our IR blueprint to llc, the LLVM Static Compiler. It will act as the back end, translating the IR into native assembly for your machine.
llc add.ll -o add.s
D. The Grand Finale
The file add.s contains the final product. Let's look inside (cat add.s)
.text
.file "sodum_module"
.globl add # -- Begin function add
.p2align 4, 0x90
.type add,@function
add: # @add
.cfi_startproc
# %bb.0: # %entry
# kill: def $esi killed $esi def $rsi
# kill: def $edi killed $edi def $rdi
leal (%rdi,%rsi), %eax
retq
.Lfunc_end0:
.size add, .Lfunc_end0-add
.cfi_endproc
# -- End function
.section ".note.GNU-stack","",@progbits
And there it is. We told LLVM to add. The back end, in its expertise, knew that on x86-64 the leal (Load Effective Address) instruction is a more efficient way to perform this specific addition. We just got a professional-grade compiler optimization for free, without having to know a single thing about x86 assembly.
This is the power of LLVM. You focus on your language's logic, and LLVM handles the expert-level work of making it fast, for every platform.
Where Do We Go From Here?
And just like that, we've done it. We've bridged the chasm.
We took a simple idea, add, and walked it all the way from a C++ program acting as our front end, to a clean, platform-agnostic IR blueprint, and finally to a real, optimized piece of x86 assembly. Seeing LLVM choose the leal instruction is one of those magic moments where you realize the sheer power of the tools we have at our fingertips. We didn't have to be assembly experts; we just had to describe our intent, and LLVM's decades of optimization expertise took care of the rest.
I have to be honest, I'm still just scratching the surface of this field myself, and every new thing I learn feels like discovering a new superpower. The journey from here is long, but it's incredibly exciting. If you've felt that same spark of curiosity, here are the resources that I've found invaluable and that I think you'll love too.
Your Next Steps into a Larger World
The Absolute Must-Do: The Kaleidoscope Tutorial If you do only one thing after reading this, do this. The Official LLVM Kaleidoscope Tutorial is the canonical guide for a reason. It's not just a tutorial; it's a rite of passage. You'll build a complete, albeit simple, programming language called "Kaleidoscope" from the ground up—lexer, parser, AST, and IR generation. It even shows you how to build a JIT (Just-In-Time) compiler so you can run your language in a REPL! It is, without a doubt, the best next step.
For the Curious: The
IRBuilder.hHeader Want to see what other instructions you can generate? The best documentation is often the code itself. Find theIRBuilder.hfile in your LLVM installation (or on GitHub) and just scroll through it. You'll see methods likeCreateSub,CreateMul,CreateICmpEQ(for integer equality comparison), and hundreds more. It’s a treasure map of possibilities.
Let's Learn Together
This stuff is hard, and the best way to learn is by sharing. I'm on this journey just like you, and I'm bound to have made mistakes or missed a clearer way of explaining something. If you spot an error, have a suggestion, or just want to share a cool project you're working on, please reach out. Leave a comment below!
What concepts should we explore next? Control flow with if/else statements? Loops? I'd love to hear what you're curious about.
For now, thank you for coming along on this little adventure.