Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Flint Book

Flint is an agent-first systems programming language for microcontrollers first, then desktop and server targets.

It is designed to be straightforward for humans to read and write, while also being rigid enough for agents to generate reliably. The language keeps the number of moving parts low: no garbage collector, no heavyweight runtime, no external LLVM or GCC toolchain, and no dependency manager for common work.

This book is the main documentation site for Flint. It is meant to be read front to back like a guide, but it is also structured so you can jump directly to the language or platform sections when you need a reference.

During the migration, the existing files in docs/spec/ remain the deeper reference material. This book is the friendlier front door and the long-term home for that content.

What This Book Covers

  • How Flint is positioned and why it exists.
  • How to get started with the compiler today.
  • Which hardware targets are in scope.
  • How the language works in practice.
  • A grouped reference for Flint keywords and operators.

Who This Book Is For

This book is for two audiences:

  • Humans who want a small, understandable systems language.
  • Agents that need a language with predictable syntax, explicit mutation, and prescriptive diagnostics.

If you are new to Flint, start with What Flint Is, then read Why Flint Exists, and move on to the Getting Started section.

What Flint Is

Flint is a portable, memory-safe systems programming language that compiles directly to machine code for targets such as ARM, RISC-V, x86-64, and WebAssembly.

The project starts with microcontrollers because they are where the tradeoffs are harshest. Toolchains are often large, setup is fragile, memory is limited, and the gap between “hello world” and “works on real hardware” is wider than it should be. Flint tries to narrow that gap.

Design Goals

Flint is built around a few strong constraints:

  • Keep the language small enough to learn quickly.
  • Keep mutation explicit everywhere.
  • Keep the toolchain self-contained.
  • Keep the generated program close to the hardware.
  • Keep the language readable for humans and predictable for agents.

What Flint Looks Like

Flint favors direct syntax and visible effects:

use micro/gpio
use time/delay

fn main() {
    let builtin_led = gpio.builtin_led()
    if builtin_led.is_none() {
        return
    }

    if let Option.Some(led) = builtin_led {
        loop {
            led.toggle()
            delay.millis(500)
        }
    }
}

The important ideas are visible in the surface syntax:

  • let creates an immutable binding.
  • mut marks mutation explicitly.
  • use imports a module.
  • return is always explicit.
  • and, or, and not are the logical operators.
  • Ownership and moves are part of the language, but there is no & or &mut syntax in safe code.
  • Optional hardware capabilities can be handled explicitly with a guard plus if let, or with match.

Current Status

Flint is in active development. The current compiler pipeline is functional end to end: lexer, parser, type checker, FIR lowering, ARM Thumb-2 code generation, ELF output, and UF2 output for RP2040.

That means Flint already works as a real compiler project, even though large parts of the language, hardware library surface, and target matrix are still being filled in.

Why Flint Exists

Flint was created to make systems programming, especially MCU work, simpler than it usually is today.

The Problem With The Usual Choices

Microcontroller development gives you many language and framework options, but most of them come with a cost that becomes obvious once you try to ship something real.

Some environments are easy to start with, but lock you into a specific IDE or workflow. Some expose the hardware well, but pull in a large compiler stack, build system, and toolchain before you can blink an LED. Some are pleasant to script in, but depend on a runtime that must be deployed to the board first. Others deliver strong safety guarantees, but impose a mental model that many developers find difficult to work with under time pressure.

Flint is not trying to pretend those tradeoffs do not exist. It is trying to choose a different set of tradeoffs.

A Self-Contained Toolchain

Flint is meant to let you write code for a target and build it with Flint alone.

That means:

  • No separate GCC installation.
  • No LLVM dependency.
  • No linker setup for the normal path.
  • No runtime that has to be pre-flashed onto the device.

If you have the Flint compiler, you have the toolchain.

Why Start With MCUs

MCUs are one of the clearest pain points in software development. Desktop and server environments already have rich frameworks, large standard libraries, and many mature toolchains. MCU work is often where setup cost, build fragility, and tight resource budgets collide.

