foto Jean Tux jeaanca
Jean Tux
Compiladores Aula 3 de 5
Interpreter — Bytecode e a VM
Ver sequência completa

Interpreter — Bytecode e a VM

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.


1. O pipeline sem AST

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:

2. O formato de bytecode

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.

3. VM baseada em pilha

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.

4. O conjunto de instruções

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.

5. Como o parser gera bytecode

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.

Variáveis locais por índice

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.

Controle de fluxo

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));
  }
}

6. O loop de execução

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]"]

7. Valores em runtime

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.

8. Ambientes e escopos

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

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;
}

9. Funções e chamadas

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:

  1. Desempilhar os argumentos e o alvo.
  2. Criar um novo d_env, filho do closure da função.
  3. Ligar cada parâmetro ao argumento correspondente, na tabela local.
  4. Trocar vm->ip para o início do bytecode da função.
  5. Ao retornar (OP_RET), restaurar o ip anterior e empilhar o resultado.

Closures

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

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.

10. Frames: o valor composto do Drax

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.

11. Erros em runtime

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.

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

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.

13. Conclusão

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.