Logo Passei Direto
Buscar
Material
páginas com resultados encontrados.
páginas com resultados encontrados.

Escolha uma das opções e acesse esse e outros materiais sem bloqueio. 🤩

Cadastre-se ou realize login

Ao continuar, você aceita os Termos de Uso e Política de Privacidade

Escolha uma das opções e acesse esse e outros materiais sem bloqueio. 🤩

Cadastre-se ou realize login

Ao continuar, você aceita os Termos de Uso e Política de Privacidade

Escolha uma das opções e acesse esse e outros materiais sem bloqueio. 🤩

Cadastre-se ou realize login

Ao continuar, você aceita os Termos de Uso e Política de Privacidade

Escolha uma das opções e acesse esse e outros materiais sem bloqueio. 🤩

Cadastre-se ou realize login

Ao continuar, você aceita os Termos de Uso e Política de Privacidade

Escolha uma das opções e acesse esse e outros materiais sem bloqueio. 🤩

Cadastre-se ou realize login

Ao continuar, você aceita os Termos de Uso e Política de Privacidade

Escolha uma das opções e acesse esse e outros materiais sem bloqueio. 🤩

Cadastre-se ou realize login

Ao continuar, você aceita os Termos de Uso e Política de Privacidade

Escolha uma das opções e acesse esse e outros materiais sem bloqueio. 🤩

Cadastre-se ou realize login

Ao continuar, você aceita os Termos de Uso e Política de Privacidade

Escolha uma das opções e acesse esse e outros materiais sem bloqueio. 🤩

Cadastre-se ou realize login

Ao continuar, você aceita os Termos de Uso e Política de Privacidade

Escolha uma das opções e acesse esse e outros materiais sem bloqueio. 🤩

Cadastre-se ou realize login

Ao continuar, você aceita os Termos de Uso e Política de Privacidade

Escolha uma das opções e acesse esse e outros materiais sem bloqueio. 🤩

Cadastre-se ou realize login

Ao continuar, você aceita os Termos de Uso e Política de Privacidade

Prévia do material em texto

COMPILADORES 
AULA 6 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Prof. Frank Coelho de Alcantara 
 
 
2 
CONVERSA INICIAL 
Depois de passar por todos os módulos anteriores, o seu algoritmo, 
representado pelo seu código-fonte, já não pode ser reconhecido como texto, e 
está representado na forma de uma estrutura de dados, sem erros de sintaxe ou 
semântica, permitindo a transcrição em linguagem de máquina. Esse é o último 
estágio do processo de compilação, e o chamaremos de otimização de código, 
simplesmente porque é nesse ponto que serão realizadas as adequações do seu 
algoritmo à linguagem de máquina. 
Estamos em um ponto do processo no qual o analisador semântico 
acabou de verificar seu código, você corrigiu os últimos erros encontrados, todos 
os tipos estão corretamente atribuídos, não existem erros na definição de 
variáveis ou funções e, finalmente, vamos gerar o código de máquina que será 
executado pela sua CPU. Esse código precisa ser eficiente, rápido e pequeno, 
de forma que seu programa seja executado sem desperdiçar recursos. Essa foi 
uma preocupação transversal a todo o processo de compilação, mas até agora 
não estávamos preocupados com as características físicas da sua CPU. 
Nesse módulo final, antes de criar o arquivo que será executado, temos 
que nos preocupar com as características físicas da máquina. Quantos 
registradores temos disponíveis? É uma arquitetura de 64 bits? Temos memória 
cache? Quais instruções a máquina entende? Essas são apenas algumas das 
perguntas que precisam ser respondidas antes que o processo de otimização de 
código tenha início. 
É nessa fase do processo que geraremos um código especial, o código 
intermediário, a partir da árvore semântica, e ele será utilizado para as primeiras 
otimizações. Depois, esse código intermediário será transformado na linguagem 
de máquina desejada e sofrerá as últimas otimizações, sendo, finalmente, 
convertido em código executável. 
Existem muitas técnicas interessantes de otimização de código que são 
específicas de uma determinada arquitetura. Essa é a razão que justifica o 
estudo das técnicas de configuração de compiladores. Todos os compiladores 
precisam ser informados, antes de começar o processo de compilação, sobre 
qual é a arquitetura alvo. Assim, na fase de otimização de código, será possível 
adotar técnicas de otimização adequadas à arquitetura desejada. Veremos nesta 
aula os conceitos referentes à geração do código intermediário e alguns dos 
processos de otimização. 
 
 
3 
TEMA 1 – O JAVA E A COMPILAÇÃO 
A linguagem Java é excelente, talvez uma das melhores linguagens já 
definidas para a programação e, como toda linguagem de programação, tem 
seus adoradores e aqueles que a odeiam. Acredito que a maior contribuição do 
Java esteja nas técnicas de compilação e interpretação que tiveram de ser 
criadas e aprimoradas de forma que os programas desenvolvidos em Java sejam 
competitivos com os programas compilados para uma arquitetura específica. O 
Java é uma linguagem na qual o programa resultante só será executado após 
dois passos distintos, uma compilação e uma interpretação. 
Para entender o Java é preciso entender o seu tempo e as razões que 
fundamentaram sua criação. Falamos do ano de 1995: a Sun Microsystems 
estava em um ritmo acelerado de crescimento e em franco processo de 
conquista do mercado de ambientes de alta performance, que eram 
notadamente ambientes de produção gráfica e de servidores de bancos de 
dados. Um mercado dividido entre vários tipos de sistemas operacionais 
derivados do Unix, além de várias distribuições Linux e o Windows. 
Nesse tempo, o processo de compilação era lento, complexo e específico. 
Todas as versões de um determinado software que almejasse a execução em 
Linux, Unix e Windows precisaria de uma equipe específica e de um processo 
de compilação e de otimização adequado a cada um desses sistemas 
operacionais. Esse não era um problema trivial e, via de regra, além de exigir 
uma equipe especializada, requeria muitas horas de trabalho. O mundo da 
tecnologia era lento, caro e complicado. 
James Gosling teve uma ideia genial. Partindo das melhores 
características do C++, e de forma a garantir o controle da Sun sobre a nova 
linguagem, criou uma linguagem nova, sobre um novo paradigma. O código 
deveria ser escrito uma única vez e executado em qualquer combinação de 
máquina e sistema operacional sem a necessidade de nova compilação e sem 
que a equipe de desenvolvimento necessitasse de conhecimentos profundos de 
otimização para cada uma das combinações possíveis de hardware e sistema 
operacional. Na época, quase mágica. 
O projeto de Gosling acabaria por produzir um ambiente de compilação 
dividido em três fases. Na primeira, front-end, estariam todos os processos 
relacionados com a linguagem de programação de alto nível (análise léxica, 
 
 
4 
sintática, semântica); na segunda, a geração de um código objeto, código 
intermediário, e, na terceira, todos os processos relacionados à linguagem de 
máquina. Nessa última fase o código intermediário gerado seria interpretado em 
uma máquina virtual, como pode ser visto na Figura 1: 
Figura 1 – Sistema de compilação do Java 
 
