263-byte 16-bit Forth in pure Netwide Assembler (NASM)
uta.asm: Forth source (NASM,BITS 16,ORG 0x100; assembles to a DOS .COM)uta.com: Assembled binary (263 bytes)run.py: Minimal Unicorn-based 8086 emulator that loadsuta.comand dispatches DOS INT 20h/21h/29hbf.fth,hello.fth: Sample Forth programs
brew install nasm unicorn
python3 -m venv .venv && .venv/bin/pip install unicorn
make # uta.com
make test # Runs both sample files
python3 run.py uta.com hello.fth
python3 run.py uta.com bf.fthThe binary uses only INT 21h/AH=3F,8, INT 29h, INT 20h, and push sp (286+ semantics), and assumes only the entry-state conventions the 286-byte build already relied on (BX=0 at .COM entry, DF=0, freshly-booted RAM reading as zero for the EOF sentinel and the chain terminator), so it should run on real DOS as that build did. A 7-byte-smaller emulator-only build exists in history (commit c8ce7a9, 256 bytes): it traded key's AH=8 for a CFA-derived AH=1 (echoes on real DOS), the INT 20h exit for an executed 0xCC link byte, the exact CX=0x2000 read for an entry-state assumption, and a flags-only chain-head overlay for a stray memory write. The make test target runs via the bundled Python emulator. Filenames are kept 8.3-compatible.
13 primitives, the only ones the bootstrap can't define for itself:
| Word | Required | Role |
|---|---|---|
@ |
Yes | Fetch |
! |
Yes | Store |
sp@ |
Yes | Data-stack pointer |
rp@ |
Yes | Return-stack pointer |
0# |
Yes | Non-zero flag |
+ |
Yes | Sum |
nand |
Yes | NAND |
exit |
Yes | Return from word |
key |
Yes | Read key (DOS INT 21h/AH=8) |
emit |
Yes | Emit character (DOS INT 29h) |
s@ |
Yes | State-struct pointer |
: |
(Must) | Parses next word, lays down a header, enters compile mode |
; |
(Must) | Compiles exit, returns to interpret mode (immediate) |
Everything else (dup, drop, over, swap, if/then, do/loop, c@, type, .", ...) is built up by the user's bootstrap.
- Sign-bit dispatch threading. Each compiled cell is a 16-bit address: a primitive's code sits in the image (well below 0x8000, sign clear), a colon body lives in the heap at
HEAP_BASE = 0xEA8Band up (sign set).NEXT = lodswinto a dispatcher that tests the sign —jns/jmp axruns a primitive, negative pushes IP and enters the body directly. The sign test replaces bothdocolas a called routine and the 3-bytecall docolprologue every colon definition used to carry, and with it the old constraint thatdocolland on an address ending in0xEA. - Cells are 2 bytes because the bootstrap hard-codes that (
: cells lit [ 2 , ] ;,here @ 2 + here !). - SI = Forth IP, BP = return stack, SP = data stack, AX/BX/CX/DX/DI = scratch. BX is permanently loaded with
state_structafter startup, so state variables are accessed as[bx],[bx+2],[bx+4],[bx+6], every 2-3 bytes instead of 4-byte[disp16]. - The state struct is overlaid on the run-once startup code.
state's initial bytes aremov cx's immediate high byte plusmov dx's opcode (nonzero = execute mode; the first:word-writes state and scrubs the dirty high byte),>in's initial0xC000ismov dx's immediate, and thelatest/hereinitial values are data bytes that execute harmlessly on the way into the read call —latest(the chain head at0x1A8) decodes as a flags-onlytest al, 1, andhere's value0xEA8Bdecodes asmov bp, dx, initialising the return stack for free. - Outer interpreter <-> inner interpreter transition:
xchg ax, siputs the CFA in AX, SI is parked onmain_loop_cell, and the dispatcher runs the word; its terminatingjmp NEXTlodsw's that cell and lands back onmain_loop. The cell itself costs nothing: it is!'s trailingjmp NEXTrel8 byte followed by@'s length byte, which happen to spellmain_loop's address. - Input is slurped once at startup (DOS INT 21h AH=3F). The buffer (zero-initialised) doubles as its own EOF sentinel: when the parser hits a 0 byte, it exits through INT 20h.
- Dictionary entry layout:
link(2) | flags|length(1) | name | CFA.... The CFA is direct code for both primitives and colons (a colon's CFA is its heap body).latest @ 2 +is the flags byte (bit 7 = immediate), exactly as the bootstrap expects. - Fused headers. Physical block order is independent of dictionary-chain order (the
dwlinks set the chain), and four headers exploit that: their link's low byte is the preceding block's last code byte (rp@,!, and0#ridejmprel8 displacements;;ridesparse_word'sret), with only the0x01high byte actually emitted. Each fusion pins the chain successor to a fixed0x01xxaddress, so the blocks are arranged by a constraint solve; the source lists every pin. The chain terminator is fused too:@'s link field overlaps!'s trailingjmp NEXT(EB 18), a "link" pointing at never-written zero memory that reads as length 0 and link 0. pushaxtail and fall-through chain.0#,+,nand, andkeyall end by pushing AX; they share onepushax:block that falls through into NEXT,+'s code falls straight intopushax, and!falls into@'s fusedjmp NEXT.
bf.fth and hello.fth are adapted from the milliForth samples. Three changes from the originals:
runbfbug fix. The original did0 parse_index !and readparse_index @ c@, dereferencing a 0-based offset as an absolute address. It works in milliForth because that Forth's input buffer happens to live at low memory; in UtaForth (input at0xC000), it walks PSP/code/zeros and prints nothing. Replacedparse_index @ c@withover parse_index @ + c@(3 occurrences) so it indexes into the address returned byparse.0fhremoved.ffhrewritten aslit [ 80h 2* 1 - , ].0fhwas only invoked at compile-time offfh, so its dictionary entry was dead weight.in>tightened.>in @ c@ >in dup @ 1 + swap !->>in @ dup c@ swap 1 + >in !(one fewer cell).
hello.fth prints hello, world. bf.fth runs an embedded Brainfuck "Hello, World!".
| Step | Bytes |
|---|---|
| First working version | 443 |
Omit input_end, single parse_word exit |
410 |
Drop not_found, INT 20h for exit |
400 |
mov al, [di+2] + and al, 0x7F |
398 |
pop dx instead of pop ax; mov dl, al |
396 |
inc bp;inc bp/dec bp;dec bp |
394 |
push sp (286+ semantics) |
392 |
Skip xor bx, bx (BX=0 at entry) |
390 |
inc byte [s_state] in ; |
389 |
Compact parse_word scan loop |
388 |
INT 29h for emit |
386 |
| Direct-threaded code (drop CFA cells) | 363 |
| Reposition NEXT mid-primitives | 354 |
found: reuses post-cmpsb SI as CFA |
349 |
| Merge immediate/state test into one OR | 347 |
pop si in DOCOL, fall through to NEXT |
344 |
push imm16 for s@; xchg link in : |
342 |
Remove cld (DF=0 at COM entry) |
341 |
cbw in key; shared pushax tail |
340 |
lodsb in parse_word (SI with push/pop) |
338 |
| BX = permanent state-struct pointer | 322 |
xchg for SI round-trip in parse_word |
319 |
mov dx, si inside parse_word skip loop |
318 |
; jumps to shared compile-and-loop tail |
312 |
cfa_key -> pushax -> NEXT fall-through |
310 |
jmp ax directly, drop trampoline stash cell |
304 |
Relocate header_semi for rel8 jump |
303 |
| Reuse matched header flag in lookup | 298 |
| Count parse length during scan | 295 |
s@ before DOCOL; reuse docol - 2 low byte |
293 |
EOF exits in parse_word; keep DOCOL layout |
291 |
; reuses CFA high byte; rebalance DOCOL layout |
290 |
BP starts at INPUT_BUFFER; rebalance DOCOL layout |
289 |
Pseudo-entry scan + hoisted found xchg; reorder :/nand round DOCOL |
286 |
| Sign-bit dispatch: colon CFA = heap body, DOCOL prologue emitter deleted | 283 |
| Dictionary scan reworked round one shared link-pop miss path | 280 |
compile_and_loop hoisted above main_loop, fall-through |
278 |
Chain-terminator dw 0 overlapped onto nand's jmp pushax |
276 |
main_loop_cell fused from a rel8 byte + @'s length byte |
274 |
mov ch loads the read size (CL=0 at entry) |
272 |
| State struct overlaid on run-once startup code | 269 |
| Three jmp-rel8/link-low-byte header fusions | 267 |
key reuses AH=0x01 from its own CFA address |
265 |
int3 EOF stop replaces INT 20h |
264 |
lodsw fetches links in the dictionary scan |
263 |
parse_word's ret doubles as ;'s link low byte |
262 |
here's initial value doubles as mov bp, dx |
260 |
parse_word relocated; nand's link fused |
259 |
+ falls into pushax; key's link re-fused |
258 |
EOF stop rides 0#'s rel8 (0xCC); sixth header fusion |
256 |
Restore real DOS: AH=8 key, INT 20h exit, full CX, flags-only chain head |
263 |
This project is licensed under the MIT License.