• Nenhum resultado encontrado

Construindo Compiladores 2ª Edição (Cooper, Keith)

N/A
N/A
Protected

Academic year: 2021

Share "Construindo Compiladores 2ª Edição (Cooper, Keith)"

Copied!
659
0
0

Texto

(1)
(2)
(3)

Tradução

Daniel Vieira

Revisão Técnica

Edson Senne

(4)

Tradução autorizada do idioma inglês da edição publicada por Morgan Kaufmann, um selo editorial Elsevier.

Todos os direitos reservados e protegidos pela Lei n° 9.610, de 19/02/1998.

Nenhuma parte deste livro, sem autorização prévia por escrito da editora, poderá ser reproduzida ou transmitida sejam quais forem os meios empregados: eletrônicos, mecânicos, fotográficos, gravação ou quaisquer outros.

A imagem de capa: “The Landing of the Ark”, uma abóbada cuja iconografia foi narrada, idea-lizada e desenhada por John Outram da John Outram Associates, Architects and City Planners, Londres, Inglaterra. Para saber mais, visite www.johnoutram.com/rice.html.

Copidesque: Bel Ribeiro

Editoração Eletrônica: Thomson Digital

Revisão: Casa Editorial BBM Elsevier Editora Ltda. Conhecimento sem Fronteiras Rua Sete de Setembro, 111 – 16° andar

20050-006 – Centro – Rio de Janeiro – RJ – Brasil Rua Quintana, 753 – 8° andar

04569-011 – Brooklin – São Paulo – SP Serviço de Atendimento ao Cliente 0800-026-5340

atendimento1@elsevier.com

ISBN: 978-85-352-5564-5

ISBN (versão digital): 978-85-352-5565-2 ISBN (edição original): 978-0-12-088478-0

Nota: Muito zelo e técnica foram empregados na edição desta obra. No entanto, podem ocorrer

erros de digitação, impressão ou dúvida conceitual. Em qualquer das hipóteses, solicitamos a comunicação ao nosso Serviço de Atendimento ao Cliente, para que possamos esclarecer ou encaminhar a questão.

Nem a editora nem o autor assumem qualquer responsabilidade por eventuais danos ou perdas a pessoas ou bens, originados do uso desta publicação.

CIP-BRASIL. CATALOGAÇÃO NA PUBLICAÇÃO SINDICATO NACIONAL DOS EDITORES DE LIVROS, RJ

C788c 2. ed.

Cooper, Keith D.

Construindo compiladores / Keith D. Cooper, Linda Torczon ; tradução Daniel Vieira. - 2. ed. - Rio de Janeiro : Elsevier, 2014.

28 cm.

Tradução de: Engineering a compiler ISBN 978-85-352-5564-5

1. Compiladores (Computadores). 2. Tecnologia. I. Torczon, Linda. II. Título.

13-03454 CDD: 005.453

(5)

A Segunda Edição de Construindo Compiladores é uma excelente introdução à construção de compiladores otimizadores modernos. Os autores a partir de uma vasta experiência na cons-trução de compiladores, ajudam os alunos a compreender o quadro geral, enquanto, ao mesmo tempo, os orientam por muitos detalhes importantes e sutis que precisam ser enfrentados para a construção de um compilador otimizador eficaz. Particularmente, este livro contém a melhor introdução ao formato de atribuição única estática que já vi.

Jeffery von Ronne Professor Assistente Departamento de Ciência da Computação Universidade do Texas, San Antonio Construindo Compiladores aumenta seu valor como livro-texto com uma estrutura mais regular e consistente, e uma série de recursos de instrução: perguntas de revisão, exemplos extras, notas em destaque e na margem. Também inclui diversas atualizações técnicas, incluindo mais sobre linguagens não tradicionais, compiladores do mundo real e usos não tradicionais da tecnologia de compiladores. O material sobre otimização – já uma assinatura forte – tornou-se ainda mais acessível e claro.

Michael L. Scott Professor do Departamento de Ciência da Computação da Universidade de Rochester Autor de Programming Language Pragmatics

Keith Cooper e Linda Torczon apresentam um tratamento efetivo da história e também um ponto de vista do profissional sobre como os compiladores são desenvolvidos. A apresentação da teoria, bem como de exemplos práticos do mundo real de compiladores existentes (com, LISP, FORTRAN etc.), incluem diversas discussões e ilustrações eficazes. A discussão completa dos conceitos, tanto introdutórios quanto avançados, de “alocação” e “otimização” abrangem um “ciclo de vida” eficaz da engenharia de compiladores. Este texto deve estar em toda prateleira de alunos de ciência da computação, e também na de profissionais envolvidos com a engenharia e o desenvolvimento de compiladores.

David Orleans Nova Southeastern University

(6)

Keith D. Cooper é Professor Doerr de Engenharia da Computação na Rice University. Trabalhou

com uma grande coleção de problemas em otimização de código compilado, incluindo análise de fluxo de dados interprocedural e suas aplicações, numeração de valores, reassociação algébrica, alocação de registradores e escalonamento de instruções. Seu trabalho recente tem se concentrado em um reexame fundamental da estrutura e do comportamento de compiladores tradicionais. Tem lecionado em diversos cursos em nível de graduação, desde introdução à programação até otimização de código em nível de pós-graduação. É associado da ACM (Association for Computing Machinery).

Linda Torczon, Cientista de Pesquisa Sênior do Departamento de Ciência da Computação na

Rice University, é a principal investigadora do projeto PACE (Platform-Aware Compilation Environment), patrocinado pela DARPA, que está desenvolvendo um ambiente de compilador otimizador que ajusta automaticamente suas otimizações e estratégias a novas plataformas. De 1990 a 2000, a Dra. Torczon trabalhou como diretora executiva do Center for Research on Parallel Computation (CRPC), um Centro de Ciência e Tecnologia da National Science Foundation. Também trabalhou como diretora executiva da HiPerSoft, do Instituto de Ciência da Computação de Los Alamos e do projeto Virtual Grid Application Development Software (VGrADS).

(7)
(8)

A capa deste livro contém uma parte do desenho “The Landing of the Ark”, que decora o teto do Duncan Hall, na Rice University. Tanto o Duncan Hall quanto seu teto foram projetados pelo arquiteto britânico John Outram. Duncan Hall é uma expressão visível dos temas arquitetônicos, decorativos e filosóficos desenvolvidos durante a carreira de Outram como arquiteto. O teto decorado do salão cerimonial desempenha papel central no esquema decorativo do prédio. Outram inscreveu o teto com um conjunto de ideias significativas – um mito da criação. Expressando essas ideias em um desenho alegórico de tão grande tamanho e intensidade de cores, Outram criou uma indicação que diz aos visitantes que entram no salão que, na realidade, esse prédio não é como outros.

Usando a mesma indicação na capa de Construindo Compiladores, os autores desejam sinalizar que este trabalho contém ideias significativas que estão no centro de sua disciplina. Assim como o prédio de Outram, este volume é o auge de temas intelectuais desenvolvidos durante as carreiras profissionais dos autores. Como o esquema decorativo de Outram, este livro é um mecanismo para comunicar ideias, que, como o teto de Outram, apresenta ideias significativas de novas maneiras. Conectando o projeto e a construção de compiladores com o projeto e a construção de prédios, nossa intenção é transmitir as muitas semelhanças nessas duas atividades distintas. Nossas muitas e longas discussões com Outram nos fizeram conhecer os ideais vitruvianos para a arquitetura: comodidade, firmeza e deleite. Esses ideais aplicam-se a muitos tipos de construção. Seus equivalentes para a construção de compiladores são temas consistentes deste texto: função, estrutura e elegância. A função importa; um compilador que gera código incorreto é inútil. A estrutura importa; o detalhe de engenharia determina a eficiência e a robustez de um compilador. A elegância importa; um compilador bem projetado, em que os algoritmos e estruturas de dados fluem tranquilamente de um passo para outro, pode ser uma expressão de beleza.

