“Object-oriented programming is an exceptionally bad idea which could only have originated in California.”

Edsger Dijkstra

19 - Compiladores

Compiladores ocupam uma posição central na computação porque fazem a ponte entre linguagens projetadas para seres humanos e linguagens ou representações executáveis por máquinas. Sempre que escrevemos um programa em C, C++, Java, Rust, Go ou muitas outras linguagens, dependemos de uma cadeia de tradução que reconhece o código-fonte, verifica sua estrutura, detecta erros, organiza símbolos, produz representações intermediárias e finalmente gera outra forma de programa que possa ser executada, interpretada ou otimizada.

Estudar compiladores também ajuda a integrar vários temas fundamentais da ciência da computação. Nesse campo aparecem linguagens formais, gramáticas, árvores, tabelas de símbolos, estruturas de dados, algoritmos, ambientes de execução, otimização e geração de código. Por isso, compiladores não são apenas ferramentas práticas de desenvolvimento; eles também funcionam como um ponto de encontro entre teoria da computação, engenharia de software, arquitetura de computadores e sistemas operacionais.

Ao longo deste capítulo, o foco recai sobre o fluxo clássico de tradução: distinção entre compiladores e interpretadores, análise léxica e sintática, tabelas de símbolos, esquemas de tradução, ambientes de tempo de execução, representação intermediária, análise semântica, geração de código, otimização e compilação separada. A ideia é construir uma visão progressiva de como um programa é entendido e transformado por essas ferramentas, desde a sequência inicial de caracteres até a forma final executável ou reutilizável.

19.1 - Compiladores e Interpretadores.

Um compilador é um programa que traduz um texto escrito em uma linguagem-fonte para outra representação de destino. A linguagem-fonte é aquela usada pelo programador, como C, C++, Rust ou Go. A linguagem-alvo pode ser código de máquina, assembly, bytecode, código-objeto ou uma representação intermediária. O ponto principal é que o compilador realiza uma tradução sistemática do programa inteiro ou de grandes partes dele antes da execução final.

Uma analogia simples é pensar no compilador como um tradutor que prepara um livro inteiro antes de sua publicação. Ele lê o material, verifica sua estrutura, detecta inconsistências, reorganiza certas partes e produz uma nova versão pronta para distribuição ou para etapas posteriores da cadeia de execução.

Um interpretador, por sua vez, executa o programa de forma mais incremental. Em vez de traduzir todo o código para um artefato final independente antes da execução, ele lê instruções ou blocos, analisa o que significam e já toma ações correspondentes naquele momento. Em uma analogia, o interpretador se parece mais com um mediador que traduz uma conversa trecho por trecho enquanto ela acontece.

Essa distinção ajuda, mas não deve ser tratada como absoluta. Em computação real, muitas linguagens usam modelos híbridos. Java e C#, por exemplo, tipicamente compilam o código-fonte para bytecode ou IL, que depois é executado por uma máquina virtual. Python também utiliza representações intermediárias antes da execução. Em alguns ambientes, um compilador JIT (Just-In-Time) ainda pode recompilar trechos do programa durante a execução para melhorar desempenho. Assim, compilação e interpretação muitas vezes aparecem combinadas.

Também existe a ideia de tradução para uma linguagem intermediária. Um transpilador, por exemplo, converte uma linguagem de alto nível em outra também de alto nível, como TypeScript para JavaScript. Já infraestruturas como LLVM usam uma representação intermediária sobre a qual diferentes otimizações podem ser aplicadas antes da geração do código final para cada arquitetura.

Em um compilador clássico, há duas grandes famílias de atividades:

  • análise, ou front-end: reconhece os elementos do código-fonte, verifica sua estrutura e seu significado, registra identificadores e produz representações internas adequadas;
  • síntese, ou back-end: transforma essas representações em código de destino, decide detalhes de armazenamento e aplica otimizações.

