2012-01-07 8 views
17

Książka "Modern Compiler Design" jest ładną książką o kompilatorach. W kodzie źródłowym denerwuje mnie AST lub abstrakcyjne drzewo składniowe. Załóżmy, że chcemy napisać nawiasowy parser wyrażeń, który analizuje coś takiego: ((2+3)*4) * 2! Książka mówi, że mamy AST takie jak:Parser rekursywny Descent

 ((2+3)*4) * 2 
     / |  \ 
     (2+3) *4 * 2 
     / | \ 
    (2+3) * 4 
    /| \ 
    2 + 3 

Tak powinienem zapisać drzewo w pamięci lub po prostu użyć wywołań rekursywnych; Uwaga: jeśli nie przechowuję go w pamięci, w jaki sposób mogę go przekonwertować na kod maszynowy?

kod Parser:

int parse(Expression &expr) 
{ 
    if(token.class=='D') 
    { 
    expr.type='D'; 
    expr.value=token.val-'0'; 
    get_next_token(); 
    return 1; 
    } 
    if(token.class=='(') 
    { 
    expr.type='P'; 
    get_next_token(); 
    parse(&expr->left); 
    parse_operator(&expr->op); 
    parse(&expr->right); 
    if(token.class!=')') 
     Error("missing)"); 
    get_next_token(); 
    return 1; 
    } 
    return 0; 
} 

Gramatyka jest:

expr -> expr | (expr op expr) 
digit -> 0|1|2....|9 
op -> +|* 

Odpowiedz

18

można przechowywać w pamięci drzewa lub można bezpośrednio wytworzyć wymagany kod wyjściowy. Przechowywanie formularza pośredniego zwykle odbywa się w celu umożliwienia przetwarzania kodu na wyższym poziomie przed wygenerowaniem danych wyjściowych.

W twoim przypadku na przykład łatwo odkryć, że twoje wyrażenie nie zawiera zmiennych, a zatem wynik jest liczbą stałą. Patrząc tylko na jeden węzeł na raz, nie jest to jednak możliwe. Aby być bardziej wyraźnym, jeśli po spojrzeniu na "2 *" wygenerujesz kod maszynowy do obliczenia podwójnego czegoś, co ten kod jest zmarnowany, gdy druga część to na przykład "3", ponieważ twój program obliczy "3", a następnie obliczy podwójne z tego za każdym razem, gdy, a samo ładowanie "6" byłoby równoważne, ale krótsze i szybsze.

Jeśli chcesz wygenerować kod maszynowy, musisz najpierw wiedzieć, jakiego rodzaju maszyna będzie generować kod ... Najprostszym modelem jest podejście oparte na stosie. W tym przypadku nie potrzebujesz logiki alokacji rejestru i łatwo ją skompilować bezpośrednio do kodu maszynowego bez pośredniej reprezentacji. Weźmy pod uwagę ten mały przykład, który obsługuje właśnie liczby całkowite, cztery operacje, jednoargumentowe negacje i zmienne ... zauważysz, że żadna struktura danych nie jest używana: znaki kodu źródłowego są odczytywane, a instrukcje maszynowe są zapisywane na wyjście ...

#include <stdio.h> 
#include <stdlib.h> 

void error(const char *what) 
{ 
    fprintf(stderr, "ERROR: %s\n", what); 
    exit(1); 
} 

void compileLiteral(const char *& s) 
{ 
    int v = 0; 
    while (*s >= '0' && *s <= '9') 
    { 
     v = v*10 + *s++ - '0'; 
    } 
    printf(" mov eax, %i\n", v); 
} 

void compileSymbol(const char *& s) 
{ 
    printf(" mov eax, dword ptr "); 
    while ((*s >= 'a' && *s <= 'z') || 
      (*s >= 'A' && *s <= 'Z') || 
      (*s >= '0' && *s <= '9') || 
      (*s == '_')) 
    { 
     putchar(*s++); 
    } 
    printf("\n"); 
} 

void compileExpression(const char *&); 

void compileTerm(const char *& s) 
{ 
    if (*s >= '0' && *s <= '9') { 
     // Number 
     compileLiteral(s); 
    } else if ((*s >= 'a' && *s <= 'z') || 
       (*s >= 'A' && *s <= 'Z') || 
       (*s == '_')) { 
     // Variable 
     compileSymbol(s); 
    } else if (*s == '-') { 
     // Unary negation 
     s++; 
     compileTerm(s); 
     printf(" neg eax\n"); 
    } else if (*s == '(') { 
     // Parenthesized sub-expression 
     s++; 
     compileExpression(s); 
     if (*s != ')') 
      error("')' expected"); 
     s++; 
    } else { 
     error("Syntax error"); 
    } 
} 

