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.