🗝️ LLVM IR: Representação Intermediária Moderna

Você acabou de dominar como interpretadores executam programas diretamente navegando pela AST. Agora está pronto para explorar uma abordagem radicalmente diferente - compilação para código que pode ser executado com velocidade máxima pelo hardware. E não estamos falando de qualquer código: vamos mergulhar no LLVM IR, uma das mais elegantes e poderosas representações intermediárias já criadas.

O LLVM revolucionou a construção de compiladores. Antes dele, criar um compilador de qualidade profissional que gerasse código otimizado para múltiplas arquiteturas era um empreendimento que consumia anos de esforço especializado. Com LLVM, você pode focar em traduzir sua linguagem para uma representação intermediária limpa, enquanto décadas de pesquisa em otimizações trabalham automaticamente para você. É verdadeiramente transformador.

Por Que Representações Intermediárias Importam

Antes de mergulharmos nos detalhes técnicos do LLVM IR, é essencial compreender por que representações intermediárias existem e qual problema fundamental elas resolvem. Esta compreensão conceitual tornará todos os detalhes subsequentes muito mais claros e significativos.

Compilar diretamente de uma linguagem de alto nível para código de máquina de uma arquitetura específica parece simples na superfície, mas esconde problemas significativos de escalabilidade e manutenibilidade. Se você quer suportar cinco linguagens fonte (C, C++, Rust, Swift, Didágica) e três arquiteturas alvo (x86-64, ARM, RISC-V), a abordagem ingênua requer 5 × 3 = 15 compiladores completamente separados.

Pior ainda, cada otimização que você desenvolve precisa ser reimplementada para cada combinação. Uma otimização inteligente de eliminação de código morto implementada para C-para-x86 não ajuda Swift-para-ARM. Este é um problema de M × N que explode rapidamente e desperdiça enormemente esforço de engenharia.

🎯 Analogia Esclarecedora: Tradução de Idiomas

Imagine que você administra uma empresa de tradução global que precisa traduzir documentos entre dez idiomas diferentes. A abordagem direta requer tradutores especializados para cada par de idiomas: português-inglês, português-francês, português-alemão, e assim por diante. Para dez idiomas, você precisa de 10 × 9 = 90 tradutores especializados diferentes (cada idioma para cada outro, excluindo auto-tradução).

Agora imagine uma abordagem diferente: escolha um idioma intermediário que todos os seus tradutores conhecem - digamos, esperanto. Agora você só precisa de 10 + 10 = 20 tradutores: dez para traduzir de cada idioma para esperanto, e dez para traduzir de esperanto para cada idioma. Reduziu a complexidade de O(N²) para O(N)!

Melhor ainda: qualquer técnica de tradução que você desenvolve para melhorar a tradução entre esperanto e qualquer idioma específico beneficia automaticamente todas as outras línguas. Uma melhor forma de expressar metáforas em esperanto-para-inglês ajuda automaticamente traduções de português-para-inglês, alemão-para-inglês, e todas as outras combinações.

De forma similar, LLVM IR é o “esperanto” da compilação. Você traduz sua linguagem fonte para IR uma vez, e então pode compilar para qualquer arquitetura que LLVM suporta. Todas as otimizações desenvolvidas para IR beneficiam todas as linguagens automaticamente. Este é o poder das representações intermediárias.

Mas LLVM IR vai muito além de simples redução de trabalho duplicado. Por operar em um nível de abstração intermediário - mais baixo que linguagens fonte mas mais alto que assembly de máquina - otimizações podem explorar informações estruturais que seriam perdidas em código de máquina direto, enquanto evitam o ruído e a complexidade de construções de alto nível que obscurecem oportunidades de otimização.

É o ponto ideal de abstração - concreto o suficiente para raciocinar sobre performance, abstrato o suficiente para aplicar transformações sofisticadas.

Estrutura Hierárquica do LLVM IR

LLVM IR é organizado hierarquicamente em quatro níveis conceituais que refletem naturalmente a estrutura de programas. Esta hierarquia não é acidental ou arbitrária - ela foi cuidadosamente projetada para facilitar tanto geração de código quanto análises e otimizações.