O arquivo seucodigo.java contém o algoritmo que o programador deseja 
criar. Esse arquivo é gravado em disco e depois compilado em um compilador 
AOT (Ahead of Time). O código será submetido a um analisador léxico, a um 
analisador sintático, a um analisador semântico e, finalmente, passará por um 
processo de otimização e geração de código-objeto, produzindo o arquivo 
seucodigo.class. E aqui está a mágica: o arquivo seucodigo.class pode ser 
armazenado em disco, transportado e executado por qualquer máquina virtual 
Java independentemente da plataforma de execução e sem nenhum 
conhecimento da arquitetura alvo. 
Duas coisas merecem destaque: 1. o código-objeto é chamado de 
bytecode e trata-se, na verdade, de uma linguagem, muito próxima da linguagem 
de máquina, com características específicas voltadas para a otimização; 2. a 
máquina virtual é na verdade um interpretador de bytecode para a arquitetura 
desejada. 
Esse bytecode pode ser entendido como sendo outra linguagem de 
programação. Uma linguagem de baixo nível adequada ao ambiente de 
interpretação da máquina virtual e, como toda linguagem, possui suas 
características próprias. Um conjunto de lexemas, sintaxe e semântica 
 
 
5 
específicos. Como qualquer linguagem, precisa passar por um processo de 
conversão em linguagem de máquina para que possa ser executado. 
Todas as características do bytecode da linguagem Java estão muito bem 
documentadas e, se for o caso, você poderá se aprofundar nesses detalhes 
procurando as especificações diretamente na página da Oracle. A versão mais 
recente é a The Java Virtual Machine Specification, Java SE 8 Edition1. 
O processo de interpretação dentro da máquina virtual Java merece um 
pouco de atenção. Primeiro, você precisa ter em mente que todos os processos 
rodam de forma interligada e interdependente, como se o próprio hardware 
(virtual) estivesse interferindo no processo de interpretação e execução. O 
arquivo seucodigo.class é submetido ao compilador. Trata-se de um compilador 
JIT (Just in Time) ou, mais tecnicamente, compilador de transição dinâmica. 
ATENÇÃO: muito cuidado. Se você olhar bem, as definições de interpretador, 
compilador JIT e compilador de transição dinâmica se confundem. Em todos os 
casos trata-se de um sistema que irá traduzir um código em uma linguagem 
qualquer em linguagem de máquina. Essas denominações diferentes são 
apenas para que você possa colocar os conceitos nos lugares certos. Então, um 
interpretador pode ter um compilador JIT no seu interior, mas a operação 
contrária não é possível. 
O JIT-Java recebe todas as classes do programa que deverá ser 
executado em bytecode e, antes de qualquer coisa, fazuma verificação estática 
das propriedades e características do arquivo seucodigo.class; por questões de 
segurança e confiabilidade, verifica também a estrutura léxica e sintática, 
notadamente a construção de métodos e parâmetros. Essa verificação é 
necessária porque as máquinas virtuais Java podem receber bytecode 
proveniente de outras linguagens, a Scala, por exemplo. Em seguida começa o 
processo de interpretação/compilação. 
A interpretação é lenta. Expressões simples, compostas apenas de 
variáveis locais, tais como 𝑥 = 𝑦 + (2 ∗ 𝑧), que em bytecode poderia ser 
codificada como apresentado na Figura 2. 
 
 
1 Disponível em: http://docs.oracle.com/javase/specs/jvms/se8/jvms8.pdf. 
 
 
6 
Figura 2 – Exemplo de bytecode, resultado da compilação de uma expressão 
simples (Cramer, Friedman, et al., 1997) 
 
