A self-hosted Forth for my Z80 computer
Introduction
A while back, I decided to implement a self-hosted Forth interpreter/compiler for the ZI-28, my Z80-based computer. The previous version of the software consisted of a simple operating system written in assembly, with applications and games written in C. There were some things I was unhappy about. Writing the OS in assembly was slow and error-prone. Writing the applications in C was much faster and easier in comparison, but it required the use of z88dk, a big software suite that was difficult to set up and went against my goal of the whole project.
My goal was always to build a computer system from the ground up, allowing me to understand every part of the process. Ideally, this would include being able to build the entire software on the computer itself. While I could maybe see myself writing an assembler in Z80 assembly, building an entire C compiler from scratch seemed too ambitious. Rewriting the entire software in a different language wasn’t an option at the time, but after a long break from the project, starting on something fresh seemed more appealing than trying to understand my old assembly code.
I chose Forth for the rewrite. It offered enough abstractions to make writing software more pleasant and reliable than assembly, while still being relatively easy to implement from scratch. There are also some excellent resources, like Starting Forth explaining the basics of the language, or Jonesforth showing how to implement a complete interpreter. My goal was to implement a system that could read its own source code and compile it, all on the ZI-28 itself.
Assembler
Since I wanted my Forth to be completely self-hosted, I decided to also write an
assembler in Forth. For development on the host computer, I used
Gforth. As I was targeting a very constrained system, I
didn’t go with traditional assembly syntax and instead chose a more Forth-like
way of writing instructions, inspired by
MMSForth.
For example, ld a, b
turns into A B LD,
. Here, A
and B
are constants
that get pushed onto the stack, and LD,
is a Forth word that takes two
register addresses and writes the resulting opcode to a buffer. I wanted to use
16-bit stack cells and a single cell per argument. This presented the problem
that a register constant is indistinguishable from an immediate number, so I
added separate instructions that deal with immediates, suffixed with a #
(ld a, 42
becomes A 42 LD#,
).
Thanks to Forth’s powerful metaprogramming features, implementing the basic
instructions was straightforward. I created a defining word for each type of
instruction. In the following example, the defining word NOARG
gets created.
|
|
This word can then be invoked like this:
|
|
Unfortunately, the Z80 instruction set is quite complex. There are a lot of
instructions that have the same name but translate to different opcodes
depending on the type of arguments. The worst of them is the ld
instruction
which has about 15 different variations, not even counting the ones loading
immediate numbers. The MMSForth manual linked above puts into perspective how
much more complex the Z80 instruction set is over the 8080 instruction set it is
based on: the 8080 assembler uses 1.2 kilobytes of RAM, while the full Z80
assembler uses 3.5 kilobytes.
To make sure I had implemented all of the instructions correctly, I also created
a test harness. I defined the words T{
, ->
and }T
to allow me to compile
an instruction and check that it outputs the correct opcode and is free of stack
effects.
A single test case then looks like this:
|
|
Meta-Compiler
Now that I had an assembler, I could start building some basic words for my own Forth. Some of the more complex ones like multiplication and division I copied from CamelForth, as I wasn’t really interested in coming up with efficient implementations on my own.
The beauty of Forth is that once you have a basic set of words, you can then
build on those and even implement the interpreter itself using just Forth. Doing
this while cross-compiling required some more effort. For a lot of the special
compiling words, I had to implement a meta-variant that compiles to the target
system instead. I defined the word :META
as a counterpart to the standard :
that defines new words. Calling :META
essentially starts up a separate Forth
compiler. It looks up words in the target output and whenever it encounters an
immediate word, it calls the special meta-variant of it. This way, Forth words
can be defined in the familiar way, only requiring :META
instead of :
.
Here’s an example for the word COUNT
being defined using the meta-compiler:
|
|
One challenge I ran into was jump labels. While jumping back to a previously defined label is very easy, jumping forward is more difficult, as the destination address is only known later. My first approach to solve this was to simply compile the code twice, putting in placeholders in the first pass and filling them in during the second pass. This was easy to implement and worked fine on my laptop, but on the ZI-28, reading in and interpreting the entire source code twice would mean a significant slow-down.
My solution was to introduce the words FWDREF
and RESOLVE
which can be used
like this:
|
|
The way this works is that FWDREF
first checks if a label with the provided
name already exists. If it doesn’t, it creates it and stores the of the jump,
putting in a placeholder value of zero. If the label already exists, it builds a
linked list of all the jump origins going to that label. The RESOLVE
word can
then traverse the list and replace all the pointers with the current address.
Self-hosting
Once I implemented all the Forth words that my assembler and compiler required, I could simply run the same code that I wrote for Gforth on the ZI-28. I ported some of my original assembly code, like the keyboard interface and the SD card and FAT filesystem drivers.
I noticed that the interpreter was really slow. I had followed a pretty standard
design of storing the address of the previous word whenever a new one is
defined, thus building a linked list of words. The issue with this was that a
lot of commonly used words were defined first and therefore at the end of the
list, like DUP
, SWAP
, or +
. Whenever one of those words was interpreted or
compiled, more than 200 words had to be traversed and checked against.
To fix this issue, I implemented a hashmap. I opted for 256 entries, as that could be indexed nicely with an 8-bit hash. It wasn’t big enough to avoid collisions, but since each word already had a pointer field to the next word before, I could keep the logic to search through a linked list. The only difference was that instead of starting from a fixed point, it now hashed the word to be searched first to find the linked list of all words with that hash value.
The initial hashmap gets built during compilation and stored in ROM. On startup, it gets copied to RAM so that it can be modified as more words are defined by the user. This took up 512 bytes (256 entries of 16-bit pointers) each in ROM and RAM, not an insignificant amount on a system with a 64 kilobyte address space. This trade-off was worth it though in my eyes, as it resulted in a speed increase of more than two times.
Conclusion
At this point, I had achieved my original goal of having a system that can compile its own source code while running on the ZI-28. The only limitation was that I still had to copy the code onto the SD card manually and it couldn’t write the compiled firmware to the ROM by itself. I started writing a code editor in Forth so that the source code could be modified on the system itself. By this point however, my enthusiasm for the project was starting to fade, as I had already accomplished the most interesting part. It also became obvious that while Forth was easier to write than assembly, it was still a lot more challenging than C and offered little convenience or safety to the developer. In addition, while I did have a functional system that I could write code on, everything was still quite slow.
Writing my own self-hosted Forth was one of my most challenging but also interesting and fulfilling projects. Modern software tools and languages offer a lot of convenience and allow you to build complex systems very quickly. The downside however is that they build on so many layers of abstractions that it is almost impossible to fully understand them. This project offered me an opportunity to explore the minimum amount of complexity required to build an interpreter and compiler.
While I tried to highlight the most interesting challenges I faced and solutions
I came up with in this article, obviously I still had to leave out a lot of the
details. If you’re interested, the code can be found in the ZI-28
repository under the forth
directory. It’s
not documented, so feel free to reach out to me if you have any questions.