Estamos encantados por ter a graça do trabalho de John Outram como capa deste livro. O teto do Duncan Hall é um artefato tecnológico interessante. Outram desenhou o projeto original em uma folha de papel, que foi fotografado e digitalizado em 1200 dpi, gerando aproximadamente 750 MB de dados. A imagem foi ampliada para formar 234 painéis distintos de 0,6 x 2,4 m, criando uma imagem de 16 x 22 m. Os painéis foram impressos em folhas de vinil perfurado usando uma impressora de tinta acrílica de 12 dpi. Essas folhas foram montadas com precisão em ladrilhos acústicos de 0,6 x 2,4 m e afixadas à estrutura de alumínio da abóbada.

(9)

ideias e algoritmos, redescobrindo técnicas mais antigas que eram eficazes, porém quase total-mente esquecidas. A pesquisa recente criou entusiasmo em torno do uso de gráficos cordais na alocação de registradores (ver Seção 13.5.2). Este trabalho promete simplificar alguns aspectos dos alocadores de coloração de grafo. O algoritmo de Brzozowski é uma técnica de minimização de autômatos finitos determinísticos (AFDs) datada do início da década de 1960, mas não foi ensinada em cursos de compiladores por muitos anos (ver Seção 2.6.2). Ele oferece um caminho fácil de uma implementação da construção de subconjuntos para outra que minimiza AFDs. Um curso moderno sobre construção de compiladores poderia incluir essas duas ideias.

Como, então, vamos estruturar um currículo em construção de compiladores de modo que prepare os alunos para entrar nesse campo em constante mudança? Acreditamos que o curso deve oferecer aos alunos o conjunto de habilidades básicas que precisarão para construir novos componentes de compilador e para modificar os existentes. Os alunos precisam entender tanto os conceitos gerais, como a colaboração entre compilador, ligador (linker), carregador (loader) e o sistema operacional incorporado em uma convenção de ligação, como os pequenos detalhes, por exemplo, como o construtor de um compilador poderia reduzir o espaço de código agregado usado pelo código de salvamento de registrador a cada chamada de procedimento.

Mudanças na segunda edição

Esta segunda edição de Construindo Compiladores apresenta duas perspectivas: visões gerais dos problemas na construção de compiladores e discussões detalhadas das alternativas algorítmicas. Na preparação desta edição, focamos na usabilidade do livro, tanto como um livro-texto quanto como uma referência para profissionais. Especificamente,

• Melhoramos o fluxo de ideias para ajudar o aluno que lê o livro de forma sequencial. As introduções dos capítulos explicam sua finalidade, estabelecem os principais conceitos e oferecem uma visão geral de alto nível do assunto ali tratado. Os exemplos foram modi-ficados para oferecer continuidade entre os capítulos. Além disso, cada capítulo começa com um resumo e um conjunto de palavras-chave para auxiliar o usuário que trata este livro como uma referência.

• Acrescentamos análises de seção e perguntas de revisão ao final de cada seção principal. As perguntas de revisão oferecem uma verificação rápida quanto a se o leitor entendeu os principais pontos expostos na seção.

• Movemos as definições dos principais termos para a margem adjacente ao parágrafo, onde são definidas e discutidas pela primeira vez.

• Revisamos o material sobre otimização ao ponto de oferecer uma cobertura mais ampla das possibilidades para um compilador otimizador.

Hoje, o desenvolvimento de compiladores se concentra na otimização e geração de código. É muito mais provável que um construtor de compiladores recém-contratado transfira um gerador de código para um novo processador ou modifique um passo de otimização do que escreva um scanner (analisador léxico) ou um parser (analisador sintático). Um construtor de compiladores

(10)

bem-sucedido precisa estar acostumado com as técnicas de melhores práticas atuais em otimiza-ção, como a construção do formato de atribuição única estática, e em geração de código, como o enfileiramento (pipelining) de software. E também precisam ter a base e o discernimento para entender novas técnicas à medida que aparecerem durante os próximos anos. Finalmente, eles precisam entender bem o suficiente as técnicas de scanning (análise léxica), parsing (análise sintática) e elaboração semântica para construir ou modificar um front end.

Nosso objetivo para este livro tem sido criar um texto e um curso que exponha os alunos às questões críticas dos compiladores modernos e oferecer-lhes uma base para enfrentar esses problemas. Mantivemos, da primeira edição, o equilíbrio básico do material. Os front ends são componentes de consumo; e podem ser comprados de um vendedor confiável ou adaptados de um dos muitos sistemas de código aberto. Ao mesmo tempo, os otimizadores e os geradores de código são preparados para processadores específicos e, às vezes, para modelos individuais, pois o desempenho conta muito com os detalhes específicos de baixo nível do código gerado. Esses fatos afetam o modo como criamos compiladores hoje; e também devem afetar o modo como ensinamos construção de compiladores.

Organização

Este livro divide o material em quatro seções principais praticamente de mesmo tamanho: • A primeira, Capítulos de 2 a 4, aborda tanto o projeto do front end de um compilador

quanto o projeto e a construção de ferramentas para construir front ends.

• A segunda, Capítulos de 5 a 7, explora o mapeamento do código fonte para o formato intermediário do compilador – ou seja, esses capítulos examinam o tipo de código que o front end gera para o otimizador e o back end.

• A terceira, Capítulos de 8 a 10, apresenta o assunto de otimização de código. O Capítulo 8 oferece uma visão geral da otimização. Os Capítulos 9 e 10 contêm tratamentos mais profundos de análise e transformação; estes dois capítulos são muitas vezes omitidos em um curso de graduação.

• A final, Capítulos de 11 a 13, concentra-se nos algoritmos usados no back end do compilador.

A arte e a ciência da compilação

A tradição da construção de compiladores inclui tanto incríveis histórias de sucesso sobre a aplicação da teoria à prática e histórias deprimentes sobre os limites daquilo que podemos fazer. No lado do sucesso, os scanners modernos são construídos pela aplicação da teoria de linguagens regulares à construção automática de reconhecedores. Parsers LR utilizam as mesmas técnicas para realizar o reconhecimento de leques (handles) que controla um analisador sintático do tipo empilha-reduz (shift-reduce parser). A análise de fluxo de dados aplica a teoria dos reticulados à análise de pro-gramas de maneiras inteligentes e úteis. Os algoritmos de aproximação usados na geração de código produzem boas soluções para muitos exemplares de problemas verdadeiramente difíceis.

Por outro lado, a construção de compiladores expõe problemas complexos que desafiam as boas soluções. O back end de um compilador para um processador moderno aproxima a solução para dois ou mais problemas NP-completos interagentes (escalonamento de instruções, alocação de registradores e, talvez, posicionamento de instrução e dados). Esses problemas NP-completos, porém, parecem fáceis perto de problemas como reassociação algébrica de expressões (ver, por exemplo, a Figura 7.1), que admite um número imenso de soluções; para piorar as coisas ainda mais, a solução desejada depende do contexto tanto no compilador quanto no código da aplicação. Como o compilador aproxima as soluções para tais problemas, enfrenta restrições no tempo de compilação e memória disponível. Um bom compilador mistura engenhosamente teoria, conhecimento prático, engenharia e experiência.

Abra um compilador otimizador moderno e você encontrará uma grande variedade de técnicas. Os compiladores usam buscas heurísticas gulosas que exploram grandes espaços de solução, e autômatos finitos determinísticos que reconhecem palavras na entrada. Eles empregam algoritmos de ponto fixo para raciocinar a respeito do comportamento do programa e provadores de teorema simples e simplificadores algébricos para prever os valores das expressões. Compiladores tiram

(11)

Abordagem

A construção de compiladores é um exercício de projeto de engenharia. O construtor de compila-dores precisa escolher um caminho através de um espaço de projeto que é repleto de alternativas diversas, cada uma com diferentes custos, vantagens e complexidade. Cada decisão tem impacto sobre o compilador resultante. A qualidade do produto final depende de decisões bem feitas em cada etapa ao longo do caminho.

Assim, não existe uma única resposta certa para muitas das decisões de projeto em um compi-lador. Até mesmo dentro de problemas “bem entendidos” e “resolvidos” nuances no projeto e implementação têm impacto sobre o comportamento do compilador e a qualidade do código que produz. Muitas considerações devem ser feitas em cada decisão. Como exemplo, a escolha de uma representação intermediária para o compilador tem um impacto profundo sobre o restante do compilador, desde os requisitos de tempo e espaço até a facilidade com que diferentes algo-ritmos podem ser aplicados. A decisão, porém, é tratada muitas vezes com pouca consideração. O Capítulo 5 examina o espaço das representações intermediárias e algumas das questões que devem ser consideradas na seleção de uma delas. Levantamos a questão novamente em diversos pontos no livro – tanto diretamente, no texto, quanto indiretamente, nos exercícios.

