foto Jean Tux jeaanca
Jean Tux
Compiladores Aula 5 de 5
Virtual Machine — Frames, Call Stack e GC
Ver sequência completa

Virtual Machine — Frames, Call Stack e GC

A série chegou ao fim. O Lexer transforma texto em tokens, o parser gera bytecode diretamente, e a VM executa esse bytecode. Mas a VM não funciona sozinha — ela precisa de frames no call stack para chamar funções, e o Garbage Collector para cuidar da memória. É disso que trata este artigo.

A pergunta central que a VM responde é:

Como a VM gerencia chamadas de função, escopos e memória durante a execução do bytecode?

Neste artigo vamos ver como frames e call stack funcionam, como funções são chamadas e retornadas, e como o Garbage Collector se integra ao runtime da VM.


1. O pipeline completo

O Drax não usa uma árvore sintática abstrata (AST) como etapa intermediária. O pipeline é direto:

flowchart LR
    Source[Código-fonte] --> Lexer[Lexer] --> Tokens[Tokens] --> Parser[Parser] --> BC[Bytecode] --> VM[VM — dvm_run] --> Result[Resultado]

O parser analisa os tokens e gera bytecode em um único passe linear. A VM então executa esse bytecode no loop fetch-decode-execute que vimos no artigo anterior. Não há árvore intermediária — o que simplifica o design e reduz o uso de memória.

2. Frames e call stack

Chamar uma função exige guardar o estado atual e restaurá-lo depois. Na VM, isso é o frame: o dvm_run cria um d_call_frame com o bytecode da função, o ip de retorno e o ambiente da chamada.

typedef struct d_call_frame {
  d_instructions* instructions;
  d_instruction* ip;      /* onde continuar ao retornar */
  d_env* env;             /* ambiente da chamada */
  int stack_start;        /* onde a pilha estava antes dos argumentos */
  int local_count;
} d_call_frame;

Os frames ficam em vm->call_stack->frames — a estrutura que o GC varre como raiz (dgc_swap_call_stack), porque o bytecode de cada frame pode embutir constantes que precisam continuar vivas.

A chamada f(a) vira algo assim:

PUSH a              # argumento
PUSH f              # função
CALL 1              # f, com 1 argumento

O OP_CALL pega a função e os argumentos da pilha, empurra um novo frame e troca vm->ip para o início do bytecode da função:

case OP_CALL: {
  d_call_frame* frame = new_frame(vm);
  frame->ip = vm->ip + 1;                 /* onde voltar */
  frame->instructions = vm->instructions;
  vm->call_stack->frames[...] = frame;
  vm->instructions = fn->instructions;    /* muda para o bytecode da função */
  vm->ip = vm->instructions->values;
  break;
}

O OP_RET faz o caminho inverso: pega o valor no topo da pilha, descarta o frame e restaura o ip de retorno.

3. Onde o Garbage Collector se encaixa

Na VM do Drax, o GC não é um anexo — ele é parte do design. As raízes que vimos no artigo anterior são todas estruturas da VM:

Raiz Estrutura da VM
envs->local Variáveis locais dos d_env
envs->global Variáveis globais
envs->native / envs->modules Funções nativas e módulos
vm->stack Pilha de operandos
vm->ip Bytecode atual — constantes embutidas
vm->call_stack->frames Frames de chamada ativos

O fluxo completo do runtime:

flowchart TD
    Source[Fonte] --> Lexer[Lexer] --> Parser[Parser] --> BC[Bytecode] --> VM[VM — dvm_run]
    VM --> Stack[stack — operandos]
    VM --> Frames[call_stack — frames]
    VM --> Envs[envs — variáveis]
    VM --> Heap[d_ls — objetos]
    Envs --> GC[GC — dgc_swap]
    Stack --> GC
    Frames --> GC
    Heap --> GC

Core.gc_swap() (visto no artigo sobre GC) roda dentro desse mesmo runtime: para a VM, varre as raízes, marca os objetos alcançáveis a partir do bytecode e da pilha e libera o resto.

4. Complexidade e trade-offs

Velocidade: o loop da VM é O(instruções) com constante pequena. O switch sobre opcodes é resolvido direto pelo processador (predição de branch), tornando cada instrução quase grátis.

Trade-offs de design:

Stack vs register-based: a VM do Drax é stack-based — bytecode menor e mais simples (operandos implícitos na pilha), à custa de mais instruções PUSH/POP. VMs baseadas em registradores (como Lua e Dalvik) têm menos instruções por operação, mas o bytecode é mais complexo e o compilador faz análise de registradores. Para o Drax, a pilha é a escolha pragmática.

Sem AST (one-pass): o Drax pula a etapa de AST e vai direto do token para bytecode. É mais rápido de compilar e usa menos memória, mas impossibilita otimizações que precisam olhar o programa inteiro (como análise de escopo global ou dead code). Para uma linguagem como o Drax, a simplicidade do one-pass vale a pena.

Frames no call stack: a pilha de frames é simples e direta — cada chamada empurra um frame, cada retorno descarta. VMs mais sofisticadas usam "segmentação" de pilha ou registers spill para reduzir o custo de chamada, mas para o Drax a abordagem simples é suficiente.

5. Conclusão

A Virtual Machine fecha o ciclo da série:

flowchart LR
    Source[Fonte] --> Lexer[Lexer] --> Parser[Parser] --> BC[Bytecode] --> VM[VM] --> Result[Resultado]

No Drax, cada peça é tangível: o parser gera d_instruction com opcode/operando/linha, o constant pool evita duplicar literais, dvm_run com o loop fetch-decode-execute sobre vm->stack e vm->ip, d_call_frame no call_stack para chamadas e retornos, e o GC usando todas esses estruturas como raízes — do bytecode à pilha, fechando o design que começou no artigo do Garbage Collector.

Da fonte ao resultado, a linguagem é um pipeline de transformações: texto → tokens → bytecode → execução. E o Garbage Collector cuida da memória o tempo todo, garantindo que o que deixou de ser alcançável seja devolvido.