From 26bbd6c1fed24351d4be26f7c82eb9616fbc89cf Mon Sep 17 00:00:00 2001 From: Irmen de Jong Date: Sat, 3 Oct 2026 02:13:32 +0200 Subject: [PATCH] vertical slice prototype for m68k stack mememory model --- .../codegen/intermediate/StackFrameLayout.kt | 206 ++++++++++++++++++ codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt | 21 ++ .../src/prog8/codegen/m68k/InstrControl.kt | 3 + .../prog8/codegen/m68k/M68kCodeGenerator.kt | 4 + codeGenM68k/test/TestStackFrameEmission.kt | 102 +++++++++ .../prog8/codegen/new6502/InstrLoadStore.kt | 1 + compiler/test/TestStackFrameLocals.kt | 167 ++++++++++++++ .../src/prog8/intermediate/IRFileReader.kt | 5 +- .../src/prog8/intermediate/IRFileWriter.kt | 2 + .../src/prog8/intermediate/IRFormat.kt | 6 +- .../intermediate/IRInstructionsFactory.kt | 13 ++ .../src/prog8/intermediate/IROperands.kt | 16 +- .../src/prog8/intermediate/IRProgram.kt | 5 +- .../src/prog8/intermediate/IRTextCodec.kt | 8 +- intermediate/test/TestIRFileInOut.kt | 2 +- intermediate/test/TestIRTextCodec.kt | 16 ++ .../src/prog8/vm/VmProgramLoader.kt | 19 ++ virtualmachine/test/TestVm.kt | 28 +++ 18 files changed, 617 insertions(+), 7 deletions(-) create mode 100644 codeGenIntermediate/src/prog8/codegen/intermediate/StackFrameLayout.kt create mode 100644 codeGenM68k/test/TestStackFrameEmission.kt create mode 100644 compiler/test/TestStackFrameLocals.kt diff --git a/codeGenIntermediate/src/prog8/codegen/intermediate/StackFrameLayout.kt b/codeGenIntermediate/src/prog8/codegen/intermediate/StackFrameLayout.kt new file mode 100644 index 000000000..8c7a6da76 --- /dev/null +++ b/codeGenIntermediate/src/prog8/codegen/intermediate/StackFrameLayout.kt @@ -0,0 +1,206 @@ +package prog8.codegen.intermediate + +import prog8.code.core.BaseDataType +import prog8.code.core.DataType +import prog8.code.core.IErrorReporter +import prog8.code.core.ZeropageWish +import prog8.intermediate.* + +/** + * Vertical Slice Prototype (m68k-stack-memory-model.md §17): classify leaf subroutines as + * "frameable", move their plain-data locals into A5-relative stack frame slots, rewrite all + * references to AddressBase.FrameSlot, remove them from the static (BSS) variable list, + * zero-clear them in the prologue and stamp the frame size on IRSubroutine. + * + * A subroutine is frameable when ALL of: + * - it has no parameters, + * - it contains no CALL/CALLI/CALLFAR/CALLFARVB/SYSCALL (leaf; also rules out self-recursion), + * - none of its locals is address-taken (SymbolAddress immediate), referenced by inline + * assembly or static-initialized, + * - every local is a byte/word/long/pointer scalar or a plain numeric/bool array. + * + * Everything else keeps the existing static path verbatim. Deferring subroutines gain a + * handler-invoke call during DeferProcessor flattening, so they are never frameable here; + * call-live vregs cannot exist in a leaf, so the flat p8_regfile may stay program-static + * in the prototype. + */ +class StackFrameLayout(private val program: IRProgram, private val errors: IErrorReporter) { + + companion object { + const val MAX_FRAME_SIZE = 16384 // §3: per-frame limit, far below the 16-bit displacement range + } + + private val target = program.options.compTarget + + private class FrameVar(val name: String, val slot: Int, val elemDt: DataType, val count: Int, val dirty: Boolean) + + fun apply() { + val allSubs = program.allSubs().toList() + + val referencedIn = mutableMapOf>() + val addressTaken = mutableSetOf() + val asmTexts = mutableListOf() + + fun noteRefs(chunk: IRCodeChunkBase, sub: String?) { + chunk.instructions.forEach { instr -> + instr.memory?.symbolName?.let { referencedIn.getOrPut(it) { mutableSetOf() } += (sub ?: "") } + (instr.immediate as? ImmediateOperand.SymbolAddress)?.let { addressTaken += it.symbol } + } + } + + for (sub in allSubs) sub.forEachChunk { noteRefs(it, sub.label) } + program.globalInits.forEachChunkRecursive { noteRefs(it, null) } + program.blocks.forEach { block -> + block.children.filterIsInstance().forEach { chunk -> + chunk.forEachChunkRecursive { noteRefs(it, null) } + } + } + program.allAsmSubs().forEach { asmSub -> asmTexts += asmSub.asmChunk.assembly } + program.forEachChunk { chunk -> if (chunk is IRInlineAsmChunk) asmTexts += chunk.assembly } + + allSubs.forEach { frameableSubroutine(it, referencedIn, addressTaken, asmTexts) } + } + + private fun frameableSubroutine( + sub: IRSubroutine, + referencedIn: Map>, + addressTaken: Set, + asmTexts: List + ) { + if (sub.parameters.isNotEmpty()) return + if (sub.frameSize != 0) return + var hasInlineAsm = false + sub.forEachChunk { chunk -> if (chunk is IRInlineAsmChunk) hasInlineAsm = true } + if (hasInlineAsm) return + val callOpcodes = setOf(Opcode.CALL, Opcode.CALLI, Opcode.CALLFAR, Opcode.CALLFARVB, Opcode.SYSCALL) + if (sub.chunks.any { containsCall(it, callOpcodes) }) return + + // candidate locals: symbols referenced only from this sub, scoped under the sub label + val prefix = sub.label + "." + val candidates = referencedIn.keys.filter { name -> + name.startsWith(prefix) && referencedIn.getValue(name) == setOf(sub.label) + }.sorted() + if (candidates.isEmpty()) return + + val frameVars = mutableListOf() + var cursor = 0 + for (name in candidates) { + val v = program.st.lookup(name) as? IRStStaticVariable ?: continue + val elemDt = isFrameableVariable(v, name, addressTaken, asmTexts) ?: continue + val count = v.length?.toInt() ?: 1 + val size = target.memorySize(v.dt, v.length?.toInt()) + if (size <= 0 || size >= MAX_FRAME_SIZE) continue + val alignment = when { + size >= 4 -> 4 + size >= 2 -> 2 + else -> 1 + } + cursor = cursor.floorDiv(alignment) * alignment // align down (cursor grows negative) + cursor -= size + frameVars += FrameVar(name, cursor, elemDt, count, v.dirty) + } + if (frameVars.isEmpty()) return + + val frameSize = (-cursor + 1) / 2 * 2 + if (frameSize > MAX_FRAME_SIZE) { + errors.err("frame size $frameSize of subroutine ${sub.label} exceeds the $MAX_FRAME_SIZE byte limit; " + + "reduce local variable usage", sub.position) + return + } + + // every static (base slot + displacement) access combination must stay in the 16-bit displacement range + val slotBySymbol = frameVars.associate { it.name to it.slot } + var outOfRange = false + sub.forEachInstruction { instr -> + val disp = when (val mem = instr.memory) { + is MemoryReference.Direct -> slotBySymbol[mem.symbolName]?.plus(mem.displacement) + is MemoryReference.Indexed -> slotBySymbol[mem.symbolName]?.plus(mem.displacement) + else -> null + } + if (disp != null && (disp < -32768 || disp > 32767)) outOfRange = true + } + if (outOfRange) { + errors.err("frame-relative displacement out of 16-bit range in subroutine ${sub.label}", sub.position) + return + } + + // rewrite references: Symbol base -> FrameSlot base + sub.forEachChunk { chunk -> + val instrs = chunk.instructions + for (i in instrs.indices) { + val instr = instrs[i] + val mem = instr.memory ?: continue + val slot = slotBySymbol[mem.symbolName] ?: continue + val newMem = when (mem) { + is MemoryReference.Direct -> mem.copy(base = AddressBase.FrameSlot(slot)) + is MemoryReference.Indexed -> mem.copy(base = AddressBase.FrameSlot(slot)) + else -> continue + } + instrs[i] = instr.mapMemoryReferences { newMem } + } + } + + // prologue zeroing for clean (non-dirty) frame vars; DIRTY vars stay intentionally uninitialized + val clearInstrs = mutableListOf() + for (fv in frameVars) { + if (fv.dirty) continue + val irType = irTypeFor(fv.elemDt) ?: continue + val elemSize = target.memorySize(fv.elemDt, null) + for (element in 0 until fv.count) { + clearInstrs += IRInstructions.storeZero(Opcode.STOREZM, irType, IRMemory.frameDirect(fv.slot, element * elemSize)) + } + } + + // the variables now live per-activation; remove them from the static (BSS) symbol table + slotBySymbol.keys.forEach { program.st.removeIfExists(it) } + + if (clearInstrs.isNotEmpty()) { + // prepend into the existing first chunk (it carries the sub label; the chunk list + // structure and its label invariant must not change) + val firstChunk = sub.chunks.first() as IRCodeChunk + firstChunk.instructions.addAll(0, clearInstrs) + } + sub.frameSize = frameSize + } + + private fun containsCall(chunk: IRCodeChunkBase, callOpcodes: Set): Boolean { + var found = false + chunk.forEachChunkRecursive { c -> if (c.instructions.any { it.opcode in callOpcodes }) found = true } + return found + } + + /** returns the element data type when the variable is frameable, null otherwise */ + private fun isFrameableVariable(v: IRStStaticVariable, name: String, addressTaken: Set, asmTexts: List): DataType? { + if (name in addressTaken) return null + if (asmTexts.any { it.contains(name) }) return null + if (v.initializationValue != null) return null // static-initialized: must keep static storage + if (v.align > 0u) return null // explicit alignment request: keep static + if (v.zpwish == ZeropageWish.REQUIRE_ZEROPAGE || v.zpwish == ZeropageWish.PREFER_ZEROPAGE) return null + return when { + v.dt.isPointer -> v.dt // pointer storage is always just an address + v.dt.isBasic && v.dt.isNumericOrBool -> v.dt + v.dt.isArray && v.dt.sub != null && v.dt.sub != BaseDataType.POINTER -> { + val subDt = baseToScalar(v.dt.sub!!) ?: return null + if (subDt.isFloat || subDt.isStructInstance || subDt.isString || subDt.isPointer) null else subDt + } + else -> null + } + } + + private fun baseToScalar(base: BaseDataType): DataType? = when (base) { + BaseDataType.UBYTE -> DataType.UBYTE + BaseDataType.BYTE -> DataType.BYTE + BaseDataType.BOOL -> DataType.BOOL + BaseDataType.UWORD -> DataType.UWORD + BaseDataType.WORD -> DataType.WORD + BaseDataType.LONG -> DataType.LONG + else -> null + } + + private fun irTypeFor(dt: DataType): IRDataType? = when { + dt.isByteOrBool -> IRDataType.BYTE + dt.isWord -> IRDataType.WORD + dt.isLong || dt.isPointer -> IRDataType.LONG + else -> null + } +} diff --git a/codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt b/codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt index bb4265ff5..0650a40e0 100644 --- a/codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt +++ b/codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt @@ -281,6 +281,17 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT output.appendLine(code) } + // === stack frame context (Vertical Slice Prototype: frame residents' locals) === + // non-zero while emitting a subroutine that has a frame; the return translation + // emits `unlk a5` before each rts when this is set. + internal var currentFrameSize = 0 + + /** emits the frame teardown for the subroutine currently being translated, if it has a frame */ + internal fun emitFrameUnlk() { + if (currentFrameSize > 0) + emitLine("unlk a5") + } + // === virtual register file layout (1, 2 or 4 bytes per slot depending on type, word-aligned) === private data class RegFileLayout(val offsets: Map, val totalSize: Int) @@ -454,6 +465,12 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT private fun resolveMemoryBase(base: AddressBase, displacement: Int): String { val resolved = when (base) { + // combine slot offset and reference displacement into a single a5-relative displacement + is AddressBase.FrameSlot -> { + val total = base.offset + displacement + if (total !in -32768..32767) error("frame displacement $total out of 16-bit range") + return "$total(a5)" + } is AddressBase.Symbol -> resolveSymbolRef(base.name) is AddressBase.Absolute -> base.address.value.toHex() } @@ -728,6 +745,9 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT val entrypointNames = setOf("p8b_main.p8s_start", "main.start") if(sub.label in entrypointNames) emitLine("bsr run_global_inits") + currentFrameSize = sub.frameSize + if (sub.frameSize > 0) + emitLine("link a5,#-${sub.frameSize}") val livenessInstructions = sub.chunks.filterIsInstance().flatMap { it.instructions } val deadStoreSuppressionAllowed = canSuppressDeadStores(sub) var instructionOffset = 0 @@ -759,6 +779,7 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT } emitRaw("; End of subroutine: $subLabel") emitRaw("") + currentFrameSize = 0 } private fun emitAsmSubroutine(sub: IRAsmSubroutine) { diff --git a/codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt b/codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt index 691dd14e4..d98afecf2 100644 --- a/codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt +++ b/codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt @@ -131,6 +131,7 @@ internal fun AsmGen.translateControl(insn: IRInstruction, forwardedImmediateCall Opcode.RETURN -> { invalidateD0Cache() + emitFrameUnlk() emitLine("rts") } @@ -144,6 +145,7 @@ internal fun AsmGen.translateControl(insn: IRInstruction, forwardedImmediateCall val reg = insn.requireIntSourceA().intNumber emitLoadD0(reg, type) } + emitFrameUnlk() emitLine("rts") } @@ -156,6 +158,7 @@ internal fun AsmGen.translateControl(insn: IRInstruction, forwardedImmediateCall emitLine("moveq #$value, d0") else emitLine("move$s #$value, d0") + emitFrameUnlk() emitLine("rts") } diff --git a/codeGenM68k/src/prog8/codegen/m68k/M68kCodeGenerator.kt b/codeGenM68k/src/prog8/codegen/m68k/M68kCodeGenerator.kt index 3c5d61b85..78c50723c 100644 --- a/codeGenM68k/src/prog8/codegen/m68k/M68kCodeGenerator.kt +++ b/codeGenM68k/src/prog8/codegen/m68k/M68kCodeGenerator.kt @@ -7,6 +7,7 @@ import prog8.code.ast.PtProgram import prog8.code.core.CompilationOptions import prog8.code.core.IErrorReporter import prog8.codegen.intermediate.IRCodeGen +import prog8.codegen.intermediate.StackFrameLayout import prog8.intermediate.IRDataType import prog8.intermediate.IRFileWriter import prog8.intermediate.VirtualRegister @@ -28,6 +29,9 @@ class M68kCodeGenerator(val retainSSA: Boolean): ICodeGeneratorBackend { } irProgram.verifyRegisterTypes(virtualRegisterTypes) + // Vertical Slice Prototype: stack-frame locals for frameable leaf subroutines + StackFrameLayout(irProgram, errors).apply() + if (options.dumpVariables) dumpVariables(irProgram) diff --git a/codeGenM68k/test/TestStackFrameEmission.kt b/codeGenM68k/test/TestStackFrameEmission.kt new file mode 100644 index 000000000..e1e0c4706 --- /dev/null +++ b/codeGenM68k/test/TestStackFrameEmission.kt @@ -0,0 +1,102 @@ +package prog8tests.codegen.m68k + +import io.kotest.core.spec.style.FunSpec +import io.kotest.engine.spec.tempdir +import io.kotest.matchers.shouldBe +import prog8.code.core.CompilationOptions +import prog8.code.core.OutputType +import prog8.code.core.Position +import prog8.code.core.ZeropageType +import prog8.code.target.Qemu68kTarget +import prog8.codegen.m68k.AsmGen +import prog8.intermediate.* +import prog8tests.helpers.DummyStringEncoder +import kotlin.io.path.exists +import kotlin.io.path.readText + +// Vertical Slice Prototype: m68k backend lowering of FrameSlot memory references +// and the link/unlk frame prologue/epilogue (m68k-stack-memory-model.md §17). +class TestStackFrameEmission : FunSpec({ + + val tempRoot = tempdir().toPath() + + fun generateAsm(frameSize: Int, instructions: List): List { + val options = CompilationOptions.builder(Qemu68kTarget()) + .output(OutputType.RAW) + .zeropage(ZeropageType.DONTUSE) + .floats(false) + .compilerVersion("test") + .memtopAddress(0xffffu) + .optimize(true) + .build() + val program = IRProgram("test", IRSymbolTable(), options, DummyStringEncoder) + program.options.outputDir = tempRoot + val chunk = IRCodeChunk(null, null) + chunk.instructions.addAll(instructions) + val sub = IRSubroutine("test.start", emptyList(), emptyList(), Position.DUMMY, frameSize) + sub.chunks.add(chunk) + val block = IRBlock("test", false, IRBlock.Options(), Position.DUMMY) + block.children.add(sub) + program.blocks.add(block) + + tempRoot.toFile().deleteRecursively() + tempRoot.toFile().mkdirs() + AsmGen(program, Qemu68kTarget()).generate() + val asmFile = tempRoot.resolve("test.asm") + check(asmFile.exists()) { "Assembly file not written: $asmFile" } + return asmFile.readText().lines().map { it.trim() } + } + + test("frameless subroutine emits no frame setup") { + val lines = generateAsm(0, listOf( + IRInstructions.loadMemory(Opcode.LOADM, IRDataType.BYTE, 1, IRMemory.direct("some.var")), + IRInstructions.simple(Opcode.RETURN) + )) + lines.any { it.startsWith("link") } shouldBe false + lines.any { it.startsWith("unlk") } shouldBe false + } + + test("framed subroutine emits link prologue and unlk epilogue") { + val lines = generateAsm(16, listOf( + IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-1)), + IRInstructions.simple(Opcode.RETURN) + )) + lines.any { it == "link a5,#-16" } shouldBe true + lines.count { it == "unlk a5" } shouldBe 1 + lines.any { "-1(a5)" in it } shouldBe true + // epilogue must directly precede the subroutine's return + lines[lines.indexOf("unlk a5") + 1] shouldBe "rts" + } + + test("frame slot direct references lower to displacements relative to a5") { + val lines = generateAsm(16, listOf( + IRInstructions.storeImmediate(IRDataType.WORD, 5, IRMemory.frameDirect(-4)), + IRInstructions.loadMemory(Opcode.LOADM, IRDataType.BYTE, 1, IRMemory.frameDirect(-1)), + IRInstructions.memoryOpImmediate(Opcode.ADDIM, IRDataType.BYTE, IRMemory.frameDirect(-1), 3), + IRInstructions.simple(Opcode.RETURN) + )) + lines.any { it.contains("-4(a5)") && it.startsWith("move.w") } shouldBe true + lines.any { it.contains("-1(a5)") } shouldBe true + lines.any { it.startsWith("addq.b") || it.startsWith("add.b") } shouldBe true + } + + test("indexed frame slot access uses lea of the slot base plus index") { + val lines = generateAsm(16, listOf( + IRInstructions.loadMemory(Opcode.LOADX, IRDataType.BYTE, 1, IRMemory.frameIndexed(-8, 3, IRDataType.WORD, scale = 1)), + IRInstructions.storeZero(Opcode.STOREZX, IRDataType.BYTE, IRMemory.frameIndexed(-8, 3, IRDataType.WORD, scale = 1)), + IRInstructions.simple(Opcode.RETURN) + )) + lines.any { it.startsWith("lea") && "-8(a5)" in it } shouldBe true + lines.count { it.contains("(a0,d0.w)") } shouldBe 2 + } + + test("frame displacement combines slot offset and reference displacement") { + val lines = generateAsm(16, listOf( + IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-8, 4)), + IRInstructions.simple(Opcode.RETURN) + )) + // -8 slot + 4 displacement = -4(a5), a single combined displacement + lines.any { it.contains("-4(a5)") } shouldBe true + lines.any { it.contains("-8(a5)+4") } shouldBe false + } +}) diff --git a/codeGenNew6502/src/prog8/codegen/new6502/InstrLoadStore.kt b/codeGenNew6502/src/prog8/codegen/new6502/InstrLoadStore.kt index 0a5f8e7b4..43692334f 100644 --- a/codeGenNew6502/src/prog8/codegen/new6502/InstrLoadStore.kt +++ b/codeGenNew6502/src/prog8/codegen/new6502/InstrLoadStore.kt @@ -1114,6 +1114,7 @@ internal fun AsmGen.resolveAddress(base: AddressBase, displacement: Int = 0): St if (displacement != 0) "$resolved+$displacement" else resolved } is AddressBase.Absolute -> base.address.value.toHex() + is AddressBase.FrameSlot -> error("stack frame slots are only supported on m68k targets, got $base") } } diff --git a/compiler/test/TestStackFrameLocals.kt b/compiler/test/TestStackFrameLocals.kt new file mode 100644 index 000000000..24291cb72 --- /dev/null +++ b/compiler/test/TestStackFrameLocals.kt @@ -0,0 +1,167 @@ +package prog8tests.compiler + +import io.kotest.core.spec.style.FunSpec +import io.kotest.engine.spec.tempdir +import io.kotest.matchers.shouldBe +import prog8.code.target.C64Target +import prog8.code.target.Qemu68kTarget +import prog8.intermediate.IRFileReader +import prog8tests.helpers.compileText + +// Vertical Slice Prototype end-to-end tests (m68k-stack-memory-model.md §17): +// stack-frame locals for frameable leaf subroutines on m68k targets. +class TestStackFrameLocals : FunSpec({ + + fun compile(src: String, target: prog8.code.core.ICompilationTarget = Qemu68kTarget()): Pair, String> { + val outputDir = tempdir().toPath() + compileText(target, optimize = false, src, outputDir, writeAssembly = true, assemble = false) + val asmFile = outputDir.toFile().listFiles()!!.single { it.name.endsWith(".asm") } + val irFile = outputDir.toFile().listFiles()!!.singleOrNull { it.name.endsWith(".p8ir") } + return asmFile.readText().lines().map { it.trim() } to (irFile?.readText() ?: "") + } + + test("frameable leaf subroutine gets an a5 frame") { + val src = """ +main { + ubyte gv = 3 + sub leaf() { + ubyte a + word b + ubyte[4] arr + a = gv + b = a * 2 + arr[3] = a + } + sub start() { + leaf() + } +} +""" + val (lines, ir) = compile(src) + // locals laid out below a5: a at -1, arr at -8..-5, b at -10..-9 -> frame size 10 + lines.any { it == "link a5,#-10" } shouldBe true + lines.count { it == "unlk a5" } shouldBe 1 + val unlkIdx = lines.indexOf("unlk a5") + lines[unlkIdx + 1] shouldBe "rts" + // accesses lowered to a5 displacements + lines.any { "-1(a5)" in it } shouldBe true + lines.any { "-10(a5)" in it } shouldBe true + lines.any { "-5(a5)" in it } shouldBe true // arr[3] + // locals are no longer static storage + lines.none { "p8v_a:" in it || "p8v_arr:" in it || "p8v_b:" in it } shouldBe true + // IR carries the new syntax and frame size, and is reloadable + ir.contains("frame:-1") shouldBe true + ir.contains("FRAMESIZE=\"10\"") shouldBe true + val reloaded = IRFileReader().read(ir) + reloaded.allSubs().first { it.label.endsWith("p8s_leaf") }.frameSize shouldBe 10 + } + + test("subroutine with parameters keeps the static path") { + val src = """ +main { + sub withparam(ubyte v) { + ubyte a + a = v + } + sub start() { + withparam(1) + } +} +""" + val (lines, _) = compile(src) + lines.any { it.startsWith("link ") } shouldBe false + lines.any { "-1(a5)" in it } shouldBe false + } + + test("subroutine that calls another is not framed") { + val src = """ +main { + ubyte gv = 2 + sub inner() { gv += 1 } + sub caller() { + ubyte a + inner() + a = gv + } + sub start() { + caller() + } +} +""" + val (lines, _) = compile(src) + lines.any { it.startsWith("link ") } shouldBe false + } + + test("address-taken local stays static, rest of the frame still works") { + val src = """ +main { + sub leaf() { + ubyte x + ^^ubyte p = &x + p^^ = 42 + x += p^^ + } + sub start() { + leaf() + } +} +""" + val (lines, _) = compile(src) + // x is address-taken: keeps its static storage; p (a pointer scalar) gets framed + lines.any { "p8v_x:" in it } shouldBe true + lines.any { it == "link a5,#-4" } shouldBe true + lines.any { "-4(a5)" in it } shouldBe true + } + + test("defer-referenced subroutine is not framed") { + val src = """ +main { + sub leaf() { + ubyte a = 1 + defer a += 1 + } + sub start() { + leaf() + } +} +""" + val (lines, _) = compile(src) + // DeferProcessor adds handler-call instructions -> the sub contains calls -> never frameable + lines.any { it.startsWith("link ") } shouldBe false + } + + test("oversized local array keeps static storage while other locals are framed") { + val src = """ +main { + sub leaf() { + ubyte small + ubyte[17000] big + small = 1 + } + sub start() { + leaf() + } +} +""" + val (lines, _) = compile(src) + lines.any { it == "link a5,#-2" } shouldBe true // only `small` is framed + lines.any { "p8v_big" in it } shouldBe true // big array remains static + } + + test("other targets are unaffected") { + val src = """ +main { + sub leaf() { + ubyte a + a = 5 + } + sub start() { + leaf() + } +} +""" + val (lines, _) = compile(src, C64Target()) + lines.any { "link " in it } shouldBe false + lines.any { "(a5)" in it } shouldBe false + } +}) diff --git a/intermediate/src/prog8/intermediate/IRFileReader.kt b/intermediate/src/prog8/intermediate/IRFileReader.kt index 358f55d21..e662845b0 100644 --- a/intermediate/src/prog8/intermediate/IRFileReader.kt +++ b/intermediate/src/prog8/intermediate/IRFileReader.kt @@ -81,7 +81,7 @@ class IRFileReader { val compilerVersion = start.attributes.asSequence().single { it.name.localPart == "COMPILERVERSION" }.value val irFormat = start.attributes.asSequence().singleOrNull { it.name.localPart == "IRFORMAT" }?.value ?: throw IRParseException("missing IRFORMAT") - if(irFormat != IR_FORMAT_VERSION.toString()) + if(irFormat.toIntOrNull() == null || irFormat.toInt() !in IR_FORMAT_MIN_SUPPORTED..IR_FORMAT_VERSION) throw IRParseException("unsupported IR format: $irFormat") options = parseOptions(reader, compilerVersion) val asmsymbols = parseAsmSymbols(reader) @@ -585,7 +585,8 @@ class IRFileReader { val sub = IRSubroutine(attrs.getValue("NAME"), parseParameters(reader), if(returns=="") emptyList() else returns.split(',').map { parseDatatype(it, false) }, - parsePosition(attrs.getValue("POS"))) + parsePosition(attrs.getValue("POS")), + attrs["FRAMESIZE"]?.toIntOrNull() ?: 0) skipText(reader) while(reader.peek().isStartElement) { diff --git a/intermediate/src/prog8/intermediate/IRFileWriter.kt b/intermediate/src/prog8/intermediate/IRFileWriter.kt index a9b243ccb..129bbe664 100644 --- a/intermediate/src/prog8/intermediate/IRFileWriter.kt +++ b/intermediate/src/prog8/intermediate/IRFileWriter.kt @@ -145,6 +145,8 @@ class IRFileWriter(private val irProgram: IRProgram, outfileOverride: Path?) { xml.writeAttribute("NAME", child.label) xml.writeAttribute("RETURNS", child.returns.joinToString(",") { it.irTypeString(null).lowercase() }) xml.writeAttribute("POS", child.position.toString()) + if (child.frameSize != 0) + xml.writeAttribute("FRAMESIZE", child.frameSize.toString()) xml.writeCharacters("\n") xml.writeStartElement("PARAMS") xml.writeCharacters("\n") diff --git a/intermediate/src/prog8/intermediate/IRFormat.kt b/intermediate/src/prog8/intermediate/IRFormat.kt index 78de63eb8..d9dcece05 100644 --- a/intermediate/src/prog8/intermediate/IRFormat.kt +++ b/intermediate/src/prog8/intermediate/IRFormat.kt @@ -8,8 +8,12 @@ package prog8.intermediate * The version of the .p8ir file format. Format 2 uses structured instruction operands and is * intentionally incompatible with the older positional format. Format 3 adds union struct definitions. * Format 4 replaces the ARRAY_POINTER base type with ARRAY+POINTER+pointeeSub. + * Format 5 adds the frame: memory address base (stack frame slots) and the SUB FRAMESIZE attribute. */ -const val IR_FORMAT_VERSION = 4 +const val IR_FORMAT_VERSION = 5 + +/** lowest .p8ir format version this reader accepts; version 5 only adds syntax that v4 files never use */ +const val IR_FORMAT_MIN_SUPPORTED = 4 object IRFormat { // Regex patterns for parsing IR text lines diff --git a/intermediate/src/prog8/intermediate/IRInstructionsFactory.kt b/intermediate/src/prog8/intermediate/IRInstructionsFactory.kt index d2817dc59..52a9ac4da 100644 --- a/intermediate/src/prog8/intermediate/IRInstructionsFactory.kt +++ b/intermediate/src/prog8/intermediate/IRInstructionsFactory.kt @@ -21,6 +21,19 @@ object IRMemory { fun direct(address: UInt, displacement: Int = 0): MemoryReference = MemoryReference.Direct(AddressBase.Absolute(MemoryAddress(address)), displacement) + fun frame(offset: Int): AddressBase = AddressBase.FrameSlot(offset) + + fun frameDirect(offset: Int, displacement: Int = 0): MemoryReference = + MemoryReference.Direct(AddressBase.FrameSlot(offset), displacement) + + fun frameIndexed( + offset: Int, + indexRegister: Int, + indexType: IRDataType, + scale: Int = 1, + displacement: Int = 0 + ): MemoryReference = indexed(AddressBase.FrameSlot(offset), indexRegister, indexType, scale, displacement) + fun indexed( base: AddressBase, indexRegister: Int, diff --git a/intermediate/src/prog8/intermediate/IROperands.kt b/intermediate/src/prog8/intermediate/IROperands.kt index aaaa33e1c..fefdbb7e6 100644 --- a/intermediate/src/prog8/intermediate/IROperands.kt +++ b/intermediate/src/prog8/intermediate/IROperands.kt @@ -112,7 +112,7 @@ val ImmediateOperand.integerValue: Int? data class HardwareSlotOperand(val slot: CallingConventionSlot, val type: IRDataType) -/** base address of a memory reference: a symbol, or a fixed numeric address */ +/** base address of a memory reference: a symbol, a fixed numeric address, or a stack frame slot */ sealed interface AddressBase { data class Symbol(val name: String) : AddressBase { init { @@ -121,6 +121,20 @@ sealed interface AddressBase { } data class Absolute(val address: MemoryAddress) : AddressBase + + /** + * A location in the current subroutine's stack frame, addressed relative to the + * frame pointer. [offset] is the signed, frame-relative byte offset assigned by + * the frame-layout pass (negative for locals, positive for incoming parameters); + * it is never 0. The displacement of the enclosing MemoryReference is added on + * top of this offset (so it stays >= 0, addressing into the slot). + */ + data class FrameSlot(val offset: Int) : AddressBase { + init { + require(offset != 0) { "frame slot offset cannot be 0" } + require(offset in -32768..32767) { "frame slot offset out of 16-bit range: $offset" } + } + } } diff --git a/intermediate/src/prog8/intermediate/IRProgram.kt b/intermediate/src/prog8/intermediate/IRProgram.kt index 0affa6c50..ba0f904eb 100644 --- a/intermediate/src/prog8/intermediate/IRProgram.kt +++ b/intermediate/src/prog8/intermediate/IRProgram.kt @@ -576,7 +576,9 @@ class IRSubroutine( override val label: String, val parameters: List, val returns: List, - val position: Position): IIRBlockElement { + val position: Position, + /** size in bytes of the local area of this subroutine's stack frame; 0 means no frame */ + var frameSize: Int = 0): IIRBlockElement { class IRParam(val name: String, val dt: DataType) @@ -589,6 +591,7 @@ class IRSubroutine( // params and return value should not be str require(parameters.all{ it.dt.isNumericOrBool || it.dt.isPointer }) {"parameter is not a bool, number or pointer"} require(returns.all { it.isNumericOrBool || it.isPointer}) {"returntype is not a bool, number or pointer"} + require(frameSize >= 0 && frameSize % 2 == 0) {"frame size must be a non-negative even number: $frameSize"} } operator fun plusAssign(chunk: IRCodeChunkBase) { diff --git a/intermediate/src/prog8/intermediate/IRTextCodec.kt b/intermediate/src/prog8/intermediate/IRTextCodec.kt index 1fa4826ee..d773c0577 100644 --- a/intermediate/src/prog8/intermediate/IRTextCodec.kt +++ b/intermediate/src/prog8/intermediate/IRTextCodec.kt @@ -102,6 +102,7 @@ object IRTextCodec { private fun printAddressBase(base: AddressBase): String = when (base) { is AddressBase.Symbol -> base.name is AddressBase.Absolute -> base.address.toHex() + is AddressBase.FrameSlot -> "frame:${base.offset}" } fun printHardwareSlot(operand: HardwareSlotOperand): String = @@ -396,7 +397,12 @@ object IRTextCodec { return reference } - val base: AddressBase = if (parts[0].first().isLetter()) + val base: AddressBase = if (parts[0].startsWith("frame:")) { + // frame:; ':' is not a legal character in a symbol name, so this never collides + val off = parts[0].removePrefix("frame:").toIntOrNull() + ?: throw IRParseException("${schema.opcode}: invalid frame slot offset in $token") + AddressBase.FrameSlot(off) + } else if (parts[0].first().isLetter()) AddressBase.Symbol(parts[0]) else AddressBase.Absolute(parseAddress(parts[0], "${schema.opcode}: invalid memory address")) diff --git a/intermediate/test/TestIRFileInOut.kt b/intermediate/test/TestIRFileInOut.kt index 424874e4f..9ae085c79 100644 --- a/intermediate/test/TestIRFileInOut.kt +++ b/intermediate/test/TestIRFileInOut.kt @@ -38,7 +38,7 @@ class TestIRFileInOut: FunSpec({ val generatedFile = writer.write() val lines = generatedFile.readLines() lines[0] shouldBe "" - lines[1] shouldBe "" + lines[1] shouldBe "" lines.last() shouldBe "" generatedFile.deleteExisting() lines.size shouldBeGreaterThan 20 diff --git a/intermediate/test/TestIRTextCodec.kt b/intermediate/test/TestIRTextCodec.kt index e4cbb41ec..b177c97b1 100644 --- a/intermediate/test/TestIRTextCodec.kt +++ b/intermediate/test/TestIRTextCodec.kt @@ -38,6 +38,22 @@ class TestIRTextCodec: FunSpec({ roundtrip("addim.b #3.b,[main.var]") } + test("frame slot memory references") { + roundtrip("loadm.w r0.w,[frame:-8]") + roundtrip("storem.b r0.b,[frame:-1]") + roundtrip("storezm.w [frame:-4]") + roundtrip("storezx.b [frame:-16+r3.w*4]") + roundtrip("loadx.l r1.l,[frame:12+r2.w]") + roundtrip("addm.b r5.b,[frame:-8+4]") + roundtrip("storeim.b #\$2a.b,[frame:-2]") + roundtrip("incm.w [frame:-10]") + IRTextCodec.print(IRTextCodec.parse("loadm.w r0.w,[frame:-8]")) shouldBe "loadm.w r0.w,[frame:-8]" + // frame:0 is illegal (a slot offset of 0 is never valid) + shouldThrow { IRTextCodec.parse("loadm.w r0.w,[frame:0]") } + // negative displacement on the reference itself stays illegal + shouldThrow { IRTextCodec.parse("loadm.w r0.w,[main.var+-3]") } + } + test("hardware slots") { roundtrip("loadhr.w r1.w,s10.w") roundtrip("storehr.b r1.b,s0.b") diff --git a/virtualmachine/src/prog8/vm/VmProgramLoader.kt b/virtualmachine/src/prog8/vm/VmProgramLoader.kt index e95bc21d3..b78829efc 100644 --- a/virtualmachine/src/prog8/vm/VmProgramLoader.kt +++ b/virtualmachine/src/prog8/vm/VmProgramLoader.kt @@ -18,6 +18,7 @@ class VmProgramLoader { fun load(irProgram: IRProgram, memory: Memory): Pair, Map> { irProgram.validate() + rejectFrameBasedIr(irProgram) irProgram.st.stripAllPrefixes() placeholders.clear() subroutines.clear() @@ -330,6 +331,24 @@ class VmProgramLoader { } } + /** + * Frame-based IR (stack frame slot memory model) is only supported by the m68k + * backend for now; the VM's activation-record support is a follow-up + * (m68k-stack-memory-model.md §9). Reject explicitly instead of silently + * mis-resolving frame references as static storage. + */ + private fun rejectFrameBasedIr(program: IRProgram) { + program.allSubs().forEach { sub -> + if (sub.frameSize != 0) + throw IRParseException("frame-based IR (subroutine ${sub.label} has frame size ${sub.frameSize}) is not (yet) supported on the virtual machine") + } + program.forEachInstruction { instr -> + val base = (instr.memory as? MemoryReference.Direct)?.base ?: (instr.memory as? MemoryReference.Indexed)?.base + if (base is AddressBase.FrameSlot) + throw IRParseException("frame-based IR (${instr.opcode} references frame slot ${base.offset}) is not (yet) supported on the virtual machine") + } + } + private fun assignArtificialLabelAddresses(program: IRProgram) { program.forEachChunk { chunk -> if(chunk !is IRCodeChunk && chunk !is IRLoopChunk) diff --git a/virtualmachine/test/TestVm.kt b/virtualmachine/test/TestVm.kt index 4b206d7b3..0fa93ddb3 100644 --- a/virtualmachine/test/TestVm.kt +++ b/virtualmachine/test/TestVm.kt @@ -1,3 +1,4 @@ +import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrowWithMessage import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldBeEmpty @@ -38,6 +39,33 @@ class TestVm: FunSpec( { vm.stepCount shouldBe 0 } + test("vm rejects frame-based IR (frameSize set)") { + val program = IRProgram("test", IRSymbolTable(), getTestOptions(), VMTarget()) + val block = IRBlock("testmain", false, IRBlock.Options(), Position.DUMMY) + val startSub = IRSubroutine("testmain.testsub", emptyList(), emptyList(), Position.DUMMY, frameSize = 8) + val code = IRCodeChunk(startSub.label, null) + code += IRInstructions.returnVoid() + startSub += code + block += startSub + program.addBlock(block) + shouldThrow { VirtualMachine(program) } + .message!!.contains("not (yet) supported on the virtual machine") shouldBe true + } + + test("vm rejects frame-based IR (FrameSlot memory reference)") { + val program = IRProgram("test", IRSymbolTable(), getTestOptions(), VMTarget()) + val block = IRBlock("testmain", false, IRBlock.Options(), Position.DUMMY) + val startSub = IRSubroutine("testmain.testsub", emptyList(), emptyList(), Position.DUMMY) + val code = IRCodeChunk(startSub.label, null) + code += IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-1)) + code += IRInstructions.returnVoid() + startSub += code + block += startSub + program.addBlock(block) + shouldThrow { VirtualMachine(program) } + .message!!.contains("not (yet) supported on the virtual machine") shouldBe true + } + test("vm execution: modify memory") { val program = IRProgram("test", IRSymbolTable(), getTestOptions(), VMTarget()) val block = IRBlock("testmain", false, IRBlock.Options(), Position.DUMMY)