Starting with MCUs forces Flint to prove itself under the hardest constraints:

  • small memory budgets,
  • direct hardware access,
  • minimal runtime assumptions,
  • and binaries that must do exactly what they say.

If Flint can work well on a small microcontroller, it has a strong foundation for larger targets later.

Fewer Dependencies, Less Supply Chain Risk

Modern ecosystems often drift toward package sprawl. Even small applications can accumulate long transitive dependency chains, more code than they need, and more places where supply chain attacks can land.

Flint takes the opposite approach. The standard library is meant to be broad enough to cover common work directly, including embedded use cases. The goal is not to block extension forever. The goal is to make the default path simple, auditable, and small.

Agent-First, Still Human

Flint is designed so agents can write it reliably:

  • syntax is explicit,
  • mutation is visible,
  • the language avoids multiple ways to express the same thing,
  • and diagnostics are prescriptive enough for automated correction.

But that does not mean the language is “for bots only”. Flint is also meant to be readable and writable by humans. The target is a language that borrows the best ideas from systems languages and practical scripting languages while staying lean.

The Direction

Flint aims to be:

  • simple enough to learn quickly,
  • powerful enough to write real firmware and systems software,
  • portable across MCU and non-MCU targets,
  • and self-contained enough to stay pleasant to deploy.

That is the core reason Flint exists.

Getting Started

The current Flint experience is still early, but it is already usable enough to understand the model and compile real examples.

At a high level, the workflow is:

  1. Get the compiler.
  2. Write a .fl source file.
  3. Build for a target such as rp2040.
  4. Flash or run the result.

The next chapters walk through that flow.

Installation

Flint does not have formal release packages yet.

For now, installation is best understood as “build the compiler from source”. This section will later become the place for binary installers, package manager instructions, and platform-specific setup once releases exist.

Current State

Today, the practical path is:

cargo build
cargo test

That builds the compiler workspace and runs the current test suite.

Future Direction

This page is intentionally a stub for now. When Flint starts shipping releases, this chapter should cover:

  • prebuilt compiler downloads,
  • checksums and signatures,
  • supported host platforms,
  • upgrade instructions,
  • and the minimum setup needed to build for supported targets.

The long-term goal is for installation to stay small and predictable.

Your First Program

The simplest useful Flint program on current hardware support is an RP2040 LED blink.

This version uses the HAL-facing modules instead of raw register writes:

use micro/gpio
use time/delay

fn main() {
    let builtin_led = gpio.builtin_led()
    if builtin_led.is_none() {
        return
    }

    if let Option.Some(led) = builtin_led {
        loop {
            led.toggle()
            delay.millis(500)
        }
    }
}

What To Notice

  • use imports modules with explicit paths.
  • fn main() is a normal function. There is no hidden runtime wrapper in the language syntax.
  • let is immutable by default.
  • is_none() makes the missing-hardware guard obvious up front.
  • loop is the simplest infinite loop form.
  • Method calls such as led.toggle() are ordinary Flint syntax, not macros.

About builtin_led()

Flint also exposes gpio.builtin_led() as a convenience API, but it returns Option<gpio.OutputPin> because not every board has a direct GPIO-backed onboard LED.

Use it when you want a helper and are prepared to handle the missing case:

let builtin_led = gpio.builtin_led()
if builtin_led.is_none() {
    return
}

if let Option.Some(led) = builtin_led {
    led.toggle()
}

This keeps the missing-hardware case explicit in the source without pushing the main path deeper to the right.

A Slightly Lower-Level Version

Flint can also target the hardware more directly when needed. The repository includes examples that use unsafe and volatile_write to configure GPIO on the RP2040 without going through a higher-level HAL abstraction.

That is an important part of Flint’s design: higher-level APIs should exist, but they should not block you from reaching the machine when necessary.

Build, Flash, and Check

The CLI is intentionally small.

Build

To compile a Flint source file:

flint build --target rp2040 --board pi-pico src/main.fl

