日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

实现一个脚本引擎

發布時間:2025/6/15 编程问答 30 豆豆
生活随笔 收集整理的這篇文章主要介紹了 实现一个脚本引擎 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.
譯者序
由于我最近有一個計劃,就是寫一個適應性很強的腳本語言,這個語言將主要用來處理劇情,希望能夠用于絕大多數需要劇情的游戲.于是最近開始找一些關于script的東西來看看,當我在flipcode看到這篇的overview時,見它提到了unreal的腳本系統和字節碼和虛擬機,就開始在沒有完全讀完的情況下翻譯這一系列文章(共9篇).在翻譯中加一些注釋...希望不要誤導您,還有為了豐富原文我也加入了幾個自己的程序.因為這是我首次翻譯這種系列文章,在腳本引擎方面也缺乏經驗,所以難免會有一些不當之處,還請大家批評指正.?

目錄
Part I: 概述?
Part II: 詞法分析器?
Part III:語法分析器?
Part IV: 符號表 & 語法樹?
Part V: 語義檢查 & 中間代碼生成?
Part VI: 優化?
Part VII:虛擬機(The Virtual Machine)?
Part VIII:可執行代碼?
Part IX:高級主題?

版本列表
2000年2月至2000年3月 第一稿

聲明
原文發布時間是1999年5月至8月,原文作者是Jan Niestadt?
如果有中文版權的話版權屬譯者(即燕良)所有?
如果您覺得有用當然可以轉載,不過請保持文章的全部內容,如果您有什么修改意見請與譯者聯系?
暫時不可用作商業用途

Part I:概述
序言

OK,在你的引擎中你想有一個腳本語言.

首先,確定你需要那種腳本語言;Henry Robinson 已經寫過了一個各種腳本語言區別的介紹(如果你還沒讀過就去讀一下吧),在這個系列教程中我將討論一個象虛幻腳本(Unreal script)那樣的編譯器/虛擬機系統.

下一步,你需要知道兩件事:怎樣實現那樣一個腳本引擎,還有腳本引擎不僅僅是酷而且在實際中十分有用的一些理由.

這里是我想到的一些:

  • 有用的新語言特性象狀態,隱藏代碼(latent code),等等.
  • 一個沙盤環境(sandbox environment)不會導致游戲引擎的崩潰.
  • 不需要游戲內部引擎的只是或者重新編譯游戲引擎就可以編寫游戲的內容.
  • 完全的獨立于平臺的腳本代碼

但是也有一些不利因素:

  • 相對較慢--腳本的運行至少比可執行代碼的執行慢15倍.
  • 限制--腳本不能用來建立實際的視覺效果(部分原因是它速度的缺點).
  • 編寫游戲內容的人必須學習一種新的語言.

當然我們不會因為這些就停下來,我們已經準備好實現我們的想法了.現在,從哪里開始呢?


必須閱讀的東西

在虛幻(Unreal)發布前很久我就開始了.我瀏覽他們的技術站點,并且發現了虛幻腳本參考文檔(UnrealScript reference document).我當然聽說過虛幻腳本,但是并不真正知道他是什么.我閱讀了這些文檔,覺得一個腳本語言的想法實在是很酷.我要自己寫一個,然后連接到一個游戲引擎,以便我的游戲的整個世界都可以輕松的建立新的內容.

幸運的是我有一個學期的編譯器構造課程(燕良注:我也剛學了一個學期的編譯原理,還考了92分,嘻嘻,不過Julien當時竟然只用兩個月就考了98分,佩服,佩服),并且作為一個實際的任務我曾經實現過一個非常非常簡單的Pascal編譯器.我開始并行工作,更好,編譯器.我已經有一個接受C的子集的可工作的版本,但是我用了2周來編碼,其內部結構相當的可怕,...我不得不完整的重新設計那東西.我相信你在某些地方有與我相似的經驗...現在我依然在做這東西,并且學到了很多關于編譯器的知識.

現在,接觸一點有用的信息吧.

