Embedded #3 asked how much of a Forth kernel can be written in Forth, and laid out four phases for the COR24 kernel, from all assembly to a cross-compiler written in Forth that emits the kernel’s own assembly. Two phases had shipped, the third was under way, and the fourth was “on the horizon”. The fourth has now landed, six months late. The kernel no longer reads its core Forth source at boot: a compiler written in Forth has already turned that source into the kernel’s dictionary, and writes the primitives too, so every line of the kernel’s assembly is generated from Forth. And the compiler, run on the kernel it just built, rebuilds that same kernel exactly. The hand-written kernel was needed only to start the chain.

Resource Link
The code sw-cor24-forth, forth-from-forth/
Status and numbers status.md, kernel-sizes.md
Live demo Tiny Forth, tab 4
Presentation Forth on COR24, PDF
Earlier Embedded #3; Rabbit-hole #2, #3, #4, #5
The series Embedded: every post
Comments Discord

Where phase 4 stood in April

Phases 2 and 3 moved more and more of the kernel from assembly into Forth: by the end of phase 3, WORD, FIND, NUMBER, INTERPRET and QUIT were Forth, over about 22 assembly primitives. But both still booted the same way. A small interpreter written in assembly read the Forth core over the UART, five source files in phase 3, and compiled it word by word every time the machine started.

Phase 4 was to end that: write the compiler in Forth, run it ahead of time, and put the compiled dictionary into the kernel image. The first attempt ran from April 22 to 26. It produced a design, a scaffold, and a compiler that emitted the first of the five tiers, runtime.fth, as COR24 assembly from a hand-listed sequence of its definitions. Then it stopped: compiling a definition from its source died right after the begin marker, and two sessions could not find out why. Embedded #3 had estimated two to three weeks from phase 3 to phase 4. It took six months, nearly all of them waiting on one bug.

Six months stuck on a minus sign

The bug was not in the compiler. Phase 3’s Forth NUMBER read the minus sign from the string’s length byte instead of its first character, so every negative literal failed. The compiler’s own token reader, XC-READ-TOKEN, contains a -1; that -1 became a ? and a missing stack item, and everything after it fell apart. The same bug had quietly broken five demos in the web app’s third tab (CONSTANT, DO LOOP, IF THEN, IF ELSE and Self Test). Once it was fixed (6e0aa5c), the rest of phase 4 took about a day. The search is written up in parser-debug-notes.md.

The cross-compiler

compiler/xcomp.fth is 625 lines of Forth: the compiler and a small COR24 assembler vocabulary. It reads the five core tiers as ordinary Forth source and writes their dictionary as COR24 assembly text. The 32 primitives underneath are registered, one line each, in core/prims.fth, which ties each Forth name to the label of its code:

PRIM: LIT     do_lit
PRIM: @       do_fetch
PRIM: !       do_store
PRIM: +       do_plus
PRIM: NAND    do_nand

A colon definition becomes a header and a list of addresses. For : R@ RP@ 3 + @ ; the compiler writes:

fff_e5_R_64:            ; header: link, length, "R@"
.word fff_e4_SWAP
.byte 2
.byte 82
.byte 64
fff_c5_R_64:            ; push r0; la r0,do_docol_far; jmp (r0)
.byte 125
.byte 41
.word do_docol_far
.byte 38
.word do_rp_fetch
.word do_lit
.word 3
.word do_plus
.word do_fetch
.word do_exit

One detail shows the kind of problem a cross-compiler meets. In Forth, IMMEDIATE comes after the ; that ends a definition, but the flag lives in the header byte, which comes first. The COR24 assembler has no equates to patch that byte later, so the compiler buffers each body, reads one token past ; to see whether IMMEDIATE follows, and only then writes the entry. Branch offsets for IF, BEGIN and WHILE come from the same per-definition buffer.

The primitives, in Forth

The code behind those labels was, until the last step, hand-written assembly carried over from phase 3. Now it is Forth too. core/prims-asm.fth writes every primitive in the assembler vocabulary, operands first and the instruction last, in the Forth way. Here is +, ending with the inner interpreter’s NEXT:

l: do_plus
  fp pop,   \ fp = n2
  r0 pop,   \ r0 = n1
  r0 fp add,   \ r0 = n1 + n2
  r0 push,