Esse pipeline costuma ser apresentado em fases, ainda que implementações reais possam fundir, reorganizar ou repetir algumas etapas:

  1. recebe um fluxo de caracteres do código-fonte;
  2. executa análise léxica e produz tokens;
  3. executa análise sintática e constrói uma árvore ou estrutura equivalente;
  4. executa análise semântica e anota ou valida essa estrutura com informações de tipo, escopo e uso;
  5. gera representação intermediária;
  6. aplica otimizações;
  7. gera código de destino, como código-objeto, assembly, bytecode ou outra forma executável.

Em termos práticos, compiladores e interpretadores diferem principalmente em quando a tradução acontece, que artefato é produzido e que tipos de otimização são mais viáveis. Um compilador nativo frequentemente consegue realizar otimizações agressivas antes da execução e gerar um executável eficiente para uma arquitetura específica. Já um interpretador ou máquina virtual tende a oferecer maior flexibilidade, portabilidade e feedback incremental, embora o custo de execução possa depender mais do ambiente em tempo de execução.

Isso não significa que programas interpretados sejam necessariamente “sempre lentos”, nem que compiladores sejam automaticamente superiores em todo contexto. Ambientes com JIT, caching de bytecode, profiling dinâmico e otimizações adaptativas podem alterar bastante esse panorama. O modelo adotado depende dos objetivos da linguagem: portabilidade, desempenho bruto, rapidez de desenvolvimento, depuração, segurança, interatividade ou integração com uma máquina virtual.

Independentemente do modelo de execução, quase toda ferramenta de tradução precisa reconhecer símbolos, validar estrutura, lidar com erros e organizar representações internas do programa. É justamente essa base que motiva as próximas seções sobre análise léxica, sintática, semântica e geração de código.

19.2 - Análise Léxica e Sintática.

As fases de análise léxica e análise sintática formam a base do front-end de um compilador. A primeira trabalha mais próxima do fluxo bruto de caracteres do código-fonte; a segunda trabalha com a estrutura gerada a partir desses elementos. Juntas, elas transformam um texto escrito pelo programador em uma representação interna organizada o suficiente para permitir checagens posteriores, tradução e otimização.

Na análise léxica, também chamada de scanning, o compilador lê a sequência de caracteres do programa e a agrupa em unidades significativas. Um lexema é a sequência concreta de caracteres reconhecida no texto, como int, soma, = ou 123. Um token é a categoria abstrata atribuída a esse lexema, como palavra-chave, identificador, operador ou constante.

É comum representar um token na forma:

$$ \langle \text{nome-token},\ \text{atributo} \rangle $$

O nome-token indica a classe léxica, como ID, NUM ou IF. O atributo carrega alguma informação adicional, como o valor numérico de um literal ou uma referência à entrada correspondente na tabela de símbolos. Assim, lexema e token não são a mesma coisa: o lexema é o texto que apareceu; o token é a classificação dada a esse texto.

Uma analogia útil é pensar em uma frase em língua natural. Antes de analisar a gramática da frase, precisamos separar palavras, pontuação e símbolos relevantes. A análise léxica desempenha esse papel inicial no código-fonte.

Considere o trecho de código em C:

int soma = a + b;

O analisador léxico pode reconhecer, por exemplo:

  • int como palavra-chave;
  • soma como identificador;
  • = como operador de atribuição;
  • a e b como identificadores;
  • + como operador aritmético;
  • ; como delimitador de fim de instrução.

Espaços em branco e comentários normalmente são descartados nessa fase porque, para o compilador clássico, costumam não carregar significado sintático direto. Isso não impede que outras ferramentas, como formatadores, linters ou IDEs, também os observem para fins diferentes. Se surgir uma sequência inválida de caracteres, como um literal mal formado ou um símbolo proibido, o erro aparece tipicamente já na análise léxica.

Depois que os tokens foram produzidos, entra em cena a análise sintática, ou parsing. Agora o problema deixa de ser “quais unidades apareceram?” e passa a ser “essas unidades formam uma estrutura válida na linguagem?”. O parser recebe a sequência de tokens e verifica se ela respeita as regras estruturais da linguagem, construindo uma árvore ou estrutura equivalente que reflita a organização gramatical do programa.

