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:
ifeelse;loopcom condição;loopinfinito;break;continue;- operadores lógicos
andeorcom 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; andeorpodem ignorar o lado direito;ifeloopprecisam desviar a execução para outra parte do programa.
Esses desvios serão implementados com labels e saltos.
No final deste laboratório, os testes da VM estarão divididos em três camadas:
- Testes Slang completos: programas
.slangpassam pelo compilador e depois pela VM. - Testes de assembly
.slasm: arquivos de assembly já prontos testam a VM diretamente. - 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->firstguarda a condição;node->secondguarda o bloco dothen;node->thirdguarda o bloco doelse, quando existir.
A lógica gerada é:
- gerar a condição;
- saltar para o
elsese a condição for falsa; - remover a condição da pilha se ela for verdadeira;
- gerar o bloco verdadeiro;
- saltar para o fim para não executar o
else; - 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.
popRepare 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_falsenão salta, e o primeiropopremove o valor verdadeiro da pilha. - Se a condição for falsa,
jump_if_falsesalta paraL0, e opopemL0remove 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:
- Prólogo: emite
push null(argumento demain),call 1 fn_mainehalt. - Label
fn_main: zera a tabela de símbolos, declara o parâmetroargsno slot 0. gen_var_declparavalue:declare_symbolatribui slot 1 avalue;gen_expressionpara o nóAST_NUMBER(5)→ emitepush 5;- emite
set_local 1.
gen_ifparaif value > 0:new_labelcriaelse_label = L0eend_label = L1;- gera a condição
value > 0:get_local 1,push 0,gt; - emite
jump_if_false L0epop; - gera o bloco
then: chamagen_callparaprint("positive"), que emitepush "positive",print,push null; - emite
jump L1; - emite
label L0epop(remove o falso da pilha); node->thirdéNULL, então não geraelse;- emite
label L1.
- Epílogo: emite
push nulleret.
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:
breaksalta para o fim do laço atual;continuesalta 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.