02. Introduction to Bison

Nice to read

Prezentare teoretică

Bison este un generator de analizoare sintactice. Acesta primește la intrare gramatica unui limbaj pe care o translatează într-un parser pentru acel limbaj. Gramaticile pentru bison sunt descrise folosind o variantă a BNF (Backus Naur Form). Gramaticile BNF pot fi folosite pentru limbaje independente de context. Parserele generate cu Bison pot folosi una din urmatoarele metode de parsare:

  • LALR(1) - Look Ahead Left to Right with a one-token lookahead
  • GLR - Generalized Left to Right

Majoritatea parserelor folosesc LALR(1), care are mai putine capabilitati, insa este semnificativ mai rapid si mai usor de folosit decat GLR. Si noi vom genera tot parsere LALR.

Bison este varianta GNU a yacc (Yet Another Compiler-Compiler).

Fișierul de intrare, ce conține gramatica, are extensia .y (este o convenție). Implicit, programul C generat are același nume ca fișierul de specificație, însă extensia .y se schimbă în .tab.c.

Instalare bison

În cadrul acestui laborator vom folosi pachetul bison standard oferit de distribuție. Pentru mașina virtuală CPL pusă la dispoziție versiunea pachetului bison este 3.0.2.

sudo apt-get install bison

Utilizare bison

bison [OPTIONS] file

Comanda dispune de o varietate de parametri și opțiuni (man bison) dintre care amintim:

  • -t - activarea modului debugging; variabila globală yydebug trebuie să fie setată pe o valoare nenulă, implicit având valoarea 0
  • -v - generează un fișier de ieșire suplimentar ce conține o descriere detaliată a stărilor analizorului și a acțiunilor preconizate pentru fiecare atom din șirul de intrare; tot aici sunt descrise și conflictele depistate la generarea analizorului; numele fișierului este același cu numele fișierului cu cod C, dar cu extensia .output în loc de .tab.c
  • -o - schimbă numele implicit al fișierului de ieșire

Structura fișierului de specificații

Fișierul de specificații este format din patru secțiuni:

%{                         // sau %code {
C declarations
%} 
Bison declarations 
%% 
grammar rules  
%% 
C code

Utilizatorul trebuie să definească cel puțin trei funcții C:

  • main - din care se apelează yyparse
  • yylex - apelată de yyparse pentru obținerea atomilor lexicali
  • yyerror - apelată de yyparse pentru afișarea mesajelor de eroare

Secțiunea de declarații C

Secțiunea de declarații C conține

  • include (s), macro (s)
  • declarații de variabile
  • prototipuri de funcții.

Codul C este inclus în fișierul .c generat așa cum a fost scris în această secțiune, fără modificări.

Secțiunea de declarații Bison

Regulile unei gramatici conțin terminali și neterminali. În gramatica acceptată de bison vom denumi terminalii tokeni. Ei sunt rezultatul analizei lexicale (În cazul de față făcută cu ajutorul flex). În această secțiune se vor defini:

  • tokenii
%token <tip> nume1 nume2
  • simbolul de start al gramaticii (opțional)
%start <tip> nume  

Dacă nu este declarat explicit, simbolul de start va fi neterminalul de la stânga primei reguli a gramaticii.

  • neterminalii

Aceștia vor fi declarați numai dacă se vor defini mai multe tipuri pentru yylval, cum vom vedea mai jos.

%type	<tip> nume1 nume2 ...
  • Unui token i se poate asocia o valoare în acțiunile din lexer, și se poate transmite către parser prin variabila yylval. Implicit, yylval este de tip int. În general, este nevoie să returnăm mai multe tipuri de valori, nu doar int. Tipurile de valori ce pot fi returnate sunt specificate tot în această secțiune într-un union. Odată ce am definit mai multe tipuri pentru tokeni, avem nevoie de tipuri și pentru neterminali.
%union {
int int_value;
char* str_value;
}

În exemplu am pus tipuri de bază, dar pot fi și tipuri definite de utilizator. În acest caz, tipurile pentru tokeni și pentru neterminali pot fi: int_value și str_value.

%token	<int_value>	INTEGER
%token  <str_value>	STR
%type	<int_value>	exp

Dacă este suficient să întoarcem valori de tip int, și nu definim un union de tipuri, atunci <token_type> este omis în declararea tokenilor.

  • Precedența %left. Aceasta va fi detaliată intr-un subcapitol ulterior.

