Como escrever um compilador muito básico

214

Compiladores avançados, como gcccódigos de compilação em arquivos legíveis por máquina, de acordo com o idioma em que o código foi gravado (por exemplo, C, C ++, etc). De fato, eles interpretam o significado de cada código de acordo com a biblioteca e as funções dos idiomas correspondentes. Corrija-me se eu estiver errado.

Desejo entender melhor os compiladores escrevendo um compilador muito básico (provavelmente em C) para compilar um arquivo estático (por exemplo, Hello World em um arquivo de texto). Eu tentei alguns tutoriais e livros, mas todos são para casos práticos. Eles lidam com a compilação de códigos dinâmicos com significados conectados ao idioma correspondente.

Como escrever um compilador básico para converter um texto estático em um arquivo legível por máquina?

O próximo passo será a introdução de variáveis ​​no compilador; imagine que queremos escrever um compilador que compile apenas algumas funções de uma linguagem.

A introdução de tutoriais e recursos práticos é muito apreciada :-)

Googlebot
fonte
Você já experimentou lex / flex e yacc / bison?
Mouviciel 20/09/12
15
@mouviciel: Essa não é uma boa maneira de aprender sobre a construção de um compilador. Essas ferramentas fazem uma quantidade significativa de trabalho duro para você, para que você nunca faça isso e aprenda como é feito.
Mason Wheeler
11
@ Mat, curiosamente, o primeiro dos links fornece 404, enquanto o segundo agora está marcado como duplicado desta pergunta.
Ruslan

Respostas:

326

Introdução

Um compilador típico executa as seguintes etapas:

  • Análise: o texto de origem é convertido em uma árvore de sintaxe abstrata (AST).
  • Resolução de referências a outros módulos (C adia essa etapa até a vinculação).
  • Validação semântica: eliminar declarações sintaticamente corretas que não fazem sentido, por exemplo, código inacessível ou declarações duplicadas.
  • Transformações equivalentes e otimização de alto nível: o AST é transformado para representar uma computação mais eficiente com a mesma semântica. Isso inclui, por exemplo, cálculo antecipado de subexpressões comuns e expressões constantes, eliminando atribuições locais excessivas (consulte também SSA ), etc.
  • Geração de código: o AST é transformado em código linear de baixo nível, com saltos, alocação de registros e similares. Algumas chamadas de função podem ser incorporadas nesse estágio, alguns loops desenrolados etc.
  • Otimização do olho mágico: o código de baixo nível é verificado em busca de ineficiências locais simples que são eliminadas.

A maioria dos compiladores modernos (por exemplo, gcc e clang) repete as duas últimas etapas mais uma vez. Eles usam uma linguagem intermediária de baixo nível, mas independente de plataforma, para a geração inicial de código. Em seguida, esse idioma é convertido em código específico da plataforma (x86, ARM etc.), fazendo aproximadamente a mesma coisa de uma maneira otimizada para a plataforma. Isso inclui, por exemplo, o uso de instruções vetoriais, quando possível, reordenação de instruções para aumentar a eficiência da previsão de ramificação e assim por diante.

Depois disso, o código do objeto está pronto para a vinculação. A maioria dos compiladores de código nativo sabe como chamar um vinculador para produzir um executável, mas não é uma etapa de compilação propriamente dita. Em linguagens como Java e C #, a vinculação pode ser totalmente dinâmica, feita pela VM no momento do carregamento.

Lembre-se do básico

  • Faça funcionar
  • Faça bonito
  • Torne-o eficiente

Essa sequência clássica se aplica a todo o desenvolvimento de software, mas exige repetição.

Concentre-se no primeiro passo da sequência. Crie a coisa mais simples que poderia funcionar.

Leia os livros!

Leia o Livro do Dragão de Aho e Ullman. Isso é clássico e ainda é bastante aplicável hoje.

O design moderno do compilador também é elogiado.

Se esse material é muito difícil para você no momento, leia algumas introduções sobre a análise primeiro; as bibliotecas de análise geralmente incluem introduções e exemplos.

Certifique-se de trabalhar com gráficos, especialmente árvores. Essas coisas são as coisas que os programas são feitos no nível lógico.

Defina bem o seu idioma

Use a notação que desejar, mas verifique se você tem uma descrição completa e consistente do seu idioma. Isso inclui sintaxe e semântica.

Está na hora de escrever trechos de código em seu novo idioma como casos de teste para o futuro compilador.

Use seu idioma favorito

Não há problema em escrever um compilador em Python ou Ruby ou qualquer outra linguagem que seja fácil para você. Use algoritmos simples que você entende bem. A primeira versão não precisa ser rápida, eficiente ou com recursos completos. Ele só precisa estar correto o suficiente e fácil de modificar.

