Os compiladores são ferramentas muito úteis e importantes no mundo da programação e desenvolvimento. A função básica dos compiladores é pegar uma linguagem de "alto nível" (linguagem com maior nível de abstração do hardware) e produzir um código semanticamente equivalente em "baixo nível". A velocidade de execução do código compilado é uma vantagem que se destaca, tendo em vista que o compilador faz otimizações no processo de compilação. Verificações de erros sintáticos e semânticos são outras funcionalidades também executadas pelo compilador.
Por que criar um compilador?
Além dos motivos mencionados anteriormente, a forma mais simples e rápida de entender como os compiladores funcionam é criando um. Neste tutorial iremos criar um compilador simples, porém abordando os principais conceitos da compilação de forma teórica e prática.
Para seguir esse tutorial será necessário o conhecimento de algoritmo e no mínimo uma linguagem de programação. Neste artigo estarei utilizando a linguagem C.
Antes de começarmos a criação do projeto, vamos organizar o nosso projeto:
- Criaremos uma linguagem que trabalha com números inteiros e reais;
- Utilizaremos condições (if, else, etc);
- Utilizaremos expressões aritméticas e relacionais;
Etapas da compilação
As etapas que um compilador executa são: Análise léxica, Análise sintática, análise semântica, otimizador de código e gerador de código objeto. Alguns compiladores tem uma estrutura bem mais complexa, dependendo da linguagem a ser compilada:
Nosso projeto terá as seguintes etapas: análise léxica, análise sintática, análise semântica e gerador de código. O gerador de código vai gerar um bytecode para uma máquina virtual que também vamos implementar. Bytecodes são instruções para uma máquina virtual, como mover um valor para a memória ou para um registrador, por exemplo. Abaixo podemos ver um trecho de código em Python e seus respectivos bytecodes:
def soma(): print(10 + 10)
0 LOAD_GLOBAL 0 (print) 2 LOAD_CONST 1 (20) 4 CALL_FUNCTION 1 6 POP_TOP 8 LOAD_CONST 0 (None) 10 RETURN_VALUE
No final desta série estaremos executando o seguinte código:
INIT VAR max := 10 VAR num INPUT num IF (num < max) INIT PRINT 0 END ELSE INIT PRINT 1 END END
Análise Léxica
A análise léxica consiste em pegar cada caractere de uma linguagem e identificar os padrões da linguagem. Exemplo:
int a = 10
Aqui podemos identificar os seguintes padrões:
- int é uma palavra reservada do compilador;
- a é um identificador/variável;
- = é um sinal de atribuição;
- 10 é um número inteiro;
Ao realizar esse processo estamos identificando os lexemas, que são pedaços de uma string (texto), reconhecidos pelo analisador léxico. Os tokens são um par constituído de um nome e um valor de atributo, sendo este último opcional:
<tipo, valor>
Onde:
- tipo como o nome já diz seria o tipo do token.
- valor é o valor de um token. Alguns tokens não utilizam este campo.
Representação da análise léxica:
Para uma entrada como VAR num := 100 + 10 obtemos os seguintes tokens:
<PC_VAR> <ID, num> <OP_ATR> <T_INT, 100> <OP_MAIS> <T_INT, 10>
Onde:
- <PC_VAR> representa a palavra chave VAR;
- <ID, num> representa um identificador (variável ou função) tendo o valor num;
- <OP_ART> representa o operador de atribuição =;
- <OP_MAIS> representa o operador aritmético mais (+);
- <T_INT, 100>, <T_INT, 10> representa um inteiro com o valor 100 e 10 respectivamente;
Não se esqueça que os tipos de token são definidos por você!
Usarei o gcc como compilador C e o vscode como editor. Iremos começar de uma forma simples, melhorando tudo aos poucos, vamos nessa!
Essa é a estrutura de pastas do nosso projeto. Temos uma pasta para os headers, uma pasta src para o código fonte e a pasta exe, que terá o executável:
Escreva o texto seguinte no arquivo teste.txt:
INIT PRINT 1 + 2 * 3 END
include/lex.h - Aqui simplesmente criamos um módulo para tratar da análise léxica e definimos a função que retorna um token:
#ifndef art_lex_h #define art_lex_h void proximo_token(); #endif
src/lex.c: Esta é nossa função inicial que lê cada caractere e mostra na console. Se o caractere for EOF, significa que não há mais caracteres no arquivo (fim de arquivo) e então paramos o loop:
#include <string.h> #include <ctype.h> #include "glob.h" #include "lex.h" // variável que passará por cada caractere do arquivo static int c; void proximo_token() { while (1) { c = fgetc(file); if (c == EOF) break; else printf("%c", c); } }
includes/glob.h: Este outro arquivo serve para algumas definições globais (que vamos usar em mais de um arquivo). Definimos os tipos dos tokens, um enum para representar o token e uma struct com os campos tipo e val:
#ifndef art_glob_h #define art_glob_h #include <stdio.h> #include <stdlib.h> FILE *file; // linha atual static int linha = 1; // tipos de tokens enum { // palavras chave PC_INIT, PC_END, PC_PRINT, PC_INPUT, PC_VAR, PC_IF, PC_ELSE, // numeros T_INT, // operadores OP_MAIS, OP_MENOS, OP_MULT, OP_DIVI, // ( ) := < > <= >= = T_LPARENT, T_RPARENT, T_ATRIB, T_MENOR, T_MAIOR, T_MENOR_I, T_MAIOR_I, T_IGUAL, // identificador ID }; typedef struct { int tipo; int val; } Token; Token tok; #endif
src/main.c: Na função main iremos tentar abrir um arquivo. Caso haja algum erro o programa sairá mostrando a mensagem de erro. Caso contrário, leremos todos os caracteres do arquivo teste.txt. Vamos ver se funciona:
#include <stdlib.h> #include "lex.h" #include "glob.h" int main(int argc, char *argv[]) { // abrir o arquivo file = fopen(argv[1], "r"); if (file == NULL) { printf("Erro ao abrir o arquivo"); exit(EXIT_FAILURE); } proximo_token(); fclose(file); return EXIT_SUCCESS; // ou return 0 }
Para facilitar o processo de compilação usaremos o seguinte Makefile:
all: gcc -c src/lex.c -I includes -o exe/lex.o gcc src/main.c exe/*.o -I includes -o exe/main rm -r exe/*.o
*Se você estiver em um ambiente Windows saiba que o comando rm -r exe/*.o não funcionará.
Ao executar o Makefile teremos na pasta exe o arquivo compilado. Ao executarmos teremos a seguinte saída:
INIT PRINT 1 + 2 * 3 END
Perfeito! Por agora vamos ignorar espaços em branco, tabulação e quebra de linha.
Criaremos agora uma função que vai criar um token. Por enquanto ela irá apenas mostrar na saída algo como <’+’, 0> <’INIT’, 0>, mas depois vamos mudar isso.
lex.c: Aqui estamos somando 1 na variável linha para uso posterior em caso de nosso compilador ache um caractere que não existe em nossa linguagem (como um “$”, por exemplo):
void makeToken(char *nome, int val) // mostrar o token { printf("<%s, %d>", nome, val); } void voltaPonteiro() // volta um caracter se necessário { if (c != EOF) fseek(file, ftell(file)-1, SEEK_SET); } void proximo_token() { // após o if else if (c == ' ' || c == '\t') continue; else if (c == '\n') { linha++; continue; } }
No código acima temos uma função voltaPonteiro, que é responsável por voltar um caractere no arquivo. Em alguns casos vamos ter que ver o caractere a frente e depois voltar o caractere quando estivermos analisando uma palavra chave. Enquanto o caractere for alfanumérico o ponteiro avança.
Para facilitar o entendimento vamos utilizar a imagem abaixo como exemplo. Aqui reconhecemos a palavra num e paramos no caractere =, ou seja, reconhecemos o token <ID, num>. Quando vamos continuar o processo iniciamos do =, isto é, o próximo caractere é o espaço, seguido do número 1 e assim por diante. Tendo em vista que = é um caractere diferente do que estaríamos esperando iremos esquece-lo e então voltaremos um caractere parando assim no m.
lex.c: vamos reconhecer operadores aritméticos como mais (+), menos (-), multiplicação (*) e divisão (/):
void proximo_token() { // codigo anterior else if (c == '+') makeToken("+", 0); else if (c == '-') makeToken("-", 0); else if (c == '*') makeToken("*", 0); else if (c == '/') makeToken("/", 0); // codigo else
Ao compilar o código e executar teremos algo como:
$ ./exe/main.exe teste.txt INITPRINT1<+, 0>2<*, 0>3END
lex.c: Agora vamos reconhecer os demais números, palavras, parênteses, etc:
else if (c == '+') { makeToken("+", 0); } else if (c == '-') { makeToken("-", 0); } else if (c == '*'){ makeToken("*", 0); } else if (c == '/') { makeToken("/", 0); } else if (c == '(') { makeToken("(", 0); } else if (c == ')') { makeToken(")", 0); } else if (c == ':') { c = fgetc(file); // pega o próximo caractere if (c == '=') // se for '=' sabemos que é o token ':=' makeToken(":=", 0); } else if (c == '<') { c = fgetc(file); // pega o próximo caractere if (c == '=') // se for '=' sabemos que é o token '<=' makeToken("<=", 0); else makeToken("<", 0); } else if (c == '>') { c = fgetc(file); if (c == '=') makeToken(">=", 0); else makeToken(">", 0); } else if (c == '=') { makeToken("=", 0); } else if (isdigit(c)) { numero(); } else if (isalpha(c)) { palavra(); } else { printf("O caracter '%c' na linha %d nao reconhecido.\n", c, linha); exit(EXIT_FAILURE); }
lex.c: Temos duas novas funções, são elas palavra e numero:
void palavra() { char palavra[100] = ""; int pos = 0; while (isalnum(c)) { palavra[pos++] = c; c = fgetc(file); } voltaPonteiro(); if (strcmp(palavra, "INIT") == 0) makeToken("INIT", 0); else if (strcmp(palavra, "PRINT") == 0) makeToken("PRINT", 0); else if (strcmp(palavra, "INPUT") == 0) makeToken("INPUT", 0); else if (strcmp(palavra, "VAR") == 0) makeToken("VAR", 0); else if (strcmp(palavra, "IF") == 0) makeToken("IF", 0); else if (strcmp(palavra, "ELSE") == 0) makeToken("ELSE", 0); else if (strcmp(palavra, "END") == 0) makeToken("END", 0); else makeToken("ID", 0); }
Não é a função mais otimizada que você já viu, mas funciona:
void numero() { int k = 0; while (isdigit(c)) { k = k * 10 + c - '0'; c = fgetc(file); } voltaPonteiro(); makeToken("T_INT", k); }
Testamos o código agora:
$ ./exe/main teste.txt <INIT, 0><PRINT, 0><T_INT, 1><+, 0><T_INT, 2><*, 0><T_INT, 3><END, 0>
Olha só, reconhecemos a maior parte dos tokens de nossa linguagem! Agora que tal mais um teste utilizando outro teste.txt?
INIT VAR max := 10 VAR num INPUT num IF (num < max) INIT PRINT 0 END ELSE INIT PRINT 1 END END
$ ./exe/main teste.txt <INIT, 0><VAR, 0><END, 0><:=, 0><=, 0><T_INT, 10><VAR, 0><END, 0><INPUT, 0><END, 0><IF, 0> <(, 0><END, 0><<, 0><END, 0><), 0><INIT, 0><PRINT, 0><T_INT, 0><END, 0><ELSE, 0><INIT, 0> <PRINT, 0><T_INT, 1><END, 0><END, 0>
Na próxima parte vamos fazer algumas alterações no analisador léxico e depois daremos início ao analisador sintático. Até lá. ?
- 2
- 3