Files
prog8/codeGenM68k/test/TestStackFrameEmission.kt

474 lines
23 KiB
Kotlin

package prog8tests.codegen.m68k
import io.kotest.core.spec.style.FunSpec
import io.kotest.engine.spec.tempdir
import io.kotest.matchers.shouldBe
import prog8.code.core.*
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
// Vertical Slice Prototype: m68k backend lowering of FrameSlot memory references
// and the link/unlk frame prologue/epilogue.
class TestStackFrameEmission : FunSpec({
val tempRoot = tempdir().toPath()
fun generateAsmWithIncoming(
frameSize: Int,
instructions: List<IRInstruction>,
incomingSize: Int = 0,
subLabel: String = "test.start",
frameVregSlots: Map<Int, Int> = emptyMap(),
errors: ErrorReporterForTests? = null,
params: List<IRSubroutine.IRParam>? = null
): List<String> {
val options = CompilationOptions.builder(Qemu68kTarget())
.output(OutputType.RAW)
.zeropage(ZeropageType.DONTUSE)
.floats(false)
.compilerVersion("test")
.memtopAddress(0xffffu)
.optimize(true)
.build()
val program = IRProgram("test", IRSymbolTable(), options, DummyStringEncoder)
program.options.outputDir = tempRoot
val chunk = IRCodeChunk(subLabel, null)
chunk.instructions.addAll(instructions)
// word-packed slots: give callers a way to declare each parameter's real type,
// since the default derivation from the incoming area size assumes uniform slots
val paramList = params
?: if (incomingSize > 0)
List(incomingSize / 2) { IRSubroutine.IRParam("p8v_p$it", DataType.UBYTE) }
else emptyList()
val sub = IRSubroutine(subLabel, paramList, emptyList(), Position.DUMMY, frameSize, incomingSize, frameVregSlots)
sub.chunks.add(chunk)
val block = IRBlock("test", false, IRBlock.Options(), Position.DUMMY)
block.children.add(sub)
program.blocks.add(block)
tempRoot.toFile().deleteRecursively()
tempRoot.toFile().mkdirs()
AsmGen(program, Qemu68kTarget(), errors).generate()
val asmFile = tempRoot.resolve("test.asm")
check(asmFile.exists()) { "Assembly file not written: $asmFile" }
return asmFile.readText().lines().map { it.trim() }
}
fun generateAsm(frameSize: Int, instructions: List<IRInstruction>): List<String> =
generateAsmWithIncoming(frameSize, instructions, 0, "test.start")
// builds a subroutine with a chunk per extra chunk label, for jump-target scope tests
fun sub(label: String, frameSize: Int, instructions: List<IRInstruction>, extraChunks: List<IRCodeChunkBase> = emptyList()): IRSubroutine {
val first = IRCodeChunk(label, null)
first.instructions.addAll(instructions)
val sub = IRSubroutine(label, emptyList(), emptyList(), Position.DUMMY, frameSize, 0, emptyMap())
sub.chunks.add(first)
sub.chunks.addAll(extraChunks)
return sub
}
fun generateAsmForSubs(vararg subs: IRSubroutine): List<String> {
val options = CompilationOptions.builder(Qemu68kTarget())
.output(OutputType.RAW)
.zeropage(ZeropageType.DONTUSE)
.floats(false)
.compilerVersion("test")
.memtopAddress(0xffffu)
.optimize(true)
.build()
val program = IRProgram("test", IRSymbolTable(), options, DummyStringEncoder)
program.options.outputDir = tempRoot
val block = IRBlock("test", false, IRBlock.Options(), Position.DUMMY)
block.children.addAll(subs)
program.blocks.add(block)
program.linkChunks()
tempRoot.toFile().deleteRecursively()
tempRoot.toFile().mkdirs()
AsmGen(program, Qemu68kTarget(), null).generate()
return tempRoot.resolve("test.asm").readText().lines().map { it.trim() }
}
test("frameless subroutine emits no frame setup") {
val lines = generateAsm(0, listOf(
IRInstructions.loadMemory(Opcode.LOADM, IRDataType.BYTE, 1, IRMemory.direct("some.var")),
IRInstructions.simple(Opcode.RETURN)
))
lines.any { it.startsWith("link") } shouldBe false
lines.any { it.startsWith("unlk") } shouldBe false
}
test("framed subroutine emits link prologue and unlk epilogue") {
val lines = generateAsm(16, listOf(
IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-1)),
IRInstructions.simple(Opcode.RETURN)
))
lines.any { it == "link a5,#-16" } shouldBe true
lines.count { it == "unlk a5" } shouldBe 1
lines.any { "-1(a5)" in it } shouldBe true
// epilogue must directly precede the subroutine's return
lines[lines.indexOf("unlk a5") + 1] shouldBe "rts"
}
test("frame slot direct references lower to displacements relative to a5") {
val lines = generateAsm(16, listOf(
IRInstructions.storeImmediate(IRDataType.WORD, 5, IRMemory.frameDirect(-4)),
IRInstructions.loadMemory(Opcode.LOADM, IRDataType.BYTE, 1, IRMemory.frameDirect(-1)),
IRInstructions.memoryOpImmediate(Opcode.ADDIM, IRDataType.BYTE, IRMemory.frameDirect(-1), 3),
IRInstructions.simple(Opcode.RETURN)
))
lines.any { it.contains("-4(a5)") && it.startsWith("move.w") } shouldBe true
lines.any { it.contains("-1(a5)") } shouldBe true
lines.any { it.startsWith("addq.b") || it.startsWith("add.b") } shouldBe true
}
test("indexed frame slot access uses lea of the slot base plus index") {
val lines = generateAsm(16, listOf(
IRInstructions.loadMemory(Opcode.LOADX, IRDataType.BYTE, 1, IRMemory.frameIndexed(-8, 3, IRDataType.WORD, scale = 1)),
IRInstructions.storeZero(Opcode.STOREZX, IRDataType.BYTE, IRMemory.frameIndexed(-8, 3, IRDataType.WORD, scale = 1)),
IRInstructions.simple(Opcode.RETURN)
))
lines.any { it.startsWith("lea") && "-8(a5)" in it } shouldBe true
lines.count { it.contains("(a0,d0.w)") } shouldBe 2
}
test("frame displacement combines slot offset and reference displacement") {
val lines = generateAsm(16, listOf(
IRInstructions.storeZero(Opcode.STOREZM, IRDataType.BYTE, IRMemory.frameDirect(-8, 4)),
IRInstructions.simple(Opcode.RETURN)
))
// -8 slot + 4 displacement = -4(a5), a single combined displacement
lines.any { it.contains("-4(a5)") } shouldBe true
lines.any { it.contains("-8(a5)+4") } shouldBe false
}
test("incoming-only frame still emits link and unlk") {
val lines = generateAsmWithIncoming(0, listOf(IRInstructions.simple(Opcode.RETURN)), incomingSize = 12)
lines.any { it == "link a5,#0" } shouldBe true
lines.any { it == "unlk a5" } shouldBe true
}
test("stack arguments are pushed word-aligned into their slots and popped by the caller") {
val calls = listOf(
IRInstructions.call(CallSite(
target = CallTarget.Direct(CodeReference.Label("test.sub")),
arguments = listOf(
Calls.argument(1, IRDataType.BYTE, CallLocation.FrameSlot(16)),
Calls.argument(2, IRDataType.WORD, CallLocation.FrameSlot(12)),
Calls.argument(3, IRDataType.LONG, CallLocation.FrameSlot(8))
)
)),
IRInstructions.simple(Opcode.RETURN)
)
val lines = generateAsmWithIncoming(0, calls, incomingSize = 12, subLabel = "test.caller")
// word-packed pushes: byte arguments use the single move.b predecrement instruction
// (the byte lands at the slot's base byte), word and long push as themselves
lines.count { it.startsWith("move.b ") && it.contains(",-(sp)") && it.contains("pushed as a word") } shouldBe 1
lines.count { it.startsWith("move.w ") && it.contains(",-(sp)") } shouldBe 1
lines.count { it.startsWith("move.l ") && it.contains(",-(sp)") } shouldBe 1
lines.none { it == "subq.l #2,sp" } shouldBe true
lines.any { it.startsWith("bsr") } shouldBe true
lines.any { it == "addq.l #8,sp" } shouldBe true // 2+2+4 bytes
// small argument areas use the short addq form
val smallCalls = listOf(
IRInstructions.call(CallSite(
target = CallTarget.Direct(CodeReference.Label("test.sub")),
arguments = listOf(Calls.argument(1, IRDataType.BYTE, CallLocation.FrameSlot(8)))
)),
IRInstructions.simple(Opcode.RETURN)
)
val smallLines = generateAsmWithIncoming(0, smallCalls, incomingSize = 4, subLabel = "test.caller")
smallLines.any { it == "addq.l #2,sp" } shouldBe true
}
test("status-flag results use CCR-preserving cleanup") {
val calls = listOf(
IRInstructions.call(CallSite(
target = CallTarget.Direct(CodeReference.Label("test.sub")),
arguments = listOf(Calls.argument(1, IRDataType.BYTE, CallLocation.FrameSlot(8))),
results = listOf(CallResult(null, CallLocation.StatusFlag(Statusflag.Pz)))
)),
IRInstructions.simple(Opcode.RETURN)
)
val lines = generateAsmWithIncoming(0, calls, incomingSize = 4, subLabel = "test.caller")
// a single byte argument (2-byte slot) would normally use addq.l #2,sp, but that
// clobbers CCR, so status-flag returns must use lea instead.
lines.any { it == "lea 2(sp),sp" } shouldBe true
lines.none { it == "addq.l #2,sp" } shouldBe true
}
test("float stack arguments travel through the FPU accumulator") {
val calls = listOf(
IRInstructions.call(CallSite(
target = CallTarget.Direct(CodeReference.Label("test.sub")),
arguments = listOf(Calls.argument(1, IRDataType.FLOAT, CallLocation.FrameSlot(8)))
)),
IRInstructions.simple(Opcode.RETURN)
)
val lines = generateAsmWithIncoming(0, calls, incomingSize = 4, subLabel = "test.caller")
// the 68881 has no absolute-long addressing mode: the value must pass through fp0,
// and the store itself reserves the slot via predecrement
lines.any { it.startsWith("fmove.s p8_fregfile+0,fp0") } shouldBe true
lines.any { it == "fmove.s fp0,-(sp)" } shouldBe true
lines.none { it == "subq.l #4,sp" } shouldBe true
}
test("per-activation virtual registers are addressed through the frame") {
// a re-entrant subroutine keeps its virtual registers in its own frame (slice 3), so they
// are addressed relative to a5 instead of the flat program-static register file
val lines = generateAsmWithIncoming(8, listOf(
IRInstructions.loadMemory(Opcode.LOADM, IRDataType.WORD, 5, IRMemory.direct("main.var")),
IRInstructions.returnRegister(IRDataType.WORD, 5)
), frameVregSlots = mapOf(5 to -2, 6 to -4))
lines.any { it.startsWith("link ") } shouldBe true
lines.any { it.startsWith("move") && it.contains("-2(a5)") } shouldBe true
lines.none { it.startsWith("move") && it.contains("p8_regfile") } shouldBe true
}
test("virtual registers without a frame slot keep using the static register file") {
val lines = generateAsmWithIncoming(8, listOf(
IRInstructions.loadMemory(Opcode.LOADM, IRDataType.WORD, 5, IRMemory.direct("main.var")),
IRInstructions.returnRegister(IRDataType.WORD, 5)
), frameVregSlots = mapOf(6 to -4))
// register 5 has no frame slot, so it stays in the static register file
lines.any { it.startsWith("move") && it.contains("p8_regfile") } shouldBe true
lines.any { it.startsWith("move") && it.contains("(a5)") && it.contains("p8_regfile") } shouldBe false
}
test("a byte-sized frame-resident register is addressed with its byte offset") {
val lines = generateAsmWithIncoming(4, listOf(
IRInstructions.loadMemory(Opcode.LOADM, IRDataType.BYTE, 5, IRMemory.direct("main.var")),
IRInstructions.returnRegister(IRDataType.BYTE, 5)
), frameVregSlots = mapOf(5 to -4))
lines.any { it.startsWith("move.b") && it.contains("-4(a5)") } shouldBe true
}
test("sub-byte extraction of a frame-resident register uses a valid a5 displacement") {
// MSIGB on a long goes through regAddrByte, which must not emit "$(-4)(a5)"
val lines = generateAsmWithIncoming(4, listOf(
IRInstructions.binary(Opcode.MSIGB, IRDataType.LONG, 6, 5),
IRInstructions.returnRegister(IRDataType.BYTE, 6)
), frameVregSlots = mapOf(5 to -4))
lines.any { it.startsWith("move.b") && it.contains("-4(a5)") } shouldBe true
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()
}
test("a long run of longword zero-clears lowers to a dbra loop") {
val stores = (0 until 17).map { i ->
IRInstructions.storeZero(Opcode.STOREZM, IRDataType.LONG, IRMemory.frameDirect(-68 + i * 4))
}
val lines = generateAsm(68, stores + IRInstructions.simple(Opcode.RETURN))
lines.any { it == "moveq #0,d0" } shouldBe true
lines.any { it == "lea -68(a5),a0" } shouldBe true
lines.any { it == "moveq #16,d1" } shouldBe true
lines.any { it == "move.l d0,(a0)+" } shouldBe true
lines.count { it.startsWith("dbra d1,zeroloop_") } shouldBe 1
lines.none { it.startsWith("clr.l") } shouldBe true
}
test("a short run of longword zero-clears keeps straight stores") {
val stores = (0 until 15).map { i ->
IRInstructions.storeZero(Opcode.STOREZM, IRDataType.LONG, IRMemory.frameDirect(-60 + i * 4))
}
val lines = generateAsm(60, stores + IRInstructions.simple(Opcode.RETURN))
lines.count { it.startsWith("clr.l") } shouldBe 15
lines.none { it.startsWith("dbra") } shouldBe true
}
test("an interleaved store splits the zero-clear run") {
val first = (0 until 8).map { i ->
IRInstructions.storeZero(Opcode.STOREZM, IRDataType.LONG, IRMemory.frameDirect(-68 + i * 4))
}
val second = (0 until 8).map { i ->
IRInstructions.storeZero(Opcode.STOREZM, IRDataType.LONG, IRMemory.frameDirect(-32 + i * 4))
}
val instructions = first +
IRInstructions.storeImmediate(IRDataType.BYTE, 0, IRMemory.frameDirect(-36)) +
second +
IRInstructions.simple(Opcode.RETURN)
val lines = generateAsm(68, instructions)
// two runs of 8 stay below the loop threshold
lines.none { it.startsWith("dbra") } shouldBe true
lines.count { it.startsWith("clr.l") } shouldBe 16
lines.any { it == "clr.b -36(a5)" } shouldBe true
}
test("dispatch jump in a framed subroutine pops the frame before jumping") {
// an on..goto leaves the subroutine for good, so the target's rts must find our caller's
// return address: leaving our frame behind would make it pop garbage
val lines = generateAsm(16, listOf(
IRInstructions.jumpIndirect(5),
IRInstructions.simple(Opcode.RETURN)
))
val unlkIdx = lines.indexOf("unlk a5")
unlkIdx shouldBe lines.indexOfFirst { it.startsWith("jmp") } - 1
}
test("dispatch jump in a frameless subroutine emits no frame teardown") {
val lines = generateAsm(0, listOf(
IRInstructions.jumpIndirect(5),
IRInstructions.simple(Opcode.RETURN)
))
lines.none { it.startsWith("unlk") } shouldBe true
}
test("jump to an absolute address pops the frame before jumping") {
// a fixed address can't be resolved to a chunk of this subroutine, so it must be assumed
// to leave for good and the frame must be gone before the target's eventual rts
val lines = generateAsm(16, listOf(
IRInstructions.jump(codeAddress(0x1234u)),
IRInstructions.simple(Opcode.RETURN)
))
val jmpIdx = lines.indexOfFirst { it.startsWith("jmp") }
(jmpIdx > 0) shouldBe true
lines[jmpIdx - 1] shouldBe "unlk a5"
}
test("jump to an absolute address in a frameless subroutine emits no frame teardown") {
val lines = generateAsm(0, listOf(
IRInstructions.jump(codeAddress(0x1234u)),
IRInstructions.simple(Opcode.RETURN)
))
lines.none { it.startsWith("unlk") } shouldBe true
}
test("conditional branch to an absolute address tears down only the taken path") {
val lines = generateAsm(16, listOf(
IRInstructions.branch(Opcode.BSTEQ, codeAddress(0x1234u)),
IRInstructions.simple(Opcode.RETURN)
))
val branchIdx = lines.indexOfFirst { it.startsWith("bne") }
val jmpIdx = lines.indexOfFirst { it.startsWith("jmp") && "$1234" in it }
(branchIdx >= 0) shouldBe true
(jmpIdx > branchIdx) shouldBe true
lines[jmpIdx - 1] shouldBe "unlk a5"
lines.count { it == "unlk a5" } shouldBe 2
}
test("conditional branch to an external non-entry label tears down only the taken path") {
val outsideInner = IRCodeChunk("test.outside.inner", null).also {
it.instructions.add(IRInstructions.simple(Opcode.RETURN))
}
val lines = generateAsmForSubs(
sub("test.start", frameSize = 16, instructions = listOf(
IRInstructions.branch(Opcode.BSTEQ, CodeReference.Label("test.outside.inner")),
IRInstructions.simple(Opcode.RETURN)
)),
sub("test.outside", frameSize = 0, instructions = listOf(IRInstructions.simple(Opcode.RETURN)),
extraChunks = listOf(outsideInner))
)
val jmpIdx = lines.indexOfFirst { it.startsWith("jmp") && "outside.inner" in it }
(jmpIdx > 0) shouldBe true
lines[jmpIdx - 1] shouldBe "unlk a5"
lines.any { it.startsWith("bne") } shouldBe true
}
test("signed comparison branch to an absolute address tears down only the taken path") {
val lines = generateAsm(16, listOf(
IRInstructions.branchImmediate(Opcode.BGTS, IRDataType.WORD, 1, 1, codeAddress(0x1234u)),
IRInstructions.simple(Opcode.RETURN)
))
lines.any { it.startsWith("cmpi.w") } shouldBe true
val branchIdx = lines.indexOfFirst { it.startsWith("ble") }
val jmpIdx = lines.indexOfFirst { it.startsWith("jmp") && "$1234" in it }
(branchIdx >= 0) shouldBe true
(jmpIdx > branchIdx) shouldBe true
lines[jmpIdx - 1] shouldBe "unlk a5"
lines.count { it == "unlk a5" } shouldBe 2
}
test("jump to a label in another subroutine pops the frame before jumping") {
// jumping out of the subroutine is a documented way to end it, so our frame must not be
// left behind for whatever runs next (and its eventual rts) to trip over
val lines = generateAsmForSubs(
sub("test.start", frameSize = 16, instructions = listOf(
IRInstructions.jump(CodeReference.Label("test.outside")),
IRInstructions.simple(Opcode.RETURN)
)),
sub("test.outside", frameSize = 0, instructions = listOf(IRInstructions.simple(Opcode.RETURN)))
)
val braIdx = lines.indexOfFirst { it.startsWith("bra") && "test.outside" in it }
(braIdx > 0) shouldBe true
lines[braIdx - 1] shouldBe "unlk a5"
}
test("jump to a label in a chunk of the same subroutine keeps the frame") {
val lines = generateAsmForSubs(
sub("test.start", frameSize = 16, instructions = listOf(
IRInstructions.jump(CodeReference.Label("test.start.second")),
IRInstructions.simple(Opcode.RETURN)
), extraChunks = listOf(
IRCodeChunk("test.start.second", null).also {
it.instructions.add(IRInstructions.storeImmediate(IRDataType.BYTE, 0, IRMemory.direct("test.var")))
}
))
)
lines.count { it == "unlk a5" } shouldBe 1 // only the return epilogue
lines[lines.indexOf("unlk a5") + 1] shouldBe "rts"
lines.any { it.startsWith("bra") && "test.start.second" in it } shouldBe true
}
test("jump to a label inside a loop body of the same subroutine keeps the frame") {
val loopBodyChunk = IRCodeChunk("test.start.loopbody", null)
loopBodyChunk.instructions.add(IRInstructions.jump(CodeReference.Label("test.start.loopbody")))
val lines = generateAsmForSubs(
sub("test.start", frameSize = 16, instructions = listOf(
IRInstructions.simple(Opcode.RETURN)
), extraChunks = listOf(IRLoopChunk("test.start.loop", 3, mutableListOf(loopBodyChunk))))
)
lines.count { it == "unlk a5" } shouldBe 1 // the jump to the loop body label must not tear the frame down
}
})