formal
A verifying compiler for bare-metal RISC-V.
You write a small Python-like language whose simple statements map one-to-one onto RISC-V instructions, with the memory layout left implicit. The compiler accepts your program only if it can prove, by symbolically executing the machine code itself, that no assertion can fail and no memory access is unsafe, on every hardware-thread interleaving and under every admissible type it infers for you.
The by-products of that proof then build the binary and shrink it.
Proven at compile time
The compiler is not a checker you can argue with. It rejects the program unless it has a proof.
Every interleaving
A fail marker is a theorem. The compiler enumerates every hart interleaving and rejects the program unless the marker is unreachable on all of them. Provably-correct racy code, which Rust's borrow checker forbids and SPARK's tasking model excludes, verifies here.
Every typing
Leave a type as _ and the compiler searches type and locality assignments, accepting one iff it makes the whole program verify. Inference and verification are the same search, so there is no "couldn't infer, add an annotation" failure mode.
Every access
An access is safe inside its variable's inferred type, or inside a raw region with the right permissions: one the target system describes, or one the program declares with section. An access nothing describes makes the program invalid.
The same program, every language
formal usually compiles to fewer instructions because it ships no runtime, no libc, and no garbage collector beneath it.
print("Hello World!\n")
exit(0)
formal new hello_world && cd hello_world
cargo run
grep -cE '^ [a-z]' build/main.s
Not measured yet: this program is in the panels but the measurement run that fills its figures has not been made.
46 instructions
944
bytes
15 ms compile time
measured at runtime (under QEMU)
108 instructions executed
8 KiB
peak memory
10 ms
execution time
fn main() {
println!("Hello World!");
}
cargo new hw && cd hw
echo 'fn main() { println!("Hello World!"); }' > src/main.rs
CARGO_PROFILE_RELEASE_OPT_LEVEL=z CARGO_PROFILE_RELEASE_LTO=true CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 CARGO_PROFILE_RELEASE_PANIC=abort CARGO_PROFILE_RELEASE_STRIP=true RUSTFLAGS='-Zlocation-detail=none -Zfmt-debug=none -Zunstable-options -Cpanic=immediate-abort' cargo +nightly build --release --target x86_64-unknown-linux-musl -Zbuild-std=std,panic_abort -Zbuild-std-features=
objdump -d target/x86_64-unknown-linux-musl/release/hw | grep -cE '^[[:space:]]+[0-9a-f]+:'
Not measured yet: this program is in the panels but the measurement run that fills its figures has not been made.
5,888 instructions
25,080 bytes
47 s compile time
measured at runtime (under QEMU)
3,063 instructions executed
48 KiB
peak memory
13 ms
execution time
Every figure is measured by the project's own comparison test against pinned toolchains. Hover an execution time for the host and QEMU setup it was measured under.
One statement, one instruction
Two layers, kept deliberately close. Every simple statement lowers to exactly one RISC-V instruction or directive, and if, while and require each lower to a small fixed pattern of branches plus generated labels. No register allocation, no hidden control flow, the C-like cost model where the price of a line is visible at the call site, and inline assembly always one asm: block away.
value: global _ -> #$ value global _ define; locality named, type inferred
welcome: _ [u8*13] -> #$ welcome _ [u8 ... u8] _ locality = search thread, then global
nums: [u32]*4 = [2, 7, 0, 1] -> #$ nums thread [u32 ...] locality elided = thread; list initialiser
t0 = &value -> la t0, value address of a variable
t0 = type(welcome) -> #& t0, welcome address of the runtime type descriptor
t0 = csr(mhartid) -> csrr t0, mhartid which hart am I?
t1 = 0x10000000 -> li t1, 0x10000000 load immediate
t1 = t1 + 1 -> addi t1, t1, 1 register +/- immediate
t0[0] = t1 -> #] t1, 0(t0) store element 0 (sized by its type)
t1 = t0[2] -> #[ t1, 2(t0) load element 2 (sized by its type)
t0[0:4] = t1 -> sw t1, 0(t0) raw byte slice: memory with no type
t1 = t0[8:16] -> ld t1, 8(t0) raw load (1 = lb, 4 = lw, 8 = ld)
section 0x100 0x200 rw -> #@ 0x100 0x200 rw declare an accessible region
fail -> #! must be proven unreachable
unreachable -> #? end of execution
asm: -> (each indented line inline assembly
wfi emitted verbatim)
require t1 < t2 # fail unless t1 < t2 (one-line `if not cond: fail`)
if t0 == 0: # run the block when the condition holds
t1 = t0[0]
while t5 != t2: # top-tested loop
t5 = t5 + 1
A runtime index is not supported yet, so arr[i] with i in a register is written out as t = i * 4 for a 4-byte element, then p = base + t, then p[0], which keeps the cost of the address arithmetic visible and is why the fannkuch sample hand-desugars its inner loop.
The memory layout is an output, not an input
There is no .data or .bss in the source, and no entry point: execution starts at the first line, like Python.
The verifier discovers each variable's type and locality and emits the sections for you, so the program describes behaviour and the compiler discovers the representation that makes it correct.
# you write
value: global _
welcome: _ [u8*13]
# the compiler emits
.section .data
__welcome_type:
.dword 8 # List
.dword __welcome_subtypes
.dword 13 # length
.section .bss
.balign 8
value:
.zero 4
.balign 8
welcome:
.zero 13
The proof shrinks the binary
Dead-data elimination: bytes the proof shows no runtime path can touch are deleted from the output, with the program's address arithmetic rewritten in lockstep.
The verifier models a type record as [u64 type, u64 subtypes-ptr, u64 length, u8 locality]. The program only ever reads the type number, so the emitted descriptors carry the type words alone and the stride is rewritten to match. The locality byte, read only by the verifier, exists nowhere in the binary.
# you write: a 25-byte record stride
t0 = t0 + 25
# the compiler emits: 8, the bytes the
# program can actually reach
addi t0, t0, 8
End to end: source to booted RISC-V
The project's real end-to-end test, tests/uart_hello/. The source races two harts on an inferred global, has hart 0 check the message's runtime type descriptor (the program inspects its own inferred types), then prints over the UART.
You write input.hl
# Do racy arithmetic with an undefined variable.
# Use global locality so the load/stores are racy.
value: global _
t0 = &value
# Set to 0
t1 = 0
t0[0:4] = t1
# Do non-atomic addition
t1 = t0[0:4]
t1 = t1 + 1
t0[0:4] = t1
# The proof obligation: value < 4 on every interleaving (`require` lowers to
# a branch over `#!`, the `fail` marker).
t1 = t0[0:4]
t2 = 4
require t1 < t2
# Use hart 0 to output a message; every other hart skips straight to the
# `wfi` at the end.
t0 = csr(mhartid)
if t0 == 0:
welcome: _ [u8*13]
# H e l l o W o r l d ! 0
# Type exploration doesn't explore list types since they are infinite, so
# to define a list a user must define it manually.
# In this case we leave the locality as unspecified which will default to `thread`.
# Declare string
# Get address of type structure
t0 = type(welcome)
# Check variable is list
t2 = 8 # Load list type number
t1 = t0[0] # Load type type number
require t1 == t2
# Check list length
t0 = t0 + 16 # Increment address to point at length
t1 = t0[0] # Load length
t2 = 13 # Load desired length
require t1 == t2
# Check all values in list are u8
t0 = t0 - 8 # Decrement address to point at list address
t0 = t0[0] # Load list address
t5 = 0 # Use t5 as the counter
while t5 != t2:
t3 = t0[0:8] # Load type of list item
t4 = 0 # Load byte type number
require t3 == t4
t0 = t0 + 25 # Increment list item address (8+8+8+1)
t5 = t5 + 1 # Increment the count
# Set string
t0 = &welcome
t1 = 72 # H
t0[0] = t1
t1 = 101 # e
t0[1] = t1
t1 = 108 # l
t0[2] = t1
t1 = 108 # l
t0[3] = t1
t1 = 111 # o
t0[4] = t1
t1 = 32 # space
t0[5] = t1
t1 = 87 # W
t0[6] = t1
t1 = 111 # o
t0[7] = t1
t1 = 114 # r
t0[8] = t1
t1 = 108 # l
t0[9] = t1
t1 = 100 # d
t0[10] = t1
t1 = 33 # !
t0[11] = t1
t1 = 0 # NUL terminator
t0[12] = t1
# Output message
a0 = &welcome # Load message address
t1 = 0x10000000 # Load character device address
t2 = a0[0] # Load the first character
while t2 != 0:
t1[0:1] = t2 # Store character into character device
a0 = a0 + 1 # Add 1 message address
t2 = a0[0] # Load next character
asm:
wfi
unreachable
It proves, optimizes & emits
.global _start
_start:
#$ value global _
la t0, value
li t1, 0
sw t1, 0(t0)
lw t1, 0(t0)
addi t1, t1, 1
sw t1, 0(t0)
lw t1, 0(t0)
li t2, 4
blt t1, t2, _l0
_l0:
csrr t0, mhartid
bnez t0, _l1
#$ welcome _ [u8 u8 u8 u8 u8 u8 u8 u8 u8 u8 u8 u8 u8]
la t0, __welcome_type # #& t0, welcome
li t2, 8
ld t1, 0(t0) # #[ t1, 0(t0)
beq t1, t2, _l2
_l2:
addi t0, t0, 16
ld t1, 0(t0) # #[ t1, 0(t0)
li t2, 13
beq t1, t2, _l3
_l3:
addi t0, t0, -8
ld t0, 0(t0) # #[ t0, 0(t0)
li t5, 0
_l4:
beq t5, t2, _l5
ld t3, 0(t0)
li t4, 0
beq t3, t4, _l6
_l6:
addi t0, t0, 8
addi t5, t5, 1
j _l4
_l5:
la t0, welcome
li t1, 72
sb t1, 0(t0) # #] t1, 0(t0)
li t1, 101
sb t1, 1(t0) # #] t1, 1(t0)
li t1, 108
sb t1, 2(t0) # #] t1, 2(t0)
li t1, 108
sb t1, 3(t0) # #] t1, 3(t0)
li t1, 111
sb t1, 4(t0) # #] t1, 4(t0)
li t1, 32
sb t1, 5(t0) # #] t1, 5(t0)
li t1, 87
sb t1, 6(t0) # #] t1, 6(t0)
li t1, 111
sb t1, 7(t0) # #] t1, 7(t0)
li t1, 114
sb t1, 8(t0) # #] t1, 8(t0)
li t1, 108
sb t1, 9(t0) # #] t1, 9(t0)
li t1, 100
sb t1, 10(t0) # #] t1, 10(t0)
li t1, 33
sb t1, 11(t0) # #] t1, 11(t0)
li t1, 0
sb t1, 12(t0) # #] t1, 12(t0)
la a0, welcome
li t1, 0x10000000
lbu t2, 0(a0) # #[ t2, 0(a0)
_l7:
beqz t2, _l8
sb t2, 0(t1)
addi a0, a0, 1
lbu t2, 0(a0) # #[ t2, 0(a0)
j _l7
_l8:
_l1:
wfi
j __halt # unreachable (program end)
__halt:
wfi
j __halt
.section .data
__welcome_type:
.dword 8 # List
.dword __welcome_subtypes # subtypes
.dword 13 # length
__welcome_subtypes:
.dword 0
.dword 0
.dword 0
.dword 0
.dword 0
.dword 0
.dword 0
.dword 0
.dword 0
.dword 0
.dword 0
.dword 0
.dword 0
.section .bss
.balign 8
value:
.zero 4
.balign 8
welcome:
.zero 13
translate (one statement, one instruction or directive) → parse → verify (2,111,465 machine states across 1- and 2-hart systems) → infer value: u32, welcome: thread [u8 × 13] → remove dead code & never-taken branches → compact the layout → emit
The emitted program assembles with the RISC-V GNU toolchain, boots under qemu-system-riscv64 -machine virt, and prints Hello World!. Two things to notice in the output:
- The descriptor records shrank. The source walks 25-byte records with t0 = t0 + 25, but the program only ever reads each record's type number, so the emitted __welcome_subtypes carries the type words alone and the stride became addi t0, t0, 8.
- The race was proven, not forbidden. Both harts run the non-atomic increment of value; the proof covers every interleaving and shows the value < 4 assertion holds in all of them.
$ cargo nt uart_hello
PASS [25.001s] formal::uart_hello uart_hello
The dialect underneath
Under the surface language sits the verification target: RISC-V assembly plus seven directives. Every directive starts with #, the RISC-V comment character, so any formal program is also a plain assembly file.
| Directive | Surface form | Meaning |
|---|---|---|
| #! | fail | An assertion the compiler must prove unreachable on every interleaving. |
| #? | unreachable | End of execution: the hart that reaches this point halts. |
| #$ x global u32 | x: global u32 | Declare a variable's locality and/or type; _ means "infer it for me". |
| #& t0, x | t0 = type(x) | Load the address of a variable's runtime type descriptor. Programs can inspect their own inferred types. |
| #@ 100 200 rw | section 100 200 rw | Declare a memory region the program may access (bounds may be registers: an allocator declares each allocation as it makes it). |
| #[ t1, 2(t0) | t1 = t0[2] | Load element 2 of the pointee's type: the verifier resolves the byte offset and width, and codegen emits the sized load. |
| #] t1, 0(t0) | t0[0] = t1 | Store into element 0 of the pointee's type: the verifier resolves the byte offset and width, and codegen emits the sized store. |
Get started
Needs only Rust (stable) and Cargo.
Three commands
Install
Clone the repository and cargo install --path .. The build script provisions everything else.
Scaffold
formal new hello_world, then cargo run inside it. In a scaffolded project cargo run is the build.
Run
qemu-riscv64 build/main. build/ also holds the combined source, the verified dialect and the assembly.