Também é bom escrever diferentes estágios de um compilador em diferentes idiomas, se necessário.

Prepare-se para escrever muitos testes

Seu idioma inteiro deve ser coberto por casos de teste; efetivamente será definido por eles. Familiarize-se com sua estrutura de teste preferida. Faça testes desde o primeiro dia. Concentre-se nos testes 'positivos' que aceitam o código correto, em vez da detecção de código incorreto.

Execute todos os testes regularmente. Corrija os testes quebrados antes de continuar. Seria uma pena acabar com uma linguagem mal definida que não pode aceitar código válido.

Crie um bom analisador

Geradores de analisadores são muitos . Escolha o que quiser. Você também pode escrever seu próprio analisador a partir do zero, mas só vale a pena se a sintaxe de sua língua é morto simples.

O analisador deve detectar e relatar erros de sintaxe. Escreva muitos casos de teste, positivos e negativos; reutilize o código que você escreveu ao definir o idioma.

A saída do seu analisador é uma árvore de sintaxe abstrata.

Se o seu idioma tiver módulos, a saída do analisador pode ser a representação mais simples do 'código de objeto' gerado. Existem várias maneiras simples de despejar uma árvore em um arquivo e carregá-la rapidamente.

Crie um validador semântico

Muito provavelmente, seu idioma permite construções sintaticamente corretas que podem não fazer sentido em determinados contextos. Um exemplo é uma declaração duplicada da mesma variável ou a passagem de um parâmetro de um tipo errado. O validador detectará esses erros olhando para a árvore.

O validador também resolverá referências a outros módulos escritos em seu idioma, carregará esses outros módulos e utilizará no processo de validação. Por exemplo, esta etapa garantirá que o número de parâmetros passados ​​para uma função de outro módulo esteja correto.

Novamente, escreva e execute muitos casos de teste. Casos triviais são tão indispensáveis ​​na solução de problemas quanto inteligentes e complexos.

Gerar código

Use as técnicas mais simples que você conhece. Geralmente, não há problema em traduzir diretamente uma construção de linguagem (como uma ifinstrução) em um modelo de código pouco parametrizado, não muito diferente de um modelo HTML.

Mais uma vez, ignore a eficiência e concentre-se na correção.

Segmente uma VM de baixo nível independente de plataforma

Suponho que você ignore coisas de baixo nível, a menos que esteja profundamente interessado em detalhes específicos de hardware. Esses detalhes são sangrentos e complexos.

Suas opções:

  • LLVM: permite geração eficiente de código de máquina, geralmente para x86 e ARM.
  • CLR: direciona-se ao .NET, principalmente x86 / Windows; tem um bom JIT.
  • JVM: tem como alvo o mundo Java, bastante multiplataforma, tem um bom JIT.

Ignorar otimização

A otimização é difícil. Quase sempre a otimização é prematura. Gere código ineficiente, mas correto. Implemente o idioma inteiro antes de tentar otimizar o código resultante.

Obviamente, otimizações triviais podem ser introduzidas. Mas evite qualquer coisa esperta e cabeluda antes que seu compilador esteja estável.

E daí?

Se tudo isso não for muito intimidador para você, continue! Para um idioma simples, cada uma das etapas pode ser mais simples do que você imagina.

Ver um 'Hello world' a partir de um programa que seu compilador criou pode valer a pena.

9000
fonte
45
Essa é uma das melhores respostas que eu já vi.
gahooa
11
Acho que você perdeu uma parte da pergunta ... O OP queria escrever um compilador muito básico . Eu acho que você vai além de muito básico aqui.
marco-fiset 21/09/12
22
@ marco-fiset , pelo contrário, acho que é uma resposta excelente que diz ao OP como fazer um compilador muito básico, enquanto aponta as armadilhas para evitar e definir fases mais avançadas.
SMCI
6
Essa é uma das melhores respostas que eu já vi em todo o universo do Stack Exchange. Parabéns!
Andre Terra
3
Ver um 'Hello world' a partir de um programa que seu compilador criou pode valer a pena. -
INDEED
27

Let's Build a Compiler , de Jack Crenshaw , embora inacabado, é uma introdução e um tutorial eminentemente legíveis.

A Construção de Compilador de Nicklaus Wirth é um livro muito bom sobre os fundamentos da construção simples de compilador. Ele se concentra na descida recursiva de cima para baixo, o que, convenhamos, é MUITO mais fácil do que lex / yacc ou flex / bison. O compilador PASCAL original que seu grupo escreveu foi feito dessa maneira.

Outras pessoas mencionaram os vários livros de Dragon.

