Skip to content

BUILD
YOUR

From source code to

Performancestandard
🔍
Lexing
Tokens
🌳
Parsing
AST
⚡
Code Generation
Assembly (x86‑64)
▶️
Link & Run
Assemble • Link • Execute
main.js
1let x = 42;
2function main() {
3 console.log(x);
4}
→
assembly.s
mov $42, %rax
push %rbp
mov %rsp, %rbp
call printf

What You’ll Do Here

Build a tiny language end‑to‑end

Start by setting the in S0 with a minimal x86‑64 program, then grow S1–S4 (variables, control flow, functions, I/O), and finish self‑hosting in S5.

  • •Type basic S expressions and see the update live.
  • •Follow the stages S0 → S5: expressions, variables, control flow, functions, runtime, bootstrapping.
  • •Use the “Running Sx on Your Machine” cards to copy, assemble, link, and run each step locally.
  • •Hover dotted terms — explain key ideas without breaking the flow.

How to follow along

  • 1.Skim the “Why a Compiler?” primer below, then hover the pipeline cards.
  • 2.Use the interactive playgrounds (lexer/AST/assembly) to connect concepts to concrete outputs.
  • 3.Try the commands in the final Tooling & Build section to assemble and run examples locally.
  • 4.Finish with the quiz to check your understanding.
Tip: Look for the green dotted underline — it means “hover me”.

Why a Compiler?

A compiler is not just a translator. It's a multi-stage pipeline that gradually transforms human-readable code into machine instructions.

Each stage adds structure, meaning, and .

Interactive Compiler Pipeline

Hover each stage to see the magic unfold — from source code to machine instructions

1
🔍

Lexing (Scanning)

Your code is broken into , the alphabet of the language.

On hover, you’ll see: tokens start glowing, hinting at lexing

Example:
"soit x = 42" → ["soit", IDENT(x), "=", NUMBER(42)]
Hover or tap to see example →
↓
2
🌳

Parsing → AST

Tokens are rearranged into a that reflects meaning.

On hover, you’ll see: see tree nodes light up as checks are applied

Example:
Hover or tap to see example →
↓
3
🧠

Semantic Analysis

The is checked for correctness (types, variables, functions).

On hover, you’ll see: show type checking and scope validation

Example:
Using variable y before declaration → error. Calling add(1) when add expects 2 args → error.
Hover or tap to see example →
↓
4
🔄

IR (Intermediate)

Code is lowered into a neutral, form for optimization.

On hover, you’ll see: show optimization passes (dead code elimination, constant folding)

Example:
while (x < 10) {...} → start:/if x>=10 goto end/body/goto start/end:
Hover or tap to see example →
↓
5
⚡

Code Generation

The verified is turned into for the CPU.

On hover, you’ll see: registers blink (RAX, RDI) showing 'this is hardware-level'

Example:
x = x + 1 → mov rax, [rbp-8]; add rax,1; mov [rbp-8],rax
Hover or tap to see example →
↓
6
🔗

Assembly & Linking

Assembly is converted to and combined with runtime/libraries.

On hover, you’ll see: tooltips explain sections, linked with libc/syscalls

Example:
main.o + libc.a → a.out (ELF binary ready to run)
Hover or tap to see example →
↓
7
💻

Execution

At runtime the CPU executes instructions on , and I/O.

On hover, you’ll see: CPU chip lights up, instructions execute step by step

Example:
Program prints 42, CPU executes syscalls to write to terminal.
Hover or tap to see example →
Where you'll build these:

Key Insights

🔄

Frontend vs Backend

Frontend understands the language. Backend generates machine code.

💻

CPU Specific

Each CPU (x86-64, ARM, RISC-V) needs a different backend.

🐛

Early Error Detection

Compilers catch errors before execution, making them your debugging ally.

The S Language

S is tiny on purpose. Its is simple, slightly quirky, and easy to .

We'll grow S in stages

🔢
S0
Integer Expression → Exit Code

One expression per program; compute it and exit with that value.

📝
S1
Variables & Statements

Variables, multiple statements, affiche (print), arithmetic precedence.

🔀
S2
Control Flow

if/else (si/sinon), while (tantque), comparisons.

⚡
S3
Functions & ABI

fonction definitions/calls, retourne; System V ABI call sequence.

🛠️
S4
Runtime I/O

itoa + write syscall; minimal print runtime.

🚀
S5
Self‑Hosting

Compiler written in S; compiles itself (bootstrap).

S leans playful with French-ish keywords

📌
soit x = 42
let binding
🖨️
affiche x
print integer
🔄
si, sinon
if/else
🔁
tantque
while
⚙️
fonction f(n) { ... }
function definition
↩️
retourne expr
return expression

Semicolons are optional; newlines end statements. Blocks use { ... }.

Syntax & Grammar

The grammar file defines the rules of the language. It describes how tokens (like numbers, identifiers, operators) combine into valid expressions and statements. Think of it as the blueprint for what programs in S are allowed to look like.

Integers:
0, 123, -5
Identifiers:
letter (letter|digit|_)*
Operators:
+ - * / %, == != < <= > >=
Delimiters:
( ) { } , =
Keywords:
soit, affiche, si, sinon, tantque, fonction, retourne

Code Sample

factorial.s
1soit n = 5
2fonction fact(x) {
3 si x <= 1 {
4 retourne 1
5 } sinon {
6 retourne x * fact(x - 1)
7 }
8}
9affiche fact(n)
🎮

Interactive Lexer Playground

Type basic S expressions (numbers with +, -, *, /) and watch the magic in real time — see tokens, the AST tree, and the generated x86‑64 assembly.No variables, control flow, or functions in this mini playground.

This playground runs a tiny lexer, parser and x86‑64 code generator in your browser — no servers.
Try: 2 + 3 * 4, 5 * (7 - 2), or -3 + 12 / 3 and compare the AST vs. assembly.