void compileMulDiv(const char *& s) 
{ 
    compileTerm(s); 
    for (;;) 
    { 
     if (*s == '*') { 
      s++; 
      printf(" push eax\n"); 
      compileTerm(s); 
      printf(" mov ebx, eax\n"); 
      printf(" pop eax\n"); 
      printf(" imul ebx\n"); 
     } else if (*s == '/') { 
      s++; 
      printf(" push eax\n"); 
      compileTerm(s); 
      printf(" mov ebx, eax\n"); 
      printf(" pop eax\n"); 
      printf(" idiv ebx\n"); 
     } else break; 
    } 
} 

void compileAddSub(const char *& s) 
{ 
    compileMulDiv(s); 
    for (;;) 
    { 
     if (*s == '+') { 
      s++; 
      printf(" push eax\n"); 
      compileMulDiv(s); 
      printf(" mov ebx, eax\n"); 
      printf(" pop eax\n"); 
      printf(" add eax, ebx\n"); 
     } else if (*s == '-') { 
      s++; 
      printf(" push eax\n"); 
      compileMulDiv(s); 
      printf(" mov ebx, eax\n"); 
      printf(" pop eax\n"); 
      printf(" sub eax, ebx\n"); 
     } else break; 
    } 
} 

void compileExpression(const char *& s) 
{ 
    compileAddSub(s); 
} 

int main(int argc, const char *argv[]) 
{ 
    if (argc != 2) error("Syntax: simple-compiler <expr>\n"); 
    compileExpression(argv[1]); 
    return 0; 
} 

Na przykład uruchamiając kompilator z 1+y*(-3+x) jako wejście dostać jako wyjście

mov eax, 1 
push eax 
mov eax, dword ptr y 
push eax 
mov eax, 3 
neg eax 
push eax 
mov eax, dword ptr x 
mov ebx, eax 
pop eax 
add eax, ebx 
mov ebx, eax 
pop eax 
imul ebx 
mov ebx, eax 
pop eax 
add eax, ebx 

Jednak to podejście do pisania kompilatorów nie dobrze skalować do kompilatora.

Chociaż można uzyskać optymalizację poprzez dodanie optymalizatora "peephole" na etapie wyjściowym, wiele użytecznych optymalizacji jest możliwe tylko patrząc na kod z wyższego punktu widzenia.

Nawet generacja kodu maszynowego może odnieść korzyści, widząc więcej kodu, na przykład, aby zdecydować, który rejestr przypisać do czego lub zdecydować, która z możliwych implementacji asemblera będzie wygodna dla określonego wzorca kodu.

Na przykład to samo wyrażenie może zostać skompilowany przez kompilatora do

mov eax, dword ptr x 
sub eax, 3 
imul dword ptr y 
inc eax 
2

Można utworzyć AST z Dijkstry Shunting-yard algorithm.

W pewnym momencie będziesz mieć całe wyrażenie lub AST w pamięci, chyba że obliczysz natychmiastowe wyniki podczas analizy. Działa to z (pod) wyrażeniami zawierającymi tylko literały lub stałe czasowe kompilacji, ale nie z żadnymi zmiennymi obliczanymi w czasie wykonywania.

4

Dziewięć razy na dziesięć zapiszesz AST w pamięci, jeśli cokolwiek robisz po leksykonie i parsowaniu.

Gdy masz AST można zrobić kilka rzeczy:

  • oceniają go bezpośrednio (być może za pomocą rekurencji, być może przy użyciu swój własny stos)
  • przekształcić go w innej produkcji, takich jak kod w innym języku lub inny rodzaj tłumaczenia.
  • skompilować go do preferowanego rozkazów
  • itp
0

Należy więc zapisać w pamięci drzewa lub po prostu użyć wywołań cyklicznych;

Będziesz używał wywołań rekursywnych w twoim parserze do budowania drzewa w pamięci.

I oczywiście, chcesz zachować drzewo w pamięci, aby je przetworzyć.

Kompilator optymalizacyjny przechowuje kilka reprezentacji kodu w pamięci (i przekształca je).

0

Odpowiedź na pytanie zależy od tego, czy chcesz kompilator, tłumacza, czy coś pośredniego (interpreter owinięty wokół języka pośredniego). Jeśli chcesz mieć interpreter, parser rekurencyjny będzie jednocześnie oceniał wyrażenie, więc nie ma potrzeby trzymania go w pamięci. Jeśli chcesz kompilator, to stałe wyrażenie takie jak przykład może i powinno być zoptymalizowane, ale większość wyrażeń będzie działała na zmiennych i musisz przekonwertować na formę drzewa jako etap pośredni przed konwersją do postaci liniowej.

Hybrydowy kompilator/interpreter zwykle kompiluje wyrażenia, ale nie musi. Często jest to tani sposób napisania programu, który wyprowadza plik wykonywalny, aby po prostu owinąć interpreter kodem źródłowym. Matlab używa tej techniki - kod był rzeczywiście kompilowany, ale występowały problemy z spójnością z wersją interaktywną. Jednak nie pozwolę, aby trudność generowania drzewa parse dla wyrażeń decydowała o problemie.