Recently Written · git

forthlisp

Scheme on a from-scratch Forth on from-scratch machine code: core (host Lisp, Scheme in Forth, R7RS harness)

git clone https://github.com/equwal/forthlisp

Log | Files | Refs


NOTES.md (17563 bytes)

1 # Assembly -> SBCL breadboard -> Forth -> Lisp on the STM32F446
2 
3 One emulated STM32F446 (Cortex-M4F, 128 KiB RAM, 512 KiB flash). Every layer is built here,
4 from Thumb-2 machine code up; no third-party Forth (Mecrisp-Stellaris, used before
5 2026-10-04, is gone, and with it the GPLv3 question). All published numbers are measured
6 on this stack: `asm-tests.txt`, `kernel-tests.txt`, `conformance.txt`.
7 
8 ```
9  4  LISP     R7RS-small subset Scheme, written in Forth words (lisp.fs), loaded through
10              the Forth console; prims.lisp + prelude.scm on top.         tests: r7rs-tests.scm
11              ------------------------------------------------------------------------
12  3  FORTH    our own indirect-threaded Forth kernel on the chip: primitives in Thumb-2,
13              outer interpreter in Forth (kernel.fs), dictionary, serial console.
14                                                                          tests: forth-tests.txt
15              ------------------------------------------------------------------------
16  2  SBCL     breadboard on the host: a Thumb-2 assembler on SBCL's sb-assem segments,
17              VOP-style primitive templates, a metacompiler for kernel.fs, image writer.
18                                                                          tests: asm-tests (vs GNU as)
19              ------------------------------------------------------------------------
20  1  ASM      Thumb-2 / ARMv7-M instructions (and FPv4-SP for floats)     tests: byte-exact encodings
21 ```
22 
23 Each layer has its own tests, run in this order: assembler encodings (byte-identical to
24 `arm-none-eabi-as`), kernel words over the serial console, then the Scheme conformance suite.
25 
26 ## Plan
27 
28 ### Layer 2: the SBCL breadboard
29 
30 Paul Khuong, "SBCL: the ultimate assembly code breadboard"
31 (https://pvk.ca/Blog/2014/03/15/sbcl-the-ultimate-assembly-code-breadboard/), writes machine
32 code from the SBCL REPL with SBCL's own assembler (`sb-assem` segments, `inst`, `define-vop`)
33 and inspects it with `sb-disassem`. SBCL's backend targets the host (x86-64), so
34 its instruction definitions and `define-vop` cannot emit Thumb-2, and a VOP cannot run on the
35 host. The decision:
36 
37 - **Reuse `sb-assem` for what is ISA-neutral**: segments (`make-segment`, `emit-byte`),
38   labels (`gen-label`, `emit-label`, `label-position`), and back-patches (`emit-back-patch`)
39   for forward references, branch offsets and absolute cells. Checked on SBCL 2.6.9.
40 - **Write the Thumb-2 encoders as plain Lisp functions** over those segments, one per
41   instruction form, and test each form against `arm-none-eabi-as` byte for byte.
42 - **VOP-style templates for primitives**: `defcode` takes a Forth name and a body of
43   instructions, lays down the dictionary header, and appends `NEXT`, the way a VOP pairs
44   a name with a code template.
45 - The host also metacompiles the high-level kernel (`kernel.fs`) into threaded code, links
46   every reference with labels, and writes the flash image.
47 
48 ### Layer 3: the kernel
49 
50 - **Threading: indirect-threaded code (ITC).** A colon definition is a list of execution
51   tokens (cell addresses), so the on-chip compiler only appends cells and never encodes
52   instructions. The cost is one extra load per word, which is acceptable for an interpreter host.
53 - **Registers:** r4 = IP, r5 = data stack pointer (full descending; holds the second item),
54   r6 = TOS, r7 = W (current xt), sp = return stack, r0-r3 and r12 scratch.
55 - **NEXT:** `ldr r7,[r4],#4 ; ldr r0,[r7] ; bx r0`.
56 - **Header:** link (4 bytes), flags (bit 7 immediate, bit 6 hidden), name length, name,
57   pad to 4, then the code field (CFA), then the body. An xt is a CFA address.
58 - **Memory map:**
59   - Flash `0x08000000`: vector table, primitives, kernel headers and threads (read-only).
60   - RAM `0x20000000`: system variables and the 512-byte input buffer, then the user dictionary.
61   - Data stack: 4 KiB below `0x2001EFE0`.
62   - Return stack: 4 KiB below `0x2001FFE0`. QEMU's netduinoplus2 breaks on writes to the last
63     32 bytes of RAM, so the stacks stop short of them.
64 - **Console:** USART2, polled, 115200 baud from the 16 MHz HSI. It is QEMU's second
65   `-serial`. `accept` treats Backspace and Delete alike.
66 - **Compatibility:** the console protocol (` ok.`, `not found.`) and the word set that
67   `lisp.fs` uses (`variable` with an initial value, `buffer:`, `token`, `h@`, `cbis!`, ...)
68   follow Mecrisp, so layer 4 loads unchanged.
69 - **Flash writing:** the image is written by the host. A runtime `flash!` follows RM0390
70   (unlock keys, PG, PSIZE). QEMU does not model the F4 flash controller, so `flash!` is
71   untested in emulation.
72 
73 ## Influences: remote patching of running systems
74 
75 - **gll-mag-patch** (Ron Garret, JPL 1993; https://github.com/rongarret/gll-mag-patch).
76   Galileo's magnetometer ran Forth on an RCA 1802. A RAM byte had failed, and the original
77   development system no longer worked (README). Garret rebuilt the toolchain in Macintosh
78   Common Lisp:
79   - `forthcomp.lisp` rebuilds the flight dictionary from the ROM and RAM images and their
80     word index (`build-gll-dictionary`).
81   - `compiler-test` recompiles every recovered RAM word from `ram source.lisp` and diffs it
82     byte for byte against the flight RAM image. This proves the new compiler reproduces the
83     old one.
84   - `compile-patch` compiles a replacement word at the **old word's address**. It warns when
85     the patch overflows the old slot and prints only the bytes that differ. `Patches/delivered`
86     holds those address-plus-hex deltas, which are what was uplinked.
87   - `Patches/development` frees space by merging the slots of retired words (DSP12 and DSP3)
88     into one larger slot for the new word.
89   - `sim1802` simulates the 1802; `asm1802` is its assembler; `lisp->forth` and macros such
90     as `main-event-loop` generate Forth from Lisp.
91   - `Patches/test results` records the instrument team's independent review. They confirmed
92     the compiled bytes matched the source, then ran the logic on their own test setup and
93     found it still wrong (extra vectors at rim 0 and 90). Review caught the bug before uplink.
94   - The flight `MAIN` computes ROM and RAM checksums (`ram source.lisp`).
95 - **"Lisping at JPL"** (Ron Garret; https://flownet.com/gat/jpl-lisp.html).
96   - The magnetometer team had judged the patch too costly to attempt. A Lisp-built Forth
97     environment with a hardware simulator delivered it in under three months of part-time work.
98   - On Deep Space 1, a Lisp REPL running on the spacecraft let the team find and fix a race
99     condition that ground testing had missed, about 100 million miles away.
100   - Earlier robots used Lisp-hosted compilers for small targets, for example a 68HC11
101     with 256 bytes of RAM.
102 
103 What this design takes from them:
104 - The host holds the full development system: assembler, metacompiler, simulator-like tests.
105 - The target keeps a live interpreter, so code can be redefined over the serial link.
106 - The compiler is proven by reproducing known-good bytes, as `compiler-test` did.
107 - Patches are small, addressed, size-checked deltas. Each one is reviewed and tested off the
108   target before it is sent.
109 
110 ## Status
111 
112 | Layer | Test | Result |
113 |---|---|---|
114 | 1-2 assembler | `asm-test.lisp`: every encoder form vs `arm-none-eabi-as` (incl. FPv4-SP), plus repeated label references | 105/105 |
115 | 3 kernel | `forth-tests.txt` via `host.lisp ktest`, fresh boot | 71/71 |
116 | 4 Scheme | chibi-scheme's R7RS suite, `tests/r7rs/`, via `host.lisp suite` | see `tests/r7rs/results.txt` |
117 | 4 Scheme | our own regression suite `r7rs-tests.scm` via `host.lisp test` | 390/390 |
118 
119 - Kernel image: about 10.4 KiB of flash, 197 words (FPU words `f+ f- f* f/ s>f f>s fsqrt f< f=`).
120 - RAM after `lisp.fs`, `prims.lisp` and `prelude.scm` load:
121   - cons heap 60 KiB (7679 cells);
122   - blob heap 12 KiB;
123   - symbol names 10 KiB;
124   - 5.9 KiB of dictionary free (`unused` = 5928).
125   The prelude grew with item 4. At 48 KiB of heap, the 20000-iteration tail-call test spent
126   most of its time in GC.
127 - The kernel prints `Redefine NAME.` when a definition shadows an existing word, and `host.lisp`
128   stops the load on it. A list-length word named `llen` once silently captured the reader's
129   `llen` variable in every later word.
130 - `lisp.fs` loaded unchanged: the kernel follows Mecrisp's console protocol and word set.
131 - Gotcha: sb-assem's `emit-back-patch` (SBCL 2.6.9) resolved only the first of several adjacent
132   references to one label, which left zero cells in the vector table and `li` loads.
133   `asm.lisp` now keeps its own fixup list; `asm-test.lisp` has a regression case for it.
134 - A Z80 machine built the same way (own Z80 kernel, SBCL Z80 assembler, z80pack cpmsim)
135   lives in `z80/`; see `z80/NOTES.md`.
136 ## Conformance
137 
138 **Target: the full R7RS-small suite of chibi-scheme** (Alex Shinn; `tests/r7rs-tests.scm`,
139 1132 test calls; BSD licence, kept in `tests/r7rs/LICENSE`), vendored unmodified in `tests/r7rs/`.
140 - `host.lisp suite` splits the file into top-level forms and sends each one to the chip as one
141   line (the longest is 454 bytes; the line buffer is 512).
142 - `shim.scm` defines `test`, `test-assert`, `test-begin` and `test-end` as chip procedures that
143   print a pass or fail marker.
144 - `skip.scm` lists what cannot run yet, by section, test head, text or number-literal kind,
145   each with a reason. A test that uses a name whose definition was skipped is skipped with
146   that reason too. `results.txt` counts the skips per reason; no test is deleted.
147 - A form that errors counts all of its unreported tests as failed. If the chip stops
148   answering, every later test counts as failed.
149 - First run on the own stack: 244 passed, 78 failed, 810 skipped, 1132 total. After item 1 (numbers): 302 passed, 119 failed, 711 skipped. After item 2 (characters,
150   strings, vectors, bytevectors): 486 passed, 123 failed, 523 skipped. After item 3 (sqrt,
151   rounding): 500 passed, 109 failed, 523 skipped. After items 4 and 5 (macros, call/cc,
152   dynamic-wind, exceptions, raisable primitive errors, values, promises, parameters, records):
153   603 passed, 140 failed, 389 skipped. It found two
154   bugs, now fixed and covered by local regression tests: `list?` looped on a circular list,
155   and `case` lacked `=>`.
156 
157 Our own suite below stays local: a reported sieve program, string cases and those regressions.
158 
159 `r7rs-tests.scm` holds 205 cases, most of them the examples of R7RS-small sections 4.1, 4.2, 5.3, 6.1-6.5, 6.7, 6.8 and 6.10, a 20000-iteration tail-call loop, and a regression case: a multi-line Sieve of Eratosthenes with a docstring, vectors, `do`, `when` and named let. `host.lisp test` sends each to the emulated chip and compares the printed result. Last run: see `conformance.txt` (205 passed, 0 failed).
160 
161 ### Supported
162 
163 - Special forms: `quote` `'x` `if` `define` (variables, procedures, internal defines) `set!` `lambda` (fixed, dotted and rest parameters) `begin` `let` (and named let) `let*` `letrec` `letrec*` `cond` (with `else` and `=>`) `case` `and` `or` `when` `unless` `do`.
164 - Data: exact integers, strings (escapes `\"` `\\` `\n` `\t`; may span lines), vectors (`#(...)`), symbols, pairs and lists, `#t` `#f` (also `#true` `#false`), `'()` distinct from `#f`, procedures.
165 - Procedures: `+ - * quotient remainder modulo = < > <= >= eq? eqv? equal? car cdr cons set-car! set-cdr! null? pair? list? symbol? number? integer? exact? exact-integer? inexact? boolean? procedure? apply display write newline string? vector? list->string list->vector make-vector vector-ref vector-set!` and, from `prelude.scm`, `not caar cadr cdar cddr caddr list length reverse append list-tail list-ref list-copy make-list map for-each memq memv member assq assv assoc zero? positive? negative? odd? even? abs max min gcd lcm square boolean=? symbol=? string->list string-length string=? string-append vector vector->list vector-length floor ceiling round truncate exact inexact sqrt exact-integer-sqrt`.
166 - Proper tail calls in `if`, `cond`, `case`, `and`, `or`, `when`, `unless`, `let` forms, `begin`, `do` and procedure bodies.
167 
168 ### Deviations from R7RS-small
169 
170 - Numbers: exact integers are 31-bit fixnums; a result outside -2^30..2^30-1 raises `overflow`
171   (no bignums; 64-bit products are checked with `m*`). Exact rationals are normalized fixnum
172   pairs. Inexact reals are IEEE **single** precision on the M4F FPU (R7RS expects double):
173   about 7 significant digits. Printing uses 7 digits and the last one can be off by one, so
174   output need not read back to the same float. No complex numbers, no `exp`/`log`/trig yet.
175 - Characters are bytes (Latin-1): no Unicode, since the serial console and the string storage
176   are 8-bit. Strings, vectors and bytevectors live in a 16 KiB blob heap, with O(1) access.
177   The GC compacts live blobs. Missing from the string library: `string-fill!`, `string-copy!`,
178   case conversion of whole strings, and `-ci` string comparisons.
179 - `sqrt` is exact for exact squares (`(sqrt 1/4)` is `1/2`) and inexact otherwise. A negative argument gives `+nan.0`, because there are no complex numbers. `exact-integer-sqrt` returns a list `(s r)` until multiple values exist. `floor`, `ceiling`, `truncate` and `round` (half to even) handle rationals and floats. No `exp`, `log` or trigonometry yet.
180 - Macros: `define-syntax`, `let-syntax`, `letrec-syntax` and `syntax-rules` (with literals, `_`,
181   nested ellipses, custom ellipsis, `(... ...)`) are **not hygienic**. A template symbol means
182   whatever it means where the expansion lands. `quasiquote`, `delay`, `delay-force`, `guard`,
183   `parameterize`, `define-record-type`, `case-lambda`, `let-values` and `define-values` are
184   procedural macros in `prelude.scm`. `let-values` binds left to right, like `let*-values`.
185 - `call/cc` is **escape-only**. A continuation works while its `call/cc` is still active.
186   Calling it later is an error. There is no re-entry and no generators. `dynamic-wind` runs the
187   *after* thunks when an escape leaves its extent.
188 - Exceptions: `raise`, `raise-continuable`, `with-exception-handler`, `guard`, `error` and
189   error objects. Errors from primitives (`car` of a non-pair, a vector index out of range, an
190   unbound variable, ...) raise error objects when a handler is installed. Uncaught errors print
191   their message and irritants and return to the prompt. A `guard` with no matching clause
192   re-raises in the dynamic environment of the `guard`, not of the original `raise`. `too deep`
193   and `out of memory` are never raisable, because recovering needs the stack or heap they
194   report as exhausted.
195 - Symbols are never garbage collected. Their names share a 10 KiB buffer.
196 - No libraries (`import`) and no ports beyond the console yet.
197 - Primitives check their argument count (a table in `prelude.scm`); a wrong count raises
198   `wrong number of arguments`. Closures check theirs (`too few arguments`, `too many arguments`).
199 - Recursion depth is bounded by the 4 KiB stacks: non-tail recursion raises `too deep` between 150 and 200 levels (measured with `(d 150)` and `(d 200)`).
200 - `define` returns an unspecified value. The REPL echoes the name of a top-level `define` or
201   `define-syntax` as a convenience; the name is not the value.
202 
203 ## Implementation
204 
205 - Values are 32-bit. Low bits: `xx1` fixnum, `000` pair (0 is `'()`), `010` symbol, `100` immediate (`#f` `#t`, unspecified, unbound, special-form markers), `110` procedure. Cells are 8 bytes (car, cdr) in a 64 KiB heap (8191 cells).
206 - A symbol's cdr is its global value. A special form name is bound to a marker value, so a local variable of the same name shadows it.
207 - An environment is an alist in front of the globals. Each procedure call and `let` adds a dummy binding first, so an internal `define` can splice its binding in after the head without touching the enclosing scope.
208 - `eval` is a loop: special-form handlers return either a value or a new (expression, environment) pair, which is how tail calls run in constant stack.
209 - GC: mark-sweep, run when `cons` finds the free list empty. Roots are the symbol list and every word on the data and return stacks that looks like a heap reference (conservative scan), so a GC inside a running expression is safe.
210 - Reader: lines come from Mecrisp's `accept`, which handles Backspace (0x08) and Delete (0x7f) and echoes the erase; the reader then parses from that line buffer (512 bytes). An expression may span lines.
211 
212 ## Serial driving
213 
214 The host sends one character at a time and waits for the echo, then waits for ` ok.` (Forth) or `> ` (Scheme). The emulated USART has a one-byte receive register.
215 
216 ## Files
217 
218 - `host.lisp` -- compiler and serial driver: `compile FILE | load | demo | test [FILE] | eval EXPR... | forth LINE...`.
219 - `lisp.fs` -- the chip Scheme: values, GC, symbols, reader, printer, eval, special forms, Forth-side primitives, REPL.
220 - `prims.lisp` -- arithmetic, comparison and type primitives in the host dialect.
221 - `prelude.scm` -- library procedures in Scheme, one form per line.
222 - `r7rs-tests.scm`, `conformance.txt` -- the conformance subset and its last result.
223 - `demo.lisp`, `demo.sh`, `transcript.txt` -- scripted proof of all three layers.
224 - `build-firmware.sh` -- the F446 Mecrisp build. The image is not committed; the launchers build it when missing.
225 - `stm32-lisp`, `stm32-forth` -- launchers (QEMU netduinoplus2).
226 
227 ## Sources used
228 
229 - Ron Garret, gll-mag-patch (`forthcomp.lisp`, README) and "Lisp at JPL" (flownet.com/gat/jpl-lisp.html).
230 - R7RS-small report (r7rs.org) for the forms, procedures and test examples.
231 - Mecrisp-Stellaris 2.6.5 source (`common/forth-core.s` stack sizes, `common/query.s` accept, `stm32f407/` port).