O listă completă cu simbolurile bison găsiți aici

Secțiunea de reguli gramaticale

O producție a gramaticii de forma:

A -> B1B2...Bn

în bison va fi scrisă astfel:

A : B1B2...Bn ;

unde A, rezultatul, este un neterminal, iar B1..Bn sunt o succesiune de terminali și neterminali.

expr :  expr ‘+’ expr ;

Dacă există mai multe reguli pentru același rezultat, fie pot fi scrise separat, fie pot fi separate de '|', ca mai jos:

expr
: NUM { $$ = $1;}
| expr ‘+’ expr { $$ = $1 + $3;}
;

Ca și regulile lexicale, regulile gramaticii pot să aibă o acțiune asociată, ce reprezintă cod C. Sintaxa este

{C statements}

Bison nu verifică corectitudinea codului C din acțiuni ci doar îl copiaza în fișierul .c al parserului, unde va fi verificat de compilatorul de C. Așadar erorile de C vor fi raportate abia la compilarea parserului.

Acțiunea poate fi plasată la sfârșitul unei alternative sau chiar în interiorul acesteia.

  • $$ reprezinta rezultatul, adica valoarea ce va fi atribuită neterminaului de la stânga regulii, cel la care se va reduce regula.
  • $n este al n-lea termen din regula sintactica.

Acțiunile definite în mijlocul unei reguli se folosesc numai în anumite situații, pot folosi doar simboluri anterioare acesteia (fiindcă se execută înainte ca simbolurile următoare regulii să fie parsați) și sunt o sursă de conflicte. Vom reveni asupra acestora în laboratorul următor.

Secțiunea de cod C

Secțiunea de cod C trebuie să conțină:

  • definiția funcției yyerror cu prototipul:
void yyerror(char *s)
  • definiția funcției main care conține un apel către yyparse()

Flex și bison/yacc

Vom vedea acum cum comunică analizorul lexical (flex) cu analizorul sintactic (bison). Bison generează un parser, din nume_gram.y, în fișierul nume_gram.tab.c și un fișier header nume_gram.tab.h. În fișierul header fiecare token are asociat un numar. Fișierul de intrare pentru lex (.l) include fișierul header (cu extensia .tab.h) și folosește numerele atribuite tokenilor în acest fisier.

Intrarea pentru bison este un șir de tokeni furnizat de flex prin funcția yylex().

Numerele asociate tokenilor:

  • Numărul unui token literal este valoarea sa ASCII
[-+]               return *yytext;

va returna valoarea ASCII a caracterului ‘-’ (45), respectiv valoarea ASCII a caracterului ‘+’ (43)

  • Ceilalți tokeni primesc numere începând cu 257

Token-ul error este rezervat pentru tratarea erorilor

Să presupunem că avem o gramatică care definește token-ul INTEGER și acesta a primit valoarea 258 în fișierul header nume_gram.tab.h. Fiecare apariție a instrucțiunii “return INTEGER” din fișierul .l va fi înlocuită, în acest caz cu “return 258”. Pentru a obține tokenii, bison apelează funcția yylex. Altfel spus, fișierul cu extensia .tab.h atribuie fiecărui token/terminal un identificator unic de tip int. Cum acest header este inclus în fisierul *.l, numele tokenilor sunt înlocuite cu identificatorul unic.

Dacă un token are asociată o valoare, aceasta poate fi transmisă către parser prin variabila yylval. (în cazul nostru un int)

Atenție: Nu faceți confuzie între id-ul token-ului dat de bison (identificator unic) și valoarea pe care o trimite din yylex.

Conflicte și ambiguități

Conflictele sunt rezultatul unei gramatici ambigue. Conflictele pot fi fie shift/reduce, fie reduce/reduce

  • Într-un conflict shift/reduce acțiunea implicită este cea de shift.
  • Într-un conflict reduce/reduce acțiunea implicită este de a reduce folosind prima regulă aplicabilă a gramaticii.

Exemplu de conflict shift/reduce datorat ambiguității dangling else:

Când token-ul else este citit și devine token-ul lookahead, ceea ce a fost deja citit se potrivește pe prima regulă și ar putea fi redus. Dar, este de asemenea legal să shiftam else-ul, pentru ca șirul de tokeni de la intrare s-ar putea potrivi pe a doua regulă. Deoarece parser-ul preferă sa shifteze, else-ul va fi atașat if-ului cel mai imbricat.

