Introdução

Propaganda
Compiladores – 1
Introdução
Linguagens de programação são notações para se descrever computações para
pessoas e para máquinas. Todo software executado em todos os computadores foi
escrito em alguma linguagem de programação.
As linguagens de programação podem ser classificadas em cinco gerações:
1ª) Linguagens de máquina: permitem a comunicação direta com o computador em
termos de bits, registradores e operações de máquina bastante primitivas.
2ª) Linguagens simbólicas (Assembly): projetadas para minimizar as dificuldades da
programação em notação binária.Utiliza mnemônicos.
3ª) Linguagens orientadas ao usuário (Fortran, Pascal, Basic, etc): linguagens
procedurais, isto é, um programa especifica uma sequência de passos a serem
seguidos para solucionar o problema, e linguagens declarativas que se baseiam na
teoria das funções recursivas (funcionais) e de lógica matemática (lógicas).
4ª) Linguagens orientadas à aplicação (Excel, SQL, Framework, etc): visam facilitar a
programação de computadores, apressar o processo de desenvolvimento de
aplicações, facilitar a manutenção de aplicações, reduzindo custos, minimizar
problemas de depuração e gerar códigos sem erros a partir de requisitos de
expressões de alto nível.
5ª) Linguagens de conhecimento: usadas principalmente na área de Inteligência
Artificial. Facilitam a representação do conhecimento que é essencial para a
simulação de comportamentos inteligentes.
Mas, antes que possa rodar, um programa primeiro precisa ser traduzido para um
formato que lhe permita ser executado por um computador.
Os sistemas de software que fazem essa tradução são denominados
compiladores.
Compiladores são programas de computador que traduzem de uma linguagem
para outra. Um programa recebe como entrada um programa escrito na linguagem-fonte
e produz um programa equivalente na linguagem-alvo.
Geralmente, a linguagem-fonte é uma linguagem de alto nível, como C ou C++,
e a linguagem-alvo é um código-objeto (código de máquina) para a máquina-alvo.
Programa fonte
Programa objeto
COMPILADOR
Tradutores de Linguagens de Programação
Os tradutores de linguagens de programação podem ser classificados em:
Montadores (assemblers): é um tradutor para a linguagem de montagem
(Assembly) de um computador em particular. Geralmente uma instrução de
linguagem simbólica (de montagem) para uma instrução de máquina. Por vezes um
compilador irá gerar uma linguagem de montagem como sua linguagem-alvo e, em
seguida, contar com um montador para concluir a tradução para o código-objeto.
Compiladores: são tradutores que mapeiam programas escritos em linguagem de
alto nível para programas equivalentes em linguagem simbólica ou de máquina.
2 – Introdução
Pré-compiladores (pré-processadores): são processadores que mapeiam instruções
escritas numa linguagem de alto nível estendida para instruções da linguagem de
programação original, ou seja, são tradutores que efetuam conversões entre duas
linguagens de alto-nível.
É um programa separado, ativado pelo compilador antes do início da tradução. Ele
pode apagar comentários, incluir outros arquivos e executar substituições de macros.
Interpretadores: é um tradutor de linguagens, assim como um compilador. A
diferença é que o interpretador executa o programa-fonte de imediato, em vez de
gerar um código-objeto que seja executado após o término da tradução. Os
interpretadores processam uma forma intermediária do programa fonte e dados ao
mesmo tempo. A interpretação da forma interna do fonte ocorre em tempo de
execução, não sendo gerado um programa objeto.
O intervalo de tempo no qual ocorre a conversão de um programa fonte para um
programa objeto é chamado de tempo de compilação.
O programa objeto é executado no intervalo de tempo chamado tempo de
execução.
O programa fonte e os dados são processados em momentos distintos,
respectivamente, tempo de compilação e tempo de execução.
Os interpretadores são, geralmente, menores que os compiladores e facilitam a
implementação de construções complexas de linguagens de programação.
O tempo de execução de um programa interpretado é maior que o tempo
necessário para executar um programa compilado equivalente. O código objeto
compilado é dez ou mais vezes mais rápido que o código fonte interpretado.
A estrutura de um tradutor
Os tradutores de linguagens de programação (compiladores, interpretadores) são
constituídos internamento por passos (fases) para operações lógicas distintas.
No processo de tradução existem duas partes: análise e síntese.
A análise (front-end) subdivide o programa fonte em partes constituintes e
impõe uma estrutura gramatical sobre elas. Depois, usa essa estrutura para criar uma
representação intermediária do programa fonte. A análise detecta má formação sintática
e erros semânticos, oferecendo mensagens esclarecedoras, para que o usuário possa
tomar a ação corretiva. A análise também coleta informações sobre o programa fonte e
as armazena em uma estrutura de dados chamada tabela de símbolos.
A síntese (back-end) constrói o programa objeto a partir da representação
intermediária e das informações na tabela de símbolos.
Análise Léxica
O objetivo desta fase é identificar sequências de caracteres que constituem
unidades léxicas (tokens, lexemas).
O analisador léxico lê o fluxo de caracteres que compõem o programa fonte e os
agrupa em sequências significativas (lexemas).
O analisador léxico verifica se os caracteres lidos pertencem ao alfabeto da
linguagem, identificando tokens e desprezando comentários e brandos desnecessários.
Os tokens constituem classes de símbolos tais como palavras reservadas,
delimitadores, identificadores, etc.
Para cada lexema, o analisador léxico produz como saída um token no formato:
<nome-token, valor-atributo>
que ele passa para a fase de análise sintática. Palavras reservadas, operadores e
delimitadores são representados pelos próprios símbolos.
Compiladores – 3
O analisador léxico, em geral, inicia a construção da tabela de símbolos e envia
mensagens de erro caso identifique unidades léxicas não aceitas pela linguagem em
questão.
Exemplo 1: Suponha que um programa fonte contenha o comando de atribuição
position = initial + rate * 60
Os caracteres nessa atribuição poderiam ser agrupados nos seguintes lexemas e
mapeados para os seguintes tokens passados ao analisador sintático:
Lexema
Símbolo
Significado
Entrada Token
position
id
<id, 1>
identificador 1
=
<=>
initial
id
<id, 2>
identificador 2
+
<+>
rate
id
<id, 3>
identificador 3
*
<*>
60
número
4
<número, 4>
inteiro
Após a análise léxica a representação do comando de atribuição fica como uma
sequência de tokens:
<id, 1> <=> <id, 2> <+> <id, 3> <*> <número, 4>
Exemplo 2: Considere a linha de código a seguir, que poderia pertencer a um programa
em C.
a [index] = 4 + 2
Esse código contém 12 caracteres diferentes de espaço, mas somente 8 marcas:
Lexema Símbolo
Significado
Entrada Token
a
id
<id, 1>
identificador 1
[
<[>
index
id
<id, 2>
identificador 2
]
<]>
4 – Introdução
Lexema Símbolo
Significado
Entrada Token
=
<=>
4
número
3
<número, 3>
inteiro
+
<+>
2
número
4
<número, 4>
inteiro
Os caracteres nessa atribuição poderiam ser mapeados para os seguintes tokens
passados para o analisador sintático:
<id, 1> <[> <id, 2> <]> <=> <número, 3> <+> <número, 4>
Exemplo 3: Seja o seguinte texto fonte em Pascal.
while i < 100 do i := j + i ;
Os caracteres nessa atribuição poderiam ser mapeados para os seguintes tokens
passados para o analisador sintático:
Lexema Símbolo
Significado
Entrada Token
while
<while>
i
id
<id, 1>
identificador 1
<
<<>
100
número
1
<número, 1>
inteiro
do
<do>
i
id
1
<id, 1>
identificador
:=
<:=>
j
id
<id, 2>
identificador 2
+
<+>
i
id
<id, 1>
identificador 1
;
<;>
Após a análise léxica da instrução ter-se ia a seguinte cadeia de tokens:
<while> <id, 1> <<> <número, 1> <do> <id, 1> <:=> <id, 2> <+> <id, 1> <;>
Análise sintática
Essa fase tem por função verificar se a estrutura gramatical do programa está
correta.
O analisador sintático utiliza os primeiros componentes dos tokens produzidos
pelo analisador léxico para criar uma representação intermediária tipo árvore (árvore de
derivação), que mostra a estrutura gramatical da sequência de tokens.
Outra função dos reconhecedores sintáticos é a detecção de erros de sintaxe
identificando clara e objetivamente a posição e o tipo de erro percorrido. O analisador
sintático deve tentar recuperar os erros encontrados, prosseguindo a análise do texto
restante.
Exemplo 1: Essa árvore mostra a ordem em que as operações do comando de atribuição
position = initial + rate * 60
deve ser realizada.
=
id, 1
+
id, 2
=
id, 3
Número, 4
Compiladores – 5
Exemplo 2: Considere a linha de código em C
a [index] = 4 + 2
que representa um elemento estrutural denominado expressão (uma expressão de
atribuição), composta por uma expressão indexada à esquerda e uma expressão
aritmética de inteiros à direita.
Esta estrutura pode ser representada em uma árvore de análise sintática (árvore
sintática) da seguinte forma:
expressão
expressão de atribuição
expressão
=
expressão
expressão indexada
expressão
[
id, 1
expressão
Expressão aritmética
]
expressão
id, 2
+
expressão
Número, 3
Número, 4
Exemplo 3: Considere o seguinte comando Pascal.
while <expressão> do <comando>;
Nesse caso, a estrutura <expressão> deve apresentar-se sintaticamente correta, e
sua avaliação deve retornar um valor do tipo lógico.
Considerando o comando while do exemplo, o analisador sintático produzirá a
árvore de derivação a partir da sequência de tokens liberada pelo analisador léxico.
<while>
<expressão>
<id, 1>
<<>
<número, 1>
<do>
<comando>
<id, 1>
<:=>
<+>
<id, 3>
<id, 2>
Análise semântica
É o significado de um programa, contrastando com sua sintaxe ou estrutura.
O analisador semântico utiliza a árvore de sintaxe e as informações na tabela de
símbolos para verificar a consistência semântica do programa fonte com a definição da
linguagem. Ele também reúne informações sobre os tipos e as salva na árvore de sintaxe
ou tabela de símbolos, para uso subsequente durante a geração de código intermediário.
Na verificação de tipo o compilador verifica se cada operador possui operandos
compatíveis.
A especificação da linguagem pode permitir algumas conversões de tipos
chamada coerções. Por exemplo, se um operador for aplicado a um número de ponto
6 – Introdução
flutuante e a um inteiro, o compilador pode converter ou coagir o inteiro para um
número de ponto flutuante.
Exemplo 1: No exemplo da expressão em C
a[index] = 4+2
as informações de tipos típicas que poderiam ser obtidas antes de analisar essa linha
seriam:
a é um vetor de valores inteiros com índices de um intervalo de inteiros
index é uma variável de inteiros
O analisador semântico anota na árvore sintática com os tipos de todas as
subexpressões e verifica se as atribuições fazem sentido para esses tipos, caso contrário
declara um erro de divergência entre tipos.
expressão de atribuição
expressão indexada
inteiro
identificador
a
vetor de
inteiros
expressão de adição
inteiro
identificador
index
número
4
inteiro
inteiro
número
2
inteiro
Exemplo 2: Suponha:
position = initial + rate * 60
que position, initial e rate foram declaradas como números de ponto flutuante, e que o
lexema 60 tenha a forma de um inteiro.
O verificador de tipos no analisador semântico descobre que o operador * é
aplicado a um número de ponto flutuante rate e a um inteiro 60. Nesse caso, o inteiro
pode ser convertido em um número de ponto flutuante.
=
id, 1
+
id, 2
=
id, 3
intfloat
Geração de código intermediário
60
No processo de traduzir um programa fonte para um código objeto, um
compilador pode produzir uma ou mais representações intermediárias. As árvores de
sintaxe denotam uma forma de representação intermediária.
Esta fase gera como saída uma sequência de código, que pode, eventualmente,
ser o código objeto final, mas, na maioria das vezes, constitui-se num código
intermediário.
A representação intermediária explícita de baixo nível (linguagem de máquina) é
um programa para uma máquina abstrata. Essa representação intermediária deve ter
duas propriedades importantes:
Compiladores – 7
ser facilmente produzida e
ser facilmente traduzida para a máquina alvo.
A tradução de código fonte para objeto em mais de um passo apresenta algumas
vantagens:
possibilita a otimização de código intermediário, de modo a obter-se o código objeto
final mais eficiente;
resolve, gradualmente, as dificuldades da passagem de código fonte para código
objeto (alto nível para baixo nível), já que o código fonte pode ser visto como um
texto condensado que explode em inúmeras instruções elementares de baixo nível.
Exemplo 1: Para o comando de atribuição
position = initial + rate * 60
o gerador de código intermediário, recebendo a árvore de derivação, produziria a
seguinte sequência de instruções:
t1 = inttofloat(60)
t2 = id3 * t1
t3 = id2 + t2
id1 = t3
Consideramos uma forma intermediária, chamada código de três endereços, que
consiste em uma sequência de instruções do tipo assembler com três operandos por
instrução. Cada operando pode atuar como um registrador. A saída do gerador de
código intermediário consiste em uma sequência de instruções ou código de três
endereços.
Exemplo 2: Para o comando while apresentado anteriormente, o gerador de código
intermediário, recebendo a árvore de derivação, poderia produzir a seguinte sequência
de instruções:
L0
if i < 100 goto L1
goto L2
L1
t := j + i
i := t
goto L0
L2
...
Há vários tipos de código intermediário: quádruplas, triplas, notação polonesa
pós-fixada, etc. A linguagem intermediária do exemplo acima é chamada código de três
endereços (cada instrução tem no máximo três operandos).
Exemplo 3: Um código de três endereços para a expressão a[index] = 4+2 em C
poderia ficar assim:
t = 4 + 2
a[index] = t
Observe o uso de uma variável temporária adicional t para armazenar o
resultado intermediário.
Vários pontos precisam ser observados em relação aos códigos de três
endereços:
1º) Cada instrução de atribuição de três endereços possui no máximo um operador do
lado direito. Assim, essas instruções determinam a ordem em que as operações
devem ser realizadas.
2º) O compilador precisa gerar um nome temporário para guardar o valor computador
por uma instrução de três endereços.
3º) Algumas instruções de três endereços possuem menos de três operandos.
8 – Introdução
Otimização de código
Tem por objetivo otimizar o código intermediário em termos de velocidade de
execução e espaço de memória.
Esta fase independente das arquiteturas de máquina faz algumas transformações
no código intermediário com o objetivo de produzir um código objeto melhor.
Melhor significa mais rápido, código menor, que consuma menos energia.
Exemplo 1: O otimizador de código melhoraria o código
a[index] = 4 + 2
em dois passos, inicialmente computando o resultado da adição
t = 6
a[index] = t
e depois substituindo t por seu valor, para obter a declaração de três endereços
a[index] = 6
Exemplo 2: Na atribuição
position = initial + rate * 60
o otimizador pode deduzir que a conversão do valor inteiro para ponto flutuante pode
ser feita de uma vez por todas durante a compilação. Além do mais, t3 é usado apenas
uma vez na atribuição de seu valor para id1, portanto o otimizador pode eliminá-lo
transformando em uma sequência de código menor.
t1 = id3 * 60.0
id1 = id2 + t1
Exemplo 3: Considerando o código intermediário do exemplo do comando while, o
código otimizado poderia ser:
L0
if i
100 goto L2
i := j + i
goto L0
L2
...
O número de otimizações de código realizadas por diferentes compiladores varia
muito. Quanto mais otimizações, mais tempo é gasto nessa faze. Existem otimizações
simples que melhoram significativamente o tempo de execução do programa objeto sem
atrasar muito a compilação.
Geração de código
Tem como objetivos: produção de código objeto, reserva de memória para
constantes e variáveis, seleção de registradores, etc. Um aspecto crítico da geração de
código está relacionado à cuidadosa atribuição dos registradores às variáveis do
programa.
Nessa fase da compilação as propriedades da máquina-alvo tornam-se o fator
principal.
Exemplo 1: Para a expressão em C,
a [index] = 4 + 2
precisamos decidir como armazenar inteiros para gerar o código de indexação de
matrizes. Uma sequência de código possível para a expressão dada poderia ser:
MOV R0, index
//
valor de índex → R0
MUL R0, 2
//
dobra valor em R0
MOV R1, &a
//
endereço de a → R1
ADD R1, R0
//
adiciona R0 a R1
MOV *R1, 6
//
constante 6 → endereço em R1
Compiladores – 9
&a é o endereço de a, o endereço inicial da matriz. *R1 significa o
endereçamento indireto de registro, a última instrução armazena o valor 6 no endereço
contido em R1.
Nesse código assumimos que a máquina efetua endereçamento de bytes e que
inteiros ocupam dois bytes de memória.
No código alvo são possíveis diversas melhorias.
Usar a instrução de deslocamento para substituir a multiplicação na segunda
instrução
Usar um modo de endereçamento indexado para armazenar a matriz.
Com essas duas otimizações o código-alvo fica assim:
MOV R0, index //
valor de índex → R0
SHL R0
//
dobra valor em R0
MOV &a [R0], 6 //
constante 6 → endereço a + R0
Exemplo 2: Na atribuição
position = initial + rate * 60
usando os registradores R1 e R2, o código intermediário poderia ser traduzido para o
código de máquina
LDF
R2, id3
// carrega o conteúdo do endereço id3 no registrador R2
MULF
R2, R2, #60.0
// multiplica pela constante de ponto flutuante 60.0
LDF
R1, id2
// move id2 para o registrador R1
ADDF
R1, R1, R2
// soma o valor contido no registrador R1 com o valor previamente
calculado no registrador R2
STF
id1, R1
// o valor no registrador R1 é armazenado no endereço id1
O F diz que a instrução manipula números de ponto flutuante. O # significa que
o valor 60.0 deve ser tratado como uma constante imediata.
A geração de código ignorou a questão relativa à alocação de espaço na memória
para os identificadores do programa fonte. A organização de memória em tempo de
execução depende da linguagem sendo compilada. Decisões sobre a alocação de espaço
podem ser tomadas em dois momentos: durante a geração de código intermediário ou
durante a geração do código.
Exemplo 3: A partir do código intermediário otimizado para o comando while
apresentando como exemplo, obter-se-ia o código objeto final baseado na linguagem
simbólica de um microprocessador PC 8086.
L0
MOV AX, i
// move o conteúdo do endereço i para o registrador AX
CMP AX, 100
// compara 100 com o conteúdo do registrador AX
JGE L2
// faz um salto condicional para L2
MOV AX, j
// move o conteúdo do endereço j para o registrador AX
MOV BX, i
// move o conteúdo do endereço i para o registrador BX
ADD BX
// adiciona o conteúdo do registrador BX
MOV i, AX
// move o conteúdo do registrador AX para o endereço i
JMP L0
// faz um salto incondicional para L0
L2
...
Exercícios
1. No contexto de implementação de linguagem de programação, dê o significado dos
seguintes termos: compilador, interpretador, montador e pré-compilador.
2. Aponte vantagens e desvantagens dos interpretadores em relação aos compiladores.
3. Explique o processo de compilação: fases e seu inter-relacionamento.
4. Dada a declaração em C
a[i+1] = a[i] + 2
10 – Introdução
desenhe uma árvore de análise sintática e uma árvore sintática para a expressão.
5. Por exemplo, o código em C
x = 4;
y = x + 2;
faça a otimização.
Bibliografia
Compiladores: princípios, técnicas e ferramentas
Aho, Alfred. Lam, Monica. Sethi, Ravi. Ullman, Jeffrey D.
São Paulo: Pearson Addison-Wesley, 2008.
Compiladores: princípios e práticas
Louden, Kenneth C.
São Paulo: Pioneira Thomson Learning, 2004
Implementação de linguagens de programação: Compiladores
Price, Ana. Toscani, Simão
Porto Alegre: Bookman: Instituto de Informática da UFRGS, 2008.
Download