首先,我建議所有想要編寫一個編譯器的人弄一本龍之書.你們中的大多數(尤其是象我這樣的計算機系學生)可能已經知道了這個(燕良注://shake).告訴那些不知道的人,我是在說Aho, Sethi 和 Ullman 所著的<<Compilers - Principles, Techniques and Tools?>>(ISBN 0-201-10194-7). 因為他的封面有一條龍,所以得到了龍之書的名字.相信我,所有對編譯器有所了解的人都讀過這本書(燕良注:國內有賣嗎?).

這本書從1986年就不曾修改過,這是因為從60年代編譯器的設計基本技術就沒有變過.當然,這本書不涉及面向機器的優化,但是有其他書.此外,我們想要編譯出字節碼(bytecode)而非機器碼.

其次,如果你想要得到一個快速實現字節碼腳本語言的預覽GamaSutra的一篇文章.那是一個值得一讀的關于實現Jedi騎士腳本語言的故事.那里的所有的東西我也將涉及,但是那仍然是值得一讀的.


我們需要什么

一個編譯器基本上包括一下這些組成部分:

  • 一個符號表,其中存儲所有的符號及其信息,例如類型,范圍,等等.
  • 一個詞法分析器,他的功能是將字符流(例如源文件)轉換為記號(例如關鍵詞,操作符等等).
  • 一個語法分析器(parser),他的功能是讀取記號流,并建立語法樹.
  • 一個語義檢查器,用來檢查語法樹的語義錯誤.
  • 一個中間代碼生成器,用來把語法樹轉換為中間代碼
  • 一個優化器,用來優化中間代碼
  • 一個代碼生成器,用來從中間代碼生成字節碼.
  • 最后但不是最少,字節碼將要在其上執行的虛擬機



如果你編寫完了所有這些組件,組合到一起,它們將成為一個完整的腳本語言系統.


這是全部嗎?

受到了一點點打擊嗎?畢竟決定使用腳本不是那么酷,DLL真的是唯一的路嗎?沒關系,我將很快討論每一組件的細節,它們的絕大多數并不是那么困難.建立一個完整的腳本引擎是一個巨大的工作,但是,本質上是構造你自己的代碼.

在這個教程的剩余部分我們將開發一個簡單的編譯器/虛擬機系統.盡管他沒有地方象是一個完整的腳本語言,但是他實現了上面提到的所有組件.我在回想一個操作字符串的簡單語言.

現在檢查上面的連接,了解他們.順便提一下,感謝所有的評論.

Quote!
"But the plans were on display ..."
"On display? I eventually had to go down to the cellar to find them."
"That's the display department."
"With a torch."
"Ah, well the lights had probably gone."
"So had the stairs."
"But look, you found the notice didn't you?"
"Yes," said Arthur, "yes I did. It was on display in the bottom of a locked filing cabinet stuck in a disused lavatory with a sign on the door saying Beware of the Leopard."

HHG 1:1

Part II:詞法分析器
序言

我總是說在學一個東西的時候例子總是不能足夠簡單.這就是為什么當我想要設計一個包含所有完整編譯器應該有的特性的簡單的編譯器時感到很累.我拼湊了一個字符串處理語言,它使用象C那樣的語法,有BASIC那樣的功能.下面是用我們的語言的正確編寫的一個程序.

print "Please enter your name > ";
input name;
if (name == "Jan")
{ // string comparison
??? name = "my creator"; // string assignment
??? happy = "yes";
}
print "Thank you, " + name + "!\n" + // string concatenation
"You've just made a simple program very happy!";

就象你看到的,他沒有構造象函數,類等等那樣的功能,它甚至沒有數值類型.這就是最終的東西,但是,他是很容易擴展的.

但是在接觸那個之前我們還一很長的路要走--記得上次的組件列表嗎?今天我們將實現第一個:詞法分析器或稱短語分析器.這是一個很好的熱身,因為它不是編譯器中真正難的部分.

OK,準備好了嗎?


是什么,為什么和怎么作

首先我猜你想要知道詞法分析器是什么和為什么我們要用它?詞法分析器的任務是把源文件的字符流轉換成記號流.本質上它查看連續的字符然后把它們識別為"單詞".

我們當然可以寫一個函數用來把源文件當前位置取得的字符串與我們的所有關鍵字比較,但是這將是不可忍受的慢.所以我們使用有限自動機來識別單詞(燕良注:就是DFA了,設計過程是正則式==>NFA==>DFA==>最小化).如果你不知道它是什么,好吧,你不需要知道(燕良注:如果你想知道,請看本文最后的附注).

關于詞法分析器的一個基本情況是我們不需要作實際的艱苦的工作,我們使用一個叫作"LEX"的程序生成詞法分析器.這是一個標準的UNIX程序,他也有幾個win 32的版本(燕良注:我有一個FLEX.exe).這里有完整的LEX手冊的HTML版.

好的,現在你知道了詞法分析器作什么和我們將如何制作它.現在你可以下載?*tut2.zip*并且看一眼那些代碼.這部分的源程序是string.l(燕良注:LEX源程序)和main.cpp以及幾個頭文件.請注意ZIP文件中含有目錄結構,flex.exe在主目錄,這部分的代碼在tut2\目錄.


LEX規則

LEX需要一些簡單的規則來生成我們的詞法分析器.在介紹規則之前,先讓我們看一下LEX源程序的分段.

說明部分
%%
規則部分
%%
輔助程序部分

<說明部分>包含一些正則式(regular expression )的宏(正則式在LEX手冊中有解釋,想徹底了解它請看這里).這些告訴LEX我們使用的LETTER,DIGIT, IDENT(標識符,通常定義為字母開頭的字母數字串)和STR(字符串常量,通常定義為雙引號括起來的一串字符)是什么意思.(燕良注:呵呵,多熟悉呀.)

這部分也可以包含一些初始化代碼.例如用#include來使用標準的頭文件和前向說明(forward references).這些代碼應該再標記"%{"和"%}"之間,你馬上將看到我include了一個lexsymb.h 文件.

<規則部分>可以包括任何你想用來分析的代碼;我們這里包括了忽略所有注釋中字符的功能,傳送ID名稱和字符串常量內容到主調函數和main函數的功能.

lexsymb.h 文件聲明了詞法分析器函數將要返回的記號的符號.它還聲明了一個'yylval' 共用體(union),用來傳送額外的信息(例如標識符的名字)到主調函數;這里我們使用這個特殊的共用體可以使下一部分更清晰些.

現在讓我們看一下實際的規則.我使用/* */作注釋;LEX是一個相當老的程序,所以它著支持//引導的注釋.順便提一下,我們將使用LEX生成C程序,C++版的LEX程序也有,但是標準的UNIX LEX產生C代碼.我們想要使這東西便攜,不是嗎? (燕良注: LEX源文件 .L--->FLEX--->C源文件,默認文件名是lex.yy.c)


"if" {return IF;}
"=" {return ASSIGN;}
";" {return END_STMT;}
{IDENT} {Identifier (); /* identifier: copy name */
return ID;}
{STR} {StringConstant (); /* string constant: copy contents */
return STRING;}
"//" {EatComment();} /* comment: skip */
\n {lineno++;} /* newline: count lines */
{WSPACE} {} /* whitespace: (do nothing) */
. {return ERROR_TOKEN;} /* other char: error, illegal token */

我刪去了一些非常簡單的規則.就象你看到的那樣,每一條規則開始部分是LEX將要識別的樣式,接下來是一些代碼告訴LEX當規則匹配后作什么(這部分代碼可以包含C++代碼,因為LEX只是簡單把它們的拷貝到輸出文件中).記住最頂端的規則被最優先評估,這通常很重要.

頭3條規則十分的簡單,它們只是識別一個字符串,然后返回相對應記號的符號.你可以改變這些字符串,例如你想使用":="來作賦值操作符.

第4行是第一條有趣的規則:它使用了IDENT宏,它識別不滿足前面的條件的字母/數字串.如果匹配,它將調用Identifier()函數,此函數把yytext(保存當前記號的文本)的內容賦復制到一個新字符數組.詞法分析器返回ID記號,主調函數可以使用'yylval->str'指針來訪問標識符非名稱.STR對于字符常量實現同樣的功能.

下一行規則處理注釋,換行和空白.注意行號將被計數,將來我們在出錯信息中將使用它.最后一行告訴LEX如果輸入不能滿足上面所有的規則(表達式"."的意思是:除了'\n'以外的所有字符),我們應該返回一個錯誤記號,然后讓主調函數決定作什么.

LEX的源程序可以使用下面的命令行來編譯成LEX.CPP:
????????flex -olex.cpp string.l

ZIP中還包含一個MSVC 6.0 (string.dsp)的Project文件,我相信它在5.0中也能工作,但是我不確定.Project為string.l設置了一個自定義命令行,所以它可以被自動編譯.

不幸的是LEX使用一個非標準的頭文件,unistd.h,它不能在windows中使用.在主目錄中有一個空的unistd.h文件,請添加主目錄到include路徑中(in MSVC:Tools->Options->Directories->Include).

lex.cpp包含一個滿足我們規則的完整的詞法分析器.它是那么簡單!主程序只是使用詞法分析函數讀取一個記號,然后顯示記號的名字和值(它是ID還是STR).你可以試著加入一些測試數據,然后觀察詞法分析器如何處理它們;隨機的字符序列通常被識別為ID,我們不使用的字符,例如'$'引發一個ERROR_TOKEN.你也可以試試example.str (在主目錄).


情況會變好的

好吧,我們現在有了一個可以"讀"的程序.遺憾的是它對它讀的是什么和這些是否符合我們的標準依然沒有概念.它只是接受它知道的一些記號.

看來它需要知道語法,驚人的巧合,語法正是我們下一部分將要討論的.下一個組件是語法分析器,它的功能是找出程序的結構并且檢查語法.

這樣就變得真正有趣了.我們將能使程序成為一個編譯器,它將接受一些東西,并不只是因為它可以接受幾乎所有東西,而是因為它知道這個程序的語法是正確的.我知道你肯定和我一樣激動,但是我不得不等到下一部分...

Quote!
"And so it was only with the advent of pocket computers that the startling truth became finally apparent, and it was this:

Numbers written on restaurant bills within the confines of restaurants do not follow the same mathematical laws as numbers written on any other pieces of paper in any other parts of the Universe.

This single fact took the scientific world by storm. It completely revolutionized it. So many mathematical conferences got held in such good restaurants that many of the finest minds of a generation died of obesity and heart failure and the science of maths was put back by years."

HHG 2:7

燕良的附注:詞法分析的手工設計舉例

程序的功能是把下面這些實常數轉換成相應的科學計數表示:

  • "Pi"轉換成0.314159265359E1
  • "E"轉換成0.271828182846E1
  • "Degree"轉換成0.174532E-1
  • 一般的實常數按值轉換,例如456==>0.3456e4,0.0098==>0.98E-2

設計思路:

Pi,E,Degree可以當作關鍵字來處理,不是本程序的主要部分,本程序的主要功能是識別一下各種形式的實常數:

  • a.
  • b.
  • a.b
  • a.E[+/-]c
  • .bE[+/-]c
  • a.bE[+/-]c
  • aE[+/-]c

識別上述形式實常數的DFA為:

參見程序:CONSTANT.zip

附送另一個程序,識別C語言源程序的LEX源程序.?

Part III:語法分析器
序言

前一部分的執行可以作一件很好的工作:把程序轉換成記號.所有的關鍵詞,操作符,分隔符,標識符和常量都被立即識別和報告.然而,你可以輸入:

{ this ) = "pointless" + ;

然后程序將只是接受它,并且高高興興的產生一個記號的列表.因為它不清楚我們想要允許什么東西(我不知道上面的"語句"要做什么).我們必須能夠識別輸入程序的語法結構(或者它的缺點).

我們借助語法分析器來作這件事,語法分析器用來找出程序的結構并且檢查所有的語法錯誤.


一點語言理論

我們怎么告訴語法分析器我們的語言是什么樣呢?我們可以使用一個叫作BNF范式(Backus-Naur Form )的東西來描述語法(syntax或grammar).這種描述方法使用組成程序的基本概念.舉例說明,表達式可以是在其他任何東西之中,標識符或者字符串常量(expressions can be, among other things, identifiers or string constants).在BNF范式中,它可以寫成下面的形式:

expression: identifier | string;

一個打印語句或者輸入語句:

statement
: PRINT expression END_STMT
| INPUT identifier END_STMT
;

(記住PRINT,INPUT和END_STMT都是詞法分析器返回的記號)

現在,一個程序可以被表示成下面這種語句列表的方式:

program: | program statement;

上式說明一個程序可以是空或者由程序加上一個語句構成,這里說的語句是一個遞歸定義,它可以是一串語句(燕良注:這個文法是左遞歸的).

那么,我們已經用BNF范式定義的語言包含下面的語句:

print a;
(燕良注:這個語句可以使用下面的推導過程:
program: program statement;
program: program statement;(應用產生式? program: 空)
program: PRINT expression END_STMT;(應用產生式 expression: identifier)
program: PRINT identifier END_STMT;
經過詞法分析后上面的語句形成的記號流與此式匹配;
以上是最左推導;下面是最右推導
program: program statement;
program: program PRINT expression END_STMT;
program: program PRINT identifier END_STMT;
program: PRINT identifier END_STMT;
根據上述推導可以畫出語法樹,對于無二義性的文法,所有的推導畫出的語法樹都是一樣的.)
print "Hello";
input name;

Not legal is:

input "Hello";

通過我們的定義,input語句(燕良注:指產生式statement:INPUT identifier END_STMT;)只能作用于一個標識符,而不能是字符串常量.

我們可以使用BNF范式正規的描述我們的語言的整個語法.注意這些現在還不包含語義,于是這條語句:

a = (b == c);

縱使它沒有意義它也將被語法分析器接受(我們正在試圖把一個布爾值賦給一個字符串變量).語義將在下一階段作檢查.

太好了,我們現在知道的語言描述法足夠來建立我們語法分析器.


看上去很熟悉!

語法分析器也可以用一個外部程序來產生.這個叫作Yacc(一個標準的UNIX工具,就像是LEX);我們將使用一個叫作Bison的改良版(get it?).Bison的手冊有又可以在這里(http://www.monmouth.com/~wstreett/lex-yacc/lex-yacc.html)找到.Yacc文件(extension .y) 的分段實際上與LEX文件十分的相似.

說明部分
%%
規則部分
%%
輔助程序部分

<說明部分>包括記號的定義,類型信息,還有在前一部分我們看到的yylval共用體.那就是為什么我們使用一個共用體:Yacc使用同一共用體來在兩個不同的"語言概念"之間傳遞信息,例如表達式,語句,程序.根據這些定義,Yacc為我們產生lexsymb.h(實際上它建立的是parse.cpp.h文件,不過parser.bat把它改名了).

就象LEX文件一樣,在這部分同樣可以在標記"%{"和"%}"之間包含一些初始化代碼.在這部分的教程中沒有用到這個功能,但還是可以增加一些你需要的附加的代碼.

規則是特定的一些BNF范式,用來解釋前一部分.

Yacc有一個惡劣的陷阱,那就是你的語言必須使用LR(1)文法描述...這究竟是什么意思在龍之書中有詳細的解釋(第4.5,關于自下向上分析),LR(1)文法基本的意思是語法分析器還必須能夠在查看當前語法記號或者最多預讀一個符號就能說出使用什么樣的語法規則.下面的語法規則會產生一個移進/歸約沖突(shift/reduce conflict).(關于更多的文法理論可以參見最后我加的附注)

A:
| B C
| B C D
| D E F

沖突產生在當你從輸入文件中讀了一個'B',而預讀符號是'C'時,因為他們可以被組合(這兩種產生式最終都將產生一個文法符號);問題是第2個產生式以'D'為結尾,而且第3個以它我起始:當語法分析器讀取到了'C',而且預讀的是'D'時,它不能決定是否歸類為A2或A1后面跟著一個A3(燕良注:請注意我們只預讀一個文法符號)!盡管這個完整的文法定義可能根本就沒有二義性,但是對于語法分析器它卻是有的,因為語法分析器只能預讀一個文法符號.Yacc把這種不確定性稱為移進/歸約沖突或歸約/歸約沖突.

呵呵,別讓這些嚇著你.看一下這些規則.最重要的一條可能就是這條語句規則了:

statement
: END_STMT {puts ("Empty statement");}
| expression END_STMT {puts ("Expression statement");}
| PRINT expression END_STMT {puts ("Print statement");}
| INPUT identifier END_STMT {puts ("Input statement");}
| if_statement {puts ("If statement");}
| compound_statement {puts ("Compound statement");}
| error END_STMT {puts ("Error statement");}

你能看到,這里定義了我們的語言所有的語句類型,后面的代碼是告訴語法分析器當發現了每個產生式時應該作什么.我認為這條規則是十分漂亮的.有一件事:"Errorstatement"告訴Yacc當分析一條語句時如果它遇到了一個語法錯誤后應該作什么(例如一個非法的記號或者一個不合時宜的記號).在這種情況下它會查找下一個END_STMT記號,然后繼續分析后面的東西.語法錯誤會始終報告到在main.cpp中定義的yyerror()函數,所以我們的編譯器會使用一個恰當的方法來處理它.如果在你的.y文件中沒有提供任何一個錯誤規則,那么你的語法分析器遇到語法錯誤就會定下來,這可不是很好.

也許你在奇怪為什么會有這么多的表達式規則呢:expression, equal_expression, assign_expression, concat_expression 和 simple_expression.這是為了描述操作符的優先級.如果語法分析器看到了這個:

if (a == b + c)

它應該知道它不應該先計算a==b然后試著把這個布爾值計算結果與一個字符串變量c相加(燕良注:這里的側重點不是數據類型,而是算符的優先級).這些不同的表達式規則確定了唯一的語句的語法分析方法.花點時間好好看看它;它能夠工作.

另外一個問題是當分析下面的語句時:

if (a == b) if (c == d) e = f; else g = h;

語法分析器不知道else屬于那個if語句(內層的還是外層的);它可以認為你的意思是:

if (a == b) {if (c == d) e = f;} else g = h;

但是作為一個所有語言都遵循的慣例,else與內層的if匹配.

因為沒有辦法通過改變我們的規則來解決這個問題,Yacc將會報告一個移進/歸約沖突.這個沖突可以簡單的在說明部分加上這行來禁止:

%expect 1

這意味這Yacc應該預期沖突1(Yacc should expect 1 conflict).Yacc將把else與最近的if配對,正象我們想要的那樣.這就是它發現任何沖突的默認的解決方法.

一旦你理解了BNF范式,Yacc文件是非常的不解自明的.如果你還有什么不清楚的地方,你可以給我來mail或者在messageboard上提出問題.

Yacc的源文件可以使用這條命令來編譯:

bison --defines --verbose -o parse.cpp

如果你得出了什么沖突,看一下輸出的parse.cpp文件,那里包含沖動的細節(即使沒有錯誤,那仍然是一個有趣的文件).如果你陷入了任何不能解決的沖突,可能把你的.y文件發給我,我會看一下的.

如果每件事都OK了(在樣例代碼中應該是這樣的),那你在parse.cpp中就得到了一個可工作的語法分析器.我們的主程序要做的就是調用yyparse()函數,這個輸入文件就會按我們的要求處理了.

再試試example.str文件,然后看一下它產生的錯誤.錯誤?是的,沒錯,我在第13行最后忘了一個';'.呵呵,它很棒吧?


Whew!?

今天我們作了很多事.我們學習了一些形式語言理論,如何使用Yacc,為什么Yacc對它支持的文法如此的挑剔,如何描述操作符的優先級.在最后,我們制作了一個可以工作的語法分析器.

好吧,我想最難的部分就在后面.如果你理解了這些,休息一下吧.然而,我在LR(1)文法上忽略了很多.給我來信或者發到messageboard讓我來澄清那些問題.歡迎任何的問題和評論,讓我知道有人在讀這些東西.

下面是什么呢?下次我們大概要寫兩個新的組件:符號表和語法樹.到那之后,你有一周來試驗這些代碼.提示:試著找到一個接受C風格的while語句的編譯器.


Quote!?

"The major problem is quite simply one of grammar, and the main work to consult in this matter is Dr Dan Streetmentioner's Time Traveller's Handbook of 1001 Tense Formations. It will tell you for instance how to describe something that was about to happen to you in the past before you avoided it by time-jumping forward two days in order to avoid it. The event will be described differently according to whether you are talking about it from the standpoint of your own natural time, from a time in the further future, or a time in the further past and is further complicated by the possibility of conducting conversations whilst you are actually travelling from one time to another with the intention of becoming your own father or mother."

HHG 2:18


Downloads

Download the tutorial code (tut3.zip) (5k)


燕良的附注:說明一下文中的幾個名詞

  • 關于BNF范式
    文中的
    expression: identifier | string;
    可以讀作expression定義為identifier或string.
    這個式子包括兩個產生式.
  • 關于LR(1)分析原文中提到的不多,所以在這里補充一下.但是完整的語法分析理論恐怕您還是要找本書來看.不過,如果只是想使用工具的話我想看了原文和這里的補充應該差不多了.
    LR(1)分析是自下向上分析的一種,自下向上的分析實際上是最右推導的逆過程,名字中的'L'表示自左向右讀入記號,'R'表示最后推導,'1'表示預讀一個記號.
    實際上LR(1)分析也好,LL(1)等等各種分析也好,其最終目的都是得出一個狀態矩陣.通過這個矩陣程序才能知道下一步該怎么作,動作主要有兩種,一是移進,即讀入下一記號,二是歸約,就是用產生式的左部來代替產生式的右部,其中如果是規約,還要說明用那個產生式歸約.
    驗證文法的LR(1)性是一件比較復雜的事.本文只講實現不講設計,其實設計出好的文法我覺得很有挑戰性.:P
  • 這篇文章還是挺不錯的,語法中的兩個難點:操作符優先級和if-else問題都提到了.這是應該注意的地方.


Part IV:符號表 & 語法樹
序言

如果我們想要用上兩部分我們建立的詞法分析器和語法分析器來作些有用的事的話,那么我們需要把我們從程序中收集的的信息存儲到數據結構中.這就是下面我們要作的.這包括兩個重要的組件: 符號表和語法樹.

符號表,顧名思義,它是我們的程序中用來存儲所有符號的一個表;在我們這里,包括所有的字符串變量,還有常量字符串.如果你的語言含有函數和類,他們的符號也將被存儲的符號表.

語法樹是程序結構的一個樹形表示;請看下圖.在下一部分中我們使用這個表示來生成中間代碼.盡管不是必須建立一個語法樹(我們已經從語法分析器中得到了所有關于程序結構的信息),但是我認為這可以使編譯器更透明(燕良注:原文是tranparent,我猜是拼寫錯誤,所以按transparent譯的,不知道那是否是什么術語...),這正是在這個系列文章中我所要達到的目標.



這是包括"真正的"代碼的第一部分,在我們觀察它之前請讓我澄清一點:這些代碼在寫時應該更易懂而不是結構好.它對于我們這里制作的編譯器是合格的,但是如果是一個真正的編譯器,你需要作很多不同的東西.當我們碰到這些問題時我會試著說明它們.


在規則之間傳遞信息

顯而易見,我們必須在我們的語法分析器中添加功能;例如,當我們發現一個符號時我們把它送人符號表--但是我們還希望它的"父"規則(事實上使用此標識符的規則)在符號描述中也要能夠被訪問.

我們在建立一個語法樹時需要某些近似的東西:我們需要父規則有一個指針指向他的"孩子結點"(構成父規則的那些規則)

還記得yylval共用體嗎?Yacc也使用他在規則之間傳遞信息.每一個規則能夠使用yylval共用體的一個域;這是規則的類型.在string.y的頂部,你能看到類似下面的類型說明:

%type <symbol> identifier string
%type <tnode> statement expression

symbol和tnode是那個共用體的新成員;他們分別描述一個指向符號描述的指針和一個指向語法樹的指針.

現在語句的規則象下面這樣使用這些類型:

| expression END_STMT {$$ = new TreeNode (EXPR_STMT, $1);}

它的意思是:如果你發現了一個expression語句,構造一個EXPR_STMT類型的新的樹結點(并且返回新的結點指針),他帶有一個"孩子":組成這個語句的表達式.$$代表一個規則的返回值,$1是規則定義中的第一個符號返回的值(expression).在這里$2沒有意義,因為詞法分析器沒有為END_STMT記號設置一個yylval成員.

我希望這樣的解釋夠清楚了,因為這很重要.本質上,規則是分層的,每一條規則能夠返回一個值到"更高層"的規則.

現在讓我們看一下符號表和語法樹使用什么樣的數據結構.


符號表

符號表在我們例子中至包含很少的信息;基本上它只是變量名和它第一次被聲明的行.后面我們會使用它來存儲更多的數據.

實現非常的簡單:它只是當我們取回一個符號時(看一眼symtab.cpp)為符號的描述建立一個單鏈表(singly-linked list)并且線性的查找這個鏈表.對于一個真正的編譯器,符號表通常被實現為一個binary search tree 或 hash table,以便能夠更快的找到符號.

你要作的是當語法分析器發現這個時把我們的符號送入那個表:

identifier
: ID
{
$$ = st.Find ($1);
if ($$ == NULL) { // doesn't exist yet; create it
$$ = new SymDesc ($1, STR_VAR, NULL, lineno);
st.Add ($$);
}
}
;

我們把字符串常量處理成常量,我們為他們生成一個名字然后把他們送入那個表.注意:一個更高級些的編譯器可能會讓詞法分析器來存儲和取回標識符.這是因為復雜的語言中標識符可能有很多不同的含義:變量,函數,類型,等等.詞法分析器可以取回標識符的描述,并直接把相應的記號返回給語法分析器.因為我們標識符肯定是變量,所以我們只使用語法分析器來處理他們.


語法樹

我為語法樹建立了一個非常簡單的TreeNode類.它只存儲指向孩子的指針和一些附加信息(結點類型,如果可用還有一個符號的連接).看看吧,這沒什么復雜的.

象你前面看到的,我們可以從已經驗證的語法規則輕松的建立語法樹:

equal_expression
: expression EQUAL assign_expression {$$ = newTreeNode(EQUAL_EXPR, $1, $3);}
| assign_expression {$$ = $1;}
;

你會看到在某些時候我們只是無變化的從孩子規則到父規則傳遞信息;如果你的equal_expression 事實上就是一個assign_expression,就沒有必要為它建立一個新的結點;你只使用在assign_expression中建立的那個.記住我們使用這么多表達式規則的唯一的原因是為了清楚的處理操作符的優先級.

編譯這部分(和下面的部分)使用與前面相同的方法.程序還是接受語法結構上正確的程序,但是現在轉儲到它建立的符號表和語法樹中.


這真的很cool,但是...

OK,它讀程序并且分析它.但是它沒有對程序作任何真正聰明或有用的事,不是嗎?

是的,依然是.我們還有更多的組件要實現.下一部分將涉及語義檢查和中間代碼的生成.這將是一條通向編譯程序的漫漫長路.

我希望你不要認為它進展的太慢,我只是想要集中到每一個分立的組件,而不是走馬觀花.如果你很快理解了這些東西,實驗一下他們吧.

下次再見.


Quote!

(Part of the Guide entry on the Babel Fish)

"Now it is such a bizarrely improbable coincidence that anything so mindboggingly useful could have evolved purely by chance that some thinkers have chosen to see it as the final and clinching proof of the non-existence of God.

The argument goes something like this: `I refuse to prove that I exist,' says God, `for proof denies faith, and without faith I am nothing.'

`But,' says Man, `The Babel fish is a dead giveaway, isn't it? It could not have evolved by chance. It proves you exist, and so therefore, by your own arguments, you don't. QED.' "

HHG 1:6


Downloads

Download the tutorial code (tut4.zip) (8k)

Part V:語義檢查 & 中間代碼生成
序言

這次晚了一點...考試真是件可怕的事,它真的妨礙了一些有用的東西.

是的,上次我承諾了結果,你想要得到它們.也許多過你的希望 ;-)

首先是關于這個教程的一個備注.我是想要寫一個非常緊湊的解釋.所有的信息都在這里,但是常常是每個句子有兩個重要的事情..這樣作的缺點是是否有些事不大清楚,你可能沒有跟上這個教程.當我進行的太快時請告訴我,好讓我能夠把事情說清楚.

回到這部分.它是關于語義和中間代碼的.語義檢查將確認你的程序是真正的正確,中間代碼將是向虛擬執行(virtual executable)的一個巨大飛躍.

讓我們開始檢查吧!


檢查語法

語義檢查不單單是檢查程序語法的正確性,它還要確認語句有意義.例如,提供給函數的參數的個數應該是函數所預期的.

語義檢查的主要部分是類型檢查:決定表達式的類型和報告任何的不一致,如想要比較一個布爾值和一個字符串,或者傳給函數錯誤的參數.

當然,也許你想要允許某些"不一致":例如有人使用了下面的語句

print "a and b equal: " + (a == b);

他的意思可能是表達式(a == b)應該被自動轉換成一個字符串,最后成為字符串"true"或"false".這稱為強制類型轉換.在我們這個簡單的編譯器中我只允許布爾到字符串的強制轉換,但是如果你認為字符串到布爾的強制轉換有用,你可以輕松的加上它.

我們的語義檢查器的代碼并不復雜.我為TreeNode加了一個名為Check()的成員函數(在synttree.cpp文件中),它檢查一個結點的語義,我們假定它的所有孩子結點都已經被檢查了.Chech()在TreeNode的構造函數中自動調用,所以這個假定是安全的.

檢查設置了一個名為rettype的新成員變量,表達式的"返回類型".例如,一個條件,當一個字符串連接另一個字符串時,布爾是它的返回類型.rettype用來檢查父結點的語義.CoerceToString函數通過插入一個作為被強制轉換的結點的父結點的新結點,COERCE_TO_STR,來強制轉換任何的表達式為字符串類型(如果它還不是).

對一個簡單的編譯器這是很輕松,但是通常它不是這樣.如果你的語言包含更多的基本類型,索引(references),數組,類和(操作符)重載,事情很快就變得非常的可怕;如果你希望你的程序能夠運行,那么你最好有一個堅實的檢查系統.

在一個真正的編譯器中它從事更多的工作:有更多的強制轉換,你必須計算出要使用哪個重載函數,類型等價不是再是這么平常,等等.

在這兒它是很簡單,并且它對于用更多的類型來膨脹這個系統的學習經驗很有用,但是在一些地方你應該更接近一般情況.

代碼應該足夠說明它們.它只執行一些簡單的事,如if條件應該是布爾型,賦值表達式應該是字符串,等等.


產生中間代碼

中間代碼在我們程序中表示為一個有序的圖:每一條指令有一個指向下一條指令的指針,跳轉有一個指向它的目標指令的指針.

我能想出兩個這么做(使用指針)而不是立即產生代碼到一個大的數組的兩個好處:第一,使用指針便于把代碼片段的連接,而且去掉某些指令時不用更新所有的跳轉,等等.優化也因此相應的簡單了.第二,如果你想要更改虛擬機的一些指令,這使你的編譯器更容易改寫來適應新的VM,因為你只需改變從中間代碼到最終代碼的翻譯步驟,這相對的簡單.

于是,基于上面的思想,我們設計了我們的中間代碼語言.這個語言的操作碼(opcode)將與我們的虛擬機要執行的即使不完全一致也是十分的相似.看一下它們:

enum Opcode {
OP_NOP, // no operation
OP_PUSH, // push string [var]
OP_GETTOP, // get string from top of stack (=assign) [var]
OP_DISCARD, // discard top value from the stack
OP_PRINT, // print a string
OP_INPUT, // input a string [var]
OP_JMP, // unconditional jump [dest]
OP_JMPF, // jump if false [dest]
OP_STR_EQUAL, // test whether two strings are equal
OP_BOOL_EQUAL, // test whether two bools are equal
OP_CONCAT, // concatenate two strings
OP_BOOL2STR, // convert bool to string
JUMPTARGET // not an opcode but a jump target;
// the target field points to the jump instruction
};

你將看到我們的VM是一個堆棧機器(a stack machine):操作碼對堆棧中的值進行操作,把值放回堆棧.我想對產生代碼和執行代碼來說這是都最簡單的機器類型了.

一個關于JUMPTARGET操作碼的說明:每當我們的代碼中有一個(條件)跳轉時,它并不指向一條實際的指令而是指向一個有"JUMPTARGET"前綴的指令.這么做的原因是當我們優化時我們必須知道代碼中的每個跳轉的目的指針,或者我們也許會把一條目的指令優化掉并且混亂(mess up)我們的程序.這些JUMPTARGET將不出現再我們最終的字節碼中.

一般而言,所有的操作碼操作堆棧頂端的項目.OP_STR_EQUAL從堆棧中彈出頂端的兩個項目(必須是字符串),檢查它們是否相等,然后把結果的布爾值進棧.你的程序接著可以使用OP_JMPF指令來使用這個結果:如果棧頂的布爾值是false跳轉到目標指令(由本指令提供,而不是在棧中),如果棧頂是true就繼續執行.

指令被存儲到一個非常簡單的中間指令類中,它只是保存操作碼,一個符號--操作數(例如OP_INPUT),如果需要還有一個跳轉目的指令,一個下一指令指針和一個行號.行號實際上只是在使用Show()函數時使代碼可讀.

現在讓我們看看如何產生中間代碼(intcode.cpp).通常我們為語法樹中的所有子樹產生代碼.所以main以樹根來調用GenIntCode()函數;GenIntCode處理并且返回一個中間代碼的起始指針.

先看個簡單的例子,INPUT_STMT結點:

case INPUT_STMT:
return new IntInstr (OP_INPUT, root->symbol);

這產生一個新的OP_INPUT指令并且返回它.注意這個指令也是一個長度為1的指令塊(block of instructions) ,next指針默認為NULL.

PRINT_STMT更困難一點:

case PRINT_STMT:
blk1 = GenIntCode (root->child[0]);
blk2 = new IntInstr (OP_PRINT);
return Concatenate (blk1, blk2);

首先我們產生代碼來計算表達式提供給print語句(root->child[0]).接著我們產生一個新指令OP_PRINT來打印棧頂的字符串.注意我們假設表達式把它的值放到棧頂.當然,我們得自己來保證這一點.最后我們連接兩個代碼塊,然后返回結果.

現在是一個真正難的:IFTHEN_STMT.我產生所有需要的塊,然后把它們都連到一起.它檢查條件,如果它是false調換到結尾,如果它是true就執行then部分.

case IFTHEN_STMT:
// First, create the necessary code parts
cond = GenIntCode (root->child[0]);
jump2end = new IntInstr (OP_JMPF); // set target below
thenpart = GenIntCode (root->child[1]);
endif = new IntInstr (JUMPTARGET, jump2end);
jump2end->target = endif;

// Now, concatenate them all
Concatenate (cond, jump2end);
Concatenate (jump2end, thenpart);
Concatenate (thenpart, endif);
return cond;

記住root->child[0]是條件子樹,root->child[1]是then子樹.

好的,如果明白了那個,對與剩余的代碼你就沒問題了.所有樹的結點都使用這個方法翻譯.Show()函數顯示我們產生的代碼.看一下所有這些:

Program:
if (a==b) a; else b;

Intermediate code:
1: OP_NOP
2: OP_PUSH a
3: OP_PUSH b
4: OP_STR_EQUAL
5: OP_JMPF 9
6: OP_PUSH a
7: OP_DISCARD
8: OP_JMP 12
9: JUMPTARGET 5
10: OP_PUSH b
11: OP_DISCARD
12: JUMPTARGET 8

這看上去非常的象匯編代碼,是吧?這是因為它就是.它是虛擬匯編(Virtual Assembly),本質上我們只需要寫一個匯編程序來產生虛擬執行代碼.


Whoa, what happened?

那進行的很快,不是嗎?剛才我們還想我們是否將作一些有趣的事,突然我們就產生了虛擬匯編代碼.我們幾乎完成了.

下次我們將看一下優化(我確信如果你觀察這部分的輸出你能想到一些).很快我們將產生真正的虛擬機代碼--但是我猜我們最好先有一個虛擬機!我們將看到從那我們去哪里.歡迎你發給我一些想法或建議.

Bottom line: some interesting stuff is coming up. Stay tuned!

See you next time.


Quote!

The story so far:

In the beginning the Universe was created.

This has made a lot of people very angry and been widely regarded as a bad move.

HHG 2:1


Downloads

Download the tutorial code (tut5.zip) (10k)

Part VI:優化?

你發現BUG了嗎?

意到了前兩次的代碼的好笑的東西了嗎?可能有一個內存漏洞(memory leak)?Emmanuel Astier發現了;他找出了符號表中的一個BUG:當刪除符號表時,我只是刪除了鏈表中的第一個實體,而沒有刪除其他...OK,雖然程序沒有崩潰,但是這不是很漂亮.這將在下一個教程中修改.多謝Emmanuel!


序言

我的考試結束了,我現在可能繼續了.

在這部分我將涉及優化我們的中間代碼的方法.記得嗎,我們使用了一個非常簡單的代碼生成算法,所以那些代碼也許相當的需要優化.

因為我們將在一個虛擬機上執行,所以優化變得格外的重要:我們的每一條指令將花費20條CPU指令去執行(很難更少),所以指令越少越好.

注意,我將只討論與機器無關(machine-independent)的優化;面向機器的優化是一個完全不同的話題,在那里我們必須考慮象流水線效率,寄存器的使用等等這些.并且,當然的,面向機器的優化只有當你的代碼在硬件上運行時才需要,這我們不需要.當然,可能有很多的方法來加速執行虛擬機本身,但是我們將在后面討論.

對不起,這部分沒有例子代碼.一些優化的想法實現起來都是相當的簡單,你將不會在這些問題上碰到麻煩.另外一些更復雜并且需要花大力氣來實現.我沒有時間來作,所以我只是給出一般的概念.

有兩個重要的加速我們的代碼的途徑.一個是把代碼翻譯成更少的指令.另一個是制作更多強大的指令.


額外的操作碼(Extra Opcodes)

高級的指令(Higher-level instructions)可以在VM上執行的更快,因為堆棧操作和更新指令指針的總開銷是(粗略)的相同的.所以我們將忽略RISC并且為外來的代碼(exotic instructions)而瘋狂!;)

