17

Lab - Geração de Código II

Introdução16.1

No laboratório anterior, adicionamos a SlangVM ao projeto e implementamos a primeira versão do gerador de código. Essa versão já consegue traduzir programas com:

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

Agora vamos finalizar a geração de código para as estruturas de controle do Slang. Essas estruturas exigem uma ideia nova: saltar de um ponto para outro do assembly gerado.

Ao final deste laboratório, o compilador será capaz de gerar código para:

  • if e else;
  • loop com condição;
  • loop infinito;
  • break;
  • continue;
  • operadores lógicos and e or com curto-circuito;
  • testes finais da VM.

Também vamos consolidar uma versão mais completa do gerador. A primeira parte mostrou os blocos fundamentais de forma incremental; esta segunda parte é o momento adequado para reunir tudo em uma versão final mais próxima do projeto completo.

Ponto de partida16.2

Este laboratório parte do codegen.c construído na parte I. A ideia não é reescrever tudo do zero, mas completar os casos que ficaram faltando.

Na parte I, gen_statement ainda rejeitava comandos como AST_IF, AST_LOOP, AST_BREAK e AST_CONTINUE. Também deixamos and e or fora de gen_binary, porque eles não podem ser tratados como operadores binários comuns.

O motivo é simples:

  • operadores como +, * e < sempre avaliam os dois lados;
  • and e or podem ignorar o lado direito;
  • if e loop precisam desviar a execução para outra parte do programa.

Esses desvios serão implementados com labels e saltos.

Testes em três níveis

No final deste laboratório, os testes da VM estarão divididos em três camadas:

  1. Testes Slang completos: programas .slang passam pelo compilador e depois pela VM.
  2. Testes de assembly .slasm: arquivos de assembly já prontos testam a VM diretamente.
  3. Testes de opcode em C: bytecode montado manualmente para exercitar instruções isoladas.

Cada camada responde uma pergunta: o codegen está gerando assembly correto? O assembler entende o texto? Os opcodes executam certo? Essa separação ajuda a isolar bugs mais rápido.

Labels e saltos na VM16.3

Antes de implementar if e loop, precisamos entender como o assembly da VM representa desvios de execução. Essa é a base de todo controle de fluxo.

Entendendo labels16.3.1

Controle de fluxo depende de saltos. Para saltar, precisamos de labels.

Um if simples como:

if x > 0 {
    print(x)
}

pode virar algo parecido com:

get_local 0
push 0
gt
jump_if_false L0
pop
get_local 0
print
push null
pop
label L0
pop

A instrução jump_if_false L0 olha para o topo da pilha. Se o valor for falso, a VM salta para L0. Se o valor for verdadeiro, a execução continua na próxima instrução.

Por isso deixamos a função abaixo pronta na parte I:

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

Cada chamada gera um número novo. Assim conseguimos criar L0, L1, L2 e assim por diante, sem repetir labels dentro do mesmo arquivo gerado.

Estruturas de controle16.4

Com labels disponíveis, podemos gerar comandos que mudam o fluxo normal da execução. Primeiro vamos tratar seleção com if e else; depois, repetição com loop.

Gerando if e else16.4.1

Um if precisa de pelo menos uma label: a label para onde saltamos quando a condição é falsa. Quando existe else, também precisamos de uma label para o fim do comando inteiro.

Adicione esta função em src/compiler/codegen.c, antes de gen_call:

// src/compiler/codegen.c (adicione antes de gen_call)

static void gen_if(Codegen *codegen, const AstNode *node) {
    int else_label = new_label(codegen);
    int end_label = new_label(codegen);

    gen_expression(codegen, node->first);
    fprintf(codegen->stream, "jump_if_false L%d\npop\n", else_label);
    gen_statement(codegen, node->second);
    fprintf(codegen->stream, "jump L%d\nlabel L%d\npop\n", end_label, else_label);
    if (node->third != NULL) {
        gen_statement(codegen, node->third);
    }
    fprintf(codegen->stream, "label L%d\n", end_label);
}

Na AST final do parser:

  • node->first guarda a condição;
  • node->second guarda o bloco do then;
  • node->third guarda o bloco do else, quando existir.

A lógica gerada é:

  1. gerar a condição;
  2. saltar para o else se a condição for falsa;
  3. remover a condição da pilha se ela for verdadeira;
  4. gerar o bloco verdadeiro;
  5. saltar para o fim para não executar o else;
  6. gerar o bloco falso, quando ele existir.

Os pop removem a condição da pilha depois que ela já foi usada para decidir o salto.

Cada caminho precisa do seu próprio pop