Este livro explora o espaço de projeto e transmite tanto a profundidade dos problemas quanto a amplitude das possíveis soluções. Mostra algumas das formas como esses problemas têm sido resolvidos, juntamente com as restrições que tornaram essas soluções atraentes. Os construtores de compiladores precisam entender tanto os problemas quanto suas soluções, além do impacto dessas decisões sobre outras facetas do projeto do compilador. Pois só assim podem fazer es-colhas informadas e inteligentes.

Filosofia

Este texto expõe nossa filosofia para a construção de compiladores, desenvolvida durante mais de vinte e cinco anos, cada um, com pesquisa, ensino e prática. Por exemplo, representações intermediárias devem expor aqueles detalhes que importam no código final; essa crença leva a uma tendência para representações de baixo nível. Os valores devem residir em registradores até que o alocador descubra que não pode mantê-los lá; esta prática produz exemplos que utilizam registradores virtuais e armazenam valores na memória somente quando isso não pode ser evitado. Todo compilador deve incluir otimização; isto simplifica o restante do compilador. Nossas ex-periências durante os anos têm inspirado a seleção de material e sua apresentação.

Um aviso sobre exercícios de programação

Uma aula de construção de compiladores oferece a oportunidade de explorar as questões de projeto de software no contexto de uma aplicação concreta – uma aplicação cujas funções básicas são bem entendidas por qualquer aluno com a base para um curso de construção de compiladores. Em muitas versões deste curso, os exercícios de programação desempenham um grande papel. Ensinamos esta matéria em versões nas quais os alunos constroem um compilador simples do início ao fim – começando com um scanner e parser gerados e terminando com um gerador de código para algum conjunto de instruções RISC simplificado. Ensinamos esta matéria em versões nas quais os alunos escrevem programas que tratam de problemas individuais bem delineados,

(12)

como alocação de registradores ou escalonamento de instruções. A escolha de exercícios de programação depende bastante do papel que o curso desempenha no currículo ao seu redor. Em algumas escolas, o curso de compilador serve como fechamento para veteranos, juntando conceitos de muitos outros cursos em um projeto de concepção e implementação grande e prático. Os alunos nessa turma poderiam escrever um compilador completo para uma linguagem sim-ples ou modificar um compilador de código aberto para acrescentar suporte a um novo recurso da linguagem ou um novo recurso arquitetural. Essa turma poderia estudar o material em uma ordem linear, que segue de perto a organização do texto.

Em outras escolas, essa experiência de fechamento ocorre em outros cursos ou de outras maneiras. Em turmas assim, o professor poderia focar os exercícios de programação de forma mais estreita sobre algoritmos e sua implementação, usando laboratórios como um alocador de registrador local ou um passo de rebalanceamento de altura da árvore. Esse curso poderia fazer saltos no texto e ajustar a ordem da apresentação para atender às necessidades dos laboratórios. Por exemplo, na Rice University, normalmente usamos um alocador de registrador local simples como primeiro laboratório; qualquer aluno com experiência de programação em linguagem assembly entende os fundamentos do problema. Esta estratégia, porém, expõe os alunos ao material do Capítulo 13 antes que vejam o Capítulo 2. Em qualquer cenário, o curso deve utilizar material de outras matérias. Existem conexões óbvias com organização de computadores, programação em linguagem assembly, sistemas operacionais, arquitetura de computadores, algoritmos e linguagens formais. Embora as conexões da construção de compiladores com outros cursos possa ser menos óbvia, não são menos importantes. A cópia de caracteres, como discutida no Capítulo 7, tem papel crítico no desempenho das aplicações que incluem protocolos de rede, servidores de arquivos e servidores Web. As técnicas desenvolvidas no Capítulo 2 para análise léxica (scanning) possuem aplicações que variam desde edição de textos até filtragem de URL. O alocador ascendente (bottom-up) de registrador local no Capítulo 13 é um primo do algoritmo ótimo de substituição de página off-line, MIN.

Agradecimentos

Muitas pessoas foram envolvidas na preparação da primeira edição deste livro. Suas contribuições foram transportadas para esta segunda edição. Muitas indicaram problemas na primeira edição, incluindo Amit Saha, Andrew Waters, Anna Youssefi, Ayal Zachs, Daniel Salce, David Peixotto, Fengmei Zhao, Greg Malecha, Hwansoo Han, Jason Eckhardt, Jeffrey Sandoval, John Elliot, Kamal Sharma, Kim Hazelwood, Max Hailperin, Peter Froehlich, Ryan Stinnett, Sachin Rehki, Sag˘nak Tas¸ırlar, Timothy Harvey e Xipeng Shen. Também queremos agradecer aos revisores da segunda edição, Jeffery von Ronne, Carl Offner, David Orleans, K. Stuart Smith, John Mallozzi, Elizabeth White e Paul C. Anagnostopoulos. A equipe de produção na Elsevier, em particular Alisa Andreola, Andre Cuello e Megan Guiney, tiveram uma participação crítica na conversão de um manuscrito bruto para sua forma final. Todas essas pessoas melhoraram este volume de formas significativas com suas ideias e sua ajuda.

Finalmente, muitas pessoas nos deram suporte intelectual e emocional durante os últimos cinco anos. Em primeiro lugar, nossas famílias e nossos colegas na Rice University nos encorajaram a cada passo do caminho. Christine e Carolyn, em particular, aguentaram milhares de longas dis-cussões sobre tópicos de construção de compiladores. Nate McFadden guiou esta edição desde seu início até sua publicação, com muita paciência e bom humor. Penny Anderson ofereceu suporte administrativo e organizacional, que foi crítico para a conclusão da segunda edição. Para todas elas vão nossos sinceros agradecimentos.

(13)

1

VISÃO GERAL DO CAPÍTULO

Compiladores são programas de computador que traduzem um programa escrito em uma linguagem em um programa escrito em outra linguagem. Ao mesmo tempo, um compilador é um sistema de software de grande porte, com muitos componentes e algoritmos internos e interações complexas entre eles. Assim, o estudo da construção de compiladores é uma introdução às técnicas para a tradução e o aperfeiçoamento de programas, além de um exercício prático em engenharia de software. Este capítulo fornece uma visão geral conceitual de todos os principais componentes de um com-pilador moderno.

Palavras-chave: Compilador, Interpretador, Tradução automática

1.1 INTRODUÇÃO

A função do computador na vida diária cresce a cada ano. Com a ascensão da internet, computadores e o software neles executados fornecem comunicações, notícias, en-tretenimento e segurança. Computadores embutidos têm modificado as formas como construímos automóveis, aviões, telefones, televisões e rádios. A computação tem criado categorias de atividades completamente novas, de videogames a redes sociais. Supercomputadores predizem o estado atmosférico diário e o curso de tempestades violentas. Computadores embutidos sincronizam semáforos e enviam e-mail para o seu celular.

Todas essas aplicações contam com programas de computador (software) que cons-troem ferramentas virtuais em cima de abstrações de baixo nível fornecidas pelo hardware subjacente. Quase todo este software é traduzido por uma ferramenta cha-mada compilador, que é simplesmente um programa de computador que traduz ou-tros programas a fim de prepará-los para execução. Este livro apresenta as técnicas fundamentais de tradução automática usadas para construir compiladores. Descreve muito dos desafios que surgem nesta construção e os algoritmos que seus construtores usam para enfrentá-los.

Roteiro conceitual

Compilador é uma ferramenta que traduz software escrito em uma linguagem para outra. Para esta tradução, a ferramenta deve entender tanto a forma (ou sintaxe) quanto o conteúdo (ou significado) da linguagem de entrada, e, ainda, as regras que controlam a sintaxe e o significado na linguagem de saída. Finalmente, precisa de um esquema de mapeamento de conteúdo da linguagem-fonte para a linguagem-alvo.

A estrutura de um compilador típico deriva dessas observações simples. O compilador tem um front end para lidar com a linguagem de origem, e um back end para lidar com a linguagem-alvo. Ligando ambos, ele tem uma estrutura formal para representar o programa num formato intermediário cujo significado é, em grande parte, independente de qualquer linguagem. Para melhorar a tradução, compiladores muitas vezes incluem um otimizador, que analisa e reescreve esse formato intermediário.

