Welcome to the BedRock language documentation. BedRock is a systems programming language that compiles to bare-metal machine code — and, where useful, to a minimal hosted Linux executable. It is no longer tied to a single architecture. The compiler is multi-architecture, with backends for MIPS-32 (big/little-endian), RISC-V (RV32I/M), and x86-64 (Linux ELF). Every language construct maps directly and transparently to a fixed sequence of hardware instructions for whichever target you choose, with no runtime, no OS abstraction, and no magic.
This documentation covers the complete language specification, compiler internals, hardware interfacing primitives, hard-won guidance on the compiler's known enforcement behaviors and edge cases, plus benchmarks (BRC3 vs GCC) and hardware / simulation deployment notes for ESP32-C3 and Wokwi.
TOC Documentation Sections
Core Philosophy
The three axioms that govern every design decision in the BedRock language and compiler.
1.1 "No Magic"
In BedRock, every operation maps to a known, fixed sequence of machine instructions. There are no hidden runtime allocations, no garbage collector, no implicit type coercions, and no virtual dispatch. What you write is exactly what executes.
This is enforced by design: the table below shows the exact 1-to-1 mapping for common constructs:
| BedRock Construct | Machine Code Behavior |
|---|---|
| let x = 5; | Exactly one sw instruction to a pre-assigned static address. No stack frame creation, no heap allocation. |
| let arr = [1, 2, 3]; | Compiler statically assigns a base address in the DATA segment, emits three sw instructions during initialization. All accesses are compile-time pointer arithmetic. |
| fn foo() { ... } | Exactly one prologue (addiu $sp, -32; sw $ra, 28($sp)) and one epilogue (lw $ra, 28($sp); addiu $sp, +32; jr $ra). Nothing else. |
.bin output to confirm.1.2 "Byte-Level Validation"
Numbers in BedRock are unsigned 64-bit integers at parse time, collapsed to 32-bit at code generation. All memory addresses are 32-bit values. There are no floating-point types, no signed arithmetic in the type system, and no implicit widening or truncation.
The compiler validates output at the byte level by:
- Encoding all instructions as big/le-endian 32-bit words via
to_be_bytes() - Writing a raw
.binfile with zero padding assumptions - Optionally writing a 1.44 MB floppy image (
bedrock_os.img) by embedding the binary at sector 0
1.3 "Direct Hardware Control"
BedRock provides five mechanisms for hardware access that map directly to bare-metal MIPS operations. There is no operating system abstraction layer.
| Construct | Hardware Operation | MIPS Instruction |
|---|---|---|
| poke(addr, val) | Write 32-bit word to physical address | sw $val, 0($addr) |
| peek(addr) | Read 32-bit word from physical address | lw $dest, 0($addr) |
| outb(port, val) | Memory-mapped I/O write | sw $val, 0($port) |
| inb(port) | Memory-mapped I/O read | lw $dest, 0($port) |
| asm("hex") | Emit raw machine instruction | Direct word emission |
Lexical Grammar
Reserved keywords, operator tokens, literal types, and preprocessor directives recognized by the BedRock lexer.
2.1 Keywords
The following identifiers are reserved and cannot be used as variable or function names.
| Keyword | Category | Description |
|---|---|---|
| fn | Declaration | Function definition |
| let | Declaration | Local/static variable declaration |
| root | Declaration | Hardware/environment constant declaration |
| return | Control Flow | Return from function |
| if | Control Flow | Conditional branch |
| else | Control Flow | Alternative branch |
| while | Control Flow | Conditional loop |
| loop | Control Flow | Infinite loop |
| break | Control Flow | Exit innermost loop |
| asm | Hardware | Emit raw machine instruction |
| outb | Hardware | Memory-mapped I/O write |
| inb | Hardware | Memory-mapped I/O read |
| poke | Hardware | Direct memory write |
| peek | Hardware | Direct memory read |
| in | Hardware | Wait for keyboard input (alias for WaitKey) |
| include | Preprocessor | Include another source file (textual substitution) |
| call | Hardware / Pointer | Jump to a runtime address via jalr: function pointer call |
2.2 Operators
Arithmetic Operators
| Symbol | Token | MIPS Instruction |
|---|---|---|
| + | Plus | addu |
| - | Minus | subu |
| * | Star | mult + mflo |
| / | Slash | div + mflo |
| ^ | Caret | xor |
Bitwise Operators
| Symbol | Token | MIPS Instruction |
|---|---|---|
| & | Ampersand | and |
| | | Pipe | or |
| ^ | Caret | xor |
| << | ShiftLeft | sllv |
| >> | ShiftRight | srlv |
Comparison Operators
| Symbol | Token | MIPS Sequence |
|---|---|---|
| == | EqEq | xor + sltiu $d, $d, 1 |
| != | NotEq | xor + sltu $d, $0, $d |
| > | Greater | slt (operands swapped) |
| < | Less | slt |
| >= | GreaterEq | slt + xori $d, $d, 1 |
| <= | LessEq | slt (swapped) + xori $d, $d, 1 |
Assignment
| Symbol | Token | Description |
|---|---|---|
| = | Equal | Assignment (not comparison, use == for equality testing) |
2.3 Literals
Integer Literals
BedRock supports decimal and hexadecimal integer literals. All integers are stored as u64 during lexing.
String Literals
Strings are delimited by double quotes, stored in the DATA segment as null-terminated byte arrays (C-style). Escape sequences are not processed; see §8.8.
Array Literals
Arrays are comma-separated lists of integer literals only enclosed in [ ]. Variables and expressions are not permitted as elements; see §8.2.
2.4 Comments & Include Directive
The include directive is a preprocessor feature handled before lexing. It performs textual substitution of the named file's content into the current file. Include paths are resolved relative to the source file's directory and processed recursively.
Memory Model
BedRock's flat 32-bit address space, segment layout, variable binding semantics, and stack frame structure.
3.1 Address Space
BedRock uses a flat 32-bit address space. The layout is determined by three root declarations:
| Root Symbol | Default Value | Description |
|---|---|---|
| BASE | 0x80000000 | Code segment start address |
| DATA | BASE + 0x10000 | Static data segment start address |
| STACK | BASE + 0x20000 | Initial stack pointer value |
3.2 Segment Layout
3.3 let: Static Variable
let declares a static variable. In global scope, the compiler allocates a fixed address in the DATA segment and emits sw (store word) instructions to initialize it. Variables do not occupy stack space in global scope.
let-declared variable's address is resolved entirely at compile time. There is no dynamic allocation.3.4 root: Hardware / Environment Constant
root declares a compile-time constant mapping a name to a fixed 32-bit address or value. Unlike let, a root symbol is never dereferenced. When used in an expression, it evaluates to the raw address value itself: not the value stored at that address.
| Declaration | Expression Behavior | Use Case |
|---|---|---|
| let x = 5 | Loads value 5 from memory address | Variables, counters, state |
| root ADDR = 0xB8000 | Loads the literal value 0xB8000: no memory dereference | Hardware addresses, config constants |
root declaration must be a literal integer. Expressions are not evaluated. See §8.1.3.5 Stack Frame Structure
Every function call creates a 32-byte stack frame:
Prologue (emitted automatically)
Epilogue (emitted automatically)
3.6 Register Allocation
The IR backend (default) uses a pool of 18 physical registers (MIPS regs 8–25: $t0–$t9 and $s0–$s7) for virtual register allocation. The legacy bridge path (--bridge) uses 14 registers. Allocation is done by the RegAlloc struct: if the pool is exhausted, the variable is spilled to a stack slot and loaded/stored as needed.
| Register | MIPS Number | Role |
|---|---|---|
| $sp | 29 | Stack pointer |
| $ra | 31 | Return address (saved by JAL) |
| $v0 | 2 | Function return value |
| $a0–$a3 | 4–7 | Function arguments (caller convention) |
| $t0–$t7 | 8–15 | Temporary expression registers (pool of 8) |
alloc_reg() finds the pool empty, it silently returns register 8 ($t0), potentially corrupting a live value. See §8.9 for mitigation.Detailed Syntax Guide
Complete syntax reference for variables, arrays, strings, functions, control flow, and inline assembly.
4.1 Variable Declarations
4.2 Arrays & Strings
Array Definition & Access
Arrays are statically allocated in the DATA segment. Only integer literals are permitted as element values. The compiler scales all indices by 4 (sll idx, idx, 2): each element is a 32-bit word.
String Definition
Strings are stored as null-terminated byte arrays in the DATA segment, padded to the next 4-byte boundary.
4.3 Arithmetic & Expressions
Precedence rules: three levels (highest to lowest):
High: *, /, <<, >> (handled in parse_factor)
Medium: +, -, &, |, ^ (handled in parse_term)
Low: ==, !=, >, <, >=, <= (handled in parse_expression)
Use parentheses liberally to make intent explicit; see §8.6.
4.4 Function Definitions
$a0–$a3. Return value is always in $v0; see §8.4 and §8.5.4.5 Control Flow
if / else
while
loop / break
An infinite loop. Exits only via break or a return from the enclosing function.
4.6 Inline Assembly
Emits a single 32-bit MIPS instruction directly into the code stream. The argument must be the exact 32-bit encoding as a hex string: without the 0x prefix.
4.7 call(): Function Pointers
The call(expr) statement jumps to a runtime address stored in a variable or computed from an expression. Unlike a regular function call which uses an absolute jal, call() emits a jalr instruction: it loads the target address at runtime from whatever expression is given.
Statement::CallPtr in ast.rs, Token::Call in lexer.rs, callptr_stmt() in parser.rs, and the jalr emitter in codegen/mod.rs.Syntax
Basic Usage: Stored Address
MIPS Instructions Generated
Encoding of jalr $ra, $tX
| Register $tX | MIPS Number | jalr Encoding (hex) |
|---|---|---|
| $t0 | 8 | 0x0100F809 |
| $t1 | 9 | 0x0120F809 |
| $t2 | 10 | 0x0140F809 |
| $t3 | 11 | 0x0160F809 |
| $t4 | 12 | 0x0180F809 |
| $t5 | 13 | 0x01A0F809 |
| $t6 | 14 | 0x01C0F809 |
| $t7 | 15 | 0x01E0F809 |
Dynamic Dispatch: Computed Address
The expression inside call() can be any valid BedRock expression: not just a simple variable. The compiler evaluates the full expression and jumps to the resulting address.
call() is a statement: it does not capture $v0. If the called function returns a value, it will be in $v0 but the compiler does not assign it anywhere. Use a regular named function call (let r = func();) if you need the return value.call(expr) does not push arguments onto the stack. The callee must read its inputs directly from known memory addresses or registers if needed. For full argument passing, use a named function call.Comparison: call() vs Named Call
| Feature | call(ptr) | func_name(args) |
|---|---|---|
| Target resolution | Runtime (register) | Compile-time (absolute jal) |
| MIPS instruction | jalr $ra, $tX | jal 0xABCDEF |
| Argument passing | ❌ None | ✅ Via stack |
| Return value | ❌ Not captured | ✅ In $v0 / let |
| Dynamic dispatch | ✅ Any expression | ❌ Static only |
| Flash-safe (PIC) | ✅ If address is correct | ❌ Absolute address |
Instruction Generation
The compiler's eight-phase pipeline, MIPS encoding patterns, control flow generation, and binary output format.
5.1 Compiler Pipeline
The compiler operates in a single pass with eight sequential phases:
| Phase | Name | Action |
|---|---|---|
| Phase 0 | Root Symbol Collection | Resolves BASE, DATA, STACK addresses |
| Phase 1 | Bootloader Emission | NOP, li $sp STACK, J <main_start> (patched later) |
| Phase 2 | Static Data Allocation | Assigns addresses to arrays and strings in DATA segment |
| Phase 3 | Function Code Generation | Emits function bodies with prologue/epilogue |
| Phase 4 | Jump Patch | Back-patches the Phase 1 J instruction to point past functions |
| Phase 5 | Static Data Initialization | Emits sw instructions to write array/string data into memory |
| Phase 6 | Main Code Generation | Emits all non-root, non-function, non-static-data statements |
| Phase 7 | Halt | Emits J <self> infinite loop |
5.2 Load Immediate (emit_li)
All constant loading uses the following pattern based on the value's bit width:
5.3 Binary Operation Code Generation
All binary operations follow the pattern: allocate regs → gen left → gen right → emit instruction → free regs.
Arithmetic Encodings
| BedRock | MIPS Encoding | Notes |
|---|---|---|
| a + b | addu $d, $s, $t | 0x00000021 | ... |
| a - b | subu $d, $s, $t | 0x00000023 | ... |
| a * b | mult $s, $t + mflo $d | Result from LO register |
| a / b | div $s, $t + mflo $d | Quotient from LO register |
| a ^ b | xor $d, $s, $t | 0x00000026 | ... |
| a & b | and $d, $s, $t | 0x00000024 | ... |
| a | b | or $d, $s, $t | 0x00000025 | ... |
Comparison Encodings
| BedRock | MIPS Sequence | Result |
|---|---|---|
| a == b | xor $d,$s,$t + sltiu $d,$d,1 | 1 if equal |
| a != b | xor $d,$s,$t + sltu $d,$0,$d | 1 if not equal |
| a < b | slt $d,$s,$t | 1 if s < t |
| a > b | slt $d,$t,$s | Operands swapped |
| a >= b | slt $d,$s,$t + xori $d,$d,1 | Invert lt |
| a <= b | slt $d,$t,$s + xori $d,$d,1 | Invert gt |
5.4 Control Flow Code Generation
if Statement
while Loop
loop (Infinite)
Function Call (jal)
5.5 Memory Operations
| BedRock | MIPS | Description |
|---|---|---|
| poke(addr, val) | sw $val, 0($addr) | 32-bit store to physical address |
| peek(addr) | lw $dest, 0($addr) | 32-bit load from physical address |
| outb(port, val) | sw $val, 0($port) | MMIO write (identical to sw) |
| inb(port) | lw $dest, 0($port) | MMIO read (identical to lw) |
5.6 Binary Output Format
The compiler emits a flat binary of 32-bit big/le-endian MIPS instructions:
| Output File | Description |
|---|---|
| <source>.bin | Raw binary of the compiled program (flat MIPS words) |
| <source>.map.json | Source map: per-instruction line number, absolute address, and 32-bit opcode: useful for debugging and binary auditing |
| bedrock_os.img | 1,474,560-byte floppy disk image (1.44 MB), binary at offset 0, first sector = 512 bytes |
Hardware Interfacing
Complete reference for direct memory access, MMIO I/O port operations, VGA text mode, keyboard input, and raw instruction injection.
6.1 Direct Memory Writes: poke
poke writes a 32-bit value to an absolute physical address. No bounds checking occurs.
Machine Code Generated
6.2 Direct Memory Reads: peek
peek reads a 32-bit value from an absolute physical address and returns it as an expression value (in $v0 / the allocated register).
6.3 I/O Port Access: outb / inb
BedRock implements I/O as MMIO. outb and inb are functionally identical to poke and peek at the machine code level: both emit sw and lw instructions respectively. The distinction is semantic only, signaling programmer intent.
6.4 VGA Text Mode: Practical Reference
VGA text mode buffer begins at physical address 0xB8000. Each character cell is 2 bytes.
Color Constants (Foreground)
| Color | Value | Color | Value |
|---|---|---|---|
| Black | 0x0 | Light Gray | 0x7 |
| Blue | 0x1 | Dark Gray | 0x8 |
| Green | 0x2 | Light Blue | 0x9 |
| Cyan | 0x3 | Light Green | 0xA |
| Red | 0x4 | Light Cyan | 0xB |
| Magenta | 0x5 | Light Red | 0xC |
| Brown | 0x6 | White | 0xF |
Cell Address Formula
Example: Print 'H' in White on Black at (0,0)
Example: Clear Entire Screen
6.5 Keyboard Input: in
The keyword in (used as an expression) reads from the keyboard input address 0x80020000 (hardcoded in the compiler). Use inb with an explicit port address to override.
6.6 Raw Instruction Injection: asm
Use asm for instructions not expressible in BedRock's grammar. Always document the encoding.
Best Practices
Proven patterns for writing maintainable, auditable, and correct bare-metal BedRock programs.
7.1 The Address Library Pattern
Declare all hardware addresses as root constants in a dedicated include file. This creates a zero-cost hardware abstraction layer and eliminates magic numbers from your code.
❌ Without Address Library (Avoid)
✅ With Address Library (Preferred)
7.2 Write Pure Functions
Functions that only operate on their parameters and local variables are predictable, testable, and cacheable. Avoid functions that modify hardware state without naming what they target.
7.3 Use Loops for Bulk Hardware Operations
7.4 Always Set BASE, DATA, STACK Explicitly
7.5 Use asm Sparingly and Document It
Inline assembly breaks the readability contract of BedRock. When you must use it, always document the instruction's human-readable name and effect.
let variables at global scope are static and persist for the program lifetime. Prefer passing values through function parameters and return values to keep program state explicit and auditable.Compiler Enforcement & Edge Cases
Twelve critical behaviors, limitations, and silent failure modes that every BedRock developer must understand before writing production code.
8.1 root Requires Literal Number Values
The right-hand side of a root declaration must be a literal integer. Expressions are not evaluated.
8.2 Array Literals Allow Only Integer Literals
Array literal values must be compile-time integer constants. Variables and expressions are not permitted: the parser will panic.
8.3 Unresolved Variable Panics at Codegen
Referencing an undeclared variable causes a panic! in the codegen phase. There is no graceful error recovery: the compiler process terminates.
8.4 Function Call Arguments Are Positional Only
BedRock has no named parameters at call sites. Arguments are matched by position. The number of arguments passed is not validated by the parser.
8.5 Return Value Is Always $v0
There is only one return register ($v0 = register 2). Multiple return values are not supported. Calling two functions in a complex expression may overwrite $v0 before it is consumed.
8.6 No Operator Precedence for Bitwise / Comparison
BedRock has three precedence levels. High: *, /, <<, >>. Medium: +, -, &, |, ^. Low: ==, !=, >, <, >=, <=. Bitwise operators like & bind before comparisons, but after shifts: still use parentheses to make intent explicit.
8.7 ! Without = Is a Lexer Error
The lexer only recognizes !=. A lone ! produces Token::EOF and terminates lexing silently: not an error message.
8.8 String Literals Do Not Support Escape Sequences
The lexer reads string content character-by-character until the closing ". Backslash sequences are stored literally as two characters.
8.9 Register Pool Exhaustion (Legacy Bridge Path Only)
In the legacy bridge path (--bridge), the register pool contains 14 registers. If exhausted, alloc_reg() silently returns register 8 ($t0), corrupting a live value without any warning. The default IR pipeline spills to stack instead: no silent corruption.
8.10 Shift Operators (<< / >>)
BedRock includes native shift operators in the lexer and codegen. Both logical left-shift (<<) and logical right-shift (>>) are first-class operators and compile to the corresponding MIPS sllv / srlv instructions.
asm workaround for shifts is no longer necessary, though raw asm remains available for advanced shift amounts or coprocessor operations.8.11 Integer Overflow Is Silent
All arithmetic uses unsigned MIPS addu/subu. Overflow wraps silently at 2³² − 1. There are no overflow traps or checks at compile time or runtime.
0xFFFFFFFF + 1 produces 0x00000000 with no warning, no exception, and no way to detect it after the fact.8.12 Division by Zero Is Undefined
BedRock emits a raw MIPS div instruction. On MIPS, division by zero produces an unpredictable value in LO and does not raise an exception unless the hardware is explicitly configured to do so. There is no compile-time or runtime guard.
if guard or assert non-zero before any / expression where the denominator could be variable.Structs
Named composite types that group fields together at compile-time offsets. BedRock structs are purely a compiler layout mechanism: no runtime type information, no VTable, no padding beyond 4-byte word alignment.
9.1 Defining a Struct
Use the struct keyword followed by the type name and a list of fields. Each field has a name and a type.
field 0 → offset 0, field 1 → offset 4, field 2 → offset 8, and so on. There is no padding or alignment adjustment: every field is a 32-bit word.9.2 Instantiating a Struct
Use the StructInstance statement to bind a variable name to a struct type. The compiler allocates one virtual register per field, named varname__fieldname.
9.3 Field Access
Fields are accessed with dot notation. A read generates a get IR op (→ lw), a write generates a mov IR op (→ sw).
| BedRock Expression | IR Lowering | MIPS Output |
|---|---|---|
| pos.x = 10 | Mov %pos.x, 10 | li $tX, 10 / sw $tX, DATA+off |
| let v = pos.x | Get %tN, %pos__x | lw $tN, DATA+off |
9.4 Struct Internals (Compiler View)
Structs are a purely compile-time construct. The IrBuilder reads Statement::StructDefine to build an internal layout table mapping each field name to a byte offset. When a StructInstance is lowered, it emits one Const IR op per field, creating named virtual registers like p__x, p__y.
poke / peek and the offset values.Interrupt System
BedRock provides first-class support for hardware interrupt handlers via three dedicated keywords: int, int_enable, and int_disable. Handlers are written as ordinary BedRock function bodies but are registered as interrupt service routines at the MIPS exception vector level.
10.1 Defining an Interrupt Handler
Use the int keyword to define an interrupt service routine (ISR). The body executes in interrupt context and should return as quickly as possible. The compiler automatically appends an ERET (exception return) at the end.
int becomes a label in the output binary. The compiler automatically saves and restores $ra, $t8, and $s0–$s3 in the prologue/epilogue, then emits ERET (0x42000018) to return from the exception.10.2 Enabling Interrupts: int_enable
The int_enable(vector, handler) statement installs an ISR at a specific interrupt vector. The first argument must be a compile-time constant: the vector number. The second is the handler label defined with int.
10.3 Disabling Interrupts: int_disable
The int_disable statement (no arguments) globally disables all hardware interrupts by clearing the IE bit in the MIPS Status coprocessor register.
10.4 Complete Example
10.5 IR Lowering
The three interrupt keywords lower to the following IR operations:
| BedRock Statement | IR Op | Notes |
|---|---|---|
int name { ... } | Mk "name" + body + Ret | Handler body emitted as a labeled block |
int_enable(vec, handler) | Int vec, handler | The MIPS backend writes handler address to vector table |
int_disable | IntDisable | Backend emits mtc0 to clear IE bit in Status |
Pruner, Transformer, and Quantum optimizer passes all handle IntHandler and IntEnable nodes correctly: the body is traversed for live symbol analysis, constant folding, and instruction scheduling, just like a regular function body.Optimizer
BedRock includes a three-pass optimizer pipeline activated via the --optimize flag. Each pass operates on the AST before IR lowering. Three levels are available, each stacking additional passes.
11.1 Optimizer Levels
| Flag | Passes Applied | Description |
|---|---|---|
--optimize 1 | Pruner | Constant folding + dead code elimination |
--optimize 2 | Pruner → Transformer | + Loop-invariant code motion + strength reduction |
--optimize 3 | Pruner → Transformer → Quantum | + Register allocation + pipeline scheduling |
| (none) | N/A | No optimization; raw AST passed to IR builder |
11.2 Pass 1: Pruner (Constant Folding & DCE)
The Pruner performs two transformations in sequence:
- Constant Folding: If both operands of a binary expression are compile-time constants, the compiler evaluates the expression at compile time and replaces it with a literal. All operators are supported: arithmetic, bitwise, shifts, and comparisons.
- Dead Code Elimination (DCE): Variables and arrays that are declared but never read (no use in any expression) are removed from the output. Function names and assignments to live variables are always preserved.
if condition folds to a constant, the dead branch is replaced with a no-op (Return(None)). A while(0) loop is similarly removed. This is safe to do at compile time.11.3 Pass 2: Transformer (LICM & Strength Reduction)
The Transformer applies two analyses to every function and loop body:
- Loop-Invariant Code Motion (LICM): Statements whose right-hand-side operands are never modified inside a loop body are hoisted out to before the loop. This eliminates redundant recomputation on every iteration.
- Strength Reduction: Multiplications and divisions by powers of two are replaced with shift instructions (
*2ⁿ→<< n,/2ⁿ→>> n,%2ⁿ→& (2ⁿ-1)), which are faster and smaller on MIPS.
11.4 Pass 3: Quantum (Register Allocation & Scheduling)
The Quantum pass implements a linear-scan register allocator and an instruction scheduler. It models 18 available machine registers.
- Live interval computation: Scans the entire program to determine the first-definition (start) and last-use (end) of every variable.
- Linear-scan allocation: Assigns physical registers to variables in order of their start time. When all 18 registers are busy, the variable with the longest remaining live range is spilled.
- Pipeline scheduling: Reorders statements within a block, placing independent operations (non-spilled variables) before spill-dependent ones to hide memory latency.
lw has a 1-cycle delay slot.IR Pipeline
BedRock now has a full intermediate representation (BedRock-IR) that decouples the frontend from the backend. The IR pipeline is the default compilation path since Rev 2.0 and enables multi-target output.
12.1 Pipeline Overview
12.2 IR Opcodes
The BedRock IR uses a simple three-address code format. Every instruction has an opcode and 0–3 operands.
| Opcode | Operands | Description |
|---|---|---|
Mov | dst, src | Copy value or immediate to virtual register |
Add/Sub/Mul/Div | dst, l, r | Arithmetic binary ops |
And/Orr/Xor/Shl/Shr | dst, l, r | Bitwise and shift ops |
Get | dst, addr | Load from memory address (→ lw) |
Bri | val, addr | Store to memory address (→ sw) |
Cal | label | Function call (→ jal) |
Ret | [val] | Return from function |
Psh / Pop | val / dst | Push/pop arguments for function calls |
Mk | label | Emit a label (function entry or branch target) |
Go | label | Unconditional jump (→ j) |
Cf | l, r | Compare two operands (sets condition) |
Jf | cond, label | Conditional jump if condition is false |
Df | label, val | Data definition (emits word in DATA segment) |
Inb / Outb | dst/val, port | Memory-mapped I/O read/write |
Int | vector, handler | Register interrupt handler at vector |
IntDisable | N/A | Disable all hardware interrupts |
Asm | raw hex str | Emit raw 32-bit instruction word |
Halt | N/A | Emit infinite jump-to-self (end of program) |
12.3 Targets & CLI Flags
| Flag | Effect |
|---|---|
--target mips/-le | Default. IR → MIPS-32 big-endian or little-endian .bin |
--target riscv | IR → RISC-V (RV32I + M) little-endian .bin. Aliases: risc-v, rv32. See §13. |
--target arm | IR → ARM backend (stub, exits with error) |
--target ir | IR → text IR listing .ir file |
--emit-ir | Print IR to stdout and exit (no binary written) |
--bridge | Skip IR; use legacy AST → MIPS codegen path |
--optimize N | Run N optimizer passes (1–3) |
--emit-ir or --target ir to audit the IR that your source compiles to before committing to a binary. This is the recommended debugging workflow for complex programs.12.4 Source Map
After compilation, the MIPS backend writes a .map.json file alongside the binary. Each entry records the BedRock source line, the output binary address, the raw 32-bit instruction word, and a human-readable description.
This source map is consumed by the VS Code debugger extension for live MIPS breakpoints correlated to BedRock source lines.
RISC-V Backend
A second, independent IR-native backend targeting RV32I with the M (multiply/divide) extension. Selected with --target riscv. Every BedRock construct that lowers to MIPS also lowers to RISC-V. The mapping only differs at the instruction-encoding level.
13.1 Overview & Target Flag
The RISC-V backend consumes the same IrModule as the MIPS IR-native backend. It sits at the same point in the pipeline, downstream of the optimizer and IrBuilder. No BedRock source changes are required to retarget a program; only the --target flag changes.
| Flag | Aliases | Output |
|---|---|---|
--target riscv | risc-v, rv32 | RV32I + M, little-endian .bin |
MUL/DIV (opcode 0x33, funct7 = 0000001). It does not emit compressed (C), atomic (A), or floating-point (F/D) instructions. Output is always little-endian. There is no big-endian variant, unlike the MIPS backend.Root Symbol Handling
Address-space layout works the same way as §3. root BASE, root DATA, and root STACK configure the code start address, static data segment, and initial stack pointer. The default BASE is 0x80000000, matching the generic RISC-V virt-machine convention used by QEMU and many bare-metal RV32 boards. It's the same numeric default as the MIPS backend, for a different reason.
13.2 Register Mapping
The backend defines a fixed set of role registers. Each one is chosen as a direct analogue of a MIPS role register from the MIPS backend, so the two backends stay easy to compare.
| Role | RISC-V Register | Number | MIPS Analogue |
|---|---|---|---|
| Zero | zero | x0 | $zero (0) |
| Operand temp | t0 | x5 | $at (1) |
| Return value | a0 | x10 | $v0 (2) |
| Addressing temps | t1, t2 | x6, x7 | $t0, $t1 (8, 9) |
| Compare operands (CF) | t3, t4 | x28, x29 | $s6, $s7 (22, 23) |
| Call / interrupt scratch | t5, t6 | x30, x31 | $k0, $k1 (26, 27) |
| Stack pointer | sp | x2 | $sp (29) |
| Return address | ra | x1 | $ra (31) |
Allocatable Pool (Virtual Register Allocation)
19 physical registers are available to the linear allocator for user variables, one more than the 18 the MIPS IR-native backend uses. RISC-V's role registers leave a slightly larger free set: s0, s1, a1–a7, s2–s11 (x8, x9, x11–x17, x18–x27). tp (x4) is reserved and never allocated. If the pool is exhausted, the variable is spilled to a stack slot at sp + offset, with the same spill semantics as §3.6. The immediate on lw/sw is a 12-bit signed value (±2048 bytes) rather than MIPS's 16-bit range, so a program with heavy spilling has a smaller spill window on RISC-V.
13.3 Instruction Encoding
RISC-V uses six base instruction formats. The backend implements each as a dedicated encoder function operating on raw field values.
| Format | Bit Layout | Used For |
|---|---|---|
| R-type | funct7[31:25] rs2[24:20] rs1[19:15] funct3[14:12] rd[11:7] opcode[6:0] | ADD, SUB, MUL, DIV, AND, OR, XOR, SLL, SRL |
| I-type | imm[31:20] rs1[19:15] funct3[14:12] rd[11:7] opcode[6:0] | ADDI, LW, JALR, ANDI, XORI, SLLI, SRLI, CSR ops |
| S-type | imm[11:5] rs2[24:20] rs1[19:15] funct3[14:12] imm[4:0] opcode[6:0] | SW (store) |
| B-type | imm[12|10:5] rs2 rs1 funct3 imm[4:1|11] opcode | BEQ, BNE, BLT, BGE (conditional branches) |
| U-type | imm[31:12] rd[11:7] opcode[6:0] | LUI (load upper immediate) |
| J-type | imm[20|10:1|11|19:12] rd[11:7] opcode[6:0] | JAL (unconditional jump / call) |
| Opcode | Hex | Opcode | Hex |
|---|---|---|---|
| LOAD | 0x03 | BRANCH | 0x63 |
| OP-IMM | 0x13 | JALR | 0x67 |
| STORE | 0x23 | JAL | 0x6F |
| OP (register-register) | 0x33 | SYSTEM (CSR) | 0x73 |
| LUI | 0x37 |
Every instruction word this backend produces has been checked against a real disassembler (objdump -m riscv:rv32), so the mnemonics in this section reflect what actually gets emitted.
13.4 Load Immediate
RISC-V has no single instruction that loads an arbitrary 32-bit constant. The backend uses the standard LUI + ADDI pattern, with rounding to compensate for ADDI's sign-extended 12-bit immediate.
+ 0x800 rounding term, values whose lower 12 bits have the sign bit set (bit 11) would load incorrectly. ADDI's immediate is sign-extended by the CPU at execution time, so the encoder has to pre-compensate for that at compile time.13.5 Arithmetic & Logic Operations
| BedRock | Mnemonic | funct7 | funct3 | opcode |
|---|---|---|---|---|
| a + b | ADD | 0000000 | 000 | OP (0x33) |
| a - b | SUB | 0100000 | 000 | OP (0x33) |
| a * b | MUL | 0000001 | 000 | OP (0x33) |
| a / b | DIV | 0000001 | 100 | OP (0x33) |
| a & b | AND | 0000000 | 111 | OP (0x33) |
| a | b | OR | 0000000 | 110 | OP (0x33) |
| a ^ b | XOR | 0000000 | 100 | OP (0x33) |
| a << b | SLL / SLLI | 0000000 | 001 | OP / OP-IMM |
| a >> b | SRL / SRLI | 0000000 | 101 | OP / OP-IMM |
mult/div followed by a separate mflo to pull the result out of the LO register, so the RISC-V path is one instruction shorter for every * and / in a BedRock program.Bitwise NOT
RV32I has no dedicated NOR/NOT instruction. The backend synthesizes it as XORI rd, rs, -1. XOR-ing with an all-ones mask flips every bit, since the 12-bit immediate -1 sign-extends to 0xFFFFFFFF at execution time.
Shift Semantics
Both the immediate (SLLI/SRLI) and register (SLL/SRL) shift forms are the logical variants. Shifting right always fills with zero, never the sign bit. This matches the MIPS backend's use of srlv (not srav) for >>, so shift behavior stays identical across both targets.
13.6 Comparisons & Branches
MIPS has no direct "branch if less than" instruction, so it has to synthesize comparisons with slt before branching on the result. RISC-V's base ISA includes signed comparison branches natively, and the backend uses them directly, with no intermediate compare-then-branch step.
| BedRock (Cf/Jf) | RISC-V Branch | funct3 |
|---|---|---|
| l == r | BEQ l, r | 000 |
| l != r | BNE l, r | 001 |
| l < r | BLT l, r | 100 |
| l >= r | BGE l, r | 101 |
| l > r | BLT r, l (operands swapped) | 100 |
| l <= r | BGE r, l (operands swapped) | 101 |
Comparisons use the signed branch forms, BLT/BGE, not BLTU/BGEU. This matches the MIPS backend's use of signed slt for the same operators.
Unconditional Jump
loop/Go lowers to JAL x0, label, a jump that discards the link address by writing it to x0 (hardwired to zero, so the write is dropped). This is the standard RISC-V idiom for an unconditional jump, equivalent to the pseudo-instruction j label.
Branch/Jump Target Resolution
Forward references, a branch to a label not yet emitted, are resolved with a two-pass patch mechanism. A placeholder word is emitted at the branch site, and once every label's final address is known, the backend re-encodes that site with the correct signed byte offset using the same B-type/J-type encoders. RISC-V branch and jump offsets are PC-relative, so the numeric value of root BASE cancels out of every intra-program offset. Only absolute loads, like the stack pointer or a hardware address, are affected by BASE's actual value.
13.7 Function Calls & Returns
Call
A named function call saves the caller's return address into a scratch register before the call and restores it right after, so the caller's own ra survives nested calls without a dedicated stack save.
Return
If the function returns a value, it's moved into a0 (skipped if already there). The function then returns via jalr x0, ra, 0, the standard RISC-V ret pseudo-instruction.
call(): Function Pointers
The §4.7 call(expr) mechanism lowers the same way as a named call, except the target address is loaded from the evaluated expression into a register at runtime instead of being a compile-time label. The underlying jump still uses jalr, RISC-V's indirect-jump instruction. This is the direct structural equivalent of the MIPS jalr $ra, $tX form documented in §4.7.
13.8 Memory Operations
| BedRock | RISC-V | Format |
|---|---|---|
| poke(addr, val) | sw val, 0(addr) | S-type |
| peek(addr) | lw dest, 0(addr) | I-type |
| outb(port, val) | sw val, 0(port) | S-type (identical to poke) |
| inb(port) | lw dest, 0(port) | I-type (identical to peek) |
| Stack spill store | sw reg, off(sp) | S-type, ±2048-byte offset range |
| Stack spill load | lw reg, off(sp) | I-type, ±2048-byte offset range |
As with the MIPS backend, outb/inb are the same instructions as poke/peek. The distinction is only there to signal programmer intent about memory-mapped I/O versus general memory.
13.9 Interrupts (CSR-Based)
The RISC-V backend implements the §10 interrupt keywords using RISC-V's native control-and-status-register (CSR) mechanism, instead of the CP0-style register manipulation the MIPS backend uses.
int_enable: Handler Address Load
Installing a handler loads its address into a scratch register using the same forward-patchable LUI + ADDI pair described in §13.4, resolved once the handler's label address is known.
int_disable: CSR Instruction
The MIPS backend clears the IE bit via mtc0 on the CP0 Status register. The RISC-V backend clears the MIE (Machine Interrupt Enable) bit directly with a CSR instruction.
CSRRCI (funct3 = 111, opcode = SYSTEM / 0x73) clears the CSR bits specified by its 5-bit immediate and writes the CSR's prior value to the destination register. Here the destination is x0, so the old value is discarded and only the clear side-effect is kept.
13.10 MIPS vs RISC-V: Quick Reference
| Aspect | MIPS Backend | RISC-V Backend |
|---|---|---|
| Endianness | Big or little (selectable) | Little only |
| Multiply/Divide | mult/div + mflo (2 instr.) | MUL/DIV (1 instr.) |
| Less-than branch | slt + beq/bne (2 instr.) | BLT/BGE (1 instr.) |
| Allocatable registers | 18 | 19 |
| Spill offset range | 16-bit (±32768 bytes) | 12-bit (±2048 bytes) |
| Bitwise NOT | nor $d, $s, $zero | xori $d, $s, -1 |
| Interrupt disable | mtc0 on CP0 Status | csrrci mstatus, 0x8 |
| Delay slots | Yes, one instruction after every branch/jump | None |
Both backends consume the same IrModule, so any BedRock program that doesn't use raw asm() injection (§4.6/§6.6, since those hex words are architecture-specific by definition) compiles correctly to either target with just a change of the --target flag.
x86-64 Backend
Hosted Linux x86-64 code generation. Emits a minimal ELF64 executable for desktop validation, tools, and syscall-based I/O — complementary to the bare-metal RISC-V path.
14.1 Overview & Target Flag
The x86 backend consumes the same IrModule as MIPS and RISC-V. Select it with:
The result is an ET_EXEC ELF64 for Linux. Mark it executable and run:
--target riscv for ESP32-C3 firmware. Prefer --target x86 for hosted algorithms, file I/O experiments, and fast desktop iteration.14.2 ELF Layout
| Piece | Detail |
|---|---|
| Text (PT_LOAD) | RX; typical VA 0x400000 |
| Data (PT_LOAD) | RW; typical VA 0x420000 — string literals, buffers, poke targets |
| Entry | e_entry at the start of generated text |
| ABI | Frame-based locals; parameters via stack; return value in RAX |
| CRT | None — no glibc, no crt0 |
14.3 Syscalls & Hosted I/O
Hosted programs use the language-level syscall(...) form. Arguments follow the Linux x86-64 System V convention: number in RAX, arguments in RDI, RSI, RDX, R10, R8, R9.
| Nr | Name | Typical use |
|---|---|---|
| 0 | read | stdin / file |
| 1 | write | stdout / file |
| 2 | open | path → fd |
| 3 | close | fd |
| 60 | exit | process exit code |
14.4 vs RISC-V
| Aspect | x86-64 | RISC-V |
|---|---|---|
| Primary use | Linux hosted tools / demos | Bare-metal MCU (ESP32-C3) |
| Artifact | ELF executable | Raw .bin |
| I/O model | Linux syscalls | MMIO peek / poke |
| Shared IR | Yes | Yes |
Benchmarks
BRC3 (BedRock compiler) versus GCC -Os on the same suite. Lower is better for compile time and binary size.
15.1 Compilation Speed
Average time in ms — lower is better.
| Compiler | Avg time | Relative |
|---|---|---|
| BRC3 | 22.73 ms | ~5.2× faster |
| GCC (-Os) | 120.39 ms | baseline |
15.2 Binary Size Footprint
Average size in bytes — lower is better.
| Compiler | Avg size | Relative |
|---|---|---|
| BRC3 | 118 B | ~133× smaller |
| GCC (-Os) | 15,782 B | baseline |
crt0) bloat or external dependency linking, resulting in fast compile passes and minimal footprints.15.3 Detailed Breakdown
| Benchmark | BRC3 Time | GCC (-Os) | BRC3 Size | GCC Size | Status |
|---|---|---|---|---|---|
| arrays.br | 99.20 ms | 328.36 ms | 80 B | 15,776 B | PASS |
| bitwise.br | 1.74 ms | 65.52 ms | 64 B | 15,776 B | PASS |
| fibonacci.br | 7.51 ms | 73.31 ms | 244 B | 15,808 B | PASS |
| loops.br | 3.48 ms | 70.75 ms | 116 B | 15,776 B | PASS |
| math.br | 1.74 ms | 64.01 ms | 88 B | 15,776 B | PASS |
| Average | 22.73 ms | 120.39 ms | 118.4 B | 15,782.4 B | 100% |
-Os is a full hosted toolchain with startup and link overhead. These numbers measure compile time and artifact size on this suite, not identical-ABI application runtime.Hardware & Simulation
Deployment evidence for BedRock firmware on ESP32-C3 and the Wokwi simulator path used during heart-rate / UART development.
16.1 ESP32-C3 Live Firmware
Serial live plot from a physical ESP32-C3 running BedRock-generated firmware. The monitored channel holds a steady reading at 72, validating the UART output path from the bare-metal payload.
Figure — Live reading from ESP32-C3 (BedRock firmware). Latest: 72.
16.2 Wokwi Simulation
Wokwi project used for heart-rate style prototyping before flashing hardware: MCU, sensor path, and 1602-class LCD labeled Heart Rate Sim.
Figure — Wokwi simulation (ESP32 family) with Heart Rate Sim LCD and serial monitor.
16.3 Recommended Flow
- Iterate pure logic with
--target x86when no MMIO is required. - Compile the firmware payload with
--target riscv. - Validate pin-level behavior in Wokwi when the diagram matches the board.
- Flash the ESP32-C3; confirm UART / display with a live serial plot as in §16.1.
bedrockco itself. The compiler emits the bare RV32 payload; host tools wrap it for flash.