No exemplo anterior, a análise sintática verifica que a sequência reconhecida pode representar uma declaração válida: uma palavra-chave de tipo, seguida por um identificador, seguida por um operador de atribuição, seguida por uma expressão e encerrada por ponto e vírgula. Se a ordem dos tokens fosse incompatível com a gramática da linguagem, o parser rejeitaria a construção.

Essa estrutura é importante porque um programa não é apenas uma lista linear de tokens. A expressão a + b * c, por exemplo, precisa ser entendida como uma hierarquia de operações, em que a multiplicação se associa mais fortemente do que a soma. É a análise sintática que organiza essa precedência e torna explícita a estrutura que depois será usada pela análise semântica e pela geração de código.

Para descrever formalmente essas estruturas, usa-se com frequência uma gramática livre de contexto. Uma gramática define quais formas de construção são válidas na linguagem. Ela é composta por símbolos terminais, símbolos não-terminais, produções e um símbolo inicial. Os terminais são os elementos finais reconhecidos no programa, como tokens; os não-terminais representam categorias sintáticas intermediárias, como expressão, instrução ou declaração.

Uma produção tem a forma geral

$$ A \rightarrow \alpha $$

e indica que o não-terminal A pode ser substituído pela sequência \alpha, formada por terminais, não-terminais ou ambos. A seta pode ser lida como “pode ter a forma”.

Um exemplo simples para uma instrução condicional seria:

$$ \text{stmt} \rightarrow \text{if ( expr ) stmt else stmt} $$

Nessa produção, if, (, ) e else são terminais. Já expr e stmt são não-terminais: representam, respectivamente, uma expressão e uma instrução válida da linguagem. A produção não executa nada; ela apenas descreve a forma estrutural permitida.

Um exemplo clássico de gramática para expressões aritméticas é:

$$ E \rightarrow E + T \\ E \rightarrow T \\ T \rightarrow T * F \\ T \rightarrow F \\ F \rightarrow (E) \\ F \rightarrow id $$

Nessa gramática, E representa expressão, T representa termo e F representa fator. A separação entre esses níveis ajuda a codificar precedência: multiplicação aparece mais “profunda” na estrutura do que soma. Assim, a expressão id + id * id não é tratada como uma sequência plana, mas como uma soma cujo operando da direita é, por sua vez, uma multiplicação.

Uma derivação possível, de forma resumida, seria:

$$ E \Rightarrow E + T \Rightarrow T + T \Rightarrow F + T \Rightarrow id + T \Rightarrow id + T * F \Rightarrow id + F * F \Rightarrow id + id * id $$

Essa derivação mostra como uma estrutura válida pode ser construída a partir do símbolo inicial, aplicando produções sucessivamente. Em compiladores reais, o parser automatiza esse processo e produz uma estrutura interna que pode ser uma árvore sintática concreta ou, em muitas implementações, uma árvore sintática abstrata, mais compacta e focada no significado estrutural relevante.

De forma resumida, uma gramática livre de contexto possui quatro componentes principais:

  1. um conjunto de símbolos terminais;
  2. um conjunto de símbolos não-terminais;
  3. um conjunto de produções;
  4. um símbolo inicial.

O resultado dessas fases alimenta o restante do compilador. A análise léxica organiza o fluxo de caracteres em tokens; a análise sintática organiza esses tokens em estruturas válidas. A partir daí, o compilador pode consultar tabelas de símbolos, verificar significado semântico e preparar a tradução para representações intermediárias ou código final.

19.3 - Tabelas de Símbolos.

A tabela de símbolos é uma estrutura central do compilador usada para registrar identificadores e outras entidades relevantes do programa, juntamente com seus atributos. Ela funciona como um cadastro interno que permite ao compilador responder perguntas como: esse nome já foi declarado? em que escopo ele vale? qual é seu tipo? quantos parâmetros essa função recebe? onde esse símbolo deverá ser encontrado nas fases posteriores?