Compilador

(14)

Visão geral

Programas de computador são simplesmente sequências de operações abstratas escritas em uma linguagem de programação — linguagem formal projetada para expressar computação. Linguagens de programação têm propriedades e significados rígidos — ao contrário de linguagens naturais, como chinês ou português —, e são projetadas visando expressividade, concisão e clareza. Linguagem natural permite ambiguidade. Linguagens de programação são projetadas para evitá-la; um programa ambíguo não tem significado. Essas linguagens são projetadas para especificar computações — registrar a sequência de ações que executam alguma tarefa ou produzem alguns resultados.

Linguagens de programação são, em geral, projetadas para permitir que os seres humanos expressem computações como sequências de operações. Processadores de computador, a seguir referidos como processadores, microprocessadores ou máquinas, são projetados para executar sequências de operações. As operações que um proces-sador implementa estão, quase sempre, em um nível de abstração muito inferior ao daquelas especificadas em uma linguagem de programação. Por exemplo, a linguagem de programação normalmente inclui uma forma concisa de imprimir algum número para um arquivo. Esta única declaração da linguagem deve ser traduzida literalmente em centenas de operações de máquina antes que possa ser executada.

A ferramenta que executa estas traduções é chamada compilador. O compilador toma como entrada um programa escrito em alguma linguagem e produz como saída um pro-grama equivalente. Na noção clássica de um compilador, o propro-grama de saída é expresso nas operações disponíveis em algum processador específico, muitas vezes chamado máquina-alvo. Visto como uma caixa-preta, um compilador deve ser semelhante a isto:

Linguagens “fonte” típicas podem ser C, C++, Fortran, Java, ou ML. Linguagem “alvo” geralmente é o conjunto de instruções de algum processador.

Alguns compiladores produzem um programa-alvo escrito em uma linguagem de programação orientada a humanos, em vez da assembly de algum computador. Os programas que esses compiladores produzem requerem ainda tradução antes que pos-sam ser executados diretamente em um computador. Muitos compiladores de pesquisa produzem programas C como saída. Como existem compiladores para C na maioria dos computadores, isto torna o programa-alvo executável em todos estes sistemas ao custo de uma compilação extra para o alvo final. Compiladores que visam linguagens de programação, em vez de conjunto de instruções de um computador, frequentemente são chamados tradutores fonte a fonte.

Muitos outros sistemas qualificam-se como compiladores. Por exemplo, um programa de composição tipográfica que produz PostScript pode assim ser considerado. Ele toma como entrada uma especificação de como o documento aparece na página impressa e produz como saída um arquivo PostScript. PostScript é simplesmente uma linguagem para descrever imagens. Como este programa assume uma especificação executável e produz outra também executável, é um compilador.

O código que transforma PostScript em pixels é normalmente um interpretador, não um compilador. Um interpretador toma como entrada uma especificação executável e produz como saída o resultado da execução da especificação.

Algumas linguagens, como Perl, Scheme e APL, são, com mais frequência, implemen-tadas com interpretadores do que com compiladores.

Algumas linguagens adotam esquemas de tradução que incluem tanto compilação quanto interpretação. Java é compilada do código-fonte para um formato denominado bytecode, uma representação compacta que visa diminuir tempos de download para aplicativos Máquina Virtual Java. Aplicativos Java são executados usando o bytecode na Máquina Virtual Java (JVM), um interpretador para bytecode. Para complicar ainda mais as coisas, algumas implementações da JVM incluem um compilador, às vezes chamado de compilador just-in-time, ou JIT, que, em tempo de execução, traduz sequências de bytecode muito usadas em código nativo para o computador subjacente. Interpretadores e compiladores têm muito em comum, e executam muitas das mesmas tarefas. Ambos analisam o programa de entrada e determinam se é ou não um programa válido; constroem um modelo interno da estrutura e significado do programa; deter-minam onde armazenar valores durante a execução. No entanto, interpretar o código para produzir um resultado é bastante diferente de emitir um programa traduzido que pode ser executado para produzir o resultado. Este livro concentra-se nos problemas que surgem na construção de compiladores. No entanto, um implementador de inter-pretadores pode encontrar a maior parte do material relevante.

Por que estudar construção de compilador?

Compiladores são programas grandes e complexos, e geralmente incluem centenas de milhares, ou mesmo milhões, de linhas de código, organizadas em múltiplos subsis-temas e componentes. As várias partes de um compilador interagem de maneira com-plexa. Decisões de projeto tomadas para uma parte do compilador têm ramificações importantes para as outras. Assim, o projeto e a implementação de um compilador são um exercício substancial em engenharia de software.

Um bom compilador contém um microcosmo da ciência da computação. Faz uso prático de algoritmos gulosos (alocação de registradores), técnicas de busca heurís-tica (agendamento de lista), algoritmos de grafos (eliminação de código morto), programação dinâmica (seleção de instruções), autômatos finitos e autômatos de pilha (análises léxica e sintática) e algoritmos de ponto fixo (análise de fluxo de dados). Lida com problemas, como alocação dinâmica, sincronização, nomeação, localidade, gerenciamento da hierarquia de memória e escalonamento de pipeline. Poucos sis-temas de software reúnem tantos componentes complexos e diversificados. Trabalhar dentro de um compilador fornece experiência em engenharia de software, difícil de se obter com sistemas menores, menos complicados.

Compiladores desempenham papel fundamental na atividade central da ciência da computação: preparar problemas para serem solucionados por computador. A maior parte do software é compilada, e a exatidão desse processo e a eficiência do código resultante têm impacto direto sobre nossa capacidade de construir sistemas de grande porte. A maioria dos estudantes não se contenta em ler sobre essas ideias; muitas delas devem ser implementadas para que sejam apreciadas. Assim, o estudo da construção de compiladores é componente importante de um curso de ciência da computação.

Conjunto de Instruções

Conjunto de operações suportadas pelo processador; o projeto global de um conjunto de instruções é muitas vezes chamado arquitetura de conjunto de instruções ou ISA (Instruction set architecture).

Máquina Virtual

É um simulador para um processador, ou seja, um inter-pretador para o conjunto de instruções da máquina.

(15)

na Máquina Virtual Java (JVM), um interpretador para bytecode. Para complicar ainda mais as coisas, algumas implementações da JVM incluem um compilador, às vezes chamado de compilador just-in-time, ou JIT, que, em tempo de execução, traduz sequências de bytecode muito usadas em código nativo para o computador subjacente. Interpretadores e compiladores têm muito em comum, e executam muitas das mesmas tarefas. Ambos analisam o programa de entrada e determinam se é ou não um programa válido; constroem um modelo interno da estrutura e significado do programa; deter-minam onde armazenar valores durante a execução. No entanto, interpretar o código para produzir um resultado é bastante diferente de emitir um programa traduzido que pode ser executado para produzir o resultado. Este livro concentra-se nos problemas que surgem na construção de compiladores. No entanto, um implementador de inter-pretadores pode encontrar a maior parte do material relevante.

Por que estudar construção de compilador?

Compiladores são programas grandes e complexos, e geralmente incluem centenas de milhares, ou mesmo milhões, de linhas de código, organizadas em múltiplos subsis-temas e componentes. As várias partes de um compilador interagem de maneira com-plexa. Decisões de projeto tomadas para uma parte do compilador têm ramificações importantes para as outras. Assim, o projeto e a implementação de um compilador são um exercício substancial em engenharia de software.

Um bom compilador contém um microcosmo da ciência da computação. Faz uso prático de algoritmos gulosos (alocação de registradores), técnicas de busca heurís-tica (agendamento de lista), algoritmos de grafos (eliminação de código morto), programação dinâmica (seleção de instruções), autômatos finitos e autômatos de pilha (análises léxica e sintática) e algoritmos de ponto fixo (análise de fluxo de dados). Lida com problemas, como alocação dinâmica, sincronização, nomeação, localidade, gerenciamento da hierarquia de memória e escalonamento de pipeline. Poucos sis-temas de software reúnem tantos componentes complexos e diversificados. Trabalhar dentro de um compilador fornece experiência em engenharia de software, difícil de se obter com sistemas menores, menos complicados.