John R. Strohm
fonte
11
Uma das coisas legais de Pascal é que tudo precisa ser definido ou declarado antes de ser usado. Portanto, ele pode ser compilado em uma única passagem. O Turbo Pascal 3.0 é um exemplo, e há muita documentação sobre os internos aqui .
tcrosley
11
O PASCAL foi projetado especificamente com a compilação de uma passagem e a vinculação em mente. O livro do compilador de Wirth menciona compiladores multipass e acrescenta que ele conhecia um compilador PL / I que levou 70 (sim, setenta) passes.
John R. Strohm
A declaração obrigatória antes do uso remonta à ALGOL. Tony Hoare teve seus ouvidos retidos pelo comitê da ALGOL quando tentou sugerir a adição de regras de tipo padrão, semelhantes às do FORTRAN. Eles já sabiam dos problemas que isso poderia criar, com erros tipográficos nos nomes e regras padrão criando bugs interessantes.
John R. Strohm
11
Aqui está uma versão mais atualizada e terminou do livro pelo autor original si mesmo: stack.nl/~marcov/compiler.pdf Edite a sua resposta e adicione :)
soneto
16

Na verdade, eu começaria escrevendo um compilador para o Brainfuck . É uma linguagem bastante obtusa para programar, mas possui apenas 8 instruções para implementar. É o mais simples possível e existem instruções C equivalentes para os comandos envolvidos, se você achar a sintaxe desanimadora.

Engenheiro Mundial
fonte
7
Mas, assim que você tiver seu compilador BF pronto, precisará escrever seu código :(
500 - Internal Server Error
@ 500-InternalServerError usa o método de subconjunto C #
World Engineer
12

Se você realmente deseja escrever apenas código legível por máquina e não direcionado a uma máquina virtual, precisará ler os manuais da Intel e entender

  • uma. Vinculando e carregando código executável

  • b. Formatos COFF e PE (para Windows), como alternativa, entender o formato ELF (para Linux)

  • c. Entenda os formatos de arquivo .COM (mais fáceis que o PE)
  • d. Entender montadores
  • e Entenda os compiladores e o mecanismo de geração de código nos compiladores.

Muito mais difícil do que foi dito. Sugiro que você leia Compiladores e Intérpretes em C ++ como ponto de partida (por Ronald Mak). Como alternativa, "vamos criar um compilador" por Crenshaw é OK.

Se você não quiser fazer isso, também poderá escrever sua própria VM e escrever um gerador de código direcionado para essa VM.

Dicas: Aprenda Flex e Bison PRIMEIRO. Em seguida, crie seu próprio compilador / VM.

Boa sorte!

Aniket Inge
fonte
7
Eu acho que segmentar LLVM e não código de máquina real é a melhor maneira disponível hoje.
9000
Concordo, já acompanho o LLVM há algum tempo e devo dizer que foi uma das melhores coisas que vi nos últimos anos em termos de esforço do programador necessário para atingi-lo!
Aniket Inge
2
E o MIPS e usar o spim para executá-lo? Ou misturar ?
@ MichaelT Eu não usei o MIPS, mas tenho certeza de que será bom.
Aniket Inge
Conjunto de instruções @PrototypeStark RISC, processador do mundo real que ainda está em uso hoje (entendendo que será traduzível em sistemas incorporados). O conjunto completo de instruções está na wikipedia . Olhando na rede, há muitos exemplos e é usado em muitas classes acadêmicas como um alvo para a programação de linguagem de máquina. Há um pouco de atividade nele na SO .
10

A abordagem DIY para compilador simples pode ser assim (pelo menos é assim que meu projeto uni era):

  1. Defina a gramática do idioma. Sem contexto.
  2. Se sua gramática ainda não é LL (1), faça-o agora. Observe que algumas regras que parecem bem na gramática simples de CF podem ficar feias. Talvez seu idioma seja muito complexo ...
  3. Escreva Lexer, que corta o fluxo de texto em tokens (palavras, números, literais).
  4. Escreva um analisador de descida recursiva de cima para baixo para sua gramática, que aceita ou rejeita a entrada.
  5. Adicione geração de árvore de sintaxe ao seu analisador.
  6. Escreva um gerador de código de máquina na árvore de sintaxe.
  7. Lucro e cerveja, como alternativa, você pode começar a pensar em como fazer um analisador mais inteligente ou gerar um código melhor.

Deve haver muita literatura descrevendo cada etapa em detalhes.

MaR
fonte
O sétimo ponto é sobre o que o OP está perguntando.
Florian Margaine 20/09/12
7
1-5 são irrelevantes e não merecem tanta atenção. 6 é a parte mais interessante. Infelizmente, a maioria dos livros segue o mesmo padrão, depois do infame livro do dragão, prestando muita atenção na análise e na saída de transformações de código fora do escopo.
SK-logic