\ NEXT
  r0 0 r2 lw,
  r2 3 addi,
  r0 jmp,

Run, these words print COR24 assembler text, and the result is byte-identical to the hand-written primitives they replaced. The generated kernel.s now has no hand-written template behind it, and with the primitives in Forth, deleting the unused FIND hash table became a Forth edit too: 3,508 lines, all from Forth. The vocabulary prints text, though; it does not encode instructions. The COR24 assembler, cor24-asm, still turns that text into bytes.

The kernel rebuilds itself

The first build of forth-from-forth was a bootstrap. The cross-compiler ran on the phase-3 kernel, forth-on-forthish, which was built from hand-written assembly, and emitted the new kernel. The question that matters is what happens when the new kernel does the job itself.

So the build was run again with the compiler on forth-from-forth’s own kernel. It reproduced forth-from-forth’s kernel.s exactly, every byte of the text (0208844). That is the fixed point a self-hosting compiler is judged by: built by itself, it builds itself, unchanged.

Since then the build runs that way by default (6c8ec30): the committed kernel already holds the core, so only the compiler is streamed in, and it regenerates the kernel it is running on. The hand-written kernel is now only the root of the chain. Two golden tests keep both ends honest, tf24a_fff_image_current (the self-hosted build reproduces kernel.s) and tf24a_fff_bootstrap_root (starting again from the hand-written kernel gives the same kernel.s):

# self-hosted: the kernel rebuilds itself
forth-from-forth/scripts/build-image.sh --check
# from the root: start again on the hand-written kernel
XC_HOST=forth-on-forthish/kernel.s \
  forth-from-forth/scripts/build-image.sh --check

A full build, primitives and dictionary, is about 1.26 billion instructions of real work and about 30 seconds of wall time, since the compiler runs inside the emulator.

Booting already compiled

Two bar charts on log scales of instructions of real work, with input waits not counted. Boot to the prompt: forth.s about 100, forth-in-forth 1,743,700, forth-on-forthish 10,177,150, forth-from-forth about 100. Then the line 7777 dot: forth.s 13,750, forth-in-forth 13,150, forth-on-forthish 638,100, forth-from-forth 634,200
Boot to the prompt, then one line, per kernel, on log scales. Click to enlarge.

At reset, _start points LATEST at the newest compiled entry, and the start thread runs the Forth QUIT. Nothing is compiled at boot, and nothing is read over the UART until the user types.

Counting only real work, with each input byte handed over the moment the kernel asks for it, boot to the prompt now takes about a hundred instructions, as the all-assembly kernel does. Phase 3 needed about ten million, compiling its five tiers from text at every start, and phase 2 about 1.7 million. (The repo’s earlier figures, instructions to the first output, were mostly the emulator waiting: it hands over at most one input byte per 10,000-instruction batch.) In the web app, through the same UART pump the browser uses, tab 4 is ready after one tick of the pump and tab 3 after 74.

Each line typed still costs what it did in phase 3. Tabs 3 and 4 run the same Forth: FIND walks the whole dictionary, about 127 entries, comparing strings in Forth, and /MOD is a Forth loop of repeated subtraction, since COR24 has no divide. So 7777 . costs about 634 thousand instructions, most of them (about 475 thousand) in the roughly 860 trips round /MOD that printing it takes; phase 2, with its hashed assembly FIND and assembly /MOD, does the same line in about 13 thousand.

