Code Generation
Code generation takes the FIR control-flow graph and produces target machine code, without involving GCC, LLVM, or any external tool.
Machine Lowering
Machine lowering translates FIR to a target-specific machine IR. For RP2040 (flint-backend), the target is Thumb v6-M (ARMv6-M).
Instruction Selection
FIR instructions are mapped to machine instructions:
| FIR operation | Thumb v6-M instruction(s) |
|---|---|
| Integer add | ADDS Rd, Rn, Rm |
| Integer subtract | SUBS Rd, Rn, Rm |
| Load field | LDR Rd, [Rn, #offset] |
| Store field | STR Rd, [Rn, #offset] |
| Conditional branch | BEQ, BNE, BLT, BGE, … |
| Function call | BL target |
| Return | BX LR |
The Thumb v6-M encoding is dense: most instructions are 16-bit, producing compact binaries suitable for the RP2040’s 2 MB flash.
Register Allocation
FIR values are assigned to physical ARM registers or stack slots. The RP2040 has 16 registers (R0-R15):
- R0-R3: argument and return registers (also caller-saved scratch)
- R4-R7: callee-saved (low registers, accessible to all Thumb v6-M instructions)
- R8-R11: callee-saved (high registers, limited access in v6-M)
- R12: scratch (IP)
- R13: stack pointer (SP)
- R14: link register (LR)
- R15: program counter (PC)
The register allocator performs linear scan allocation. Values that cannot be kept in registers are spilled to the stack frame.
ABI
Flint follows the ARM Procedure Call Standard (APCS / AAPCS) for RP2040:
- First four arguments in R0-R3.
- Additional arguments on the stack.
- Return value in R0 (or R0-R1 for 64-bit values).
- Caller saves R0-R3, R12. Callee saves R4-R11, R14.
Parameter modes:
- Copy scalars: passed in registers by value.
- Default (read-only borrow) non-copy: passed as a hidden address register.
mut(mutable borrow) non-copy: passed as an exclusive hidden address register.- Large aggregates: passed as hidden pointer per ABI rules.
Stack Frames
Every function that uses local variables or calls other functions has a stack frame:
High address
┌─────────────────┐
│ Caller's frame │
├─────────────────┤ ← SP on entry
│ Saved LR │ (if function calls others)
│ Saved R4-R7 │ (callee-saved registers used)
│ Local variables│
│ Spill slots │
└─────────────────┘ ← SP during function body
Low address
The compiler emits PUSH on entry and POP on exit for callee-saved registers. Local variable layout is computed from type sizes and alignment requirements.
Instruction Encoding
Thumb v6-M instructions are encoded to 16-bit or 32-bit binary. The encoder converts the machine IR’s instruction objects directly to bytes with no intermediate assembly text format.
All relocations (cross-function calls, PC-relative data references) are resolved after instruction encoding:
- Each function is encoded to a byte buffer.
- Relocation entries record the offset and target for each unresolved reference.
- The linker pass assigns final addresses to all sections and symbols.
- Relocations are patched into the byte buffer.
Section Layout
The final image is organized into ELF sections:
| Section | Contents |
|---|---|
.text | Executable code |
.rodata | Read-only data (string literals, constants, embedded assets) |
.data | Initialized mutable data (copied from flash to RAM at startup) |
.bss | Zero-initialized data |
For RP2040:
.textand.rodatalive in flash at0x10000000..dataand.bsslive in RAM at0x20000000.- The boot2 second-stage bootloader is prepended to the flash image.
Image Packaging
ELF: Standard 32-bit ARM ELF executable (ET_EXEC) with section headers and a symbol table containing all emitted functions. Used by probe-rs, OpenOCD, and GDB. The flint-elf crate constructs the ELF from the same raw binary image, adding ELF headers, a PT_LOAD segment mapped to the flash base address, and function symbols from the artifact layout. DWARF debug information is not yet emitted.
BIN: The raw .text + .data binary, stripped of all ELF metadata. Just the bytes that go on flash.
UF2: The flint-uf2 crate wraps the BIN into UF2 format:
- UF2 family ID for RP2040 (
0xe48bff56) - 256-byte data blocks with 476-byte UF2 headers
- Correct flash start address and block count
- The Pico’s USB bootloader validates the family ID and block structure before accepting the file
No LLVM, No GCC
Everything described above (instruction selection, register allocation, encoding, relocation, image packaging) is implemented in Rust inside the Flint compiler crates. There is no dependency on LLVM IR, GCC’s assembler, GNU ld, or any external compiler infrastructure.
This is a core design requirement: the compiler is self-contained.