讓我們觀察一些代碼.這是example.vasm的一部分,example.str的編譯后的版本:

1: OP_NOP
2: OP_PUSH strconst1
3: OP_GETTOP a
4: OP_DISCARD
5: OP_PUSH strconst2
6: OP_GETTOP b
7: OP_DISCARD
8: OP_PUSH a
9: OP_PUSH b
10: OP_CONCAT
11: OP_GETTOP s
12: OP_DISCARD
13: OP_PUSH s
14: OP_PRINT

我應該注意它的一些事情.第一,在這個代碼中的三個地方有一個OP_DISCARD跟隨在一條OP_GETTOP的情況.我們將它它轉換成一條OP_POP來提高速度,這條指令取得棧頂的值并且把它從堆棧中移走.我可以在開始時這么做,但是我想現在這樣更簡單.

第二,我看到了OP_PUSH; OP_GETTOP; OP_DISCARD兩次.. 這是一個向"a = b"這樣的簡單賦值語句的代碼.我們可以為它提供一個特殊的操作碼OP_COPY,它把一個變量的值拷貝到另一個中.

第三,在這個程序的完整的代碼中有相當多的"double pushes",兩個進棧操作在一起.我們一個制作一個單獨的OP_PUSH2操作碼來加速它.

你或許能想出另外的高級指令.例如,一條連接一個現有字符串OP_CONCATTO操作碼(s += "foo";).如果仔細的挑選他們將能夠加速執行,所以花寫時間來研究你的匯編代碼,然后發現優化的可能.