For RP2040, this produces a UF2 image that can be copied to the board in BOOTSEL mode.

Run and Flash

The current CLI shape is:

flint run [--target <chip>] [--board <board>] src/main.fl
flint flash --target <chip> build/main.uf2

run is the convenience path for “build and then execute or flash”. flash is the explicit step when you already have an artifact.

Check, Format, and Test

The intended developer loop also includes:

flint test src/
flint fmt src/
flint check --format json src/

These commands matter for Flint’s agent-first goals. A language that wants reliable automated use needs a stable formatter and structured diagnostics, not only a compiler binary.

What This Means In Practice

The shortest description of the workflow is:

  1. write a .fl file,
  2. target a board or chip,
  3. build,
  4. flash or run,
  5. use diagnostics to correct the program.

Platform Overview

Flint is MCU-first, but not MCU-only.

The project begins with bare-metal microcontrollers because they are the strictest environment for a language and compiler:

  • memory is limited,
  • startup is explicit,
  • runtime assumptions must stay minimal,
  • and the compiler must produce binaries that map closely to the hardware.

That said, Flint is designed with a larger target set in mind. The same language should eventually span:

  • MCU firmware,
  • native desktop and server binaries,
  • and WebAssembly.

The following chapters cover the current target picture and the standard library model that supports it.

Hardware Support

Flint’s current development focus is RP2040 and the surrounding MCU toolchain story. Other targets are part of the design, but they are not yet equally mature.

