15

Lab - Geração de Código I

Introdução15.1

No laboratório anterior de implementação, finalizamos o analisador sintático estrutural do Slang. Até esse ponto, o compilador já consegue executar duas etapas importantes:

  • transformar o código-fonte em tokens com o modo lex;
  • transformar o código-fonte em uma AST completa com o modo parse.

Isso significa que o front-end do compilador já está funcionando. O programa abaixo, por exemplo:

fn main(args: void) -> void {
    value: int = 5
    print(value)
}

já pode ser lido, tokenizado, analisado sintaticamente e impresso como árvore. Porém, ele ainda não pode ser executado, porque falta uma etapa essencial: transformar a AST em instruções que alguma máquina consiga executar.

Neste laboratório, vamos iniciar a fase de geração de código. O objetivo será percorrer a AST produzida pelo parser e gerar assembly textual para uma máquina virtual de pilha, a SlangVM.

O fluxo que estamos construindo é:

CodegenPipelinesourceSlang(código-fonte)lexerLexer(tokens)source->lexerparserParser(AST)lexer->parsercodegenCodegenassembly da VMparser->codegenvmSlangVM(execução)codegen->vm

Na prática, queremos chegar a este fluxo de comandos:

./build/compiler code programa.slang > programa.slasm
./build/slang-vm programa.slasm

Neste primeiro laboratório de geração de código, vamos preparar a infraestrutura e gerar código para uma parte controlada da linguagem:

  • literais;
  • variáveis locais;
  • expressões aritméticas;
  • expressões de comparação;
  • chamadas de função;
  • print;
  • funções simples com return.

Vamos deixar if, loop, break, continue e curto-circuito lógico para a segunda parte. Esses recursos exigem saltos e labels; por isso, fazem mais sentido depois que a base da VM e do gerador já estiver funcionando.

O que muda no compilador15.2

No fim do laboratório 12, o executável aceitava dois modos:

./build/compiler lex arquivo.slang
./build/compiler parse arquivo.slang

Agora vamos adicionar um terceiro modo:

./build/compiler code arquivo.slang

Esse novo modo fará quatro coisas:

  1. inicializar o parser;
  2. construir a AST do programa;
  3. percorrer a AST com o gerador de código;
  4. escrever assembly da SlangVM em stdout.

Também vamos adicionar um segundo executável ao projeto:

./build/slang-vm arquivo.slasm

O compilador gera o arquivo .slasm; a VM executa esse arquivo. Essa separação é didática e prática: conseguimos inspecionar o assembly gerado antes de executar o programa.

A ideia da SlangVM15.3

A SlangVM é uma máquina de pilha. Isso significa que suas instruções manipulam valores no topo de uma pilha, em vez de usar registradores explícitos.

Por exemplo, a expressão:

1 + 2 * 3

será traduzida para:

push 1
push 2
push 3
mul
add

A execução ocorre assim:

  1. push 1 empilha 1;
  2. push 2 empilha 2;
  3. push 3 empilha 3;
  4. mul remove 2 e 3, calcula 6 e empilha o resultado;
  5. add remove 1 e 6, calcula 7 e empilha o resultado.

Essa estratégia simplifica muito a geração de código. Em vez de escolher registradores para cada valor temporário, o compilador apenas emite instruções que empilham operandos e consomem esses operandos na ordem correta.

Instruções da SlangVM que usaremos15.3.1

O codegin vai emitir instruções de três categorias:

Manipulação da pilha:

Instrução Efeito
push N Empilha o valor N
pop Remove o valor do topo
dup Duplica o valor do topo

Variáveis locais:

Instrução Efeito
get_local N Empilha o valor do slot local N
set_local N Desempilha e armazena no slot N

Aritmética e comparação:

Instrução Operação
add, sub, mul, div, mod Desempilha dois, empilha resultado
eq, ne, lt, le, gt, ge Desempilha dois, empilha true/false
neg, not Desempilha um, empilha resultado

Controle e funções:

Instrução Efeito
call N fn_label Chama função com N argumentos
ret Retorna da função
halt Encerra o programa
print Imprime o valor do topo
jump L, jump_if_false L Saltos (usados na Parte II)

Não é preciso decorar essa tabela agora. Durante a implementação, cada instrução será apresentada no contexto em que aparece.

Preparando o alvo de execução15.4

