Lex和Yacc應用方法(二).再識Lex與Yacc
草木瓜 20070314
早在二十世記七十年代之前,編寫編譯器一直是一個非常費時的工作。但到了1975這
一年這一切卻發生了重大轉變,首先Stephen C. Johnson Lesk在貝爾實驗室完成了
Yacc開發,為了配合yacc更好的協作, Mike Lesk和Eric Schmidt又完成了lex。從
而Lex和yacc成為電腦編譯領域的重要理論,而這些工具也極大地簡化了編寫編譯
器的工作。
後來Robert Corbett和Richard Stallman在此基礎上又完成了bison。Jef Poskanzer,
Vern Paxson 也對lex作了大量改進,稱為flex。
<本系列文章的地址:http://blog.csdn.net/liwei_cmg/category/207528.aspx>
一、Lex理論
Lex使用Regex從輸入代碼中掃描和匹配字串。每一個字串會對應一個動作。
通常動作返回一個標記給後面的剖析器使用,代表被匹配的字串。每一個正則表達
式代表一個有限狀態自動機(FSA)。我們可以用狀態和狀態間的轉換來代表一個(FSA)。
其中包括一個開始狀態以及一個或多個結束狀態或接受狀態。
我們以上文《Lex和Yacc應用方法(一).初識Lex》第一個例子詳細說明:
exfirst.l
%{
#include "stdio.h"
%}
%%
[/n] ; A
[0-9]+ printf("Int : %s/n",yytext); B
[0-9]*/.[0-9]+ printf("Float : %s/n",yytext); C
[a-zA-Z][a-zA-Z0-9]* printf("Var : %s/n",yytext); D
[/+/-/*///%] printf("Op : %s/n",yytext); E
. printf("Unknown : %c/n",yytext[0]); F
%%
這裡使用一相對簡單的輸入檔案 file.txt
i=1.344+39;
bcd=4%9-333
我們假定,
Lex 系統建立一動態列表:內容+規則+狀態
Lex 狀態:1 接受 2 結束
接受表示該元素可以做為模式比對
結束表示該元素已完成模式比對
讀入“i”
[尋找元素]尋找相鄰且狀態為1的元素,無元素,
[匹配規則]D,
[新增列表<元素1>共置資料](存在則覆蓋)狀態為1,規則為D,內容為"i"。
[操作順序符] 1
讀入“=”
[尋找元素]尋找相鄰且狀態為1的元素,“i=”尋找匹配規則,無規則
[置上一元素]<元素1>狀態為2
[匹配規則]F,
[新增列表<元素2>共置資料](存在則覆蓋)狀態為1,規則為F,內容為"="
[操作順序符] 2
讀入“1”,
[尋找元素]尋找相鄰且狀態為1的元素,“=1”尋找匹配規則,無規則
[置上一元素]<元素2>狀態為2
[匹配規則]B,
[新增列表<元素3>共置資料](存在則覆蓋)狀態為1,規則為B,內容為"1"
[操作順序符] 3
讀入“.”
[尋找元素]尋找相鄰且狀態為1的元素,“1.”尋找匹配規則,無規則,但有潛在規則C
[匹配規則]F,
[新增列表<元素4>共置資料](存在則覆蓋)狀態為1,規則為F,內容為"."
[置上一元素]<元素3>狀態為1
[操作順序符] 4
讀入“3”
[尋找元素]尋找相鄰且狀態為1的元素,“1.3”尋找匹配規則,有規則
[置起始元素]狀態為1,規則為C,內容為"1.3"
[操作順序符] 3 組合元素的起始操作符
讀入“4”
[尋找元素]尋找相鄰且狀態為1的元素,“1.34”尋找匹配規則,有規則
[置起始元素]狀態為1,規則為C,內容為"1.34"
[操作順序符] 3 組合元素的起始操作符
讀入“4”
[尋找元素]尋找相鄰且狀態為1的元素,“1.344”尋找匹配規則,有規則
[置起始元素]狀態為1,規則為C,內容為"1.344"
[操作順序符] 3 組合元素的起始操作符
讀入“+”
[尋找元素]尋找相鄰且狀態為1的元素,“1.344+”尋找匹配規則,無規則
[匹配規則]E,
[新增列表<元素4>共置資料](存在則覆蓋)狀態為1,規則為E,內容為"+"
[置上一元素]<元素3>狀態為2
[操作順序符] 4
...
最後解析結果為
內容 規則 狀態
<元素1> i D 2
<元素2> = F 2
<元素3> 1.344 C 2
<元素4> + E 2
...
上面列出的演算法是僅屬於個人的分析,是相對直觀且便於理解的,也可以參照這個演算法
用C語言類比出lex的效果。不過真正的Lex演算法肯定是更為複雜的理論體系,這個沒有
接觸過,有興趣可以參看相關資料。
二、yacc的BNF檔案
個人認為lex理論比較容易理解的,yacc要複雜一些。
我們先從yacc的文法說起。yacc文法採用BNF(Backus-Naur Form)的變數規則描
述。BNF文法最初由John Backus和Peter Naur發明,並且用於描述Algol60語言。BNF
能夠用於表達上下文無關的語言。現代程式語言大多數結構能夠用BNF來描述。
舉個加減乘除例子來說明:
1+2/3+4*6-3
BNF文法:
優先順序
E = num 規約a 0
E = E / E 規約b 1
E = E * E 規約c 1
E = E + E 規約d 2
E = E - E 規約e 2
這裡像(E運算式)這樣出現在左邊的結構叫做非終結符(nonterminal)。像(num
標識符)這樣的結構叫終結符(terminal,讀了後面內容就會發現,其實是由lex返回
的標記),它們只出現在右邊。
我們將 “1+2/3+4*6-3-2”逐個字元移進堆棧,如下所示:
.1+2/3+4*6-3
1 1.+2/3+4*6-3 移進
2 E.+2/3+4*6-3 規約a
3 E+.2/3+4*6-3 移進
4 E+2./3+4*6-3 移進
5 E+E./3+4*6-3 規約a
6 E+E/.3+4*6-3 移進
7 E+E/3.+4*6-3 移進
8 E+E/E.+4*6-3 規約a
9 E+E/E+.4*6-3 移進
10 E+E/E+4.*6-3 移進
11 E+E/E+E.*6-3 規約a
12 E+E/E+E*.6-3 移進
13 E+E/E+E*6.-3 移進
14 E+E/E+E*E.-3 規約a
15 E+E/E+E*E-.3 移進
16 E+E/E+E*E-3. 移進
17 E+E/E+E*E-E. 規約a
18 E+E+E*E-E. 規約b
19 E+E+E-E. 規約c
20 E+E-E. 規約d
21 E-E. 規約d
22 E. 規約e
我們在實際運算操作中是把一個運算式逐步簡化成一個非終結符。稱之為“自底
向上”或者“移進歸約”的分析法。
點左面的結構在堆棧中,而點右面的是剩餘的輸入資訊。我們以把標記移入堆棧開
始。當堆棧頂部和右式要求的記號匹配時,我們就用左式取代所匹配的標記。概念上,
匹配右式的標記被彈出堆棧,而左式被壓入堆棧。我們把所匹配的標記認為是一個控制代碼,
而我們所做的就是把控制代碼向左式歸約。這個過程一直持續到把所有輸入都壓入堆棧中,
而最終堆棧中只剩下最初的非終結符。
在第1步中我們把1壓入堆棧中。第2步對應規則a,把1轉換成E。然後繼續壓入和歸
約,直到第5步。此時堆棧中剩下E+E,按照規則d,可以進行E=E+E的合并,然而輸入信
息並沒有結束,這就產生了“移進-歸約”衝突(shift-reduce conflict)。在yacc中產
生這種衝突時,會繼續移進。
在第17步,E+E/E,即可以採用E+E規則d,也可以採用E/E規則b,如果使用E=E+E
規約,顯然從演算法角度是錯誤的,這就有了運算子的優先順序概念。這種情況稱為“歸約
-歸約”衝突(reduce-reduce conflict)。此時yacc會採用第一條規則,即E=E/E。這
個內容會在後面的執行個體做進一步深化。
三、十分典型的利用lex和yacc類比的簡單+-*/計算機。
A.樣本
最有效方法是樣本學習,這樣首先給出全部樣本檔案。
lex檔案:lexya_a.l
%{
#include <stdlib.h>
void yyerror(char *);
#include "lexya_a.tab.h"
%}
%%
[0-9]+ { yylval = atoi(yytext); return INTEGER; }
[-+*//n] return *yytext;
[/t] ;/* 去除空格 */
. yyerror("無效字元");
%%
int yywrap(void) {
return 1;
}
yacc檔案:lexya_a.y
%{
#include <stdlib.h>
int yylex(void);
void yyerror(char *);
%}
%token INTEGER
%left '+' '-'
%left '*' '/'
%%
program:
program expr '/n' { printf("%d/n", $2); }
|
;
expr:
INTEGER { $$ = $1; }
| expr '*' expr { $$ = $1 * $3; }
| expr '/' expr { $$ = $1 / $3; }
| expr '+' expr { $$ = $1 + $3; }
| expr '-' expr { $$ = $1 - $3; }
;
%%
void yyerror(char *s) {
printf("%s/n", s);
}
int main(void) {
yyparse();
return 0;
}
進行編譯:
bison -d lexya_a.y
lex lexya_a.l
cc -o parser *.c
運行:
./parser
輸入計算式,斷行符號會顯示運算結果
如:
1+2*5+10/5
13
9+8/3
11
10+2-2/2-2*5
1
這裡有兩個檔案lexya_a.y和lexya_a.l。lexya_a.y是yacc檔案,bison -d lexya_a.y
編譯後會產生 lexya_a.tab.c lexya_a.tab.h。lex檔案lexya_a.l中頭聲明已包括了
lexya_a.tab.h。這兩個檔案是典型的互相協作的樣本。
B.分析
(1)定義段和預定義標記部分
yacc檔案定義與lex十分相似,分別以%{ }% %% %%分界。
%{
#include <stdlib.h>
int yylex(void);
void yyerror(char *);
%}
這一段十分容易理解,只是標頭檔一些引用說明。稱為“定義”段。
%}
%token INTEGER
%left '+' '-'
%left '*' '/'
%%
%}和%%這一段可以看作預定義標記部分。%token INTEGER 定義聲明了一個標記。
當我們編譯後,它會在lexya_a.tab.c中產生一個剖析器,同時會在lexya_a.tab.h
產生包含資訊:
# define INTEGER 257
其中0-255的之間的標記值約定為字元值,是系統保留的後定義的token。
lexya_a.tab.h其它部分是預設產生的,與token INTEGER無關。
# ifndef YYSTYPE
# define YYSTYPE int
# define YYSTYPE_IS_TRIVIAL 1
# endif
extern YYSTYPE yylval;
lex檔案需要包含這個標頭檔,並且使用其中對標記值的定義。為了獲得標記,yacc
會調用yylex。yylex的傳回值類型是整型,可以用於返回標記。而在yylval變數中保
存著與返回的標記相對應的值。
yacc在內部維護著兩個堆棧,一個分析棧和一個內容棧。分析棧中儲存著終結符和
非終結符,並且記錄了當前剖析狀態。而內容棧是一個YYSTYPE類型的元素數組,對於分
析棧中的每一個元素都儲存著一個對應的值。例如,當yylex返回一個INTEGER標記時,
把這個標記移入分析棧。同時,相應的yacc值將會被移入內容棧中。分析棧和內容棧的
內容總是同步的,因此從棧中找到對應的標記值是很容易的。
比如lex檔案中下面這一段:
[0-9]+ { yylval = atoi(yytext); return INTEGER; }
這是將把整數的值儲存在yylval中,同時向yacc返回標記INTEGER。即內容棧存在
了整數的值,對應的分析棧就為INTEGER標記了。yylval類型由YYSTYPE決定,由於它的
預設類型是整型,所以在這個例子中程式運行正常。
lex檔案還有一段:
[-+*//n] return *yytext;
這裡顯然只是向yacc的分析棧返回運算子標記,系統保留的0-255此時便有了作用,
內容棧為空白。把“-”放在第一位是防止Regex發現類似a-z的歧義。
再看下面的:
%left '+' '-'
%left '*' '/'
%left 表示左結合,%right 表示右結合。最後列出的定義擁有最高的優先權。因
此乘法和除法擁有比加法和減法更高的優先權。+ - * / 所有這四個算術符都是左結合
的。運用這個簡單的技術,我們可以消除文法的歧義。
註:關於結合性,各運算子的結合性分為兩種,即左結合性(自左至右)和右結合性
(自右至左)。例如算術運算子的結合性是自左至右,即先左後右。如有運算式x-y+z則y
應先與“-”號結合, 執行x-y運算,然後再執行+z的運算。這種自左至右的結合方向
就稱為“左結合性”。而自右至左的結合方向稱為“右結合性”。 最典型的右結合性運
算符是賦值運算子。如x=y=z,由於“=”的右結合性,應先執行y=z再執行x=(y=z)運算。
(2)規則部分
%%
program:
program expr '/n' { printf("%d/n", $2); }
|
;
expr:
INTEGER { $$ = $1; }
| expr '*' expr { $$ = $1 * $3; }
| expr '/' expr { $$ = $1 / $3; }
| expr '+' expr { $$ = $1 + $3; }
| expr '-' expr { $$ = $1 - $3; }
;
%%
這個規則乍看起來的確有點暈,關鍵一點就是要理解yacc的遞迴解析方式。
program和expr是規則標記,但是作為一個整體描述運算式。
先看expr,可以由單個INTEGER值組成,也可以有多個INTERGER和運算子組合組
成。
以運算式“1+4/2*3-0”為例,1 4 2 3 都是expr,就是expr+expr/expr*expr-expr
說到底最後還是個expr。遞迴思想正好與之相反,逆推下去會發現expr這個規則標記
能表示所有的數值運算運算式。
瞭解了expr後,再看program,首先program可以為空白,也可以用單單的expr加下
“/n”斷行符號符組成,結合起來看program定義的就是多個運算式組成的檔案內容。
回過頭,建立如下檔案input:
[root@localhost yacc]# cat input
1+5/5+4*5
3+9+2*10-9
2/2
3-9
運行則結果如下:
[root@localhost yacc]# ./parser < input
22
23
1
-6
粗略有了概念之後,再看lex如何執行相應的行為。
以 expr: expr '+' expr { $$ = $1 + $3; }為例:
在分析棧中我們其實用左式替代了右式。在本例中,我們彈出“ expr '+' expr ”
然後壓入“expr”。我們通過彈出三個成員,壓入一個成員來縮小堆棧。在我們的代碼中
可以看到用相對位址訪問內容棧中的值。如$1,$2,這樣都是yacc預定義可以直接使用的
標記。“$1”代表右式中的第一個成員,“$2”代表第二個,後面的以此類推。“$$”
表示縮小後的堆棧頂部。在上面的動作中,把對應兩個運算式的值相加,彈出內容棧中的三
個成員,然後把得到的和壓入堆棧中。這樣,保持分析棧和內容棧中的內容依然同步。
而
program:
program expr '/n' { printf("%d/n", $2); }
說明每當一行運算式結束時,列印出第二個棧值,即expr的值,完成字元運算。
四.後記
如果到這裡能完全理解所述內容,對於lex和yacc就有些感覺了。後面文章會做進一步
的深入。