Repare que há um pop no bloco verdadeiro (depois de jump_if_false) e outro pop no bloco falso (depois de label). Isso não é repetição acidental: a condição está no topo da pilha em ambos os caminhos.

  • Se a condição for verdadeira, jump_if_false não salta, e o primeiro pop remove o valor verdadeiro da pilha.
  • Se a condição for falsa, jump_if_false salta para L0, e o pop em L0 remove o valor falso da pilha.

Sem esse pop em cada ramo, a pilha acumularia valores de condição que nunca seriam removidos.

Simulação manual: if value > 0 { print("positive") }16.4.1.1

Para ver as labels e saltos em ação, vamos simular a geração deste programa simples:

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

O codegen percorre a AST e executa a seguinte sequência:

  1. Prólogo: emite push null (argumento de main), call 1 fn_main e halt.
  2. Label fn_main: zera a tabela de símbolos, declara o parâmetro args no slot 0.
  3. gen_var_decl para value:
    • declare_symbol atribui slot 1 a value;
    • gen_expression para o nó AST_NUMBER(5) → emite push 5;
    • emite set_local 1.
  4. gen_if para if value > 0:
    • new_label cria else_label = L0 e end_label = L1;
    • gera a condição value > 0: get_local 1, push 0, gt;
    • emite jump_if_false L0 e pop;
    • gera o bloco then: chama gen_call para print("positive"), que emite push "positive", print, push null;
    • emite jump L1;
    • emite label L0 e pop (remove o falso da pilha);
    • node->third é NULL, então não gera else;
    • emite label L1.
  5. Epílogo: emite push null e ret.

O assembly final fica:

push null
call 1 fn_main
halt
label fn_main
push 5
set_local 1
pop
get_local 1
push 0
gt
jump_if_false L0
pop
push "positive"
print
push null
jump L1
label L0
pop
label L1
push null
ret

Gerando loop16.4.2

Um loop precisa de uma label de início e uma label de fim.

O início é o ponto para onde continue deve voltar. O fim é o ponto para onde break deve saltar.

Adicione esta função depois de gen_if:

// src/compiler/codegen.c (adicione depois de gen_if)

static void gen_loop(Codegen *codegen, const AstNode *node) {
    if (codegen->loop_count == MAX_LOOPS) {
        fprintf(stderr, "Erro de geração: laços aninhados demais.\n");
        codegen->had_error = true;
        return;
    }

    int start_label = new_label(codegen);
    int false_label = new_label(codegen);
    int end_label = new_label(codegen);
    codegen->loops[codegen->loop_count++] = (LoopLabels){start_label, end_label};

    fprintf(codegen->stream, "label L%d\n", start_label);
    if (node->first != NULL) {
        gen_expression(codegen, node->first);
        fprintf(codegen->stream, "jump_if_false L%d\npop\n", false_label);
    }

    gen_statement(codegen, node->second);
    fprintf(codegen->stream, "jump L%d\n", start_label);

    if (node->first != NULL) {
        fprintf(codegen->stream, "label L%d\npop\n", false_label);
    }
    fprintf(codegen->stream, "label L%d\n", end_label);
    codegen->loop_count--;
}

Esse código cobre dois formatos da linguagem:

loop {
    print(1)
}

e:

loop x < 10 {
    x = x + 1
}

Quando node->first é NULL, o laço não tem condição e se torna infinito. Quando node->first existe, a condição é testada a cada iteração.

A pilha loops guarda as labels do laço atual. Isso é necessário porque break e continue podem aparecer dentro de laços aninhados; nesse caso, eles devem afetar o laço mais interno.

Gerando break e continue16.4.3

break e continue dependem do laço atual.

Por isso usamos a pilha loops dentro de Codegen:

LoopLabels loops[MAX_LOOPS];
int loop_count;

O topo dessa pilha guarda as labels do laço mais interno.

Em gen_statement, os casos serão:

case AST_BREAK:
    if (codegen->loop_count == 0) {
        fprintf(stderr, "Erro de geração: break fora de laço.\n");
        codegen->had_error = true;
    } else {
        fprintf(codegen->stream, "jump L%d\n", codegen->loops[codegen->loop_count - 1].end_label);
    }
    break;

case AST_CONTINUE:
    if (codegen->loop_count == 0) {
        fprintf(stderr, "Erro de geração: continue fora de laço.\n");
        codegen->had_error = true;
    } else {
        fprintf(codegen->stream, "jump L%d\n", codegen->loops[codegen->loop_count - 1].start_label);
    }
    break;

Assim:

  • break salta para o fim do laço atual;
  • continue salta para o início do laço atual.

Completando gen_statement16.4.4

Agora complete gen_statement com os casos das estruturas de controle.