Antes de gerar código, precisamos ter uma máquina capaz de executar esse código. Por isso, o primeiro conjunto de mudanças adiciona a SlangVM ao projeto e define a interface entre o compilador e o gerador.

Adicionando a VM ao projeto15.4.1

Crie as pastas:

include/vm/
src/vm/

Dentro delas, adicione os arquivos da VM fornecidos com o material da aula:

include/vm/value.h
include/vm/chunk.h
include/vm/vm.h
include/vm/assembler.h
include/vm/debug.h
src/vm/chunk.c
src/vm/vm.c
src/vm/assembler.c
src/vm/debug.c
src/vm/main.c

O papel desses arquivos é:

  • value.h: define os tipos de valores que a VM manipula;
  • chunk.h e chunk.c: armazenam bytecode e constantes;
  • vm.h e vm.c: executam as instruções;
  • assembler.h e assembler.c: transformam assembly textual em bytecode;
  • debug.h e debug.c: ajudam a imprimir bytecode durante depuração;
  • src/vm/main.c: cria o executável slang-vm.

Neste momento, o compilador ainda não gera código para a VM. Estamos apenas preparando o alvo de execução.

Correções necessárias nos arquivos da VM

Os arquivos fornecidos contêm dois bugs que precisam ser corrigidos para que programas com recursão, if, loop e labels funcionem corretamente:

1. src/vm/vm.creturn_slot em OP_RET

O OP_RET original lê o return_slot do quadro chamador (caller->return_slot), mas deveria ler do quadro sendo destruído (cur->return_slot). Sem essa correção, chamadas de função aninhadas (como recursão) retornam null em vez do valor correto.

Substitua o trecho:

case OP_RET: {
    Value result = vm->stack_top == 0 ? value_null() : vm_pop(vm);
    vm->frame_count--;
    if (vm->frame_count > 0) {
        CallFrame *caller = &vm->frames[vm->frame_count - 1];
        if (caller->return_slot >= 0) {
            vm->stack_top = caller->return_slot;
            vm_push(vm, result);
        }
    }
    break;
}

por:

case OP_RET: {
    Value result = vm->stack_top == 0 ? value_null() : vm_pop(vm);
    int return_slot = cur->return_slot;
    vm->frame_count--;
    if (vm->frame_count > 0 && return_slot >= 0) {
        vm->stack_top = return_slot;
        vm_push(vm, result);
    }
    break;
}

2. src/vm/assembler.c — sistema de labels separado das constantes do chunk

O find_or_create_label original armazena labels como constantes string no chunk, mas depois as busca iterando sobre constants[i].as.string. Quando uma constante numérica aparece antes de uma label (ex.: push 5 antes de jump_if_false L0), o acesso a as.string de um número causa segfault.

A correção envolve três mudanças:

a) Em include/vm/assembler.h, substitua o campo int *label_addresses por um vetor de structs Label:

typedef struct {
    char *name;
    int name_len;
    int address;
} Label;

typedef struct {
    Chunk chunk;
    Label *labels;
    int label_count;
    int label_capacity;
    int *fixup_ips;
    int *fixup_labels;
    int fixup_count;
    int fixup_capacity;
} Assembler;

b) Em src/vm/assembler.c, reescreva assembler_init e find_or_create_label:

static int find_label(Assembler *assembler, const char *name, int name_len) {
    for (int i = 0; i < assembler->label_count; i++) {
        if (assembler->labels[i].name_len == name_len &&
            memcmp(assembler->labels[i].name, name, name_len) == 0) {
            return i;
        }
    }
    return -1;
}

static int create_label(Assembler *assembler, const char *name, int name_len) {
    if (assembler->label_capacity <= assembler->label_count) {
        int old = assembler->label_capacity;
        assembler->label_capacity = old < 8 ? 8 : old * 2;
        assembler->labels = (Label *)realloc(assembler->labels,
                                              sizeof(Label) * assembler->label_capacity);
    }
    int idx = assembler->label_count++;
    assembler->labels[idx].name = (char *)malloc(name_len + 1);
    memcpy(assembler->labels[idx].name, name, name_len);
    assembler->labels[idx].name[name_len] = '\0';
    assembler->labels[idx].name_len = name_len;
    assembler->labels[idx].address = -1;
    return idx;
}

void assembler_init(Assembler *assembler) {
    chunk_init(&assembler->chunk);
    assembler->labels = NULL;
    assembler->label_count = 0;
    assembler->label_capacity = 0;
    assembler->fixup_ips = NULL;
    assembler->fixup_labels = NULL;
    assembler->fixup_count = 0;
    assembler->fixup_capacity = 0;
}

