amiga: added recursive fractal-tree example

This commit is contained in:
Irmen de Jong committed 2026-10-07 19:06:16 +02:00
1 parent 29254e892b
commit 125af552ff
9 files changed
+321 -363

No files matched your search

@@ -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
+6 -4
View File
@@ -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
+6 -3
View File
@@ -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
+19 -1
View File
@@ -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
+3 -3
View File
@@ -22,7 +22,8 @@ Won't do's or deferred
- Won't do: ``var x = <value>`` 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
+145
View File
@@ -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
}
}
}
}
+71
View File
@@ -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.
+56 -43
View File
@@ -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,
-301
View File
@@ -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.