代碼變形(Code Transformations)

優化輸出代碼的另一個途徑是吧一部分代碼變形成更快的執行同樣任務的某些東西.下面是一個例子.

SourceAssemblyOptimized
??s = a;
? ?if (s == d) ...
? ?OP_PUSH a
? ?OP_GETTOP s
? ?OP_DISCARD
? ?OP_PUSH s
? ?OP_PUSH d
? ?OP_STR_EQUAL
? ?...

? ?OP_PUSH a
? ?OP_GETTOP s
??? (cut away)
??? (cut away)
? ?OP_PUSH d
? ?OP_STR_EQUAL
? ?...

下面是一些變形代碼的算法,節約指令..(saving instructions and thus time)

絕大多數優化集中在優化一些被認為是"基本模塊(basic blocks)"的一小段代碼.一個基本模塊有下面這些性質:你能夠在開始時跳轉到它里面,并且你只能在它的結尾跳出.所以在這些塊的中間沒有跳轉或者跳轉目標(jump targets).這意味著在塊之內我們能夠確定一件關于我們的變量的值的必然的事情,我們可以利用這個信息來優化代碼.舉個例子,如果你可以跳轉到塊內的某處,我們不能確定,t仍然保留著值(a * b - c).

指針帶給基本模塊優化很多困難,因為你必須確定變量沒有通過一個指針被修改,而不是了基本模塊的某處通過它的名字被修改.往往你不能確定這點(指向指針的指針就幾乎不可能知道什么變量被改變了).