Analisando o código da Figura 2, é possível perceber porque o Java, 
quando interpretado, é lento. Para executar esse código é necessário decodificar 
seis linhas em bytecode, colocar cinco valores na pilha de operação e retirá-los 
de lá. Um programa em Java típico é composto de centenas de métodos. 
Interpretar cada um desses métodos torna o programa muito lento. A opção seria 
compilar o programa antes de executá-lo. Contudo, a compilação também 
tomaria tempo e recursos de memória, e faria com que o usuário tivesse de 
esperar um longo tempo antes de conseguir utilizar o programa. 
Na prática, os métodos não são compilados na sua primeira execução. 
Para cada método a máquina virtual mantém um contador de chamadas do 
próprio método. Todas as vezes em que um determinado método é chamado 
esse contador é incrementado. Quando o contador atinge um número 
determinado, o método é compilado da forma mais rápida possível e 
armazenado em memória. A partir desse ponto, sempre que o método for 
chamado, o código compilado será executado e não haverá nenhuma nova 
interpretação para este código. 
Uma vez que o código seja compilado, o contador de chamadas é zerado 
e começa a ser incrementado novamente. Se esse número atingir um novo valor 
pré-determinado, o método será novamente compilado, dessa vez com uma 
configuração de otimização mais agressiva em busca de uma velocidade de 
execução ainda melhor. 
Sempre que um método é enviado para a compilação maiores serão as 
otimizações necessárias e, consequentemente, maior será o tempo necessário 
para a compilação. A lista de possíveis compilações varia de máquina virtual 
para máquina virtual. Assim, a máquina virtual Java da IBM pode ter uma 
 
 
7 
performance melhor que a máquina virtual Java da Oracle2 em alguns cenários, 
e não em outros. 
De uma forma ou de outra, usamos técnicas de compilação JIT desde os 
anos 1960. Nesse caso, nada é mais forte que a máxima popular: “quem não 
conhece sua história está condenado a repeti-la”. A compilação dinâmica é o fato 
que justifica a máxima. As máquinas que rodavam o Basic nos anos 1970-80 já 
utilizavam essa mesma técnica para otimizar a execução dos programas. De lá 
para cá, talvez graças ao Java, tais técnicas foram resgatadas e otimizadas de 
tal forma que parecem apontar para o futuro da compilação. 
TEMA 2 – A APPLE ESCOLHE O LLVM 
Em 2000, um grupo de estudantes da Universidade de Illinois liderado 
pelo professor Vikram Adve e por Chris Lattner iniciou um projeto para o estudo 
de técnicas de compilação dinâmica chamado de Low Level Virtual Machine, ou 
LLVM. Esse projeto resultou na dissertação de mestrado de Chris Lattner em 
2002 e em um artigo publicado nos anais do The international symposium on 
Code generation and optimization, em 2004. Basicamente, foi todo o sucesso 
que se espera de um bom projeto acadêmico, e tudo poderia ter ficado por aí 
não fosse o interesse da Apple. 
Em 2005, a Apple contratou Chris Lattiner e criou uma equipe para 
desenvolver essa tecnologia visando seu uso nos dispositivos da marca. Um dos 
usos mais interessantes foi a conversão de chamadas de funções da biblioteca 
gráfica OpenGL, criadas para uso em processadores gráficos (GPU), em código 
capaz de ser executado utilizando a tecnologia Intel (GMA). 
Originalmente, o projeto LLVM utilizou como front-end o compilador GCC, 
em software livre e licenciado sob a GPLv3. Como os softwares comercializados 
sob a GPL v3 possuem diversas limitações do ponto de vista comercial, a Apple 
optou por desenvolver, do zero, um novo compilador para C, Objective-C e C11. 
O resultado desse esforço foi o Clang. O Clang teve seu código aberto em 2007 
em uma licença que não atrapalha em nada a geração de bons e lucrativos 
negócios. 
Hoje, o LLVM cresceu tanto que está além de um simples projeto de 
compilador. Trata-se de um verdadeiro guarda-chuva, ou compiler 
 
2 A Oracle comprou a Sun em 2009 e hoje detém todos os direitos a tecnologia Java. 
 
 
8 
infrastructure3, sob o qual se encontram centenas de projetos relacionados à 
compilação e eficiência de execução. Tudo graças a uma ideia difundida em 
razão do sucesso do Java sendo otimizada em conceitos mais flexíveis. 
2.1 Os conceitos do LLVM 
Estudamos o processo de compilação em um diagrama de blocos que, 
propositalmente, dividiu os processos em dois grandes blocos: o front-end e o 
back-end. Originalmente, os compiladores incluíam um módulo apenas para 
otimização, no qual o código-objeto e o código de máquina produzidos deveriam 
ser otimizados em um processo inter-relacionado. Nesse caso, a maior parte da 
otimização é feita no código-objeto e nada – ou quase nada – de otimização 
existe no código de máquina. Agora, vamos dividir em três grandes blocos: front-
end, otimizador e back-end. 
A divisão do compilador em três grandes blocos tem algumas vantagens 
interessantes. Imagine, por exemplo, que você deseje criar um compilador para 
C e PHP para x64. Tudo que precisará fazer é criar um front-end para C e outro 
para PHP, e apenas um otimizador e apenas um back-end. Imagine agora que, 
além de compilar C e PHP, você precisa que esses códigos rodem nas 
arquiteturas ARM e x64. Precisará criar só e somente só um back-end extra. A 
essa funcionalidade, em inglês, damos o nome de retargetablity4. Esse conceito, 
justiça seja feita, com uma grande influência do Java, pautou o desenvolvimento 
do LLVM desde que a Apple se tornou a principal patrocinadora. LLVM, 
retargetablity. 
A Figura 3 simplifica o conceito ao mesmo tempo que exemplifica algumas 
linguagens de front-end e algumas arquiteturas-alvo. Se conseguirmos dividir o 
processo de compilação nesses três blocos e cada um desses nos seus próprios 
sub-blocos, podemos ter equipes de desenvolvimento especialistas em cada 
funcionalidade, o que resultará em um processo de compilação mais rápido e 
que utilize menos memória, melhorando a qualidade do aplicativo final e de todo 
o processo de produção e distribuição de software. A grande inovação do projeto 
 
