Dancing with LLVM: A Moonbit Chronicle (Part 2) - LLVM Backend Generation

Introductionβ
In the process of programming language design, the frontend is responsible for understanding and verifying the structure and semantics of a program, while the compiler backend takes on the task of translating these abstract concepts into executable machine code. The implementation of the backend not only requires a deep understanding of the target architecture but also mastery of complex optimization techniques to generate efficient code.
LLVM (Low Level Virtual Machine), as a comprehensive modern compiler infrastructure, provides us with a powerful and flexible solution. By converting a program into LLVM Intermediate Representation (IR), we can leverage LLVM's mature toolchain to compile the code to various target architectures, including RISC-V, ARM, and x86.
Moonbit's LLVM Ecosystem
Moonbit officially provides two important LLVM-related projects:
llvm.mbt: Moonbit language bindings for the original LLVM, providing direct access to the llvm-c interface. It requires the installation of the complete LLVM toolchain, can only generate for native backends, and requires you to handle compilation and linking yourself, but it can generate IR that is fully compatible with the original LLVM.MoonLLVM: A pure Moonbit implementation of an LLVM-like system. It can generate LLVM IR without external dependencies and supports JavaScript and WebAssembly backends.This article chooses
llvm.mbtas our tool. Its API design is inspired by the highly acclaimedinkwelllibrary in the Rust ecosystem.
In the previous article, "Dancing with LLVM: A Moonbit Chronicle (Part 1) - Implementing the Frontend," we completed the conversion from source code to a typed abstract syntax tree. This article will build on that achievement, focusing on the core techniques and implementation details of code generation.
Chapter 1: Representing the LLVM Type System in Moonbitβ
Before diving into code generation, we first need to understand how llvm.mbt represents LLVM's various concepts within Moonbit's type system. LLVM's type system is quite complex, containing multiple levels such as basic types, composite types, and function types.
Trait Objects: An Abstract Representation of Typesβ
In the API design of llvm.mbt, you will frequently encounter the core concept of &Type. This is not a concrete struct or enum, but a Trait Objectβwhich can be understood as the functional equivalent of an abstract base class in object-oriented programming.
// &Type is a trait object representing any LLVM type
let some_type: &Type = context.i32_type()
Type Identification and Conversionβ
To determine the specific type of a &Type, we need to perform a runtime type check using the as_type_enum interface:
pub fn identify_type(ty: &Type) -> String {
match ty.as_type_enum() {
IntType(int_ty) => "Integer type with \{int_ty.get_bit_width()} bits"
FloatType(float_ty) => "Floating point type"
PointerType(ptr_ty) => "Pointer type"
FunctionType(func_ty) => "Function type"
ArrayType(array_ty) => "Array type"
StructType(struct_ty) => "Structure type"
VectorType(vec_ty) => "Vector type"
ScalableVectorType(svec_ty) => "Scalable vector type"
MetadataType(meta_ty) => "Metadata type"
}
}
Safe Type Conversion Strategiesβ
When we are certain that a &Type has a specific type, there are several conversion methods to choose from:
-
Direct Conversion (for deterministic scenarios)
let ty: &Type = context.i32_type() let i32_ty = ty.into_int_type() // Direct conversion, errors are handled by llvm.mbt let bit_width = i32_ty.get_bit_width() // Call a method specific to IntType -
Defensive Conversion (recommended for production environments)
let ty: &Type = get_some_type() // An unknown type obtained from somewhere guard ty.as_type_enum() is IntType(i32_ty) else { raise CodeGenError("Expected integer type, got \{ty}") } // Now it's safe to use i32_ty let bit_width = i32_ty.get_bit_width()
Constructing Composite Typesβ
LLVM supports various composite types, which are usually constructed through methods of basic types:
pub fn create_composite_types(context: @llvm.Context) -> Unit {
let i32_ty = context.i32_type()
let f64_ty = context.f64_type()
// Array type: [16 x i32]
let i32_array_ty = i32_ty.array_type(16)
// Function type: i32 (i32, i32)
let add_func_ty = i32_ty.fn_type([i32_ty, i32_ty])
// Struct type: {i32, f64}
let struct_ty = context.struct_type([i32_ty, f64_ty])
// Pointer type (all pointers are opaque in LLVM 18+)
let ptr_ty = i32_ty.ptr_type()
// Output type information for verification
println("Array type: \{i32_array_ty}") // [16 x i32]
println("Function type: \{add_func_ty}") // i32 (i32, i32)
println("Struct type: \{struct_ty}") // {i32, f64}
println("Pointer type: \{ptr_ty}") // ptr
}
Important Reminder: Opaque Pointers
Starting with LLVM version 18, all pointer types use the opaque pointer design. This means that regardless of the type they point to, all pointers are represented as
ptrin the IR, and the specific type information they point to is no longer visible in the type system.
Chapter 2: The LLVM Value System and the BasicValue Conceptβ
Compared to the type system, LLVM's value system is more complex. llvm.mbt, consistent with inkwell, divides values into two important abstract layers: Value and BasicValue. The difference lies in distinguishing the source of value creation from the way values are used:
- Value: Focuses on how a value is produced (e.g., constants, instruction results).
- BasicValue: Focuses on what basic type a value has (e.g., integer, float, pointer).
Practical Application Exampleβ
pub fn demonstrate_value_system(context: Context, builder: Builder) -> Unit {
let i32_ty = context.i32_type()
// Create two integer constants - these are directly IntValue
let const1 = i32_ty.const_int(10) // Value: IntValue, BasicValue: IntValue
let const2 = i32_ty.const_int(20) // Value: IntValue, BasicValue: IntValue
// Perform an addition operation - the result is an InstructionValue
let add_result = builder.build_int_add(const1, const2)
// In different contexts, we need different perspectives:
// As an instruction to check its properties
let instruction = add_result.as_instruction()
println("Instruction opcode: \{instruction.get_opcode()}")
// As a basic value to get its type
let basic_value = add_result.into_basic_value()
println("Result type: \{basic_value.get_type()}")
// As an integer value for subsequent calculations
let int_value = add_result.into_int_value()
let final_result = builder.build_int_mul(int_value, const1)
}
Complete Classification of Value Typesβ
-
ValueEnum: All possible value types
pub enum ValueEnum { IntValue(IntValue) // Integer value FloatValue(FloatValue) // Floating-point value PointerValue(PointerValue) // Pointer value StructValue(StructValue) // Struct value FunctionValue(FunctionValue) // Function value ArrayValue(ArrayValue) // Array value VectorValue(VectorValue) // Vector value PhiValue(PhiValue) // Phi node value ScalableVectorValue(ScalableVectorValue) // Scalable vector value MetadataValue(MetadataValue) // Metadata value CallSiteValue(CallSiteValue) // Call site value GlobalValue(GlobalValue) // Global value InstructionValue(InstructionValue) // Instruction value } derive(Show) -
BasicValueEnum: Values that have a basic type
pub enum BasicValueEnum { ArrayValue(ArrayValue) // Array value IntValue(IntValue) // Integer value FloatValue(FloatValue) // Floating-point value PointerValue(PointerValue) // Pointer value StructValue(StructValue) // Struct value VectorValue(VectorValue) // Vector value ScalableVectorValue(ScalableVectorValue) // Scalable vector value } derive(Show)
π‘ Best Practices for Value Conversionβ
In the actual code generation process, we often need to convert between different value perspectives:
pub fn value_conversion_patterns(instruction_result: &Value) -> Unit {
// Pattern 1: I know what type this is, convert directly
let int_val = instruction_result.into_int_value()
// Pattern 2: I just need a basic value, I don't care about the specific type
let basic_val = instruction_result.into_basic_value()
// Pattern 3: Defensive programming, check before converting
match instruction_result.as_value_enum() {
// Handle integer values
IntValue(int_val) => handle_integer(int_val)
// Handle float values
FloatValue(float_val) => handle_float(float_val)
_ => raise CodeGenError("Unexpected value type")
}
}
Through this two-layer abstraction, llvm.mbt maintains the integrity of the LLVM value system while providing an intuitive and easy-to-use interface for Moonbit developers.
Chapter 3: Practical LLVM IR Generationβ
Now that we understand the type and value systems, let's demonstrate how to use llvm.mbt to generate LLVM IR with a complete example. This example will implement a simple muladd function, showing the entire process from initialization to instruction generation.
Infrastructure Initializationβ
Any LLVM program begins by establishing three core components:
pub fn initialize_llvm() -> (Context, Module, Builder) {
// 1. Create an LLVM context - a container for all LLVM objects
let context = @llvm.Context::create()
// 2. Create a module - a container for functions and global variables
let module = context.create_module("demo_module")
// 3. Create an IR builder - used to generate instructions
let builder = context.create_builder()
(context, module, builder)
}
A Simple Function Generation Exampleβ
Let's implement a function that calculates (a * b) + c:
pub fn generate_muladd_function() -> String {
// Initialize LLVM infrastructure
let (context, module, builder) = initialize_llvm()
// Define the function signature
let i32_ty = context.i32_type()
let func_type = i32_ty.fn_type([i32_ty, i32_ty, i32_ty])
let func_value = module.add_function("muladd", func_type)
// Create the function entry basic block
let entry_block = context.append_basic_block(func_value, "entry")
builder.position_at_end(entry_block)
// Get the function parameters
let arg_a = func_value.get_nth_param(0).unwrap().into_int_value()
let arg_b = func_value.get_nth_param(1).unwrap().into_int_value()
let arg_c = func_value.get_nth_param(2).unwrap().into_int_value()
// Generate calculation instructions
let mul_result = builder.build_int_mul(arg_a, arg_b).into_int_value()
let add_result = builder.build_int_add(mul_result, arg_c)
// Generate the return instruction
let _ = builder.build_return(add_result)
// Output the generated IR
module.dump()
}
Generated LLVM IRβ
Running the above code will produce the following LLVM Intermediate Representation:
; ModuleID = 'demo_module'
source_filename = "demo_module"
define i32 @muladd(i32 %0, i32 %1, i32 %2) {
entry:
%3 = mul i32 %0, %1
%4 = add i32 %3, %2
ret i32 %4
}
π‘ Code Generation Best Practicesβ
-
Naming Conventions
For instructions that return a value, the build interface has a
namelabel argument, which can be used to add a name to the result of the instruction.let mul_result = builder.build_int_mul(lhs, rhs, name="temp_product") let final_result = builder.build_int_add(mul_result, offset, name="final_sum") -
Error Handling
Use
raiseinstead ofpanicfor error handling, and manage exceptions for situations that are not easy to determine directly.// Check for operations that might fail match func_value.get_nth_param(index) { Some(param) => param.into_int_value() None => raise CodeGenError("Function parameter \{index} not found") }
Chapter 4: TinyMoonbit Compiler Implementationβ
Now let's turn our attention to the actual compiler implementation, converting the abstract syntax tree we built in the previous article into LLVM IR.
Type Mapping: From Parser to LLVMβ
First, we need to establish a mapping between the TinyMoonbit type system and the LLVM type system:
pub struct CodeGen {
parser_program : Program // AST representation of the source program
llvm_context : @llvm.Context // LLVM context
llvm_module : @llvm.Module // LLVM module
builder : @llvm.Builder // IR builder
llvm_functions : Map[String, @llvm.FunctionValue] // Function map
}
pub fn convert_type(self : Self, parser_type : Type) -> &@llvm.Type raise {
match parser_type {
Type::Unit => self.llvm_context.void_type() as &@llvm.Type
Type::Bool => self.llvm_context.bool_type()
Type::Int => self.llvm_context.i32_type()
Type::Double => self.llvm_context.f64_type()
// Can be extended with more types as needed
}
}
Environment Management: Mapping Variables to Valuesβ
During the code generation phase, we need to maintain a mapping from variable names to LLVM values:
pub struct Env {
parent : Env? // Reference to the parent environment
symbols : Map[String, &@llvm.Value] // Local variable map
// Global information
codegen : CodeGen // Reference to the code generator
parser_function : Function // AST of the current function
llvm_function : @llvm.FunctionValue // LLVM representation of the current function
}
pub fn get_symbol(self : Self, name : String) -> &@llvm.Value? {
match self.symbols.get(name) {
Some(value) => Some(value)
None =>
match self.parent {
Some(parent_env) => parent_env.get_symbol(name)
None => None
}
}
}
Variable Handling: Memory Allocation Strategyβ
As a systems-level language, TinyMoonbit supports variable reassignment. In LLVM IR's SSA (Static Single Assignment) form, we need to use the alloca + load/store pattern to implement mutable variables:
pub fn Stmt::emit(self : Self, env : Env) -> Unit raise {
match self {
// Variable declaration: e.g., let x : Int = 5;
Let(var_name, var_type, init_expr) => {
// Convert the type and allocate stack space
let llvm_type = env.codegen.convert_type(var_type)
let alloca = env.codegen.builder.build_alloca(llvm_type, var_name)
// Record the allocated pointer in the symbol table
env.symbols.set(var_name, alloca as &@llvm.Value)
// Calculate the value of the initialization expression
let init_value = init_expr.emit(env).into_basic_value()
// Store the initial value into the allocated memory
let _ = env.codegen.builder.build_store(alloca, init_value)
}
// Variable assignment: x = 10;
Assign(var_name, rhs_expr) => {
// Get the memory address of the variable from the symbol table
guard let Some(var_ptr) = env.get_symbol(var_name) else {
raise CodeGenError("Undefined variable: \{var_name}")
}
// Calculate the value of the right-hand side expression
let rhs_value = rhs_expr.emit(env).into_basic_value()
// Store the new value into the variable's memory
let _ = env.codegen.builder.build_store(var_ptr, rhs_value)
}
// Other statement types...
_ => { /* Handle other statements */ }
}
}
Design Decision: Why use
alloca?In functional languages, immutable variables can be directly mapped to SSA values. However, TinyMoonbit supports variable reassignment, which conflicts with the SSA principle of "each variable is assigned only once."
The alloca + load/store pattern is the standard way to handle mutable variables:
alloca: Allocates memory space on the stack.store: Writes a value to memory.load: Reads a value from memory.LLVM's optimization process will automatically convert simple
allocas back to value form (themem2regoptimization).
Expression Code Generationβ
Expression code generation is relatively straightforward, mainly involving calling the corresponding instruction-building methods based on the expression type:
fn Expr::emit(self: Self, env: Env) -> &@llvm.Value raise {
match self {
AtomExpr(atom_expr, ..) => atom_expr.emit(env)
Unary("-", expr, ty = Some(Int)) => {
let value = expr.emit().into_int_value()
let zero = env.gen.llvm_ctx.i32_type().const_zero()
env.gen.builder.build_int_sub(zero, value)
}
Unary("-", expr, ty = Some(Double)) => {
let value = expr.emit().into_float_value()
env.gen.builder.build_float_neg(value)
}
Binary("+", lhs, rhs, ty=Some(Int)) => {
let lhs_val = lhs.emit().into_int_value()
let rhs_val = rhs.emit().into_int_value()
env.gen.builder.build_int_add(lhs_val, rhs_val)
}
// ... others
}
}
Technical Detail: Floating-Point Negation
Note that when handling floating-point negation, we use
build_float_neginstead of subtracting the operand from zero. This is because:
- IEEE 754 Standard: Floating-point numbers have special values (like NaN, β), and simple subtraction might produce incorrect results.
- Performance Considerations: Dedicated negation instructions are usually more efficient on modern processors.
- Precision Guarantee: Avoids unnecessary rounding errors.
Chapter 5: Implementation of Control Flow Instructionsβ
Control flow is the backbone of program logic, including conditional branches and loop structures. In LLVM IR, control flow is implemented through Basic Blocks and branch instructions. Each basic block represents a sequence of instructions with no internal jumps, and blocks are connected by branch instructions.
Conditional Branches: Implementing if-else Statementsβ
Conditional branches require creating multiple basic blocks to represent different execution paths:
fn Stmt::emit(self: Self, env: Env) -> Unit raise {
let ctx = env.gen.llvm_ctx
let func = env.llvm_func
let builder = env.gen.builder
match self {
If(cond, then_stmts, else_stmts) => {
let cond_val = cond.emit(env).into_int_value()
// Create three basic blocks
let then_block = ctx.append_basic_block(llvm_func)
let else_block = ctx.append_basic_block(llvm_func)
let merge_block = ctx.append_basic_block(llvm_func)
// Create the jump instruction
let _ = builder.build_conditional_branch(
cond_val, then_block, else_block,
)
// Generate code for the then_block
builder.position_at_end(then_block)
let then_env = self.subenv()
then_stmts.each(s => s.emitStmt(then_env))
let _ = builder.build_unconditional_branch(merge_block)
// Generate code for the else_block
builder.position_at_end(else_block)
let else_env = self.subenv()
else_stmts.each(s => s.emitStmt(else_env))
let _ = builder.build_unconditional_branch(merge_block)
// After code generation is complete, the builder's position should be on the merge_block
builder.position_at_end(merge_block)
}
// ...
}
}
Generated LLVM IR Exampleβ
For the following TinyMoonbit code:
if x > 0 {
y = x + 1;
} else {
y = x - 1;
}
It will generate LLVM IR similar to this:
%1 = load i32, ptr %x, align 4
%2 = icmp sgt i32 %1, 0
br i1 %2, label %if.then, label %if.else
if.then: ; preds = %0
%3 = load i32, ptr %x, align 4
%4 = add i32 %3, 1
store i32 %4, ptr %y, align 4
br label %if.end
if.else: ; preds = %0
%5 = load i32, ptr %x, align 4
%6 = sub i32 %5, 1
store i32 %6, ptr %y, align 4
br label %if.end
if.end: ; preds = %if.else, %if.then
; Subsequent code...
Loop Structures: Implementing while Statementsβ
The implementation of loops requires special attention to the correct connection of the condition check and the loop body:
fn Stmt::emit(self: Self, env: Env) -> Unit raise {
let ctx = env.gen.llvm_ctx
let func = env.llvm_func
let builder = env.gen.builder
match self {
While(cond, body) => {
// Generate three blocks
let cond_block = ctx.append_basic_block(llvm_func)
let body_block = ctx.append_basic_block(llvm_func)
let merge_block = ctx.append_basic_block(llvm_func)
// First, unconditionally jump to the cond block
let _ = builder.build_unconditional_branch(cond_block)
builder.position_at_end(cond_block)
// Generate code within the cond block, as well as the conditional jump instruction
let cond_val = cond.emit().into_int_value()
let _ = builder.build_conditional_branch(
cond_val, body_block, merge_block,
)
builder.position_at_end(body_block)
// Generate code for the body block, with an unconditional jump to the cond block at the end
let body_env = self.subenv()
body.each(s => s.emitStmt(body_env))
let _ = builder.build_unconditional_branch(cond_block)
// After code generation is finished, jump to the merge block
builder.position_at_end(merge_block)
}
// ...
}
}
Generated LLVM IR Exampleβ
For the TinyMoonbit code:
while i < 10 {
i = i + 1;
}
It will generate:
br label %while.cond
while.cond: ; preds = %while.body, %0
%1 = load i32, ptr %i, align 4
%2 = icmp slt i32 %1, 10
br i1 %2, label %while.body, label %while.end
while.body: ; preds = %while.cond
%3 = load i32, ptr %i, align 4
%4 = add i32 %3, 1
store i32 %4, ptr %i, align 4
br label %while.cond
while.end: ; preds = %while.cond
; Subsequent code...
π‘ Control Flow Design Points
-
Basic Block Naming Strategy
The
append_basic_blockfunction also has anamelabel argument.// Use descriptive block names for easier debugging and understanding let then_block = context.append_basic_block(func, name="if.then") let else_block = context.append_basic_block(func, name="if.else") let merge_block = context.append_basic_block(func, name="if.end") -
Scope Management
// Create a separate scope for each branch and loop body let branch_env = env.sub_env() branch_stmts.each( stmt => stmt.emit(branch_env) } -
Builder Position Management
At the end, be sure to place the instruction builder on the correct basic block.
// Always ensure the builder points to the correct basic block builder.position_at_end(merge_block) // Generate instructions in this block...
Chapter 6: From LLVM IR to Machine Codeβ
After generating the complete LLVM IR, we need to convert it into assembly code for the target machine. Although llvm.mbt provides a complete target machine configuration API, for learning purposes, we can use a simpler method.
Compiling with the llc Toolchainβ
The most direct method is to output the generated LLVM IR to a file and then use the LLVM toolchain to compile it:
Call the dump function of the Module, or you can use the println function.
let gen : CodeGen = ...
let prog = gen.llvm_prog
prog.dump() // dump is recommended as it will be slightly faster than println, with the same effect
// or println(prog)
Complete Compilation Flow Exampleβ
Let's look at a complete compilation flow from source code to assembly code:
-
TinyMoonbit Source Code
fn factorial(n: Int) -> Int { if n <= 1 { return 1; } return n * factorial(n - 1); } fn main() -> Unit { let result: Int = factorial(5); print_int(result); } -
Generated LLVM IR
; ModuleID = 'tinymoonbit' source_filename = "tinymoonbit" define i32 @factorial(i32 %0) { entry: %1 = alloca i32, align 4 store i32 %0, ptr %1, align 4 %2 = load i32, ptr %1, align 4 %3 = icmp sle i32 %2, 1 br i1 %3, label %4, label %6 4: ; preds = %entry ret i32 1 6: ; preds = %entry %7 = load i32, ptr %1, align 4 %8 = load i32, ptr %1, align 4 %9 = sub i32 %8, 1 %10 = call i32 @factorial(i32 %9) %11 = mul i32 %7, %10 ret i32 %11 } define void @main() { entry: %0 = alloca i32, align 4 %1 = call i32 @factorial(i32 5) store i32 %1, ptr %0, align 4 %2 = load i32, ptr %0, align 4 call void @print_int(i32 %2) ret void } declare void @print_int(i32 %0) -
Generating RISC-V Assembly with
llc# Generate llvm ir moon run main --target native > fact.ll # Generate RISC-V 64-bit assembly code llc -march=riscv64 -mattr=+m -o fact.s fact.ll -
Generated RISC-V Assembly Snippet
factorial: .Lfunc_begin0: .cfi_startproc addi sp, sp, -32 .cfi_def_cfa_offset 32 sd ra, 24(sp) .cfi_offset ra, -8 sd s0, 16(sp) .cfi_offset s0, -16 addi s0, sp, 32 .cfi_def_cfa s0, 0 sw a0, -20(s0) lw a0, -20(s0) li a1, 1 blt a1, a0, .LBB0_2 li a0, 1 j .LBB0_3 .LBB0_2: lw a0, -20(s0) lw a1, -20(s0) addi a1, a1, -1 sw a0, -24(s0) mv a0, a1 call factorial lw a1, -24(s0) mul a0, a1, a0 .LBB0_3: ld ra, 24(sp) ld s0, 16(sp) addi sp, sp, 32 ret
Conclusionβ
Through this two-part series, we have completed a fully functional, albeit simple, compiler implementation. From the lexical analysis of a character stream to the construction of an abstract syntax tree, and finally to the generation of LLVM IR and machine code output.
Reviewβ
Part 1:
- An elegant lexer based on pattern matching
- Implementation of a recursive descent parser
- A complete type-checking system
- Scope management with an environment chain
Part 2:
- A deep dive into the LLVM type and value systems
- Variable management strategies in SSA form
- Correct implementation of control flow instructions
- A complete code generation pipeline
Moonbit's Advantages in Compiler Developmentβ
Through this practical project, we have gained a deep appreciation for Moonbit's unique value in the field of compiler construction:
- Expressive Pattern Matching: Greatly simplifies the complexity of AST processing and type analysis.
- Functional Programming Paradigm: Immutable data structures and pure functions make the compiler logic clearer and more reliable.
- Modern Type System: Trait objects, generics, and error handling mechanisms provide ample abstraction capabilities.
- Excellent Engineering Features: Features like
deriveand JSON serialization significantly improve development efficiency.
Final Wordsβ
Compiler technology represents the perfect combination of computer science theory and engineering practice. With a modern tool like Moonbit, we can explore this ancient yet vibrant field in a more elegant and efficient way.
We hope this series of articles will provide readers with a powerful aid on their journey into compiler design.
Recommended Learning Resources