代數上等同(Algebraic identities)

一個優化代碼的簡單方法是使用產生相同結果的更快版本來替代原來的"天真的"計算.這些"天真的"計算的計算經常采用一個簡單的代碼產生方案而不是象程序員指定的那樣.觀察下表,十分明顯.

BeforeAfter

? ?x + 0
? ?x * 1
? ?x ** 2
? ?2.0 * x
? ?x / 2


? ?x
? ?x
? ?x * x
? ?x + x
? ?x * 0.5


消除通用子表達式(Common subexpression elimination)

這種優化利用某一表達式可能多次使用一小段代碼的事實:

a = a + (b - 1);
c = c + (b - 1);

這里(b-1)是一個通用子表達式并且可被再次使用(第二個(b-1)表達式可以被"消除").

t = b - 1; // 把子表達式存儲到一個臨時變量中
a = a + t;
c = c + t;



為了檢測通用子表達式,你需要構造一個出現在你表達式中基本模塊的有向無環圖(DAG,directed acyclic graph).每次你遇到一個新的表達式(例如,語法樹中一個更高的結點),你檢查在這個基本模塊的它是否已經出現在表達式DAG中.當這個圖完成時你能很容易的看出那個子表達式使用了多次,這樣你就可以把它們的值存入一個鏈式變量,并且再次使用它.上圖是一個例子.