3 A expressão compiler infrastructure não tem relação com a expressão “guarda-chuva”, em 
português, mas é a forma que os participantes do projeto usam para definir o próprio projeto. 
 
4 Podemos traduzir retargetablity com toda a liberdade possível como a “capacidade de trocar de 
alvos”. 
 
 
9 
LLVM está exatamente no coração desse sistema no Otimizador de Código 
Intermediário. 
O código intermediário do projeto LLVM, chamado de Intermediate 
Representation (IR)5, é uma representação do algoritmo original em uma 
linguagem muito próxima do Assembly, desenvolvida de forma que qualquer ser 
humano que saiba ler Assembly possa ler. Considere o LLVM-IR como sendo o 
Assembly de uma máquina hipotética. A linguagem LLVM-IR é muito parecida 
com o Assembly de uma Arquitetura RISC. 
Figura 3 – Diagrama de um compilador flexível adaptado de Lattner (2014) 
 
O LLVM-IR segue osmesmos conceitos que vimos na criação do código-
objeto. Trata-se de uma linguagem fortemente tipada, na forma de três 
endereços – que chamamos anteriormente de 3AC –, e que permite sequências 
lineares de comandos simples, como 𝑎𝑑𝑑, 𝑠𝑢𝑏𝑡𝑟𝑎𝑐𝑡 etc. Isso quer dizer que cada 
comando pode receber até duas entradas em endereços diferentes e produzir 
um resultado em um terceiro endereço, contrastando com as linguagens 
Assembly tradicionais, como a que é utilizada para o x64. Mesmo tendo sido 
desenhado como uma linguagem, o LLVM-IR possui três formas distintas: a 
forma textual, que vimos na Figura 3, uma estrutura de dados especificamente 
para uso em memória e um bytecode de alta eficiência, especialmente 
desenhado para ser armazenado em disco. 
 
 
 
 
5 Em tradução livre, “representação intermediária”. 
 
 
10 
A Figura a seguir apresenta dois fragmentos de código: um em C e o outro 
em LLVM-IR, ao lado do resultado em Assembler x64. Apenas observe que tanto 
o código LLVM-IR quanto o código Assembler x64 foram gerados a partir do 
código em C, usando o complilador Clang 4.0, enquanto o código LLVM-IR foi 
manualmente criado (Lattner, 2014). 
Figura 4 – Comparação de código em C, seu equivalente em LLVM e o código 
Assembly x64 resultante do código em C 
 
TEMA 3 – ENTENDENDO O LLVM-IR 
O código utilizado no LLVM-IR contém um conjunto de estruturas definido 
para representar uma arquitetura virtual, suficientemente complexa para conter 
as operações necessárias ao processamento, mas sem ficar restrita às 
limitações de uma arquitetura específica, como número de registradores e 
características de endereçamento. Esse conjunto de instruções contém um 
número infinito de registradores capazes de armazenar tipos primitivos, como 
𝑖𝑛𝑡𝑒𝑔𝑒𝑟, 𝑓𝑙𝑜𝑎𝑡, e endereços (ponteiros). 
Na arquitetura virtual adotada pelo LLVM, a memória disponível estará 
dividida em três escopos: 𝑔𝑙𝑜𝑏𝑎l, 𝑠𝑡𝑎𝑐𝑘 e um ℎ𝑒𝑎𝑝; todas as operações entre os 
registradores e a memória são feitas apenas com as instruções 𝑙𝑜𝑎𝑑 e 𝑠𝑡𝑜𝑟𝑒 
usando ponteiros tipados. Os objetos no 𝑠𝑡𝑎𝑐𝑘 e ℎ𝑒𝑎𝑝 são alocados utilizando 
 
 
11 
𝑎𝑙𝑙𝑜𝑐𝑎 e 𝑚𝑎𝑙𝑙𝑜𝑐, e são acessados por meio dos ponteiros que serão devolvidos 
por essas operações. Por fim, é preciso observar que, enquanto objetos no 𝑠𝑡𝑎𝑐𝑘 
serão automaticamente liberados ao final da função, objetos ℎ𝑒𝑎𝑝 deverão ser 
manualmente liberados pelo comando 𝑓𝑟𝑒𝑒. 
Essa linguagem, a máquina virtual e o conjunto de instruções foram 
especificamente desenvolvidos para criar uma arquitetura voltada para 
performance e retargetablity, e possui características interessantes: 
1. Comentários em LLVM-IR sempre começam como um ponto-e-vírgula (;) 
e se estendem até o fim da linha. 
2. Identificadores globais começam com o símbolo arroba (@). Dessa forma, 
todas as funções e variáveis globais precisam começar com @. 
Identificadores locais começam com o símbolo de percentual (%). 
3. Identificadores, globais e locais podem conter letras, números e os 
caracteres $. Se consideramos estas três últimas condições, a criação de 
uma expressão regular para localização de identificadores é muito 
simples: [%@][a − zA − Z$. _][a − zA − Z$. _0 − 9] ∗ 
No LLVM-IR, definimos inteiros pela letra i seguida do número de bits que 
serão ocupados na memória. Assim, um inteiro de 32bits será i32. Você poderá 
especificar qualquer quantidade de bits entre 1 𝑒 223 − 1. Na Figura 5 é possível 
ver um exemplo de código LLVM-IR para uma função de adição de dois números 
inteiros de 64 bits: 
Figura 5 – Exemplo de código em LLVM-IR utilizando inteiros de 64 bits 
 
