mirror of
https://github.com/irmen/prog8.git
synced 2026-10-22 05:02:58 +00:00
amiga: added recursive fractal-tree example
This commit is contained in:
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user