graph TD
    A["Módulo LLVM<br/>Unidade de compilação completa<br/>Contém funções e globais"] --> B["Função 1<br/>foo(int, int)<br/>Assinatura e corpo"]
    A --> C["Função 2<br/>bar(float)<br/>Assinatura e corpo"]
    A --> D["Variáveis Globais<br/>Constantes<br/>Declarações externas"]

    B --> E["Bloco Básico: entry<br/>Parâmetros e setup<br/>Sem branches internas"]
    B --> F["Bloco Básico: loop<br/>Corpo do loop<br/>Termina com branch"]
    B --> G["Bloco Básico: exit<br/>Código de retorno<br/>Termina com ret"]

    E --> H["Instruções<br/>alloca, store, load<br/>Sequência linear"]
    F --> I["Instruções<br/>add, icmp, br<br/>Computações"]
    G --> J["Instruções<br/>ret<br/>Terminador"]

    style A fill:#e3f2fd,stroke:#1976d2,stroke-width:3px
    style B fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style C fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style E fill:#e8f5e9,stroke:#388e3c
    style F fill:#e8f5e9,stroke:#388e3c
    style G fill:#e8f5e9,stroke:#388e3c
    style H fill:#f3e5f5,stroke:#9c27b0
    style I fill:#f3e5f5,stroke:#9c27b0
    style J fill:#f3e5f5,stroke:#9c27b0

Vamos dissecar cada nível desta hierarquia, compreendendo não apenas o “o que” mas também o “por que” de cada escolha de design.

Módulos correspondem a unidades de compilação - tipicamente um arquivo fonte completo. Um módulo contém declarações de funções (protótipos sem corpo), definições de funções (implementações completas), variáveis globais, constantes, e tipos definidos pelo usuário. Módulos podem ser linkados posteriormente para formar programas completos, exatamente como arquivos objeto são linkados em compilação tradicional.

A modularização permite compilação separada - você pode compilar diferentes partes de um programa grande independentemente e depois combiná-las. Isso é fundamental para escalabilidade de builds grandes.

Funções são unidades de código executável com parâmetros tipados e tipo de retorno. Cada função tem uma assinatura que especifica os tipos de parâmetros e retorno, e um corpo consistindo em um ou mais blocos básicos. Funções podem ser internas ao módulo (privadas) ou exportadas para linkage externo (públicas).

Uma característica importante: funções LLVM não precisam corresponder exatamente a funções na linguagem fonte. Você pode gerar múltiplas funções LLVM de uma única função fonte (por exemplo, separando inicialização de corpo), ou inline múltiplas funções fonte em uma função LLVM durante otimização.

Blocos básicos são sequências de instruções sem ramificações internas. Execução entra em um bloco básico no início e continua sequencialmente até a instrução de término (branch, return, ou similar). Esta propriedade - sem saltos internos - facilita enormemente análises e otimizações porque você pode raciocinar sobre o bloco inteiro como uma unidade atômica.

Se você sabe que execução entrou no bloco, sabe que todas as instruções até o terminador serão executadas em ordem. Não há surpresas, não há saltos para o meio do bloco. Esta previsibilidade é ouro para análises de fluxo de dados.

Instruções são operações individuais em forma de três endereços: dois operandos e um resultado. LLVM tem um rico conjunto de instruções cobrindo aritmética (add, sub, mul, div), comparações (icmp, fcmp), acessos a memória (alloca, load, store), chamadas de função (call), e controle de fluxo (br, ret, switch). Instruções operam sobre valores tipados, garantindo segurança de tipos mesmo em nível de IR.

A forma de três endereços simplifica análises porque dependências entre computações são explícitas. Não há pilha implícita ou registradores escondidos - tudo é nomeado e rastreável.

Formato Textual do LLVM IR

LLVM IR tem uma representação textual legível por humanos que facilita tremendamente compreensão, depuração, e aprendizado. Você pode examinar o IR gerado pelo seu compilador, estudar exemplos de compiladores maduros, e entender exatamente o que cada instrução faz. Vamos explorar este formato através de exemplos progressivamente mais complexos.

Começaremos com a função mais simples possível - uma que simplesmente soma dois inteiros:

define i32 @soma(i32 %a, i32 %b) {
entry:
  %resultado = add i32 %a, %b
  ret i32 %resultado
}

