Building a Stack-Based VM in Rust – Part 2
24 May 2025Introduction
In Part 1, we built the foundation of a
Forth-inspired stack-based virtual machine in Rust. It could execute arithmetic expressions using a simple data stack,
with support for operations like PUSH
, ADD
, MUL
, and basic stack manipulation like DUP
, DROP
, and SWAP
.
In this post, we’re going to extend our instruction set with a broader set of stack manipulation words, modeled after standard Forth operations.
Why focus on stack operations? Because in a language like Forth, the stack is everything. Understanding and manipulating it precisely is key to building complex programs — without variables, parentheses, or traditional control structures.
Stack Operations in Forth
Let’s take a look at some of the classic stack words used in Forth and what they do:
Word | Stack Effect | Description |
---|---|---|
OVER |
( a b -- a b a ) |
Copies the second value to the top |
ROT |
( a b c -- b c a ) |
Rotates the third value to the top |
NIP |
( a b -- b ) |
Removes the second item |
TUCK |
( a b -- b a b ) |
Duplicates the top item under the second |
2DUP |
( a b -- a b a b ) |
Duplicates the top two items |
2DROP |
( a b -- ) |
Drops the top two items |
2SWAP |
( a b c d -- c d a b ) |
Swaps the top two pairs |
DEPTH |
( -- n ) |
Pushes the current stack depth |
These tiny instructions are the building blocks for everything from loops and conditionals to data structures and control flow. Let’s implement them.
Extending the Instruction Set
First, we add new variants to our Instruction
enum:
enum Instruction {
Push(i32),
Add,
Mul,
Dup,
Drop,
Swap,
Over,
Rot,
Nip,
Tuck,
TwoDup,
TwoDrop,
TwoSwap,
Depth,
Halt,
}
Implementing the New Instructions
Each of these stack operations is implemented as a new match
arm in our run()
method. Here’s the complete method
with all new instructions included:
OVER
Copies the second value from the top and pushes it to the top.
Stack effect: ( a b -- a b a )
Instruction::Over => {
if self.stack.len() < 2 {
panic!("Stack underflow on OVER");
}
let val = self.stack[self.stack.len() - 2];
self.stack.push(val);
}
This implementation uses indexing to read the second-to-top value without popping. It’s a clean operation that doesn’t disturb the existing stack order — a very common primitive in Forth.
ROT
Rotates the third item to the top of the stack.
Stack effect: ( a b c -- b c a )
Instruction::Rot => {
if self.stack.len() < 3 {
panic!("Stack underflow on ROT");
}
let c = self.stack.pop().unwrap();
let b = self.stack.pop().unwrap();
let a = self.stack.pop().unwrap();
self.stack.push(b);
self.stack.push(c);
self.stack.push(a);
}
We pop all three values, then push them back in rotated order. It’s a destructive operation — it reshuffles the top 3 items completely.
NIP
Removes the second item, leaving the top item alone.
Stack effect: ( a b -- b )
Instruction::Nip => {
if self.stack.len() < 2 {
panic!("Stack underflow on NIP");
}
let top = self.stack.pop().unwrap();
self.stack.pop(); // discard second
self.stack.push(top);
}
Here we temporarily save the top, discard the second, then restore the top. This is essentially “keep the top, ignore the rest.”
TUCK
Duplicates the top item and inserts it beneath the second.
Stack effect: ( a b -- b a b )
Instruction::Tuck => {
if self.stack.len() < 2 {
panic!("Stack underflow on TUCK");
}
let top = *self.stack.last().unwrap();
self.stack.insert(self.stack.len() - 2, top);
}
We avoid popping by using last()
and insert()
. Inserting at len() - 2
puts the copy just beneath the second item, preserving the original order.
2DUP
Duplicates the top two stack items.
Stack effect: ( a b -- a b a b )
Instruction::TwoDup => {
if self.stack.len() < 2 {
panic!("Stack underflow on 2DUP");
}
let len = self.stack.len();
self.stack.push(self.stack[len - 2]);
self.stack.push(self.stack[len - 1]);
}
We peek at the last two items and push duplicates in-place. It’s a straightforward double copy.
2DROP
Removes the top two items from the stack.
Stack effect: ( a b -- )
Instruction::TwoDrop => {
if self.stack.len() < 2 {
panic!("Stack underflow on 2DROP");
}
self.stack.pop();
self.stack.pop();
}
Just two pops in a row. Very simple and direct.
2SWAP
Swaps the top two pairs on the stack.
Stack effect: ( a b c d -- c d a b )
Instruction::TwoSwap => {
if self.stack.len() < 4 {
panic!("Stack underflow on 2SWAP");
}
let d = self.stack.pop().unwrap();
let c = self.stack.pop().unwrap();
let b = self.stack.pop().unwrap();
let a = self.stack.pop().unwrap();
self.stack.push(c);
self.stack.push(d);
self.stack.push(a);
self.stack.push(b);
}
This is the most complex so far. We destructure two pairs from the stack, then push them back in swapped order.
DEPTH
Pushes the number of elements currently on the stack.
Stack effect: ( -- n )
Instruction::Depth => {
let depth = self.stack.len() as i32;
self.stack.push(depth);
}
No stack input required. Just measure and push. Very handy for introspection or debugging.
Example: Forth-ish Stack Dance
Let’s build a small program using some of these new instructions:
let program = vec![
Instruction::Push(1),
Instruction::Push(2),
Instruction::Push(3),
Instruction::Rot, // [2, 3, 1]
Instruction::Over, // [2, 3, 1, 3]
Instruction::Add, // [2, 3, 4]
Instruction::TwoDup, // [2, 3, 4, 3, 4]
Instruction::Swap, // [2, 3, 4, 4, 3]
Instruction::TwoDrop, // [2, 3, 4]
Instruction::Depth, // [2, 3, 4, 3]
Instruction::Halt,
];
let mut vm = VM::new(program);
vm.run();
println!("Final stack: {:?}", vm.stack);
The final stack should look like this:
[2, 3, 4, 3]
That last 3
is the result of DEPTH
, reporting how many values were on the stack before it was called.
Conclusion
With just a few additional instructions, our little VM has become much more expressive. We’ve added powerful new tools to inspect, duplicate, and reorder values on the stack — just like a real Forth environment.
This kind of “stack choreography” might feel alien at first, but it’s deeply intuitive once you start thinking in terms of data flow. It’s the perfect foundation for:
- Building control structures
- Defining new words
- Supporting conditionals and loops
- Creating a REPL
And that’s where we’re headed next.
The code for this part is available up in my github.