A versão completa da função deve ficar assim:

// src/compiler/codegen.c — gen_statement completa

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_IF:
            gen_if(codegen, node);
            break;
        case AST_LOOP:
            gen_loop(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_BREAK:
            if (codegen->loop_count == 0) {
                fprintf(stderr, "Erro de geração: break fora de laço.\n");
                codegen->had_error = true;
            } else {
                fprintf(codegen->stream, "jump L%d\n", codegen->loops[codegen->loop_count - 1].end_label);
            }
            break;
        case AST_CONTINUE:
            if (codegen->loop_count == 0) {
                fprintf(stderr, "Erro de geração: continue fora de laço.\n");
                codegen->had_error = true;
            } else {
                fprintf(codegen->stream, "jump L%d\n", codegen->loops[codegen->loop_count - 1].start_label);
            }
            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:
            gen_expression(codegen, node);
            fputs("pop\n", codegen->stream);
            break;
    }
}

A mudança principal está nos casos AST_IF e AST_LOOP. Agora, comandos de controle não são tratados como erro; eles chamam funções próprias que emitem labels e saltos.

Operadores lógicos com curto-circuito16.5

Depois das estruturas de controle, falta tratar os operadores lógicos que também dependem de saltos. and e or não podem ser gerados como operadores binários comuns, porque nem sempre avaliam os dois lados.

Curto-circuito de and16.5.1

Na parte I, gen_binary tratava operadores binários comuns. Agora vamos adicionar and e or.

O operador and deve avaliar o lado direito apenas se o lado esquerdo for verdadeiro. Se o lado esquerdo for falso, o resultado inteiro já é falso.

No início de gen_binary, antes de gerar os dois lados normalmente, adicione:

if (node->token.type == TOK_AND) {
    int end_label = new_label(codegen);
    gen_expression(codegen, node->first);
    fprintf(codegen->stream, "jump_if_false L%d\npop\n", end_label);
    gen_expression(codegen, node->second);
    fprintf(codegen->stream, "label L%d\n", end_label);
    return;
}

Se o lado esquerdo for falso, jump_if_false salta para o fim e deixa esse falso no topo da pilha. Se for verdadeiro, removemos o valor com pop e avaliamos o lado direito.

Curto-circuito de or16.5.2

O operador or faz o contrário. Se o lado esquerdo já for verdadeiro, não precisamos avaliar o lado direito.

Ainda no início de gen_binary, adicione depois do caso TOK_AND:

if (node->token.type == TOK_OR) {
    int right_label = new_label(codegen);
    int end_label = new_label(codegen);
    gen_expression(codegen, node->first);
    fprintf(codegen->stream, "jump_if_false L%d\njump L%d\nlabel L%d\npop\n", right_label, end_label, right_label);
    gen_expression(codegen, node->second);
    fprintf(codegen->stream, "label L%d\n", end_label);
    return;
}

Esse padrão é um pouco mais longo porque jump_if_false só salta quando o valor é falso. Se o lado esquerdo for verdadeiro, usamos um jump incondicional para pular o lado direito.

Conferindo a versão mais completa de gen_binary16.5.3

Depois dos casos especiais de curto-circuito, o restante da função continua igual ao que fizemos na parte I.

A versão mais completa fica assim:

// src/compiler/codegen.c — gen_binary completa