A declaração de arrays é feita com colchetes na forma 
[𝑛ú𝑚𝑒𝑟𝑜 𝑑𝑒 𝑒𝑙𝑒𝑚𝑒𝑛𝑡𝑜𝑠 × 𝑡𝑖𝑝𝑜 𝑒 𝑙𝑎𝑟𝑔𝑢𝑟𝑎 𝑑𝑒 𝑐𝑎𝑑𝑎 𝑒𝑙𝑒𝑚𝑒𝑛𝑡𝑜]. Assim, para 
declarar um array que conterá a string “compiladores”, precisaremos de um tipo 
[13 × 𝑖8], se consideramos que cada letra usará 8bits e que usaremos um 
caracter 𝑁𝑈𝐿𝐿 para marcar o fim do string. Nesse caso podemos declarar uma 
constante global para armazenar a string da seguinte forma: 
@𝑐𝑜𝑚𝑝𝑖𝑙𝑎𝑑𝑜𝑟𝑒𝑠 = 𝒄𝒐𝒏𝒔𝒕𝒂𝒏𝒕 [13 × 𝒊𝟖] 𝒄”𝑐𝑜𝑚𝑝𝑖𝑙𝑎𝑑𝑜𝑟𝑒𝑠\00” 
 
 
12 
Declaramos uma constante (𝒄𝒐𝒏𝒔𝒕𝒂𝒏𝒕), chamada @𝑐𝑜𝑚𝑝𝑖𝑙𝑎𝑑𝑜𝑟𝑒𝑠, do 
tipo [13 × 𝑖8], cujo valor começa com a letra 𝑐 seguida da string completa, entre 
aspas duplas e terminada por 0. Observe que usamos uma string típica do C e, 
nesse caso, todas as strings terminam em \0 (caractere NULL). 
Definimos funções a partir da palavra reservada define seguida de um tipo 
de retorno e, finalmente, o tipo da função: 
𝒅𝒆𝒇𝒊𝒏𝒆 𝒊𝟑𝟐 @treco() { ; código da função } 
Declaramos funções começando com a palavra reservada declare, o tipo 
de retorno da função e, finalmente, seu nome e a lista de argumentos. Todas as 
declarações de funções devem estar no escopo global. 
printf: 𝒅𝒆𝒄𝒍𝒂𝒓𝒆 𝒊𝟑𝟐 puts(𝒊𝟖 ∗) 
Todas as funções terminam com uma declaração 𝑟𝑒𝑡𝑢𝑟𝑛. Existem apenas 
duas formas de declarar o retorno de uma função: 𝑟𝑒𝑡 < 𝑡𝑦𝑝𝑒 > <
𝑣𝑎𝑙𝑢𝑒 > 𝑜𝑢 𝑟𝑒𝑡 𝑣𝑜𝑖𝑑; 
Chamamos as funções utilizando a palavra reservada 𝑐𝑎𝑙𝑙, usando a 
seguinte regra de produção: 
< function return type > < function name > < optional function arguments > 
𝒄𝒂𝒍𝒍 𝒊𝟔 @test( 𝒊𝟑𝟔 %arg1 ) 
Podemos ter uma ideia de como ficaria uma função para imprimir a string 
“compiladores”. A Figura 6 mostra um fragmento de código para esta função. 
Figura 6 – Fragmento de código em LLVM-IR para impressão de "compiladores" 
 
