Building a Stack-Based VM in Rust - Part 4
25 May 2025Introduction
In Part 3, we introduced control flow and subroutines into our virtual machine. That gave us branching logic and reusable code blocks — a huge step forward.
But one core Forth idea is still missing: the ability to define and name new words.
In this part, we’ll add a dictionary to our VM and support calling reusable routines by name. This will allow us to define Forth-style words like:
: square dup * ;
5 square
Let’s get into it.
The Concept of a “Word”
In Forth, a word is any named function — even built-ins like +
and *
are just words. User-defined words are
created using :
and ;
, and then they behave just like native instructions.
To support this, we need:
- A dictionary mapping word names to addresses
- An instruction that can call a word by name
- A way to define new words at specific locations in the program
Extending the Instruction Set
First, we extend our enum to support calling named words:
enum Instruction {
Push(i32),
Add,
Mul,
Dup,
Drop,
Swap,
Over,
Rot,
Nip,
Tuck,
TwoDup,
TwoDrop,
TwoSwap,
Depth,
Jump(isize),
IfZero(isize),
Call(usize),
CallWord(String), // new
Return,
Halt,
}
The new CallWord(String)
variant allows us to write programs that reference named words directly.
Adding a Dictionary
Next, we update our VM
structure to store a dictionary:
use std::collections::HashMap;
struct VM {
stack: Vec<i32>,
program: Vec<Instruction>,
ip: usize,
return_stack: Vec<usize>,
dictionary: HashMap<String, usize>, // new
}
And initialize it in VM::new()
:
impl VM {
fn new(program: Vec<Instruction>) -> Self {
Self {
stack: Vec::new(),
program,
ip: 0,
return_stack: Vec::new(),
dictionary: HashMap::new(), // new
}
}
}
Adding New Words
We create a helper method to register a word at a specific address:
impl VM {
fn add_word(&mut self, name: &str, address: usize) {
self.dictionary.insert(name.to_string(), address);
}
}
This lets us register any block of code under a name.
Calling Named Words
Now we implement the CallWord
instruction in our dispatch loop:
Instruction::CallWord(name) => {
let addr = self.dictionary.get(name)
.expect(&format!("Unknown word: {}", name));
self.return_stack.push(self.ip + 1);
self.ip = *addr;
continue;
}
This works just like Call
, but performs a dictionary lookup first.
Example: Defining square
Here’s a complete program that defines and calls a square
word:
let program = vec![
Instruction::Push(5),
Instruction::CallWord("square".to_string()),
Instruction::Halt,
// : square dup * ;
Instruction::Dup,
Instruction::Mul,
Instruction::Return,
];
let mut vm = VM::new(program);
vm.add_word("square", 3); // definition starts at index 3
vm.run();
println!("Final stack: {:?}", vm.stack);
Output:
[25]
We’ve now made it possible to extend the language from within the language — a hallmark of Forth.
Optional: Parsing : square dup * ;
Currently we define words manually by inserting them into the dictionary, but in true Forth style we’d like to write:
: square dup * ;
5 square
To support that, we’ll need a minimal parser or macro-assembler to convert high-level Forth code into VM instructions. This will be the focus of a future post.
Conclusion
In this post, we gave our VM the ability to define and call named words, which turns our stack machine into something far more expressive and composable.
Our VM now supports:
- Arithmetic
- Stack manipulation
- Control flow and subroutines
- A dictionary of named routines
In Part 5, we’ll push even further — implementing a simple parser that can read actual Forth-like text, resolve words, and build programs dynamically.
We’re getting very close to having a minimal, working Forth interpreter — and it’s all built in Rust.
The code for this part is available here on GitHub