Entre os elementos que podem aparecer na tabela estão variáveis, constantes, parâmetros, funções, procedimentos, tipos, classes, métodos, campos de estruturas, rótulos e outros nomes definidos na linguagem. O importante é que a tabela não armazena apenas “nomes”, mas nomes associados a significado.

Os atributos registrados para cada entrada dependem da linguagem e da fase do compilador, mas com frequência incluem informações como:

  • nome do identificador;
  • categoria, como variável, função, parâmetro ou tipo;
  • tipo do dado associado;
  • escopo de validade;
  • quantidade e tipo de parâmetros, no caso de funções ou métodos;
  • tipo de retorno;
  • endereço, deslocamento ou referência interna para fases posteriores.

É importante não confundir a tabela de símbolos com a memória real do programa em execução. Ela é uma estrutura do compilador, usada durante a tradução e análise. Mesmo quando guarda informações sobre endereços ou offsets, esses dados ainda pertencem ao processo de compilação, não ao programa final já executando na máquina.

A construção da tabela começa cedo. Na análise léxica, o compilador já pode reconhecer que certos lexemas são identificadores. Entretanto, reconhecer um nome não basta: é nas fases seguintes que o compilador determina se esse nome foi declarado corretamente, qual o seu tipo e em que contexto ele pode ser usado. Em outras palavras, o léxico identifica; a semântica atribui significado mais completo.

O tema de escopo é particularmente importante. Um mesmo nome pode existir em regiões diferentes do programa com significados diferentes. Um identificador global pode ser ocultado por uma variável local de mesmo nome dentro de uma função ou bloco. Por isso, muitos compiladores organizam a tabela como uma pilha de ambientes ou tabelas encadeadas por escopo, permitindo abrir um novo contexto ao entrar em um bloco e descartá-lo ao sair.

Considere o exemplo abaixo:

int x = 10;

int soma(int a, int b) {
    int x = a + b;
    return x;
}

Nesse trecho, o compilador precisa lidar com pelo menos estas entradas:

  • x global: categoria variável, tipo inteiro, escopo global;
  • soma: categoria função, retorno inteiro, parâmetros inteiros a e b;
  • a e b: categoria parâmetro, tipo inteiro, escopo local da função;
  • x local: categoria variável, tipo inteiro, escopo interno da função, ocultando o x global naquele bloco.

Esse exemplo mostra por que a tabela de símbolos precisa registrar mais do que o nome textual. O compilador deve saber qual x está sendo referenciado em cada ponto do código. Sem essa informação, seria impossível realizar corretamente checagem de escopo, análise de tipos e geração de código.

Na análise semântica, a tabela é consultada e atualizada constantemente. Ela ajuda a detectar erros como uso de variável não declarada, redeclaração inválida em um mesmo escopo, chamada de função com número errado de argumentos, incompatibilidade entre tipos e uso indevido de nomes reservados ou categorias incompatíveis.

Do ponto de vista de implementação, a tabela costuma ser projetada para permitir busca e inserção eficientes, já que o compilador a consulta repetidamente. Estruturas como tabelas hash, árvores e pilhas de tabelas por escopo são comuns. A escolha concreta depende da linguagem, da arquitetura do compilador e das operações mais frequentes em cada fase.

Quando o compilador gera representação intermediária, a tabela de símbolos continua importante. Ela ajuda a relacionar nomes do programa a tipos, temporários, offsets, registradores virtuais ou localizações abstratas que serão refinadas depois. Em otimização, essas informações podem orientar decisões sobre propagação de constantes, eliminação de código morto, análise de uso e alocação mais eficiente de recursos.

Mais adiante, na geração de código e no ambiente de tempo de execução, a informação associada aos símbolos influencia layout de registros de ativação, posicionamento de variáveis locais, convenções de chamada e resolução de referências externas. Por isso, a tabela de símbolos acompanha o compilador por várias fases: nasce cedo, ganha detalhes ao longo da análise e continua útil até a tradução final.

