slice 5 for m68k stack memory model

(slice 4 was skipped)
This commit is contained in:
Irmen de Jong committed 2026-10-07 19:06:16 +02:00
1 parent 329b3df4a3
commit 353b0c84b7
11 files changed
+1123 -55

No files matched your search

@@ -240,7 +240,9 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro
/** parameter variable name -> frame offset of its incoming slot */
val paramSlots: Map<String, Int>,
/** parameter variable name -> right-justification displacement within its slot */
val paramDisplacements: Map<String, Int>
val paramDisplacements: Map<String, Int>,
/** parameter variable names that keep a static cell and get an entry-time copy-in (§6.1) */
val copyInParams: Set<String>
)
/** outcome of the frameability decision for one subroutine */
@@ -276,15 +278,18 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro
}
val paramSlots = mutableMapOf<String, Int>()
val paramDisplacements = mutableMapOf<String, Int>()
val copyInParams = mutableSetOf<String>()
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 static("parameter '$name' is also referenced from outside the subroutine")
val v = program.st.lookup(name) as? IRStStaticVariable ?: return static("parameter '$name' has no static variable")
val elemDt = isFrameableVariable(v, name, addressTaken, asmTexts) ?: return static("parameter '$name' cannot be moved into a frame")
val elemDt = isFrameableParameter(v, name, addressTaken, asmTexts) ?: return static("parameter '$name' cannot be moved into a frame")
if (v.length != null) return static("parameter '$name' is an array")
// a parameter referenced from outside the subroutine (defer handler, inline assembly)
// keeps its static cell for those external references and gets an entry-time copy-in
// from its incoming slot (§6.1); its address may not be taken (that stays a hard error)
val refs = referencedIn[name]
if ((refs != null && refs.any { it != sub.label }) || isLabelReferencedInAsm(name, asmTexts))
copyInParams += name
// 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
@@ -347,17 +352,9 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro
val vregs = vregsUsedIn(sub)
if (vregs.keys.any { it >= RESERVED_VREG_RANGE_START })
return static("it uses a reserved virtual register number")
for ((regNum, irType) in vregs.entries.sortedBy { it.key }) {
val size = slotSizeFor(irType)
val alignment = when {
size >= 4 -> 4
size >= 2 -> 2
else -> 1
}
cursor = cursor.floorDiv(alignment) * alignment
cursor -= size
vregSlots[regNum] = cursor
}
val (newCursor, slots) = allocateVregSlots(sub, vregs, cursor)
cursor = newCursor
vregSlots.putAll(slots)
}
if (frameVars.isEmpty() && paramSlots.isEmpty() && vregSlots.isEmpty())
return static("it has no frameable local variables or parameters")
@@ -384,9 +381,11 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro
else -> null
}
if (disp != null && (disp < -32768 || disp > 32767)) outOfRange = true
// a relocated virtual register must stay in 16-bit displacement range as well
val regOffset = (instr.registerAccesses.firstOrNull()?.register)?.let { vregSlots[it.num] }
if (regOffset != null && (regOffset < -32768 || regOffset > 32767)) outOfRange = true
// relocated virtual registers must stay in 16-bit displacement range as well
for (access in instr.registerAccesses) {
val regOffset = vregSlots[access.register.num]
if (regOffset != null && (regOffset < -32768 || regOffset > 32767)) outOfRange = true
}
}
if (outOfRange) {
errors.err("frame-relative displacement out of 16-bit range in subroutine ${userName(sub)}", sub.position)
@@ -413,30 +412,63 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro
}
}
// prologue zeroing for clean (non-dirty) locals; incoming parameters are written by the caller
val clearInstrs = mutableListOf<IRInstruction>()
// prologue: entry-time copy-in for externally referenced parameters (§6.1), then the
// numeric initializers of static-initialized locals, then zeroing for clean (non-dirty)
// locals; incoming parameters are written by the caller
val prologueInstrs = mutableListOf<IRInstruction>()
if (copyInParams.isNotEmpty()) {
val used = program.registersUsed()
var nextIntScratch = (used.intRegsTypes.keys.maxOfOrNull { it.value } ?: 0) + 1
var nextFloatScratch = ((used.floatRegsRead.keys + used.floatRegsWritten.keys).maxOfOrNull { it.value } ?: 0) + 1
require(nextIntScratch < RESERVED_VREG_RANGE_START) { "copy-in integer scratch register would collide with reserved vreg range" }
require(nextFloatScratch < RESERVED_VREG_RANGE_START) { "copy-in float scratch register would collide with reserved vreg range" }
for (name in copyInParams.sorted()) {
val v = program.st.lookup(name) as? IRStStaticVariable ?: continue
val elemDt = if (v.dt.isArray) baseToScalar(v.dt.sub!!) else v.dt
val irType = elemDt?.let { irTypeFor(it) } ?: continue
val offset = paramSlots[name] ?: continue
val displacement = paramDisplacements[name] ?: 0
val scratch = if (irType == IRDataType.FLOAT) nextFloatScratch++ else nextIntScratch++
prologueInstrs += IRInstructions.loadMemory(Opcode.LOADM, irType, scratch, IRMemory.frameDirect(offset, displacement))
prologueInstrs += IRInstructions.storeMemory(Opcode.STOREM, irType, scratch, IRMemory.direct(name))
}
}
for (fv in frameVars) {
val init = (program.st.lookup(fv.name) as? IRStStaticVariable)?.initializationValue as? IRVariableInitializer.Numeric
if (init != null) {
// a static-initialized local gets its initializer stored into the frame slot;
// this replaces the zero-clear for it
val irType = irTypeFor(fv.elemDt) ?: continue
if (irType == IRDataType.FLOAT)
prologueInstrs += IRInstructions.storeImmediateFloat(init.value, IRMemory.frameDirect(fv.slot))
else
prologueInstrs += IRInstructions.storeImmediate(irType, init.value.toInt(), IRMemory.frameDirect(fv.slot))
continue
}
if (fv.dirty) continue
val irType = irTypeFor(fv.elemDt) ?: continue
val elemSize = target.memorySize(fv.elemDt, null)
// float scalars and float array elements are zero-filled with STOREZM float stores
// (the m68k backend supports them); the VM does not run this pass
for (element in 0 until fv.count) {
clearInstrs += IRInstructions.storeZero(Opcode.STOREZM, irType, IRMemory.frameDirect(fv.slot, element * elemSize))
prologueInstrs += IRInstructions.storeZero(Opcode.STOREZM, irType, IRMemory.frameDirect(fv.slot, element * elemSize))
}
}
// the moved variables now live per-activation; remove them from the static (BSS) symbol table
(slotBySymbol.keys + paramSlots.keys).forEach { program.st.removeIfExists(it) }
// the moved variables now live per-activation; remove them from the static (BSS) symbol table.
// copy-in parameters keep their static cell for the external reference (§6.1)
(slotBySymbol.keys + paramSlots.keys.filter { it !in copyInParams }).forEach { program.st.removeIfExists(it) }
if (clearInstrs.isNotEmpty()) {
if (prologueInstrs.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)
firstChunk.instructions.addAll(0, prologueInstrs)
}
sub.frameSize = frameSize
sub.incomingSize = paramSlots.size * PARAM_SLOT_SIZE
sub.frameVregSlots = vregSlots
return Frameability.Frameable(FrameLayoutResult(frameSize, sub.incomingSize, paramSlots, paramDisplacements))
return Frameability.Frameable(FrameLayoutResult(frameSize, sub.incomingSize, paramSlots, paramDisplacements, copyInParams))
}
/** the virtual registers used by the instructions of a subroutine, by register number */
@@ -455,6 +487,117 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro
return result
}
private class VregSlot(val offset: Int, val size: Int, var occupant: Int)
/**
* Frame slots for the virtual registers of a re-entrant subroutine. Unless the subroutine
* has a backward branch, register lifetimes are intervals over the flattened instruction
* order and a slot whose previous occupant is already dead is reused by a later register.
* A back edge (any loop chunk, or a branch/jump to a label at or before its own position,
* including a jump into another subroutine) can keep a value live across the jump, which
* plain last-use intervals would get wrong, so such a subroutine keeps one dedicated slot
* per register.
*/
private fun allocateVregSlots(sub: IRSubroutine, vregs: Map<Int, IRDataType>, cursor: Int): Pair<Int, Map<Int, Int>> {
if (vregs.isEmpty())
return cursor to emptyMap()
// flatten the non-loop chunks into a linear instruction list; loop chunks force the
// conservative one-slot-per-register fallback and are skipped here
val instrList = mutableListOf<IRInstruction>()
val chunkFirstIndex = mutableMapOf<String, Int>()
var containsLoop = false
sub.forEachChunk { chunk ->
if (chunk is IRLoopChunk) {
containsLoop = true
return@forEachChunk
}
val label = chunk.label
if (label != null)
chunkFirstIndex[label] = instrList.size
chunk.instructions.forEach { instrList.add(it) }
}
if (containsLoop || branchesBackwards(sub, instrList, chunkFirstIndex))
return oneSlotPerRegister(vregs, cursor)
val firstUse = mutableMapOf<Int, Int>()
val lastUse = mutableMapOf<Int, Int>()
instrList.forEachIndexed { index, instr ->
for (access in instr.registerAccesses) {
val num = access.register.num
firstUse[num] = minOf(firstUse[num] ?: index, index)
lastUse[num] = maxOf(lastUse[num] ?: index, index)
}
}
// greedy linear scan: a register takes the first free slot it fits in, otherwise a
// fresh slot below the locals; registers live at the same time can never share
val slots = mutableListOf<VregSlot>()
var c = cursor
val vregSlots = mutableMapOf<Int, Int>()
instrList.forEachIndexed { index, instr ->
for (regNum in instr.registerAccesses.map { it.register.num }.distinct().sorted()) {
if (firstUse[regNum] != index) continue
val size = slotSizeFor(vregs[regNum] ?: continue)
val alignment = when {
size >= 4 -> 4
size >= 2 -> 2
else -> 1
}
val slot = slots.firstOrNull { s ->
(s.occupant < 0 || lastUse[s.occupant]!! < index) && s.offset % alignment == 0 && s.size >= size
}
if (slot != null) {
slot.occupant = regNum
vregSlots[regNum] = slot.offset
} else {
c = c.floorDiv(alignment) * alignment
c -= size
vregSlots[regNum] = c
slots += VregSlot(c, size, regNum)
}
}
}
return c to vregSlots
}
/** true when any branch/jump instruction targets a label at or before its own position,
* a label outside this subroutine, an unresolvable label, or an indirect target */
private fun branchesBackwards(sub: IRSubroutine, instrList: List<IRInstruction>, chunkFirstIndex: Map<String, Int>): Boolean {
val subChunks = mutableSetOf<IRCodeChunkBase>()
sub.forEachChunk { subChunks.add(it) }
for ((index, instr) in instrList.withIndex()) {
// an indirect jump can target arbitrary code, including a backward edge;
// be conservative and treat it like a backward branch
if (instr.opcode == Opcode.JUMPI)
return true
val target = instr.target as? CodeReference.Label ?: continue
val targetChunk = program.resolveCodeTarget(target) ?: return true
if (targetChunk !in subChunks) return true
val targetIndex = chunkFirstIndex[target.name] ?: return true
if (targetIndex <= index) return true
}
return false
}
private fun oneSlotPerRegister(vregs: Map<Int, IRDataType>, cursor: Int): Pair<Int, Map<Int, Int>> {
var c = cursor
val vregSlots = mutableMapOf<Int, Int>()
for ((regNum, irType) in vregs.entries.sortedBy { it.key }) {
val size = slotSizeFor(irType)
val alignment = when {
size >= 4 -> 4
size >= 2 -> 2
else -> 1
}
c = c.floorDiv(alignment) * alignment
c -= size
vregSlots[regNum] = c
}
return c to vregSlots
}
private fun slotSizeFor(irType: IRDataType): Int = when (irType) {
IRDataType.BYTE -> 1
IRDataType.WORD -> 2
@@ -484,19 +627,26 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro
if (sub.label !in reentrant) continue
val framed = sub.label in framedSubs
val sharedVars = staticVarsOwnedBy(sub).filter { referencedIn[it]?.contains(sub.label) == true }
// a copy-in parameter keeps a static cell for its external references (§6.1); two live
// activations of a re-entrant subroutine would share that cell
val sharedCopyIns = (framedSubs[sub.label]?.copyInParams ?: emptySet()).filter { it !in sharedVars }
// an unframed subroutine also shares its virtual registers; a framed one relocated them all
val sharedVregs = if (framed) emptyList() else vregsUsedIn(sub).keys.toList()
if (sharedVars.isEmpty() && sharedVregs.isEmpty()) continue
val reason = if (framed)
"some of the values it uses cannot be moved into a stack frame"
else
notFramedReasons[sub.label] ?: "it could not be given a stack frame"
if (sharedVars.isEmpty() && sharedVregs.isEmpty() && sharedCopyIns.isEmpty()) continue
val reason = when {
!framed -> notFramedReasons[sub.label] ?: "it could not be given a stack frame"
sharedCopyIns.isNotEmpty() -> "some parameters must be copied into a static cell for external references"
else -> "some of the values it uses cannot be moved into a stack frame"
}
val details = mutableListOf<String>()
for (varName in sharedVars) {
val v = program.st.lookup(varName) as? IRStStaticVariable
val why = v?.let { frameableVariableProblem(it, varName, addressTaken, asmTexts) } ?: "it is shared with other code"
details += "variable '${varName.substringAfterLast('.').removePrefix("p8v_")}' ($why)"
}
for (paramName in sharedCopyIns) {
details += "parameter '${paramName.substringAfterLast('.').removePrefix("p8v_")}' (its static cell for external references is shared between activations)"
}
if (sharedVregs.isNotEmpty())
details += "${sharedVregs.size} intermediate value(s)"
errors.err("recursive subroutine '${userName(sub)}' cannot be compiled: $reason, because " +
@@ -605,11 +755,20 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro
return if (v.dt.isArray) baseToScalar(v.dt.sub!!) else v.dt
}
/** like [isFrameableVariable], but an inline-assembly reference is acceptable: the parameter
* keeps its static cell and gets an entry-time copy-in from its incoming slot (§6.1) */
private fun isFrameableParameter(v: IRStStaticVariable, name: String, addressTaken: Set<String>, asmTexts: List<String>): DataType? {
if (frameableVariableProblem(v, name, addressTaken, asmTexts, asmAllowed = true) != null)
return null
// an array is described by its element data type, a scalar by its own
return if (v.dt.isArray) baseToScalar(v.dt.sub!!) else v.dt
}
/** returns why the variable cannot be moved into a frame, or null when it can */
private fun frameableVariableProblem(v: IRStStaticVariable, name: String, addressTaken: Set<String>, asmTexts: List<String>): String? {
private fun frameableVariableProblem(v: IRStStaticVariable, name: String, addressTaken: Set<String>, asmTexts: List<String>, asmAllowed: Boolean = false): String? {
if (name in addressTaken) return "its address is taken"
if (isLabelReferencedInAsm(name, asmTexts)) return "inline assembly refers to it"
if (v.initializationValue != null) return "it has a static initializer"
if (!asmAllowed && isLabelReferencedInAsm(name, asmTexts)) return "inline assembly refers to it"
if (v.initializationValue != null && v.initializationValue !is IRVariableInitializer.Numeric) return "it has a static initializer"
if (v.align > 0u) return "it requests an explicit alignment"
if (v.zpwish == ZeropageWish.REQUIRE_ZEROPAGE || v.zpwish == ZeropageWish.PREFER_ZEROPAGE) return "it wants to be in the zeropage"
return when {
@@ -618,7 +777,7 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro
v.dt.isArray && v.dt.sub != null && v.dt.sub != BaseDataType.POINTER -> {
val subDt = baseToScalar(v.dt.sub!!)
if (subDt == null) "it is not a plain numeric or boolean array"
else if (subDt.isFloat || subDt.isStructInstance || subDt.isString || subDt.isPointer) "it is not plain data"
else if (subDt.isStructInstance || subDt.isString || subDt.isPointer) "it is not plain data"
else null
}
else -> "it is not plain data"
@@ -641,6 +800,7 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro
BaseDataType.UWORD -> DataType.UWORD
BaseDataType.WORD -> DataType.WORD
BaseDataType.LONG -> DataType.LONG
BaseDataType.FLOAT -> DataType.FLOAT
else -> null
}
@@ -648,6 +808,7 @@ class StackFrameLayout(private val program: IRProgram, private val errors: IErro
dt.isByteOrBool -> IRDataType.BYTE
dt.isWord -> IRDataType.WORD
dt.isLong || dt.isPointer -> IRDataType.LONG
dt.isFloat -> IRDataType.FLOAT
else -> null
}
}
+17 -2
View File
@@ -103,7 +103,7 @@ internal fun nativeFloatConst(value: Double): String? = when (value) {
* instead. Return values are passed via virtual registers mapped back to the caller's result register.
*/
internal class AsmGen(val program: IRProgram, internal val target: ICompilationTarget) {
internal class AsmGen(val program: IRProgram, internal val target: ICompilationTarget, internal val errors: IErrorReporter? = null) {
private val output = StringBuilder()
private val regsUsed by lazy { program.registersUsed() }
internal val cpu get() = target.cpu
@@ -111,6 +111,8 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT
companion object {
const val REGFILE_LABEL = "p8_regfile"
const val FLOAT_REGFILE_LABEL = "p8_fregfile"
// frames larger than this are reported with an INFO message; deep recursion multiplies the frame per call
const val LARGE_FRAME_SIZE = 512
}
init {
@@ -763,8 +765,11 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT
emitLine("bsr run_global_inits")
frameActive = sub.hasFrame
frameVregSlots = sub.frameVregSlots
if (sub.hasFrame)
if (sub.hasFrame) {
emitRaw("; stack frame: ${sub.frameSize} bytes of locals, ${sub.incomingSize} bytes of arguments")
emitLine(if (sub.frameSize > 0) "link a5,#-${sub.frameSize}" else "link a5,#0")
reportFrameSize(sub)
}
val livenessInstructions = sub.chunks.filterIsInstance<IRCodeChunk>().flatMap { it.instructions }
val deadStoreSuppressionAllowed = canSuppressDeadStores(sub)
var instructionOffset = 0
@@ -817,6 +822,16 @@ internal class AsmGen(val program: IRProgram, internal val target: ICompilationT
private fun unscopedName(scopedName: String): String =
scopedName.substringAfterLast('.')
/** emits an informational message for unusually large frames; deep recursion multiplies the frame per call */
private fun reportFrameSize(sub: IRSubroutine) {
if (sub.frameSize > LARGE_FRAME_SIZE)
errors?.info("subroutine '${userFacingName(sub.label)}' uses a ${sub.frameSize} byte stack frame; deep recursion multiplies this per call", sub.position)
}
/** the scoped IR label without its internal scope markers, for use in user-facing messages */
private fun userFacingName(scopedName: String): String =
scopedName.split('.').joinToString(".") { it.removePrefix("p8b_").removePrefix("p8s_") }
// Dead-store suppression checks whether a register is read anywhere else in the
// subroutine (see isRegisterReadElsewhere), which is independent of control
// flow and therefore sound regardless of branch topology (loops, indirect
@@ -37,7 +37,7 @@ class M68kCodeGenerator(val retainSSA: Boolean): ICodeGeneratorBackend {
IRFileWriter(irProgram, null).write()
val gen = AsmGen(irProgram, irProgram.options.compTarget)
val gen = AsmGen(irProgram, irProgram.options.compTarget, errors)
if (!gen.generate())
throw RuntimeException("M68k assembly generation failed")
+52 -2
View File
@@ -8,6 +8,7 @@ import prog8.code.target.Qemu68kTarget
import prog8.codegen.m68k.AsmGen
import prog8.intermediate.*
import prog8tests.helpers.DummyStringEncoder
import prog8tests.helpers.ErrorReporterForTests
import kotlin.io.path.exists
import kotlin.io.path.readText
@@ -22,7 +23,8 @@ class TestStackFrameEmission : FunSpec({
instructions: List<IRInstruction>,
incomingSize: Int = 0,
subLabel: String = "test.start",
frameVregSlots: Map<Int, Int> = emptyMap()
frameVregSlots: Map<Int, Int> = emptyMap(),
errors: ErrorReporterForTests? = null
): List<String> {
val options = CompilationOptions.builder(Qemu68kTarget())
.output(OutputType.RAW)
@@ -47,7 +49,7 @@ class TestStackFrameEmission : FunSpec({
tempRoot.toFile().deleteRecursively()
tempRoot.toFile().mkdirs()
AsmGen(program, Qemu68kTarget()).generate()
AsmGen(program, Qemu68kTarget(), errors).generate()
val asmFile = tempRoot.resolve("test.asm")
check(asmFile.exists()) { "Assembly file not written: $asmFile" }
return asmFile.readText().lines().map { it.trim() }
@@ -215,4 +217,52 @@ class TestStackFrameEmission : FunSpec({
lines.none { it.contains("\$(-4)(a5)") } shouldBe true
}
test("framed subroutine emits a stack frame listing comment") {
val lines = generateAsmWithIncoming(24, listOf(
IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-1)),
IRInstructions.simple(Opcode.RETURN)
), incomingSize = 8)
lines.any { it == "; stack frame: 24 bytes of locals, 8 bytes of arguments" } shouldBe true
}
test("incoming-only frame emits a stack frame listing comment with zero locals") {
val lines = generateAsmWithIncoming(0, listOf(IRInstructions.simple(Opcode.RETURN)), incomingSize = 8)
lines.any { it == "; stack frame: 0 bytes of locals, 8 bytes of arguments" } shouldBe true
}
test("frameless subroutine emits no stack frame listing comment") {
val lines = generateAsm(0, listOf(
IRInstructions.loadMemory(Opcode.LOADM, IRDataType.BYTE, 1, IRMemory.direct("some.var")),
IRInstructions.simple(Opcode.RETURN)
))
lines.none { it.startsWith("; stack frame:") } shouldBe true
}
test("subroutine boundary markers are emitted exactly once per subroutine") {
val lines = generateAsm(16, listOf(
IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-1)),
IRInstructions.simple(Opcode.RETURN)
))
lines.count { it == "; ---- Subroutine: test.start ----" } shouldBe 1
lines.count { it == "; End of subroutine: test.start" } shouldBe 1
}
test("large frames report an informational message") {
val errors = ErrorReporterForTests()
generateAsmWithIncoming(900, listOf(
IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-1)),
IRInstructions.simple(Opcode.RETURN)
), subLabel = "p8b_main.p8s_worker", errors = errors)
errors.infos.any { it.contains("subroutine 'main.worker' uses a 900 byte stack frame; deep recursion multiplies this per call") } shouldBe true
}
test("small frames produce no informational message") {
val errors = ErrorReporterForTests()
generateAsmWithIncoming(24, listOf(
IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-1)),
IRInstructions.simple(Opcode.RETURN)
), incomingSize = 8, errors = errors)
errors.infos shouldBe emptyList()
}
})
+4 -1
View File
@@ -718,8 +718,11 @@ private fun postprocessAst(program: Program, errors: IErrorReporter, compilerOpt
program.variousCleanups(errors, compilerOptions)
val callGraph = CallGraph(program)
callGraph.checkRecursiveCalls(errors)
if(compilerOptions.compTarget.cpu.is68k)
if(compilerOptions.compTarget.cpu.is68k) {
checkDeferInReentrantSubroutines(program, callGraph, errors)
checkFrameAddressEscapes(program, errors)
checkRegfileAsmInRecursivePrograms(program, callGraph, errors)
}
program.verifyFunctionArgTypes(errors, compilerOptions)
errors.report()
@@ -0,0 +1,178 @@
package prog8.compiler.astprocessing
import prog8.ast.Node
import prog8.ast.Program
import prog8.ast.expressions.AddressOf
import prog8.ast.expressions.FunctionCallExpression
import prog8.ast.statements.*
import prog8.ast.walk.IAstVisitor
import prog8.code.core.IErrorReporter
import prog8.compiler.CallGraph
// m68k stack-frame rule (see ideas/m68k-stack-memory-model.md section 10): the address of a
// subroutine-local variable points into the current activation's frame and dangles once the
// subroutine returns. v1 only warns, best-effort: a frame address that is stored to memory
// outside the subroutine, passed to a call, returned from the subroutine, or mentioned in inline
// assembly is flagged. The check is deliberately conservative: it does not track pointer values
// flowing through intermediate locals (so transitive escapes are missed), and it can warn for
// cases that are safe today, because address-taken locals currently keep static storage due to
// the conservative frame-layout classifier.
internal fun checkFrameAddressEscapes(program: Program, errors: IErrorReporter) {
val checker = FrameAddressEscapeChecker(errors)
checker.visit(program)
checker.checkInlineAssemblyMentions()
}
private sealed interface EscapeContext
private data object NoEscape : EscapeContext
private data class StoreEscape(val storageName: String?) : EscapeContext
private data object CallEscape : EscapeContext
private data object ReturnEscape : EscapeContext
private class FrameAddressEscapeChecker(private val errors: IErrorReporter) : IAstVisitor {
private var currentSubroutine: Subroutine? = null
private val warnedLocals = mutableSetOf<VarDecl>()
private val addressTakenLocals = mutableMapOf<Subroutine, MutableSet<VarDecl>>()
private val asmMentionedNames = mutableMapOf<Subroutine, MutableSet<String>>()
override fun visit(subroutine: Subroutine) {
val previous = currentSubroutine
currentSubroutine = subroutine
subroutine.asmAddress?.varbank?.accept(this)
subroutine.statements.forEach { it.accept(this) }
currentSubroutine = previous
}
override fun visit(addressOf: AddressOf) {
val identifier = addressOf.identifier ?: return
val decl = identifier.targetVarDecl() ?: return
val declaringSub = decl.definingSubroutine
if (declaringSub == null || !isSubroutineLocal(decl, declaringSub))
return
if (decl !in warnedLocals) {
when (val escape = escapeContext(addressOf)) {
is NoEscape -> {}
is StoreEscape -> {
warnedLocals.add(decl)
val storage = escape.storageName?.let { " in '$it'" } ?: " in a location outside the subroutine"
errors.warn("the address of local '${decl.name}' in subroutine '${declaringSub.name}' is stored$storage; a stack frame address is only valid while the subroutine is running", addressOf.position)
}
is CallEscape -> {
warnedLocals.add(decl)
errors.warn("the address of local '${decl.name}' in subroutine '${declaringSub.name}' escapes the subroutine (passed to a call); a stack frame address is only valid while the subroutine is running", addressOf.position)
}
is ReturnEscape -> {
warnedLocals.add(decl)
errors.warn("the address of local '${decl.name}' in subroutine '${declaringSub.name}' escapes the subroutine (returned); a stack frame address is only valid while the subroutine is running", addressOf.position)
}
}
}
addressTakenLocals.getOrPut(declaringSub) { mutableSetOf() }.add(decl)
}
override fun visit(inlineAssembly: InlineAssembly) {
currentSubroutine?.let { sub ->
asmMentionedNames.getOrPut(sub) { mutableSetOf() }.addAll(inlineAssembly.names)
}
}
// whether an address-taken local is also mentioned in inline assembly can only be decided
// after the whole program was walked, so the check runs after the visit pass
fun checkInlineAssemblyMentions() {
for ((subroutine, locals) in addressTakenLocals) {
val asmNames = asmMentionedNames[subroutine] ?: continue
for (decl in locals) {
if (decl in warnedLocals)
continue
val shortName = "p8v_${decl.name}"
if (decl.name in asmNames || asmNames.any { it == shortName || it.endsWith(".$shortName") }) {
warnedLocals.add(decl)
errors.warn("the address of local '${decl.name}' in subroutine '${subroutine.name}' is referenced from inline assembly; a stack frame address is only valid while the subroutine is running", decl.position)
}
}
}
}
// walk up from the address-of expression to the statement that consumes the pointer value;
// the first relevant context decides whether the frame address escapes the subroutine
private fun escapeContext(addressOf: AddressOf): EscapeContext {
var node: Node = addressOf
while (true) {
when (val parent = node.parent) {
is FunctionCallExpression, is FunctionCallStatement -> return CallEscape
is Return -> return ReturnEscape
is Assignment -> {
val target = storeTarget(parent.target)
return if (target.escaping) StoreEscape(target.name) else NoEscape
}
is VarDecl -> return if (isSubroutineLocal(parent, parent.definingSubroutine)) NoEscape else StoreEscape(null)
is Statement -> return NoEscape
else -> node = parent
}
}
}
private fun storeTarget(target: AssignTarget): StoreTarget {
if (target.void)
return StoreTarget(false, null)
target.multi?.let { multi ->
for (t in multi) {
val st = storeTarget(t)
if (st.escaping)
return st
}
return StoreTarget(false, null)
}
val identifier = target.identifier
if (identifier != null) {
val decl = identifier.targetVarDecl()
if (isSubroutineLocal(decl, currentSubroutine))
return StoreTarget(false, null)
return StoreTarget(true, identifier.nameInSource.last())
}
val arrayindexed = target.arrayindexed
if (arrayindexed != null) {
val baseDecl = arrayindexed.plainarrayvar?.targetVarDecl()
if (isSubroutineLocal(baseDecl, currentSubroutine))
return StoreTarget(false, null)
return StoreTarget(true, arrayindexed.plainarrayvar?.nameInSource?.last())
}
// pointer dereference, struct field or fixed-memory targets: not a same-subroutine local,
// so conservatively treat a frame address stored there as escaping
return StoreTarget(true, null)
}
private fun isSubroutineLocal(decl: VarDecl?, subroutine: Subroutine?): Boolean {
if (decl == null || subroutine == null)
return false
if (decl.type != VarDeclType.VAR)
return false
if (decl.sharedWithAsm) // @shared variables keep static storage
return false
return decl.definingSubroutine === subroutine
}
}
private data class StoreTarget(val escaping: Boolean, val name: String?)
// m68k stack-frame rule (see ideas/m68k-stack-memory-model.md sections 3 and 17.7): the virtual
// registers of re-entrant subroutines live in stack frames, so raw inline assembly that pokes the
// program-static p8_regfile / p8_fregfile blocks would read or write the wrong storage.
internal fun checkRegfileAsmInRecursivePrograms(program: Program, callGraph: CallGraph, errors: IErrorReporter) {
if (callGraph.calls.keys.none { callGraph.hasRecursionCycle(it) })
return
val checker = object : IAstVisitor {
override fun visit(inlineAssembly: InlineAssembly) {
val text = inlineAssembly.assembly
val regfileName = when {
"p8_regfile" in text -> "p8_regfile"
"p8_fregfile" in text -> "p8_fregfile"
else -> return
}
errors.warn("inline assembly uses $regfileName but the program contains recursive subroutines whose registers now live in stack frames; this code will not do what it expects", inlineAssembly.position)
}
}
checker.visit(program)
}
+196
View File
@@ -0,0 +1,196 @@
package prog8tests.compiler
import io.kotest.core.spec.style.FunSpec
import io.kotest.engine.spec.tempdir
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import prog8.code.target.C64Target
import prog8.code.target.Qemu68kTarget
import prog8tests.helpers.ErrorReporterForTests
import prog8tests.helpers.compileText
class TestFrameAddressEscape : FunSpec({
val outputDir = tempdir().toPath()
// m68k stack frames make subroutine locals per-activation (ideas/m68k-stack-memory-model.md section 10),
// so a pointer to a local dangles after the subroutine returns. The compiler only warns, best-effort,
// on the m68k targets, and only when the frame address actually escapes the subroutine.
test("storing the address of a local into a shared variable warns on qemu68k") {
val src = """
main {
^^ubyte @shared shared_ptr
sub start() {
ubyte buf = 42
shared_ptr = &buf
}
}
"""
val errors = ErrorReporterForTests(keepMessagesAfterReporting = true)
compileText(Qemu68kTarget(), optimize = false, src, outputDir, writeAssembly = false, errors = errors) shouldNotBe null
errors.errors.size shouldBe 0
errors.warnings.any { "the address of local 'buf' in subroutine 'start' is stored in 'shared_ptr'" in it } shouldBe true
errors.warnings.any { "a stack frame address is only valid while the subroutine is running" in it } shouldBe true
}
test("returning the address of a local warns on qemu68k") {
val src = """
main {
sub start() {
^^ubyte p = getbuf()
}
sub getbuf() -> ^^ubyte {
ubyte buf = 42
return &buf
}
}
"""
val errors = ErrorReporterForTests(keepMessagesAfterReporting = true)
compileText(Qemu68kTarget(), optimize = false, src, outputDir, writeAssembly = false, errors = errors) shouldNotBe null
errors.errors.size shouldBe 0
errors.warnings.any { "the address of local 'buf' in subroutine 'getbuf' escapes the subroutine (returned)" in it } shouldBe true
}
test("passing the address of a local to a subroutine call warns on qemu68k") {
val src = """
main {
ubyte @shared result
sub start() {
ubyte buf = 42
consume(&buf)
}
sub consume(^^ubyte p) {
result = p^^
}
}
"""
val errors = ErrorReporterForTests(keepMessagesAfterReporting = true)
compileText(Qemu68kTarget(), optimize = false, src, outputDir, writeAssembly = false, errors = errors) shouldNotBe null
errors.errors.size shouldBe 0
errors.warnings.any { "the address of local 'buf' in subroutine 'start' escapes the subroutine (passed to a call)" in it } shouldBe true
}
test("warning fires only once per escaping variable even with multiple uses") {
val src = """
main {
^^ubyte @shared shared_ptr
sub start() {
ubyte buf = 42
shared_ptr = &buf
shared_ptr = &buf
}
}
"""
val errors = ErrorReporterForTests(keepMessagesAfterReporting = true)
compileText(Qemu68kTarget(), optimize = false, src, outputDir, writeAssembly = false, errors = errors) shouldNotBe null
errors.warnings.count { "the address of local 'buf' in subroutine 'start'" in it } shouldBe 1
}
test("no warning when the pointer stays inside the same subroutine") {
val src = """
main {
sub start() {
ubyte buf = 42
^^ubyte p = &buf
p^^ = 99
buf = p^^
}
}
"""
val errors = ErrorReporterForTests(keepMessagesAfterReporting = true)
compileText(Qemu68kTarget(), optimize = false, src, outputDir, writeAssembly = false, errors = errors) shouldNotBe null
errors.errors.size shouldBe 0
errors.warnings.none { "address of local" in it } shouldBe true
}
test("no escape warnings at all on a 6502 target") {
val src = """
main {
^^ubyte @shared shared_ptr
sub start() {
ubyte buf = 42
shared_ptr = &buf
}
}
"""
val errors = ErrorReporterForTests(keepMessagesAfterReporting = true)
compileText(C64Target(), optimize = false, src, outputDir, writeAssembly = false, errors = errors) shouldNotBe null
errors.errors.size shouldBe 0
errors.warnings.none { "address of local" in it } shouldBe true
}
test("inline assembly using p8_regfile warns when the program is recursive on qemu68k") {
val src = """
main {
ubyte @shared g
sub start() {
g = fib(5)
}
sub fib(ubyte n) -> ubyte {
%asm {{
move.l p8_regfile, d0
}}
if n < 2 {
return n
}
return fib(n - 1) + fib(n - 2)
}
}
"""
val errors = ErrorReporterForTests(keepMessagesAfterReporting = true)
compileText(Qemu68kTarget(), optimize = false, src, outputDir, writeAssembly = false, errors = errors) shouldNotBe null
errors.errors.size shouldBe 0
errors.warnings.any { "inline assembly uses p8_regfile" in it } shouldBe true
errors.warnings.any { "registers now live in stack frames" in it } shouldBe true
}
test("inline assembly using p8_fregfile also warns when the program is recursive on qemu68k") {
val src = """
main {
ubyte @shared g
sub start() {
g = fib(5)
}
sub fib(ubyte n) -> ubyte {
%asm {{
fmove.s p8_fregfile, fp0
}}
if n < 2 {
return n
}
return fib(n - 1) + fib(n - 2)
}
}
"""
val errors = ErrorReporterForTests(keepMessagesAfterReporting = true)
compileText(Qemu68kTarget(), optimize = false, src, outputDir, writeAssembly = false, errors = errors) shouldNotBe null
errors.errors.size shouldBe 0
errors.warnings.any { "inline assembly uses p8_fregfile" in it } shouldBe true
}
test("inline assembly using p8_regfile does not warn when the program is not recursive") {
val src = """
main {
sub start() {
%asm {{
move.l p8_regfile, d0
}}
}
}
"""
val errors = ErrorReporterForTests(keepMessagesAfterReporting = true)
compileText(Qemu68kTarget(), optimize = false, src, outputDir, writeAssembly = false, errors = errors) shouldNotBe null
errors.errors.size shouldBe 0
errors.warnings.none { "p8_regfile" in it } shouldBe true
}
})
+334
View File
@@ -462,4 +462,338 @@ main {
lines.any { "link " in it } shouldBe false
lines.any { "(a5)" in it } shouldBe false
}
test("slice 5: defer-referenced parameter is framed with an entry-time copy-in") {
// §6.1: the positive incoming slot is the parameter storage; a parameter referenced
// from a defer handler keeps its static cell and gets an entry-time copy-in
val src = """
main {
sub parm(ubyte x) {
defer x += 1
}
sub start() {
parm(1)
}
}
"""
val (lines, _) = compile(src)
val parm = subAssembly(lines, "p8b_main.p8s_parm")
// the subroutine is framed (only the incoming slot, no locals)
parm.any { it == "link a5,#0" } shouldBe true
parm.any { it == "unlk a5" } shouldBe true
// entry-time copy-in: load the incoming slot, store into the static cell, before any clearing
parm.any { it.startsWith("move.b 11(a5),") } shouldBe true
parm.any { it.contains("p8b_main.p8s_parm.p8v_x") } shouldBe true
// the static cell is kept so the block-scope defer handler can still see it
lines.any { "p8b_main.p8s_parm.p8v_x:" in it } shouldBe true
}
test("slice 5: inline-asm-referenced parameter is framed with an entry-time copy-in") {
val src = """
main {
sub rec(ubyte x) -> ubyte {
return x + 2
}
sub start() {
g = rec(3)
%asm {{
; this assembly reads the parameter's static cell
move.b p8b_main.p8s_rec.p8v_x,d0
nop
}}
}
ubyte @shared g
}
"""
val (lines, _) = compile(src)
val rec = subAssembly(lines, "p8b_main.p8s_rec")
rec.any { it == "link a5,#0" } shouldBe true
rec.any { it.startsWith("move.b 11(a5),") } shouldBe true
rec.any { it.contains("p8b_main.p8s_rec.p8v_x") } shouldBe true
// the static cell is kept for the assembly reference; the body itself uses the frame slot
lines.any { "p8b_main.p8s_rec.p8v_x:" in it } shouldBe true
rec.any { it.startsWith("move.b 11(a5),") && it.endsWith(",p8_regfile+0") } shouldBe true
}
test("slice 5: static-initialized local is framed and initialized at entry") {
val src = """
main {
sub leaf() {
ubyte x = 5
x += 1
}
sub start() {
leaf()
}
}
"""
val (lines, _) = compile(src)
val leaf = subAssembly(lines, "p8b_main.p8s_leaf")
leaf.any { it.startsWith("link ") } shouldBe true
leaf.any { it == "move.b #5,-1(a5)" } shouldBe true
// the initialized local no longer lives in static storage
lines.none { "p8v_x:" in it } shouldBe true
}
test("slice 5: float static-initialized local is framed") {
val src = """
%option enable_floats
main {
sub leaf() {
float f = 1.5
f += 1.0
}
sub start() {
leaf()
}
}
"""
val (lines, _) = compile(src)
val leaf = subAssembly(lines, "p8b_main.p8s_leaf")
leaf.any { it.startsWith("link ") } shouldBe true
leaf.any { it.contains("fmove.s fp0,-4(a5)") } shouldBe true
lines.none { "p8v_f:" in it } shouldBe true
}
test("slice 5: float array local is framed and zero-filled in the prologue") {
val src = """
%option enable_floats
main {
sub leaf() {
float[2] arr
arr[0] = 1.5
}
sub start() {
leaf()
}
}
"""
val (lines, _) = compile(src)
val leaf = subAssembly(lines, "p8b_main.p8s_leaf")
leaf.any { it == "link a5,#-8" } shouldBe true
// every float element is zero-filled in the prologue (STOREZM float)
leaf.any { it.contains("fmove.s fp0,-8(a5)") } shouldBe true
leaf.any { it.contains("fmove.s fp0,-4(a5)") } shouldBe true
lines.none { "p8v_arr:" in it } shouldBe true
}
test("slice 5: recursive subroutine with a defer-referenced parameter is rejected") {
val src = """
main {
sub rec(ubyte n) -> ubyte {
defer n = n + 1
if n == 0 {
return 0
}
return rec(n-1)
}
sub start() {
g = rec(3)
}
ubyte @shared g
}
"""
val errors = ErrorReporterForTests(keepMessagesAfterReporting = true)
compileText(Qemu68kTarget(), optimize = false, src, tempdir().toPath(),
writeAssembly = true, assemble = false, errors = errors) shouldBe null
// the defer rule rejects this at the compiler level; the static cell would be
// shared between live activations of the recursive subroutine
errors.errors.any { it.contains("recursive") } shouldBe true
}
test("slice 5: recursive subroutine with an inline-asm-referenced parameter is rejected") {
val src = """
main {
sub rec(ubyte n) -> ubyte {
return rec(1)
}
sub start() {
g = rec(3)
%asm {{
move.b p8b_main.p8s_rec.p8v_n,d0
nop
}}
}
ubyte @shared g
}
"""
val errors = ErrorReporterForTests(keepMessagesAfterReporting = true)
compileText(Qemu68kTarget(), optimize = false, src, tempdir().toPath(),
writeAssembly = true, assemble = false, errors = errors) shouldBe null
errors.errors.any { it.contains("recursive") } shouldBe true
errors.errors.any { it.contains("shared between activations") } shouldBe true
}
test("slice 5: recursive subroutine with a static-initialized local now compiles") {
val src = """
main {
sub rec(ubyte n) -> ubyte {
ubyte local = 5
if n == 0 {
return local + n
}
return rec(n-1) + local
}
sub start() {
g = rec(3)
}
ubyte @shared g
}
"""
val (lines, ir) = compile(src)
// the static-initialized local is now frameable, so nothing keeps static storage
ir.contains("VREGSLOTS=") shouldBe true
val rec = subAssembly(lines, "p8b_main.p8s_rec")
rec.any { it.startsWith("link ") } shouldBe true
rec.any { it.contains("p8_regfile") } shouldBe false
}
test("slice 5: vreg slots of a recursive subroutine are reused when lifetimes don't overlap") {
// two recursive call results are each stored right after their call, so their lifetimes
// don't overlap and they can share one frame slot; the frame shrinks from 16 to 10 bytes
val src = """
main {
sub rec(ubyte n) -> uword {
if n == 0
return 1
uword a = rec(n-1)
uword b = rec(n-2)
return a + b
}
ubyte @shared g = 1
sub start() {
g = lsb(rec(10))
}
}
"""
val (_, ir) = compile(src)
val subLine = ir.lineSequence().first { it.contains("SUB NAME=\"p8b_main.p8s_rec\"") }
subLine.contains("FRAMESIZE=\"10\"") shouldBe true
val slots = subLine.substringAfter("VREGSLOTS=\"").substringBefore('"').split(',').associate {
it.substringBefore(':').toInt() to it.substringAfter(':').toInt()
}
// the two call results inside rec (the call results whose registers appear in the slot
// map; the call from start uses a register outside it) share one slot
val callResults = Regex("call p8b_main\\.p8s_rec\\([^)]*\\):r(\\d+)\\.w").findAll(ir)
.map { it.groupValues[1].toInt() }
.filter { it in slots }
.toList()
callResults.size shouldBe 2
slots.getValue(callResults[0]) shouldBe slots.getValue(callResults[1])
// reuse happened: fewer distinct slots than virtual registers
slots.values.toSet().size shouldBe 3
slots.size shouldBe 7
// the layout is deterministic across compilations
val (_, ir2) = compile(src)
val subLine2 = ir2.lineSequence().first { it.contains("SUB NAME=\"p8b_main.p8s_rec\"") }
subLine2.substringAfter("FRAMESIZE") shouldBe subLine.substringAfter("FRAMESIZE")
}
test("slice 5: a recursive subroutine with a loop keeps one slot per virtual register") {
// the loop's back edge can keep values live across it, so plain last-use intervals
// would be unsound and the safe one-slot-per-register layout is kept
val src = """
main {
sub rec(ubyte n) -> uword {
uword total = 0
for i in 0 to 5 {
total += 1
}
if n == 0
return total
return total + rec(n-1)
}
ubyte @shared g = 1
sub start() {
g = lsb(rec(10))
}
}
"""
val (_, ir) = compile(src)
val subLine = ir.lineSequence().first { it.contains("SUB NAME=\"p8b_main.p8s_rec\"") }
subLine.contains("FRAMESIZE=\"12\"") shouldBe true
subLine.contains("VREGSLOTS=\"1:-3,2:-6,3:-7,4:-10,5:-12\"") shouldBe true
val slots = subLine.substringAfter("VREGSLOTS=\"").substringBefore('"').split(',').associate {
it.substringBefore(':').toInt() to it.substringAfter(':').toInt()
}
// every register has its own dedicated slot: all offsets are distinct
slots.values.toSet().size shouldBe slots.size
}
test("slice 5: simultaneously-live virtual registers get distinct frame slots") {
// both recursive call results are live at the addition, so they cannot share a slot
val src = """
main {
sub rec(ubyte n) -> uword {
if n < 2
return n
return rec(n-1) + rec(n-2)
}
ubyte @shared g = 1
sub start() {
g = lsb(rec(10))
}
}
"""
val (_, ir) = compile(src)
val subLine = ir.lineSequence().first { it.contains("SUB NAME=\"p8b_main.p8s_rec\"") }
val slots = subLine.substringAfter("VREGSLOTS=\"").substringBefore('"').split(',').associate {
it.substringBefore(':').toInt() to it.substringAfter(':').toInt()
}
val callResults = Regex("call p8b_main\\.p8s_rec\\([^)]*\\):r(\\d+)\\.w").findAll(ir)
.map { it.groupValues[1].toInt() }
.filter { it in slots }
.toList()
callResults.size shouldBe 2
slots.getValue(callResults[0]) shouldNotBe slots.getValue(callResults[1])
}
test("slice 5: non-recursive subroutines keep the static register file and no vreg slots") {
// only re-entrant subroutines relocate their virtual registers into the frame
val src = """
main {
ubyte gv = 2
sub inner() { gv += 1 }
sub caller() {
ubyte a
inner()
a = gv
}
sub start() {
caller()
}
}
"""
val (lines, ir) = compile(src)
ir.contains("VREGSLOTS") shouldBe false
val caller = subAssembly(lines, "p8b_main.p8s_caller")
caller.any { it == "link a5,#-2" } shouldBe true
caller.any { it.contains("p8_regfile") } shouldBe true
}
test("slice 5: compiler-generated temporaries inside a subroutine are framed already") {
// the desugarer of `on ... goto` creates its own auto_heap_value variables; plain
// scalar ones are ordinary subroutine locals and are framed without any extra work
val src = """
main {
sub pick(ubyte n) -> ubyte {
on n goto(smaller, bigger)
return 0
smaller:
return 1
bigger:
return 2
}
sub start() {
g = pick(1)
}
ubyte @shared g
}
"""
val (lines, ir) = compile(src)
val pick = subAssembly(lines, "p8b_main.p8s_pick")
pick.any { it.startsWith("link ") || it.startsWith("bra") } shouldBe true
// the jump table auto-variable keeps static storage on purpose: it is read-only
// constant data, and framing it would add an initialization store on every call
ir.contains("auto_heap_value") shouldBe true
}
})
+3 -1
View File
@@ -22,7 +22,7 @@ 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.
- Deferred: implement a stack-based memory model for m68k locals and parameters to make subroutines reentrant and recursive. See ideas/m68k-stack-memory-model.md for the design.
- Deferred: the m68k stack-based memory model for locals and parameters (making subroutines reentrant and recursive) is implemented for slices 1, 2, 3 and 5 of ``ideas/m68k-stack-memory-model.md``: 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: the remaining optimization items below, and the VM activation records of slice 4, which are planned in ``ideas/m68k-vm-frames-plan.md`` (the VM rejects frame-based IR until then).
- 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,6 +59,8 @@ 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 (see §7 and §17.8 of ``ideas/m68k-stack-memory-model.md``).
- 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. See §17.7 and §17.8 of ``ideas/m68k-stack-memory-model.md``.
Dead Code Elimination bug in 64tass, for nested subroutines
+138 -9
View File
@@ -1,6 +1,6 @@
# M68K Stack Memory Model
**Status: slices 1-3 implemented (Vertical Slice Prototype §17, parameters-on-the-stack §17.6, re-entrant subroutines §17.7); slices 4-5 and the register allocator are not yet implemented.** Listed as "Deferred" in `docs/source/todo.rst`.
**Status: slices 1-3 and 5 implemented (Vertical Slice Prototype §17, parameters-on-the-stack §17.6, re-entrant subroutines §17.7, diagnostics and polish §17.8); slice 4 and the register allocator are not 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
@@ -789,11 +789,11 @@ without corrupting anything.
**done** (see §17.7).
4. VM activation records + recursion tests through the VM. Planned in detail,
deliberately not implemented yet: `ideas/m68k-vm-frames-plan.md`.
5. Diagnostics and polish: escape warnings (§10), frame-size reporting,
liveness-based slot reuse (currently *every* vreg of a re-entrant
subroutine gets its own slot), §4 auto-temporaries, §6.1 copy-in for
5. Diagnostics and polish - **done**, see §17.8: escape warnings (§10),
frame-size reporting, liveness-based slot reuse, §6.1 copy-in for
constrained parameters (inline-asm / defer referenced), float and
static-initialized locals via per-slot init metadata.
static-initialized locals in frames, and the resolution of §4
auto-temporaries. Remaining items are optimizations only (§17.8).
### 17.5 Findings (prototype / slice 1)
@@ -918,8 +918,9 @@ 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).
- ~~No copy-in yet for inline-asm/defer referenced parameters~~ - implemented
in §17.8, with a compile error when the subroutine is also re-entrant.
Float arrays and numerically initialized locals can now live in frames too.
- The VM still rejects frame-based IR (slice 4).
### 17.7 Slice 3: re-entrant subroutines (implemented)
@@ -1004,11 +1005,139 @@ results with and without optimizations.
- Frames are still rejected by the VM (slice 4), so recursion cannot be tested
through the VM yet.
- Every virtual register of a re-entrant subroutine gets a slot, so its frame is
larger than strictly necessary; liveness-based reuse is slice-5 work.
- ~~Every virtual register of a re-entrant subroutine gets a slot~~ - resolved
in §17.8: slots are now shared between registers with non-overlapping live
ranges (with a conservative fallback for subroutines containing loops).
- The two rejections above are reported by the frame-layout pass, which only
runs when assembly is generated: `-check` reports the `defer` rule (compiler
phase) but not the frame-layout rejections.
- Dynamic dispatch remains conservative: a subroutine whose address is taken
cannot be framed at all, so a callback cycle through a dispatch table is
rejected rather than compiled.
### 17.8 Slice 5: diagnostics and polish (implemented)
Status: implemented, runtime-verified on qemu-system-m68k, full test suite
green.
#### Liveness-based frame-slot reuse
A re-entrant subroutine used to get one dedicated frame slot per virtual
register. Two registers whose live ranges never overlap can share a slot, which
matters because the frame is paid on every recursive call. The allocator now:
- flattens the subroutine into a linear instruction order (loop bodies inline)
and records where each labeled chunk starts;
- computes a `[firstUse, lastUse]` interval per register from its register
accesses;
- allocates greedily in instruction order: a register takes a slot whose previous
occupant stopped being used before this register starts, otherwise a new slot
is taken below the locals;
- **falls back to one slot per register whenever the subroutine contains a back
edge** (an `IRLoopChunk`, a branch to a label at or before its own position, or
a jump into another subroutine), because a value can then be live across a
jump backwards and plain last-use intervals stop being sound. This is the
deliberate seam for a future loop-aware dataflow analysis.
Sharing is safe by construction: any execution path is a subsequence of the
flattened order, so simultaneously live registers always have overlapping
intervals and can never be aliased. Allocation is deterministic.
Measured effect on a recursive subroutine with twelve virtual registers, three of
which are helper-call results consumed immediately: `FRAMESIZE` 32 -> 12, twelve
slots -> two. A recursive subroutine containing a `while` loop keeps all seven of
its dedicated slots, as intended.
#### Entry-time copy-in for constrained parameters (§6.1)
A parameter that inline assembly or a defer handler refers to by name needs a
permanent address, because those refer to it from outside the activation. Such a
parameter no longer disqualifies the whole subroutine from having a frame:
- it keeps its positive incoming slot as its storage, exactly like any other
parameter (internal references are unchanged);
- it keeps its static cell in the BSS symbol table, and the prologue copies the
incoming value into that cell before anything else runs;
- a parameter whose address is merely *taken* inside the callee keeps the older
behavior (no frame at all), since §6.1 treats `&param` as ordinary
local-pointer semantics and that case is not resolved yet;
- if such a parameter occurs in a re-entrant subroutine it is a compile error,
because one static cell shared by nested activations would be wrong. The
defer case is caught earlier by the §11 rule; the inline-asm case by the
frame-layout pass.
#### Locals with an initializer, and float locals, in frames
- A local carrying a numeric initializer is now framed and initialized in the
prologue instead of keeping static storage. Note that plain numeric
initializers were already lowered to a body-level `storeim` before this
change, so in practice this affects the shapes that still carried an
initializer on the symbol-table entry.
- Float array locals are frameable and zero-filled element by element in the
prologue; float scalars are now zero-cleared as well (previously they were
framed but silently left uninitialized).
- Initializers that cannot be expressed as a single store (`Str`, `Array`
initializers) still keep the variable static, and a re-entrant subroutine that
needs one is still rejected.
#### Escape diagnostics (§10)
Warnings only, never errors, m68k targets only, implemented in the compiler
module next to the §11 defer check:
- the address of a subroutine local or parameter is stored outside that
subroutine, passed in a call, returned, or named in inline assembly. The
message names the variable, the subroutine, and the escape form. This is
best-effort by design: flow through an intermediate local is not tracked, and
address-of nodes that the optimizer folded away before the check runs are not
seen.
- raw inline assembly mentions `p8_regfile`/`p8_fregfile` while the program
contains recursive subroutines, whose registers now live in frames and would
therefore not be where the assembly expects them.
Known noise: amiga500's `textio.print_*` routines pass `&param` to `dos.VPrintf`
and now warn. Those are true positives, but a follow-up should decide whether to
exempt the standard library or narrow the diagnostic.
#### Frame-size reporting
- every framed subroutine gets a listing comment above its prologue:
`; stack frame: 24 bytes of locals, 8 bytes of arguments`;
- a frame above 512 bytes also produces an INFO message naming the subroutine and
its size, because deep recursion multiplies it per call;
- the pre-existing hard rejection of frames over `MAX_FRAME_SIZE` is unchanged.
#### Compiler auto-temporaries (§4): resolved, nothing to do
§4 predates the frame-layout pass and assumed auto-temporaries needed their own
marking. They do not: `auto_heap_value_N` are ordinary `VarDecl`s, and the pass
frames any plain subroutine-scoped local, so the scalar ones (the `on..goto`
index variable, the pointer dot-chain address temp, hoisted array literals) are
framed today without extra work.
The exception is the `on..goto`/`on..call` jump table, which stays static on
purpose. It carries a static initializer (symbolic addresses), so it cannot be
framed without array/symbolic initializer support in frames, and framing a
read-only jump table would be the wrong trade anyway: it would add an
initialization store on every call to save a few bytes of BSS. A test pins this
behavior. Should a future slice add initializer support for frame slots, revisit
this decision then.
#### Still open (optimizations, not correctness)
- **Zeroing coalescing.** The prologue still emits one store per element. A
prototype of range merging worked and was measured: contiguous clean locals
are merged and cleared with the widest stores the alignment allows, turning a
17-byte clear into 5 stores (4 long stores plus 1 byte store), with the array
and its neighbouring byte sharing the merged range. It was reverted rather
than kept, because the invariant it depends on is subtle and only worth its
code size: only *clean, non-initialized* locals may contribute a range, ranges
must never be merged across a dirty or initialized neighbour, and float
elements cannot be folded into integer stores. Worth doing only together with
the range/offset bookkeeping becoming explicit (for example a per-slot
"size, alignment, initialization" list, which §7 already anticipates).
- **Loop-aware liveness**, so that recursive subroutines containing loops can
share slots too. This needs a real backward dataflow analysis, not interval
extension; see the note in §17.7 about the current conservative fallback.
- **§4 compiler auto-temporaries**: resolved as described above; only the jump
table case is open, and it is a deliberate non-goal.
+2 -2
View File
@@ -5,8 +5,8 @@ the VM keeps rejecting frame-based IR for now. This document records the design
the exact steps so the slice can be picked up later without re-deriving it.
Context: `ideas/m68k-stack-memory-model.md` (§9 "VM Changes", §17.4 item 4 "VM
activation records + recursion tests through the VM"). Slices 1-3 of that document
are implemented; the m68k backend produces frame-based IR, the VM refuses it.
activation records + recursion tests through the VM"). The m68k backend produces
frame-based IR; the VM refuses it.
---