Call frames
Call frames are a cheap isolation mechanism available in the virtual machine. They define a subslice in the stack, preventing the vm from accessing values that are outside of the slice.
They have the following rules:
- Instructions cannot access values outside of their current call frame.
- When we return from the call frame the subslice must be empty.
If any these two conditions aren't maintained, the virtual machine will error.
Call frames fill two purposes. The subslice provides a well-defined variable
region. Stack-relative operations like copy 0
are always defined relative to
the top of their call frame. Where copy 0
would mean "copy from offset 0 of
the current stack frame".
They also provide a cheap security mechanism against miscompilations. This might be made optional in the future once Rune is more stable, but for now it's helpful to detect errors early and protect the user against bad instructions. But don't mistake it for perfect security. Like stack protection which is common in modern operating systems, the mechanism can be circumvented by malicious code.
To look close at the mechanism, let's trace the following program:
fn foo(a, b) {
a + b
}
let a = 3;
foo(1, 2) + a
$> cargo run -- run scripts/book/the_stack/call_and_add.rn --trace --dump-stack
fn main() (0xe7fc1d6083100dcd):
0005 = integer 3
0+0 = 3
0006 = integer 1
0+0 = 3
0+1 = 1
0007 = integer 2
0+0 = 3
0+1 = 1
0+2 = 2
0008 = call 0xbfd58656ec9a8ebe, 2 // fn `foo`
=> frame 1 (1):
1+0 = 1
1+1 = 2
fn foo(arg, arg) (0xbfd58656ec9a8ebe):
0000 = copy 0 // var `a`
1+0 = 1
1+1 = 2
1+2 = 1
0001 = copy 1 // var `b`
1+0 = 1
1+1 = 2
1+2 = 1
1+3 = 2
0002 = add
1+0 = 1
1+1 = 2
1+2 = 3
0003 = clean 2
1+0 = 3
0004 = return
<= frame 0 (0):
0+0 = 3
0+1 = 3
0009 = copy 0 // var `a`
0+0 = 3
0+1 = 3
0+2 = 3
0010 = add
0+0 = 3
0+1 = 6
0011 = clean 1
0+0 = 6
0012 = return
*empty*
== 6 (45.8613ms)
# full stack dump after halting
frame #0 (+0)
*empty*
We're not going to go through each instruction step-by-step like in the last section. Instead we will only examine the parts related to call frames.
We have a call 0xbfd58656ec9a8ebe, 2
instruction, which tells the virtual
machine to jump to the function corresponding to the type hash
0xbfd58656ec9a8ebe
, and isolate the top two values on the stack in the next
call frame.
We can see that the first argument a
is in the lowest position, and the
second argument b
is on the highest position. Let's examine the effects this
function call has on the stack.
0+0 = 3
0+1 = 1
0+2 = 2
0008 = call 0xbfd58656ec9a8ebe, 2 // fn `foo`
=> frame 1 (1):
1+0 = 1
1+1 = 2
Here we can see a new call frame frame 1
being allocated, and that it contains
two items: 1
and 2
.
We can also see that the items are offset from position 1
, which is the base
of the current call frame. This is shown as the addresses 1+0
and 1+1
. The
value 3
at 0+0
is no longer visible, because it is outside of the current
call frame.
Let's have a look at what happens when we return
:
1+0 = 1
1+1 = 2
1+2 = 3
0003 = clean 2
1+0 = 3
0004 = return
<= frame 0 (0):
0+0 = 3
0+1 = 3
We call the clean 2
instruction, which tells the vm to preserve the top of the
stack (1+2
), and clean two items below it, leaving us with 3
. We then
return
, which jumps us back to frame 0
, which now has 0+0
visible and
our return value at 0+1
.