Vamos dissecar meticulosamente cada elemento deste código, porque compreender estas convenções é fundamental para trabalhar com LLVM.

A palavra-chave define indica o início de uma definição de função (em contraste com declare que seria apenas um protótipo). O tipo i32 antes do nome especifica o tipo de retorno - neste caso, inteiro de 32 bits. LLVM suporta inteiros de larguras arbitrárias (i1 para booleanos, i8 para bytes, i64 para longs, até i256 ou além se necessário).

O nome @soma identifica a função globalmente. O prefixo @ é usado para todos os símbolos globais em LLVM - funções e variáveis globais. Este prefixo visual elimina ambiguidade e facilita parsing.

Os parênteses contêm a lista de parâmetros. Cada parâmetro especifica um tipo (i32) seguido de um nome local (%a, %b). O prefixo % identifica valores locais - parâmetros de função, resultados de instruções, e variáveis temporárias. Tudo que existe apenas dentro de uma função usa %.

💡 Convenções de Nomenclatura no LLVM IR

LLVM usa prefixos diferentes para distinguir categorias de identificadores, reduzindo ambiguidade e facilitando tanto parsing quanto compreensão humana:

**Identificadores com @** são símbolos globais - funções (@main, @calcular) e variáveis globais (@contador_global, @mensagem). Eles têm escopo de módulo ou programa e podem ser visíveis através de linkage externo.

Identificadores com % são valores locais - parâmetros de função (%x, %nome), resultados de instruções (%temp, %resultado), e variáveis locais alocadas na stack. Eles têm escopo de função e não são visíveis fora dela.

Identificadores sem prefixo são tipos (i32, double, float) ou labels de blocos básicos (entry, loop, exit). Alguns contextos também permitem nomes de estruturas sem prefixo.

Esta convenção parece arbitrária inicialmente, mas rapidamente se torna segunda natureza e ajuda enormemente na leitura de IR complexo.

Dentro do corpo da função, entry: é o rótulo do primeiro bloco básico. Por convenção forte (mas não obrigatória), o primeiro bloco é chamado entry. A instrução add i32 %a, %b computa a soma dos parâmetros. O resultado é automaticamente armazenado em uma variável temporária SSA que nomeamos %resultado.

Finalmente, ret i32 %resultado retorna o valor computado. A instrução ret é um terminador - cada bloco básico deve terminar com exatamente um terminador que transfere controle para outro bloco ou retorna da função.

Exemplo com Controle de Fluxo

Para realmente apreciar a estrutura de blocos básicos e como controle de fluxo funciona em LLVM, precisamos de algo mais complexo. Considere uma função que retorna o máximo de dois números:

define i32 @maximo(i32 %a, i32 %b) {
entry:
  %cond = icmp sgt i32 %a, %b
  br i1 %cond, label %then, label %else

then:
  ret i32 %a

else:
  ret i32 %b
}

Esta função demonstra perfeitamente como decisões condicionais se traduzem para blocos básicos e branches. Vamos analisar o fluxo de controle passo a passo.

O bloco entry começa comparando %a e %b usando icmp sgt (integer compare signed greater than). O resultado é um booleano de 1 bit (i1) armazenado em %cond. A instrução br (branch) é condicional - ela toma o valor booleano e dois rótulos de destino.

Se %cond é verdadeiro (i1 1), controle salta para o bloco then. Se falso (i1 0), salta para else. Note que esta é uma decisão binária limpa - não há caminho intermediário ou ambiguidade.

Os blocos then e else são casos extremamente simples: cada um contém apenas uma instrução ret retornando um dos valores. Não há necessidade de um bloco de merge porque ambos os caminhos terminam a função imediatamente. Esta é uma característica importante: nem todos os condicionais precisam de merges - depende da semântica do programa.

graph TD
    A["Bloco: entry<br/>Compara a > b<br/>Decide caminho"] --> B["Bloco: then<br/>Retorna a"]
    A --> C["Bloco: else<br/>Retorna b"]

    style A fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style B fill:#e8f5e9,stroke:#388e3c
    style C fill:#fff3e0,stroke:#f57c00

Cada seta no diagrama corresponde a uma instrução br no IR. O grafo de fluxo de controle é explícito e rastreável - não há “magia” ou comportamento implícito.