static int find_or_create_label(Assembler *assembler, const char *name, int name_len) {
    int idx = find_label(assembler, name, name_len);
    if (idx >= 0) return idx;
    return create_label(assembler, name, name_len);
}

c) Em src/vm/assembler.c, em todas as ocorrências de assembler->label_addresses[...] = ..., substitua por assembler->labels[...].address = .... Em resolve_fixups, use assembler->labels[label_idx].address. Em assembler_free, libere cada labels[i].name antes de liberar o vetor.

Essas correções são necessárias para que a VM funcione com todos os recursos que implementaremos no próximo laboratório.

Criando a interface do gerador15.4.2

Crie o arquivo include/compiler/codegen.h:

// include/compiler/codegen.h

#ifndef COMPILER_CODEGEN_H_
#define COMPILER_CODEGEN_H_

#include <stdbool.h>
#include <stdio.h>
#include "compiler/ast.h"

bool codegen_generate(FILE *stream, const AstNode *program);

#endif

A função codegen_generate recebe dois argumentos:

  • stream, que indica onde o assembly será escrito;
  • program, que é a raiz da AST gerada pelo parser.

Usar um FILE * deixa o gerador flexível. Nos testes e no modo code, vamos passar stdout; no futuro, poderíamos passar um arquivo aberto ou até um buffer de memória.

Estado interno do gerador15.5

Com a VM disponível, precisamos representar o estado usado durante a geração. Esse estado concentra a saída do assembly, a tabela de variáveis locais, os contadores de labels e as informações que serão usadas mais tarde em laços.

Criando a estrutura Codegen15.5.1

Crie src/compiler/codegen.c e comece com a estrutura abaixo:

// src/compiler/codegen.c

#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "compiler/codegen.h"

#define MAX_SYMBOLS 256
#define MAX_LOOPS 64

typedef struct {
    const char *start;
    int length;
    int slot;
} Symbol;

typedef struct {
    int start_label;
    int end_label;
} LoopLabels;

typedef struct {
    FILE *stream;
    Symbol symbols[MAX_SYMBOLS];
    int symbol_count;
    int next_slot;
    LoopLabels loops[MAX_LOOPS];
    int loop_count;
    int next_label;
    bool had_error;
} Codegen;

static void gen_declaration(Codegen *codegen, const AstNode *node);
static void gen_statement(Codegen *codegen, const AstNode *node);
static void gen_expression(Codegen *codegen, const AstNode *node);

A estrutura Codegen guarda tudo que o gerador precisa enquanto percorre a AST:

  • stream é o destino do assembly;
  • symbols associa nomes de variáveis a slots locais;
  • next_slot indica qual será o próximo slot livre;
  • loops será usado na segunda parte para break e continue;
  • next_label será usado para criar labels únicas;
  • had_error interrompe a geração quando encontramos algo inválido.

Mesmo que LoopLabels só seja usado na segunda parte, já deixamos a estrutura preparada. Assim, a forma geral do gerador não muda quando adicionarmos controle de fluxo.

Trabalhando com tokens e símbolos15.5.2

O gerador precisa comparar tokens com nomes especiais, como main e print. Como os tokens apontam para fatias do código-fonte original, não podemos usar strcmp diretamente.

Adicione:

static bool token_equals(Token token, const char *text) {
    int length = (int)strlen(text);
    return token.length == length && memcmp(token.start, text, length) == 0;
}

static void print_token(FILE *stream, Token token) {
    fprintf(stream, "%.*s", token.length, token.start);
}

static int new_label(Codegen *codegen) {
    return codegen->next_label++;
}

Agora precisamos de uma tabela simples para variáveis locais. Cada variável será associada a um slot numérico da VM.

Adicione:

static int find_symbol(Codegen *codegen, Token name) {
    for (int i = codegen->symbol_count - 1; i >= 0; i--) {
        Symbol symbol = codegen->symbols[i];
        if (symbol.length == name.length && memcmp(symbol.start, name.start, name.length) == 0) {
            return symbol.slot;
        }
    }

    fprintf(stderr, "Erro de geração: variável '%.*s' não declarada.\n", name.length, name.start);
    codegen->had_error = true;
    return 0;
}