19.4 - Esquemas de Tradução.

Reconhecer a estrutura sintática de um programa ainda não basta para traduzi-lo. Depois que o compilador identifica tokens, produções e árvores, ele precisa associar significado a essa estrutura e produzir artefatos úteis para as fases seguintes. É nesse ponto que entram esquemas de tradução e regras semânticas: mecanismos que dizem como extrair informações da estrutura sintática e como transformá-las em tipos, valores, verificações ou código intermediário.

Uma forma comum de fazer isso é associar atributos aos símbolos da gramática. Um símbolo sintático pode carregar informações como tipo, valor constante, nome temporário, endereço, lista de parâmetros ou local de desvio. Em vez de enxergar a árvore sintática apenas como uma forma estrutural, passamos a vê-la também como uma estrutura que transporta informação semântica.

Quando uma produção é aplicada, regras semânticas podem calcular atributos a partir de outros atributos relacionados aos símbolos da produção. Em notação geral, algo como

$$ b = f(c_1, c_2, \ldots, c_k) $$

indica que o atributo b é computado a partir de outros atributos c_1, c_2, ..., c_k. A função f pode representar, por exemplo, uma verificação de tipos, o cálculo de um valor, a montagem de uma instrução intermediária ou a combinação de listas de controle de fluxo.

Essas ideias aparecem em gramáticas de atributos, isto é, gramáticas enriquecidas com regras semânticas. Quando essas regras são usadas para orientar diretamente a construção de alguma saída, falamos em tradução dirigida pela sintaxe. Em outras palavras, a própria estrutura reconhecida pelo parser passa a guiar a produção de informação semântica ou de código.

Há dois tipos clássicos de atributos. Atributos sintetizados são calculados a partir dos filhos de um nó e “sobem” na árvore. Atributos herdados vêm do contexto: podem ser transmitidos do pai para o filho ou entre irmãos, dependendo da forma da produção. Essa distinção é importante porque algumas informações dependem apenas da subárvore local, enquanto outras dependem do ambiente em que o símbolo aparece.

Um exemplo simples de atributo sintetizado aparece no valor de uma expressão aritmética. Se temos uma produção como

$$ E \rightarrow E_1 + T $$

podemos associar a ela uma regra semântica do tipo

$$ E.\text{valor} = E_1.\text{valor} + T.\text{valor} $$

Nesse caso, o valor da expressão inteira é sintetizado a partir dos valores calculados nas subárvores correspondentes.

Também podemos usar a mesma ideia para gerar código intermediário. Suponha a expressão

$$ a + b * c $$

A análise sintática organiza essa expressão segundo a precedência dos operadores. Um esquema de tradução pode então produzir temporários e instruções em três endereços, como:

t1 = b * c
t2 = a + t1

A ordem dessas instruções não vem apenas do texto original, mas da estrutura da árvore sintática e das regras semânticas associadas às produções. É por isso que esquemas de tradução são tão importantes: eles ligam a forma sintática à ação concreta de construir representações executáveis ou otimizáveis.

Em muitos compiladores, essas ações semânticas são executadas durante o parsing ou logo após a construção da árvore. Dependendo da técnica adotada, o compilador pode anotar a árvore com tipos, criar nós adicionais, gerar uma AST mais enxuta, preencher a tabela de símbolos, montar código intermediário ou preparar estruturas para análise de fluxo e otimização.

A árvore sintática, ou árvore de parse, continua sendo o suporte natural para esse processo. Cada nó representa uma construção reconhecida pela gramática, e os atributos associados a esses nós permitem transportar informações para cima, para baixo ou lateralmente, conforme a disciplina de atributos adotada. Em muitas implementações práticas, a árvore usada nas fases seguintes já é uma árvore sintática abstrata, mais simples do que a árvore concreta produzida pelo parser.