循環的優化(Loop optimizations)

一個眾所周知的程序員的格言"程序90%的時間花費在執行10%的代碼上",盡管這個百分比每個程序都不同,但是每個人都會同意絕大多數運行時間花費在一個內層循環上.

所以如果我們能使用某種方法優化這些循環,我們就能節省很多的時間...好吧,有很多中優化循環的方法;我將簡單的討論他們中的兩個,代碼移動和變量歸納(code motion and induction variables).

代碼移動類似與子表達式消除,但是不是在一個基本模塊中,它在循環開始前計算表達式并且在循環的整個過程中使用這個值.

while ( i <= limit-2 )

變為

t = limit - 2;
while ( i <= t )

可是,循環也許沒有很多的不變的表達式.它們經常使用的是一個循環技術器,并且這個技術器被頻繁的使用在計算中,例如數組下標,等等.那就是變量歸納能幫我們的了.

如果j是我們的循環技術器,并且每次循環中都計算j*4,我們可以使用一個變量歸納,然后把這個乘法替代為加法:

for (j = 0; j < n; j++) {
.... (j * 4) ....
}

變為:

t = 0;
for (j = 0; j < n; j++) {
.... t ....
t += 4;
}


跳轉的消除(Jump elimination)

有時你能夠通過觀察跳轉的目的塊來消去一個跳轉.例如,你可能有:

1: jmp 7
...
7: str_equal
8: jmpf 10
9: ...

你可以從目的塊拷貝代碼,然后節省一個跳轉(如果條件為假):

1a: str_equal // | 目的塊的拷貝
1b: jmpf 10 // |
1c: jmp 9 // 如果條件為真,跳轉到9
...
7: str_equal
8: jmpf 10
9: ...

你要決定為了消除一個跳轉你將要復制多大一部分代碼,但是在內層循環中它能省很多時間.


下次的東西...

這些信息使你的程序變得更有效率了.可是編譯器優化是一個非常復雜的領域,我們只涉及到了非常少的一點.龍之書討論了更多,所以如果你感興趣,就去看它吧 .

下次我們將建立我們虛擬機,然后也許產生我們的虛擬機代碼吧.那時我們就終于可以執行一個程序了.


Quote!

Somewhere on the wall a small white light flashed.

"Come," said Slartibartfast, "you are to meet the mice. Your arrival on the planet has caused considerable excitement. It has already been hailed, so I gather, as the third most improbable event in the history of the Universe."

"What were the first two?"

"Oh, probably just coincidences," said Slartibartfast carelessly.

HHG 1:30


Part VII:虛擬機(The Virtual Machine)

序言

我們已經在Part V產生了中間代碼,并且我們想要把它轉換成可執行代碼,好讓我們能夠執行一個程序.但是我已經決定要先建立一個虛擬機,這樣我們可以知道該如何處理產生可執行代碼.

虛擬機當然是一個腳本引擎中非常重要的組件.我們的代碼將在它那里執行,所以它最好快一些.但是這里我將不把焦點集中到速度上.

Oh yeah:這部分結束后,你將完全免費的得到我那令人驚奇的堆棧模板(Amazing Stack Template),也不需要額外的小費.并且你將得到一個為這部分特別編寫的很酷的字符串類,它完成至少5個精密的工作.那是你的物有所值的東西.

但是,首先是一個不同機器類型的說明.在Part V我只是說了我們的VM將是什么種類,沒有說明其它的可能.Andy Campbell詢問我關于這方面的其它可能性,并且我想其他人也許會感興趣.


機器類型

以前說過,我們的機器將是一個堆棧機器(stack machine).在真實的機器中,堆棧CPU被用于早期的計算機(并且今天依然在一些簡單的設備中使用).缺點是需要很多的堆棧操作:每個操作數需要一個PUSH,每個結果需要一個POP.盡管你直接使用這個結果來進行下面的計算,所以那不總是必須的.

現在的大多數CPU有寄存器(數量非常有限的存儲位置)來進行操作而不是堆棧;堆棧依然在函數傳遞參數時使用.可以只在寄存器上操作的機器被稱為load/store機器,因為你必須load每個你用到的值,然后在你計算完后store每個結果.