static int declare_symbol(Codegen *codegen, Token name) {
    if (codegen->symbol_count == MAX_SYMBOLS) {
        fprintf(stderr, "Erro de geração: muitas variáveis locais.\n");
        codegen->had_error = true;
        return 0;
    }

    int slot = codegen->next_slot++;
    codegen->symbols[codegen->symbol_count++] = (Symbol){name.start, name.length, slot};
    return slot;
}

Essa tabela ainda não é uma análise semântica completa. Ela apenas responde: “qual slot local representa este nome?”.

Emitindo literais e nomes de função15.5.3

Strings precisam ser emitidas entre aspas no assembly. Também precisamos preservar escapes básicos para não quebrar o formato textual.

Adicione:

static void emit_string_literal(FILE *stream, Token token) {
    fputc('"', stream);
    for (int i = 1; i < token.length - 1; i++) {
        char c = token.start[i];
        if (c == '"' || c == '\\') {
            fputc('\\', stream);
        }
        fputc(c, stream);
    }
    fputc('"', stream);
}

Também vamos padronizar labels de função. A função Slang main vira fn_main; a função add vira fn_add.

static void emit_function_label(FILE *stream, Token name) {
    fputs("fn_", stream);
    print_token(stream, name);
}

Esse prefixo evita colisão com labels internas como L0, L1 e __entry.

Gerando os primeiros elementos da linguagem15.6

Agora que o gerador já tem estado interno e funções auxiliares, podemos começar a percorrer a AST. Nesta parte, vamos gerar funções, variáveis, chamadas e expressões simples.

Encontrando a função main15.6.1

O arquivo gerado precisa começar chamando uma função inicial. Em programas normais, essa função será main.

Adicione:

static const AstNode *find_main(const AstNode *program) {
    for (int i = 0; i < program->children.count; i++) {
        const AstNode *child = program->children.items[i];
        if (child->kind == AST_FUNCTION && token_equals(child->token, "main")) {
            return child;
        }
    }
    return NULL;
}

static bool program_has_top_level_code(const AstNode *program) {
    for (int i = 0; i < program->children.count; i++) {
        if (program->children.items[i]->kind != AST_FUNCTION) {
            return true;
        }
    }
    return false;
}

Se existir main, o assembly começa chamando fn_main. Se não existir, criaremos uma entrada sintética chamada __entry para executar declarações de topo.

Gerando blocos, funções e variáveis15.6.2

Um bloco é uma sequência de declarações. Portanto, a geração de bloco apenas percorre seus filhos:

static void gen_block(Codegen *codegen, const AstNode *block) {
    for (int i = 0; i < block->children.count; i++) {
        gen_declaration(codegen, block->children.items[i]);
    }
}

Uma função vira uma label. Seus parâmetros ocupam os primeiros slots locais:

static void gen_function(Codegen *codegen, const AstNode *function) {
    fputs("label ", codegen->stream);
    emit_function_label(codegen->stream, function->token);
    fputc('\n', codegen->stream);

    codegen->symbol_count = 0;
    codegen->next_slot = 0;
    codegen->loop_count = 0;

    for (int i = 0; i < function->children.count; i++) {
        declare_symbol(codegen, function->children.items[i]->token);
    }

    gen_block(codegen, function->first);
    fputs("push null\nret\n", codegen->stream);
}

A lógica é a seguinte:

  • antes de gerar uma função, limpamos a tabela de símbolos;
  • cada parâmetro recebe um slot local;
  • o corpo da função é gerado normalmente;
  • se a função terminar sem return, emitimos push null e ret como retorno padrão.

Uma declaração de variável gera o inicializador, salva o resultado no slot novo e remove o valor da pilha:

static void gen_var_decl(Codegen *codegen, const AstNode *node) {
    int slot = declare_symbol(codegen, node->token);
    gen_expression(codegen, node->first);
    fprintf(codegen->stream, "set_local %d\npop\n", slot);
}

O inicializador deixa um valor no topo da pilha. A instrução set_local copia esse valor para o slot da variável, e o pop logo em seguida remove o valor original da pilha — evitando acúmulo de lixo a cada declaração.

Gerando chamadas de função15.6.3

Chamadas comuns avaliam os argumentos da esquerda para a direita e emitem call:

static void gen_call(Codegen *codegen, const AstNode *node) {
    if (node->first != NULL && node->first->kind == AST_IDENTIFIER && token_equals(node->first->token, "print")) {
        for (int i = 0; i < node->children.count; i++) {
            gen_expression(codegen, node->children.items[i]);
            fputs("print\n", codegen->stream);
        }
        fputs("push null\n", codegen->stream);
        return;
    }

    for (int i = 0; i < node->children.count; i++) {
        gen_expression(codegen, node->children.items[i]);
    }

    if (node->first == NULL || node->first->kind != AST_IDENTIFIER) {
        fprintf(stderr, "Erro de geração: chamada indireta não suportada.\n");
        codegen->had_error = true;
        return;
    }

    fprintf(codegen->stream, "call %d ", node->children.count);
    emit_function_label(codegen->stream, node->first->token);
    fputc('\n', codegen->stream);
}

Aqui fazemos um tratamento especial para print. Em vez de exigir uma função Slang chamada print, geramos diretamente a instrução print da VM. Depois disso, empilhamos null para manter a chamada como uma expressão que produz algum valor.

Gerando expressões simples15.6.4

Operadores binários simples seguem sempre o mesmo padrão:

  1. gerar o lado esquerdo;
  2. gerar o lado direito;
  3. emitir a instrução que consome os dois valores.

Adicione:

static void gen_binary(Codegen *codegen, const AstNode *node) {
    gen_expression(codegen, node->first);
    gen_expression(codegen, node->second);

    switch (node->token.type) {
        case TOK_PLUS: fputs("add\n", codegen->stream); break;
        case TOK_MINUS: fputs("sub\n", codegen->stream); break;
        case TOK_STAR: fputs("mul\n", codegen->stream); break;
        case TOK_SLASH: fputs("div\n", codegen->stream); break;
        case TOK_PERCENT: fputs("mod\n", codegen->stream); break;
        case TOK_EQ: fputs("eq\n", codegen->stream); break;
        case TOK_BANG_EQ: fputs("ne\n", codegen->stream); break;
        case TOK_LT: fputs("lt\n", codegen->stream); break;
        case TOK_LT_EQ: fputs("le\n", codegen->stream); break;
        case TOK_GT: fputs("gt\n", codegen->stream); break;
        case TOK_GT_EQ: fputs("ge\n", codegen->stream); break;
        default:
            fprintf(stderr, "Erro de geração: operador binário não suportado.\n");
            codegen->had_error = true;
            break;
    }
}

Neste primeiro momento, and e or ainda não entram aqui. Eles exigem curto-circuito com saltos, então ficarão para a segunda parte.

Agora adicione a função principal de geração de expressões:

static void gen_expression(Codegen *codegen, const AstNode *node) {
    if (node == NULL || codegen->had_error) {
        return;
    }

    switch (node->kind) {
        case AST_BINARY:
            gen_binary(codegen, node);
            break;
        case AST_UNARY:
            gen_expression(codegen, node->first);
            fputs(node->token.type == TOK_MINUS ? "neg\n" : "not\n", codegen->stream);
            break;
        case AST_CALL:
            gen_call(codegen, node);
            break;
        case AST_IDENTIFIER:
            fprintf(codegen->stream, "get_local %d\n", find_symbol(codegen, node->token));
            break;
        case AST_NUMBER:
            fputs("push ", codegen->stream);
            print_token(codegen->stream, node->token);
            fputc('\n', codegen->stream);
            break;
        case AST_STRING:
            fputs("push ", codegen->stream);
            emit_string_literal(codegen->stream, node->token);
            fputc('\n', codegen->stream);
            break;
        case AST_CHAR:
            fputs("push ", codegen->stream);
            print_token(codegen->stream, node->token);
            fputc('\n', codegen->stream);
            break;
        case AST_BOOL:
            fputs(node->token.type == TOK_KW_TRUE ? "push true\n" : "push false\n", codegen->stream);
            break;
        default:
            fprintf(stderr, "Erro de geração: expressão não suportada.\n");
            codegen->had_error = true;
            break;
    }
}

Cada caso da AST deixa exatamente um valor no topo da pilha. Essa regra é importante: se toda expressão deixa um valor, os comandos podem decidir se usam esse valor, armazenam esse valor ou descartam esse valor.

Simulação manual: percorrendo uma expressão no codegen15.6.5

Para visualizar como essas funções se encaixam, vamos simular a geração de:

print(1 + 2 * 3)