A maior parte das instruções no LLVM-IR utiliza o padrão Three Address 
Code em sua representação quádrupla. De fato, todas as operações aritméticas 
estão nesse formato e incluem todas as operações comuns em linguagens de 
programção: 𝑎𝑑𝑑, 𝑠𝑢𝑏, 𝑚𝑢𝑙, 𝑑𝑖𝑣, 𝑟𝑒𝑚, 𝑛𝑜𝑡, 𝑎𝑛𝑑, 𝑜𝑟, 𝑥𝑜𝑟, 𝑠ℎ𝑙, 𝑠ℎ𝑟 𝑒 𝑠𝑒𝑡𝑐𝑐. 
 
 
13 
Esta última, 𝑠𝑒𝑡𝑐𝑐, representa uma família de instruções de comparação 
(𝑠𝑒𝑡𝑒𝑞, 𝑠𝑒𝑡𝑛𝑒, 𝑠𝑒𝑡𝑙𝑡, 𝑠𝑒𝑡𝑔𝑡, 𝑠𝑒𝑡𝑙𝑒, 𝑠𝑒𝑡𝑔𝑒) que compara valores de uma mesma 
classe de tipos. Em LLVM-IR, essa classe é chamada de 𝑓𝑖𝑟𝑠𝑡 − 𝑐𝑙𝑎𝑠𝑠 e inclui 
os tipos: 
𝑏𝑜𝑜𝑙, 𝑢𝑏𝑦𝑡𝑒, 𝑠𝑏𝑦𝑡𝑒, 𝑢𝑠ℎ𝑜𝑟𝑡, 𝑠ℎ𝑜𝑟𝑡, 𝑢𝑖𝑛𝑡, 
𝑖𝑛𝑡, 𝑢𝑙𝑜𝑛𝑔, 𝑙𝑜𝑛𝑔, 𝑓𝑙𝑜𝑎𝑡, 𝑑𝑜𝑢𝑏𝑙𝑒 𝑒 𝑝𝑜𝑖𝑛𝑡𝑒𝑟 
Uma característica interessante do LLVM-IR é que as instruções são 
polimórficas. Uma instrução 𝑎𝑑𝑑, ou 𝑠𝑢𝑏, por exemplo, pode ser aplicada em 
tipos diferentes. No nosso caso, tanto a instrução 𝑎𝑑𝑑 quanto a instrução 𝑠𝑢𝑏 
podem ser aplicadas em valores do tipo 𝑖𝑛𝑡 e 𝑓𝑙𝑜𝑎𝑡. Esae polimorfismo é uma 
característica de linguagens de alto nível e, no LLVM-IR, acaba reduzindo o 
tamanho do conjunto de instruções necessário para a linguagem (Lattner; Adve, 
2002). 
O sistema de tipos adotados no LLVM-IR é independente da linguagem 
que está sendo compilada; inclui tipos primitivos, tais como: 
𝑣𝑜𝑖𝑑, 𝑏𝑜𝑜𝑙, 𝑠𝑖𝑔𝑛𝑒𝑑 𝑎𝑛𝑑 𝑢𝑛𝑠𝑖𝑔𝑛𝑒𝑑 𝑖𝑛𝑡𝑒𝑔𝑒𝑟𝑠 𝑓𝑟𝑜𝑚 8 𝑡𝑜 64 𝑏𝑖𝑡𝑠, 
𝑓𝑙𝑜𝑎𝑡𝑖𝑛𝑔 − 𝑝𝑜𝑖𝑛𝑡 𝑣𝑎𝑙𝑢𝑒𝑠 𝑖𝑛 𝑠𝑖𝑛𝑔𝑙𝑒 𝑎𝑛𝑑 𝑑𝑜𝑢𝑏𝑙𝑒 𝑝𝑟𝑒𝑐𝑖𝑠𝑖𝑜𝑛, 𝑒 𝑜𝑝𝑎𝑞𝑢𝑒 
Os tipos 𝑜𝑝𝑎𝑞𝑢𝑒 são usados para representar tipos desconhecidos no 
LLVM-IR e são motivo de grande discussão entre os desenvolvedores. Além dos 
tipos primitivos, o programador de LLVM-IR terá acesso a tipos compostos, como 
𝑝𝑜𝑖𝑛𝑡𝑒𝑟𝑠, 𝑎𝑟𝑟𝑎𝑦𝑠, 𝑠𝑡𝑟𝑢𝑐𝑡𝑢𝑟𝑒𝑠, 𝑓𝑢𝑛𝑐𝑡𝑖𝑜𝑛𝑠, todos independentes da linguagem 
original, aquela que está sendo compilada, mas sem perder a possiblidade de 
representação de estruturas complexas como objetos em LLVM-IR. 
Se quiser saber mais, a única fonte confiável e constantemente atualizada 
sobre o assunto é o LLVM Language Reference Manual (Manual de Referência 
da Linguagem LLVM)6. Porém, não se esqueça:o ponto chave dessa tecnologia 
é promover a otimização do código-objeto (representação intermediária). 
Considerando as características do LLVM-IR, é interessante observar o 
resultado de uma função em C, convertida para LLVM-IR. 
 
 
6 Disponível em: http://releases.llvm.org/3.3/docs/LangRef.html#simple-constants 
 
 
14 
Código em C: 
float exemplo(float a, float x, float y) { 
return a * x + y; 
} 
 