Compiladores desempenham papel fundamental na atividade central da ciência da computação: preparar problemas para serem solucionados por computador. A maior parte do software é compilada, e a exatidão desse processo e a eficiência do código resultante têm impacto direto sobre nossa capacidade de construir sistemas de grande porte. A maioria dos estudantes não se contenta em ler sobre essas ideias; muitas delas devem ser implementadas para que sejam apreciadas. Assim, o estudo da construção de compiladores é componente importante de um curso de ciência da computação.

Conjunto de Instruções

Conjunto de operações suportadas pelo processador; o projeto global de um conjunto de instruções é muitas vezes chamado arquitetura de conjunto de instruções ou ISA (Instruction set architecture).

(16)

Compiladores demonstram a aplicação bem-sucedida da teoria para problemas práticos. As ferramentas que automatizam a produção de analisadores léxicos (scan-ners) e analisadores sintáticos (parsers) aplicam resultados da teoria de linguagem formal. Estas mesmas ferramentas são utilizadas para pesquisa de texto, filtragem de website, processamento de textos e interpretadores de linguagem de comandos. A verificação de tipo e a análise estática aplicam resultados das teorias de reticulados e dos números, e outros ramos da matemática, para compreender e melhorar pro-gramas. Geradores de código utilizam algoritmos de correspondência de padrão de árvore, parsing, programação dinâmica e correspondência de texto para automatizar a seleção de instruções.

Ainda assim, alguns problemas que surgem na construção de compiladores são pro-blemas abertos — isto é, as melhores soluções atuais ainda têm espaço para melhorias. Tentativas de projeto de representações de alto nível, universais e intermediárias es-barram na complexidade. O método dominante para escalonamento de instruções é um algoritmo guloso com várias camadas de heurística de desempate. Embora seja evidente que os compiladores devem usar comutatividade e associatividade para melhorar o código, a maioria deles que tenta fazer isto, simplesmente reorganiza a expressão em alguma ordem canônica.

Construir um compilador bem-sucedido exige conhecimentos em algoritmos, enge-nharia e planejamento. Bons compiladores aproximam as soluções para problemas difíceis. Eles enfatizam eficiência em suas próprias implementações e no código que geram. Têm estrutura de dados interna e representações de conhecimento que expõem o nível correto de detalhe — suficientes para permitir otimização forte, mas não para forçar o compilador a “nadar” em detalhes. A construção de compiladores reúne ideias e técnicas de toda a extensão da ciência da computação, aplicando-as em um ambiente restrito para resolver alguns problemas verdadeiramente difíceis.

Princípios fundamentais da compilação

Compiladores são objetos grandes, complexos e cuidadosamente projetados. Embora muitos problemas no seu projeto sejam passíveis de múltiplas soluções e interpretações, há dois princípios fundamentais que um construtor de compiladores deve ter em mente o tempo todo. O primeiro é inviolável:

O compilador deve preservar o significado do programa a ser compilado.

Exatidão é uma questão fundamental na programação. O compilador deve preservar a exatidão, implementando fielmente o “significado” de seu programa de entrada. Este princípio está no cerne do contrato social entre o construtor e o usuário do compilador. Se o compilador puder ter liberdade com o significado, então, por que simplesmente não gerar um nop ou um return? Se uma tradução incorreta é aceitável, por que se esforçar para acertá-la?

O segundo princípio a ser observado é prático:

O compilador deve melhorar o programa de entrada de alguma forma perceptível. Um compilador tradicional melhora o programa de entrada ao torná-lo executável diretamente em alguma máquina-alvo. Outros “compiladores” melhoram suas entradas de diferentes maneiras. Por exemplo, tpic é um programa que toma a especificação para um desenho escrito na linguagem gráfica pic e a converte para LATEX; a “me-lhoria” reside na maior disponibilidade e generalidade do LATEX. Um tradutor fonte a fonte para C deve produzir código que seja, de certa forma, melhor que o programa de entrada; se não for, por que alguém iria chamá-lo?

(17)

O front end concentra-se na compreensão do programa na linguagem-fonte. Já o back end, no mapeamento de programas para a máquina-alvo. Essa separação de interesses tem várias implicações importantes para o projeto e a implementação dos compiladores. O front end deve codificar seu conhecimento do programa fonte em alguma estrutura para ser usada mais tarde pelo back end. Essa representação intermediária (IR) torna-se a representação definitiva do compilador para o código que está sendo traduzido. Em cada ponto da compilação, o compilador terá uma representação definitiva. Ele pode, na verdade, utilizar várias IRs diferentes à medida que a compilação prossegue, mas, em cada ponto, uma determinada representação será a IR definitiva. Pensamos na IR definitiva como a versão do programa passada entre fases independentes do compilador, como a IR passada do front end para o back end no desenho anterior.

Em um compilador de duas fases, o front end deve garantir que o programa-fonte esteja bem formado, e mapear aquele código para a IR. Já o back end, mapear o programa em IR para o conjunto de instruções e os recursos finitos da máquina-alvo. Como este último só processa a IR criada pelo front end, pode assumir que a IR não contém erros sintáticos ou semânticos.

IR

Um compilador usa algum conjunto de estruturas de dados para representar o código que é processado. Este formato é chamado representação intermediária, ou IR (Intermediate Representation).

VOCÊ PODE ESTAR ESTUDANDO EM UMA ÉPOCA INTERESSANTE

Esta é uma época interessante para projetar e implementar compiladores. Na década de 1980, quase todos os compiladores eram sistemas grandes, monolíticos. Usavam como entrada um punhado de linguagens e produziam código assembly gerando-o para algum computador em particular. O código assembly era colado junto com o código produzido por outras compilações — incluindo bibliotecas de sistema e de aplicação — para formar um executável. Este era armazenado em disco e, no momento apropriado, o código final era movido do disco para a memória principal e executado.

Hoje, a tecnologia do compilador está sendo aplicada em muitos ambientes

diferentes. À medida que os computadores encontram aplicações em diversos lugares, os compiladores precisam lidar com novas e diferentes restrições. Velocidade não é mais o único critério para julgar o código compilado. Hoje, este código pode ser julgado com base no quanto é pequeno, em quanta energia consome, quanto ele comprime ou quantas faltas de página gera quando executado.

(18)

O compilador pode fazer múltiplas passagens pelo formato IR do código antes de emitir o programa-alvo. Esta capacidade deve levar a um código melhor, pois o compilador pode, desta forma, estudar o código em uma fase e registrar detalhes relevantes. Depois, em outras fases, pode usar os fatos registrados para melhorar a qualidade da tradução. Esta estratégia requer que o conhecimento derivado na primeira passagem seja regis-trado na IR, onde outras passagens podem encontrá-lo e utilizá-lo.

Finalmente, a estrutura de duas fases pode simplificar o processo de retargeting do compilador. Podemos facilmente imaginar a construção de vários back ends para um único front end, a fim de produzir compiladores que aceitam a mesma linguagem, mas visam diferentes máquinas. De modo semelhante, podemos imaginar front ends para diferentes linguagens produzindo a mesma IR e usando um back end comum. Ambos os cenários consideram que uma IR pode atender a várias combinações de fonte e alvo; na prática, tanto os detalhes específicos da linguagem quanto os detalhes específicos da máquina normalmente encontram seu lugar na IR.

A introdução de uma IR torna possível acrescentar mais fases à compilação. O cons-trutor de compiladores pode inserir uma terceira fase entre o front end e o back end. Essa seção do meio, ou otimizador, toma um programa em IR como entrada e produz outro programa em IR semanticamente equivalente como saída. Usando a IR como interface, o construtor pode inserir esta terceira fase com o mínimo de rompimento entre o front end e o back end, o que leva à estrutura de compilador a seguir, chamada compilador de três fases.

O otimizador é um transformador IR-para-IR que tenta melhorar o programa em IR de alguma maneira. (Observe que esses transformadores, por si sós, são compila-dores, de acordo com nossa definição na Seção 1.1.) Ele pode fazer uma ou mais pas-sagens pela IR, analisá-la e reescrevê-la. Pode, também, reescrever a IR de um modo que provavelmente produza um programa-alvo mais rápido, ou menor, pelo back end. E, ainda, ter outros objetivos, como um programa que produz menos faltas de página ou usa menos energia.