Dacă parser-ul ar alege să reducă, atunci când poate, și nu să shifteze, else-ul va fi atașat if-ului exterior(primul).

if_stmt:
  "if" expr "then" stmt
| "if" expr "then" stmt "else" stmt
;

Cu această gramatică, secvența de intrare if (exp1) if (exp2) stmt1 else stmt2 poate fi parsată în 2 moduri diferite:

Cazul 1: if (e1) { if (e2) s1 else s2 }
 
Cazul 2: if (e1) { if (e2) s1 } else s2

Acesta este un asa zis conflict shift/reduce legitim. Există cazuri în care gramatica generează astfel de conflicte pentru că a fost scrisa ambiguu, deși se putea scrie și într-o formă neambiguă. În aceste cazuri se recomandă rescriere regulilor cu conflicte.

expr
: expr ’+’ expr
| expr ’*’ expr
|-’ expr
| ID
| NUMBER
;

Gramatica expresiilor generează conflicte când uitam să implementăm asociativitatea și precedența tokenilor. Sunt 2 moduri de a specifica precedența și asociativitatea pentru o gramatică: implicit și explicit. Când o specificăm implicit trebuie să introducem un simbol neterminal pentru fiecare nivel de precedenta. Iată mai jos soluția implicită:

expr
: expr '+' factor
| factor 
;
 
factor
: factor '*' term
| term
;
 
term
: '-' term   
| ID
| NUMBER
;

Gramatica devine mai stufoasă, dar neambiguă. Metoda explicită presupune folosirea explicită a regulilor de precedență suportate de Bison.

Asociativitatea și precedența pot fi specificate în următorul mod:

  • Pentru asociativitate se pot folosi: %left, %right, %nonassoc
  • Precedența operatorilor binari:
    • Se specifică asociativitatea folosind %left
    • Operatorii din același grup au aceeași precedență, iar între grupuri, precedența crește în jos.
  • Pentru a stabili precedența operatorilor unari se foloseste %prec. Acesta schimbă precedența unei reguli la precedența tokenului următor.
%left ’+’ ’-%left ’*’ ’/’
...
expr
: expr ’+’ expr
| expr ’*’ expr
|-’ expr %prec ’*| ID
;

Exercițiu: Încercați să rezolvați conflictul shift/reduce pentru dangling else folosind reguli de precedență.

Instrucțiuni compilare și execuție folosind flex și bison

bison –d ex1.y
flex ex1.l
cc lex.yy.c ex1.tab.c –o ex1
./ex1 input_file

Exerciții de laborator (13p)

În rezolvarea laboratorului folosiți arhiva de sarcini lab02_simple_ops.zip

Exercițiul 1 - simple-ops (5p)

Intrați în directorul 1-simple-ops. calc.y si calc.l sunt analizorul lexical și cel sintactic pentru un calculator care poate evalua expresii aritmetice cu operanzi întregi și operatori '+' si '*'. Expresiile sunt citite dintr-un fișier de intrare (de exemplu add-mul-test), fiecare expresie se încheie cu ';'. Generați cu comanda make fișierul executabil și rulați testul add-mul-test. Modificați apoi calculatorul astfel încât să accepte și operatorii '-' și '/'. Pentru a verifica implementarea, rulați testul div-sub-test.

Exercițiul 2 - variables (5p)

Extindeți exercițiul anterior astfel încât calculatorul să accepte instrucțiuni de atribuire și variabile și să poată evalua expresii care conțin variabile cărora le-a fost atribuită o valoare. Pentru a implementa această extensie, veți avea nevoie de o tabelă de simboli cu ajutorul căreia să țineți minte variabilele cărora li s-a atribuit o valoare și din care să citiți valoarea curentă a unei variabile în cazul în care variabila este operand al unei expresii. Tabela de simboli o puteți implementa folosind std::map<char *, int>.

Exercițiul 3 (bonus - 3p)

Extindeți exercițiul anterior astfel încât calculatorul să accepte parantezarea expresiilor și operatorul unar minus (-), având în vedere prioritatea operatorilor.

cpl/labs/02.txt · Last modified: 2016/10/05 23:18 by bogdan.nitulescu
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0