The target matrix and the MCU HAL are separate questions. This page covers chips and boards; the current micro/* peripheral surface is documented in the MCU HAL chapter.

MCU Targets

TargetArchitectureStatus
rp2040ARM Cortex-M0+Code generation working
rp2350ARM Cortex-M33 / RISC-VPlanned
samd21ARM Cortex-M0+Planned
samd51ARM Cortex-M4FPlanned
stm32f4ARM Cortex-M4FPlanned
esp32c3RISC-VPlanned
nrf52ARM Cortex-M4FPlanned

Board Variants

BoardChipStatus
pi-picorp2040Current reference board
pi-pico-wrp2040Planned
pi-pico2rp2350Planned
pi-pico2-wrp2350Planned

Future Native Targets

TargetArchitectureStatus
x86-64x86-64Planned
arm64AArch64Planned
riscv64RISC-V 64-bitPlanned
wasm32WebAssemblyPlanned

Why The Target Model Matters

Flint does not treat cross-compilation as a special trick layered on top later. The target is part of the ordinary build flow from the start.

That matters for embedded work because the language, standard library, and backend all need to agree on:

  • memory layout,
  • calling conventions,
  • startup behavior,
  • and which platform APIs exist.

MCU HAL

Flint’s MCU-facing standard library lives under micro/*.

The long-term goal is a broad, portable hardware abstraction layer that lets the same Flint source move between boards and chips with minimal rewriting. The current implementation is narrower than that vision, so this chapter only documents what is actually present in the repository today.

Implemented Today

These micro modules have real RP2040-backed APIs and working examples:

ModuleStatusWhat it provides
micro/gpioImplementedOutput GPIO setup, toggle, set high, set low, builtin LED lookup
micro/pwmImplementedPWM output setup and duty-cycle updates
micro/uartImplementedUART0 initialization and byte-by-byte TX output

micro/gpio

micro/gpio is the current foundation for board bring-up and LED examples.

Available surface today:

  • gpio.output(pin) returns an OutputPin
  • gpio.builtin_led() returns Option<OutputPin>
  • OutputPin.pin()
  • OutputPin.toggle()
  • OutputPin.set_high()
  • OutputPin.set_low()

Example:

use micro/gpio
use time/delay

fn main() {
    let builtin_led = gpio.builtin_led()
    if builtin_led.is_none() {
        return
    }

    if let Option.Some(led) = builtin_led {
        loop {
            led.toggle()
            delay.millis(500)
        }
    }
}

builtin_led() is intentionally optional because not every board has a directly usable onboard LED.

micro/pwm

micro/pwm builds on a resolved GPIO pin and configures it for PWM output.

Available surface today:

  • pwm.output(pin, top) returns a PwmPin
  • PwmPin.set_duty(duty)

Example:

use micro/gpio
use micro/pwm
use time/delay

fn main() {
    let builtin_led = gpio.builtin_led()
    if builtin_led.is_none() {
        return
    }

    if let Option.Some(pin) = builtin_led {
        let led = pwm.output(pin.pin(), 1000)

        loop {
            mut duty = 0
            while duty < 1000 {
                led.set_duty(duty)
                duty = duty + 10
                delay.millis(5)
            }

            while duty > 0 {
                led.set_duty(duty)
                duty = duty - 10
                delay.millis(5)
            }
        }
    }
}

The current implementation is tuned for RP2040 PWM slices and channels.

micro/uart

micro/uart currently exposes a small UART0 transmit path for debugging and bring-up.

Available surface today:

  • uart.init()
  • uart.write_byte(byte)

Example:

use micro/uart
use time/delay

fn write_hello() {
    uart.write_byte(72)
    uart.write_byte(105)
    uart.write_byte(10)
}

fn main() {
    uart.init()

    loop {
        write_hello()
        delay.millis(1000)
    }
}

Right now this is intentionally small and practical: enough to get serial output on RP2040 without needing an external runtime or SDK.

Present But Not Implemented Yet

These module files already exist in stdlib/micro, but they are still placeholders at the moment:

  • micro/adc
  • micro/i2c
  • micro/interrupt
  • micro/spi
  • micro/system
  • micro/timer

They are part of the intended HAL surface, but they should not be treated as available APIs yet.

Scope Today

The honest current picture is:

  • GPIO is usable.
  • PWM is usable.
  • UART TX is usable.
  • The rest of the MCU HAL surface is still being filled in.

That narrower surface is enough for real RP2040 bring-up work, and it gives the language a concrete place to prove out its embedded model before the broader HAL is expanded.

RP2040 Today

RP2040 is the current proof target for Flint.

What Works

The compiler pipeline already produces RP2040 UF2 images without relying on an external GCC or LLVM toolchain. That includes:

  • parsing and type checking Flint source,
  • lowering into FIR,
  • ARM Thumb-2 code generation,
  • startup and vector table generation,
  • and UF2 packaging.

Board Support In The Repository

The current repository includes RP2040 board data for:

  • pi-pico
  • pi-pico-w

Examples also exist for:

  • a HAL-based blink example,
  • PWM LED fading,
  • and UART output.

Why RP2040 Is A Good First Target

RP2040 is a good stress test for Flint because it is small enough that waste is obvious, but common enough that a working toolchain is immediately useful.

It also forces the language and compiler to stay honest about:

  • startup code,
  • direct register access,
  • binary size,
  • and hardware-facing APIs.

Standard Library

Flint is deliberately biased toward a strong standard library instead of a large package ecosystem for common work.

The Goal

The standard library should cover the tasks that most programs need without forcing every project into a dependency hunt.

That is especially important for embedded systems, where:

  • code size matters,
  • auditability matters,
  • and deployment constraints are tighter than they are on desktops and servers.

Layered Design

The current standard library design is layered:

  • Layer 0: core building blocks such as math, formatting, comparison, hashing, and iteration.
  • Layer 1: collections and encoding.
  • Layer 2: time.
  • Layer 3: MCU-facing hardware abstraction.

Cross-Target Intent

The long-term goal is for common code to work across MCU and non-MCU targets where that makes sense. The MCU-specific pieces should still feel like part of one coherent platform, not a separate language.

That is why Flint uses standard library paths such as:

use json
use micro/gpio
use time/delay

The namespace tells you what kind of capability you are using without adding a package-manager step to get started.

Language Guide

Flint aims to keep the surface language direct.

There are a few core ideas that shape almost everything else:

  • immutability by default,
  • explicit mutation,
  • explicit returns,
  • move semantics for non-trivial values,
  • errors as values,
  • and one obvious spelling for common operations.

The following chapters explain those ideas in the order most people need them.

Bindings and Mutation

Flint has two binding keywords:

  • let for immutable bindings,
  • mut for mutable bindings.
let threshold = 42
mut count = 0

Why This Matters

Flint wants mutation to be visible at a glance.

That applies beyond local variables. Mutation is also visible in parameter positions and call sites. If something can be changed, Flint tries to say so directly instead of hiding it behind reference syntax or convention.

No Hidden Reference Syntax In Safe Code

Flint does not use & and &mut in normal safe code. The compiler decides when to pass by value and when a by-reference strategy is needed internally.

This keeps the surface language smaller while still preserving ownership and mutation rules.

Functions and Methods

Functions in Flint use straightforward syntax:

fn add(a: i32, b: i32) -> i32 {
    return a + b
}

Explicit Return

Flint requires return. There is no implicit “last expression” return rule.

That choice keeps function exits more obvious for both readers and tools.

Methods

Methods live in impl blocks:

struct Counter {
    value: i32,
}

impl Counter {
    fn bump(mut self) {
        self.value = self.value + 1
    }
}

Method syntax is meant to stay unsurprising. An impl block attaches behavior to a type. A mut self receiver makes mutation explicit.

Types, Structs, and Enums

Flint includes primitive numeric and boolean types, user-defined structs, and enums used as tagged unions.

Structs

Structs group fields together:

struct Sensor {
    pin: u8,
    offset: f32,
}

Enums

Enums represent one of several variants:

enum Command {
    Read(pin: u8),
    Write(pin: u8, value: bool),
    Reset,
}

This form is especially important for protocol parsing, driver state, and result-oriented programming.

Pattern Matching

Enums become much more useful together with match:

match cmd {
    Command.Read(pin) => { return ok(pin) }
    Command.Write(pin, value) => { return ok(pin) }
    Command.Reset => { return ok(0) }
}

Flint uses tagged unions as a normal language tool, not as a niche feature.

Control Flow

Flint keeps control flow conventional.

Conditionals

Use if and else for branching:

if ready {
    return ok(())
} else {
    return err(Error.NotReady)
}

Loops

Flint currently supports while and loop as the stable loop forms:

mut i = 0
while i < 10 {
    i = i + 1
}

loop {
    break
}

while is the obvious bounded loop. loop is the obvious infinite loop.

The parser already accepts for i in 0..10, but range-based for lowering is still being finished. Until that lands end to end, the book treats while and loop as the supported control-flow loops.

Match

match handles tagged unions and structured branching:

match state {
    State.Idle => { return }
    State.Busy(job) => { return run(job) }
}

Use match when the shape of a value should drive control flow.

For the common “one pattern or fall back” case, Flint also supports if let:

if let Option.Some(job) = next_job() {
    run(job)
} else {
    return
}

Defer

defer schedules cleanup for function exit:

fn work(bus: spi.Bus) -> Result<(), Error> {
    let device = bus.init()?
    defer device.release()

    return ok(())
}

Deferred actions run in last-in, first-out order when the function returns.

Modules and Visibility

Flint keeps the module system intentionally small.

One File, One Module

Each file is a module. The filename defines the module name. There is no separate module declaration.

Imports

Use use for imports:

use json
use micro/gpio
use ./board
use ./drivers/sensor

The rule is simple:

  • bare paths are for standard library modules,
  • ./ paths are for local modules in the current project.

Visibility

Flint has two visibility levels:

  • pub
  • private by default

There is no larger visibility ladder to memorize.

Ownership and Moves

Flint uses move semantics by default for non-trivial values.

That means assignment and passing a value to a function may transfer ownership instead of copying it.

Why Flint Uses Moves

The goal is memory safety without a garbage collector. Moves help the compiler prevent use-after-free style bugs and keep object lifetime explicit.

Copy Types

Primitive values and small explicitly copyable values can be copied normally. Larger or non-trivial values move unless the type says otherwise.

The Surface Language Stays Small

One of Flint’s design choices is that ownership exists without forcing safe code to spell everything with &, &mut, or explicit lifetime annotations.

That means the ownership model still matters, but the syntax remains smaller than many systems languages.

Errors and Cleanup

Flint treats errors as values.

Result and Option

The standard path for recoverable errors is Result<T, E>. Optional values use Option<T>.

This keeps error handling in the type system instead of hiding it behind exceptions.

The ? Operator

Use ? to propagate an error upward:

fn setup(bus: spi.Bus) -> Result<(), Error> {
    let device = bus.init()?
    device.configure()?
    return ok(())
}

No Exceptions

Flint does not use exceptions, try/catch, or finally. The language prefers visible control flow and explicit propagation.

Cleanup With defer

defer is the cleanup tool:

fn setup(bus: spi.Bus) -> Result<(), Error> {
    let device = bus.init()?
    defer device.release()

    return ok(())
}

This is especially useful for embedded and systems code where resources are often acquired and released in a tight scope.

Keywords

This chapter groups the main Flint keywords by purpose. Each keyword has its own short section so the reference stays easy to scan.

Bindings

let

Creates an immutable binding.

let name = "flint"

mut

Marks a binding, parameter, or receiver as mutable.

mut count = 0

Flint uses mut to make mutation visible wherever it occurs.

Functions and Types

fn

Introduces a function or method.

struct

Defines a struct type with named fields.

enum

Defines a tagged union with named variants.

impl

Introduces methods attached to a type.

Modules and Visibility

use

Imports a module path into the file.

pub

Marks an item as visible outside the current module.

Control Flow

if

Branches on a condition.

else

Introduces the fallback branch for an if.

for

Introduces a counted or iterator-style loop. The frontend syntax exists today, but range lowering is still being finished.

in

Separates a for loop binding from the range or iterable it walks.

while

Repeats while a condition remains true.

loop

Repeats forever until an explicit exit such as break.

match

Branches on the shape of a value, especially enum variants.

return

Returns explicitly from a function.

Errors and Safety

defer

Schedules cleanup to run when the function exits.

unsafe

Marks an operation that escapes normal safety checks, such as direct volatile access or inline assembly boundaries.

Use unsafe for the smallest possible region and keep the reason obvious.

Logical Operators As Words

and

Logical conjunction.

or

Logical disjunction.

not

Logical negation.

Flint spells logical operators as words so bitwise and logical operations stay visually distinct.

Operators

Flint keeps operator spelling conventional where it helps and explicit where ambiguity would hurt.

Arithmetic

Flint uses the familiar arithmetic operators:

  • +
  • -
  • *
  • /
  • %

% is modulo.

Comparison

Comparison operators follow the usual spellings:

  • ==
  • !=
  • <
  • <=
  • >
  • >=

Logical Operators

Logical operators are spelled as words:

  • and
  • or
  • not

This makes them visually separate from bitwise operators.

Bitwise Operators

Bitwise operators keep symbolic spellings:

  • &
  • |
  • ^
  • ~
  • <<
  • >>

In Flint, those operators are for integer and bit-level work, not boolean logic.

Compiler and Targets

Flint’s compiler is written in Rust, but the language itself is not layered on top of LLVM.

Compilation Model

The broad pipeline is:

.fl source -> parser -> typed AST -> FIR -> backend -> binary

FIR, the Flint Intermediate Representation, is the stable interface between the frontend and code generation backends.

Why This Matters

Flint is trying to keep the toolchain self-contained. The compiler owns:

  • lexing,
  • parsing,
  • type checking,
  • IR lowering,
  • code generation,
  • and target packaging such as UF2 output.

That is a core part of the project, not an implementation detail.

Current Focus

Today, the most mature path is RP2040 output. Other targets are part of the intended design and documentation, but they are not yet equally implemented.

Long-Term Shape

The long-term goal is one language with a target-aware standard library and multiple direct backends, rather than a language that depends on a large external compiler stack to become useful.