O Lexer transformou texto em tokens, o parser validou a gramática e gerou bytecodes diretos. Mas ninguém ainda executou nada: media = (n1 + n2) / 2 continua sendo uma sequência de instruções inertes. É o trabalho do Interpreter (na verdade, a Virtual Machine) percorrer esses bytecodes e produzir efeitos — valores, variáveis, saída, chamadas de função.
Como executar um programa de forma rápida e previsível, sem reanalisar a sintaxe a cada execução?
Neste artigo vamos ver o formato de bytecode do Drax, como o parser gera instruções diretamente, o loop de execução da VM, como expressões e statements são executados e como valores e escopos funcionam em runtime.
O Drax não usa uma árvore sintática abstrata (AST) como etapa intermediária. O parser analisa os tokens e, em um único passe, gera bytecode — uma sequência linear de instruções compactas. A VM então executa esse bytecode sem conhecer a sintaxe original.
flowchart LR
Source[Código-fonte] --> Lexer[Lexer] --> Tokens[Tokens] --> Parser[Parser] --> BC[Bytecode] --> VM[VM] --> Result[Resultado]
Essa abordagem tem vantagens práticas:
Cada instrução do Drax é um d_instruction — opcode, operando e linha de origem:
typedef struct d_instruction {
uint8_t opcode;
int operand;
int line; /* linha de origem, para erros e stack traces */
} d_instruction;
O programa compilado é um d_instructions — o array de instruções e o constant pool:
typedef struct d_instructions {
d_instruction* values;
int instr_count;
drax_value* const_pool; /* constantes: números, strings, nomes */
int const_count;
} d_instructions;
O constant pool é o detalhe-chave: literais como números e strings não são embutidos em cada instrução — ficam uma vez no pool, e a instrução referencia pelo índice. Economiza memória e torna o bytecode uniforme.
A VM do Drax é stack-based: a maioria das instruções opera sobre uma pilha de operandos, vm->stack. Para avaliar 1 + 2, o parser gera:
PUSH_CONST 1 # coloca 1 na pilha
PUSH_CONST 2 # coloca 2 na pilha
ADD # tira 2 e 1, empurra 3
flowchart TB
subgraph Stack[Pilha de operandos]
S1["[2]"] --> S2["[1]"]
end
ADD[ADD] --> S1
S1 --> Result["[3]"]
O resultado fica no topo da pilha, pronto para a próxima instrução. A pilha é uma das raízes do GC: valores intermediários empilhados são alcançáveis, e o dgc_swap_stack(vm) da dgc_swap (artigo sobre GC) os protege da coleta.
O bytecode do Drax tem um opcode por operação, por exemplo:
| Opcode | O que faz |
|---|---|
OP_CONST |
Empurra o valor do constant pool operand |
OP_NIL / OP_TRUE / OP_FALSE |
Empurra o valor correspondente |
OP_LOAD_LOCAL / OP_STORE_LOCAL |
Lê/grava variável local pelo índice |
OP_LOAD_GLOBAL / OP_STORE_GLOBAL |
Lê/grava variável global |
OP_GET_FIELD / OP_SET_FIELD |
Lê/grava campo de um frame |
OP_INDEX / OP_SET_INDEX |
Lê/grava elemento de lista |
OP_ADD / OP_SUB / OP_MULT / OP_DIV / OP_MOD |
Operações aritméticas (topo da pilha) |
OP_NEG / OP_NOT |
Operações unárias |
OP_EQ / OP_NE / OP_LT / OP_LE / OP_GT / OP_GE |
Comparações |
OP_JUMP |
Pulo incondicional |
OP_JUMP_IF_FALSE / OP_JUMP_IF_TRUE |
Pulo condicional (desempilha o valor) |
OP_MAKE_FRAME / OP_MAKE_LIST |
Constrói frame/lista com operand elementos |
OP_CALL |
Chama a função no topo da pilha |
OP_RET |
Retorna da função |
OP_POP |
Descarta o topo da pilha |
OP_HALT |
Encerra o programa |
A VM executa o bytecode para media = (n1 + n2) / 2 assim:
OP_LOAD_GLOBAL n1 # empurra valor de n1
OP_LOAD_GLOBAL n2 # empurra valor de n2
OP_ADD # n1 + n2
OP_CONST 2 # empurra 2
OP_DIV # (n1 + n2) / 2
OP_STORE_GLOBAL media # pop -> media
Note a ausência de parênteses no bytecode: o parser os "resolveu" na ordem das instruções. 2 * 3 + 1 e 2 * (3 + 1) geram sequências diferentes — a precedência vira ordem de instruções.
O parser do Drax faz um passe único sobre os tokens: em vez de montar uma árvore, ele emite instruções diretamente. Cada regra da gramática gera bytecode na mesma ordem que foi analisada.
Para expressões binárias, o padrão é: emitir o lado esquerdo, emitir o lado direito, emitir o operador:
static void dparse_binary(d_vm* vm, d_parser* parser) {
dparse_precedence(vm, parser, PREC_ONE_HIGHER); /* lado esquerdo */
/* consome o operador */
dparse_precedence(vm, parser, precedence_of(op)); /* lado direito */
switch (op.type) {
case D_TOKEN_PLUS: emit(vm, OP_ADD, op.line); break;
case D_TOKEN_STAR: emit(vm, OP_MULT, op.line); break;
/* ... */
}
}
As duas partes (emitir os operandos primeiro e o operador depois) são exatamente o contrato stack-based: operandos vão para a pilha na ordem, e a operação os consome.
O ganho de performance da VM vem daqui: em vez de resolver nomes por hash a cada execução, o parser resolve a variável em tempo de parse e emite o índice.
static void dparse_ident(d_vm* vm, d_parser* parser) {
int index = resolve_local(parser,/parser->previous);
if (index >= 0) {
emit(vm, OP_LOAD_LOCAL, index);
} else {
emit(vm, OP_LOAD_GLOBAL, add_const(vm, parser->previous));
}
}
Locais ficam em slots fixos do frame; globais no constant pool. Nenhum hash na VM.
if e while emitem pulos com backpatching: o parser emite OP_JUMP_IF_FALSE com um endereço provisório e volta para preencher o endereço real quando o bloco é compilado.
static void dparse_if(d_vm* vm, d_parser* parser) {
consume(parser, D_TOKEN_IF, "esperava 'if'");
dparse_expression(vm, parser); /* condição */
int jump = emit(vm, OP_JUMP_IF_FALSE, parser->previous.line);
dparse_block(vm, parser); /* bloco then */
if (match(parser, D_TOKEN_ELSE)) {
int skip = emit(vm, OP_JUMP, parser->previous.line);
patch(vm, jump, here(vm)); /* preenche o desvio do if */
dparse_block(vm, parser); /* bloco else */
patch(vm, skip, here(vm)); /* preenche o pulo do else */
} else {
patch(vm, jump, here(vm));
}
}
Com o bytecode pronto, a VM só precisa de um loop fetch → decode → execute. O Instruction Pointer (vm->ip) aponta para a instrução atual; cada passo lê o opcode, executa o caso e avança. É a função dvm_run em src/dvm.c:
static void dvm_run(d_vm* vm) {
for (;;) {
d_instruction* instr = vm->ip;
switch (instr->opcode) {
case OP_CONST: {
push(vm, instr->operand, vm->instructions->const_pool[instr->operand]);
break;
}
case OP_ADD: {
drax_value b = pop(vm);
drax_value a = pop(vm);
push(vm, 0, d_num_val(NUM_VAL(a) + NUM_VAL(b)));
break;
}
case OP_JUMP: {
vm->ip = vm->instructions->values + instr->operand;
continue; /* avança sem incrementar */
}
/* ... demais opcodes ... */
case OP_HALT:
return;
}
vm->ip++;
}
}
Cada case é pequeno e previsível — sem busca de nomes, sem alocação por nó. É isso que torna a VM mais rápida que um interpretador de árvore.
A execução de 1 + 2 na pilha:
flowchart LR
P1[PUSH 1] --> S1["pilha: [1]"]
S1 --> P2[PUSH 2] --> S2["pilha: [1, 2]"]
S2 --> A[ADD] --> S3["pilha: [3]"]
Na execução, tudo são valores. O Drax usa NaN-boxing: um drax_value é um uint64_t que carrega números diretamente e ponteiros para objetos no heap (veja o artigo sobre Garbage Collector para os detalhes das macros IS_STRUCT, IS_NUMBER, CAST_STRUCT, DS_VAL).
flowchart LR
Eval[Executar instrução] --> Value[drax_value] --> Is{NaN-boxing}
Is -- bits de número --> Num[double]
Is -- ponteiro --> Struct[d_struct<br/>string, lista, frame, função]
Quando a VM executa OP_CONST 7, produz o valor 7 (número). Quando executa OP_CONST "drax", referencia um d_struct do tipo DS_STRING no heap. É esse mesmo heap — a lista ligada vm->d_ls — que o GC varre.
Variáveis vivem em ambientes. No Drax, cada ambiente é um d_env que guarda as tabelas que vimos como raízes do GC:
typedef struct d_env {
struct d_env* parent; /* escopo externo (para closures) */
drax_table local; /* variáveis locais */
drax_table global; /* variáveis globais */
drax_table native; /* funções nativas registradas */
drax_table modules; /* módulos: Core, List, String, ... */
} d_env;
O d_vm guarda o ambiente atual em vm->envs. Locais são acessados por índice (via OP_LOAD_LOCAL), sem hash. Globais vão para a tabela global.
Escopo é a regra que diz onde um nome é visível. O Drax usa escopo léxico: a visibilidade é determinada pela estrutura do código, não pela ordem de execução.
fun f() {
x = 10 ## x é local de f
return x + 1
}
x = 5 ## x global
Variáveis locais são criadas na tabela local do ambiente atual; globais vão para global. Ao entrar em uma função, a VM cria um novo d_env; ao sair, descarta. A busca por um nome sobe a cadeia parent:
static drax_value dvm_load(d_vm* vm, const char* name) {
d_env* env = vm->envs;
while (env != NULL) {
drax_value value;
if (table_get(&env->local, name, &value)) return value;
if (table_get(&env->global, name, &value)) return value;
env = env->parent;
}
dvm_error(vm, "variável não definida: %s", name);
return 0;
}
Uma função é um valor como qualquer outro: quando o parser encontra fun, ele compila o corpo para bytecode e cria um drax_function que guarda o nome, os parâmetros, o bytecode e o ambiente em que foi definida:
typedef struct drax_function {
char* name;
int param_count;
d_instructions* instructions; /* bytecode do corpo */
d_env* closure; /* ambiente da definição — é isso que cria closures */
} drax_function;
Chamar uma função envolve os passos clássicos:
d_env, filho do closure da função.local.vm->ip para o início do bytecode da função.OP_RET), restaurar o ip anterior e empilhar o resultado.Por guardar o closure, a função "lembra" do ambiente onde foi criada — mesmo depois que esse ambiente saiu de cena. É isso que permite:
fun contador() {
n = 0
return fun() {
n = n + 1
return n
}
}
c = contador()
print(c()) ## 1
print(c()) ## 2
Cada chamada de contador() cria um d_env novo; a closure interna o mantém vivo enquanto c existir. Como o ambiente é referenciado pelo valor da função, ele é alcançável a partir das raízes do GC — e não é coletado.
return precisa interromper o fluxo em qualquer profundidade. O Drax usa um flag na VM: return define o valor e sinaliza vm->has_return = 1; cada frame checa o flag e para de executar. Ao retornar da função, o flag é limpo e o valor é devolvido.
No Drax, o literal de tabela { a: 1, b: 2 } é um frame — um DS_FRAME com arrays paralelos de chaves e valores:
typedef struct drax_frame {
drax_string** keys;
drax_value* values;
int length;
} drax_frame;
OP_MAKE_FRAME cria o frame e desempilha os valores:
Ler ponto.x desempilha o alvo e busca a chave:
static drax_value dvm_get_field(d_vm* vm) {
drax_value target = pop(vm);
drax_frame* f = CAST_FRAME(target);
drax_string* field = AS_STRING(vm->instructions->const_pool[vm->ip->operand]);
for (int i = 0; i < f->length; i++) {
if (string_equals(f->keys[i], field)) {
return f->values[i];
}
}
dvm_error(vm, "campo não encontrado: %s", field);
return 0;
}
O mesmo raciocínio vale para listas (DS_LIST) e para o acesso por índice (lista[0]): desempilhar alvo, desempilhar índice, buscar. Todo valor composto é um d_struct no heap — e portanto vive na lista vm->d_ls que o Garbage Collector varre.
Erros de runtime são diferentes de erros de sintaxe: a gramática está certa, mas a execução falhou — variável inexistente, divisão por zero, campo ausente. O Drax cria um valor de erro (DS_ERROR) com a mensagem e a posição da instrução:
static void dvm_error(d_vm* vm, const char* fmt, ...) {
/* cria um drax_error com a posição de vm->ip->line */
}
Como cada instrução guarda line, a mensagem aponta para a linha exata do código-fonte:
linha 4: variável não definida: nota
A VM para a execução e reporta; o erro pode ser capturado por mecanismos de tratamento que a linguagem oferece.
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.
Custo da compilação: o parser gera bytecode em um passe linear. Esse custo é pago uma vez; o bytecode pode até ser serializado e cacheado.
Trade-offs de design:
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.
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.
Environments vs slots da VM: locais por índice, globais por pool. É o que separa a VM de um interpretador de árvore: a semântica é a mesma, mas o "resolver nome por hash" só acontece para globais e em runtime.
O Interpreter transforma a pergunta "como executar este programa?" em "como empilhar e consumir instruções?":
flowchart LR
Source[Fonte] --> Parser[Parser<br/>gera bytecode] --> BC[Bytecode] --> VM[dvm_run] --> Value[drax_value]
VM --> Effect[Efeitos: variáveis, escopo, chamadas]
Value --> Heap[Objetos no heap — vm->d_ls]
No Drax, isso é tangível: o parser emite 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, e erros de runtime apontam para a linha exata graças à posição guardada em cada instrução.
Executar bytecode define o que a linguagem faz. 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. É o assunto dos próximos artigos.