Conceitualmente, a estrutura em três fases representa o compilador otimizante clássico. Na prática, cada fase é dividida internamente em uma série de passos. Retargeting

A tarefa de mudar o compilador para gerar código para um novo processador é frequentemente chamada retargeting do compilador.

Otimizador

A seção do meio de um compilador, chamada otimizador, analisa e transforma a IR para melhorá-la.

Ao mesmo tempo, as técnicas de compilação têm fugido do sistema monolítico dos anos 1980. E aparecendo em muitos lugares novos. Compiladores Java tomam programas parcialmente compilados (no formato Java bytecode) e os traduzem em código nativo para a máquina-alvo. Nesse ambiente, o sucesso exige que a soma do tempo de compilação mais o de execução deve ser inferior ao custo da interpretação. Técnicas para analisar programas inteiros estão passando de tempo de compilação para de montagem, no qual o montador pode analisar o código assembly para o aplicativo inteiro e utilizar este conhecimento para melhorar o programa. Finalmente, compiladores estão sendo invocados em tempo de execução para gerar código personalizado que tira proveito de fatos que não podem ser conhecidos mais cedo. Se o tempo de compilação pode ser mantido pequeno e os benefícios são grandes, esta estratégia tende a produzir melhorias visíveis.

(19)

a melhoria do formato IR. O back end precisa mapear o programa transformado em IR para os recursos limitados da máquina alvo de um modo que leve ao uso eficiente desses recursos.

Dessas três fases, o otimizador tem a descrição mais obscura. O termo otimização implica que o compilador descobre uma solução ótima para algum problema. As ques-tões e os problemas que surgem nesta fase são tão complexos e tão inter-relacionados que não podem, na prática, ser solucionados de forma ótima. Além do mais, o com-portamento real do código compilado depende das interações entre todas as técnicas aplicadas no otimizador e o back end. Assim, mesmo que uma única técnica possa ser comprovadamente ótima, suas interações com outras podem produzir resultados que não são ótimos. Como resultado, um bom compilador otimizante pode melhorar a qualidade do código em relação a uma versão não otimizada, mas quase sempre deixará de produzir o código ótimo.

A seção intermediária pode ser um único passo monolítico que aplica uma ou mais otimizações para melhorar o código, ou ser estruturada como uma série de passos menores com cada um lendo e escrevendo a IR. A estrutura monolítica pode ser mais eficiente. A de múltiplos passos, pode servir como uma implementação menos complexa e um método mais simples de depurar o compilador. Esta também cria a flexibilidade para empregar diferentes conjuntos de otimização em diferentes situações. A escolha entre essas duas técnicas depende das restrições sob as quais o compilador é cons-truído e opera.

(20)

1.3 VISÃO GERAL DA TRADUÇÃO

Para traduzir o código escrito em uma linguagem de programação para código adequado à execução em alguma máquina-alvo, um compilador passa por muitas etapas. Para tornar esse processo abstrato mais concreto, considere as etapas necessárias para gerar um código executável para a seguinte expressão

a ← a × 2× b × c× d

onde a, b, c e d são variáveis, ← indica uma atribuição, e × o operador para multiplicação. Nas subseções seguintes, rastrearemos o caminho que um compilador segue para transformar esta expressão simples em código executável.

NOTAÇÃO

Os livros sobre compiladores tratam basicamente de notação. Afinal, um compilador traduz um programa escrito em uma notação para um programa equivalente escrito em outra notação. Diversas dúvidas de notação surgirão durante sua leitura deste livro. Em alguns casos, elas afetarão diretamente sua compreensão do material.

Expressando algoritmos Tentamos manter os algoritmos curtos. Algoritmos são escritos em um nível relativamente alto, considerando que o leitor possa fornecer detalhes da implementação, em fonte inclinada, monoespaçada. O recuo é deliberado e significativo, mais importante em uma construção if-then-else. O código recuado após um then ou um else forma um bloco. No fragmento de código a seguir

if Action [s,word] = “shift si” then

push word push si

word ← NextWord() else if. . .

todas as instruções entre then e else fazem parte da cláusula then da construção if-then-else. Quando uma cláusula em uma construção if-then-else contém apenas uma instrução, escrevemos a palavra-chave then ou else na mesma linha da instrução.

Escrevendo código Em alguns exemplos, mostramos o texto real do programa escrito em alguma linguagem escolhida para demonstrar um tópico em particular. Este texto é escrito em uma fonte monoespaçada.

Operadores aritméticos Por fim, abrimos mão do uso tradicional de * para × e de / para ÷, exceto no texto do programa real. O significado deve ser claro para o leitor.

1.3.1 Front end

Antes que o compilador possa traduzir uma expressão para código executável da máquina-alvo, precisa entender tanto sua forma, ou sintaxe, quanto seu significado, ou semântica. O front end determina se o código de entrada está bem formado nestes termos. Se descobrir que o código é válido, ele cria uma representação deste código na representação intermediária do compilador; se não, informa ao usuário com mensagens de erro de diagnóstico para identificar os problemas com o código.

Verificando a sintaxe

Para verificar a sintaxe do programa de entrada, o compilador precisa comparar a es-trutura do programa com uma definição para a linguagem. Isto exige uma definição formal apropriada, um mecanismo eficiente para testar se a entrada atende ou não esta definição e um plano de como proceder em uma entrada ilegal.

(21)

onde verbo e marca de fim são classes, e Sentença, Sujeito e Objeto são variáveis sintáticas. Sentença representa qualquer string com a forma descrita por esta regra. O símbolo “→” indica “deriva” e significa que uma ocorrência do lado direito pode ser simplificada para a variável sintática do lado esquerdo.

Considere uma sentença como “Compiladores são objetos construídos.” O primeiro passo para entender a sintaxe desta sentença é identificar palavras distintas no programa de entrada e classificar cada palavra com uma classe gramatical. Em um compilador, esta tarefa fica com um passo chamado scanner (ou analisador léxico). O scanner apa-nha um fluxo de caracteres e o converte para um fluxo de palavras classificadas — ou seja, pares na forma (c, g), onde c é a classe gramatical e g sua grafia. Um scanner converteria a sentença do exemplo no seguinte fluxo de palavras classificadas: (substantivo,“Compiladores”), (verbo,“são”), (substantivo,“objetos”), (adjetivo,“construídos”), (marca de fim,“.”)

Na prática, a grafia real das palavras poderia ser armazenada em uma tabela hash e representada nos pares como um índice inteiro para simplificar testes de igualdade. O Capítulo 2 explora a teoria e a prática da construção do scanner.

No próximo passo, o compilador tenta corresponder o fluxo de palavras categorizadas às regras que especificam a sintaxe da linguagem de entrada. Por exemplo, um co-nhecimento funcional do português poderia incluir as seguintes regras gramaticais:

1 Sentença → Sujeito verbo Objeto marca de fim

2 Sujeito → substantivo

3 Sujeito → Modificador substantivo

4 Objeto → substantivo

5 Objeto → Modificador substantivo 6 Modificador → adjetivo

.. .

Por inspeção, podemos descobrir a seguinte derivação para nossa sentença de exemplo:

Regra Sentença de protótipo

Sentença

1 Sujeito verbo Objeto marca de fim

2 substantivo verbo Objeto marca de fim

5 substantivo verbo Modificador substantivo marca de fim 6 substantivo verbo adjetivo substantivo marca de fim

Scanner (analisador léxico)

O passo do compilador que converte uma string de caracteres em um fluxo de palavras.

(22)

A derivação começa com a variável sintática Sentença. A cada passo, reescreve um termo na sentença de protótipo, substituindo-o por um lado direito que pode ser derivado desta regra. O primeiro passo usa a Regra 1 para substituir Sentença. O segundo, a Regra 2 para substituir Sujeito. O terceiro substitui Objeto usando a Regra 5, enquanto o passo final reescreve Modificador com adjetivo de acordo com a Regra 6. Neste ponto, a sentença de protótipo gerada pela derivação corresponde ao fluxo de palavras categorizadas produzidas pelo scanner.

A derivação prova que a sentença “Compiladores são objetos construídos.” pertence à linguagem descrita pelas Regras de 1 a 6. Ela está gramaticalmente correta. O processo de encontrar automaticamente as derivações é chamado parsing (ou análise sintática). O Capítulo 3 apresenta as técnicas que os compiladores usam para analisar sintaticamente o programa de entrada.

