From 125af552ff1003016d76f1efff4e95af1aefe93a Mon Sep 17 00:00:00 2001 From: Irmen de Jong Date: Sun, 4 Oct 2026 02:52:50 +0200 Subject: [PATCH] amiga: added recursive fractal-tree example --- .../src/prog8/codegen/m68k/InstrControl.kt | 23 +- codeGenM68k/test/TestStackFrameEmission.kt | 10 +- compiler/test/TestStackFrameLocals.kt | 9 +- docs/source/technical.rst | 20 +- docs/source/todo.rst | 6 +- examples/amiga/fractal-tree.p8 | 145 +++++++++ ideas/m68k-frames-followups.md | 71 +++++ ideas/m68k-register-allocation.md | 99 +++--- ideas/m68k-vm-frames-plan.md | 301 ------------------ 9 files changed, 321 insertions(+), 363 deletions(-) create mode 100644 examples/amiga/fractal-tree.p8 create mode 100644 ideas/m68k-frames-followups.md delete mode 100644 ideas/m68k-vm-frames-plan.md diff --git a/codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt b/codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt index 34cab00c4..62c2cf2f8 100644 --- a/codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt +++ b/codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt @@ -826,20 +826,27 @@ private fun AsmGen.translateArgument( } } else { // Stack argument: push into a padded longword slot, right-justified. - // The caller pops the whole argument area after the return, so the callee never needs to - // know the argument count. + // The caller pops the whole argument area after the return, so the callee never needs to know the argument count. + // Optimizing this to use word-padding instead of long padding could be done in a future version if (arg.location is CallLocation.FrameSlot) { - emitLine("subq.l #4,sp") if (argReg.isFloat) { // the FPU has no absolute-long addressing mode, so the value has to travel - // through an FPU register first (fp0 is translator scratch, never holds a live vreg) + // through an FPU register first (fp0 is translator scratch, never holds a live vreg). + // The store itself reserves the slot via predecrement, so no separate subq is needed. emitLine("fmove.s ${floatRegFileAddr(argReg.floatNumber)},$FP_ACC") - emitLine("fmove.s $FP_ACC,(sp)") + emitLine("fmove.s $FP_ACC,-(sp)") } else { when (argReg.type) { - IRDataType.BYTE -> emitLine("move.b ${regAddr(argReg.intNumber)},3(sp)") - IRDataType.WORD -> emitLine("move.w ${regAddr(argReg.intNumber)},2(sp)") - else -> emitLine("move.l ${regAddr(argReg.intNumber)},(sp)") + IRDataType.BYTE -> { + emitLine("subq.l #4,sp") + emitLine("move.b ${regAddr(argReg.intNumber)},3(sp)") + } + IRDataType.WORD -> { + emitLine("subq.l #4,sp") + emitLine("move.w ${regAddr(argReg.intNumber)},2(sp)") + } + // longs and pointers fill the whole slot, so reserve and store in one push + else -> emitLine("move.l ${regAddr(argReg.intNumber)},-(sp)") } } return diff --git a/codeGenM68k/test/TestStackFrameEmission.kt b/codeGenM68k/test/TestStackFrameEmission.kt index b090afa7c..927179480 100644 --- a/codeGenM68k/test/TestStackFrameEmission.kt +++ b/codeGenM68k/test/TestStackFrameEmission.kt @@ -130,10 +130,10 @@ class TestStackFrameEmission : FunSpec({ IRInstructions.simple(Opcode.RETURN) ) val lines = generateAsmWithIncoming(0, calls, incomingSize = 12, subLabel = "test.caller") - lines.count { it == "subq.l #4,sp" } shouldBe 3 + lines.count { it == "subq.l #4,sp" } shouldBe 2 lines.count { it.startsWith("move.b ") && it.endsWith(",3(sp)") } shouldBe 1 lines.count { it.startsWith("move.w ") && it.endsWith(",2(sp)") } shouldBe 1 - lines.count { it.startsWith("move.l ") && it.endsWith(",(sp)") } shouldBe 1 + lines.count { it.startsWith("move.l ") && it.endsWith(",-(sp)") } shouldBe 1 lines.any { it.startsWith("bsr") } shouldBe true lines.any { it == "lea 12(sp),sp" } shouldBe true // small argument areas use the short addq form @@ -173,9 +173,11 @@ class TestStackFrameEmission : FunSpec({ IRInstructions.simple(Opcode.RETURN) ) val lines = generateAsmWithIncoming(0, calls, incomingSize = 4, subLabel = "test.caller") - // the 68881 has no absolute-long addressing mode: the value must pass through fp0 + // the 68881 has no absolute-long addressing mode: the value must pass through fp0, + // and the store itself reserves the slot via predecrement lines.any { it.startsWith("fmove.s p8_fregfile+0,fp0") } shouldBe true - lines.any { it == "fmove.s fp0,(sp)" } shouldBe true + lines.any { it == "fmove.s fp0,-(sp)" } shouldBe true + lines.none { it == "subq.l #4,sp" } shouldBe true } test("per-activation virtual registers are addressed through the frame") { // a re-entrant subroutine keeps its virtual registers in its own frame (slice 3), so they diff --git a/compiler/test/TestStackFrameLocals.kt b/compiler/test/TestStackFrameLocals.kt index b76e57dd8..6929fdfe2 100644 --- a/compiler/test/TestStackFrameLocals.kt +++ b/compiler/test/TestStackFrameLocals.kt @@ -2,6 +2,7 @@ package prog8tests.compiler import io.kotest.core.spec.style.FunSpec import io.kotest.engine.spec.tempdir +import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.string.shouldContain @@ -91,10 +92,12 @@ main { // parameters are no longer static storage lines.none { "p8v_a:" in it || "p8v_b:" in it || "p8v_c:" in it } shouldBe true // the caller pushes every argument into its own slot, then pops the whole area - lines.count { it == "subq.l #4,sp" } shouldBe 3 + lines.count { it == "subq.l #4,sp" } shouldBe 2 lines.count { it.startsWith("move.b ") && it.endsWith(",3(sp)") } shouldBe 1 lines.count { it.startsWith("move.w ") && it.endsWith(",2(sp)") } shouldBe 1 - lines.count { it.startsWith("move.l ") && it.endsWith(",(sp)") } shouldBe 1 + // long/pointer slots push with a single move.l ...,-(sp), same shape as PUSH ops elsewhere, + // so only require at least the one argument push (exact isolation is covered in TestStackFrameEmission) + lines.count { it.startsWith("move.l ") && it.endsWith(",-(sp)") } shouldBeGreaterThanOrEqual 1 lines.any { it == "lea 12(sp),sp" } shouldBe true // IR carries the incoming area size and the frame-slot argument locations ir.contains("INCOMING=\"12\"") shouldBe true @@ -118,7 +121,7 @@ main { } """ val (lines, ir) = compile(src) - lines.count { it == "subq.l #4,sp" } shouldBe 5 + lines.count { it == "subq.l #4,sp" } shouldBe 4 lines.any { it == "lea 20(sp),sp" } shouldBe true // 20 bytes: the addq form maxes out at 8 // 5 parameters: slots at 24/20/16/12/8, narrow values right-justified inside their slot lines.any { it.contains("27(a5)") } shouldBe true // ubyte at slot 24 + 3 diff --git a/docs/source/technical.rst b/docs/source/technical.rst index 01d5ddd3c..9e9b6bc85 100644 --- a/docs/source/technical.rst +++ b/docs/source/technical.rst @@ -258,10 +258,28 @@ Normal Prog8 subroutines use one uniform all-stack argument convention: * Scalar and pointer results remain register-based, normally in ``D0``. Float results use the floating-point accumulator. +The uniform four-byte slot is a deliberate first version: it keeps every parameter offset +independent of the other arguments' types and keeps the caller rules trivial. A future +version may switch to word-packed slots (narrow arguments at their natural size) to save +stack space and bus traffic. + ``asmsub`` and ``extsub`` keep their explicit register-based, annotation-driven convention. AmigaOS library calls continue to use ``A6`` for the selected library base. Hand-written M68K assembly that calls a normal Prog8 subroutine must follow the stack convention and preserve -``A5``. +``A5``. For example, calling ``sub add2(ubyte a, uword b) -> uword``:: + + ; call add2($12, $3456); each argument gets one padded 4-byte slot + subq.l #4,sp + move.b #$12,3(sp) ; byte: right-justified in its slot + subq.l #4,sp + move.w #$3456,2(sp) ; word: right-justified in its slot + jsr p8b_main.p8s_add2 + lea 8(sp),sp ; caller pops the 2 slots (lea preserves CCR) + ; result is now in d0 + +Long, pointer and float arguments fill the whole slot, so they push in one step +(``move.l x,-(sp)`` or ``fmove.s fp0,-(sp)``). Only use longword pushes: a byte +push on ``sp`` steps by 2 to keep the stack word-aligned and would break the slot layout. .. note:: The built-in virtual machine does not currently execute frame-based IR. It rejects M68K diff --git a/docs/source/todo.rst b/docs/source/todo.rst index c89370d70..1b458fd02 100644 --- a/docs/source/todo.rst +++ b/docs/source/todo.rst @@ -22,7 +22,8 @@ Won't do's or deferred - Won't do: ``var x = `` type inference for ordinary variables will not be added: it hides the explicit size/cost that is central to Prog8's simple retro vibe, and has ambiguous literal/tag/scope interactions. Inference stays limited to ``const`` (compile-time, no allocation) and ``for`` loop counters (type fixed by the iterable). - Won't do: make enums strongly typed instead of just syntactic sugar for ints (see ideas/enum-strong-type.md for the plan) - Deferred: implement a true m68k register allocator that keeps virtual registers in hardware registers (D0-D7/A0-A6/FP0-FP7) instead of the flat ``p8_regfile`` memory block. See ideas/m68k-register-allocation.md for the design. -- M68K stack model follow-ups: the stack-based memory model for locals and parameters is implemented in the M68K backend: frame slots for locals, parameters in the caller-pushed incoming area, per-activation virtual registers for re-entrant subroutines, and the diagnostics/polish items. Still open are the remaining optimization items below and VM activation records, which are planned in ``ideas/m68k-vm-frames-plan.md`` (the VM rejects frame-based IR until then). See the user-facing description in ``technical.rst``. +- M68K stack model follow-ups: the stack-based memory model for locals and parameters is implemented in the M68K backend: frame slots for locals, parameters in the caller-pushed incoming area, per-activation virtual registers for re-entrant subroutines, and the diagnostics/polish items. Still open are the remaining optimization items below (see ``ideas/m68k-frames-followups.md``). See the user-facing description in ``technical.rst``. +- Won't do: VM stack frames. The virtual target keeps the static-memory model and the VM rejects frame-based IR with an explicit error instead of executing it (see ``technical.rst``). Rationale: the VM exists to test ordinary virtual-target programs, not as a second executor for m68k IR; giving it activation records plus per-activation vreg spilling would duplicate the m68k frame semantics for little validation gain, while emitting frames for ``-target virtual`` would churn hundreds of compile-test expectations. M68K programs are validated with the M68K backend and an emulator instead. - Deferred: make the fixed zero-page scratch locations (``P8ZP_SCRATCH_B1``/``_REG``/``_W1``/``_W2``/``_PTR``) dynamic rather than hardcoded per target. Right now they live at fixed per-target addresses and are always reserved; a ``%zpreserved``/``%zpallowed`` range that excludes them only produces a warning (the codegen would still overwrite that memory). Relocating scratch to an available low-address byte (or making it configurable) when not in conflict would let ``%zpreserved``/``%zpallowed`` fully protect the desired areas. See ``MemoryRegions.kt`` (``Zeropage.checkScratchConflicts`` and the scratch-byte list it maintains) and the per-target ``*Zeropage`` classes. - Deferred: really fixing the pointer dereferencing issues (cursed hybrid between IdentifierReference, PtrDereferece and PtrIndexedDereference), which may require getting rid of scoped identifiers altogether and treating '.' as a "scope or pointer following operator". - STR is now assignable to LONG on all targets including 16-bit ones, for consistency with 32-bit targets where str arrays are LONG[]. On 16-bit targets this means a 2-byte string pointer can be assigned to a 4-byte long variable without a typecast. @@ -59,8 +60,7 @@ Optimizations - Port more benchmarks from https://thred.github.io/c-bench-64/ to prog8 and see how it stacks up. (see benchmark-c/ directory) - Compilation speed: try to join multiple modifications in 1 result in the AST processors instead of returning it straight away every time - various optimizers skip stuff if compTarget.name==VMTarget.NAME. Once new 6502 codegen is done from IR code, those 6502 only optimizations should probably be removed -- m68k frames: coalesce the prologue's per-element zeroing of clean locals into the widest stores the alignment allows. The prologue still emits one store per element, so a 16-byte local array costs 16 stores. A prototype merged contiguous ranges and cleared a 17-byte range with 5 stores (4 long plus 1 byte). It was reverted because the invariant it relies on is subtle: only clean, non-initialized locals may contribute a range, ranges must never merge across a dirty or initialized neighbour, and float elements cannot be folded into integer stores. Do it together with explicit per-slot size/alignment/initialization bookkeeping in the frame-layout pass. -- m68k frames: loop-aware liveness for frame slot reuse. Slots are currently shared between virtual registers with non-overlapping live ranges, but a subroutine containing any back edge (loop, backward branch, jump into another subroutine) conservatively falls back to one dedicated slot per register. Lifting that needs a real backward live-in/live-out dataflow analysis over the subroutine's control-flow graph; interval extension alone is unsound there. +- m68k frames follow-ups (see ideas/m68k-frames-followups.md): word-packed argument slots (section 1), register argument passing (section 2), reserved outgoing-argument area (section 3), recursion-sound defers via framebase-as-parameter (section 4), prologue zero-store coalescing (section 5), loop-aware liveness for frame slot reuse (section 6). Dead Code Elimination bug in 64tass, for nested subroutines diff --git a/examples/amiga/fractal-tree.p8 b/examples/amiga/fractal-tree.p8 new file mode 100644 index 000000000..e5bbd0ec7 --- /dev/null +++ b/examples/amiga/fractal-tree.p8 @@ -0,0 +1,145 @@ +; Amiga windowed fractal tree, drawn with the OS graphics library. +; Uses fixed-point integer math so it also runs on a stock 68000 (no FPU required). +; Uses native recursion, which works because of the m68k target's stack call convention + +%import intuition +%import graphics +%import exec +%import math + +main { + + ; Branch angle, in a 256-degree circle. 256/12 ~= 21, which is pi/6. + const ubyte delta_theta = 21 + + ; Length shrink factor: 2/3, applied as (len * 2) / 3 in the recursion. + const word minimum_branch = 256 ; 1 pixel in 8.8 fixed point + + ^^graphics.RastPort rp + + ; The window's client area. Width and Height of a window include its borders and title bar, + ; so the usable drawing area is only known after the window is open. + word border_left + word border_top + word xmax + word ymax + word client_w + word client_h + word xscale + + sub update_geometry(^^intuition.Window win) { + border_left = win.BorderLeft as word + border_top = win.BorderTop as word + client_w = win.Width - border_left - win.BorderRight as word + client_h = win.Height - border_top - win.BorderBottom as word + xmax = win.Width - win.BorderRight as word - 1 + ymax = win.Height - win.BorderBottom as word - 1 + if client_h > 256 + client_h = 256 ; draw_branch carries y as a ubyte + + ; Compensate for non-square pixels (e.g. the default 2:1 aspect on many Amiga screens). + ^^intuition.Screen scr = win.WScreen + xscale = 128 + if scr != 0 and scr.Height != 0 { + long num = scr.Width as long * 384 ; 3*128 + long den = scr.Height as long * 4 + xscale = (num / den) as word + if xscale == 0 + xscale = 128 + } + } + + + sub draw_tree() { + graphics.SetAPen(rp, 0) + graphics.RectFill(rp, border_left, border_top, xmax, ymax) + graphics.SetAPen(rp, 1) + ; Initial branch: upright (64 = pi/2 in a 256-degree circle), length = client_h / 3 pixels. + word start_len = ((client_h as long) * 256 / 3) as word + draw_branch(client_w / 2 as word, (client_h - 1) as word, 64, start_len) + } + + sub draw_segment(word x1, word y1, word x2, word y2) { + word dx1 = clamp(x1, border_left, xmax) + word dy1 = clamp(y1, border_top, ymax) + word dx2 = clamp(x2, border_left, xmax) + word dy2 = clamp(y2, border_top, ymax) + graphics.Move(rp, dx1, dy1) + graphics.Draw(rp, dx2, dy2) + } + + ; draw a branch starting at (x1, y1) at angle 'angle' with length 'len' + ; recurses if length is large enough + ; draw_branch is recursive, so its parameters and locals live in its own stack frame and + ; x1, y1, angle and len keep their values across the recursive calls without saving anything + sub draw_branch(word x1, word y1, ubyte angle, word branch_len) { + + ; math.cos8 / math.sin8 return signed values approximating 127*cos/sin. + ; branch_len is in 8.8 fixed point, so shifting the 32-bit product by 15 gives integer pixels. + ; X is then scaled by xscale/128 to correct for non-square screen pixels. + word dx = (((((branch_len as long) * (math.cos8(angle) as long)) >> 15) as word * xscale) >> 7) as word + word dy = (((branch_len as long) * (math.sin8(angle) as long)) >> 15) as word + word x2 = x1 + dx + word y2 = y1 - dy + draw_segment(x1, y1, x2, y2) + + if branch_len >= minimum_branch { + word new_len = ((branch_len as long) * 2 / 3) as word + draw_branch(x2, y2, angle - delta_theta, new_len) + + ; x1, y1, angle and branch_len are untouched by the call above, so the same expressions + ; give the same x2 and y2 again + x2 = x1 + dx + y2 = y1 - dy + draw_segment(x1, y1, x2, y2) + + draw_branch(x2, y2, angle + delta_theta, new_len) + } + } + + sub start() { + ; Same size window3d.p8 uses, so it fits whatever screen the Workbench is on. + ^^intuition.NewWindow nw = [ + 10, 20, 512, 220, -1 as ubyte, -1 as ubyte, + intuition.IDCMP_CLOSEWINDOW | intuition.IDCMP_REFRESHWINDOW | intuition.IDCMP_NEWSIZE | intuition.IDCMP_VANILLAKEY, + intuition.WFLG_CLOSEGADGET | intuition.WFLG_DRAGBAR | intuition.WFLG_DEPTHGADGET | intuition.WFLG_ACTIVATE | intuition.WFLG_SIZEGADGET, + 0, 0, + "Fractal Tree", + 0, 0, 100, 50, 800, 600, + intuition.WBENCHSCREEN + ] + + ^^intuition.Window win = intuition.OpenWindow(nw) + if win == 0 + return + defer intuition.CloseWindow(win) + + rp = win.RPort + update_geometry(win) + graphics.SetDrMd(rp, graphics.RP_JAM1) + draw_tree() + + ; Wait for the user to close the window or press a key. + bool running = true + while running { + ^^intuition.IntuiMessage msg = exec.GetMsg(win.UserPort) as ^^intuition.IntuiMessage + while msg != 0 { + when msg.Class { + intuition.IDCMP_CLOSEWINDOW -> running = false + intuition.IDCMP_REFRESHWINDOW -> { + intuition.BeginRefresh(win) + draw_tree() + intuition.EndRefresh(win, 1) + } + intuition.IDCMP_NEWSIZE -> { + update_geometry(win) + draw_tree() + } + intuition.IDCMP_VANILLAKEY -> running = false + } + exec.ReplyMsg(msg as ^^exec.Message) + msg = exec.GetMsg(win.UserPort) as ^^intuition.IntuiMessage + } + } + } +} diff --git a/ideas/m68k-frames-followups.md b/ideas/m68k-frames-followups.md new file mode 100644 index 000000000..a1e19149f --- /dev/null +++ b/ideas/m68k-frames-followups.md @@ -0,0 +1,71 @@ +# M68K Frames Follow-ups + +**Status: v1 implemented** on `m68k-stack` (uniform all-stack convention, caller cleanup). +User-facing description: `docs/source/technical.rst` ("M68K memory model and calling convention"). +Implementation: `StackFrameLayout`, `AddressBase.FrameSlot` / `CallLocation.FrameSlot`, +`IRSubroutine.frameSize/incomingSize/frameVregSlots`. Decision: the VM does not get +activation records (see `docs/source/todo.rst`, "Won't do: VM stack frames"); it keeps +rejecting frame-based IR with an explicit error. + +This file preserves the convention-level rationale from the deleted +`ideas/m68k-stack-memory-model.md` (§6.1/§6.4) so future optimizations stay variants +of v1, never second conventions. + +## Why v1 is uniform 4-byte slots + +- v1 follows the strict System V m68k scheme: every argument gets one padded longword slot, + narrow values right-justified (`move.b x,3(sp)` / `move.w x,2(sp)` / `move.l x,(sp)`), + param `i` of `N` at `8+4*(N-i)(a5)`, caller pops `N*4` after `jsr`, callee epilogue is + always `unlk a5` / `rts` regardless of arity. +- Rejected for the baseline: all-register (struct-by-value params have no register form), + hybrid first-scalar-in-D0/first-pointer-in-A0 (needs the register allocator to pay off; + without it, it just swaps `move.l x,-(sp)` for `move.l x,d0` plus clobber-ordering hazards + and mandatory copy-ins, doubling marshalling paths and tests). +- `asmsub`/`extsub` keep their explicit register-annotation ABI as a separate lane; normal + subs calling them (or vice versa) follow §6.3 push rules. A Linux/GCC-style word-packed + scheme (natural sizes, 16-bit alignment) is legal on 68000 but was deferred to keep v1 + offsets type-independent and hand-written caller rules trivial. + +## Follow-ups (each needs its own design review + test pass) + +### 1. Word-packed argument slots + +Linux/GCC scheme: byte/bool = 2 bytes incl. pad, word = 2, long/float/pointer = 4, +still even-aligned. Saves stack space and bus traffic per narrow arg. Cost: frame layout +sums per-arg rounded sizes instead of constant stride; documented `asmsub` push rules change. +Introduce as a convention variant/flag, never by silently breaking v1 layout. + +### 2. Register argument passing + +Paired caller/callee specialization over the fixed stack convention (e.g. first scalar +in D0, first pointer in A0, rest on stack), applied only where it measurably wins (small +scalar signatures). Requires the register allocator (`ideas/m68k-register-allocation.md`) +first. + +### 3. Reserved outgoing-argument area + +Allocate the max call-site arg size once in the caller's `link` frame, write args via +negative A5 offsets, drop per-call `addq/lea` cleanup. Best for calls in loops; cleaner +for the allocator (no sp churn between spills). Caller frames grow; frame-size limits +must count the area. + +### 4. Recursion-sound defers via framebase-as-parameter + +Push (id, framebase) pairs on the defer handler stack, pass the parent's bottom-of-frame +address as a hidden trailing parameter, lower parent-local accesses to indirect +framebase-relative accesses. Lifts the current rejection of recursive deferring subs. +Also needs VM activation records in linear memory so saved frame bases are addresses. + +### 5. Prologue zero-store coalescing + +Merge per-element zeroing of clean locals into widest aligned stores (prototype: 17-byte +range in 5 stores instead of 17). Only clean non-initialized locals may merge; never +across dirty/initialized neighbours; floats excluded. Needs explicit per-slot +size/alignment/init bookkeeping in the layout pass. + +### 6. Loop-aware liveness for frame slot reuse + +Today vreg frame slots are shared only when live ranges provably don't overlap, and any +back edge (loop, backward branch, cross-sub jump) falls back to one slot per register. +Lifting needs real backward live-in/live-out dataflow over the CFG; interval extension +alone is unsound. diff --git a/ideas/m68k-register-allocation.md b/ideas/m68k-register-allocation.md index 84a35a8d5..6b3a2cf47 100644 --- a/ideas/m68k-register-allocation.md +++ b/ideas/m68k-register-allocation.md @@ -2,14 +2,16 @@ **Status: reconciled - the seven correctness issues from `m68k-reg-problems.md` are resolved and folded into this design** (that file was removed from `ideas/` after folding; the `m68k-reg-problems.md §N` references below are historical provenance only). Listed as "Deferred" in `docs/source/todo.rst`. The calling convention (§2), register classes (§4), spilling strategy (§5), prologue/epilogue (§6), and the Stage 0/1/3 execution work (§7) are decided and delegable. Still open before Stage 2 is delegable: the §7.1 items 1-4 (width semantics, class-constraint mechanism, spill-slot policy mechanics, call-site metadata representation). -Related implementation: `../docs/source/technical.rst` (M68K stack frames for locals; reserves -A5 as the future frame pointer and A6 for AmigaOS library bases — both are -reflected in the register split below). **Sequencing decision (Oct 2026): the -stack model is implemented *first*** (see its §16) - the allocator is then -built against the final all-stack calling convention and the frame-slot spill -substrate. The convention-agnostic prep (Stage 0/1/3: translator restructure, -vreg bookkeeping, slot machinery) may proceed in parallel; spill-slot policy, -prologue/epilogue emission, and call-site marshalling must wait for frames. +Related implementation: `../docs/source/technical.rst` (M68K stack frames for locals; A5 +is the frame pointer and A6 is reserved for AmigaOS library bases — both are +reflected in the register split below). **Sequencing (Oct 2026, done): the +stack model is implemented** (`StackFrameLayout`, `technical.rst` "M68K memory +model and calling convention") - the allocator is then built against the final +all-stack calling convention and the frame-slot spill substrate. The +convention-agnostic prep (Stage 0/1/3: translator restructure, vreg +bookkeeping, slot machinery) may proceed in parallel; spill-slot policy, +prologue/epilogue emission, and call-site marshalling build on the implemented +frames. This document describes the design for a **true register allocator** for the m68k code generation backend (`codeGenM68k`). @@ -17,10 +19,10 @@ m68k code generation backend (`codeGenM68k`). ## 0. Goals (in priority order) 1. **Values live in CPU registers.** Virtual-register values must reside in the - m68k's actual hardware registers (D0–D6, A0–A4, FP0–FP7) for as long as they - are live. The in-memory `p8_regfile` block is demoted to a spill-only area - (and shrinks accordingly). Memory residence is the exception, not the - default. This is the whole point of targeting a register-rich 32-bit CPU. + m68k's allocatable hardware registers (D2–D6, A2–A4, FP2–FP7, see §2.3) for + as long as they are live. The in-memory `p8_regfile` block is demoted to a + spill-only area (and shrinks accordingly). Memory residence is the exception, + not the default. This is the whole point of targeting a register-rich 32-bit CPU. 2. **Dramatically smaller and better assembly, as a *consequence* of goal 1.** When operands are already in registers, the load/compute/store round-trips disappear and register-to-register instructions (`add.l d1, d0`) fall out @@ -40,8 +42,9 @@ convention, §2.1, not the C ABI), squeezing the last cycle out of hot loops ## 1. Current State (background) -The m68k backend today (`AsmGen.kt`) spills **every** virtual register to a -single flat BSS memory block: +The m68k backend today (`AsmGen.kt`) spills virtual registers to memory — the +flat BSS `p8_regfile` block, except for the per-activation vregs of re-entrant +subroutines, which already resolve to A5-relative frame slots (`frameVregSlots`): - `regFileLayout` lays out **all** registers returned by `program.registersUsed().regsTypes` — program-wide — into one block labelled @@ -72,7 +75,8 @@ IR instruction translator (`InstrArithmetic`, `InstrBitwise`, `InstrControl`, `dest`/`srcA`/`memory`/`target`/`callSite` operands and memory lowering is centralized in `AsmGen.resolveMemory()` — that part of Stage 1 is done. What remains is the vreg side, still lowered via `regAddr()`/`floatRegFileAddr()` -to the `p8_regfile` spill area:) +to the `p8_regfile` spill area (or to an A5-relative frame slot for a +re-entrant subroutine's relocated vregs): ``` INC -> addq.b #1, p8_regfile+N @@ -150,7 +154,7 @@ The `CALL` instruction itself does **no** register save/restore. It is a bare | FP0, FP1 | caller-saved | caller | float scratch / return | | FP2–FP7 | callee-saved | callee | float values that survive calls | | D7 | reserved (not allocatable) | — | physical `repeat` loop counter (§2.5) | -| A5 | reserved (not allocatable) | — | future frame pointer (§2.5) | +| A5 | reserved (not allocatable) | — | frame pointer (§2.5) | | A6 | reserved (not allocatable) | — | AmigaOS library-base register (§2.5) | | A7 | stack pointer | — | hardware stack | @@ -245,9 +249,9 @@ targets: `IRProgram.kt` near `usedRegisters`). The allocator cannot model these clobbers, so D7 stays reserved until the loop-counter mechanism is reworked to request a register from the allocator. -- **A5 — future frame pointer.** Reserved for the stack-frame memory model in - the M68K stack-frame implementation (`link a5,#-N` / `unlk a5`). Reserving it from - the start avoids a flag day when frames land. +- **A5 — frame pointer.** Used by the implemented M68K stack-frame model + (`link a5,#-N` / `unlk a5`; locals at negative offsets, incoming arguments at + positive offsets). Never allocated, so frame access stays valid. - **A6 — AmigaOS library-base register.** On amiga500 the backend loads a library base into A6 around every OS call (`move.l 4.w,a6` or `move.l sys.xxxBase,a6`, then `jsr Symbol(a6)`; see CALLFAR in @@ -319,7 +323,7 @@ the callee allocated to its result vreg, so the boundary is pinned: the caller's allocator treats D0 as defined by the CALL and consumes or moves it from there. This matches the current backend default (`translateReturnValue`: non-float returns go through d0). -- **Single float result:** same, through FP0. +- **Single float result:** same, through FP0 (the `FP_ACC` floating-point accumulator). - **Multi-value returns:** unchanged from today (memory slots / LOADHR). Register-based multi-return is a later optimization. - **asmsub/extsub:** unchanged - explicit slot annotations. Slot annotations @@ -523,18 +527,20 @@ When register pressure exceeds the physical registers available in a class, the allocator spills vregs to memory, inserting stores/loads at definition/use points. Three possible spill targets, with different soundness properties: -- **Frame slots (primary):** with the A5 - frame model landed first, spills become - frame-resident; its frame layout already budgets "compiler spill slots". - Per-invocation, recursion-safe, and cheaper addressing - (`move.l -8(a5),d0`). -- **SP-reserved spill area (fallback for frameless subroutines):** for - subroutines the frame-layout pass leaves frameless (`frameSize == 0` fast - path: no locals, no incoming args, no call-live vregs), the - prologue reserves a fixed-size spill area once with a single `subq` and - keeps SP stable throughout the body; each spill is addressed through a - fixed SP displacement. Per-invocation storage, so recursion-safe for the - register state. Naive push/pop around a live range is *not* a sound general +- **Frame slots (primary, intended):** with the A5 frame model implemented, + spills become frame-resident: per-invocation, recursion-safe, and cheaper + addressing (`move.l -8(a5),d0`). The frame layout pass does not yet budget + generic "compiler spill slots" for the allocator — it currently lays out + locals, the incoming argument area, and `frameVregSlots` for re-entrant + subroutines only — so the slot-allocation policy is Stage-2 work (§7.1 #3). +- **SP-reserved spill area (proposed fallback for frameless subroutines):** for + subroutines the frame-layout pass leaves frameless (`hasFrame == false`, + i.e. `frameSize == 0 && incomingSize == 0`; today such subroutines just use + the static regfile), the prologue would reserve a fixed-size spill area once + with a single `subq` and keep SP stable throughout the body; each spill would + be addressed through a fixed SP displacement. Per-invocation storage, so + recursion-safe for the register state. Naive push/pop around a live range is + *not* a sound general strategy: general live ranges are not necessarily nested in LIFO order (they cross branches, loops, calls, and multiple uses), so push/pop insertion can produce different stack depths at CFG joins or pop a @@ -570,11 +576,13 @@ m68k-reg-problems.md §5). Register allocation alone never made subroutines recursive: when this concern was raised, per-invocation storage for ordinary parameters and locals did not exist, so a recursive call overwrote the caller's variable storage regardless of where registers spilled. With the - frame model landing *first*, parameters, -locals and call-live vregs are already per-activation when Stage 2 starts. +frame model implemented, parameters and locals of framed subroutines are +per-activation when Stage 2 starts; virtual registers are per-activation only +for re-entrant subroutines (via `frameVregSlots`) and stay in the static +regfile otherwise. The §8 recursion test therefore stays scoped to register-state preservation (callee-saved and spill areas must not collide across a recursive call chain); - variable-storage recursion is covered by the existing M68K stack-frame tests. +variable-storage recursion is covered by the existing M68K stack-frame tests. Per-subroutine vreg-number reuse (resetting `RegisterPool` per subroutine) is a safe optional extra *because* no value is live in a shared hardware register @@ -591,6 +599,10 @@ For each subroutine, the codegen emits: (e.g. `movem.l d2-d6/a2-a4, -(sp)`), determined intraprocedurally. - At every exit (`rts`): restore them (`movem.l (sp)+, d2-d6/a2-a4`). +On framed subroutines this nests inside the existing frame setup: `link +a5,#-N` stays the first instruction and `unlk a5` the last before each `rts`, +so the `movem` save/restore happens with the frame active. + Only registers that are used are saved, keeping the cost minimal. The reserved registers (D7, A5, A6) never appear in the save mask by construction — the allocator never hands them out. A subroutine that uses callee-saved FPU @@ -776,15 +788,16 @@ delegable: the constraint to the allocator: a per-vreg constraint recorded in a pre-pass, a hint at allocation-call time, or an IR-level annotation. The constraint-propagation plumbing is undesigned. - 3. **Spill-slot allocation policy (§5 lists targets, not policy).** How spill - slots are assigned per subroutine (slot count, alignment, interplay with - the frame-layout pass's "compiler spill slots" budget; worst-case sizing - from live-range pressure vs iterative re-layout) and whether spill code is - inserted during coloring (rewriting the IR) or emitted at use points. The - *targets* are decided: frame slots, SP-reserved fallback for frameless - subroutines (§5); the push/pop variant stays rejected as a general +3. **Spill-slot allocation policy (§5 lists targets, not policy).** How spill + slots are assigned per subroutine (slot count, alignment, interplay with + the frame-layout pass — which currently budgets locals, incoming args, and + `frameVregSlots` only, with no generic spill budget yet; worst-case sizing + from live-range pressure vs iterative re-layout) and whether spill code is + inserted during coloring (rewriting the IR) or emitted at use points. The + *targets* are decided: frame slots, SP-reserved fallback for frameless + subroutines (§5); the push/pop variant stays rejected as a general mechanism. - 4. **Call-site metadata representation (mechanism).** The liveness side is +4. **Call-site metadata representation (mechanism).** The liveness side is decided: "CALL kills D0/D1/A0/A1/FP0/FP1" is realized as kill edges at call points, and the translator-scratch plus reserved registers never appear in the interference graph at all (they are never vreg-allocated, diff --git a/ideas/m68k-vm-frames-plan.md b/ideas/m68k-vm-frames-plan.md deleted file mode 100644 index 01ed20d71..000000000 --- a/ideas/m68k-vm-frames-plan.md +++ /dev/null @@ -1,301 +0,0 @@ -# VM Stack Frame Support (slice 4) - Implementation Plan - -**Status: planned, not implemented.** Deliberately deferred by the user (2026-10-03); -the VM keeps rejecting frame-based IR for now. This document records the design and -the exact steps so the slice can be picked up later without re-deriving it. - -Context: the M68K user documentation (`docs/source/technical.rst`) and this VM -activation-record follow-up. The m68k backend produces -frame-based IR; the VM refuses it. - ---- - -## 1. Goal - -Let the VM execute frame-based IR: one activation record per call, allocated on -call and discarded on return, with frame-relative memory operands resolved against -the active record. The payoff is that recursion becomes testable without qemu, and -the VM acts as a second, independent executor for the same IR (an m68k-produced -`.p8ir` can be run by `prog8c -vm`). - -The VM must implement the *abstract* frame-slot semantics only. It does not emulate -A5, `link`/`unlk`, or any m68k instruction. - -Out of scope for this slice: emitting frames for `-target virtual` (open decision, -§6), copy-in of constrained parameters, escape warnings. - ---- - -## 2. Current state (verified) - -Loader (`virtualmachine/src/prog8/vm/VmProgramLoader.kt`): - -- `rejectFrameBasedIr()` (`:340-356`) is called from `load()` (`:22`) and throws - `IRParseException` for: a subroutine with `frameSize != 0` (`:342`), one with - `incomingSize != 0` (`:345`), a memory operand whose base is - `AddressBase.FrameSlot` (`:348-350`), and a call argument whose location is - `CallLocation.FrameSlot` (`:351-354`). This rejection is the §9-sanctioned - interim behavior and must stay until this slice is done. -- Symbol resolution (`pass2replaceLabelsByProgIndex` `:370`, `resolvedSymbolInstruction` - `:258`) folds every symbolic base into `AddressBase.Absolute` with the - displacement folded in. It keys off `instr.memory?.symbolName`, which is `null` - for frame references, so **frame-relative operands are already left untouched by - this pass** - no change needed there. -- Call arguments: for `CallLocation.ParameterMemory` with `address == null`, the - loader looks up the callee's parameter variable address (`:413-419`). A - `CallLocation.FrameSlot` argument has no name and must simply be skipped here - (it carries its own offset). -- Subroutine metadata is kept in `subroutines` (`:203`, keyed by - `SymbolNames.stripPrefixes(label)`), which is what the machine needs to answer - "how big is the frame of the subroutine I am calling". -- The final sanity check (`:227-233`) only asserts that no *symbol* references - remain, so frame references pass it. -- `VmVariableAllocator` hands out variable addresses from 0 upwards and exposes - `freeMem` = the first address above all variables, slabs and struct instances - (`VmVariableAllocator.kt:6-9,45`). `VMTarget.RAM_SIZE` is 16 MB - (`VMTarget.kt:25`), so there is plenty of room for a stack area. - -Machine (`virtualmachine/src/prog8/vm/VirtualMachine.kt`): - -- `InsCALL` (`:852-869`) writes every argument into the callee parameter variable - memory (`requireNotNull(location.address)`), pushes a - `CallSiteContext(returnChunk, returnIndex, callSite)` (`:64`) and branches. - Anything other than `CallLocation.ParameterMemory` throws today. -- `InsRETURN` (`:871`), `InsRETURNI` (`:882`), `InsRETURNR` (`:944`) pop the - `callStack` and restore `pcChunk`/`pcIndex`. `exit(0)` happens when the - `callStack` empties. -- Branch/call targets stay symbolic: `branchTo` (`:238`) resolves - `CodeReference.Label` through `irProgram.resolveCodeTarget`, so at CALL time the - machine knows the callee *label* and can therefore look up its frame info. -- `Registers` (`Registers.kt`) stores virtual registers in two flat Kotlin arrays - (`Array(99999)` of `Int`, plus `Array(99999)` of `Double`), with - `getUB/setUB`, `getUW/setUW`, `getSL/setSL`, `getFloat/setFloat` accessors. - `reset()` zeroes them. - ---- - -## 3. The catch: activation records alone are not enough - -Because the VM keeps every virtual register in one flat array shared by all -activations, a recursive subroutine that keeps an intermediate value in a vreg -would corrupt it exactly like the m68k backend did before slice 3. Frames on the -m68k side fix that by relocating those vregs into the frame -(`IRSubroutine.frameVregSlots`, produced by the M68K frame-layout implementation). - -So the VM must honor `frameVregSlots` too: - -- On CALL to a framed subroutine, spill every register listed in its - `frameVregSlots` (with its `IRDataType`) into that activation record. -- On RETURN, restore them. - -This is the same trade-off §3 of the design doc describes ("spilled to frame slots -around calls"). Alternatives considered and rejected: - -- Giving each activation its own full register file: 100000 entries per activation, - hopeless for deep recursion. -- Spilling all registers on every call: far too slow. -- Ignoring `frameVregSlots`: silently wrong for exactly the recursive programs this - slice exists to test. - -Note that vreg numbers are program-wide unique and are not shared between -subroutines, so the caller's and callee's register sets are disjoint. That makes -the spill/restore set well defined, and it also means the return value register -(always a caller's vreg on this calling convention) is never part of the callee's -spill set. - -Order matters on return: read the return value first, then restore the spilled -registers and tear the frame down (otherwise a spilled register could overwrite the -just-delivered result). - ---- - -## 4. Design - -### 4.1 Activation record layout - -Mirror the hardware convention exactly, so the same IR runs on both executors and -any divergence in the §6.1 convention shows up immediately instead of being papered -over by a shifted base: - -``` - block start block start + frameSize + 8 = base - | | | - +-- locals (frameSize) --+-- 8 bytes linkage --+-- incoming args (incomingSize) --+ - base-frameSize .. base-1 saved a5 + return base+8 .. base+8+incomingSize-1 -``` - -- The 8 linkage bytes correspond to the saved A5 and return address that `link` - puts at `0(a5)` and `4(a5)`. The VM does not use them; it reserves them only so - that `8(a5)` really is the first argument slot, matching - `INCOMING_BASE = 8` in `StackFrameLayout`. -- `FrameSlot(-k)` resolves to `base - k`, `FrameSlot(+k)` to `base + k`. Local slots - are negative, incoming slots start at 8, which is why `CallLocation.FrameSlot` - requires `offset >= 8`. -- Records come from a bump pointer starting at `VmVariableAllocator.freeMem`, - reset in the same place the VM resets memory and registers. A simple - stack-discipline bump pointer is enough: frames are strictly nested. -- Guard the total against `VMTarget.RAM_SIZE` and report a clear - "VM stack overflow: N bytes of activation records" error rather than letting an - index-out-of-bounds surface. This is the only new failure mode recursion adds. - -### 4.2 Machine state - -``` -private var frameBase: UInt = 0u // base of the active activation record -private var framePointer: UInt = 0u // next free byte for a new record -private var frameStackSize = 0 // bytes currently in use, for the overflow message -``` - -plus one saved `frameBase` (and the record's start address) per `callStack` entry. -`CallSiteContext` (`VirtualMachine.kt:64`) is the natural place to store that, since -RETURN/RETURNR/RETURNI already pop it. - -### 4.3 Address resolution - -Add frame resolution next to the existing address computation used by -`loadm`/`storem`/etc.: for `MemoryReference.Direct` and `.Indexed` with an -`AddressBase.FrameSlot` base, compute `frameBase + slotOffset + displacement` -(plus `index * scale` for `Indexed`) instead of treating the base as a symbol. -`Indirect` over a frame slot needs no special case as long as the pointer value is -already computed. - -The m68k backend emits a bounds check on the 16-bit displacement range; the VM -should sanity check that the computed address is inside the active record, so a -layout bug produces a clear message instead of a silent cross-activation access. - -### 4.4 Calls - -`InsCALL` (`:852`) becomes: - -1. Resolve the callee's frame info from the call target label (frameless and - unknown labels behave exactly as today). -2. Read all argument source values *first*, while the caller's `frameBase` is - still active (a source vreg may live in the caller's frame). -3. If the callee has a frame: allocate the record, set `frameBase`, spill the - callee's `frameVregSlots` registers into it. -4. Write the arguments: - - `CallLocation.FrameSlot(offset)` -> `newBase + offset`, with the same - byte/word/long/float widths the machine already uses in `InsCALL`, so the - right-justified narrow slots work unchanged. - - `CallLocation.ParameterMemory` -> unchanged (static convention). -5. Push the context (return chunk/index, call site, saved frame base, record start) - and branch. - -Returns: after delivering the result (`RETURN` has none, `RETURNI`/`RETURNR` write -`callSite.results`), pop the frame base, restore the spilled registers, and rewind -the frame pointer to the record start. - -`exit()` from deep inside recursion does not need unwinding: the whole frame area -is reclaimed on the next `reset()`. - -### 4.5 Loader changes - -- Narrow `rejectFrameBasedIr` (`:340`) instead of deleting it wholesale: keep - rejecting whatever the VM genuinely cannot execute, so a future gap produces an - error rather than a miscompile. Frame *memory* operands need no rejection once - §4.3 is in place. -- Skip `CallLocation.FrameSlot` arguments in the parameter-address fixup (`:413-419`). -- Hand the machine two things it cannot derive itself: the frame info per - subroutine label (`frameSize`, `incomingSize`, `frameVregSlots`, and whether the - subroutine has a frame at all), and `freeMem` as the frame area start. - ---- - -## 5. Implementation order - -1. Loader: build and expose the frame-info map and the frame area start; skip - `FrameSlot` call arguments; keep a narrow rejection. -2. Machine: frame base/pointer state, record allocation and release, bounds checks. -3. Machine: `FrameSlot` address resolution in the memory-operand path. -4. Machine: `FrameSlot` argument delivery in `InsCALL`; frame teardown plus - register restore in `InsRETURN`/`InsRETURNI`/`InsRETURNR`. -5. Machine: narrow (or remove) the loader rejection once 1-4 are in. -6. Tests (§7), then update the user-facing M68K documentation in - `docs/source/technical.rst`. - -Modules touched: `virtualmachine` only, plus optionally `codeGenIntermediate` for -open decision §6.1. Per the project rules, `intermediate` needs no change: the IR -already carries `frameSize`, `incomingSize` and `frameVregSlots`. - ---- - -## 6. Open decisions - -### 6.1 Should `-target virtual` also *produce* frames? - -`StackFrameLayout` is currently invoked only from the m68k code generator -(`codeGenM68k/.../M68kCodeGenerator.kt:33`), so VM-targeted programs never get -frames and the new VM code path would only ever be exercised by IR produced for -another target. Three options: - -- **Also run the layout pass for `VMTarget`** (recommended). Recursion becomes - testable end to end with `prog8c -target virtual -emu rec.p8`, and the existing - VM test suite starts exercising frames, which is the strongest validation - available. Cost: every virtual-target program's locals become frames, so - expectations in the many `VMTarget()` compile tests (`compiler/test`, ~467 call - sites) and the VM execution tests may need updating. -- **VM support only.** `-target virtual` output is unchanged, so nothing else - churns, but the frame path is then only covered by tests that hand-build IR or - load IR produced for another target, which is much weaker validation. -- **Opt-in switch** for frames on the virtual target: keeps default behavior and - still allows the end-to-end tests, at the cost of a new user-visible option. - -Decision needed before step 6 of the implementation order; the VM mechanics in §4 -do not depend on it. - -### 6.2 Smaller points - -- Keep the 8-byte linkage area (recommended, for offset fidelity) or start the - argument area at the record base and adjust the offsets (cheaper, but then the - VM and the hardware would disagree about what `8(a5)` means). -- Spill only the registers in `frameVregSlots` (recommended) versus everything a - conservative analysis says is live across calls (needs a liveness analysis that - does not exist in the production code path, see §17.7). -- Frame overflow: dedicated budget with a clear error (recommended) versus relying - on the 16 MB bounds check. - ---- - -## 7. Testing - -Unit level (`virtualmachine/test/TestVm.kt`, hand-built IR programs): - -- A recursive subroutine with parameters and locals returns the mathematically - correct value (guards both activation records and vreg spilling; this is the test - that fails if `frameVregSlots` is ignored). -- Frame slot read/write: values written in one activation are not visible in - another (the slice-2 bug, reproduced as a VM test). -- Incoming arguments: byte/word/long/pointer/float mixes land at the documented - offsets, including the right-justified narrow slots. -- Frame teardown: after a call returns, the next activation reuses the same bytes - (no unbounded growth for a non-recursive loop). -- Deep recursion up to a few thousand activations, then a clean overflow error. - -End to end: - -- With open decision 6.1 = first or third option: compile recursive and mutually - recursive programs for `-target virtual` and compare the printed result against - the same programs run under qemu (both executors, same expected values). -- With the second option: load an m68k-produced `.p8ir` containing frames and run - it with `prog8c -vm`. Avoid syscalls in such a program, because m68k and VM - syscall numbering differ; check the result through variables in memory instead. - -Verification commands: `gradle :virtualmachine:test --console=plain` for the unit -tests, then `gradle build --console=plain` before finishing. - ---- - -## 8. Risks - -- **Silent wrongness if `frameVregSlots` is ignored** (§3). This is the main trap; - the recursive unit test exists specifically to catch it. -- **Offset drift** between the VM and the hardware convention if the linkage area - is fudged; mitigated by keeping the layout identical and by testing both - executors on the same programs. -- **Return-value ordering**: restoring spilled registers before delivering the - result would corrupt it (see §4.4). -- **Test churn** if the virtual target starts emitting frames (open decision 6.1); - this is scope, not correctness. -- The loader's final "nothing unresolved" assertion (`:227`) and the artificial - address logic do not understand frames; make sure neither starts rejecting or - rewriting frame operands once frames are allowed.