Adding two numbers in an interpreter requires:
All is actually 2 instructions on a x86_64 CPU
; rax = rbx + rcx
mov rax, rbx
add rax, rcx
virtualCodeAddress = mmap(
NULL,
codeBytes,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_ANONYMOUS | MAP_PRIVATE,
0,
0);
auto const buffer = VirtualAlloc(nullptr, page_size, MEM_COMMIT, PAGE_READWRITE);
VirtualProtect(buffer, code.size(), PAGE_EXECUTE_READ, &dummy);
typedef unsigned (*asmFunc)(void);
unsigned char * memory = (unsigned char *) (virtualCodeAddress);
// mov %rdi, %rax
memory[i++] = 0x48; // REX.W prefix
memory[i++] = 0x8b; // MOV opcode, register/register
memory[i++] = 0xc7; // MOD/RM byte for %rdi -> %rax
// ret
memory[i++] = 0xc3; // RET opcode
((asmFunc) (virtualCodeAddress))();
Most VMs have tiers that allow to trade off between latency and throughput.
VMs have more than two tiers, but they are levels between the two extremes
Every opcode can be translated to machine code snippet, possibly with changeable registers for inputs and outputs.
The program can be translated to machine code by concatenating all the snippets for the opcodes, possibly with other snippets that make sure that input and output registers are matching.
function add_answer(x) {
return x + 42;
}
func add_answer
const 1, 42
add 2, -1, 1
ret 2
add_answer(int):
push rbp # save the current call frame
mov rbp, rsp # create a new call frame
mov dword ptr [rbp - 4], edi # spill first argument to stack
mov eax, dword ptr [rbp - 4]
add eax, 42
pop rbp # clean up our frame
ret
ASM prolog() {
return ASM(["push rbp", "mov rbp, rsp"]);
}
ASM add(Reg a0, Reg a1, Reg a2) {
return ASM(["mov a0, a1", "add a0, a2"]).set(a0, a1, a2);
}
add_answer(int):
lea eax, [rdi + 42]
ret
.LCPI0_0:
.long 0x42280000
add_answer(float):
push rbp
mov rbp, rsp
movaps xmm1, xmm0
movss xmm0, dword ptr [rip + .LCPI0_0] # xmm0 = mem[0],zero,zero,zero
movss dword ptr [rbp - 4], xmm1
addss xmm0, dword ptr [rbp - 4]
pop rbp
ret
Foreign Function Interface
FFI allows calls from the VM to functions in existing libraries, written in other languages, most importantly C and OS functions.
Translate Spasm calling convention to x86_64 and back
For simplicity, lets assume that spasm works with integer numbers instead of floating point.
RDI
, RSI
, RDX
, RCX
, R8
,
R9
RAX
RDX
to point to the top of the stackPush new registers onto the stack as they are created
Make all the opcode JIT code work with RCX
, R8
and R9
Translate Spasm negative registers to positive offsets from RDX
rbp
, r15
stored on the stackTranslate Spasm positive registers to negative offsets from RDX
The native stack grows towards lower addresses!
For each opcode
RCX
, R8
, R9
RCX
to the spasm register on the stackrax
ret
How to handle vararg functions?
func add_answer
const 1, 42
add 2, -1, 1
ret 2
push rbp # save the current call frame
mov rbp, rsp # create a new call frame
push r15
mov r15, rsp # keep the stack pointer (or do lots of pop in the end)
push r9
push r8
push rcx
push rdx
push rsi
push rdi
push 1
mov rdi, rsp
mov [rdi - 8], 42 # 8 -> sizeof(vm_value) # const 1, 42
mov r8, [rdi + 8] # load operands
mov r9, [rdi - 8]
mov rcx, r8 # add
add rcx, r9
mov [rdi - 16], rcx # store result
mov eax, [rdi - 16]
mov rsp, r15
pop r15
pop rbp
ret