Código em LLVM-IR: 
define float @exemplo(float %a, float %x, float %y) { 
%1 = fmul float %a, %x 
%2 = fadd float %1, %y 
ret float %2 
} 
Note que cada instrução obedece ao 3AC e possui no máximo três 
endereços com apenas um do lado direito da igualdade. Além disso, as variáveis 
são atribuídas uma única vez no escopo da função. 
TEMA 4 – OTIMIZANDO O LLVM-IR 
Durante o processo de otimização de código, a principal função do 
compilador é encontrar fragmentos de código que possam ser otimizados e 
substituí-los por código mais eficiente, mantendo o mesmo significado 
semântico, não alterando suas funcionalidades e o mais rapidamente possível. 
Estudamos um pouco sobre otimização na Aula 5 e, agora, vamos trilhar a seara 
da otimização do ponto de vista do LLVM. 
O projeto LLVM ataca o problema da otimização do código intermediário 
na forma de um conjunto de passagens (pass) sucessivas. Uma passagem, ou 
pass, é uma operação realizada sobre uma determinada unidade de código 
LLVM-IR. Essas unidades de código podem ser arquivos, funções ou até mesmo 
blocos básicos. 
Cada uma dessas passagens – ou pass, em inglês, que alguns têm 
traduzido por “passo” – é direcionada para um determinado tipo de otimização. 
Cada passagem pode executar uma ou mais otimizações. Entre elas se 
destacam (Lattner, 2002): 
1. as otimizações tradicionais derivadas do uso do padrão SSA, tais como: 
eliminação de código morto (normal e agressiva); simplificação de 
variáveis; substituição de funções, eliminação de variáveis globais, 
constante propagação etc. 
2. otimizações baseadas no gráfico do fluxo de controle: eliminação de borda 
crítica; construção de laços naturais etc. 
 
 
15 
3. análises e transformações interprocedurais: fusão de variáveis globais; 
análise segura de tipos; eliminação de variáveis globais etc. 
A analysis pass, ou passagem de análise, observa a unidade de código 
LLVM-IR recolhendo informações, como, por exemplo, a estrutura de domínios 
no código. Uma determinada passagem de análise pode chamar outra 
passagem de análise, já que nenhuma delas alterará a unidade de código e 
todos os resultados serão mantidos em cache até que determinada unidade seja 
modificada por outra passagem. 
Outro grupo interessante é formado pelas transformation pass, ou 
passagens de transformação. Nesse caso, como o nome diz, a unidade de 
código será transformada. Contudo, todas as passagens de transformação 
devem, obrigatoriamente, deixar a unidade de código em um estado válido. As 
passagens de transformação utilizam as passagens de análise e, na maior parte 
das vezes, acabam por invalidar o cache das passagens de análise. 
Existem duas passagens que merecem um destaque especial. São elas: 
a combinação de instruções e o aumento de nível. 
O código intermediário gerado pelos compiladores geralmente contém 
pouquíssima informação sobre os tipos envolvidos no algoritmo. Entretanto, 
como a estratégia mais importante do projeto LLVM é justamente o retargetablity, 
foi desenvolvida uma passagem especial para recuperar as informações 
relativas aos tipos permitindo, a fim de que front-ends diferentes possam 
recuperar essa informação e adequar os tipos às características de cada 
linguagem de programação envolvida. 
A combinação de instruções, por outro lado, é voltada para a eficiência do 
código. Trata-se de uma lista de instruções como 𝑎𝑑𝑑 𝑋, 0 = 𝑋, “𝑠𝑢𝑏 𝑋, 𝑋 = 0”, 
“𝑎𝑛𝑑 𝑋, −1 = 𝑋” etc. que permitem um excelente grau de otimização em 
pequenos fragmentos de código (Lattner, 2002). A primeira parte dessa 
otimização trata de colocar todas as declarações em sua forma canônica. 
No caso do LLVM-IR, utilizamos as regras gramaticais para identificar a 
forma canônica e fazer as correções necessárias. Por exemplo: “se uma 
instrução 𝑎𝑑𝑑 tem como operando um valor constante, a forma canônica 
determina que o valor constante será o segundo operando” (Lattner, 2002). O 
processo de canonização é parte do processo de otimização e acaba facilitando 
 
 
16 
a execução de outros processos de otimização, como, por exemplo, a eliminação 
de código morto. 
A combinação de instruções facilita a otimização em janela ou peephole 
optimization7, e é feita em pequenos fragmentos do código intermediário gerado. 
Pense em blocos básicos, como discutido no processo de otimização. Como 
essa substituição é muito rápida e permite uma grande simplificação do código, 
os passos subsequentes podem partir do princípio de que esse tipo de problema 
não existe, o que torna mais simples a criação de passagens de otimização mais 
elaboradas. 
TEMA 5 – COMPILADORES NA WEB 
Nesse começo de século XXI, os aplicativos on-line são tão ou mais 
importantes que qualquer programa criado para rodar em servidores ou 
máquinas individuais. Rodar um aplicativo em um navegador é uma tarefa tão 
comum que já não surpreende ninguém. Nesse cenário, o Javascript é uma das 
linguagens mais proeminentes. 
Damos o nome de javascript engine ao compilador de javascript que roda 
no seu navegador e faz com que seja possível que aplicativos rodem dentro de 
páginas web. Diversos desses engines estão sendo ativamente desenvolvidos: 
V8, desenvolvido pelo Google, e escrito em C++ , o Rhino,  da Fundação Mozilla, 
desenvolvido em Java, e o Chakra (JavaScript)  da Microsoft são apenas alguns 
exemplos interessantes para esta disciplina. 
O V8 foi criado pelo Google para melhorar a velocidade das páginas web 
e acabou sendo escolhido pelo time do projeto node.js como engine para a 
criação de um “servidor” para rodar javascript. Esses dois motivos bastam para 
que nos concentremos neste engine. 
ATENÇÃO: a expressão forma canônica no domínio das linguagens de 
programação afeta aquelas linguagens que possuem uma ou mais formas para 
a mesma declaração. Dizemos que canônica é a forma preferida, ou escolhida 
como padrão. Sendo assim, colocar em forma canônica significa dizer que todas 
as formas gramaticalmente corretas serão modificadas para coincidir com a 
forma canônica. 
 
7 A melhor tradução de peephole seria “olho mágico”, mas aqui temos que ficar com o sentido. 
Esse tipo de otimização tem tal nome por observar pequenos fragmentos de código. 
 
 
17 
Até a versão 5.9, o V8 fez uso de dois compiladores: o full-codegen, um 
compilador com poucos níveis de otimização, mas muito rápido, e o CrankShaft, 
um compilador do tipo JIT capaz de produzir código de máquina extremamente 
otimizado. O código era rapidamente compilado pelo full-codegen e otimizado 
em tempo de execução pelo CrankShaft. Para conseguir esse feito, o V8 utilizava 
diversos threads específicos para cada script em javascript: 
 o thread principal responsável por recolher o código na web, compilá-lo e 