Na prática, esquemas de tradução aparecem em tarefas como verificação de tipos, coerção entre tipos compatíveis, construção de tabelas de símbolos, geração de código de três endereços, montagem de listas para saltos condicionais e verificação de regras semânticas dependentes de contexto. Isso faz deles uma ponte natural entre a análise sintática e as seções seguintes sobre ambientes de execução, representação intermediária e geração de código.

19.5 - Ambientes de Tempo de Execução.

Depois que o compilador reconhece, analisa e traduz o programa, ainda resta uma pergunta essencial: como esse programa será sustentado durante a execução? O ambiente de tempo de execução, ou runtime, é justamente o conjunto de estruturas e serviços que tornam possível executar o código gerado. Ele envolve pilha de chamadas, heap, registradores, convenções de chamada, bibliotecas de suporte e mecanismos para lidar com procedimentos, exceções, alocação dinâmica e, em algumas linguagens, coleta de lixo.

Essa discussão marca uma transição importante: até aqui o foco estava na tradução do programa; agora o foco passa para a forma como o programa vive enquanto executa. O compilador precisa gerar código compatível com esse ambiente, decidindo como parâmetros serão passados, onde variáveis locais serão colocadas, como valores de retorno serão tratados e como chamadas de função serão organizadas.

Um conceito central nesse contexto é o de ativação de procedimento ou ativação de função. Sempre que uma função é chamada, surge uma instância concreta daquela chamada em execução. Essa instância precisa guardar seu contexto próprio: argumentos recebidos, variáveis locais, ponto para o qual a execução deverá retornar e outras informações de controle. O conjunto desses dados forma o registro de ativação, também chamado de frame de pilha.

A pilha de execução, ou stack, costuma organizar esses frames em ordem LIFO (Last In, First Out). A chamada mais recente fica no topo da pilha. Quando uma função termina, seu frame é removido e a execução retorna ao frame anterior. Isso explica por que chamadas aninhadas e recursivas se encaixam tão naturalmente nesse modelo.

Considere o seguinte fluxo de chamadas:

main()
  chama soma(2, 3)
    soma chama multiplica(2, 3)
    multiplica retorna 6
  soma retorna 5 ou outro valor calculado
main continua

Nesse cenário, a pilha cresce à medida que main, soma e multiplica entram em execução. Cada uma recebe seu próprio registro de ativação. Quando multiplica termina, seu frame é removido; em seguida, o controle volta a soma; depois, a main. Uma analogia útil é pensar em fichas de trabalho empilhadas: cada chamada abre sua própria ficha, e a ficha mais recente precisa ser concluída antes de retomar a anterior.

Um registro de ativação pode conter, entre outros elementos:

  • parâmetros recebidos pela função;
  • variáveis locais da ativação;
  • endereço de retorno, isto é, o ponto do programa para onde a execução deve voltar;
  • registradores salvos ou parte do estado da máquina;
  • valor de retorno, quando necessário;
  • link de controle, que permite localizar o frame anterior na pilha;
  • link de acesso, útil em linguagens com escopos aninhados para alcançar variáveis não locais;
  • informações auxiliares para tratamento de exceções ou depuração.

Os dados locais pertencem apenas àquela ativação específica. Isso é especialmente importante em recursão: chamadas diferentes da mesma função precisam manter suas variáveis independentes. É exatamente por isso que o runtime consegue suportar múltiplas ativações simultâneas da mesma rotina sem confundir seus estados.

Além da stack, muitos ambientes de execução também usam a heap para alocação dinâmica. A stack tende a armazenar dados de vida curta, ligados diretamente ao fluxo de chamadas, como variáveis automáticas e parâmetros. A heap é usada para objetos, blocos ou estruturas cuja vida útil não coincide necessariamente com o retorno imediato de uma função. O compilador precisa conhecer essa distinção porque ela afeta layout de memória, custo de acesso e geração de código.