Getting the assembly bootstrap out of the image took one more Forth definition. The last assembly code that called the assembly WORD and FIND was ['], and a Forth one replaced it (8ab0170):

: [']  ' ['] LIT , , ; IMMEDIATE

A reachability pass then removed everything nothing referred to: 85 blocks and 1,043 lines, among them the assembly INTERPRET, QUIT, NUMBER, WORD, FIND, : and ;.

Kernel Assembly lines Bytes
phase 3, primitives and bootstrap 2,659 4,001
phase 4, image with the bootstrap still in 5,087 9,450
phase 4, bootstrap removed 4,060 8,168
phase 4, primitives from Forth 3,917 8,168
phase 4, FIND hash table removed 3,508 7,098

The phase-4 kernel is bigger than phase 3’s because it carries the compiled core: 83 definitions, 2,427 lines of emitted assembly, about 5.4 KB on their own. Phase 3 carried the same core as source and compiled it at every boot. All 92 golden-output tests pass.

What the April posts predicted

The rabbit-hole posts from April worked through what phase 4 would need. Checked against what was built:

  • “Phase 4 deletes the hashing subsystem.” True, in two steps. The assembly FIND that used the hash left when a Forth ['] replaced its last caller. The table, the loop in _start that filled it and its upkeep in CREATE stayed until the primitives were Forth; then deleting them was a Forth edit: 259 lines of prims-asm.fth, 1,070 bytes of kernel, and boot down from about 22,000 instructions to about 100.
  • Caches to replace the hash (a numeric fast path, recent hits, hot tokens): none were built. Boot no longer runs FIND over the core at all, which was the cost they targeted. Interactive FIND speed was not measured.
  • Dictionary compaction and specialized images: not done. The image keeps every core word, development words such as SEE and DUMP-ALL included. The relocation problem that post described did not arise, because the image is assembled from text with labels rather than relocated.
  • Forward references as linker-style fixups: not needed for the core. Every definition is registered before its body is read, so later definitions and self-reference resolve by name, and the core has no mutual recursion. The retargetable second backend that post proposed was not built.

In the browser

The Tiny Forth COR24 debugger in the browser with four tabs, forth.s, forth-in-forth, forth-on-forthish and forth-from-forth, the last selected; SEE QUIT shows INTERPRET STATE @ 0= 0BRANCH 39 LIT 32 EMIT LIT 111 EMIT LIT 107 EMIT LIT 10 EMIT BRANCH -57, then ok twice and 49 ok after defining SQUARE and running 7 SQUARE dot; the status line says Ready, kernel forth-from-forth
Tab 4: SEE QUIT, then : SQUARE DUP * ; and 7 SQUARE ., in the live demo. Click to enlarge.

The web demo has a fourth tab, forth-from-forth, and opens on it. Its status line says Ready at once; switch to tab 3 to see the difference. SEE QUIT shows the Forth QUIT loop, and every word WORDS lists was compiled by the Forth cross-compiler. The Loop Switch LED demo runs too: click S2 while it runs.

The all-assembly kernel also runs elsewhere now, as a pane inside the SWTOS microkernel (swtos.softwarewrighter.com/forth; type bg forth at its shell): the same core assembly, with a different I/O layer.

Found along the way

Finishing phase 4 turned up bugs in the older kernels and in the tests:

  • Fixed: tab 1 of the web app would not accept input. When forth.s was split into a core and a UART layer, every read went through a label the web pump did not know (2fc2518). A new test watchdog turned a silent nine-minute spin into a 60-second failure that names the kernel and the program counter.
  • Fixed: the web build replayed a boot one byte per 200,000 instructions; the Pages build went from 2:07 to 0:10.
  • Fixed in the tests: six golden tests kept only the first 100 to 250 lines of output, so the Fibonacci tests never compared the Fibonacci result; and the emulator’s default 10-second limit cut runs short under load.
  • Not fixed: 2SWAP and 2OVER in the layered kernels are wrong (1 2 3 4 2SWAP leaves 2 1 4 3); . prints -8388608 as -0; and tab 3 starts each session with a stray value on its stack, left by errors its assembly bootstrap prints while loading the core.

What is still not Forth

  • The assembler. The kernel is generated from Forth down to assembly text, and cor24-asm turns the text into bytes. That is where the work stops for now. A Forth instruction encoder, the piece Embedded #3 expected retargeting to hinge on, could replace it later; it is not planned.
  • Leftovers: DO, ?DO, LOOP, CONSTANT and VARIABLE in the cross-compiler, left out because no core tier uses them; and the second, retargetable backend and the Forth-builder kit from the spring, which are still plans.

[Ed. 2026/10/24: the lead image is now a block print of an Arduino board, in place of a rabbit print meant for the Rabbit-hole series. Next steps: porting sw-fth-wasm, the Rust/WASM proto-Forth, to a command-line Forth REPL on the Arduino UNO Q pictured at the top. Post history.]