IR and Lowering
Flint uses two intermediate representations (IRs) between the AST and machine code:
- HIR (High-Level IR): typed, desugared, ownership-annotated
- FIR (Flint IR): target-independent control-flow graph
HIR: High-Level IR
HIR is produced from the type-checked AST. It retains the high-level structure of the source but with:
- All types resolved and inferred, with no ambiguity remaining.
?propagation expanded into explicitmatch/returnsequences.deferstatements expanded into explicit cleanup scheduling at scope exits.- Ownership transfers made explicit: every value move, borrow, and drop is annotated.
- Pattern match exhaustiveness verified.
- Collection method calls type-checked against official generic definitions.
- Implicit drop sites computed: the compiler inserts drop operations at scope boundaries for every owned value.
HIR is still structured as functions with statements and expressions. It does not have basic blocks or explicit control flow edges yet.
Key HIR Transformations
? desugaring:
// Source
let data = file.read()?
// HIR (conceptual)
let data = match file.read() {
Ok(v) => v
Err(e) => { drop(everything_live); return Err(e) }
}
defer scheduling:
// Source
let conn = db.connect()?
defer conn.close()
do_work(conn)
// HIR (conceptual)
let conn = db.connect()?
// ... defer registered at this scope level ...
do_work(conn)
// Scope exit: conn.close() runs before conn is dropped
FIR: Flint IR
FIR is a typed, SSA-like control-flow graph IR. It is the last shared representation before target-specific lowering begins.
Basic Blocks
Each function in FIR is a set of basic blocks. A basic block is a linear sequence of instructions with exactly one entry and one exit (a terminator).
Terminators:
Branch(cond, true_block, false_block): conditional jumpJump(target_block): unconditional jumpReturn(value): return from functionTrap: unreachable / fatal
Control flow between blocks is explicit as edges in a directed graph.
FIR Values
FIR uses named values (not physical registers). Each value has a type and is assigned exactly once (SSA property where it applies). Ownership is explicit:
- A move of value
vto a new owner invalidatesv. - A borrow of
vcreates a view valid for a lexically bounded region. - A drop of
vinserts the generated drop glue for its type.
FIR Instructions
Example FIR instruction kinds:
Let(result, type, init): bind a valueCall(result, callee, args, calling_conv): function callFieldGet(result, base, field_index): struct field readFieldSet(base, field_index, value): struct field writeIndex(result, base, index): array/slice indexingMove(result, source): ownership transferBorrow(result, source, mode): create read or write borrowDrop(value): drop an owned valueCast(result, value, target_type): numeric conversion
Why Two IRs?
HIR is better for semantic analysis: it is still structured like source code, making it easier to check ownership, defer, and match exhaustiveness.
FIR is better for code generation: the control-flow graph makes reachability analysis, dead code elimination, and instruction selection straightforward.
Ownership in FIR
The ownership model is encoded directly in FIR:
- Every owned value has a single live definition at any program point.
- Moves are explicit edges that transfer ownership.
- Borrows carry a mode (
readorwrite) and a scope. - Drop insertions are explicit instructions; nothing drops implicitly at the FIR level.
This makes FIR verifiable: a simple analysis can confirm that no value is used after a move or drop.
Dead Code Elimination
FIR supports dead code elimination (DCE) before machine lowering:
- Unreachable basic blocks are removed.
- Unused bindings are eliminated.
- Capability-query branches that are always-false for the selected target are removed (static constant folding).
DCE keeps MCU binaries small by removing code paths for unsupported features on the selected target.