Compilador - Geração de código intermediário

Um código-fonte pode ser traduzido diretamente em seu código de máquina-alvo, então por que precisamos traduzir o código-fonte em um código intermediário que é então traduzido para seu código-alvo? Vejamos os motivos pelos quais precisamos de um código intermediário.

  • Se um compilador traduz a linguagem fonte para a linguagem da máquina de destino sem ter a opção de gerar código intermediário, então, para cada nova máquina, um compilador nativo completo é necessário.

  • O código intermediário elimina a necessidade de um novo compilador completo para cada máquina exclusiva, mantendo a parte de análise igual para todos os compiladores.

  • A segunda parte do compilador, síntese, é alterada de acordo com a máquina de destino.

  • Torna-se mais fácil aplicar as modificações do código-fonte para melhorar o desempenho do código, aplicando técnicas de otimização de código no código intermediário.

Representação Intermediária

Os códigos intermediários podem ser representados de várias maneiras e têm seus próprios benefícios.

  • High Level IR- A representação do código intermediário de alto nível é muito próxima da linguagem de origem. Eles podem ser gerados facilmente a partir do código-fonte e podemos facilmente aplicar modificações de código para melhorar o desempenho. Mas para otimização de máquina alvo, é menos preferido.

  • Low Level IR - Este está próximo da máquina de destino, o que o torna adequado para alocação de registro e memória, seleção de conjunto de instruções, etc. É bom para otimizações dependentes de máquina.

O código intermediário pode ser específico da linguagem (por exemplo, Byte Code para Java) ou independente da linguagem (código de três endereços).

Código de três endereços

O gerador de código intermediário recebe entrada de sua fase predecessora, o analisador semântico, na forma de uma árvore de sintaxe anotada. Essa árvore de sintaxe pode então ser convertida em uma representação linear, por exemplo, notação pós-fixada. O código intermediário tende a ser um código independente de máquina. Portanto, o gerador de código assume que existe um número ilimitado de armazenamento de memória (registro) para gerar o código.

Por exemplo:

a = b + c * d;

O gerador de código intermediário tentará dividir esta expressão em subexpressões e então gerar o código correspondente.

r1 = c * d;
r2 = b + r1;
a = r2

r sendo usado como registrador no programa de destino.

Um código de três endereços tem no máximo três localizações de endereço para calcular a expressão. Um código de três endereços pode ser representado em duas formas: quádruplos e triplos.

Quádruplos

Cada instrução na apresentação quádrupla é dividida em quatro campos: operador, arg1, arg2 e resultado. O exemplo acima é representado abaixo em formato quádruplo:

Op arg 1 arg 2 resultado
* c d r1
+ b r1 r2
+ r2 r1 r3
= r3 uma

Triplos

Cada instrução na apresentação tripla tem três campos: op, arg1 e arg2. Os resultados das respectivas subexpressões são denotados pela posição da expressão. Os triplos representam semelhanças com o DAG e a árvore de sintaxe. Eles são equivalentes ao DAG enquanto representam expressões.

Op arg 1 arg 2
* c d
+ b (0)
+ (1) (0)
= (2)

Os triplos enfrentam o problema da imobilidade do código durante a otimização, pois os resultados são posicionais e a alteração da ordem ou posição de uma expressão pode causar problemas.

Triplos indiretos

Esta representação é um aprimoramento da representação tripla. Ele usa ponteiros em vez de posição para armazenar os resultados. Isso permite que os otimizadores reposicionem livremente a subexpressão para produzir um código otimizado.

Declarações

Uma variável ou procedimento deve ser declarado antes de poder ser usado. A declaração envolve a alocação de espaço na memória e a entrada do tipo e nome na tabela de símbolos. Um programa pode ser codificado e projetado mantendo a estrutura da máquina-alvo em mente, mas nem sempre é possível converter com precisão um código-fonte em sua linguagem-alvo.

Tomando todo o programa como uma coleção de procedimentos e subprocedimentos, torna-se possível declarar todos os nomes locais para o procedimento. A alocação de memória é feita de maneira consecutiva e os nomes são alocados na memória na seqüência em que são declarados no programa. Usamos a variável de deslocamento e definimos como zero {deslocamento = 0} que denota o endereço base.

A linguagem de programação de origem e a arquitetura da máquina de destino podem variar na maneira como os nomes são armazenados, portanto, o endereçamento relativo é usado. Enquanto o primeiro nome é memória alocada a partir do local de memória 0 {deslocamento = 0}, o próximo nome declarado posteriormente, deve ser alocado em memória próximo ao primeiro.

Example:

Pegamos o exemplo da linguagem de programação C onde uma variável inteira é atribuída a 2 bytes de memória e uma variável float é atribuída a 4 bytes de memória.

int a;
float b;

Allocation process:
{offset = 0}

   int a;
   id.type = int
   id.width = 2

offset = offset + id.width 
{offset = 2}

   float b;
   id.type = float
   id.width = 4
   
offset = offset + id.width 
{offset = 6}

Para inserir este detalhe em uma tabela de símbolos, um procedimento enter pode ser usado. Este método pode ter a seguinte estrutura:

enter(name, type, offset)

Este procedimento deve criar uma entrada na tabela de símbolos, para o nome da variável , tendo seu tipo configurado para tipo e relativo deslocamento do endereço em sua área de dados.