executá-lo; 
 um thread extra apenas para o CrankShaft, de forma que o script poderia 
continuar sendo executado enquanto era executado; 
 um thread apenas para a verificação da eficiência de cada um dos 
métodos chamados, classificando esses métodos em uma ordem de 
compilação para o CrankShaft. 
Além desses três threads, cada script acabava criando alguns outros 
threads para captura de lixo e sincronização do processo de execução. Ainda 
que essa estrutura tenha tido sucesso e permitido a criação de aplicativos web 
tão eficientes quanto dos aplicativos locais, esse número de threads acaba 
causando um excesso de consumo de memória.A partir da versão 5.9, o V8 passou a utilizar uma nova estrutura de 
compilação baseada no Ignítion e no TurboFan, melhorando o tempo de 
execução dos scripts e diminuindo o uso de memória. De fato, a estrutura 
anterior era, na verdade, uma variação da estrutura monolítica de compilação, 
ainda que em duas passagens. A introdução do Ignítion e do TurboFan foi 
acompanhada da criação de um código-objeto intermediário ao qual a equipe de 
desenvolvimento do Javascript deu o nome de Bytecode, como pode ser visto 
na Figura 7: 
 
 
 
18 
Figura 7 – Representação do processo de compilação do V8 
 
O ponto chave dessa nova tecnologia é o novo interpretador chamado de 
Ignition. Sob tal denominação estão os analisadores léxico, sintático e 
semântico, um gerador de código intermediário, Bytecode e três ciclos de 
otimização: reorganização de código e uso de registradores; peephole 
optimization e eliminação de código morto, como pode ser visto na Figura 8: 
Figure 8 – Diagrama de compilação do V8 destacando os módulos de otimização 
o Ignition 
 
 
 
19 
FINALIZANDO 
Esta foi uma disciplina interessante. Partimos do código-fonte de um 
programa em C e fomos até aos mais recentes avanços tecnológicos na área de 
compilação. 
Nesta última aula, é preciso prestar atenção nas mudanças que o advento 
do Java trouxe para o cenário da computação, criando plataformas mais flexíveis 
e eficientes com a inclusão de uma camada extra no processo de compilação, o 
Bytecode. Você precisa entender esse conceito e observar como a sua evolução 
provocou a criação de um ambiente mais rápido na web. É importante que você 
perceba o funcionamento do V8 e, no futuro, não deixe de acompanhar as novas 
soluções nesta área apresentadas pela Fundação Mozilla no Firefox e pela 
Microsoft. 
Não se pode perder de vista o projeto LLVM. Esse projeto está, de uma 
forma ou de outra, envolvido em todos os avanços tecnológicos dos últimos 5 
anos e, ao que tudo indica, estará nessa posição por outros 5 anos. 
A separação definitiva do processo de compilação em três grandes 
módulos (análise, código intermediário e código final) é, neste momento, a 
tendência mais exuberante para a execução de algoritmos. Sejam eles voltados 
para o uso em dispositivos móveis, desktops ou na nuvem. Essa divisão, além 
de promover a flexibilidade, permite que a performance final seja a melhor 
possível. 
Observe, também, os processos de análise léxica, sintática e semântica. 
Além de terem uso na compilação de programa, estão sendo utilizados em 
muitas áreas relacionadas à inteligência artificial, e devem ser seus temas em 
estudos futuros. 
A partir deste ponto você deve se preocupar com a tecnologia de sistemas 
distribuídos – tecnologias como Docker e Kubernets que emprestam esses 
conceitos, além de permitir o máximo de performance, facilitam a instalação, 
distribuição e uso de aplicativos. Mas esse é um assunto para as próximas 
disciplinas. 
 
 
 
20 
REFERÊNCIAS 
AHO, A. V., LAM, M. S., SETHI, R., & ULLMAN, J. D. Compiladores: princípios, 
técnicas e ferramentas. 2a ed. Boston, MA, USA: Pearson Education Inc., 2007. 
BERGMANN, S. D. Compiler Design: Theory, Tools, and Examples. Rowan 
University, 2010. 
CRAMER, T., FRIEDMAN, R., Miller, T., Seberger, D., Wilson, R., & Wolczko, M. 
Compiling Java Just In Time. IEEE Micro, v.17, n. 3, 8, mai/1997. 
HOLUB, A. I.. Compiler design in C. (B. W. Kernighan, Ed.) Englewood Cliffs, 
NJ, USA: Prentice Hall Software Series, 1990. 
LATTNER, C. LLVM. In: BROWN, A. & WILSON G. (ed.). The Architecture of 
Open Source Applications: Elegance Evolution and a few fearless hacks. 2014, 
p. 432. 
LATTNER, C. LLVM: an infrastructure for multi-stage optimization. Urbana, IL, 
USA: University of Illinois at Urbana-Champaign, 2002. 
LATTNER, C.; ADVE, V. LLVM: A Compilation Framework for Lifelong Program 
Analysis & Transformation. University of Illinois at Urbana-Champaign, Urbana, 
IL, USA, 2002. 
ZANDER, C. Languages (Finite State Machines). CSS 448/548 - Introduction 
to Compilers, 2012. Disponível em: 
<http://courses.washington.edu/css448/zander/Notes/langFSM.pdf>. Acesso 
em: 28 nov. 2017

Mais conteúdos dessa disciplina