The smallest z80 assembler I can find is Kroc's v80, which is a table driven assembler that weighs in at 6.7 KB according to the author. I'd give you a link, but he's deleted the entire repo as an act of anti-AI defiance. Oh dear. (Ironic that I only discovered v80 through the AI search thingy at the top of google search).
But if his post is to be believed, AntForth is 1.8 KB off the pace.
I'd noticed that there was a lot of duplication in outputting console messages, with a lot of places in the code setting up their own BDOS calls. In addition it seemed like there was a lot of duplication, particularly around stacking registers, that might benefit from factoring out the common functionality (which is a very Forth thing to do!).
I assembled the LLM team and set them to finding opportunities to reduce the code size. We created a new epic 6 just for optimisation, covering the stdout type stuff I already mentioned, and various 'peephole' optimisations around refactoring code. They came up with an ingenious LD, rework on their own.
At every story in this sprint the assembler remained fully functional whilst gradually reducing in size. No regressions at all.
While doing the retro at the end of sprint 6, it occured to me that much of the complexity that remained in LD, (and in many other words) was to do with stacking registers while we juggled them for other purposes - something that the alternate register set would be ideal for, and which we hitherto had not used at all.
Cue epic 7 (only 3 stories this time) for shadow register optimisations.
During the retro for epic 7, a few more optimisations were identified, including that we hadn't taken advantage of EX AF, AF' either, so another epic (8) was created, with 4 short stories this time around.
TL;DR - how big is it now?
After 14 sprints-worth of refactoring, the final binary size is:
** 13.7 KB **
That means our assembler is 6.9 KB, so we're roughly 200 bytes larger than the most compact assembler on record - 1.5 KB smaller than the original implementation!
MVP is done! Before starting to think about additional phases and new features, I wanted to do a bit of optimisation for code size, in part prompted by the tentative offer of putting AntForth into the MicroBeast's romdisk - where space is at a premium.
When we made
The last story in the current epic is a review of our compliance to the ANS Forth "Core" wordset.
The result: 72.2% compliance
Some of this is deliberate omission: I chose to exclude double precision cells and Forth's quirky number formatting system from MVP.
Others
This sprint brings us the MARKER word which lets us undo word definitions. The way it works is, you type MARKER foo and then go about your business, defining words and variables etc. At some future point you can type foo and it will restore the dictionary to the state
It's all been a bit "jazz improv" recently as we fought to get ourselves to 100% coverage of z80 opcodes. Hopefully we are now back on track as we queue up /bmad-bmm-create-story 5.1 - finally, the elusive comments!
We kick off development with /bmad-bmm-dev-story 5.
The last sprint (5.0) was a survey to discover how good our z80 opcode coverage, and the results were not so great, with only 86% opcode coverage. So we created a new urgent story story to fix the omissions (5.0.5) and scheduled it for immediate execution.
Implementation
The previous sprint marked the end of epic 4 ("let's add an assembler!"), so I held a retro with the LLM dev team to get a few things off my chest before we move on:
* the LD, register ordering issue was annoying and could have been
There's both an annotated and an un-annotated version in the 'examples' folder, because I just realised that we've come this far without implementing comments... it's not even in the plan! We'll address that in the next retro, which is coming up as we're now at the end of Epic 4.
In this sprint we'll be getting the remainder of the z80 opcodes. These are the "extended" opcodes prefixed by 0xcb, 0xdd , 0xed and 0xfd bytes.
The 0xcb set gives us all of the bit set/clear/test instructions, as well as a few shifts and rotates.
It's an entire sprint of work that we had anticipated, but it's worth nailing this down now before we get to 4.4 which introduces all the extended opcodes, including all the indexed addressing modes, bit operations, and IO. The old scheme wouldn't have coped.
Implementation and code review went smoothly, so let's carry on where we left off.
# looks slightly different:
The assembler is now able to warn you if you forget a #:
There are a bunch of new utility routines for our new tag scheme:
And our condition words get compiled into tags like this:
We also gained ADD,, SUB,, AND,, XOR,, OR, and CP, which all share the same implementation:
RET,, CALL,, JP, and JR, now have full implementations. The DW and DB words got updated to handle the new tagging scheme also.
Testing
Let's take it out for a spin! We'll use our new words to write this definition:
\ >upper — convert ASCII lowercase to uppercase, in Z80 machine code.
CODE >upper ( c -- C )
LABEL done \ forward-reference label (declared before opcodes)
A C LD, \ A = character (low byte of TOS)
97 # CP, \ compare A with 'a'
CS done JR, \ carry set → below 'a', skip conversion
123 # CP, \ compare A with 'z' + 1
NC done JR, \ no carry → above 'z', skip conversion
32 # XOR, \ toggle bit 5: lowercase → uppercase
done FIX \ ← both JR, targets resolve here
C A LD, \ store result back in C (low byte of TOS)
NEXT,
END-CODE
\ Try it:
104 >upper EMIT \ h → H
90 >upper EMIT \ Z → Z (already uppercase)
53 >upper EMIT \ 5 → 5 (not a letter)
In this sprint we'll be getting the bulk of the z80 opcodes including 16 bit loads, conditional jumps, CALL and RET.
We /bmad-bmm-create-story 4.3 to kick things off. Whilst creating the story the agent noted that we have a namespace clash: we can't use C
The more contemporary CollapseOS has quite a peculiar assembler architecture, and for labels they also use the VALUE approach, but with a bunch of special Forth words for using them as a backwards reference (BR) , forward reference (FJR) and for setting them LSET.
To avoid using dict memory in compilation targets, we predeclare label variables here, which means we have a limited number of it. We have 3: L1, L2, L3.
You can define your own labels with a simple "0 VALUE lblname", but you have to do so before you begin spitting opcodes.
It's better, but it's still a bit clumsy. But there are some good ideas we can swipe.
In the end, we came up with this:
a LABEL word that declares a label name, must be start of CODE block
LABEL foo makes foo a plain-old-Forth word that knows how to handle itself (more later)
to define a label we use a new FIX word. foo FIX means "label foo now points to this memory location"
new words like foo get cleaned up during END-CODE so that the system dictionary isn't poluted. They are local to this CODE block.
The onus is on the user to remember to pre-declare labels and to FIX them precisely once, but both of these are enforceable by the interpreter.
I also like that you end up with a mini declarations block at the start of the code block:
I am also particularly pleased with the choice of the word FIX: it has a natural double meaning that fits perfectly. "Fix this label to the current position" and "fix up any pending forward references". Both meanings are simultaneously true whenever you call it, which is exactly the kind of semantic compression Forth is all about.
Best of all, we didn't introduce any hacky nonsense into INTERPRET, one of the most highly used words in the whole interpreter, avoiding a guaranteed source of regressions in the future.
After 4 or 5 design iterations, we could finally let development proceed. Code review identified the usual test gaps, and a bunch of copypasta that was quickly refactored.
assembler.asm
LABEL is the big new word in this file. It's a big routine, so I'm not going to paste it all here, but here's the header:
LABEL does a lot of work:
it creates a "label slot" in the dedicated label sub-dictionary and temporarily redirects HERE to point to the new entry
it writes a code body for the new word that will push the word's "label tag" onto the stack
it remembers a bunch of dictionary hash bucket state so that it can unlike itself on END-CODE
it links its definition into the hash bucket chains so that the word can be located by FIND.
it restores HERE, which hasn't changed because the system dictionary is unmodified.
FIX is a little more succinct:
FIX pulls a "label tag" from teh parameter stack (previously pushed there by the invocation of a label word). It uses the tag to find the slot for the label, and sets its status to resolved with an address equal to HERE. If there are any outstanding "fixups" for the label (a "fixup" is an opcode that referenced the label before it was FIXed) then those are relocated.
Now's probably a good time to mention some limitations of our implementation. Each CODE block:
can have a maximum of 16 labels
can have a maximum of 32 fixups
Here's some example code:
CODE TBLDEMO
LABEL OVER
OVER JR,
1 DW,
2 DW,
3 DW,
OVER FIX
NEXT,
END-CODE
and here it is in action:
The JR word takes a target off the stack that is either a label reference or a 16 bit address literal. It emits the relevant opcode, and if it's a label reference and the label is unresolved it queues a "fixup" for when the label is finally FIXed:
DB is the classic "define a byte":
and DS is the classic "define space":
DW is a little more complex, because unlike DB it will accept either an immediate constant or a label tag on the top of the stack, which lets you do things like:
Of course, the label might be unresolved, in which case a "fixup" for it needs to be queued.
Finally we have the innocuous looking EQU:
EQU has an important restriction: you can only use it outside code blocks. You may think I've taken leave of my senses, but the usage is quote manageable:
EQUs are still close by, just not in the code block. The reason is, they are implemented using the standard Forth CONSTANT machinery, which compiles words, and we don't want Forth words in the middle of our pure, unsullied machine code - it's the same problem that labels faced, but here we can solve it by simply moving EQUs (which are constant anyway) out of the CODE block, and then we don't need all the intricate side-dictionary/fixup mechanisms that labels required.
The interpreter will warn you if you forget the rules:
Not gonna lie, this was the hardest design challenge in AntForth to date. What started as simple whimsy ("just add labels!") turned out to be quite problematic.
The difficulty is in reconciling the sort of two-pass assembler label behaviour that we're used to (think sjasmplus) with
Many original Forths included an inline assembler for their host platform (or even for some other target platform) - one of the reasons that Forth was so popular for bringing up new hardware.
We're going to do the same thing, and we'll base our implementation on
The previous blog post marked the end of epic 3 development, so the "team" had another retro and we all agreed that everything had gone swimmingly. Next up is a built-in assembler! This was a crucial feature of early Forths and was
one of the main reasons Forth
compiler.asm
First up, we have COMPILE, which does the same thing as , from memory.asm - in fact here they are side by side:
The reason for the duplication is that the ANS Forth spec makes a specific distinction between the two: , is a general-purpose memory store ("compile