From fe90ca572d8ce8063008a42f24a464b8b9565ffb Mon Sep 17 00:00:00 2001 From: Irmen de Jong Date: Sat, 3 Oct 2026 03:00:04 +0200 Subject: [PATCH] slice 2 for m68k stack mememory model --- .../codegen/intermediate/StackFrameLayout.kt | 352 +++++++++++++++--- codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt | 18 +- .../src/prog8/codegen/m68k/InstrControl.kt | 33 ++ codeGenM68k/test/TestStackFrameEmission.kt | 87 ++++- .../src/prog8/codegen/new6502/InstrControl.kt | 2 + compiler/test/TestStackFrameLocals.kt | 155 +++++++- ideas/m68k-stack-memory-model.md | 109 +++++- .../src/prog8/intermediate/IRCalls.kt | 14 + .../src/prog8/intermediate/IRFileReader.kt | 3 +- .../src/prog8/intermediate/IRFileWriter.kt | 2 + .../src/prog8/intermediate/IRProgram.kt | 14 +- .../src/prog8/intermediate/IRTextCodec.kt | 4 + intermediate/test/TestIRTextCodec.kt | 9 + .../src/prog8/vm/VmProgramLoader.kt | 6 + 14 files changed, 723 insertions(+), 85 deletions(-) diff --git a/codeGenIntermediate/src/prog8/codegen/intermediate/StackFrameLayout.kt b/codeGenIntermediate/src/prog8/codegen/intermediate/StackFrameLayout.kt index 8c7a6da76..df025aa17 100644 --- a/codeGenIntermediate/src/prog8/codegen/intermediate/StackFrameLayout.kt +++ b/codeGenIntermediate/src/prog8/codegen/intermediate/StackFrameLayout.kt @@ -7,27 +7,40 @@ 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. + * Stack frame layout for m68k targets (m68k-stack-memory-model.md §3/§6.1/§7/§17). * - * 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. + * Classifies subroutines as "frameable" and, for those: + * - moves their plain-data locals into A5-relative frame slots (negative offsets), + * - moves their parameters into the caller-pushed incoming argument area (positive offsets, + * 4 bytes per parameter, first parameter at the highest offset), + * - rewrites every memory reference to AddressBase.FrameSlot and every call site's argument + * locations to CallLocation.FrameSlot, + * - removes the moved variables from the static (BSS) symbol table, + * - emits prologue zero-clears for clean locals (parameters are written by the caller), + * - stamps frameSize / incomingSize on the IRSubroutine. * - * 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. + * Everything else keeps the existing static path verbatim. A subroutine is frameable when ALL of: + * - it contains no inline assembly chunk, + * - it is not on a call-graph cycle and makes no indirect calls: until call-live virtual + * registers move into frames (design-doc slice 3), a re-entrant activation would share the + * flat program-static p8_regfile slots, + * - its address is never taken (subptr dispatch tables, interrupt handler registration, ...), + * - all its parameters are frameable (a parameter that cannot move forces the whole + * subroutine to keep the static convention, since the caller must then write static cells), + * - every local it moves is plain data (byte/word/long/pointer scalar or numeric/bool array) + * that is not address-taken, inline-asm referenced, static-initialized or explicitly aligned. + * Locals that fail this test simply stay static; they do not disqualify the subroutine. */ 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 + + /** bytes reserved per incoming parameter (padded longword slot, §6.1) */ + const val PARAM_SLOT_SIZE = 4 + + /** frame-pointer offset of the last-pushed (rightmost) argument slot: above saved a5 + return address */ + const val INCOMING_BASE = 8 } private val target = program.options.compTarget @@ -36,6 +49,7 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro fun apply() { val allSubs = program.allSubs().toList() + val subLabels = allSubs.map { it.label }.toSet() val referencedIn = mutableMapOf>() val addressTaken = mutableSetOf() @@ -58,29 +72,223 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro 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) } + val callSites = collectCallSites(subLabels) + val dispatchTargets = collectDispatchTargets(subLabels, addressTaken) + val indirectCallers = allSubs.filter { sub -> containsIndirectCall(sub) }.map { it.label }.toSet() + val onCycle = subsOnCallCycles(callSites) + + val framedSubs = mutableMapOf() + for (sub in allSubs) { + val result = frameableSubroutine(sub, referencedIn, addressTaken, asmTexts, callSites, dispatchTargets, indirectCallers, onCycle) + if (result != null) + framedSubs[sub.label] = result + } + + if (framedSubs.isNotEmpty()) + rewriteCallArgumentLocations(framedSubs) } + private class CallSiteRef(val chunk: IRCodeChunkBase, val index: Int, val caller: String, val site: CallSite) + + /** all direct calls between normal subroutines, grouped by callee label */ + private fun collectCallSites(subLabels: Set): Map> { + val result = mutableMapOf>() + for (sub in program.allSubs()) { + sub.forEachChunk { chunk -> + val instrs = chunk.instructions + for (i in instrs.indices) { + val site = instrs[i].callSite ?: continue + val ref = site.codeReference + if (ref is CodeReference.Label && ref.name in subLabels) { + result.getOrPut(ref.name) { mutableListOf() } += CallSiteRef(chunk, i, sub.label, site) + } + } + } + } + return result + } + + /** + * Subroutine labels whose address is taken somewhere in the program: dispatch tables + * (static variable initializers holding symbolic references), address-taken immediates, + * or referenced from inline assembly. Such a subroutine can be entered indirectly and + * therefore possibly re-entered while an activation is live. + */ + private fun collectDispatchTargets(subLabels: Set, addressTaken: Set): Set { + val targets = mutableSetOf() + addressTaken.forEach { sym -> if (sym in subLabels) targets += sym } + program.st.allVariables().forEach { v -> + val init = v.initializationValue ?: return@forEach + val refs = when (init) { + is IRVariableInitializer.Array -> init.elements.mapNotNull { (it as? IRStSymbolicReference.Symbol)?.name } + is IRVariableInitializer.Str, is IRVariableInitializer.Numeric -> emptyList() + } + refs.forEach { name -> if (name in subLabels) targets += name } + } + return targets + } + + private fun containsIndirectCall(sub: IRSubroutine): Boolean { + var found = false + sub.forEachInstruction { instr -> + val site = instr.callSite ?: return@forEachInstruction + if (site.codeReference is CodeReference.Indirect) + found = true + } + return found + } + + /** + * Labels of subroutines that lie on a cycle of direct calls (direct recursion or mutual + * recursion), using an iterative Tarjan SCC pass. Those subroutines can have two live + * activations at once, which the flat program-static regfile cannot support yet (slice 3). + */ + private fun subsOnCallCycles(callSites: Map>): Set { + val edges = mutableMapOf>() + for (sub in program.allSubs()) + edges.getOrPut(sub.label) { mutableSetOf() } + for ((callee, refs) in callSites) { + for (ref in refs) { + val outs = edges.getOrPut(ref.caller) { mutableSetOf() } + if (outs.add(callee)) + edges.getOrPut(callee) { mutableSetOf() } + } + } + + val index = mutableMapOf() + val lowLink = mutableMapOf() + val onStack = mutableSetOf() + val sccStack = ArrayDeque() + var counter = 0 + val onCycle = mutableSetOf() + + for (root in edges.keys) { + if (root in index) continue + index[root] = counter + lowLink[root] = counter + counter++ + sccStack.addLast(root) + onStack += root + val work = ArrayDeque>>() + work.addLast(root to edges.getValue(root).iterator()) + while (work.isNotEmpty()) { + val (node, children) = work.last() + if (children.hasNext()) { + val next = children.next() + when { + next !in index -> { + index[next] = counter + lowLink[next] = counter + counter++ + sccStack.addLast(next) + onStack += next + work.addLast(next to edges.getValue(next).iterator()) + } + next in onStack -> { + lowLink[node] = minOf(lowLink.getValue(node), index.getValue(next)) + } + } + } else { + work.removeLast() + if (lowLink.getValue(node) == index.getValue(node)) { + val members = mutableListOf() + while (true) { + val w = sccStack.removeLast() + onStack -= w + members += w + if (w == node) break + } + val cyclic = members.size > 1 || node in edges.getValue(node) + if (cyclic) onCycle += members + } + if (work.isNotEmpty()) { + val parent = work.last().first + lowLink[parent] = minOf(lowLink.getValue(parent), lowLink.getValue(node)) + } + } + } + } + return onCycle + } + + private class FrameLayoutResult( + val frameSize: Int, + val incomingSize: Int, + /** parameter variable name -> frame offset of its incoming slot */ + val paramSlots: Map, + /** parameter variable name -> right-justification displacement within its slot */ + val paramDisplacements: Map + ) + private fun frameableSubroutine( sub: IRSubroutine, referencedIn: Map>, addressTaken: Set, - asmTexts: List - ) { - if (sub.parameters.isNotEmpty()) return - if (sub.frameSize != 0) return + asmTexts: List, + callSites: Map>, + dispatchTargets: Set, + indirectCallers: Set, + onCycle: Set + ): FrameLayoutResult? { + if (sub.hasFrame) return null 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 + if (hasInlineAsm) return null + if (sub.label in onCycle || sub.label in indirectCallers || sub.label in dispatchTargets) return null + if (isLabelReferencedInAsm(sub.label, asmTexts)) return null + + // every parameter must be able to move: otherwise the caller keeps writing static cells + // IRParam.name is the scoped variable name; call sites refer to the same parameter by its + // last name segment only, so accept both forms. + val scopedParamNames = sub.parameters.map { param -> + if (param.name.startsWith("${sub.label}.")) param.name else "${sub.label}.${param.name}" + } + val paramSlots = mutableMapOf() + val paramDisplacements = mutableMapOf() + for ((index, _) in sub.parameters.withIndex()) { + val name = scopedParamNames[index] + // a parameter referenced from outside the subroutine (defer handler, external code) + // cannot become per-activation storage: the whole subroutine keeps the static convention + val refs = referencedIn[name] + if (refs != null && refs != setOf(sub.label)) return null + val v = program.st.lookup(name) as? IRStStaticVariable ?: return null + val elemDt = isFrameableVariable(v, name, addressTaken, asmTexts) ?: return null + if (v.length != null) return null // array parameters don't exist in the language, but be safe + // first parameter ends up at the highest offset, last one just above the return address + val offset = INCOMING_BASE + PARAM_SLOT_SIZE * (sub.parameters.size - 1 - index) + paramSlots[name] = offset + // narrow values are right-justified in their longword slot + paramDisplacements[name] = when (irTypeFor(elemDt)) { + IRDataType.BYTE -> 3 + IRDataType.WORD -> 2 + else -> 0 + } + } + + // all call sites must use the plain parameter-memory convention of this convention + val sites = callSites[sub.label].orEmpty() + if (paramSlots.isNotEmpty()) { + if (sites.isEmpty()) return null // no caller found: keep the static convention + for (siteRef in sites) { + val args = siteRef.site.arguments + if (args.size != sub.parameters.size) return null + for ((argIndex, arg) in args.withIndex()) { + val loc = arg.location + if (loc !is CallLocation.ParameterMemory) return null + if (loc.address != null) return null + val scoped = scopedParamNames[argIndex] + if (loc.name.isNotBlank() && loc.name != scoped && loc.name != scoped.substringAfterLast('.')) + return null + } + } + } // 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) + name.startsWith(prefix) && referencedIn.getValue(name) == setOf(sub.label) && name !in paramSlots }.sorted() - if (candidates.isEmpty()) return + if (candidates.isEmpty() && paramSlots.isEmpty()) return null val frameVars = mutableListOf() var cursor = 0 @@ -99,48 +307,57 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro cursor -= size frameVars += FrameVar(name, cursor, elemDt, count, v.dirty) } - if (frameVars.isEmpty()) return + if (frameVars.isEmpty() && paramSlots.isEmpty()) return null 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 + return null } // 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) + val mem = instr.memory + val sym = mem?.symbolName + val slot = if (sym != null) slotOf(sym, slotBySymbol, paramSlots) else null + // parameters are right-justified in their padded longword slot, so include that displacement + val extra = paramDisplacements[sym] ?: 0 + val disp = when (mem) { + is MemoryReference.Direct -> slot?.plus(mem.displacement)?.plus(extra) + is MemoryReference.Indexed -> slot?.plus(mem.displacement)?.plus(extra) 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 + return null } - // rewrite references: Symbol base -> FrameSlot base + // rewrite references: Symbol base -> FrameSlot base (parameters additionally get the + // right-justification displacement of their padded slot) 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 sym = mem.symbolName ?: continue + val slot = slotOf(sym, slotBySymbol, paramSlots) ?: continue val newMem = when (mem) { - is MemoryReference.Direct -> mem.copy(base = AddressBase.FrameSlot(slot)) - is MemoryReference.Indexed -> mem.copy(base = AddressBase.FrameSlot(slot)) + is MemoryReference.Direct -> + mem.copy(base = AddressBase.FrameSlot(slot), displacement = mem.displacement + (paramDisplacements[sym] ?: 0)) + is MemoryReference.Indexed -> + mem.copy(base = AddressBase.FrameSlot(slot), displacement = mem.displacement + (paramDisplacements[sym] ?: 0)) else -> continue } instrs[i] = instr.mapMemoryReferences { newMem } } } - // prologue zeroing for clean (non-dirty) frame vars; DIRTY vars stay intentionally uninitialized + // prologue zeroing for clean (non-dirty) locals; incoming parameters are written by the caller val clearInstrs = mutableListOf() for (fv in frameVars) { if (fv.dirty) continue @@ -151,8 +368,8 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro } } - // the variables now live per-activation; remove them from the static (BSS) symbol table - slotBySymbol.keys.forEach { program.st.removeIfExists(it) } + // the moved variables now live per-activation; remove them from the static (BSS) symbol table + (slotBySymbol.keys + paramSlots.keys).forEach { program.st.removeIfExists(it) } if (clearInstrs.isNotEmpty()) { // prepend into the existing first chunk (it carries the sub label; the chunk list @@ -161,18 +378,60 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro firstChunk.instructions.addAll(0, clearInstrs) } sub.frameSize = frameSize + sub.incomingSize = paramSlots.size * PARAM_SLOT_SIZE + return FrameLayoutResult(frameSize, sub.incomingSize, paramSlots, paramDisplacements) } - 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 + private fun slotOf(symbol: String?, slotBySymbol: Map, paramSlots: Map): Int? { + if (symbol == null) return null + return slotBySymbol[symbol] ?: paramSlots[symbol] } + /** rewrite the argument locations of every call to a framed subroutine */ + private fun rewriteCallArgumentLocations(framedSubs: Map) { + if (framedSubs.isEmpty()) return + for (sub in program.allSubs()) { + val offsetsByCallee = mutableMapOf>() + for ((callee, layout) in framedSubs) { + val params = calleeParameters(callee) ?: continue + if (params.isEmpty()) continue + offsetsByCallee[callee] = params.mapIndexed { index, param -> + val scoped = if (param.startsWith("$callee.")) param else "$callee.$param" + layout.paramSlots[scoped] ?: 0 + } + } + if (offsetsByCallee.isEmpty()) continue + sub.forEachChunk { chunk -> + val instrs = chunk.instructions + for (i in instrs.indices) { + val instr = instrs[i] + val site = instr.callSite ?: continue + val callee = (site.codeReference as? CodeReference.Label)?.name ?: continue + val offsets = offsetsByCallee[callee] ?: continue + if (site.arguments.size != offsets.size) continue + var changed = false + val rewritten = site.arguments.mapIndexed { argIndex, arg -> + if (arg.location is CallLocation.FrameSlot) { + arg + } else { + changed = true + arg.copy(location = CallLocation.FrameSlot(offsets[argIndex])) + } + } + if (changed) + instrs[i] = instr.copy(callSite = site.copy(arguments = rewritten)) + } + } + } + } + + private fun calleeParameters(callee: String): List? = + program.allSubs().firstOrNull { it.label == callee }?.parameters?.map { it.name } + /** 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 (isLabelReferencedInAsm(name, asmTexts)) 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 @@ -187,6 +446,15 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro } } + /** true if [label] occurs as a whole symbol reference in any inline assembly text */ + private fun isLabelReferencedInAsm(label: String, asmTexts: List): Boolean { + // Avoid false positives where one label is a prefix/suffix of another (e.g. main.foo + // inside main.foobar). Treat label characters as letters, digits, underscore and dot. + val escaped = Regex.escape(label) + val pattern = "(? DataType.UBYTE BaseDataType.BYTE -> DataType.BYTE diff --git a/codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt b/codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt index 0650a40e0..8bfe1b603 100644 --- a/codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt +++ b/codeGenM68k/src/prog8/codegen/m68k/AsmGen.kt @@ -281,14 +281,14 @@ 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 + // === stack frame context (m68k-stack-memory-model §3/§17) === + // true while emitting a subroutine that has a frame (local area and/or incoming argument + // area); the return translation emits `unlk a5` before each rts when this is set. + internal var frameActive = false /** emits the frame teardown for the subroutine currently being translated, if it has a frame */ internal fun emitFrameUnlk() { - if (currentFrameSize > 0) + if (frameActive) emitLine("unlk a5") } @@ -745,9 +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}") + frameActive = sub.hasFrame + if (sub.hasFrame) + emitLine(if (sub.frameSize > 0) "link a5,#-${sub.frameSize}" else "link a5,#0") val livenessInstructions = sub.chunks.filterIsInstance().flatMap { it.instructions } val deadStoreSuppressionAllowed = canSuppressDeadStores(sub) var instructionOffset = 0 @@ -779,7 +779,7 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT } emitRaw("; End of subroutine: $subLabel") emitRaw("") - currentFrameSize = 0 + frameActive = false } private fun emitAsmSubroutine(sub: IRAsmSubroutine) { diff --git a/codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt b/codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt index d98afecf2..28996d1ff 100644 --- a/codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt +++ b/codeGenM68k/src/prog8/codegen/m68k/InstrControl.kt @@ -657,6 +657,20 @@ private fun AsmGen.translateCall(fnLabel: String, callSite: CallSite, forwardedI emitLine("bsr $fnLabel") } + // caller cleanup of the all-stack argument area (m68k-stack-memory-model §6.1): + // each argument was pushed into its own padded longword slot + val pushedSlots = callSite.arguments.count { it.location is CallLocation.FrameSlot } + if (pushedSlots > 0) { + val totalSize = pushedSlots * 4 + val needsCcr = callSite.results.any { it.location is CallLocation.StatusFlag } + when { + // addq is compact but updates CCR; lea preserves CCR so status-flag returns + // remain visible to the IR's branch pattern. + totalSize in 1..8 && !needsCcr -> emitLine("addq.l #$totalSize,sp") + else -> emitLine("lea $totalSize(sp),sp") + } + } + // Move return values back to virtual registers. // Skip status flag returns: always handled by IR's bsteq/bstneg/bstvs branch pattern. // In multi-assign context (results.size > 1), also skip slot-based returns: @@ -811,6 +825,25 @@ private fun AsmGen.translateArgument( } } } else { + // Stack argument: push into a padded longword slot, right-justified (m68k-stack-memory-model §6.1). + // The caller pops the whole argument area after the return, so the callee never needs to + // know the argument count. + 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) + emitLine("fmove.s ${floatRegFileAddr(argReg.floatNumber)},$FP_ACC") + 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)") + } + } + return + } // Store to the callee's parameter variable (if this is a named param) val paramName = (arg.location as? CallLocation.ParameterMemory)?.name.orEmpty() if (paramName.isNotEmpty() && fnLabel != null) { diff --git a/codeGenM68k/test/TestStackFrameEmission.kt b/codeGenM68k/test/TestStackFrameEmission.kt index e1e0c4706..13b90d5ed 100644 --- a/codeGenM68k/test/TestStackFrameEmission.kt +++ b/codeGenM68k/test/TestStackFrameEmission.kt @@ -3,10 +3,7 @@ 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.core.* import prog8.code.target.Qemu68kTarget import prog8.codegen.m68k.AsmGen import prog8.intermediate.* @@ -20,7 +17,12 @@ class TestStackFrameEmission : FunSpec({ val tempRoot = tempdir().toPath() - fun generateAsm(frameSize: Int, instructions: List): List { + fun generateAsmWithIncoming( + frameSize: Int, + instructions: List, + incomingSize: Int = 0, + subLabel: String = "test.start" + ): List { val options = CompilationOptions.builder(Qemu68kTarget()) .output(OutputType.RAW) .zeropage(ZeropageType.DONTUSE) @@ -33,7 +35,10 @@ class TestStackFrameEmission : FunSpec({ program.options.outputDir = tempRoot val chunk = IRCodeChunk(null, null) chunk.instructions.addAll(instructions) - val sub = IRSubroutine("test.start", emptyList(), emptyList(), Position.DUMMY, frameSize) + val params = if (incomingSize > 0) + List(incomingSize / 4) { IRSubroutine.IRParam("p8v_p$it", DataType.UBYTE) } + else emptyList() + val sub = IRSubroutine(subLabel, params, emptyList(), Position.DUMMY, frameSize, incomingSize) sub.chunks.add(chunk) val block = IRBlock("test", false, IRBlock.Options(), Position.DUMMY) block.children.add(sub) @@ -47,6 +52,9 @@ class TestStackFrameEmission : FunSpec({ return asmFile.readText().lines().map { it.trim() } } + fun generateAsm(frameSize: Int, instructions: List): List = + generateAsmWithIncoming(frameSize, instructions, 0, "test.start") + test("frameless subroutine emits no frame setup") { val lines = generateAsm(0, listOf( IRInstructions.loadMemory(Opcode.LOADM, IRDataType.BYTE, 1, IRMemory.direct("some.var")), @@ -99,4 +107,71 @@ class TestStackFrameEmission : FunSpec({ lines.any { it.contains("-4(a5)") } shouldBe true lines.any { it.contains("-8(a5)+4") } shouldBe false } + + test("incoming-only frame still emits link and unlk") { + val lines = generateAsmWithIncoming(0, listOf(IRInstructions.simple(Opcode.RETURN)), incomingSize = 12) + lines.any { it == "link a5,#0" } shouldBe true + lines.any { it == "unlk a5" } shouldBe true + } + + test("stack arguments are pushed right-justified and popped by the caller") { + val calls = listOf( + IRInstructions.call(CallSite( + target = CallTarget.Direct(CodeReference.Label("test.sub")), + arguments = listOf( + Calls.argument(1, IRDataType.BYTE, CallLocation.FrameSlot(16)), + Calls.argument(2, IRDataType.WORD, CallLocation.FrameSlot(12)), + Calls.argument(3, IRDataType.LONG, CallLocation.FrameSlot(8)) + ) + )), + 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.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.any { it.startsWith("bsr") } shouldBe true + lines.any { it == "lea 12(sp),sp" } shouldBe true + // small argument areas use the short addq form + val smallCalls = listOf( + IRInstructions.call(CallSite( + target = CallTarget.Direct(CodeReference.Label("test.sub")), + arguments = listOf(Calls.argument(1, IRDataType.BYTE, CallLocation.FrameSlot(8))) + )), + IRInstructions.simple(Opcode.RETURN) + ) + val smallLines = generateAsmWithIncoming(0, smallCalls, incomingSize = 4, subLabel = "test.caller") + smallLines.any { it == "addq.l #4,sp" } shouldBe true + } + + test("status-flag results use CCR-preserving cleanup") { + val calls = listOf( + IRInstructions.call(CallSite( + target = CallTarget.Direct(CodeReference.Label("test.sub")), + arguments = listOf(Calls.argument(1, IRDataType.BYTE, CallLocation.FrameSlot(8))), + results = listOf(CallResult(null, CallLocation.StatusFlag(Statusflag.Pz))) + )), + IRInstructions.simple(Opcode.RETURN) + ) + val lines = generateAsmWithIncoming(0, calls, incomingSize = 4, subLabel = "test.caller") + // a single byte argument would normally use addq.l #4,sp, but that clobbers CCR, + // so status-flag returns must use lea instead. + lines.any { it == "lea 4(sp),sp" } shouldBe true + lines.none { it == "addq.l #4,sp" } shouldBe true + } + + test("float stack arguments travel through the FPU accumulator") { + val calls = listOf( + IRInstructions.call(CallSite( + target = CallTarget.Direct(CodeReference.Label("test.sub")), + arguments = listOf(Calls.argument(1, IRDataType.FLOAT, CallLocation.FrameSlot(8))) + )), + 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 + lines.any { it.startsWith("fmove.s p8_fregfile+0,fp0") } shouldBe true + lines.any { it == "fmove.s fp0,(sp)" } shouldBe true + } }) diff --git a/codeGenNew6502/src/prog8/codegen/new6502/InstrControl.kt b/codeGenNew6502/src/prog8/codegen/new6502/InstrControl.kt index c34cc54e3..0d8215a6f 100644 --- a/codeGenNew6502/src/prog8/codegen/new6502/InstrControl.kt +++ b/codeGenNew6502/src/prog8/codegen/new6502/InstrControl.kt @@ -670,6 +670,8 @@ private fun AsmGen.translateArgument(arg: CallArgument, argIndex: Int = -1, fnLa // Syscall argument - value is already in the register file, // the syscall handler reads it from there. } + is CallLocation.FrameSlot -> + error("stack frame argument slots are only supported on m68k targets: $location") is CallLocation.HardwareRegister -> throw IllegalStateException("slot already handled") } } diff --git a/compiler/test/TestStackFrameLocals.kt b/compiler/test/TestStackFrameLocals.kt index 24291cb72..be7e4702c 100644 --- a/compiler/test/TestStackFrameLocals.kt +++ b/compiler/test/TestStackFrameLocals.kt @@ -56,24 +56,66 @@ main { reloaded.allSubs().first { it.label.endsWith("p8s_leaf") }.frameSize shouldBe 10 } - test("subroutine with parameters keeps the static path") { + test("subroutine with parameters passes them on the stack") { val src = """ main { - sub withparam(ubyte v) { - ubyte a - a = v + sub withp(ubyte a, word b, long c) -> long { + return b + c + a } sub start() { - withparam(1) + g = lsb(withp(1, 2, 3)) } + ubyte g } """ - val (lines, _) = compile(src) - lines.any { it.startsWith("link ") } shouldBe false - lines.any { "-1(a5)" in it } shouldBe false + val (lines, ir) = compile(src) + // incoming-only frame: no locals, but the parameters need a frame pointer + lines.any { it == "link a5,#0" } shouldBe true + lines.any { it == "unlk a5" } shouldBe true + // narrow parameters are right-justified inside their longword slot (offsets 8/12/16) + lines.any { it.contains("19(a5)") } shouldBe true // ubyte at slot 16 + 3 + lines.any { it.contains("14(a5)") } shouldBe true // word at slot 12 + 2 + lines.any { it.contains("8(a5)") } shouldBe true // long at slot 8 + // 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.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.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 + ir.contains("frame:16=") shouldBe true + val reloaded = IRFileReader().read(ir) + val sub = reloaded.allSubs().first { it.label.endsWith("p8s_withp") } + sub.frameSize shouldBe 0 + sub.incomingSize shouldBe 12 } - test("subroutine that calls another is not framed") { + test("many parameters use one cleanup for the whole argument area") { + val src = """ +main { + sub five(ubyte a, uword b, long c, ubyte d, uword e) -> long { + return a + b + c + d + e + } + sub start() { + g = lsb(five(1, 2, 3, 4, 5)) + } + ubyte g +} +""" + val (lines, ir) = compile(src) + lines.count { it == "subq.l #4,sp" } shouldBe 5 + 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 + lines.any { it.contains("22(a5)") } shouldBe true // uword at slot 20 + 2 + lines.any { it.contains("16(a5)") } shouldBe true // long at slot 16 + ir.contains("INCOMING=\"20\"") shouldBe true + } + + test("subroutine that calls another is framed when it is not recursive") { val src = """ main { ubyte gv = 2 @@ -87,9 +129,98 @@ main { caller() } } +""" + val (lines, ir) = compile(src) + lines.any { it == "link a5,#-2" } shouldBe true + lines.none { "p8v_a:" in it } shouldBe true + ir.contains("FRAMESIZE=\"2\"") shouldBe true + } + + test("recursive subroutine keeps the static path") { + // call-live virtual registers still live in the flat program-static regfile (design-doc + // slice 3), so a subroutine on a call-graph cycle must not be framed yet + val src = """ +main { + sub rec(ubyte n) -> long { + if n < 2 + return n + return rec(n-1) + rec(n-2) + } + ubyte @shared g = 1 + sub start() { + g = lsb(rec(10)) + } +} """ val (lines, _) = compile(src) lines.any { it.startsWith("link ") } shouldBe false + lines.any { "p8s_rec.p8v_n:" in it } shouldBe true + } + + test("mutually recursive subroutines keep the static path") { + val src = """ +main { + sub ping(ubyte n) -> long { + if n == 0 + return 0 + return pong(n-1) + } + sub pong(ubyte n) -> long { + if n == 0 + return 0 + return ping(n-1) + } + sub start() { + g = lsb(ping(4)) + } + ubyte @shared g +} +""" + val (lines, _) = compile(src) + lines.any { it.startsWith("link ") } shouldBe false + } + + test("indirectly called subroutine keeps the static path") { + val src = """ +main { + sub t1() { g = 1 } + sub t2() { g = 2 } + sub start() { + on 1 call ( + t1, + t2 + ) + } + ubyte @shared g +} +""" + val (lines, _) = compile(src) + // t1/t2 are dispatch-table targets: they could be re-entered through the indirect call + lines.any { it.startsWith("link ") } shouldBe false + } + + test("defer-referenced local stays static, deferring sub with parameters keeps the static convention") { + val src = """ +main { + sub leaf() { + ubyte a = 1 + defer a += 1 + } + sub parm(ubyte x) { + defer x += 1 + } + sub start() { + leaf() + parm(1) + } +} +""" + val (lines, _) = compile(src) + // the defer handler is emitted at block scope and reads the parent local, so that local + // keeps static storage (§11 option 2) + lines.any { "p8s_leaf.p8v_a:" in it } shouldBe true + // a defer body that reads a parameter keeps the whole subroutine on the static convention + lines.any { "p8s_parm.p8v_x:" in it } shouldBe true } test("address-taken local stays static, rest of the frame still works") { @@ -126,8 +257,10 @@ main { } """ val (lines, _) = compile(src) - // DeferProcessor adds handler-call instructions -> the sub contains calls -> never frameable - lines.any { it.startsWith("link ") } shouldBe false + // the defer body reads the parent local from a block-scope handler, so that local keeps + // static storage; the subroutine itself may still get a frame for its per-activation + // defer mask, but the defer-referenced variable must never move + lines.any { "p8s_leaf.p8v_a:" in it } shouldBe true } test("oversized local array keeps static storage while other locals are framed") { diff --git a/ideas/m68k-stack-memory-model.md b/ideas/m68k-stack-memory-model.md index d2dcc6e14..70d60b8c2 100644 --- a/ideas/m68k-stack-memory-model.md +++ b/ideas/m68k-stack-memory-model.md @@ -1,6 +1,6 @@ # M68K Stack Memory Model -**Status: Vertical Slice Prototype (§17) implemented; the full model is not yet implemented.** Listed as "Deferred" in `docs/source/todo.rst`. +**Status: slices 1-2 implemented (Vertical Slice Prototype §17 + parameters-on-the-stack §17.6); slices 3-5 and the register allocator are not yet implemented.** Listed as "Deferred" in `docs/source/todo.rst`. Design decisions taken (Oct 2026): the normal-prog8-subroutine calling convention is a single uniform **all-stack** convention with caller cleanup @@ -195,13 +195,20 @@ cleanup.** No baseline exceptions, no per-signature register assignment. Mechanics: -- The caller pushes every argument with `move.w/l` (or `fmove.x`) to `-(sp)` +- The caller pushes every argument with `move.w/l` (or `fmove.s`) to `-(sp)` in left-to-right source order; each argument occupies a padded longword slot (68000 alignment rules apply: words and longs are never accessed at odd addresses). Narrow values are right-justified in their slot: reserve 4 bytes (`subq.l #4,sp`), then `move.b d0,3(sp)` / `move.w d0,2(sp)` / - `move.l d0,(sp)`. Callee-side offsets are the constant `8 + N*4` above the - frame pointer. + `move.l d0,(sp)`. Float arguments go through the FPU accumulator first, + because the 68881 has no absolute-long addressing mode (a direct + `fmove.s ,(sp)` does not assemble). After `link a5,#-N` the + saved A5 sits at `0(a5)` and the return address at `4(a5)`, so the + **rightmost argument is at `8(a5)`** and each argument to its left sits + 4 bytes higher: parameter *i* (1-based, leftmost first) is at + `8 + 4*(N-i)`, i.e. the leftmost argument is at `4*N+4`. (Verified by + the slice-2 implementation; an earlier draft of this document had an + off-by-4 `8 + N*4` here.) - This deliberately follows the strict System V m68k scheme (uniform 4-byte slots) rather than the Linux/GCC m68k scheme (16-bit stack alignment, arguments at natural size, word-aligned). Both are legal on the 68000. @@ -340,9 +347,10 @@ follow-ups; each needs its own design review and test pass: alignment, arguments at their natural size (byte/bool = 2 bytes including pad, word = 2, long/float/pointer = 4, still even-aligned). Saves stack space and bus traffic per narrow argument. Impact: frame-layout pass must - sum per-argument rounded sizes instead of using the constant `8 + N*4` - stride; §6.3's documented asmsub push rules change. Introduce as a - convention variant/flag, not by breaking the v1 layout. + sum per-argument rounded sizes instead of the fixed 4-byte-per-parameter + stride of §6.1 (rightmost argument still at `8(a5)`); §6.3's documented + asmsub push rules change. Introduce as a convention variant/flag, not by + breaking the v1 layout. 2. **Register argument passing** as a *paired caller/callee specialization* derived at codegen over the fixed stack convention (model: classic SysV m68k / Mac C: first scalar in D0, first pointer in A0, rest on stack), @@ -395,9 +403,19 @@ Minimal proposal for the structured IR: separate `CallLocation.FrameSlot` variant), otherwise callers keep assuming statically addressable parameter variables. Under the all-stack convention (§6.1) the callee-side parameter slots are positive-offset frame - slots; the caller-side push sequence and the cleanup `addq` size are derived - from the parameter list (types/padding), so `IRSubroutine` needs no separate - outgoing-size field beyond `parameters` and the locals `frameSize`. + slots; the caller-side push sequence and the cleanup size are derived from + the parameter list (types/padding). + *Implemented (slice 2):* a separate `CallLocation.FrameSlot(offset)` variant + (`offset >= 8`), printed as `frame:16=` in the argument position of a call. +- Slice-2 correction to the paragraph above: `IRSubroutine` does need one + small extra field after all. `frameSize` alone cannot express "this + subroutine has a frame that consists only of incoming arguments" + (`link a5,#0` is still required, and `unlk a5` on every return path), and + framed-ness is not derivable from the parameter list alone. Hence + `IRSubroutine.incomingSize`: the byte size of the caller-pushed argument + area, 0 meaning "legacy static parameter convention". `hasFrame` is then + `frameSize > 0 || incomingSize > 0`. Serialized as the optional `INCOMING` + SUB attribute (absence = 0), same convention as `FRAMESIZE`. - `OpcodeSchema` validation: memory-slot schemas keep accepting `Symbol` and `Absolute` unchanged, and additionally accept a `FrameSlot` base for `Direct` access. The `displacement >= 0` rule stays for symbol/absolute @@ -751,14 +769,17 @@ without corrupting anything. ### 17.4 Slice ladder (post-prototype) -1. Vertical Slice Prototype (this section). -2. Parameters + the §6.1 all-stack convention flip (the big one). +1. Vertical Slice Prototype (this section) - **done**. +2. Parameters + the §6.1 all-stack convention flip (the big one) - **done** + (see §17.6). 3. Call-graph cycles: call-live vreg relocation, §11 defer rejection rule. 4. VM activation records + recursion tests through the VM. 5. Diagnostics and polish: escape warnings (§10), frame-size reporting, - liveness-based slot reuse, §4 auto-temporaries. + liveness-based slot reuse, §4 auto-temporaries, §6.1 copy-in for + constrained parameters (inline-asm / defer referenced), float and + static-initialized locals via per-slot init metadata. -### 17.5 Findings +### 17.5 Findings (prototype / slice 1) The prototype was built and verified (unit + e2e tests, and runtime verification of a framed program on qemu-system-m68k: repeated calls to a @@ -824,3 +845,63 @@ What it taught us: Verdict: the design held up end to end with no architectural rework; the format changes (§7), backend lowering list (§8), and implementation order (§15) can be built as specified. + +### 17.6 Slice 2: parameters on the stack (implemented) + +Status: implemented and runtime-verified (e2e tests plus a +qemu-system-m68k run with byte/word/long/pointer/float parameter mixes, +nested framed caller -> framed callee, and the static fallbacks for +recursive, mutually recursive and indirectly dispatched subroutines). + +What the implementation does: + +- Parameters are laid out in the caller-pushed incoming area (§6.1): 4 bytes + per parameter, rightmost at `8(a5)`, leftmost at `4*N+4`, narrow values + right-justified. Their static variables are removed from the BSS and all + callee-side reads become `Direct(FrameSlot(offset))` with the + right-justification displacement folded in. +- Every call site to a framed subroutine is rewritten from + `CallLocation.ParameterMemory(name)` to `CallLocation.FrameSlot(offset)`; + the m68k backend then pushes each argument into its own slot + (`subq.l #4,sp` + right-justified `move`) and pops the whole area after the + return (`addq.l` up to 8 bytes, `lea -N(sp),sp` beyond). +- Float arguments are staged through the FPU accumulator (`$FP_ACC`), since + the 68881 has no absolute-long addressing mode and the direct + `fmove.s ,(sp)` encoding does not exist. +- `IRSubroutine.incomingSize` records the size of the argument area so the + backend can emit `link a5,#0` / `unlk a5` for an incoming-only frame + (see the §7 correction note). + +Frameability rules that the implementation added on top of the prototype's +(the prototype simply refused any subroutine with parameters or calls): + +- **No call-graph cycles.** Subroutines on a direct-call cycle (self or + mutual recursion) stay on the static convention: until slice 3 moves + call-live virtual registers into frames, two live activations would share + the flat program-static `p8_regfile` slots. Detected with an iterative + Tarjan SCC pass over the IR call graph. +- **No indirect calls inside, no address-taken subroutine.** A subroutine + whose address appears in a dispatch table (`on .. call` table initializer), + in a `SymbolAddress` immediate, or in inline assembly keeps the static + convention, because such a subroutine can be re-entered through dispatch. + (Interrupt handlers registered by address fall in this class.) +- **All parameters must be movable.** If any parameter is address-taken, + inline-asm referenced, defer referenced, static-initialized or otherwise + not frameable, the *whole* subroutine stays static, because the caller must + then keep writing static cells. This is why §6.1's entry-time copy-in for + such parameters is still listed as slice-5 work: until it exists, the + conservative rule is used instead. +- A parameter referenced from *outside* its own subroutine (notably from a + block-scope `defer` handler) likewise keeps the whole subroutine static. + This one is subtle and was found by the assembler during bring-up: the + defer body reads the parent variable from a handler that lives outside the + subroutine, so removing the variable from BSS broke the link. + +Known remaining gaps (unchanged from before slice 2, all covered by the +ladder): + +- Ordinary recursion is still broken (verified empirically: a recursive + subroutine overwrites its static parameter storage). Slice 3 fixes it. +- No copy-in yet for inline-asm/defer referenced parameters (slice 5), and + float/static-initialized locals still keep static storage (slice 5). +- The VM still rejects frame-based IR (slice 4). diff --git a/intermediate/src/prog8/intermediate/IRCalls.kt b/intermediate/src/prog8/intermediate/IRCalls.kt index 6b30299de..6f5fbfa8b 100644 --- a/intermediate/src/prog8/intermediate/IRCalls.kt +++ b/intermediate/src/prog8/intermediate/IRCalls.kt @@ -55,6 +55,20 @@ sealed interface CallLocation { } } + /** + * the value is passed in a slot of the callee's incoming argument area on the machine stack + * (the uniform all-stack calling convention, m68k-stack-memory-model.md §6.1). + * [offset] is the positive, frame-pointer-relative byte offset of the slot: the caller pushes + * every argument left-to-right into padded longword slots, so the first argument ends up at the + * highest offset (4*N+4) and the last one at 8. + */ + data class FrameSlot(val offset: Int) : CallLocation { + init { + require(offset >= 8) { "incoming argument slot offset must be >= 8: $offset" } + require(offset <= 32767) { "incoming argument slot offset out of range: $offset" } + } + } + /** the value is passed in a cpu hardware register (abstract calling convention slot) */ data class HardwareRegister(val slot: CallingConventionSlot) : CallLocation diff --git a/intermediate/src/prog8/intermediate/IRFileReader.kt b/intermediate/src/prog8/intermediate/IRFileReader.kt index e662845b0..db582d7be 100644 --- a/intermediate/src/prog8/intermediate/IRFileReader.kt +++ b/intermediate/src/prog8/intermediate/IRFileReader.kt @@ -586,7 +586,8 @@ class IRFileReader { parseParameters(reader), if(returns=="") emptyList() else returns.split(',').map { parseDatatype(it, false) }, parsePosition(attrs.getValue("POS")), - attrs["FRAMESIZE"]?.toIntOrNull() ?: 0) + attrs["FRAMESIZE"]?.toIntOrNull() ?: 0, + attrs["INCOMING"]?.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 129bbe664..7c337f481 100644 --- a/intermediate/src/prog8/intermediate/IRFileWriter.kt +++ b/intermediate/src/prog8/intermediate/IRFileWriter.kt @@ -147,6 +147,8 @@ class IRFileWriter(private val irProgram: IRProgram, outfileOverride: Path?) { xml.writeAttribute("POS", child.position.toString()) if (child.frameSize != 0) xml.writeAttribute("FRAMESIZE", child.frameSize.toString()) + if (child.incomingSize != 0) + xml.writeAttribute("INCOMING", child.incomingSize.toString()) xml.writeCharacters("\n") xml.writeStartElement("PARAMS") xml.writeCharacters("\n") diff --git a/intermediate/src/prog8/intermediate/IRProgram.kt b/intermediate/src/prog8/intermediate/IRProgram.kt index ba0f904eb..543bc4751 100644 --- a/intermediate/src/prog8/intermediate/IRProgram.kt +++ b/intermediate/src/prog8/intermediate/IRProgram.kt @@ -577,13 +577,19 @@ class IRSubroutine( val parameters: List, val returns: List, 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 { + /** size in bytes of the local area of this subroutine's stack frame; 0 means no local frame area */ + var frameSize: Int = 0, + /** size in bytes of the caller-pushed incoming argument area at positive frame-pointer offsets; + * 0 means the subroutine uses the legacy convention with statically allocated parameter variables */ + var incomingSize: Int = 0): IIRBlockElement { class IRParam(val name: String, val dt: DataType) val chunks = mutableListOf() + /** does this subroutine have a stack frame at all (local area and/or incoming argument area)? */ + val hasFrame: Boolean get() = frameSize > 0 || incomingSize > 0 + init { require('.' in label) {"subroutine name is not scoped: $label"} require(!label.startsWith("main.main.")) {"subroutine name invalid main prefix: $label"} @@ -592,6 +598,10 @@ class IRSubroutine( 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"} + require(incomingSize >= 0 && incomingSize % 4 == 0) {"incoming argument area size must be a non-negative multiple of 4: $incomingSize"} + require(incomingSize == 0 || incomingSize == parameters.size * 4) { + "incoming argument area size must be 4 bytes per parameter: $incomingSize for ${parameters.size} parameters" + } } operator fun plusAssign(chunk: IRCodeChunkBase) { diff --git a/intermediate/src/prog8/intermediate/IRTextCodec.kt b/intermediate/src/prog8/intermediate/IRTextCodec.kt index d773c0577..dedf78e6f 100644 --- a/intermediate/src/prog8/intermediate/IRTextCodec.kt +++ b/intermediate/src/prog8/intermediate/IRTextCodec.kt @@ -157,6 +157,7 @@ object IRTextCodec { is CallLocation.ParameterMemory -> if (location.name.isNotBlank()) "${location.name}=" else location.address?.let { "${it.toHex()}=" } ?: "" + is CallLocation.FrameSlot -> "frame:${location.offset}=" else -> "" } @@ -644,6 +645,9 @@ object IRTextCodec { val locationText = remainder.substring(0, equals) remainder = remainder.substring(equals + 1) parameterLocation = if (locationText.isEmpty()) null + else if (locationText.startsWith("frame:")) + CallLocation.FrameSlot(locationText.removePrefix("frame:").toIntOrNull() + ?: throw IRParseException("$opcode: invalid frame slot offset in $text")) else if (locationText[0].isLetter()) CallLocation.ParameterMemory(locationText) else CallLocation.ParameterMemory("", parseAddress(locationText, "$opcode: invalid parameter address")) } diff --git a/intermediate/test/TestIRTextCodec.kt b/intermediate/test/TestIRTextCodec.kt index b177c97b1..befd1c6c0 100644 --- a/intermediate/test/TestIRTextCodec.kt +++ b/intermediate/test/TestIRTextCodec.kt @@ -54,6 +54,15 @@ class TestIRTextCodec: FunSpec({ shouldThrow { IRTextCodec.parse("loadm.w r0.w,[main.var+-3]") } } + test("calls with incoming stack argument slots") { + roundtrip("call main.sub(frame:16=r5.b,frame:12=r6.w,frame:8=r7.l):r8.l") + roundtrip("call main.sub(frame:8=r5.b):r6.w") + roundtrip("call main.noparam()") + roundtrip("call main.sub(frame:8=fr5.f):fr6.f") + // incoming argument slots always sit above the return address + shouldThrow { IRTextCodec.parse("call main.sub(frame:4=r5.b)") } + } + 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 b78829efc..f8e7b3b81 100644 --- a/virtualmachine/src/prog8/vm/VmProgramLoader.kt +++ b/virtualmachine/src/prog8/vm/VmProgramLoader.kt @@ -341,11 +341,17 @@ class VmProgramLoader { 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") + if (sub.incomingSize != 0) + throw IRParseException("frame-based IR (subroutine ${sub.label} has incoming size ${sub.incomingSize}) 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") + instr.callSite?.arguments?.forEach { arg -> + if (arg.location is CallLocation.FrameSlot) + throw IRParseException("frame-based IR (${instr.opcode} passes argument in frame slot ${(arg.location as CallLocation.FrameSlot).offset}) is not (yet) supported on the virtual machine") + } } }