Tip: this playground accepts numeric expressions (no variables/functions).

Enter a valid expression to see AST

Stage 0 — Hello Assembly

🎯

Goal

Take a single , generate a tiny x86-64 program that exits with that value as its .

No variables. No I/O. One expression per program.

Why start with assembly before S?

Because S doesn’t exist yet. Stage 0 gives us a concrete target and a working toolchain. By hand‑writing a minimal program that computes an expression and calls sys_exit, we learn the exact shape our future S compiler must emit to produce a real executable.

  • Concrete target: Linux x86‑64 SysV ABI (we know how to pass arguments and which syscall number to use).
  • Toolchain check: nasm → ld works on your machine.
  • Golden template: the minimal assembly our S compiler will generate in later stages.
  • Demystify: see how 2 + 3 * 4 turns into a few CPU instructions and an exit code.

Where does the assembly come from?

We mentally (or with a tiny script) turn the expression into a :

2 + 3 * 4
Then we emit a sequence of instructions that:
  • •Computes it in a register (we'll use )
  • •Calls the Linux exit(status)
That's all Stage 0 is doing.

From Expression to Assembly

expression
2 + 3 * 4
Simple integer expression
→
s0.asm
; s0.asm — compute 2 + 3*4 and exit with the result (14)
; Linux x86-64 SysV, NASM syntax
global _start
section .text
_start:
    ; --- compute 3*4 into RAX ---
    mov  rax, 3         ; RAX = 3
    imul rax, 4         ; RAX = RAX * 4  => 12

    ; --- add 2 ---
    add  rax, 2         ; RAX = 12 + 2   => 14

    ; --- exit(status = RAX) ---
    mov  rdi, rax       ; First arg (status) goes in RDI (SysV ABI)
    mov  rax, 60        ; Syscall number for exit on Linux x86-64
    syscall             ; exit(14)

How this works (line by line)

⚡
mov rax, 3 / imul rax, 4 / add rax, 2
Evaluate the expression, keeping the running result in RAX.
📤
mov rdi, rax
In Linux AMD64, the first argument to a syscall is placed in RDI. We pass the exit code.
🔢
mov rax, 60
Syscall number for exit on Linux x86-64 is 60.
💀
syscall
The kernel terminates the process with that status. (You'll read it with echo $? in the shell.)

Complete Build Process

⚠️Prerequisites (pick your platform)

Linux / WSL
sudo apt-get update
sudo apt-get install -y python3 nasm binutils
macOS (Docker)
docker run --platform=linux/amd64 -it --rm -v "$PWD":/work \
  -w /work debian:stable-slim bash
apt-get update
apt-get install -y python3 nasm binutils

Note: We target Linux x86-64 SysV ABI. On macOS natively, syscalls differ; use Docker (add --platform=linux/amd64) or a Linux VM.

1

Write the assembly

Create s0.asm and paste the assembly above.

terminal
cat > s0.asm <<'ASM' global _start section .text _start: mov rax, 3 imul rax, 4 add rax, 2 mov rdi, rax mov rax, 60 syscall ASM
2

Assemble & link

Convert assembly to machine code and create executable.

terminal
nasm -felf64 s0.asm -o s0.o
ld -o s0 s0.o
3

Run & verify the exit code

Execute and check the result.

terminal
./s0
echo $?
# → 14
If you see 14, your Stage 0 is working 🎉
🧠

Quiz Time!

Q:Why place the expression result in ?
A:We treat while computing. Before calling the syscall, we move it to (the place where Linux expects the first syscall argument). Then we set RAX = 60 to select exit, and syscall finishes the program.

🚨Quick Troubleshooting

ld: cannot find ...
Ensure you used NASM ELF64 format: -felf64.
Permission denied when running ./s0
chmod +x s0.
Exit code looks different
Remember: the program doesn't print 14; it exits with 14. You must check with echo $?.

🚀What to tweak next

• Change the expression to 5 * (7 - 2) and adjust the instruction sequence accordingly.
• Try by hand, then emit fewer instructions.
• Replace add rax, 2 with sub rax, -2 and see it still yields 14.

Stage 1 — Variables & Operators

📝

Goal

At Stage 0 we could only evaluate a single arithmetic expression. Now we add and multiple statements.

Variables are stored in memory (on the stack).

Key concepts in Stage 1:

  • Variables are stored in memory (on the ).
  • Each soit declaration introduces a new slot in a .
  • Assignments update these slots.
  • At the end, the last expression's value becomes the program's (for now).

This prepares us for control flow in Stage 2 and functions in Stage 3.

Operator Precedence

Operator decides which operations group first; breaks ties. The AST shows 2 + 3 * 4 as 2 + (3 * 4), not (2 + 3) * 4.

In Stage 1 we add variables and multiple statements, but every assignment still relies on expressions. Getting precedence right ensures a = a + b * 3 means a + (b * 3), not (a + b) * 3. We also need this foundation for Stage 2 conditions (if/while) and Stage 3 function calls where argument expressions must evaluate in the correct order.

2 + 3 * 4
Interactive AST showing precedence rules
Key ideas:
  • •: * / before + -. So 2 + 3 * 4 = 2 + (3 * 4).
  • •: + and - are left-associative. 10 - 3 - 2 = (10 - 3) - 2 = 5.
  • •: they force grouping. (2 + 3) * 4 = 20.
  • •Why it matters: the parser builds the AST from these rules; the evaluation order and generated assembly depend on it.
S1 examples to compare:
# Default precedence
soit x = 2 + 3 * 4
# x = 14 (2 + (3*4))
# Parentheses
soit y = (2 + 3) * 4
# y = 20

S1 Assembly Implementation

Conceptual snippet showing using a base pointer (RBP):

s1.asm
global _start
section .text
_start:
; --- stack frame setup ---
push rbp
mov rbp, rsp
sub rsp, 16 ; reserve: [rbp-8]=a, [rbp-16]=b
; --- declarations ---
mov qword [rbp-8], 10 ; a = 10
mov qword [rbp-16], 2 ; b = 2
; --- compute a = a + b*3 ---
mov rax, [rbp-16] ; rax = b
imul rax, 3 ; rax = b * 3
add rax, [rbp-8] ; rax = a + (b*3)
mov [rbp-8], rax ; a = rax
; --- program result = a ---
mov rax, [rbp-8]
; --- exit(result) ---
mov rdi, rax
mov rax, 60
syscall
How this assembly works:

1. Stack frame setup

  • • push rbp / mov rbp, rsp: creates a for local variables.
  • • sub rsp, 16: reserves 16 bytes (two 8-byte slots).
Convention: a lives at [rbp-8], b lives at [rbp-16]

2. Initialize variables

  • • mov [rbp-8], 10 → a = 10
  • • mov [rbp-16], 2 → b = 2

3. Assignment with arithmetic

Load b into rax → multiply by 3 → add a → store back in a.

4. Result & Exit

Load a's value into rax (result register), then syscall exit.

Running S1 on Your Machine

1

Write the assembly

Create the s1.asm file with our stack-based variable implementation.

terminal
cat > s1.asm <<'ASM'
global _start
section .text
_start:
push rbp
mov rbp, rsp
sub rsp, 16 ; reserve: [rbp-8]=a, [rbp-16]=b
; --- declarations ---
mov qword [rbp-8], 10 ; a = 10
mov qword [rbp-16], 2 ; b = 2
; --- compute a = a + b*3 ---
mov rax, [rbp-16] ; rax = b
imul rax, 3 ; rax = b * 3
add rax, [rbp-8] ; rax = a + (b*3)
mov [rbp-8], rax ; a = rax
; --- program result = a ---
mov rax, [rbp-8]
; --- exit(result) ---
mov rdi, rax
mov rax, 60
syscall
ASM
2

Assemble & link

Convert assembly to object file, then link into executable.

terminal
nasm -felf64 s1.asm -o s1.o
ld -o s1 s1.o
3

Run & check exit code

Execute the program and verify the result matches our expectation.

terminal
./s1
echo $?
# → 16
If you see 16, your S1 implementation works! 🎉

💡Why stack allocation?

Even though Stage 1 could technically keep everything in , we force variables onto the stack.

This makes it future-proof:
  • Stage 2 needs (if/while).
  • Stage 3 needs (with stack frames).
  • Debugging is easier when each variable has a clear address.
🧠

Quiz Time!

Q:Why do we allocate variables on the even if are available?
A:Because registers are limited (only a handful) and need a stable place to store locals. The gives every variable a predictable slot.

⚙️S1 Compiler Tasks

1. Symbol table for locals
Each variable gets a at rbp - offset.
Example: first local = [rbp-8], second = [rbp-16].
2. Expression parsing with precedence
Parser must respect * / % higher than + -.
3. Assignments
a = expr → compute expr into a register → mov [rbp-offset(a)], reg.
4. Program result
For now: last evaluated expression is the exit code.
Later: we'll use affiche to print results explicitly.

🚀Toward Stage 2

• Add (if/else) for conditional execution.
• Implement (while) loops for iteration.
• Add comparison operators: ==, !=, <, <=, >, >=.
• Handle more complex with jumps and labels.

Stage 2 — Control Flow

🔀

Goal

At Stage 1 we had variables and arithmetic. Now we add :

The compiler now needs to branch in the generated assembly.

Stage 2 introduces:

  • : conditional execution, like if / else.
  • : looping, like while.
  • Comparisons like <=, <, == produce a (0 = false, 1 = true).

Assembly branching: Evaluate condition → Emit cmp → Jump to right label → Execute block.

Control Flow Graph

Control Flow Graph for a :

While-loop CFG: true → Body → back edge • false → End
Example S2 Program:
soit sum = 0
soit i = 1
tantque i <= 5 {
sum = sum + i
i = i + 1
}
sum # 15
Computes 1+2+3+4+5 = 15
Notice the loop structure with condition checking and body execution!

S2 Assembly with Labels & Jumps

Here's how the compiler lowers the tantque loop into and :

s2.asm
; Reserve space: [rbp-8]=sum, [rbp-16]=i
push rbp
mov rbp, rsp
sub rsp, 32
mov qword [rbp-8], 0 ; sum = 0
mov qword [rbp-16], 1 ; i = 1
; --- while loop starts ---
.L_loop_cond:
mov rax, [rbp-16] ; load i
cmp rax, 5 ; compare i vs 5
jg .L_loop_end ; if i > 5, jump to end
; body: sum = sum + i
mov rax, [rbp-8] ; rax = sum
add rax, [rbp-16] ; rax += i
mov [rbp-8], rax ; store back
; i = i + 1
mov rax, [rbp-16]
add rax, 1
mov [rbp-16], rax
jmp .L_loop_cond ; check condition again
.L_loop_end:
; result = sum, exit(result)
mov rax, [rbp-8]
mov rdi, rax
mov rax, 60
syscall
Step by step explanation:

1. Setup & Initialization

Stack frame reserves space. sum = 0 → [rbp-8], i = 1 → [rbp-16].

2. Condition check

cmp rax, 5 compares i to 5. → if i > 5, exit loop.

3. Body execution

Add i to sum. Increment i by 1.

4. Loop back

returns to condition check.

5. Exit

When condition fails (i > 5), jump to .L_loop_end. Load sum into rax as exit code.
Result: 1+2+3+4+5 = 15 ✨

Running S2 on Your Machine

1

Write the program

Create s2.asm with our loop implementation using labels and conditional jumps.

terminal
cat > s2.asm <<'ASM'
global _start
section .text
_start:
push rbp
mov rbp, rsp
sub rsp, 32
; sum = 0, i = 1
mov qword [rbp-8], 0
mov qword [rbp-16], 1
; while (i <= 5)
.L_loop_cond:
mov rax, [rbp-16] ; load i
cmp rax, 5 ; compare i vs 5
jg .L_loop_end ; if i > 5, exit loop
; body: sum += i
mov rax, [rbp-8] ; rax = sum
add rax, [rbp-16] ; rax += i
mov [rbp-8], rax ; store back
; i = i + 1
mov rax, [rbp-16]
add rax, 1
mov [rbp-16], rax
; back to condition
jmp .L_loop_cond
.L_loop_end:
; program result = sum
mov rax, [rbp-8]
mov rdi, rax
mov rax, 60
syscall
ASM
2

Assemble & link

Convert assembly with labels to executable binary.

terminal
nasm -felf64 s2.asm -o s2.o
ld -o s2 s2.o
3

Run & check result

Execute and verify the loop computed the sum correctly.

terminal
./s2
echo $?
# → 15
If you see 15, your S2 control flow works! 🎉
🧠

Quiz Time!

Q:How does tantque i <= 5 become ?
A:
  • • Compute i, with 5.
  • • If i > 5, to end.
  • • Otherwise, run and jump back to condition.

⚙️S2 Compiler Tasks

1. Comparisons
Use .
(jg, jl, je, etc.) moves execution flow.
2. If / else (si/sinon)
Generate cmp, jump to else/end labels.
3. While (tantque)
Place condition at the top (.L_cond).
Jump to end if false. Body executes. Jump back to .L_cond.

🚀Toward Stage 3

• Add definitions and calls.
• Implement for function return values.
• Handle and .
• Manage the with proper stack frame management.

Stage 3 — Functions

⚡

Goal

Until now, everything was "inlined" in _start. With we can define reusable blocks of code with and .

Separate caller vs callee responsibilities via the System V AMD64 ABI.

System V ABI rules (Linux, x86-64):

  • First 6 integer args:
  • Return value:
  • Caller must save if needed.
  • Callee must preserve (RBX, RBP, R12–R15).

System V ABI Call Sequence

showing caller and callee responsibilities:

In Stage 3 we make real function calls. To interoperate with any code we (or the OS/runtime) might call, both sides must agree on a contract: where arguments live, where the return value comes back, and which registers must be preserved. That contract is the System V AMD64 ABI on Linux.

What the ABI defines
  • • Argument registers: RDI, RSI, RDX, RCX, R8, R9
  • • Return value in RAX
  • • Caller-saved vs callee-saved registers
  • • Stack frame shape and alignment
Why you care
  • • Your compiled S code can call helpers and be called back
  • • No clobbered state: respect saved registers
  • • Predictable return results in RAX
  • • Easier debugging and future interop (C, libc)
Caller:
mov args → regs
call f
(reads RAX result)
Callee:
push rbp
mov rbp, rsp
sub rsp, locals
; body...
leave
ret
→ function call flow →
Example S3 Program:
fonction add3(a, b, c) {
retourne a + b + c
}
soit v = add3(2, 3, 4)
v # 9
Function call: add3(2, 3, 4) → 9
Notice the function definition with parameters and return value!

S3 Assembly with Function Calls

Function add3 with , body, and :

s3.asm
; --- callee: function add3(a, b, c) ---
add3:
push rbp
mov rbp, rsp
sub rsp, 16 ; reserve local space
; args: a=RDI, b=RSI, c=RDX
mov rax, rdi ; rax = a
add rax, rsi ; rax += b
add rax, rdx ; rax += c
leave ; restore stack frame
ret ; return to caller
; --- entry point ---
_start:
mov rdi, 2 ; arg a
mov rsi, 3 ; arg b
mov rdx, 4 ; arg c
call add3
; result now in RAX
mov rdi, rax ; exit(result)
mov rax, 60
syscall
Step by step explanation:

1. Caller prepares arguments

Arguments placed in registers (RDI, RSI, RDX) as per .

2. Function call

pushes return address and jumps to function.

3. Function prologue

Callee saves base pointer, optionally reserves .

4. Function body

Does work: rax = a + b + c. Result in RAX register.

5. Return

resets stack frame. returns to caller.
Result: 2+3+4 = 9 ✨

Running S3 on Your Machine

1

Write the assembly

Create s3.asm with function definition and calling code.

terminal
cat > s3.asm <<'ASM'
global _start
section .text
; --- function add3(a,b,c) ---
add3:
push rbp
mov rbp, rsp
sub rsp, 16 ; reserve local space (optional)
; args: a=RDI, b=RSI, c=RDX
mov rax, rdi ; rax = a
add rax, rsi ; rax += b
add rax, rdx ; rax += c
leave ; restore stack frame
ret ; return to caller
; --- entry point ---
_start:
mov rdi, 2 ; arg a
mov rsi, 3 ; arg b
mov rdx, 4 ; arg c
call add3
; exit(result in RAX)
mov rdi, rax
mov rax, 60
syscall
ASM
2

Assemble & link

Convert assembly with function calls to executable binary.

terminal
nasm -felf64 s3.asm -o s3.o
ld -o s3 s3.o
3

Run & check result

Execute and verify the function returned the sum correctly.

terminal
./s3
echo $?
# → 9
If you see 9, your S3 functions work! 🎉

💡Why functions matter?

• Reusability: same logic reused with different inputs.
• Scoping: per function.
• Step towards bootstrapping: later the will be a collection of functions.
🧠

Quiz Time!

Q:Why is the placed in RAX?
A:By , the caller always looks in RAX after a function call. That's how and "agree" on where to put the result.

⚙️S3 Compiler Tasks

1. Function symbol table
So compiler knows where functions live and their .
2. Separate stack frames
Generate separate per function.
3. Enforce ABI rules
Caller saves temporaries in caller-saved registers.
Callee preserves callee-saved if touched.

🚀Toward Stage 4

• Add (integer print function).
• Implement proper and debugging support.
• Build a of common functions.
• Prepare for the compiler in S language.

Stage 4 — Tiny Runtime & I/O

🖥️

Goal

We add : affiche expr → compute expr → → → optionally exit(0).

This shows how a compiler calls tiny runtime helpers and Linux syscalls.

Stage 4 introduces I/O system:

  • : itoa converts integers to ASCII strings.
  • : write and exit for I/O and termination.
  • Output pipeline: Expression evaluation → String conversion → System output → Clean exit.
  • Foundation for: Error messages, logs, and future standard library.

Integer to ASCII Helper (itoa)

The itoa_rax_to_str helper handles zero and negatives, converting a in RAX to decimal ASCII:

itoa_rax_to_str
; Input: RAX = signed integer (64-bit)
; Output: RSI = pointer, RDX = length
itoa_rax_to_str:
push rbp
mov rbp, rsp
sub rsp, 128 ; scratch space
; Buffer setup: [rbp-128 .. rbp-1]
lea r8, [rbp-128] ; buffer start
lea rsi, [rbp-1] ; write ptr (end)
; Handle zero explicitly
cmp rax, 0
jne .L_nonzero
mov byte [rsi], '0'
mov rdx, 1
jmp .L_done
.L_nonzero:
; Handle negative numbers
mov rbx, 0 ; sign flag
cmp rax, 0
jge .L_convert
mov rbx, 1 # negative
neg rax ; abs(rax)
.L_convert:
; Convert digits (reverse order)
xor rdx, rdx
mov rcx, 10
div rcx ; rax/10, rdx=remainder
add dl, '0' ; digit to ASCII
mov byte [rsi], dl
dec rsi
test rax, rax
jnz .L_convert ; loop if more digits
How itoa works:

1. Setup stack buffer

Reserve 128 bytes on stack. Write pointer starts at end (rbp-1).

2. Handle special cases

Zero gets special treatment. Negative numbers: save sign, make positive.

3. Extract digits

: remainder becomes ASCII digit, quotient continues.

4. Add sign & finalize

If negative, prepend '-'. Calculate final length and return RSI (start), RDX (length).
Return values:
RSI → pointer to first character
RDX → length in bytes

Complete S4 Program with I/O

Complete program calculating 42 - 5*2 = 32, calling itoa, printing via , and exiting cleanly:

s4.asm (complete)
_start:
; --- compute 42 - 5*2 = 32 ---
mov rax, 42
mov rbx, 5
imul rbx, 2 ; rbx = 10
sub rax, rbx ; rax = 32
; --- convert RAX to ASCII ---
call itoa_rax_to_str ; RSI=ptr, RDX=len
; --- write(1, buf=RSI, len=RDX) ---
mov rax, 1 ; sys_write
mov rdi, 1 ; fd=stdout
; rsi, rdx already set by itoa
syscall
; --- write newline ---
mov rax, 1
mov rdi, 1
lea rsi, [rel newline]
mov rdx, 1
syscall
; --- exit(0) ---
mov rdi, 0
mov rax, 60
syscall
section .rodata
newline: db 10 ; '\\n'
How the write syscall works:

System call parameters

RAX=1 selects write (Linux syscall number)
RDI=1 chooses stdout (file descriptor)
RSI/RDX come from itoa (buffer pointer + length)

Output process

1. Compute expression → result in RAX
2. Call itoa → RSI (text), RDX (length)
3. Syscall write → sends bytes to terminal
4. Optional newline for readability
5. Clean exit with status 0

Expected output

$ ./s4
32
$ echo $?
0

Running S4 on Your Machine

1

Create the file

Write the complete S4 assembly with itoa helper and main program.

terminal
cat > s4.asm <<'ASM'
global _start
section .text
; --- integer to ASCII helper: RSI=ptr, RDX=len (input RAX) ---
itoa_rax_to_str:
push rbp
mov rbp, rsp
sub rsp, 128 ; scratch buffer on stack
push rbx ; preserve callee-saved
; buffer setup
lea rsi, [rbp-1] ; write pointer (end)
; handle zero
cmp rax, 0
jne .L_nonzero
mov byte [rsi], '0'
mov rdx, 1
jmp .L_done
.L_nonzero:
; handle negatives
mov rbx, 0 ; sign flag
cmp rax, 0
jge .L_convert
mov rbx, 1 ; negative
neg rax
.L_convert:
mov rcx, 10
.L_loop:
xor rdx, rdx ; clear remainder
div rcx ; rax = quot, rdx = rem
add dl, '0' ; rem to ASCII
mov byte [rsi], dl
dec rsi
test rax, rax
jnz .L_loop
cmp rbx, 0
je .L_finalize
mov byte [rsi], '-' ; prepend minus
dec rsi
.L_finalize:
lea rdx, [rbp-1] ; end pointer
sub rdx, rsi ; length = end - rsi
inc rsi ; rsi = start
.L_done:
pop rbx
leave
ret
; --- entry point ---
_start:
; compute 42 - 5*2 = 32
mov rax, 42
mov rbx, 5
imul rbx, 2 ; rbx = 10
sub rax, rbx ; rax = 32
; convert RAX to decimal string
call itoa_rax_to_str ; returns RSI=ptr, RDX=len
; write(1, RSI, RDX)
mov rax, 1 ; sys_write
mov rdi, 1 ; fd=stdout
syscall
; write newline
mov rax, 1
mov rdi, 1
lea rsi, [rel newline]
mov rdx, 1
syscall
; exit(0)
mov rdi, 0
mov rax, 60
syscall
section .rodata
newline: db 10
ASM
2

Assemble & link

Convert assembly with runtime helpers to executable binary.

terminal
nasm -felf64 s4.asm -o s4.o
ld -o s4 s4.o
3

Run & verify output

Execute and check both the printed output and exit code.

terminal
./s4 # prints the number + newline
echo $? # shows process exit code (0)
If you see '32' printed and exit code 0, your S4 I/O works! 🎉

Build Your Compiler (s4c)

s4c is your bootstrap compiler written in a host language (Python). It reads S source and emits x86-64 assembly. Start with a minimalist MVP (numeric expressions), then extend to variables, control flow, functions, and affiche.

s4c.py (MVP — expressions → exit code)
#!/usr/bin/env python3
"""
s4c.py - A minimal compiler from S expressions to x86-64 assembly
Demonstrates the 4 phases of compilation:
1. Lexical Analysis (tokenize)
2. Syntax Analysis (parse) 
3. Code Generation (gen)
4. Assembly Output (compile_expr_to_asm)
"""
import sys, argparse

# =============================================================================
# TOKEN TYPES - The vocabulary of our language
# =============================================================================
TOK_NUM, TOK_OP, TOK_LP, TOK_RP, TOK_EOF = 'NUM','OP','LP','RP','EOF'

def tokenize(s):
    """
    PHASE 1: LEXICAL ANALYSIS
    Converts source text into tokens (lexemes)
    Example: "2 + 3 * 4" -> [NUM(2), OP(+), NUM(3), OP(*), NUM(4), EOF]
    """
    i, n, toks = 0, len(s), []  # Current position, length, token list
    
    while i < n:
        c = s[i]
        
        # Skip whitespace
        if c.isspace(): 
            i += 1
            continue
            
        # Parse multi-digit numbers
        if c.isdigit():
            j = i  # Remember start position
            while j < n and s[j].isdigit(): 
                j += 1  # Find end of number
            toks.append((TOK_NUM, int(s[i:j])))  # Convert to integer
            i = j
            continue
            
        # Single-character operators
        if c in '+-*/': 
            toks.append((TOK_OP, c))
            i += 1
            continue
            
        # Parentheses for grouping
        if c == '(': 
            toks.append((TOK_LP, c))
            i += 1
            continue
        if c == ')': 
            toks.append((TOK_RP, c))
            i += 1
            continue
            
        # Unknown character - compilation error
        raise SystemExit(f'Unsupported char: {c}')
    
    # Always end with EOF token
    toks.append((TOK_EOF, ''))
    return toks

class Parser:
    """
    PHASE 2: SYNTAX ANALYSIS
    Builds Abstract Syntax Tree (AST) with correct operator precedence
    Uses recursive descent parsing with these grammar rules:
    
    expr -> term (('+' | '-') term)*     # Lowest precedence
    term -> fact (('*' | '/') fact)*     # Higher precedence  
    fact -> number | '(' expr ')' | unary_op fact  # Highest precedence
    """
    
    def __init__(self, toks): 
        self.toks = toks     # Token stream
        self.i = 0           # Current token index
        
    def cur(self): 
        """Get current token"""
        return self.toks[self.i]
        
    def eat(self, expected_type):
        """Consume token of expected type, or error"""
        if self.cur()[0] == expected_type: 
            self.i += 1
        else: 
            raise SystemExit(f'Expected {expected_type}, got {self.cur()}')
            
    def parse(self): 
        """Entry point - parse expression"""
        return self.expr()
        
    def expr(self):
        """Parse addition/subtraction (lowest precedence)"""
        node = self.term()  # Parse left operand
        
        # Handle left-associative operators: a+b+c = ((a+b)+c)
        while self.cur()[0] == TOK_OP and self.cur()[1] in '+-':
            op = self.cur()[1]
            self.eat(TOK_OP)
            # Create binary operation node: ('bin', operator, left, right)
            node = ('bin', op, node, self.term())
        return node
        
    def term(self):
        """Parse multiplication/division (higher precedence)"""
        node = self.fact()
        
        # Same pattern as expr(), but for */ operators
        while self.cur()[0] == TOK_OP and self.cur()[1] in '*/':
            op = self.cur()[1]
            self.eat(TOK_OP)
            node = ('bin', op, node, self.fact())
        return node
        
    def fact(self):
        """Parse factors: numbers, parentheses, unary operators (highest precedence)"""
        tok = self.cur()
        
        # Unary plus/minus: +5, -3
        if tok[0] == TOK_OP and tok[1] in '+-':
            op = tok[1]
            self.eat(TOK_OP)
            return ('un', op, self.fact())  # Unary operation node
            
        # Number literal
        if tok[0] == TOK_NUM:
            self.eat(TOK_NUM)
            return ('num', tok[1])  # Number node
            
        # Parenthesized expression
        if tok[0] == TOK_LP:
            self.eat(TOK_LP)
            node = self.expr()  # Parse inner expression
            self.eat(TOK_RP)    # Expect closing paren
            return node
            
        # Syntax error
        raise SystemExit(f'Unexpected token: {tok}')

# =============================================================================
# PHASE 3: CODE GENERATION
# =============================================================================
reg_cycle = ['rax', 'rbx', 'rcx', 'rdx']  # Available x86-64 registers

def gen(node, code, it=[0]):
    """
    CODE GENERATION - Tree walking code generator
    Recursively traverses AST and emits x86-64 assembly instructions
    Uses register allocation with cycling through available registers
    """
    
    def nextreg(): 
        """Get next available register (cycles through rax->rbx->rcx->rdx->rax...)"""
        r = reg_cycle[it[0] % len(reg_cycle)]
        it[0] += 1
        return r
    
    # Number literal: load immediate value
    if node[0] == 'num':
        r = nextreg()
        code.append(f'mov {r}, {node[1]}')  # mov rax, 42
        return r
        
    # Unary operation: +expr or -expr  
    if node[0] == 'un':
        r = gen(node[2], code)  # Generate code for operand
        if node[1] == '+': 
            return r  # Unary + is no-op
        if node[1] == '-': 
            code.append(f'neg {r}')  # Two's complement negation
            return r
            
    # Binary operation: left op right
    if node[0] == 'bin':
        # Generate code for both operands (left-to-right evaluation)
        l = gen(node[2], code)  # Left operand in register l
        r = gen(node[3], code)  # Right operand in register r
        
        # Emit operation instruction
        if node[1] == '+': 
            code.append(f'add {l}, {r}')    # l += r
        if node[1] == '-': 
            code.append(f'sub {l}, {r}')    # l -= r  
        if node[1] == '*': 
            code.append(f'imul {l}, {r}')   # l *= r (signed multiply)
        if node[1] == '/': 
            # Division is complex in x86-64: requires rax/rdx register pair
            code += [
                f'mov rax, {l}',    # Move dividend to rax
                'cqo',              # Sign-extend rax into rdx:rax  
                f'idiv {r}',        # Signed divide rdx:rax by r
                f'mov {l}, rax'     # Move quotient back to result register
            ]
        return l  # Result is in left register
        
    # Unknown AST node type
    raise SystemExit('Unknown node')

# =============================================================================
# PHASE 4: ASSEMBLY OUTPUT  
# =============================================================================

# Program wrapper - creates complete Linux executable
HEAD = 'global _start\nsection .text\n_start:'  # ELF entry point
TAIL = '\n    mov rdi, rax\n    mov rax, 60\n    syscall\n'  # Linux exit syscall

def compile_expr_to_asm(expr):
    """
    MAIN COMPILATION PIPELINE
    Takes S expression string, returns complete x86-64 assembly program
    """
    # Phase 1: Tokenize source code
    toks = tokenize(expr)
    
    # Phase 2: Parse tokens into AST  
    ast = Parser(toks).parse()
    
    # Phase 3: Generate assembly code
    body = []           # Assembly instruction list
    it = [0]           # Register counter (mutable for nested calls)
    result_reg = gen(ast, body, it)
    
    # Ensure final result is in rax (required for exit code)
    if result_reg != 'rax': 
        body.append(f'mov rax, {result_reg}')
    
    # Phase 4: Wrap in complete program
    # Indent body instructions and combine with header/footer
    asm = [HEAD] + ['    ' + line for line in body] + [TAIL]
    return '\n'.join(asm)

def main():
    """Command-line interface - reads S file, writes assembly file"""
    ap = argparse.ArgumentParser(description='S4C: S to x86-64 Compiler')
    ap.add_argument('src', help='S source file (MVP: a single expression)')
    ap.add_argument('-o', '--out', default='out.asm', help='Output assembly file')
    args = ap.parse_args()
    
    # Read source expression from file
    expr = open(args.src).read().strip()
    
    # Compile to assembly
    asm = compile_expr_to_asm(expr)
    
    # Write assembly output
    open(args.out, 'w').write(asm)
    print(f'[ok] wrote {args.out}')

if __name__ == '__main__':
    main()
              
Try it:
terminal (Python)
cat > prog.s <<'S'
2 + 3 * 4
S
python3 s4c.py prog.s -o out.asm
nasm -felf64 out.asm -o out.o
ld -o out out.o
./out; echo $?
MVP exits with the computed value (14). To print numbers, extend s4c to emit itoa + write.

How the Compiler Works

Phase by phase breakdown: Understanding the s4c.py compiler from source to assembly

1. Lexical Analysis (Tokenizing)

tokenize() converts source text into a stream of tokens:
• "2 + 3 * 4" → [NUM(2), OP(+), NUM(3), OP(*), NUM(4), EOF]
• Skips whitespace, parses multi-digit numbers
• Recognizes operators + - * /
• Handles parentheses for expression grouping: ( )

2. Syntax Analysis (Parsing)

Parser builds an Abstract Syntax Tree (AST) with correct operator precedence:
• expr() handles + and - (lowest precedence)
• term() handles * and / (higher precedence)
• fact() handles numbers, unary operators, parentheses (highest precedence)
• Example tree: ('bin', '+', ('num', 2), ('bin', '*', ('num', 3), ('num', 4)))
Precedence Magic:
The recursive structure ensures 3 * 4 binds tighter than 2 +

3. Code Generation (Tree Walk)

gen() walks the AST recursively and emits x86-64 assembly instructions:
• ('num', 42) → mov rax, 42
• ('bin', '+', l, r) → add left_reg, right_reg
• Uses register cycling: ['rax', 'rbx', 'rcx', 'rdx']
• Special division handling: mov rax, dividend; cqo; idiv divisor
• Unary minus: neg register
Register Management:
Each subexpression gets its own register, cycling through available ones

4. Assembly Output & Wrapper

compile_expr_to_asm() wraps generated instructions in a complete program:
• HEAD: global _start; section .text; _start:
• Generated computation instructions (expression evaluation)
• TAIL: mov rdi, rax; mov rax, 60; syscall (Linux exit)
• Result: A complete Linux x86-64 executable that exits with computed value
Linux Syscall:
Uses exit syscall (#60) to return the computed value as program exit code

💡Complete Example: "2 + 3 * 4" Compilation

1. Tokenize: [NUM(2), OP(+), NUM(3), OP(*), NUM(4), EOF]
2. Parse AST:
('bin', '+',
('num', 2),
('bin', '*', ('num', 3), ('num', 4))
)
3. Generate Assembly:
mov rax, 2 # load left operand (2)
mov rbx, 3 # load 3 for multiplication
imul rbx, 4 # rbx = 3 * 4 = 12
add rax, rbx # rax = 2 + 12 = 14
4. Final Program: Exits with code 14 ✅
Run ./out; echo $? to see the result!
🧠

Quiz Time!

Q:Why not just print directly from the ?
A:Because output is a (syscalls, buffers). The compiler emits calls to and syscalls instead of embedding full I/O logic everywhere—cleaner and .

⚙️S4 Compiler Tasks

1. Lower affiche expr
• Evaluate expr → result in RAX
• Call itoa_rax_to_str
• Syscall write(1, RSI, RDX) + optional newline
• Optionally exit(0) for this stage's demo
2. Keep helpers in same .text
For simplicity, embed . Later: move to tiny runtime library.

🚀Toward Stage 5

• Expand runtime with more .
• Add (read syscall, string parsing).
• Build comprehensive and debugging tools.
• Create foundation for real-world programs.

Stage 5 — Bootstrapping

🚀

Goal

The S compiler is now written in S itself — the language becomes . Use s4c from Stage 4 to cross‑compile sc.s into sc0, then rebuild sc1 with sc0 and verify.

We’ve crossed from toy demos to a real, self‑sustaining toolchain.

What does that mean?

  • Stage 4: you had a compiler (s4c) in Python/C that compiled S to x86‑64.
  • Stage 5: write the compiler source sc.s in S.
  • Cross‑compile once: s4c sc.s → sc0.
  • Self‑rebuild: sc0 sc.s → sc1.
  • Fixed point: sha256sum sc0 sc1 should match (or converge in 1–2 iterations).

From now on, the compiler evolves in its own language.

bootstrapping flow
# Bootstrap phase
s4c (Python/C) + sc.s (S code) → sc0 (binary)
# Self-hosting phase
sc0 (binary) + sc.s (S code) → sc1 (binary)
# Verification
sha256sum sc0 sc1 # Should match (or converge in 1–2 iterations)!
# Victory 🎉
Drop s4c → S is now self-sustaining
Key idea

Compile the compiler with itself and ensure binaries stabilize — this proves correctness and reproducibility.

Bootstrap Steps

1

Write s4c in host language

Create bootstrap compiler in Python/C supporting Stage 4 S features: expressions, variables, functions, control flow, and affiche. Must output working x86-64 assembly.

2

Author sc.s (compiler in S)

Implement lexer, parser, and codegen entirely in S language. Keep minimal: integers, identifiers (strings for names), loops, conditionals, functions.

3

Cross-compile once

Use bootstrap compiler to compile the S-written compiler into binary form.

command
s4c sc.s → sc0 # compiler binary from S source
4

Self-rebuild

Use the compiled S compiler to recompile itself from source.

command
sc0 sc.s → sc1 # compiler rebuilds itself
5

Compare outputs

Verify that both compiler binaries are identical (or converge after 1–2 iterations).

command
sha256sum sc0 sc1 # Should be identical!
6

Victory! 🎉

You are now fully self-hosting. Drop the bootstrap compiler. S is self-sustaining and can evolve in its own language.

Trust Chain Checklist

are critical for verification:

🎯
✓
Deterministic codegen

No randomness, no hidden timestamps in output.

🛡️
✓
No undefined behavior

Always initialize locals, handle edge cases.

🔄
✓
Reproducible builds

Rebuilding sc.s always gives same binary.

🔒
✓
Lock tool versions

Use exact same NASM, ld versions.

testing process
# 1) Bootstrap compile
python3 s4c.py sc.s -o sc0.asm
nasm -felf64 sc0.asm -o sc0.o
ld -o sc0 sc0.o
# 2) Self-rebuild
./sc0 sc.s -o sc1.asm
nasm -felf64 sc1.asm -o sc1.o
ld -o sc1 sc1.o
# 3) Verification
sha256sum sc0 sc1
# They should match (or converge in 1–2 iterations)
🎉 If they match:

Why self‑hosting matters

• Rite of passage: all reached this milestone.
• Language maturity: Proves your language is to describe its own compiler.
• Independence: You no longer depend on Python/C — your language is .
• Evolution: You can write new features in S itself, recompile with itself.
• Distribution: Share only the source (sc.s) as the .

Quiz

🧠

Quiz Time!

Q:What happens if sc0 and sc1 don't match during ?
A:This indicates a bug in either the or the . Fix bugs and repeat. Some compilers need 1–2 iterations to , but they should eventually converge to identical binaries.

What’s next

⚙️S5 Compiler Tasks

1. Rewrite compiler in S
• Implement , , and in S language
• Support all Stage 4 features: expressions, variables, functions, I/O
• Keep implementation minimal but complete
2. Bootstrap & verify
• Cross-compile using Stage 4 bootstrap compiler
• Self-rebuild and verify
• Achieve convergence

🚀Beyond Bootstrapping

• Add to the self-hosted compiler.
• Expand the language with and .
• Build comprehensive and ecosystem.
• Create development tools: debugger, package manager, .
• Share your language with the world — it's now ! 🌍

Tooling & Build

Environment

with + .

  • — assembler
  • — linker (GNU binutils)

You can also use and , but we stick with NASM for clarity.

Steps

tooling-install
• Install tools
sudo apt-get install nasm binutils
•
nasm -felf64 in.asm -o out.o
•
ld -o out out.o
•
./out; echo $?

Platform notes

macOS (Apple Silicon)

Use or a . Native syscalls differ — this tutorial assumes .

Windows

Use . Then follow the Linux steps exactly.

Playground & Exercises

Lexer

Type S code → view . Toggles: show positions, show lines.

Parser

See structure. Click nodes to highlight spans.

Codegen

See emitted and dry‑run .

Quiz

  • What’s the register order for integer args (SysV AMD64)?
  • What does leave do?
  • Why are labels required for control flow?
  • Which register holds the return value?
  • Name the callee‑saved registers (SysV AMD64).
  • What does itoa_rax_to_str return in RSI and RDX?
  • Linux x86‑64 syscall numbers for write and exit?
  • How do you verify self‑hosting reached a fixed point?

FAQ

Why not emit C and compile with gcc?
That’s a valid route! But we go straight to to understand the CPU.
Where’s the type system?
Minimal for now (just integers). Add as an extension after bootstrapping.
Can I target ARM64?
Yes — just change and .
How big should the S compiler be?
Hundreds to a couple thousand lines of S code. Enough to bootstrap.