Sistema de Tipos do LLVM

LLVM tem um sistema de tipos rico mas relativamente simples que fornece segurança de tipos sem sacrificar flexibilidade de baixo nível ou performance. Compreender este sistema de tipos é essencial para gerar IR correto.

Tipos Primitivos incluem inteiros de larguras arbitrárias, pontos flutuantes, e ponteiros:

  • Inteiros: i1 (booleano), i8 (byte), i16 (short), i32 (int), i64 (long), i128 (muito long), e virtualmente qualquer largura até i16777216 (embora larguras não-padrão sejam raras)
  • Floats: half (16 bits), float (32 bits), double (64 bits), fp128 (128 bits quad precision)
  • Ponteiros: tipo* como i32* (ponteiro para inteiro), double* (ponteiro para double), %struct.Point* (ponteiro para estrutura)

Tipos Agregados permitem composição de tipos mais simples:

  • Arrays: [tamanho x tipo] como [10 x i32] (array de 10 inteiros), [100 x double] (array de 100 doubles)
  • Estruturas: { tipo1, tipo2, ... } como { i32, float, i8* } (estrutura com int, float, e ponteiro)
  • Vetores: <tamanho x tipo> como <4 x float> (vetor SIMD de 4 floats)

Um aspecto particularmente interessante e às vezes confuso do sistema de tipos LLVM é a uniformidade de ponteiros. Não há distinção entre ponteiros para heap, stack, ou memória global - todos são apenas tipo*. Esta uniformidade simplifica enormemente transformações e otimizações porque não há regras especiais sobre como diferentes categorias de ponteiros podem ser usadas.

Correspondência de Tipos: Didágica para LLVM

Ao gerar LLVM IR para Didágica, você precisa mapear os tipos da linguagem para tipos LLVM apropriados. Aqui está um mapeamento natural e eficiente:

Inteiro em Didágica → i64 em LLVM

Usar 64 bits garante compatibilidade com plataformas modernas e evita problemas de overflow em computações típicas. Se você quer ser mais agressivo com uso de memória, i32 também funciona, mas i64 é mais seguro.

Real em Didágica → double em LLVM

Float de dupla precisão é o padrão de fato para computações de ponto flutuante. float (simples precisão) é menor mas perde precisão rapidamente em cálculos acumulados.

Texto em Didágica → i8* em LLVM

Ponteiro para array de bytes, similar a strings estilo C. Strings literais serão arrays globais de i8, e variáveis string mantêm ponteiros para estes arrays.

Booleano em Didágica → i1 em LLVM

Inteiro de 1 bit é perfeitamente adequado para valores verdadeiro/falso. LLVM otimiza operações booleanas eficientemente.

Listas e Arrays em Didágica → { i64, tipo* } em LLVM

Estrutura contendo tamanho (quantidade de elementos) e ponteiro para os dados. Isso permite arrays de tamanho dinâmico com verificação de limites.

Objetos de Classes em Didágica → Estruturas LLVM

Cada classe vira um tipo estrutura contendo seus campos como membros. Métodos são funções que recebem ponteiro para estrutura como primeiro parâmetro.

Este mapeamento não é único ou obrigatório - há trade-offs entre simplicidade de implementação, uso de memória, e performance que podem justificar escolhas diferentes em contextos específicos. Mas este é um ponto de partida sólido e bem testado.

Instruções de Memória: Alloca, Store e Load

LLVM distingue cuidadosamente entre valores e locações de memória, usando instruções explícitas para alocação e acesso. Esta distinção pode parecer verbosa comparada a linguagens de alto nível, mas é fundamental para permitir análises sofisticadas de alias e otimizações agressivas.

A instrução alloca aloca memória na stack do frame atual da função. Ela não inicializa a memória - apenas reserva espaço. Por exemplo:

%ptr = alloca i32

Isto aloca 4 bytes (tamanho de i32) na stack e retorna um ponteiro para essa locação. A memória é automaticamente desalocada quando a função retorna - não há necessidade de free manual. Isso é equivalente a declarar uma variável local em C.

A instrução store escreve um valor em uma locação de memória. A sintaxe é:

store tipo valor, tipo* ponteiro