O compilador também precisa seguir convenções de chamada. Essas convenções definem, por exemplo, como os argumentos são passados, onde o valor de retorno será colocado, quais registradores devem ser preservados e quem é responsável por limpar a pilha após a chamada. Em arquiteturas e linguagens diferentes, essas decisões variam, mas o princípio geral permanece: sem uma convenção clara, código gerado por partes distintas do sistema não conseguiria cooperar corretamente.

Um exemplo conceitual em C pode ajudar:

int soma(int a, int b) {
    int temp = a + b;
    return temp;
}

Ao ativar essa função, o runtime precisa reservar espaço para a, b, temp, endereço de retorno e possíveis registradores preservados. O compilador então gera código assumindo offsets específicos dentro do frame, de forma que consiga acessar cada dado durante a execução.

Em linguagens com exceções, o ambiente de tempo de execução também participa da propagação de erros. Se uma exceção for lançada, o sistema precisa saber como desempilhar ativações, procurar tratadores apropriados e restaurar um estado consistente. Em linguagens com coleta de lixo, o runtime ainda precisa rastrear objetos acessíveis e administrar a memória dinâmica.

Esses mecanismos não pertencem apenas a compiladores tradicionais. Máquinas virtuais como as da JVM e do .NET mantêm frames, pilhas, heaps e bibliotecas de runtime próprios. Interpretadores como o de Python também organizam frames de execução para funções e chamadas aninhadas. Assim, embora a implementação varie, a ideia de ambiente de tempo de execução atravessa quase todas as plataformas de programação modernas.

A forma como esse ambiente é modelado influencia diretamente a representação intermediária, a geração de código e até as oportunidades de otimização. Por isso, entender runtime não é um detalhe periférico: é parte essencial de como um compilador produz programas corretos e executáveis.

19.6 - Representação Intermediária.

A representação intermediária de código desempenha um papel crucial no processo de compilação, atuando como uma ponte entre o código-fonte de alto nível e o código de máquina final. Ela é uma forma intermediária que captura a essência semântica do código-fonte original e facilita a otimização e a geração de código eficiente. Esse conceito é uma peça fundamental dos compiladores modernos, pois permite que as etapas subsequentes do processo de compilação sejam mais organizadas, eficientes e passíveis de manutenção.

A principal função da representação intermediária é fornecer uma forma mais simplificada e padronizada do código-fonte original, tornando mais fácil a análise, transformação e otimização. Ela é criada durante a etapa de análise sintática e semântica do compilador, quando o código-fonte é analisado e estruturado em uma forma mais compreensível para a máquina.

Existem diferentes formas de representação intermediária, cada uma com suas próprias características e finalidades. Algumas das representações intermediárias comuns incluem:

  1. Árvores Sintáticas Abstratas (AST): As ASTs capturam a estrutura hierárquica do código-fonte, eliminando detalhes irrelevantes e deixando apenas a estrutura lógica do programa. Isso facilita a análise semântica e a geração subsequente de código.

  2. Representação de Três Endereços: Nessa representação, as instruções são expressas em uma forma mais simples, normalmente com três operandos. Isso torna a análise de código mais direta e facilita a aplicação de otimizações.

  3. Código de Três Endereços: É uma extensão da representação de três endereços, onde as instruções são expressas com operações e operandos, semelhantes às instruções de uma linguagem de montagem simplificada.

  4. Representações de Fluxo de Controle: Algumas representações intermediárias focam na estrutura de controle do programa, capturando fluxos de execução, condições e ciclos.

A representação intermediária permite que o compilador realize diversas otimizações, como otimização de expressões, eliminação de código morto, redução de redundâncias e reorganização de instruções. Além disso, simplifica a geração de código de máquina, já que a partir da representação intermediária é possível criar instruções específicas para a arquitetura de destino.

19.7 - Análise Semântica.

Nesta fase o analisador semântico utiliza a árvore sintática gerada na fase anterior (análise sintática) para avaliar a consistência semântica do programa com base nas definições da linguagem. A checagem de tipos, por exemplo, faz parte desse processo (verificar se um vetor é indexado por inteiros, que a atribuição a um tipo char tem o tamanho correto, conversões de tipo (float para int ou vice-versa), etc…).

