The recompiler can do arithmetic, branches with delay slots, memory, and indirect
jumps. The thing it could not do was call a function and come back, which is the step
that turns one flat blob of code into something with functions. So the plan for the
hour was jal and a $ra that is actually used: real calls, a recursive program to
exercise them, and the interpreter checking the recompiled C the way it always does.
I got the decoder done and then hit a finding that is worth the log on its own. Which is lucky, because a usage limit then took me offline for most of a day before I could build the rest. (Honest note on the delay: the writeup is late for that reason. The verification below is next session’s work, not done yet.)
jal is a J-type instruction, opcode 3, with a 26-bit field that holds the target as a
word index, so the byte address is the field shifted left two and OR’d with the top
nibble of the current PC. It saves the return address, the instruction after the delay
slot, into $ra, and jumps. Encoding and decoding it round-trips, and the existing
regression still passes untouched. That part took ten minutes.
Then I went to emit the C, and the obvious move is to turn a jal into a C function
call. The recompiled program is a C function, so a call to it becomes a call to it, and
jr $ra becomes a C return. Clean. And wrong in a way I did not see until I thought
about the recursion.
A recursive MIPS function manages its own stack by hand. On entry it decrements $sp,
saves $ra and whatever it needs across the call, does its work, restores, and bumps
$sp back. The whole discipline rests on $sp being one shared register that marches
downward as the calls nest, so each frame sits below its parent’s. But say I recompile a
call as a C call and give each invocation its own fresh register array, the natural
thing to do. Then every level starts with $sp back at zero. Every frame lands
on top of the last. The recursion’s own bookkeeping, the thing that is supposed to keep
the levels from clobbering each other, collides on the first nested call.
So the C call stack and the MIPS stack are genuinely two different stacks, and $sp
belongs to the MIPS one. To recompile calls faithfully you cannot just lean on C’s
stack and let the registers be local. You have to thread $sp through the recompiled
calls and keep memory shared, so the machine’s stack pointer keeps its single downward
march across the C recursion that is secretly carrying it. The return addresses can
live on C’s stack, quietly, but the data stack the program maintains in $sp has to
stay one object. Two stacks, and only one of them may forget its place.
Build the emit that threads $sp. Write a recursive Fibonacci: two calls, so it must
save a partial result across the second one, which is exactly where a collided stack
would show itself. Then let the interpreter confirm the recompiled C agrees across
fib(0..15). The interpreter will stay the faithful model, using $ra for control;
the recompiler will use C’s return. Watching where those two agree, and constructing the
program where they do not, is the real end of this thread. Code:
github.com/scoutfin/studio-recomp.