Por exemplo:

store i32 42, i32* %ptr

Isto escreve o valor 42 na locação apontada por %ptr. Note a ordem: primeiro o valor a ser armazenado, depois onde armazenar. Esta ordem pode parecer invertida inicialmente (você pode esperar “store onde o que”), mas reflete a convenção store-destino-fonte que é consistente em todo o IR.

A instrução load lê um valor de uma locação de memória. A sintaxe é:

%resultado = load tipo, tipo* ponteiro

Por exemplo:

%valor = load i32, i32* %ptr

Isto lê um inteiro de 32 bits da locação apontada por %ptr e armazena em %valor, que é agora um valor SSA que pode ser usado em computações.

⚠️ SSA Form e Mutabilidade

LLVM IR está em forma SSA (Static Single Assignment), significando que cada variável é atribuída exatamente uma vez. Cada %temp ou %resultado recebe valor uma única vez e nunca muda. Isto simplifica enormemente análises e otimizações porque não há confusão sobre qual “versão” de uma variável está ativa em um ponto particular do código.

Mas linguagens de programação permitem mutação - variáveis podem mudar valor durante execução. Em Didágica, você pode escrever:

Guarde Inteiro contador como 0;
Guarde contador como contador + 1;
Guarde contador como contador + 1;

O valor de contador muda repetidamente. Como reconciliar isto com SSA, onde cada variável é atribuída apenas uma vez?

A solução elegante é usar memória explicitamente. Variáveis mutáveis não são representadas como valores SSA diretos - são representadas como locações de memória (via alloca). Mutação é implementada via store para a locação, e leitura usa load da locação.

Exemplo: incremento de contador não pode ser expresso diretamente em SSA:

%counter = 0
loop:
  %counter = add %counter, 1  ; ERRO: %counter já foi atribuído!

Mas funciona perfeitamente com memória:

%counter_ptr = alloca i64
store i64 0, i64* %counter_ptr
loop:
  %temp = load i64, i64* %counter_ptr
  %inc = add i64 %temp, 1
  store i64 %inc, i64* %counter_ptr
  br label %loop

Agora cada load e add cria novos valores SSA temporários (%temp, %inc), enquanto a locação de memória %counter_ptr permanece constante (é alocada uma vez e seu endereço nunca muda).

O melhor: otimizações posteriores (especialmente o pass mem2reg) promovem alocações triviais de stack de volta para registradores SSA quando possível, obtendo o melhor de ambos os mundos - simplicidade de geração com performance de SSA puro.

Exemplo Completo: Tradução Passo-a-Passo

Vamos consolidar tudo o que aprendemos com um exemplo completo e detalhado. Considere este fragmento de código Didágica:

Funcao Inteiro fatorial(Inteiro n)
    Se n <= 1 entao
        retorne 1;
    Senao
        retorne n * fatorial(n - 1);
    Fim
Fim

Vamos gerar LLVM IR para esta função recursiva, explicando cada decisão:

define i64 @fatorial(i64 %n) {
entry:
  ; Alocar espaço para parâmetro (permite tratamento uniforme)
  %n.addr = alloca i64
  store i64 %n, i64* %n.addr
  
  ; Carregar n e comparar com 1
  %n.val = load i64, i64* %n.addr
  %cond = icmp sle i64 %n.val, 1
  br i1 %cond, label %if.then, label %if.else

if.then:
  ; Caso base: retorna 1
  ret i64 1

if.else:
  ; Caso recursivo: calcula n * fatorial(n-1)
  %n.val2 = load i64, i64* %n.addr
  %n.minus1 = sub i64 %n.val2, 1
  %rec.result = call i64 @fatorial(i64 %n.minus1)
  %n.val3 = load i64, i64* %n.addr
  %result = mul i64 %n.val3, %rec.result
  ret i64 %result
}

Vamos analisar cada seção:

Bloco entry: Começamos alocando espaço na stack para o parâmetro n. Isto pode parecer redundante (já temos %n como parâmetro), mas simplifica geração porque podemos tratar todos os nomes uniformemente como ponteiros. Depois carregamos o valor, comparamos com 1 usando icmp sle (signed less-or-equal), e fazemos branch condicional para if.then ou if.else.