19.8 - Geração de Código.

O gerador de código recebe como entrada uma representação intermediária do programa fonte e produz o código na linguagem de destino. Se essa linguagem é em código de máquina, se faz necessário definir registradores e endereços de memória para armazenar os dados do programa. Um exemplo de código Assembly, intermediário, que será traduzido em linguagem de máquina está abaixo:

            LDF   R2, id3
            MULF  R2, R2, #60.0
            LDF   R1, id2
            ADDF  R1, R1, R2
            STF   id1, R1
        

Decisões sobre armazenamento das variáveis podem ocorrer tanto durante a geração de código intermediário ou na geração de código.

19.9 - Otimização de Código.

Nesta fase há uma tentativa de melhorar o código gerado para a arquitetura escolhida. Uma otimização escolhida pode ser a velocidade, ou código menor, que utiliza menos CPU, etc… Esse processo pode ser bem custoso e lento, com resultados importantes para o código final no quesito performance em geral. Inclusive, muitas opções podem ser utilizadas diretamente na linha de comando para experimentar com alternativas de compilação, tendo resultados expressivos sem precisar alterar o código original.

Um dos principais problemas do novo código gerado é saber como ele se compara com outras possibilidades. Obviamente há infinitas formas de reorganizar o programa, mas apenas algumas que vão cumprir os requisitos definidos antecipadamente. A questão então é como definir quais caminhos seguir em determinado arranjo de palavras em um código para que seja mais eficiente quando executado? Outro problema é sobre a execução em máquinas com múltiplos processadores e compartilhamento de memória com GPUs? O rigor matemático nos ajuda a investigar essa questão e podemos utilizar grafos, matrizes e programação linear para produzir código bem otimizado. Porém, há ainda muitos problemas indecidíveis.

Alguns objetivos básicos devem ser seguidos pelo processo de otimização:

  • O programa gerado deve ter o mesmo comportamento do original;
  • A otimização não deve piorar a performance anterior; O tempo de compilação deve ser razoável;
  • Para produzir a otimização, o esforço de desenvolvimento deve ser gerenciável.

A pilha de execução armazena o registro de ativação do procedimento sendo executado e quando há chamada para outro procedimento, o primeiro fica em suspensão até o término da execução daquele que foi chamado. A árvore de ativação contém a sequência de chamadas para controle e transferência do procedimento em execução.

19.10 - Bibliotecas e Compilação em Separado.

Quando incluímos arquivos externos em um programa o compilador precisa tomar algumas ações para localizar essas referências e incluí-las de maneira otimizada no código, analisando se de fato há chamadas que utilizam o que foi incluído nas diretivas. Esse processo de referenciar arquivos externos leva ao conceito de modularização de código e também indica ao compilador como efetuar a compilação dos arquivos de forma separada gerando um mesmo arquivo ou vários. Esses módulos podem então ser utilizados para compor o programa principal.

As bibliotecas são conjuntos de funções ou classes com métodos utilizados de maneira recorrente e que podem ser reaproveitados, ou seja, não precisam ser codificados novamente pelo desenvolvedor.

A compilação de arquivos em separado está na base dos ambientes de desenvolvimento modernos com diversos projetos dentro de uma solução que os agrupa. Para referenciar um projeto no outro, por exemplo em C#, precisamos compilá-lo, gerando um arquivo .dll (Dynamic-link Library) que pode então ser copiado para a pasta de referencias externa, debug ou release, dependendo do tipo de execução do código.

Referências:

  1. Compilers : Principles, Techniques, Tools - Alfred V. Aho, Monica S. Lam, Ravi Sethi, et al.
  2. Introdução ao software de sistemas - Ivan Luiz Marques Ricarte
  3. Introdução à computação - Jorge Muniz Barreto
  4. Compiladores - Judson Santiago