- Como os programas escritos em uma linguagem de alto nível (ex.: Java, C++) são transformados em código de máquina?
- Como podemos garantir que um programa tenha um significado (semântica)?
- Como é realizado o gerenciamento de memória do programa?
- Como nós podemos analisar programas para descobrir propriedades invariantes (constantes) e melhorar o desempenho em tempo de execução?
Ao longo do curso todas essas perguntas ficarão mais claras.
- Até a década de 50, computadores eram programados em assembly.
- Em 1951-1952, Grace Hopper desenvolveu o sistema A-0 para o UNIVAC I.
- Posteriormente ela contribuiu significativamente para o desenvolvimento da linguagem COBOL.
- Em 1957 o compilador de FORTRAN foi desenvolvido pela IBM.
- Com uma equipe liderada por John Backus.
- Na decada de 60 ocorreu o desenvolvimento do primeiro compilador de bootstrapping para LISP.
- Na decada de 70 o design de linguagens/compiladores floresceu*.
- Hoje nós temos milhares de linguagens a disposição (muitas delas pouco utilizadas).
- Algumas com um design melhor que outras.
*Para manter uma fidelidade maior ao material, optamos por manter alguns termos em inglês. Porém suas equivalências serão feitas posteriormente.
- Realiza a análise léxica, sintática e semântica (Lexing e Parsing);
- Faz a conversão de cadeias de caracteres (código fonte) em estruturas de dados internas do compilador.
- Ocorre usualmente em duas fases:
- A etapa de parsing ocorre basicamente em todas as aplicações.
- Ex.: Barra de pesquisa do Google, calendários, etc.
- É o primeiro passo para transformar dados brutos em informação.
- Um pouco de teoria da computação pode te ajudar a entender esses passos (aqui vai um link)
- Expressões regulares
- Gramáticas livre de contexto (automato de pilha)
- Essas abstrações aparecem também na etapa de otimização!
- Existem também muitas ferramentas para te ajudar nessa etapa:
- Ex.: Yacc e Lex, Menhir, Antlr, Parsing Combinators, etc.
- É aqui onde ocorre as checagens de tipos.
- Resolver variáveis, módulos, etc.
- Checagem das operações sobre os tipos (se a operação é válida sobre aquele tipo de variável).
- Infere tipos para sub-expressões.
- Checa outros problemas.
- Traduz recursos de alto nível em um pequeno número de instruções.
- Ex.:
while
,for
,do-while
são traduzidos basicamente em instruções utilizandogoto
. - Ex.: Objetos, closures e funções de ponteiros.
- Ex.: Realiza testes de tipagem, verificações de tamanho de arrays, etc. (Explicitamente).
- Ex.:
- Reescreve sequências instruções custosas em sequências menos custosas.
- Ex.: operações com constantes:
3 + 4
→7
- Ex.: Retira uma instrução invariável de um loop
- Ex.: Paraleliza um loop
- Ex.: operações com constantes:
- Traduz o código intermediário em código de máquina (ou algum código alvo).
- Registra atribuições
- Instruções de seleção
- Instruções de scheduling
- Otimização específica para a arquitetura de cada máquina
- Pessoas facinadas por linguagens de programação e compiladores
- "Por que esta linguagem não inclue essa feature?"
- Programadores de sistemas
- Conheça a ferramenta que você usa todos os dias
- "O que o compilador não pode fazer tão bem?"
- Designers de Arquiteturas
- Interdependência entre compiladores e arquiteturas
- Veja as falhas do Intel iAPX 432 e Intel Itanium em relação a compiladores
- Atualmente, o pessoal da arquitetura usa compiladores também!
- Desenvolvedores de APIs
- Uma linguagem é uma API
- Ex.: A linguagem Hack do Facebook.
- Você vai aprender sobre:
- Aplicações práticas da teoria
- Análise léxica, sintática e interpretadores
- Como linguagens de alto nível são implementadas em código de máquina
- (Um subconjunto) da arquitetura Intel x86
- Mais sobre ferramentas de compiladores como GCC e LLVM
- Um entendimento maior sobre códigos
- Um pouco sobre semântica das linguagens de programação, análises de programas e tipos
- Como manipulas estruturas de dados complexas
- Como ser um melhor programador
- É interessante saber sobre teoria da computação, estruturas de dados e arquitetura de computadores
- A linguagem utilizada nos materiais é OCaml, porém optamos por adaptar ao ReasonML
- Modern Compiler Implementation in ML by Andrew W. Appel
- Recomendado porém não obrigatório
- Na maioria dos casos os matériais disponibilizados serão suficientes
- OCaml
- Ideal para desenvolver compiladores
- Desenvolvida para permitir uma fácil manipulação de árvores de sintaxe abstrata (AST)
- Linguagem funcional com suporte para tipos de dados algébricos polimórficos (genéricos), suporte para módulos e estado mutável.
- Ideal para desenvolver compiladores
-
6 projetos serão desenvolvidos, em sua maioria implementando partes de um compilador
-
Em sua maioria tomam 2-3 semanas
-
Implementados em OCaml (materiais originais) e ReasonML (materiais extras criados por mim)
-
- HelloOCaml
- Refrescar um pouco da codificação em OCaml
- Implementar um interpretador
-
- x86Lite
- x86 Assembler e Simulator
- Entender o código de máquina que buscamos gerar
-
- Compilação LLVMlite
- Compilar um subconjunto simples de LLVM para x86Lite
-
- OAT v1
- Parsing e Lexing (Análise Léxica e Sintática)
- Um front-end simples de um compilador
-
- OAT v2
- Structs, function pointers (ponteiros de funções)
- Uma geração de código (intermediário e alvo) simples
-
- Análise do fluxo de dados e otimização
-
Qualquer dúvida utilizem as Issues do Github como fórum, a comunidade e eu podemos ajudar a resolver. Toda dúvida é válida e deve ser sanada, conhecimento é um bem para todos e todos os níveis dele são válidos :)
- Em 1971, Robin Milner iniciou o projeto LCF em Stanford
- "Lógica de funções computáveis"
- Em 1973, em Edimburgo, Milner implementou seu provador de teoremas e o apelidou de "Meta Language" - ML para os intimos.
- Em 1984, o ML escapou para o mundo selvagem e se tornou "Standard ML"
- SML'97 é a versão mais nova do Standard
- Há uma grande família de compiladores SML:
- SML/NJ: desenvolvido na AT&T Bell Labs
- Poly/ML
- Moscow ML
- ML Kit Compiler
- MLj: Compilador SML para Java bytecode
- MLton: compilador de otimização de programa inteiro
- CakeML: dialeto para ser fácil de programar e raciocinar sobre
- ML2000: falha na padronização revisada
- sML: sucessor do ML (discutido de forma intermitente)
- Veja um pouco mais aqui
- Em 1987, Guy Cousineau reimplementou uma variação do ML
- A implementação foi nomeada como "Categorical Abstract Machine" (CAM)
- Rapidamente, "CAM-ML" se tornou "CAML"
- Em 1991, Xavier Leroy e Damien Doligez escreveram Caml-light
- Compilando CAML para uma máquina virtual com um bytecode simples (bem mais rápido)
- Em 1996, Xavier Leroy, Jérôme Vouillon e Didier Rémy:
- Adicionaram um sistema de objetos para criar o OCaml
- Adicionaram também uma compilação de código nativo
- Muitas atualizações, extensões foram feitas desde então
- A linguagem F# da Microsoft é descendente do OCaml
- Um pouco mais você pode encontrar aqui
- ocaml
- ocamlc: compilador de bytecode
- ocamlopt: compilador de código nativo
- ocamldep: analisador de dependências
- ocamldoc: gerador de documentação
- ocamllex: o gerador de analisadores léxicos (lexer)
- ocamlyacc: gerador de analisadores sintáticos (parser)
- menhir: um gerador de analisadores sintáticos (parser) mais moderno
- ocamlbuild: um gerenciador de compilação
- utop
- opam: gerenciador de pacotes
- Considerando uma linguagem simples com expressões e comandos
- Exemplo de programa para computar fatoriais:
X = 6;
ANS = 1;
whileNZ (X) {
ANS = ANS * X;
X = X + -1;
}
- Para descrever essa linguagem nós precisamos de:
- Sintaxe: que sequência de caracteres formam comandos válidos/programa válido?
- Semântica: o que um programa significa?
<exp> ::=
| <X>
| <exp> + <exp>
| <exp> * <exp>
| <exp> < <exp>
| <integer constant>
| (<exp>)
<cmd> ::=
| skip
| <X> = <exp>
| ifNZ <exp> { <cmd> } else { <cmd> }
| whileNZ <exp> { <cmd> }
| <cmd>; <cmd>
- Sintaxe Concreta (gramática) para uma linguagem imperativa simples
- Escrita na forma de Backus-Naur (Backus-Naur form)
<exp>
e<cmd>
são símbolos não terminais::=
,|
e<...>
são partes da metalinguagem- Palavras chave, como
skip
eifNZ
, e símbolos como{
e+
fazem parte da linguagem objeto
- Precisa representar a sintaxe abstrata (ou seja, ocultar partes irrelevantes da sintaxe concreta)
- Implementar a semântica operacional (ou seja, define o comportamento/ignificado do programa)
- Veja a seguir:
(* Implementação de uma simples linguagem orientada a comandos. *)
(* gramática ------------------------------------------------------------------ *)
(*
gramática BNF para essa simples linguagem:
<exp> ::=
| <X>
| <exp> + <exp>
| <exp> * <exp>
| <exp> < <exp>
| <integer constant>
| (<exp>)
<cmd> ::=
| skip
| <X> = <exp>
| ifNZ <exp> { <cmd> } else { <cmd> }
| whileNZ <exp> { <cmd> }
| <cmd>; <cmd>
*)
(* Sintáxe Abstrata (AST) ---------------------------------------------------- *)
type var = string
type exp =
| Var of var
| Add of (exp * exp)
| Mul of (exp * exp)
| Lt of (exp * exp)
| Lit of int
type cmd =
| Skip
| Assn of var * exp
| IfNZ of exp * cmd * cmd
| WhileNZ of exp * cmd
| Seq of cmd * cmd
(* AST para o exemplo Fatorial ------------------------------------------------ *)
(*
X = 6;
ANS = 1;
whileNZ (x) {
ANS = ANS * X;
X = X + -1;
}
*)
let factorial : cmd =
let x = "X" in
let ans = "ANS" in
Seq (Assn (x, Lit 6),
Seq (Assn (ans, Lit 1),
WhileNZ(Var x,
Seq (Assn(ans, Mul(Var ans, Var x)),
Assn(x, Add(Var x, Lit (-1)))))))
(* Interpretador -------------------------------------------------------------- *)
type state = var -> int
let rec interpret_exp (s:state) (e:exp) : int =
match e with
| Var x -> s x
| Add (e1, e2) -> (interpret_exp s e1) + (interpret_exp s e2)
| Mul (e1, e2) -> (interpret_exp s e1) * (interpret_exp s e2)
| Lt (e1, e2) -> if (interpret_exp s e1) < (interpret_exp s e2) then 1 else 0
| Lit n -> n
let update s x v =
fun y -> if x = y then v else s y
let rec interpret_cmd (s:state) (c:cmd) : state =
match c with
| Skip -> s
| Assn (x, e1) ->
let v = interpret_exp s e1 in
update s x v
| IfNZ (e1, c1, c2) ->
if (interpret_exp s e1) = 0 then interpret_cmd s c2 else interpret_cmd s c1
| WhileNZ (e, c) ->
if (interpret_exp s e) = 0 then s else interpret_cmd s (Seq(c, WhileNZ (e, c)))
| Seq (c1, c2) ->
let s1 = interpret_cmd s c1 in
interpret_cmd s1 c2
let init_state : state = fun _ -> 0
- Vamos observar como converter C em código de máquina
- Aprender sobre o Intelx86
- x86Lite
Acima vemos a conversão de código c somar.c
, para o código assembly somar.s
e então para o código de máquina somar.o
.
- Muitos compiladores da linguagem C geram código de máquina (código objeto) diretamente
- Isso é, sem gerar um código assembly legível
- Linguagem assembly é mais usável para humanos, não para máquinas
- Podemos gerar o arquivo assembly do C utilizando o comando
gcc -S
- Que pode ser compilado para código objeto manualmente utilizando o gas
- Um arquivo fonte em C (
somar.c
) é compilado para um arquivo objeto (somar.o
)- O arquivo objeto contém código de máquina para aquele código C
- Pode conter referências para variáveis e rotinas externas
- Ex.: se
somar.c
chamar a rotinaprintf()
então osomar.o
irá conter uma referências para oprintf()
- Multiplos arquivos são ligados (linked) para produzir um programa executável
- Tipicamente, bibliotecas padrões (como a libc) são incluidas no processo
- Bibliotecas são somente coleções de códigos pré-compilados, nada mais!
- Assembly é uma linguagem muito, muito simples
- Possui estruturas de dados simples
- dados inteiros de 1, 2, 4 ou 8 bytes
- dados de ponto flutuante de 4, 8 ou 10 bytes
- não existem estruturas de dados agregadas como arrays
- Operações primitivas
- Performa operações aritméticas nos registradores ou na memória (Adição, multiplicação, etc)
- Lê dados da memória para o registrador
- Armazena dados do registrador na memória
- Controle de transferência do programa
- Testa uma flag de controle, um salto condicional (salta somente se a flag for 0)
- Outras operações complexas devem ser desenvolvidas com uma sequências (muitas vezes longa) de instruções
- Nós escrevemos instruções em Assembly
- Tais como:
addq %rbx, %rax
- Tais como:
- A máquina interpreta código de máquina em bits
- 101011001100111...
- O assembler cuida para que o código assembly seja compilado para bits
- Isso nos provê muitas conveniencias
- 1978: Intel introduces 8086
- 1982: 80186, 80286
- 1985: 80386
- 1989: 80486 (100MHz, 1µm)
- 1993: Pentium
- 1995: Pentium Pro
- 1997: Pentium II/III
- 2000: Pentium 4
- 2003: Pentium M, Intel Core
- 2006: Intel Core 2
- 2008: Intel Core i3/i5/i7
- 2011: SandyBridge / IvyBridge
- 2013: Haswell
- 2014: Broadwell
- 2015: Skylake (4.2GHz, 14nm)
- AMD tem uma linha paralela de processadores
- Assembly do X86 é muito complexo e complicado:
- 8-, 16-, 32-, 64-bits para valores de pontos flutuantes, etc
- Arquiteturas Intel64 e IA 32 tem um longo número de funções
- Instruções CISC complexas
- Código de máquina: o range do tamanho das intruções variam de 1 byte até 17 bytes
- Muitas decisões de design remanescentes para compatibilidade com versões anteriores
- Difícil de entender, há um grande livro sobre otimizações apenas no nível de seleção de instrução
- X86Lite é um simples subconjunto do X86:
- Somente 64 bits de inteiros sinalizados (+/-)
- Cerca de somente 20 instruções
- Suficiente como indioma de chegada para o propósito do curso.
- Arquivo de registrador: 16 registradores 64-bit
Nome | Propósito |
---|---|
rax |
Acumulador para propósitos gerais |
rbx |
Registrador base, ponteiro para dados |
rcx |
Registrador contador para strings e loops |
rdx |
Registrador de dados para entrada/saída |
rsi |
Registrador de ponteiro, registrador da fonte de strings |
rdi |
Registrador de ponteiro, registrador de destino de strings |
rbp |
Ponteiro base, aponta para a stack frame |
rsp |
Ponteiro da pilha (stack), aponta para o topo da pilha |
r08-r15 |
Registradores para propósitos gerais |
rip
é um registrador "virtual", que aponta para a instrução atualrip
é manipulado diretamente e somente viajumps
, saltos, ereturn
, retornos.
movq SRC, DEST
copia o SRC (fonte) para o DEST (destino)- Aqui DEST e SRC são operandos
- DEST é tratado como uma localização
- Uma localização pode ser um registrador ou um endereço de memória
- SRC é tratado como um valor
- Um valor pode ser o conteúdo de um registrador ou um endereço de memória
- Um valor pode ser uma constante ou um label
- Exemplos:
- Move a constante de 64 bits com valor 4 para o
rax
- Move a constante de 64 bits com valor 4 para o
movq $4, %rax
- Move o conteúdo de
rbx
para orax
movq %rbx, %rax
- X86 apresenta duas formas de sintaxe
- notação AT&T:
source
antes dodestination
, ou seja, fonte antes do destino
movq $5, %rax
movl $5, %eax
- Prevalente nos ecossistemas UNIX/Mac
- Valores constantes são precedidos de um
$
- Registradores são precedidos de um
%
- Sufixos mnemônicos:
movq
vsmov
q
= quadword (4 palavras)l
= long (2 palavras)w
= word (1 palavra)b
= byte
- notação Intel:
destination
antes dosource
mov rax, 5
mov eax, 5
- Usada nas especificações da intel e manuais
- Prevalente no ecossistema Windows
- Variação das instruções são determinadas pelo nome do registrador
- Obs.: X86Lite utiliza a notação AT&T e o formato de intruções e registradores de 64 bits
- Representar inteiros não negativos em bits é simples
- Mas como representamos inteiros negativos em bits?
- Três codificações comuns:
- Sinal e magnitude
- Complemento de um
- Complemento de dois
- Se um inteiro
k
é representado por bitsb1, ..., bn
, então-k
é representado por100...00 - b1...bn
(onde|100...00| = n + 1
)- Equivalente a adicionarmos 1 no complemento de um
- Ex, usando 4 bits:
- 6 =
0110
- -6 =
10000-0110 = 1010 = (1111-0110) + 1
- 6 =
- Usando n bits, podemos representar números com valores de 2 elevado a n
- Exemplo, usando 4 bits, podemos representar inteiros:
-8, -7, ..., -1, 0, 1, ..., 6, 7
- Como o sinal e magnitude e o complemento de 1, o primeiro bit indica se o número é negativo
- Exemplo, usando 4 bits, podemos representar inteiros:
- Mesma implementação de operações aritméticas como para não sinalizados (negativos)
- Ex.: adição usando 4 bits
- Sem sinal:
0001 + 1001 = 1 + 9 = 10 = 1010
- Complemento de dois:
0001 + 1001 = 1 + -7 = -6 = 1010
- Sem sinal:
- Ex.: adição usando 4 bits
- Uma representação de zero!
- Simples para implementar operações
- Não é simétrico ao redor do zero
- Pode representar mais números negativos do que números positivos
- A mais comum representação de números inteiros negativos
- O overflow pode ocorrer também com números inteiros negativos
- Com 32 bits, o maior número inteiro expressivo em complementos de 2 é 2 elevado a 31 menos 1, ou seja,
0x7fffffff
0x7fffffff + 0x1
=0x80000000 = -2^31
(leia - dois elevado a trinta e um)- Menor inteiro que podemos expressar em 32 bits com complemento de 2
0x80000000 + 0x80000000 = 0x0
negq DEST
: negação com complemento de 2addq SRC, DEST
: DEST ← DEST + SRCsubq SRC, DEST
: DEST ← DEST - SRCimulq SRC, Reg
: Reg ← Reg * SRC (multiplação truncada em 128bits)- Exemplos:
addq %rbx, %rax
(rax ← rax + rbx)subq $4, rsp
(rsp ← rsp -4)
- Nota: Reg (em imulq) precisa ser um registrador, não um endereço de memória
notq DEST
: negação lógicaandq SRC, DEST
: DEST ← DEST && SRCorq SRC, DEST
: DEST ← DEST || SRCxorq SRC, DEST
: DEST ← DEST xor SRCsarq Amt, DEST
: DEST ← DEST >> amt (arithmetic shift right)shlq Amt, DEST
: DEST ← DEST << amt (arithmetic shift left)shrq Amt, DEST
: DEST ← DEST >>> amt (bitwise shift right)
- Operandos são os valores operados em instruções assembly
- lmm: inteiro de 64 bits com sinal - "Immediate"/Constante
- Lbl: um 'label'/etiqueta representando um endereço de máquina.
- O assembler/linker/loader resolve esses labels
- Reg: um dos 16 registradores, o valor de um registrador é seu conteúdo
- Ind:
[base:Reg][index:Reg, scale:int32][disp]
- Código de máquina