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.
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.
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.
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.
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.
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.