Uma sentença gramaticalmente correta pode não ter significado. Por exemplo, “Pe-dras são vegetais verdes.” tem as mesmas classes gramaticais na mesma ordem de “Compiladores são objetos construídos.”, mas não tem um significado racional. Para entender a diferença entre essas duas sentenças, é preciso ter conhecimento contextual sobre os sistemas de software, pedras e vegetais.

Os modelos semânticos que os compiladores usam para raciocinar a respeito das linguagens de programação são mais simples do que aqueles necessários para entender a linguagem natural. Compiladores montam modelos matemáticos que detectam tipos específicos de inconsistência em um programa. Compiladores verificam a consistência de tipo. Por exemplo, a expressão:

a ← a × 2× b × c× d

poderia ser sintaticamente bem formada, mas se b e d forem strings de caracteres, a sentença poderia ser inválida. Os compiladores também verificam a consistência de número em situações específicas; por exemplo, uma referência de array deve ter o mesmo número de subscritos do que o número de dimensões (rank) declarado do array e uma chamada de procedimento deve especificar o mesmo número de argumentos da definição do procedimento. O Capítulo 4 explora algumas das questões que surgem na verificação de tipo e na elaboração semântica baseadas em compilador.

Representações intermediárias (IRs)

O último aspecto tratado no front end de um compilador é a geração de um formato de IR do código. Compiladores usam diversos tipos diferentes de IR, dependen-do da linguagem-fonte, da linguagem-alvo e das transformações específicas que o compilador aplica. Algumas IRs representam o programa como um grafo, outras assemelham-se a um programa em código assembly sequencial. O código ao lado mostra como nossa expressão de exemplo ficaria em uma IR sequencial de baixo nível. O Capítulo 5 apresenta uma visão geral da variedade de tipos de IR que os compiladores utilizam.

Para cada construção na linguagem-fonte o compilador precisa de uma estratégia para como implementá-la no formato IR do código. Escolhas específicas afetam a capacidade do compilador de transformar e melhorar o código. Assim, usamos dois capítulos para analisar as questões que surgem na geração da IR para construções do código-fonte. As ligações de procedimento são, ao mesmo tempo, uma fonte de ineficiência no código final e a cola fundamental que junta as partes de diferentes arquivos-fonte em uma aplicação. Assim, dedicamos o Capítulo 6 para as questões em torno das chamadas de procedimento. O Capítulo 7 apresenta estratégias de implementação para a maioria das outras construções de linguagem de programação.

Parser (analisador sintático)

O passo do compilador que determina se o fluxo de entrada é uma sentença na linguagem-fonte.

a←a×2×b×c×d

Verificação de tipo

O passo do compilador que verifica a consistência de tipo do uso de nomes no programa de entrada.

t0 ← a × 2

t1 ← t0 × b

t2 ← t1 × c

t3 ← t2 × d

(23)

contém estratégias de implementação gerais que funcionarão em qualquer contexto ao redor do qual o compilador poderia gerar. Em tempo de execução (runtime), o código será executado em um contexto mais restrito e previsível. O otimizador analisa o formato IR do código para descobrir fatos sobre esse contexto e usa esse conhecimento contextual para reescrever o código de modo que calcule a mesma resposta de maneira mais eficiente.

Eficiência pode ter muitos significados. A noção clássica da otimização é reduzir o tempo de execução da aplicação. Em outros contextos, o otimizador poderia tentar reduzir o tamanho do código compilado ou outras propriedades, como, por exemplo, a energia que o processador consome para avaliar o código. Todas essas estratégias visam à eficiência.

Retornando ao nosso exemplo, considere-o no contexto mostrado na Figura 1.2a. A declaração ocorre dentro de um laço (ou loop). Dos valores que ele usa, somente a e d mudam dentro do laço. Os valores 2, b e c são invariantes no laço. Se o otimizador descobrir este fato, poderá reescrever o código como mostra a Figura 1.2b. Nesta versão, o número de multiplicações foi reduzido de 4 .n para 2 .n + 2. Para n > 1, o laço reescrito deve ser executado mais rapidamente. Este tipo de otimização é discutido nos Capítulos 8, 9 e 10.

Análise

A maioria das otimizações consiste em uma análise e uma transformação. A análise determina onde o compilador pode aplicar a técnica de forma segura e lucrativa. Compiladores utilizam vários tipos de análise para dar suporte às transformações. Análise de fluxo de dados, em tempo de compilação, raciocina sobre o fluxo de valores em runtime. Os analisadores de fluxo de dados normalmente resolvem um sistema

(24)

de equações simultâneas que são derivadas da estrutura do código sendo traduzido. Análise de dependência usa testes da teoria dos números para raciocinar sobre os valores que podem ser assumidos por expressões de subscrito. É usada para remover a ambiguidade das referências a elementos de array. O Capítulo 9 apresenta uma visão detalhada da análise de fluxo de dados e sua aplicação, junto com a construção do formato de atribuição única estática, uma IR que codifica informações sobre o fluxo de valores e controle diretamente na IR.

Transformação

Para melhorar o código, o compilador precisa ir além da análise; necessita usar os resultados dessa análise para reescrever o código para um formato mais eficiente. Inúmeras transformações foram inventadas para melhorar os requisitos de tempo ou espaço do código executável. Algumas, como descobrir computações invariantes no laço e movê-las para locais executados com menos frequência, melhoram o tempo de execução do programa. Outras tornam o código mais compacto. As transformações variam em seus efeitos, no escopo sobre as quais operam e na análise exigida para lhes dar suporte. A literatura sobre transformações é muito rica; o assunto é abrangente e profundo o suficiente para merecer um ou mais livros separados. O Capítulo 10 aborda o tema de transformações escalares — ou seja, transformações voltadas para melhorar o desempenho do código em um único processador — e apresenta uma classificação para organizar o assunto, preenchendo essa classificação com exemplos.

1.3.3 Back end

O back end do compilador atravessa o formato IR do código e emite código para a máquina-alvo; seleciona as operações da máquina-alvo para implementar cada operação da IR; escolhe uma ordem em que as operações serão executadas de modo mais eficiente; decide quais valores residirão nos registradores e quais na memória, inserindo código para impor essas decisões.

Análise de fluxo de dados

Forma de raciocínio em tempo de compilação sobre o fluxo de valores em runtime.

SOBRE A ILOC

Por todo este livro, os exemplos de baixo nível são escritos em uma notação que chamamos de ILOC — acrônimo derivado de “Intermediate Language for an Optimizing Compiler”. Com o passar dos anos, esta notação passou por várias mudanças. A versão usada neste livro é descrita com detalhes no Apêndice A. Pense na ILOC como a linguagem assembly para uma máquina RISC simples. Ela possui um conjunto padrão de operações. A maior parte delas utiliza argumentos que são registradores. As operações de memória, loads e stores, transferem valores entre a memória e os registradores. Para simplificar a exposição no texto, a maior parte dos exemplos considera que todos os dados consistem em inteiros.

Cada operação tem um conjunto de operandos e um alvo, e é escrita em cinco partes: nome de operação, lista de operandos, separador, lista de alvos e comentário opcional. Assim, para somar os conteúdos dos registradores 1 e 2, deixando o resultado no registrador 3, o programador escreveria

add r1,r2 ⇒ r3 // exemplo de instrução

O separador, ⇒, precede a lista de alvos. É um lembrete visual de que a informação flui da esquerda para a direita. Em particular, ele tira a ambiguidade de casos nos quais uma pessoa, lendo o texto em nível de assembly, pode facilmente confundir operandos e alvos. (Ver loadAI e storeAI na tabela a seguir.)

(25)

Seleção de instruções

O primeiro estágio da geração de código reescreve as operações da IR em operações da máquina-alvo, processo chamado seleção de instruções, que mapeia cada operação da IR, em seu contexto, para uma ou mais operações da máquina-alvo. Considere a rees-crita da nossa expressão de exemplo, a ← a × 2 × b × c × d, em código para a máquina virtual ILOC, a fim de ilustrar o processo. (Usaremos a ILOC no decorrer do livro.) O formato IR da expressão é repetido ao lado. O compilador poderia escolher as operações na Figura 1.3. Esse código considera que a, b, c e d estão localizados nos deslocamentos (offsets) @a, @b, @c e @d a partir de um endereço contido no registrador rarp.