Bloco if.then: O caso base é trivial - apenas retorna a constante 1. Não há computação necessária.

Bloco if.else: O caso recursivo é mais interessante. Carregamos n novamente (necessário porque estamos em bloco diferente), subtraímos 1, fazemos chamada recursiva com call i64 @fatorial(...), e então multiplicamos o resultado pela versão original de n. Finalmente retornamos este produto.

Note os múltiplos loads de %n.addr - isto parece ineficiente, mas o otimizador eliminará redundâncias automaticamente. Nossa prioridade na geração é correção e clareza, não micro-otimização.


📄 Tradução Sistemática: De AST para LLVM IR

Agora que você compreende a estrutura e sintaxe de LLVM IR, está pronto para aprender como gerar sistematicamente este IR a partir de sua AST. Este é o núcleo da fase de geração de código - transformar representações abstratas em instruções executáveis concretas.

Arquitetura do Gerador de Código

Gerar LLVM IR de AST é um processo sistemático que percorre a árvore recursivamente, emitindo instruções IR apropriadas para cada tipo de nó. A estrutura conceitual é surpreendentemente similar a interpretadores - ambos fazem visitor pattern sobre a AST. A diferença fundamental é que, em vez de executar ações diretamente, você gera código que executará essas ações.

graph LR
    A["AST em Memória<br/>Estrutura de dados"] --> B["Gerador de Código<br/>Visitor Pattern"]
    B --> C["Builder LLVM<br/>API de construção"]
    C --> D["LLVM IR<br/>Código Textual/Bitcode"]

    B -.-> E["Contexto:<br/>- Módulo atual<br/>- Função atual<br/>- Bloco atual<br/>- Mapa de variáveis"]

    style A fill:#e3f2fd,stroke:#1976d2
    style B fill:#fff3e0,stroke:#f57c00
    style C fill:#e8f5e9,stroke:#388e3c
    style D fill:#f3e5f5,stroke:#9c27b0
    style E fill:#ffebee,stroke:#d32f2f

O gerador de código mantém contexto de geração que rastreia:

  • Módulo LLVM atual: Container para todas as funções e globais sendo geradas
  • Função atual: Função LLVM sendo atualmente construída (se estamos dentro de uma)
  • Bloco básico atual: Bloco onde próximas instruções serão inseridas
  • Mapeamento de variáveis: Dicionário associando nomes de variáveis fonte aos ponteiros ou valores LLVM correspondentes

À medida que percorre a AST, o gerador atualiza este contexto e usa o LLVM Builder para emitir instruções. O Builder é uma API conveniente fornecida pelo LLVM que abstrai detalhes de formato de IR e fornece métodos como CreateAdd(), CreateStore(), CreateBr() para construir instruções.

Gerando Código para Expressões

Expressões são traduzidas recursivamente, retornando um valor LLVM que representa o resultado da expressão. A estrutura recursiva de expressões se reflete naturalmente na recursão do gerador - é uma correspondência perfeita.

Literais são os casos base mais simples. Um literal inteiro gera uma constante inteira:

Value gerarLiteralInteiro(LiteralInteiro no) {
  return ConstantInt.get(contexto, APInt(64, no.valor));
}

Literais de ponto flutuante geram constantes float. Literais de string requerem um pouco mais de trabalho: você precisa criar um array global contendo os bytes da string e retornar um ponteiro para esse array.

Variáveis são traduzidas gerando um load do ponteiro associado à variável. Lembre-se: variáveis mutáveis são representadas como locações de memória, então acesso requer load explícito:

Value gerarVariavel(ExpressaoVariavel no) {
  // Busca ponteiro no mapeamento
  Value ptr = mapeamentoVariaveis[no.nome];
  // Gera load para obter valor atual
  return builder.CreateLoad(ptr, no.nome + ".val");
}

Operações binárias geram código para os operandos esquerdo e direito recursivamente, depois emitem instrução apropriada combinando os resultados:

Value gerarBinaria(ExpressaoBinaria no) {
  // Gera código para operandos
  Value esquerdo = gerar(no.esquerdo);
  Value direito = gerar(no.direito);
  
  // Emite instrução apropriada
  switch (no.operador) {
    case '+':
      return builder.CreateAdd(esquerdo, direito, "addtmp");
    case '-':
      return builder.CreateSub(esquerdo, direito, "subtmp");
    case '*':
      return builder.CreateMul(esquerdo, direito, "multmp");
    case '/':
      return builder.CreateSDiv(esquerdo, direito, "divtmp");
    case '>':
      return builder.CreateICmpSGT(esquerdo, direito, "cmptmp");
    // ... outros operadores
  }
}

Cada método Create* do builder emite uma instrução no bloco atual e retorna um valor SSA representando o resultado.

Considere a tradução de (x + 5) * 2:

Passo 1: Visitar nó de multiplicação

Passo 2: Gerar operando esquerdo (adição)

Passo 3: Gerar operando esquerdo da adição (variável x) - Buscar %x_ptr no mapeamento - Gerar: %x_val = load i64, i64* %x_ptr

Passo 4: Gerar operando direito da adição (literal 5) - Gerar: constante i64 5

Passo 5: Combinar com CreateAdd - Gerar: %add_result = add i64 %x_val, 5

Passo 6: Gerar operando direito da multiplicação (literal 2) - Gerar: constante i64 2

Passo 7: Combinar com CreateMul - Gerar: %mul_result = mul i64 %add_result, 2

Passo 8: Retornar %mul_result

%x_val = load i64, i64* %x_ptr
%add_result = add i64 %x_val, 5
%mul_result = mul i64 %add_result, 2
; %mul_result contém resultado final da expressão

Note como cada subexpressão gera um valor intermediário que é então usado pela expressão pai. Esta abordagem de valores temporários é natural em forma SSA e facilita imensamente otimizações posteriores, porque dependências entre computações são explícitas.

Gerando Código para Declarações de Variáveis

Declarações de variáveis combinam alocação de memória com inicialização opcional. O processo tem três passos conceituais:

  1. Alocar memória via alloca
  2. Avaliar expressão de inicialização (se presente)
  3. Armazenar valor inicial via store

Para a declaração Didágica Guarde Inteiro x como 10;, geramos:

%x_ptr = alloca i64
store i64 10, i64* %x_ptr

Para declaração sem inicialização Guarde Real y;, geramos apenas a alocação:

%y_ptr = alloca double
; Valor é indefinido até primeira atribuição

É importante adicionar %x_ptr ao mapeamento de variáveis no contexto de geração, associando o nome “x” com seu ponteiro. Usos subsequentes de x consultarão este mapeamento para gerar loads apropriados.

💡 Otimização de Promotabilidade

Alocações triviais de stack que não têm seu endereço tomado (via operador &) e não escapam da função podem ser promovidas automaticamente para registradores virtuais SSA pelo pass de otimização mem2reg (memory-to-register).

Este pass transforma código verboso com loads/stores em forma SSA limpa e eficiente. Por exemplo, este código inicial:

%x_ptr = alloca i64
store i64 10, i64* %x_ptr
%temp = load i64, i64* %x_ptr
%result = add i64 %temp, 5
ret i64 %result

É automaticamente otimizado para:

%result = add i64 10, 5
ret i64 %result

Esta transformação acontece automaticamente quando você executa o pipeline de otimizações padrão do LLVM. Você pode gerar código naive inicial com muitos loads/stores e confiar que o otimizador o refinará em código eficiente.

Isto libera você para focar em gerar IR correto sem se preocupar excessivamente com micro-otimizações locais.

Gerando Código para Condicionais

Condicionais requerem múltiplos blocos básicos: um para avaliar a condição e decidir o caminho, um para cada branch (then e else), e opcionalmente um bloco de merge para código após o condicional.

O algoritmo geral de geração é:

  1. Criar blocos then, else (se existir), e merge
  2. No bloco atual (entry), avaliar expressão de condição
  3. Emitir branch condicional: br i1 %cond, label %then, label %else_or_merge
  4. Mudar inserção para bloco then, gerar código do corpo, emitir branch incondicional para merge
  5. Se else existe, mudar para bloco else, gerar código, emitir branch para merge
  6. Mudar para bloco merge, continuar geração normal