O codegen começa em gen_expression com o nó AST_CALL que representa print(...). Como print é uma função especial, gen_call é chamada e faz o seguinte:

  1. identifica que o alvo é print;
  2. para cada argumento (no caso, um só), chama gen_expression;
  3. dentro dessa chamada, o argumento é um nó AST_BINARY com operador +;
  4. gen_expression despacha para gen_binary, que por sua vez:
    • chama gen_expression no lado esquerdo (1): emite push 1;
    • chama gen_expression no lado direito (2 * 3): é outro AST_BINARY com *;
    • gen_binary para * emite gen_expression(2)push 2, gen_expression(3)push 3, e depois mul;
  5. voltando ao +, emite add;
  6. volta para gen_call, que emite print e depois push null.

O assembly gerado fica:

push 1
push 2
push 3
mul
add
print
push null

Esse push null final é o valor de retorno de print — como print é uma expressão (toda chamada é), ela precisa deixar um valor na pilha. O null indica "nenhum valor relevante". Quem chamou print decide se usa ou descarta esse valor.

Gerando comandos simples15.6.6

Nesta primeira parte, vamos gerar apenas comandos que não exigem saltos.

Adicione gen_statement com os casos abaixo:

static void gen_statement(Codegen *codegen, const AstNode *node) {
    if (node == NULL || codegen->had_error) {
        return;
    }

    switch (node->kind) {
        case AST_BLOCK:
            gen_block(codegen, node);
            break;
        case AST_VAR_DECL:
            gen_var_decl(codegen, node);
            break;
        case AST_RETURN:
            if (node->first != NULL) {
                gen_expression(codegen, node->first);
            } else {
                fputs("push null\n", codegen->stream);
            }
            fputs("ret\n", codegen->stream);
            break;
        case AST_ASSIGN:
            gen_expression(codegen, node->first);
            fprintf(codegen->stream, "set_local %d\npop\n", find_symbol(codegen, node->token));
            break;
        case AST_EXPR_STMT:
            gen_expression(codegen, node->first);
            fputs("pop\n", codegen->stream);
            break;
        default:
            fprintf(stderr, "Erro de geração: comando ainda não suportado nesta etapa.\n");
            codegen->had_error = true;
            break;
    }
}

Observe a diferença entre expressão e comando:

  • uma expressão deixa valor na pilha;
  • um comando normalmente não deve deixar lixo na pilha;
  • por isso AST_EXPR_STMT gera a expressão e depois emite pop.

Ainda não tratamos AST_IF, AST_LOOP, AST_BREAK e AST_CONTINUE. Isso fica para a segunda parte, quando vamos falar de labels e saltos.

Gerando a entrada do programa15.6.7

Agora precisamos ligar as funções anteriores ao programa inteiro.

Adicione as funções finais do arquivo:

static void gen_declaration(Codegen *codegen, const AstNode *node) {
    if (node->kind != AST_FUNCTION) {
        gen_statement(codegen, node);
    }
}

static void gen_entry(Codegen *codegen, const AstNode *program) {
    fputs("label __entry\n", codegen->stream);
    codegen->symbol_count = 0;
    codegen->next_slot = 0;
    codegen->loop_count = 0;

    for (int i = 0; i < program->children.count; i++) {
        const AstNode *child = program->children.items[i];
        if (child->kind != AST_FUNCTION) {
            gen_declaration(codegen, child);
        }
    }

    fputs("push null\nret\n", codegen->stream);
}

bool codegen_generate(FILE *stream, const AstNode *program) {
    Codegen codegen = {0};
    codegen.stream = stream;

    const AstNode *main_function = find_main(program);
    bool has_main = main_function != NULL;
    bool has_top_level = program_has_top_level_code(program);

    if (has_main) {
        for (int i = 0; i < main_function->children.count; i++) {
            fputs("push null\n", stream);
        }
        fprintf(stream, "call %d fn_main\nhalt\n", main_function->children.count);
    } else {
        fputs("call 0 __entry\nhalt\n", stream);
    }

    for (int i = 0; i < program->children.count; i++) {
        const AstNode *child = program->children.items[i];
        if (child->kind == AST_FUNCTION) {
            gen_function(&codegen, child);
        }
    }

    if (has_top_level || !has_main) {
        gen_entry(&codegen, program);
    }

    return !codegen.had_error;
}

Essa versão já é suficiente para programas sem controle de fluxo. A versão mais completa, com if, loop, break, continue e curto-circuito lógico, será consolidada no próximo laboratório.

