November 7, 2026 · 9 min read
Writing a Forth interpreter in C
One C file, no dependencies: a Forth that compiles each line into a tiny instruction set, runs it on a small virtual machine, shows the stack after every command and draws turtle graphics right in the terminal using braille characters.
Why Forth?
Most languages need a parser, a grammar and a syntax tree before anything runs. Forth needs almost none of that. A program is a list of words separated by spaces. Numbers are pushed onto a stack, and every other word takes its inputs from the stack and leaves its results there:
3 4 + . \ prints 7
: square dup * ; \ define a new word
5 square . \ prints 25
That makes it one of the few languages where you can build the whole thing yourself, including variables, loops, recursion and an interactive shell, and still understand every line. It was the perfect excuse to practice the parts of C I find most interesting: memory layout, function pointers and a virtual machine loop.
Building it is one command:
gcc -Wall -Wextra -std=c11 -o forth forth.c
./forth # interactive REPL
./forth < turtle.fs # run a script
Compile first, then run
The simplest Forth reads a word and executes it immediately. I went one step further: every line is first compiled into an array of instructions, and only then executed by a small virtual machine. Control flow like if and loop becomes plain jumps, and the VM gets one central loop where I can check for errors, Ctrl-C and runaway recursion.
source line ─► next_token() ─► compile_token() ─► code[] ─► run()
The whole instruction set is twelve opcodes, and an instruction is just an opcode plus one argument:
enum { OP_LIT, OP_PRIM, OP_CALL, OP_EXIT, OP_BRANCH, OP_BRANCH0,
OP_DO, OP_LOOP, OP_PLOOP, OP_I, OP_J, OP_DOTQ };
typedef struct { int op; cell arg; } instr;
OP_LIT pushes a number, OP_PRIM calls a built-in C function, OP_CALL jumps into a user-defined word, and the branch opcodes implement every control structure. Everything else in the language is built on top of these.
Code lives in one array with two regions. The first holds colon definitions, which stay forever. The area after it is a scratch buffer: a line typed at the prompt is compiled there, run once, and then thrown away. A nice side effect is that loops and if work at the prompt too, not only inside definitions:
static instr code[CODE_MAX + SCRATCH_MAX];
static int here = 0; /* next free slot for definitions */
static int shere = CODE_MAX; /* next free slot for the current line */
The dictionary
Every word lives in a dictionary entry with a name, a kind and one value. For a built-in it is an index into the table of C functions, for a colon definition it is the address of its first instruction, for a variable it is its slot in memory, and for a constant it is the value itself:
enum { K_PRIM, K_COLON, K_VAR, K_CONST };
typedef struct {
char name[NAME_LEN + 1];
int kind;
cell val;
} dict_entry;
Lookups search from the newest entry backwards, so redefining a word simply shadows the old one, which is how Forth is supposed to behave. Variables and constants both compile to OP_LIT: a constant pushes its value, a variable pushes its address, and @ and ! read and write through it. The eight color names are just constants added at startup, which is why red color works without any special syntax.
The built-ins come from one table that pairs each name with its C function and a short stack comment. That single table fills the dictionary, prints the words list and answers help <word>, so the documentation can't drift away from the code:
{ "dup", p_dup, "( a -- a a ) copy the top item" },
{ "swap", p_swap, "( a b -- b a ) swap the top two" },
{ "fd", p_forward, "( n -- ) turtle: same as forward" },
The virtual machine
The VM is a single switch inside a loop. Calls push the return address onto a separate return stack, and OP_EXIT pops it:
static void run(int ip) {
int rbase = rp;
while (!err && !want_exit) {
if (interrupted) { interrupted = 0; forth_error("interrupted"); break; }
instr in = code[ip++];
switch (in.op) {
case OP_LIT: push(in.arg); break;
case OP_PRIM: PRIMS[in.arg].fn(); break;
case OP_CALL:
if (rp >= RSTACK_MAX) { forth_error("return stack overflow (recursion too deep?)"); break; }
rs[rp++] = ip;
ip = (int)in.arg;
break;
case OP_EXIT:
if (rp == rbase) return;
ip = rs[--rp];
break;
case OP_BRANCH: ip = (int)in.arg; break;
case OP_BRANCH0: if (!need(1)) break; if (pop() == 0) ip = (int)in.arg; break;
/* ... loops, i, j, ." ... */
}
}
}
Because the return stack is my own array and not the C call stack, infinite recursion ends with a clear error message after 1024 levels instead of a segmentation fault. And because every instruction passes through the same loop, a single flag is enough to stop a program.
Compiling control flow
The interesting part of the compiler is turning if ... else ... then and loops into jumps. When the compiler sees if, it doesn't know yet where to jump, so it emits a BRANCH0 with an empty target and remembers its address on a small control-flow stack. When then arrives, it goes back and fills in the target. This is called backpatching:
if (is(tok, "if")) {
a = compile_op(OP_BRANCH0, 0); /* target unknown yet */
if (a >= 0) cf_push(CF_IF, a);
return;
}
if (is(tok, "then")) {
if (!(e = cf_expect(CF_IF, CF_ELSE, "then", "if"))) return;
code[e->addr].arg = *cpos; /* patch it now */
cfp--;
return;
}
For example, this word replaces negative numbers with zero:
: clamp0 dup 0< if drop 0 then ;
0 PRIM dup
1 PRIM 0<
2 BRANCH0 5 ← filled in when "then" was reached
3 PRIM drop
4 LIT 0
5 EXIT
else emits an unconditional jump, patches the if to land right after it, and leaves its own jump waiting for then. Loops go the other way: begin just records the current address, and until compiles a BRANCH0 back to it, so there is nothing to patch. while ... repeat combines both ideas. The control-flow stack also records which kind of structure is open, so a stray then or a repeat without begin produces a readable error instead of broken code.
Counted loops use a third stack. Real Forth systems keep loop counters on the return stack, but a separate stack of {index, limit} frames made i and j trivial: i reads the top frame and j the one below it. One detail I kept from standard Forth: do always runs its body at least once, so 0 0 do ... loop runs once. That is exactly why real Forths also have ?do.
The catch with compiling first
Compiling a whole line before running it has one surprising consequence. In 42 constant answer, the 42 has only been compiled, not executed, when the compiler reaches constant, so there is nothing on the stack yet. The fix is to run whatever has been compiled so far before defining the constant:
if (is(tok, "constant")) {
flush_toplevel(); /* the value must be on the stack now */
...
dict_add(name, K_CONST, pop());
}
: does the same thing, so a line like 1 2 + . : foo ... ; behaves the way you would expect.
Arithmetic that doesn't lie
C's / rounds toward zero, but this Forth uses floored division, where the remainder always has the sign of the divisor. With C you get -7 / 3 = -2 and a remainder of -1. Here -7 3 / . prints -3 and -7 3 mod . prints 2, which is what you want for things like wrapping angles. The correction is two lines:
cell qq = a / b, rr = a % b;
if (rr != 0 && ((rr < 0) != (b < 0))) { qq--; rr += b; }
Signed overflow is undefined behavior in C, so +, - and * are done on unsigned values and cast back, which simply wraps around. Dividing the smallest long by -1, the one division that can crash a program, is handled as a special case.
Errors that don't crash
Every primitive starts by checking that the stack holds enough items, so + on an empty stack prints stack underflow instead of reading garbage. @ and ! check that the address actually belongs to a variable. Only the first error on a line is reported, so one mistake doesn't produce a wall of follow-up messages.
When an error happens halfway through a definition, the half-built word is rolled back: the dictionary and the code pointer are reset to where they were before the :. In scripts, errors include the line number and the program exits with status 1, so ./forth < file.fs can be used in a shell script or a test.
A friendly REPL
I wanted the REPL to feel like a teaching tool. After every line it prints the stack, so you can see exactly what each word did:
forth> 3 4
<2> [ 3 4 ]
forth> + dup
<2> [ 7 7 ]
forth> : fact dup 1 > if dup 1 - fact * else drop 1 then ;
forth> 10 fact .
3628800
<2> [ 7 7 ]
While a definition or an if is still open, the prompt changes to ...>, so multi-line definitions are easy to type. help gives a short tour, help <word> shows a word's stack effect, and words lists your own words separately from the built-ins. Colors are only used when both input and output are a terminal, so piping a script into the interpreter produces clean text.
Ctrl-C was more subtle than expected. Inside a signal handler you can only safely do very little, so the handler just sets a flag that the VM checks before each instruction. An endless begin again loop stops with an interrupted message and you're back at the prompt. At an empty prompt, Ctrl-C quits, using write() instead of printf(), because printf is not safe to call from a signal handler.
Turtle graphics in the terminal
Logo-style turtle graphics are the most fun way to play with loops, but I didn't want a window or a graphics library. The trick is Unicode braille. The block starting at U+2800 contains every combination of a 2×4 grid of dots, one bit per dot, so each terminal character can show eight pixels:
static const unsigned char BRAILLE_BIT[4][2] = {
{ 0x01, 0x08 }, { 0x02, 0x10 }, { 0x04, 0x20 }, { 0x40, 0x80 }
};
/* character = U+2800 + the bits of the dots that are set */
The canvas is 240×240 dots, which is 120×60 characters. Moving the turtle draws a line with Bresenham's algorithm, which only needs integer additions. When the canvas is printed, each 2×4 block becomes one braille character, encoded to UTF-8 by hand, and only the area that actually contains ink is printed.
The turtle follows Logo's conventions: it starts in the middle, heading 0 points up, and turning right is clockwise. That means moving forward is x += n·sin(heading) and y += n·cos(heading). To keep the build command free of -lm, sine is a short Taylor series on whole degrees, after reducing the angle to the range −180 to 180 where the series converges quickly.
cs yellow color 5 0 do 80 fd 144 rt loop \ a yellow star
The star works because 5 turns of 144° add up to 720°: the turtle goes around twice and comes back to where it started. Colors have one limitation that comes straight from the terminal: a character cell can only have one foreground color, so where two colored lines pass through the same cell, the last one wins.
What's missing
This is a small Forth, not a standard one. There are no strings beyond .", no leave or ?do, no floating point and no file access. The biggest missing piece is create ... does>, the feature that lets Forth programmers define their own defining words. It needs a real memory model where code and data share one address space, while here variables live in a separate array of cells.
What I learned
- Backpatching is a small idea that makes control flow almost free once you have jumps.
- Keeping the call stack inside the VM turns crashes into error messages.
- Defining a language's behavior precisely, like floored division or
dorunning at least once, is a big part of writing an interpreter. - One table that drives the dictionary, the help and the word list keeps everything in sync.
The whole project is open source under the GPL-3.0 license. Clone it, run ./forth < turtle.fs, or read the code on GitHub.
Further reading: Leo Brodie, Starting Forth and Thinking Forth. Graphics: Unicode braille patterns (U+2800–U+28FF) and Bresenham's line algorithm.