graph TD
    A["Bloco: entry<br/>Código antes do if"] --> B["Bloco: if.cond<br/>Avalia condição"]
    B -->|"condição = true"| C["Bloco: if.then<br/>Corpo do then"]
    B -->|"condição = false"| D["Bloco: if.else<br/>Corpo do else"]
    C --> E["Bloco: if.merge<br/>Código após if"]
    D --> E

    style A fill:#e3f2fd,stroke:#1976d2
    style B fill:#fff3e0,stroke:#f57c00
    style C fill:#e8f5e9,stroke:#388e3c
    style D fill:#ffebee,stroke:#d32f2f
    style E fill:#f3e5f5,stroke:#9c27b0

Para o condicional Didágica:

Se x > 10 entao
    Escreva "Grande";
Senao
    Escreva "Pequeno";
Fim

Geramos algo como:

entry:
  %x_val = load i64, i64* %x_ptr
  %cond = icmp sgt i64 %x_val, 10
  br i1 %cond, label %if.then, label %if.else

if.then:
  call void @escrever_texto(i8* getelementptr([7 x i8], [7 x i8]* @str_grande, i32 0, i32 0))
  br label %if.merge

if.else:
  call void @escrever_texto(i8* getelementptr([9 x i8], [9 x i8]* @str_pequeno, i32 0, i32 0))
  br label %if.merge

if.merge:
  ; Código após condicional continua aqui

A estrutura é mecânica mas requer cuidado para gerenciar os blocos corretamente.


🚀 Pipeline de Compilação com LLVM

LLVM não é apenas uma biblioteca para gerar IR - é um toolchain completo para construir compiladores profissionais. Uma vez que você gera LLVM IR, pode usar ferramentas LLVM para otimizar, compilar para código nativo, e linkar em executáveis finais.

graph LR
    A["Código Fonte<br/>Didágica"] --> B["Frontend<br/>Análise"]
    B --> C["AST<br/>Verificada"]
    C --> D["Gerador<br/>Seu código"]
    D --> E["LLVM IR<br/>Não otimizado"]
    E --> F["Otimizador<br/>LLVM"]
    F --> G["LLVM IR<br/>Otimizado"]
    G --> H["Backend<br/>LLVM"]
    H --> I["Código Nativo<br/>Executável"]

    style A fill:#e3f2fd,stroke:#1976d2
    style C fill:#fff3e0,stroke:#f57c00
    style E fill:#e8f5e9,stroke:#388e3c
    style G fill:#f3e5f5,stroke:#9c27b0
    style I fill:#ffebee,stroke:#d32f2f

Você é responsável apenas pelas fases até geração de LLVM IR. LLVM cuida do resto - otimização, seleção de instruções, alocação de registradores, geração de código nativo, tudo. Esta separação de responsabilidades é incrivelmente poderosa.

Otimizações Automáticas

LLVM inclui centenas de passes de otimização que transformam IR para versões semanticamente equivalentes mas muito mais eficientes. O melhor: você não precisa implementar nenhuma delas!

Exemplos de otimizações aplicadas automaticamente:

  • Constant folding: avalia operações em constantes em tempo de compilação
  • Dead code elimination: remove código que não afeta resultado do programa
  • Common subexpression elimination: identifica cálculos repetidos e os computa apenas uma vez
  • Loop optimizations: invariant code motion, unrolling, vectorization

Você simplesmente chama o PassManager com nível de otimização desejado (O0, O1, O2, O3) e todas as transformações relevantes são aplicadas automaticamente.


🎓 Conclusão: O Poder do LLVM

Você agora compreende profundamente como LLVM IR funciona, desde sua estrutura hierárquica até instruções individuais. Viu como gerar IR sistematicamente a partir de ASTs e como o toolchain LLVM transforma este IR em executáveis otimizados.

O LLVM democratizou a construção de compiladores. Antes dele, criar um compilador de qualidade profissional era empreendimento de anos. Com LLVM, você pode focar na semântica de sua linguagem, gerar IR relativamente simples, e obter automaticamente:

  • Otimizações de nível mundial
  • Geração de código nativo para múltiplas arquiteturas
  • Suporte a debugging e profiling
  • Integração com ferramentas de desenvolvimento

É uma das contribuições mais significativas da ciência da computação moderna para engenharia de software prática. E agora você sabe usá-lo! 🚀