mirror of
https://github.com/irmen/prog8.git
synced 2026-10-22 05:02:58 +00:00
slice 5 for m68k stack memory model
(slice 4 was skipped)
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 `¶m` 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 `¶m` 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.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in new issue
Block a user