某些處理器只操作內存數據;沒有堆棧,也沒有寄存器.使用這種處理器的機器被稱為三地址機器(three-address machines),因為絕大多數指令有三個地址操作數(例如 ADD dest,src1,src2).由于內存帶寬的限制,我認為他們不會在很多硬件中使用,但是他是虛擬機的一個選擇.

對于虛擬機,堆棧機器非常容易實現,因為當你計算一個表達式時不需要臨時變量來存儲中間結果;你把所有東西放入堆棧(它與你處理一個后綴表達式的方法十分相似).雖然我將在這里使用臨時變量.后面還有更多內容.

我不清楚三地址機器是否可能有一個優點;速度是最重要的一個,盡管我嘗試了兩者,我能肯定的說出哪個在優化中做了更少的計算...我想優化三地址代碼更容易,所以也許這是這種機器的一個優點.

JAVA表面上使用一個堆棧機器(我聽說是這樣,我對JAVA VM不熟).


一件虛擬的非常容易的事(A Virtual Piece of Cake)

我們的虛擬機對象根本就不復雜.它的最重要的成員有:一個指令數組,一個字符串表和一個堆棧.它有三個主要的接口函數:Reset,Read和Execute.

指令數組存儲我們的程序包含的指令.指令類簡單極了,看上去就像我們在Part 5中的中間代碼使用的一樣.

字符串表只是一個指針數組,它可以是NULL或者一個當前使用的字符串.這可能是一個程序的變量,或者一個堆棧中的臨時變量.

我們的堆棧是由整數組成的.它們指向字符串表,使我們知道什么字符串現在在堆棧中.為什么我使用整數,而不是字符串類的指針呢?因為我想保持事物的簡單(為了讀者,也為了我自己):記住我們有時也想讓堆棧存儲布爾值,所以我們不得不建立一個存儲字符串指針或布爾值的'stack item'類...現在我們只是使用一個整數:如果它是非負數,我們知道它指向一個字符串,如果它是負數它就是一個布爾值.它是臟的代碼,但是他有利于工作并且每個人都可以理解它.不要在家試它,不要在一個真正的項目中使用它.

現在是接口函數.'Reset'重新初始化VM.它是一個很簡單的函數.

'Read'將要在程序中讀取.下次我們將改變這個函數讓他從stdin中讀取,但是現在它里面有一個測試程序.如果你喜歡就改寫它--只是小心的讓程序保持正確,不要讓我們的VM崩潰.

'Execute'執行當前在內存中的程序.這也是一個簡單的函數:它有一個指令指針,它察看一個指令,然后使用一個switch語句執行正確的代碼.關于臨時變量的一個說明:每當我們把一個變量放到堆棧,我們需要它的一個拷貝:我們不能只是把在字符串表中的變量的索引值進棧,因為他們的值可能改變并且接著堆棧中的值也會改變.這就是為什么幾乎每個堆棧操作都使用NewTempCopy和DelTempCopy.

一點關于優化VM的說明:我們應該確保我們的堆棧操作盡可能的快;我們的堆棧模板不是特別的快.在字符串操作上也一樣.一般而言,我們應該使通用的case快.最好把所有普通的優化技術應用到VM上.

關于VM還有很多要說:存儲分配(allocation schemes),垃圾收集(garbage collection),保持他們穩定和高速,但是我想我將推延到下一部分.

下一次

下一次我們將最終執行代碼.然后我們就完成了我們的簡單的腳本引擎.之后我可能給出一個復雜的真實的腳本引擎的概貌,并且討論所需的主題.


Quote!?

"Come," called the old man, "come now or you will be late."

"Late?" said Arthur. "What for?"

"What is your name, human?"

"Dent. Arthur Dent," said Arthur.

"Late, as in the late Dentarthurdent," said the old man, sternly. "It's a sort of threat you see." Another wistful look came into his tired old eyes. "I've never been very good at them myself, but I'm told they can be very effective."

HHG 1:22


Downloads

Download the tutorial code (tut7.zip) (5k)


Part VIII:可執行代碼

序言

我們有了執行我們的程序的所有需要的東西,除了...可執行代碼.我們已經有了中間代碼,并且它已經非常接近我們的虛擬機能理解的東西了.所以我們必須作的是一個中間代碼和可執行代碼之間的快速的翻譯步驟.

為什么這需要是一個分離的步驟?就象你看到的,翻譯實際上涉及到把我們的字符串放到一個數組中,并且為符號表提供他們的索引而不是指針.我們上次已經做了跳轉目的,所以他們將不再改變.所以這是一個簡短的部分,代碼改變不大.

也許對于我們,建立中間代碼不是嚴格的需要.但是寫一個更高級的編譯器時,有這樣一個分離是非常有用的,在實際的機器碼之前更多的'概念上的'階段:它簡化代碼優化;你可以不困難的重新定義你的編譯器到另一個機器.


最后一步

當你閱讀這部分的代碼時,你將在幾個地方看到到我的懶惰,它使我寫了真正罪惡的代碼.

舉個例,我把編譯器和虛擬機組合到了一個程序中,并且我傳送"中間代碼"給虛擬機,這不是很恰當的方法.你也許想要你的編譯器來處理每件事直到可執行代碼產生,然后也許存儲可執行代碼到一個文件,然后讓你的VM讀取&執行這個文件.

在我們這里,VM中的Read()函數首先從我們的符號表中取得所有的字符串,然后把他們放入字符串數組.然后它線性的通覽代碼,并且一行接一行的翻譯它們.我們所使用的特殊的跳轉目的指令只被轉換成NOP指令,它應該被優化掉.

Oh,我做得一個顯著的可惡的事是我用來自編譯器的符號表來存儲虛擬機的字符串表索引(使用符號表的新成員PutNo()/GetNo())...它是非常簡單的找到字符索引的方法,但是我同意模塊化的程序設計是全然不同的...


它工作了!我簡直不能相信!

嗨,你真的可以使用這個編譯器/虛擬機的結合體來執行一個程序!你大概幾乎放棄了它,不是嗎?好吧,繼續嘗試例子.這部分有源碼可以下載...他們應該正確執行.這很有趣吧.

好的,那就是我們曾經為之工作的東西.一個小小的語言,盡管它自身不是很有用,但是它表現了很酷的東西--你現在學習了建立你自己的腳本引擎的足夠的東西.

現在發生了什么?

經過了這樣一個難以置信的極限(啊咳)我相信你有一點感覺空虛和不知所措.我們將從這里去到哪里?

我將可能作一個或更多的part介紹一些高級的主題,也許談到為這個語言增加函數,類,多態,等等.讓我知道你對什么感興趣.

盡管將不再有代碼--每個人都可以取得這個簡單的編譯器并且擴充它.或者,更好,寫一個你自己的.The world's your oyster!

Quote!

"More importantly, a towel has immense psychological value. For some reason, if a strag (strag: non-hitch hiker) discovers that a hitch hiker has his towel with him, he will automatically assume that he is also in possession of a toothbrush, face flannel, soap, tin of biscuits, flask, compass, map, ball of string, gnat spray, wet weather gear, space suit etc., etc. Furthermore, the strag will then happily lend the hitch hiker any of these or a dozen other items that the hitch hiker might accidentally have "lost". What the strag will think is that any man who can hitch the length and breadth of the galaxy, rough it, slum it, struggle against terrible odds, win through, and still knows where his towel is is clearly a man to be reckoned with."

HHG 1:3

Downloads

Download the tutorial code (tut8.zip) (15k)


Part IX:高級主題?

序言

現在你已經玩了一下那個完成的腳本例子,也許你實現了一些新特性,或許當我們將要接觸新東西時你在疑惑.

請允許我提醒您,這些好東西里的絕大部分都需要大量的工作(這些我將不再提供例子代碼).我將討論幾個高級的腳本主題,給出如何實現(我的想法)的一般想法.

第一個:


A lockup-resistent VM(不會翻,暈倒....)

前一段時間Joseph Hall給了我一個處理無限循環(infinite-looping)的腳本代碼的很好的想法.他的思想是:每次調用虛擬機時給他最大數量的操作碼去執行,并且如果下一幀它還沒有完成時讓它繼續執行;這是虛擬的等價與CPU優先級多任務.這種方法使你的游戲引擎在腳本代碼掛起時可以保持運行;它可以自動檢測腳本是一個不變的循環并且重起VM.

現在,讓我們看看我們可以怎么樣擴展我們的語言:


函數