static void gen_binary(Codegen *codegen, const AstNode *node) {
    if (node->token.type == TOK_AND) {
        int end_label = new_label(codegen);
        gen_expression(codegen, node->first);
        fprintf(codegen->stream, "jump_if_false L%d\npop\n", end_label);
        gen_expression(codegen, node->second);
        fprintf(codegen->stream, "label L%d\n", end_label);
        return;
    }

    if (node->token.type == TOK_OR) {
        int right_label = new_label(codegen);
        int end_label = new_label(codegen);
        gen_expression(codegen, node->first);
        fprintf(codegen->stream, "jump_if_false L%d\njump L%d\nlabel L%d\npop\n", right_label, end_label, right_label);
        gen_expression(codegen, node->second);
        fprintf(codegen->stream, "label L%d\n", end_label);
        return;
    }

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

Esse é o primeiro ponto da segunda parte em que reunimos código anterior com código novo. A ideia é mostrar como a função final fica depois da adição do curto-circuito.

Testes manuais de controle de fluxo16.6

Agora vamos validar os recursos novos com programas pequenos. A ideia é observar o comportamento final antes de partir para a suíte automatizada.

Testando if16.6.1

Crie um arquivo programa.slang:

fn main(args: void) -> void {
    value: int = 5
    if value > 0 {
        print("positive")
    } else {
        print("zero")
    }
}

Gere e execute:

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

A saída esperada é:

positive

Esse teste valida a geração de condição, salto condicional, bloco verdadeiro e bloco falso.

Testando recursão16.6.2

Agora teste uma função recursiva:

fn factorial(n: int) -> int {
    if n <= 1 {
        return 1
    }
    return n * factorial(n - 1)
}

fn main(args: void) -> void {
    print(factorial(5))
}

Gere e execute:

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

A saída esperada é:

120

Esse teste valida várias partes ao mesmo tempo:

  • declaração de função;
  • parâmetro local;
  • comparação <=;
  • if;
  • return;
  • chamada recursiva;
  • multiplicação;
  • print.

Testando loop e continue16.6.3

Agora teste:

fn main(args: void) -> void {
    x: int = 0
    loop x < 5 {
        x = x + 1
        if x % 2 == 0 {
            continue
        }
        print(x)
    }
}

Gere e execute:

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

A saída esperada é:

1
3
5

Esse exemplo valida:

  • variável local;
  • atribuição;
  • laço condicional;
  • módulo %;
  • comparação ==;
  • continue;
  • salto de volta para o início do laço.

Testando curto-circuito16.6.4

Agora teste uma expressão em que o lado direito não deve ser avaliado:

fn fail() -> bool {
    print("fail")
    return true
}

fn main(args: void) -> void {
    if false and fail() {
        print("wrong")
    } else {
        print("ok")
    }
}

Gere e execute:

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

A saída esperada é:

ok

Se fail fosse chamado, a saída teria a palavra fail. Como isso não acontece, sabemos que o curto-circuito de and está funcionando.

Testes automatizados da VM16.7

Os testes manuais ajudam a entender o comportamento, mas o projeto precisa de uma suíte repetível. Por isso, vamos separar os testes em camadas: programas Slang completos, assembly direto e opcodes isolados.

Adicionando testes de VM16.7.1

No estado final do projeto, a pasta tests passa a ter também testes de VM:

tests/
├── lexer/
├── parser/
├── vm/
│   ├── input/
│   └── expected/
└── run_tests.sh

Os testes de VM são arquivos .slang completos, que passam por duas etapas:

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

O compilador gera o assembly, a VM executa e a saída padrão é comparada com o arquivo de resultado esperado. O script run_tests.sh faz esse processo automaticamente para todos os testes.

Rodando a suíte completa16.7.2

Compile tudo:

cmake -B build
cmake --build build

Rode todos os testes:

./tests/run_tests.sh all

A saída esperada no estado final é:

=============================================
   EXECUTANDO TESTES DE LEXER
=============================================
  [OK] lexer/01_delimiters
  [OK] lexer/02_simple_operators
  ...
  [OK] lexer/16_unterminated_string_error

=============================================
   EXECUTANDO TESTES DE PARSER
=============================================
  [OK] parser/01_variable_declaration
  [OK] parser/02_function_declaration
  ...
  [OK] parser/09_invalid_parameter

=============================================
   EXECUTANDO TESTES DE VM
=============================================
  [OK] vm/aritmetica_basica
  [OK] vm/associatividade
  [OK] vm/atribuicao
  [OK] vm/break
  [OK] vm/break_while
  ...
  [OK] vm/while_simples

=============================================
   RESULTADO: 63 PASSARAM | 0 FALHARAM | 63 TOTAL
=============================================

Limitações conscientes16.8

Ainda existem simplificações importantes:

  • não há análise semântica completa de tipos;
  • não há escopo lexical sofisticado para blocos aninhados;
  • strings são suportadas principalmente como valores imprimíveis;
  • a VM usa endereços pequenos em um byte para saltos e chamadas;
  • print é tratado como primitiva especial, não como função Slang comum.

Essas limitações são aceitáveis neste marco porque o objetivo é consolidar a ideia central de geração de código. A partir daqui, cada uma delas pode virar uma melhoria futura.

Próximos passos16.9

Com o compilador gerando código para a SlangVM, fechamos o ciclo completo de compilação e execução. A partir daqui, o próximo tema do curso será o estudo de algoritmos de otimização, isto é, técnicas para transformar o programa gerado em versões equivalentes, mas mais eficientes.

Algumas extensões naturais para essa direção são:

  • adicionar análise semântica antes da geração de código;
  • validar tipos de expressões e retornos de função;
  • implementar escopos por bloco;
  • estudar e implementar otimizações simples, como constant folding;
  • trocar o assembly textual por um bytecode binário real.

O ponto principal, porém, já foi alcançado: agora temos um ciclo completo de compilação e execução para a linguagem Slang.