O compilador escolheu uma sequência direta de operações. Ele carrega todos os valores relevantes em registradores, realiza as multiplicações em ordem e armazena o resultado ao local da memória para a. Assume um estoque ilimitado de registradores e os nomeia com nomes simbólicos como ra para manter a e rarp para manter o

endereço onde começa o armazenamento de dados para nossos valores nomeados. Implicitamente, o seletor de instruções conta com o alocador de registradores para mapear esses nomes de registrador simbólicos, ou registradores virtuais, aos regis-tradores reais da máquina-alvo.

O seletor de instruções pode tirar proveito de operações especiais na máquina-alvo. Por exemplo, se uma operação de multiplicação imediata (multI) estiver disponível, ele pode substituir a operação mult ra, r2 ⇒ra por multI ra, 2 ⇒ ra, eliminando

a necessidade da operação loadI 2 ⇒ r2 e reduzindo a demanda por registradores.

Se a adição é mais rápida do que a multiplicação, ele pode substituir mult ra, r2

⇒ ra por add ra, ra ⇒ra, evitando tanto o loadI quanto seu uso de r2, além de

Registrador virtual

Nome de registrador simbólico que o compilador usa para indicar que um valor pode ser armazenado em um registrador.

ativação. a ← t ← t3

(26)

substituir mult por um add mais rápido. O Capítulo 11 apresenta duas técnicas diferentes para a seleção de instruções, que utilizam combinação de padrões para es-colher implementações eficientes para operações em IR.

Alocação de registradores

Durante a seleção de instruções, o compilador deliberadamente ignora o fato de que a máquina-alvo possui um conjunto limitado de registradores. Ao invés disso, ele usa registradores virtuais e considera que existem registradores “suficientes”. Na prática, os primeiros estágios da compilação podem criar mais demanda por registradores do que o hardware consegue aceitar. O alocador de registradores precisa mapear esses registradores virtuais para os registradores reais da máquina alvo. Assim, este alocador decide, em cada ponto do código, quais valores devem residir nos registradores da máquina alvo. Depois, reescreve o código para refletir suas decisões. Por exemplo, um alocador de registrador poderia minimizar o uso de registradores reescrevendo o código da Figura 1.3 da seguinte forma:

loadAI rarp, @a ⇒ r1 // load ‘a’

add r1, r1 ⇒ r1 // r1← a × 2

loadAI rarp, @b ⇒ r2 // load ‘b’

mult r1, r2 ⇒ r1 // r1 ← (a × 2) × b

loadAI rarp, @c ⇒ r2 // load ‘c’

mult r1, r2 ⇒ r1 // r1 ← (a × 2 × b) × c

loadAI rarp, @d ⇒ r2 // load ‘d’

mult r1, r2 ⇒ r1 // r1← (a × 2 × b × c) × d

storeAI r1 ⇒ rarp, @a // escrever ra de volta para ‘a’

Esta sequência usa três registradores, em vez de seis.

Minimizar o uso de registradores pode ser contraprodutivo. Se, por exemplo, qualquer um dos valores nomeados, a, b, c ou d, já estiverem em registradores, o código deverá referenciá-los diretamente. Se todos estiverem em registradores, a sequência poderia ser implementada de modo que não exigisse registradores adicionais. Como alternativa, se alguma expressão próxima também calculasse a × 2, poderia ser melhor preservar este valor em um registrador do que recalculá-lo mais tarde. Essa otimização aumentaria a demanda por registradores, mas eliminaria uma instrução mais tarde. O Capítulo 13 explora os problemas que surgem na alocação de registradores e as técnicas que os construtores de compilador utilizam para solucioná-los.

Escalonamento de instruções

Para produzir código que seja executado rapidamente, o gerador de código pode ter que reordenar operações para refletir as restrições de desempenho específicas da máquina-alvo. O tempo de execução das diferentes operações pode variar. As operações de acesso à memória podem tomar dezenas ou centenas de ciclos, enquanto algumas operações aritméticas, particularmente a divisão, exigem vários ciclos. O impacto dessas operações com latência mais longa sobre o desempenho do código compilado pode ser substancial.

Suponha, por enquanto, que uma operação loadAI ou storeAI exija três ciclos; um mult dois ciclos; e todas as outras operações um ciclo. A tabela a seguir mostra como o fragmento de código anterior funciona sob essas suposições. A coluna Início mostra o ciclo em que cada operação inicia a execução, e a coluna Fim o ciclo em que ela termina.

(27)

Esta sequência de nove operações gasta 22 ciclos para ser executada. A redução no uso de registradores não levou a uma execução rápida.

Muitos processadores têm uma propriedade pela qual podem iniciar novas operações enquanto uma de longa latência é executada. Desde que os resultados de uma operação de longa latência não sejam referenciados até que a operação termine, a execução pros-segue normalmente. Porém, se alguma operação intermediária tentar ler o resultado de uma operação de longa latência prematuramente, o processador adia a operação que precisa do valor até que a operação de longa latência termine. Uma operação não pode começar a executar até que seus operandos estejam prontos, e seus resultados não estão prontos até que a operação termine.

O escalonador de instruções reordena as operações no código, e tenta minimizar o número de ciclos desperdiçados aguardando pelos operandos. Naturalmente, ele precisa garantir que a nova sequência produza o mesmo resultado da original. Em muitos casos, o escalonador pode melhorar bastante o desempenho de um código “simples”. Para o nosso exemplo, um bom escalonador poderia produzir a seguinte sequência:

Início Fim

1 3 loadAI rarp, @a ⇒ r1 // load ‘a’

2 4 loadAI rarp, @b ⇒ r2 // load ‘b’

3 5 loadAI rarp, @c ⇒ r3 // load ‘c’

4 4 add r1, r1 ⇒ r1 // r1← a × 2

5 6 mult r1, r2 ⇒ r1 // r1← (a × 2) × b

6 8 loadAI rarp, @d ⇒ r2 // load ‘d’

7 8 mult r1, r3 ⇒ r1 // r1← (a × 2 × b) × c

9 10 mult r1, r2 ⇒ r1 // r1← (a × 2 × b × c) × d

11 13 storeAI r1 ⇒ rarp, @a // escrever ra de volta para ‘a’

CONSTRUÇÃO DE COMPILADORES É ENGENHARIA

Um compilador típico tem uma série de passos que, juntos, traduzem o código de alguma linguagem-fonte para alguma linguagem-alvo. Ao longo do caminho, ele usa dezenas de algoritmos e estruturas de dados. O construtor de compiladores precisa selecionar, para cada etapa no processo, uma solução apropriada.

Um compilador bem-sucedido é executado um número inimaginável de vezes. Considere o número total de vezes que o compilador GCC foi executado. Durante o tempo de vida do GCC, até mesmo pequenas ineficiências acrescentam uma quantidade de tempo significativa. As economias devidas a um bom projeto e

Referências

Documentos relacionados

A participação foi observada durante todas as fases do roadmap (Alinhamento, Prova de Conceito, Piloto e Expansão), promovendo a utilização do sistema implementado e a

Atualmente os currículos em ensino de ciências sinalizam que os conteúdos difundidos em sala de aula devem proporcionar ao educando o desenvolvimento de competências e habilidades

O objetivo do curso foi oportunizar aos participantes, um contato direto com as plantas nativas do Cerrado para identificação de espécies com potencial

Silva e Márquez Romero, no prelo), seleccionei apenas os contextos com datas provenientes de amostras recolhidas no interior de fossos (dado que frequentemente não há garantia

Local de realização da avaliação: Centro de Aperfeiçoamento dos Profissionais da Educação - EAPE , endereço : SGAS 907 - Brasília/DF. Estamos à disposição

A nutrição enteral (NE), segundo o Ministério da Saúde do Brasil, designa todo e qualquer “alimento para fins especiais, com ingestão controlada de nutrientes, na forma isolada

Capítulo 7 – Novas contribuições para o conhecimento da composição química e atividade biológica de infusões, extratos e quassinóides obtidos de Picrolemma sprucei

Em ambos os processos foi possível obter macarrões instantâneos com um teor de amido resistente significativo (acima de 3 % para todos os ensaios). Na análise de textura,