在你的腳本語言中增加函數是非常困難的,它引入了參數和局部變量的概念.為了他們需要使用堆棧.在一個函數調用前程序把參數入棧.然后函數在同一堆棧中預留空間給它的局部變量.然后執行函數,使用預留的堆棧空間來讀寫值.在我們的簡單的編譯器中,我們僅僅從棧頂進棧和退棧,但是現在你也可以訪問堆棧中間的內存地址.

你需要為函數使用兩個特殊的操作碼:CALL和RETURN.CALL是一個無條件的跳轉,它吧指令指針保存到堆棧中.RETURN讀取那個被存儲的指令指針,然后跳回CALL后面的指令.

要做的一件最符合邏輯的事是讓調用者(不是該函數)把參數從堆棧中移走參數;畢竟最初是調用者把他們放進來的.這也考慮到一個"輸出參數(output parameters)"的簡單機制:函數改變一個參數的然后調用者把這個值存入一個變量.一個函數的返回值也可以看作是一個輸出參數.

函數的信息頭可以存儲到一個符號表中.使用他們,你可以存儲它的參數和局部變量(可以每個是一個分離的符號表實體).在代碼生成的過程中,你可以在符號表中存儲函數的起始地址.

重載(Overloading)
函數的重載可以是一個語言中非常好的特性,但是實現它可能很棘手的.問題是如何通過提供的參數類型來正確的從可能的函數頭信息中找到一個恰好匹配的函數來調用.在這種情況下,你將不得不強制某些參數到不同的類型來得到一個完全的匹配.問題是什么參數需要強制轉換和把它們轉換到什么類型.大多數編譯器試著比較調用和可能的選擇,然后選擇一個需要最少強制轉換的.一些編譯器允許雙重強制轉換(例如:bool->int,然后int->unsigned),這使麻煩更復雜,我建議保持簡單.

操作符可能看作是一個用不同語法調用的函數;如果用這種方法來處理你的操作符(不要真把它們作成函數(慢),而是inline函數或者宏),你可以輕松的擴展函數重載到操作符重載.

如果你想要在你的語言中實現類,正確的決定你想要支持那些特性.支持完整的C++類,包括多繼承,訪問控制,動態束定,虛函數,等等是非常困難的,我建議不要在一開始就處理所有這些.一個帶有單繼承的簡單的類系統是一個很好的起點,如果需要的話你以后可以擴展它.

類和結構體是符合數據類型:他們包含多個數據成員,并且連接到一定數量的方法或者成員函數.你可以在你的符號表中存儲一個成員列表,它與其他分離的成員符號表實體相連接.這可以使你簡單的找到結構中某個成員的偏移量.

繼承
單繼承相對的簡單:當你在一個對象中查找一個成員時,檢查這個成員是否在子類中;如果不是就檢查它的父類.子類的存儲布局很簡單:首先你存儲父類,然后是他的子類,其他子類,等等.這樣向下的束定被隱藏:你可以處理向處理一個Animal的指針一樣處理一個Cat的指針,這個的意思是你的程序可以訪問更少的成員,但是指針的地址不需要改變.

多繼承,當調用一個成員函數或者訪問一個數據成員時,它帶來了二義性問題.思考這個:兩個類B和C是統一個類--類A的子類.然后建立一個類D源于類B和類C這兩個類.現在,如果類A有一個公有成員函數DoSomething,當成員在一個D類型的對象中調用DoSomething時,你不能知道調用兩個DoSomething中的哪個:一個是B的A部分,另一個是C的A部分..好吧,也許看圖可以更清楚.



虛函數
虛函數是建立多態的一個方法;例如一個Animal類包含一個虛函數MakeSound(),一個子類Cat和Dog都各自用不同的方法實現一個這個函數(我想讓你考慮如何正確的實現他們).于是當你調用一個Animal對象的MakeSound函數時,你不知道(并且不需要知道)是那種動物在發出聲音.

虛函數函數使用一個所謂的vtable來實現.當父類聲明一個函數為virtual時,它在那個類中增加了vtalbe.每個子類現在取得他們自己版本的vtable,這樣,不同的函數調用基于那個對象實際的類型,盡管在調用者看來這些table之間并沒有區別.

動態束定
動態束定可以很便利:例如,在UnrealScript中你不僅僅可以向下束定一個對象(把它束定到它的父類型),而且可以向上束定(束定一個對象到它的子類),如果這個對象的確是子類的對象.這意味著你需要一個方法來決定一個Parent類型的對象實際上是向下束定的一個Child1對象(在這種情況它可以被向上束定),或者是一個Child2對象(在這種情況它不可以被向上束定).在最新的C++編譯器中你可以使用dynamic_cast<...>操作符.怎么覺得這個呢?每個對象都將必須有一個獨一無二的號碼,也許是一個類的表和他們的父類的索引.使用這個號碼,你可以斷定它到底是那種對象.

類型變量(Type variables)
類型變量允許類型的變量.這允許你動態建立一個變量類型的對象.舉個例子,你有一個游戲,一個敵人走了進來,兩個同樣的敵人走了出去.你可能會看到一個包含所有可能的敵人的巨大的switch語句,但是這不是很好擴展.所以你可以存儲敵人的類型,告訴游戲使用這個類型建立一個怪物.這是一些假想的語言代碼:

TypeVar<Enemy> enemytype; // A type variable
enemytype = typeof (monster); // Get the monster's type
Enemy *newmonster = new enemytype; // Create a new monster of the same type

你可以傳遞類型變量到一個函數;這將使得他們很有可塑性,你可以使用同一個函數來建立和處理很多不同類型的對象.

為了類型變量,你需要擴充類和他們的父類的表來包含每個類型的大小;否則你將沒法動態建立他們.


Game-specific language constructs

UnrealScript(據我所知)是第一個提出了兩個在游戲中非常有用的特性的語言:狀態和隱藏代碼.

狀態
UnrealScript中的類可以有幾種狀態;一個對象總是在一個確定的狀態.基于對象處在那個狀態,為這個對象執行不同的函數.所以如果這個對象是一個敵人并且它處在Angry的狀態,Angry版本的SeePlayer函數將被執行,這個敵人將可是攻擊玩家.如果這個敵人處在一個Frightened的狀態,另一個SeePlayer函數(使用同樣的參數類型)將被調用,使得敵人逃跑.

狀態并不是非常難加入,盡管它的確需要一些工作;狀態是一個額外的類成員(不可見),并且每當調用特定的狀態函數時恰當的函數版本將被執行.這可以使用一個使用狀態號碼為索引的跳轉表來輕松實現.

狀態可以有它們自己的函數外的代碼,在UnrealScript中是狀態代碼.這可以方便的與下一個構思相結合:隱藏的函數.

隱藏的函數(latent code)
隱藏的函數相當的難實現,但是非常的酷:一個隱藏的函數花費一些游戲時間來執行;換句話說,這個過程可以起動一個函數等待或者激活那個等待或者激活一個人物,當這個動畫完成后代碼繼續執行.這是一個AI腳本很好的特性.

隱藏代碼帶來的另一個問題是本質上它與其他代碼并行執行.偶爾隱藏代碼被執行,然后它又被停止.所以我們必須記住隱藏代碼的指令指針.并且當對象改變它的狀態時,你將也需要執行其他的隱藏代碼.

我們可以看到UnrealScript唯一提供隱藏代碼的原因是為了調用狀態代碼,而不是普通函數:假設隱藏函數可以在任何地方被調用,每個對象本質上可以有很多的并行執行的"線程"..這可能需要大量的記錄并且將變慢.而且也將產生同步問題:一個對象的線程將把一個成員變量設為某個特定的值,然后一個其他的線程變為活動后再次修改它...如果你想允許它將需要實現一個完整的多線程系統.

That's it for now..?

我希望這可以激發你的想象力.有許多特性你的腳本語言可以實現;如果你想完成它你將限制自己為某一個.

這可能是這個系列教程的最后以部分.我樂于寫它.如果你覺得在一些地方還不夠,讓我知道,也許我將寫一個額外的部分.當然,如果你有其他的一些問題我也樂于聽你說.

Good luck, and keep on scripting! ;-)


Quote!?
"He stared at it for some time as things began slowly to reassemble themselves in his mind. He wondered what he should do, but he only wondered it idly. Around him people were beginning to rush and shout a lot, but it was suddenly very clear to him that there was nothing to be done, not now or ever. Through the new strangeness of noise and light he could just make out the shape of Ford Prefect sitting back and laughing wildly.

A tremendous feeling of peace came over him. He knew that at last, for once and for ever, it was now all, finally, over."

HHG 5:25

總結

以上是生活随笔為你收集整理的实现一个脚本引擎的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。