Integrando o gerador ao compilador15.7

Depois de implementar o gerador, precisamos expô-lo na linha de comando e garantir que o sistema de build compile todos os novos arquivos.

Atualizando o main.c15.7.1

Precisamos expor o novo modo code na interface do compilador.

No topo de src/main.c, adicione:

#include <stdbool.h>
#include "compiler/codegen.h"

Depois de run_parser_mode, adicione:

static int run_codegen_mode(const char *source) {
    Parser parser;
    parser_init(&parser, source, stderr);

    AstNode *program = parser_parse(&parser);
    if (program == NULL) {
        return 65;
    }

    bool ok = codegen_generate(stdout, program);
    ast_free(program);
    return ok ? 0 : 65;
}

Agora ajuste a mensagem de uso:

fprintf(stderr, "Uso: compiler <lex|parse|code> <arquivo>\n");

E adicione o novo ramo no main:

} else if (strcmp(argv[1], "code") == 0) {
    result = run_codegen_mode(source);

Atualizando o CMakeLists.txt15.7.2

Primeiro, adicione src/compiler/codegen.c na biblioteca:

add_library(compiler_lib
    src/compiler/token.c
    src/compiler/lexer.c
    src/compiler/ast.c
    src/compiler/parser.c
    src/compiler/codegen.c
)

Depois, adicione o executável da VM:

add_executable(slang-vm
    src/vm/main.c
    src/vm/assembler.c
    src/vm/chunk.c
    src/vm/debug.c
    src/vm/vm.c
)

target_include_directories(slang-vm
    PRIVATE
        ${PROJECT_SOURCE_DIR}/include
)

target_compile_options(slang-vm PRIVATE
    -Wall -Wextra -Wpedantic
)

Agora o projeto deve gerar dois executáveis de uso direto:

  • build/compiler;
  • build/slang-vm.

Testando a primeira versão do gerador15.8

Com a integração pronta, já podemos gerar assembly e executar programas simples na SlangVM.

Primeiro teste manual15.8.1

Crie um arquivo chamado programa.slang:

fn main(args: void) -> void {
    print(1 + 2 * 3)
}

Compile o projeto:

cmake -B build
cmake --build build

Gere assembly:

./build/compiler code programa.slang

A saída deve ser parecida com:

push null
call 1 fn_main
halt
label fn_main
push 1
push 2
push 3
mul
add
print
push null
pop
push null
ret
Entendendo os push null e pop extras

O assembly gerado tem algumas instruções que podem parecer repetidas à primeira vista. Cada uma tem uma origem diferente:

  • push null antes de call: valor passado como argumento para o parâmetro args de main.
  • print: consome um valor (o resultado de 1 + 2 * 3) e o imprime.
  • push null depois de print: é o valor de retorno de print (toda chamada deixa algo na pilha).
  • pop logo em seguida: o comando print(...) é uma expressão usada como comando, então o gerador descarta o valor deixado pela chamada.
  • push null antes de ret: retorno padrão que o gerador insere quando a função termina sem um return explícito.

Na prática, apenas push 1, push 2, push 3, mul, add e print são essenciais para o cálculo. As demais instruções são necessárias por causa da convenção de pilha que adotamos.

Agora salve e execute:

./build/compiler code programa.slang > programa.slasm
./build/slang-vm programa.slasm

A saída esperada é:

7

Testando chamada de função15.8.2

Agora troque programa.slang por:

fn add(a: int, b: int) -> int {
    return a + b
}

fn main(args: void) -> void {
    x: int = 10
    print(add(x, 32))
}

Gere e execute:

./build/compiler code programa.slang > programa.slasm
./build/slang-vm programa.slasm

A saída esperada é:

42

Esse teste valida:

  • parâmetros de função;
  • variável local;
  • leitura com get_local;
  • escrita com set_local;
  • chamada com call;
  • retorno com ret.

Próximos passos15.9

Neste ponto, já temos a primeira versão da geração de código. Ela consegue executar programas com funções, variáveis, expressões e chamadas simples.

No próximo laboratório, Lab - Geração de Código II, vamos completar o compilador adicionando:

  • if e else;
  • loop;
  • break e continue;
  • curto-circuito de and e or;
  • testes completos de VM, assembly e opcodes.

Também será no próximo laboratório que vamos consolidar uma versão mais completa do gerador, reunindo os casos simples desta parte com os casos de controle de fluxo.