#
早就聽說bcb(borland c++ builder)是一個強大的RAD開發工具,也早就聽說曾經的borland搞出的編譯器堪稱經典。
恰好最近在做一個GUI工具,想在界面開發上盡量快一點。每一次用上MFC都讓我覺得渾身難受,總有些常用的
界面功能它就是沒有。在接口實現上,MFC基本上就只是封裝了WIN API而已。想想世界上還有什么強大的GUI庫,
找了一下,其實不管GUI庫封裝的怎么樣,我更多地還是需要一個工具,能夠快速地堆積出界面。
于是,在網上下載了被國人精簡了的bcb2009。然后,噩夢開始了。首先,我需要把邏輯層代碼(也就是實現具體
功能的那一層)移植到BCB下。然后得到了很多和語法相關的編譯錯誤:
1.
E2397: Template argument cannot have static or local linkage
這個錯誤發生于:
void func()
{
struct Info
{
};
std::queue<Info> abc;
}
它的意思是,模板參數必須是全局鏈接的,總之它不允許std::queue的參數是一個在函數內部臨時定義
的類型(誰來告訴我這是C++標準)。
2.
E2357 Reference initialized with 'FileLoader::RawData', needs lvalue of type 'FileLoader::RawData'
這個錯誤發生于:
FileLoader::RawData FileLoader::GetRawData() const;
FileLoader::RawData &raw = loader.GetRawData(); //不能用引用
很久沒看C++書,所以,誰又來告訴我C++標準里,這里到底能不能用引用?
3.
E2515 Cannot explicitly specialize a member of a generic template class
這個錯誤發生的情景更復雜些:
template <typename _Tp>
class Test
{
template <typename _U>
class Other;
template <>
class Other<void>
{
};
};
意思是說,我不能在一個模板類里特化成員模板類。誰又來告訴我標準規定的是什么?
4.
void func( Obj &a )
{
}
func( Obj() );這個也被視為錯誤。必須得在調用func之前自己定義個臨時變量。
5.
我曾經留下了關于宏遞歸的一些代碼,被用在我寫的lua-binder和lua-caller中自動生成代碼。這下好了,
BCB開始警告我,我的這些宏不能工作了。它和MSVC在某些事情上分歧可真是大:
#define PARAM( n ) ,typename P##n //注意這個宏包含一個逗號
#define CHR( x, y ) CHR1( x, y )
#define CHR1( x, y ) x##y
#define BCB_ERROR( a, b ) CHR( a, b )
BCB_ERROR( 1, PARAM( 1 ) ) 當這樣使用宏時,基于我在GNU C上看到的關于宏的規則,會先展開
PARAM(1),于是得到BCB_ERROR( 1, ,typename P2 )。然后,BCB認為PARAM(1)展開的逗號需要參與
BCB_ERROR的展開了。于是,我的整個宏庫無法工作了。
關于這個問題,我直接用MSVC寫了個生成器,讓MSVC替我生成各種參數的lua-binder和lua-caller,然后
寫成外部頭文件,最后直接在BCB里包含了這些頭文件。從而使我的lua-binder和lua-caller可以繼續使用。
然后,我的1W多行代碼終于在BCB下50多個WARNINGS的提示下編譯成功了。懷揣著興奮的心情,想自己終
于可以rapid開發界面了。創建了個VCL FORM APPLICATION,噩夢又開始了:
1.
BCB莫名其妙地在我編譯一個CPP文件時給出如下提示:
F1004 Internal compiler error at 0x59b4ea8 with base 0x5980000
看起來像是BCB的編譯器給崩潰了。囧。google了一下,發現不是我人品問題,很多人遇到相同的問題。
別人給出的解決方案是:restart your bcb。從昨天晚上到現在為止,這個錯誤發生了好幾次。
2.
new std::ofstream();會讓程序崩潰,往不該寫的地方寫了東西。我就奇怪了,你BCB自己帶的C++IO實現,
難道還有BUG?再次google,還真發現是BCB自己的BUG,并且在幾個版本之前就存在這個BUG。那個天真
的老外還說希望在BCB2009下能被修復。修改方案如下:
1)xlocale文件里把這句話注釋了:*(size_t *)&table_size = 1 << CHAR_BIT;
2)xlocale里把成員_Id_cnt訪問屬性改為public,然后在自己的文件里定義一次。
3.
程序終于可以運行了。但是BCB的IDE環境總是不那么貼心。我移動了幾個窗口改成我習慣的樣子,但是一重啟
居然又恢復成default(難道是因為盜版)。它的智能提示似乎總是跟著鼠標指針,有時候指向某個符號,鼠標
就顯示忙。為了提示某個類的成員,某個函數的原型,BCB偶爾都會卡一下。其實我不介意我的編輯器沒有這
些提示功能,在MSVC下我也從不用VA來幫我寫代碼。我甚至不厭其煩地在VIM下敲代碼切窗口去看函數原型,
但是,你他媽作為一個IDE就得像個IDE的樣子,要不,你干脆關掉所有功能,別給我卡就行了。
這個時候我開始懷疑選擇BCB會不會是一個錯誤的開始,或者說在使用某個東西時,總會帶著使用其他同類東西
的感覺甚至偏見去看待這個新事物。但是,在我想堅持繼續使用BCB時,我一compile,它又提示我:
F1004 Internal compiler error at 0x59b4ea8 with base 0x5980000
仔細想想能導致一個C++程序崩潰的幾乎90%原因都是跟指針有關??罩羔樢爸羔?,一不小心
程序就崩了。寫C++程序的人基本上都知道這個問題。在我們周圍避免這些問題的常規方法
也很多,諸如auto_ptr(及其他基于template的原始指針wrapper)、SAFE_DELETE。當然也
會有很多人在實現一個函數時會很勤勞地對每一個parameter進行合法判斷。
其實,我們都知道,auto_ptr這些東西始終是無法避免野指針和空指針帶來的災難。
SAFE_DELETE也不能阻止別人使用這個空指針。
在我看過的一些開源項目的代碼中,這些代碼給人的感覺就是別人總能詳細地掌控各種資源
(包括指針及其他變量)的使用情況。相比之下,公司隔壁組的老大則顯得保守很多。他要
求我們幾乎要對所有指針的使用進行空值判斷(野指針也判斷不了),當然,各種成員變量
也要進行即使現在看上去沒多大用的初始化。
也許,這樣做后程序是不會掛掉了。但是,就我們的觀點來看,這樣反而會隱藏一些BUG。
為什么我們不能詳盡地去管理一個指針?一個指針變為空了,總是因為在這之前發生了錯誤
。當然,野指針本身就是愚蠢代碼產生的東西,這里沒必要討論??罩羔樦詾榭眨彩?br>因為在很多時候我們把空作為失敗/錯誤/無效的標志。
恰好上周我的一些代碼就真的在空指針上出現了問題。外網的服務器隨時會因為玩家的一些
臨界操作行為而崩潰掉。雖然我通過修改腳本來屏蔽這個問題(因為不能說停機維護就停機
維護),但是總感覺程序是不安全的。人不吃點教訓絕對不學乖。
后來我對這個問題徹底思考了一下。很多程序員都自認聰明。在寫C++程序時,我從來不提
供沒用的public接口,尤其是set/get。我也從來不對沒必要的成員變量進行初始化。我給
的理由是對于這些東西我都有很清晰的把握,我為什么要做stupid的事情?
但是,我幾乎從來沒有界定,指針在哪些情況下需要去判斷為空?函數的參數絕對不需要。
假如函數的參數就是個空指針,那是client程序員的責任。僅供模塊內使用的指針(包含其
他資源)在內部使用時也不需要去判斷。如果去判斷了,那說明你對你自己寫的模塊都缺乏
精確的把握,證明你的設計思維不夠清晰。
什么時候需要判斷?當指針依賴于外部環境時,例如讀配置文件、載入資源,因為外部因素
不確定不在自己控制范圍內,那么進行判斷。同樣,當使用了其他模塊返回的指針值時,也
需要判斷。這個其實和“外部環境”屬于同一種情況。因為我們對其他模塊也不清楚,更為
隱蔽的是(隨著其他模塊的改變,將來會在你的模塊里爆發崩潰錯誤),其他模塊由別人維
護,其變化更不受自己控制。之前我對這一點界定不是很清楚,這也是我犯錯的原因。
現在想想,像游戲服務器這種程序,里面塞著各種各樣的游戲功能。無論是哪一個模塊出現
個空指針訪問出錯的問題,都會直接讓服務器崩掉。關鍵是這個結果經常伴隨著玩家的損失
。所以理想狀態下,把每一個模塊都放置在單獨的進程里,確實是很有好處的。
要給項目中增加一個新的模塊,需要先在服務器端做一些圖片處理相關的工作。本來,對圖片
做一些諸如ALPHA混合旋轉縮放的操作,在游戲客戶端應該是很容易的事。但是這事要在服務
器做,就不得不引入一些第三方庫。反正我們的服務器運行于WINDOWS下,這里又需要處理
JPG圖片的加載,我就考慮到了GDI+。
在這之前對GDI+沒有過任何接觸。直接翻了MSDN,還好居然有個一系列的usage。GDI+的Image
本身支持JPG的直接載入。但是并沒有我理想中的CreateFromMemory( const void *buf )接口。
看起來唯一可以從內存創建Gdiplus::Image對象的方法是從一個叫IStream*的COM東西。我揣摩
微軟為什么沒有提供我理想中的那個接口,或者說要把GDI+設計成這樣,可能還是考慮到對多語
言的支持。于是問題轉換為如何將一個C語言的const void*轉換為IStream*。我甚至在開始的時候
感覺到是不是要自己實現個Stream。后來在google上找到了一個似乎是標準的方法:首先創建個
HGLOBAL對象,然后通過GlobalLock就可以將一個C的const void*直接memcpy到這個HGLOBAL
里,最后,通過CreateStreamOnHGlobal這樣的接口就可以得到一個IStream。
惡心的是,基于之前對服務器內存使用的優化,我現在對于內存的使用非常敏感(誰說現在內存
大了就可以任意malloc了??)。上面那個過程對于資源的管理在MSDN文檔中似乎顯得有點
模糊。CreateStreamOnHGlobal函數的第二個參數指定當IStream->Release的時候,是否會自動
刪除這個HGLOBAL對象。我雖然對COM不懂,但也知道它的對象是基于一種引用計數的管理方式。
逐字看了下文檔,發現一個final單詞,原來是IStream->Release最后一次釋放時,會同時釋放掉
這個HGLOBAL對象。更讓人發指的是,我猜測Image( IStream * )來創建Image時,Image又
會對這個IStream進行一次AddRef。我發覺MSDN對于Gdiplus::Image::FromStream函數的說明
也有點模糊。我揣摩使用FromStream獲得的Image*,是否需要手動去delete?這個地方的內存
資源管理,一定得搞個水落石出。結果是,FromStream的實現就是簡單地new了個Image。而
Image內部肯定會對IStream進行AddRef,并且,如果在Image銷毀前銷毀這個HGLOBAL,這個
Image基本也就廢了。
也就是說,Image本身不對HGLOBAL中的圖片數據進行復制。囧。別想讓我再寫個wrap class把
HGLOBAL和Image糾結在一起,簡單考慮,將CreateStreamOnHGlobal第二個參數設為TRUE。
要將一個Image保存為一段內存,也比較麻煩。我的方法和google上的相同。當然,微軟的庫依
然讓我在很多細節上栽跟斗(如前所說,可能這是基于多語言支持的考慮)。首先需要創建個空
的IStream,即CreateStreamOnHGlobal第一個參數為NULL。然后將Image Save到這個IStream。
再根據該IStream::Seek獲取其大小,自己再分配段內存,最后IStream::Read讀取進來。同樣,
需要注意相關內存資源的管理。
下午簡單把以上兩個過程簡單封裝了下。
下載代碼。
上午公司斷網,晚上失眠頭痛沒精神,于是隨便打開了DNF游戲目錄下的資源文件。以
前一直對提取游戲資源存在好奇,需要對一些關鍵字節猜測其加密方式。
DNF游戲目錄下soundpacks下的npk文件看起來似乎比較簡單,這里直接給出文件格式,
懶得寫分析思路了。
文件開頭的十六個字節是一個固定字符串:NeoplePack_Bill\0。
接下來四個字節表示本npk文件里打包了多少個WAV文件。npk文件是一個包含了很多聲
音或者圖片的打包文件。類似這種打包文件,一般文件頭都會保存一個文件列表。而這個列
表里又會附加上偏移量和大小等信息。
接下來的數據就是這里所說的列表。每一個列表項包含三個數據域:偏移、大小、文件
名。如下示意:
NeoplPack_Bill\0 (16 bytes)
file_count( 4 bytes)
item1:offset(4 bytes), size(4 bytes)
item2:offset(4 bytes), size(4 bytes)
...
itemn:offset(4 bytes), size(4 bytes)
...
文件列表之后,就是具體的每個文件的內容。開始我還在擔心npk會為每一個聲音文件
加密?;蛘咧槐4媛曇粑募木唧w數據,而聲音文件文件頭則只保存一份(因為所有文件的
文件頭很有可能全部是一樣的)。后來稍微搜索了下WAV的格式,只需要比對下npk中某一個
文件內容的頭部是否和WAV格式的頭部相同,就可以基本斷定其是否加密。
結果是,npk對包內的每一個WAV文件沒做加密。
然后立即寫了個程序,根據文件列表中的偏移值和大小值,將每一個WAV單獨取出來,就
OK了。
完整的格式為:
NeoplPack_Bill\0 (16 bytes)
file_count( 4 bytes)
item1:offset(4 bytes), size(4 bytes)
item2:offset(4 bytes), size(4 bytes)
...
itemn:offset(4 bytes), size(4 bytes)
file1
file2
...
filen
我想圖片資源也應該差不多,不過圖片資源肯定要復雜些。下午公司網絡好了,網上搜
索了下,發現居然已經有了DNF資源提取工具了,唉。
提供下源代碼和MingW編譯好的可執行文件,另聲明:本文及相關工具代碼只作學習研究
用,任何后果與作者無關。
kl中的錯誤處理
之前我一直說錯誤處理是kl里的軟肋,由于一直在關注一些具體功能的改進,也沒有對
這方面進行改善。
我這里所說的錯誤處理,包括語言本身和作為庫本身兩方面。
語言本身指的是對于腳本代碼里的各種語法錯誤、運行時錯誤等的處理。好的處理應該
不僅僅可以報告錯誤,而且還能忽視錯誤讓處理過程繼續。
而把kl解釋器作為一個庫使用時,庫本身也應該對一些錯誤情況進行報告。
整體上,kl簡單地通過回調函數指針來把錯誤信息傳給庫的應用層。而因為我希望整個
kl實現的幾層(詞法分析、語法分析、符號表、解釋器等)可以盡可能地獨立。例如雖然語
法分析依賴于詞法分析(依賴于詞法分析提供的接口),但是因為詞法分析并不對語法分析
依賴,所以完全可以把詞法分析模塊拿出來單獨使用。所以,在日志方面,我幾乎為每一層
都附加了個error_log函數指針。
而用戶層在通過kllib層使用整個庫時,傳入的回調函數會被間接地傳到詞法分析層。
實際上,當kl作為一個庫時,kllib正是用于橋接庫本身和用戶層的bridge。
另一方面,語言本身在處理錯誤的腳本代碼時,錯誤分為幾大類型層次:
1.詞法錯誤 lex error,如掃描字符串出錯
2.語法錯誤 syntax error,整理語法樹時出錯
3.運行時錯誤 runtime error,在解釋執行代碼時出錯
4.庫錯誤 lib error,發生在kllib這個bridge層的錯誤
kl在報告錯誤信息時,會首先附加該錯誤是什么類型的錯誤。
這里最麻煩的是語法錯誤的處理。因為語法分析時發生錯誤的可能性最大,錯誤類型也
有很多。例如你少寫了分號,少寫了括號,都會導致錯誤。這個階段發生錯誤不僅要求能準
確報告錯誤,還需要忽略錯誤讓整個過程盡量正確地下去。
語法分析階段最根本的就是符號推導(單就kl的實現而言),所謂的符號推導是這樣一
個過程,例如有賦值語句:a = 1;語法分析時,語法分析器希望(所謂的推導)等號后面會
是一個表達式,當分析完了表達式后,又希望接下來的符號(token)是分號作為該語句的結
束。
所以,klparser.c中的syn_match正是完成這個過程。每次你傳入你希望的符號,例如
分號,該函數就檢查詞法分析中當前符號(token)是否是分號。當然,對于正確的腳本代碼,
它是一個分號,但是如果是錯誤的代碼,syn_match就會打印諸如:
>>syntax error->unexpected token-> ....
即當前的符號是不被期望的。
上面完成了錯誤的檢測。對于錯誤的忽略,或者更高級點地對錯誤的校正,kl中處理得
比較簡單,即:直接消耗掉這個不是期望中的符號。例如:
a = 1 /* 忘加了分號 */
b = 1;
上面兩句代碼被處理時,在處理完a=1后,發現當前的符號(token)b(是一個ID token)不
是期望(expect)中的分號,首先報告b不是期望的符號,然后kl直接掠過b,獲取下個符號=。
然后處理a=1這個過程結束。當然,下次處理其他語句時,發現=符號,又會繼續發生錯誤。
錯誤信息中比較重要的還有行號信息。之前kl這方面一直存在BUG,我在寫貪食蛇例子
的時候每次新加代碼都不敢加太多。因為解釋器報告的錯誤行號總是錯誤的,我只能靠有沒
有錯誤來找錯誤,而不能通過錯誤信息找錯誤。
行號信息被保存在詞法分析狀態中(lexState:lineno),語法分析中獲取token時,會取
出當前的行號,保存到語法樹樹節點中。因為包括解釋模塊都是基于樹節點的,所以詞法分
析語法分析解釋器三層都可以準確報告行號。
但是之前解釋器報告的行號始終很詭異。癥結在于我在載入腳本代碼文件時,以rb方式
載入,即二進制形式。于是,在windows下,每行文本尾都會有\r\n兩個字符。而在詞法分
析階段對于行號的增加是:
case '\n':
case '\r':
ls->lineno ++;
不同OS對于文本文件的換行所添加的字符都不一樣,例如windows用\r\n,unix系用\n
,貌似Mac用\r。所以,詞法分析這里寫應該可以準確地處理行號。
但是對于windows,這里就直接將行號增加了兩次,所以也就導致了行號出錯的問題。查
了下文檔,發現以文本方式打開文件("r"),調用fread函數讀入文件內容時,就會自動把
\r\n替換為\n。
代碼改后,又出問題。這個時候,通過fseek和ftell獲取到的文件尺寸,貌似包括了
\r\n,而fread出來的內容卻因為替換\r\n為\n而沒有這么多。
不過文件載入不屬于kl庫本身,kl只接收以字符串形式表示的腳本代碼,所以也算不了
核心問題。
同樣,最新代碼可以從google SVN獲取。當然,我也在考慮是否換一個新的項目地址。
貌似最近CPPBLOG寫一門腳本語言比較流行,連我這種山寨程序員都搞出一個像C又像
BASIC的所謂腳本語言,可見其流行程度。
這個kl腳本例子,是一個具有基本功能的貪食蛇游戲。這個例子中使用了兩個插件:
HGE引擎、以及一個撇腳的二維數組插件。因為kl對于數組的實現不是那么漂亮,而我實在
不想因為加入二維數組的支持而讓代碼看起來更亂,所以直接不支持這個特性??紤]到二維
數組的應用在一些小游戲中還是比較重要(例如這個貪食蛇,總需要個容器去保存游戲區域
的屬性),所以撇腳地加了個支持number的二維數組插件。
HGE插件我只port了部分接口,也就是注冊了一部分函數到腳本里,提供基本的貼圖功
能。(port--我實在找不到一個合適的詞語來形容這種行為---HGE到一門腳本語言里,我似
乎做過幾次)
不知道有沒必要提供貪食蛇的實現算法,這似乎說出來有點弱智。- - 不過為了方便別
人閱讀kl腳本代碼,我還是稍微講一下。游戲中使用一個二維數組保存整個游戲區域,所謂
的游戲區域就是蛇可以活動到的地方。每一個二維數組元素對應游戲區域中的一個格子,姑
且稱為tile。每個tile有一個整數值表示其屬性,如BODY、WALL、FOOD、NONE。蛇體的移動
歸根結底就是蛇頭和蛇尾的移動。蛇頭和蛇尾屬性一樣,但是蛇頭負責把所經過的tile設置
為BODY,而蛇尾則把經過的tile設置為NONE。蛇頭的移動方向靠玩家控制,每次蛇頭轉彎時
,都會記錄一個轉彎點到一個隊列。轉彎點包括轉彎XY坐標以及轉向的方向。蛇尾每次移動
時都會檢查是否到達了一個轉彎點,是的話就設置自己的移動方向為該轉彎點記錄的方向。
雖然我寫了kl這個腳本語言,但是語言特性并不是我設計的。我只是取了C語言的一些
特性。所以在寫這個sample的時候,我對于kl這個腳本語言的感覺,就是一個像basic的C。
因為它太單一,就像BASIC一樣只擁有語言的一些基本功能,不能定義復雜的結構,沒有天
生的對各種數據結構的支持(例如某些語言直接有list, tuple之類)。
以前中學的時候在電子詞典上用GVBASIC寫小游戲,當時除了BASIC什么也不知道。今天
寫這個貪食蛇例子,感覺就像以前用BASIC。
回頭說說一些kl腳本里的特性。從這個例子里(見下載包里的snake.kl),諸如while,
for,if...else if...被支持(之前發布的版本里還不支持for和else if)。全局變量支持
賦初值(上個版本不支持)。當然,還演示了如何使用插件函數。
但是,仍有一些特性在我的懶惰之下被置之不理。例如return后必須跟一個表達式,這
意味著單純的return;將被視為語法錯誤。對于if( a && b ),kl會計算所有的表達式,而
別的語言也許會在a會false后不計算b,這也許不算個問題,但起碼我還沒修正。還有,kl
內部對于錯誤的報告依然沒被修復,少打一個分號你會得到一系列錯誤的報告,但是卻沒有
準確的行號。甚至,你會看到解釋器崩掉。不要緊,在我心里,它作為當年電子詞典上那個
GVBASIC而言,已經很強大的了。:DD
最近接觸了很多UNIX和GNU之類的東西,發覺沒有提供版權說明的‘開源’,原來都是偽
開源。雖然我也想按照GNU編碼標準里所說為kl的發布包里附加Changelog之類的說明,但是
出于懶惰,還是以后再說吧。同樣,這次提供的下載里包含了一些編譯好的東西,所以我不
保證它在你的機器上依然可以運行。我使用了MingW來編譯這些,并且提供有點丑陋的Makefile。
HGE使用了1.81版本。
貼張圖給懶得下載的人:
下載例子,包含腳本代碼。
如果要獲取kl實現代碼,建議從我在google的SVN獲?。?br>http://code.google.com/p/klcommon/
author: Kevin Lynx email: zmhn320#163.com date: 3.12.2009
腳本與C語言交互
這其實是這一系列的最后一篇,因為我覺得沒什么其他需要寫的了。
一般而言,腳本語言同C語言交互,包括在C語言中注冊C函數到腳本,從而擴展腳本的
功能,以及在C語言中調用腳本函數。
為了擴展腳本的功能,這里引入插件的概念。kl在這方面大致上實現得和lua相似。kl
支持靜態插件和動態插件。
在C語言中調用腳本函數,kl中提供了一些簡單的接口用于滿足需求。
靜態插件
靜態插件其意思是在C代碼中注冊函數到腳本中,并隨腳本庫一起編譯鏈接成最終執行
程序。因為其綁定是在開發一個程序的過程中,所以被稱為靜態的。
一個插件函數,指的是可以被注冊進腳本的C函數。這種函數必須原型一樣,在kl中這
個函數的原型為:typedef struct TValue (*kl_func)( ArgType arg_list );
當你定義了一個這樣的原型的函數時,可以通過kl庫提供的:
int kl_register( struct klState *kl, kl_func f, const char *name )來注冊該
函數到kl腳本中。該函數參數很簡單,第三個參數指定注冊進腳本中時的名字。
原理比較簡單:在解釋器中保存著一個插件符號表,該符號表的符號名就是這個函數提
供的名字,符號對應的值就是第二個參數,也就是插件函數的函數地址。
解釋器解釋到函數調用時,先從插件符號表中查找,如果找到符號,就將符號的值轉換
為插件函數,并調用之。
插件函數的參數其實是一個參數鏈表。腳本里調用插件函數時,所傳遞的參數將被解釋
器整理成參數鏈表并傳遞給插件函數。kl庫中(集中在kllib.h中)提供了一些方便的接口用
于獲取每個參數。
插件函數的返回值也將被解釋器轉換為腳本內部識別的格式,并在必要的時候參與運算
。
動態插件
動態插件同靜態插件的運作方式相同,所不同的是動態插件的插件函數被放在動態運行
時庫里,例如windows下的dll。
kl插件編寫標準里要求每個動態插件必須提供一個lib_open函數。kl解釋器(或者kl庫
--當被用作庫時)載入一個動態插件時,會直接調用lib_open函數。lib_open函數的主要目
的就是把該插件中的所有函數都注冊進腳本里。
因為動態插件在設計之初沒有被考慮,所以我并沒有為kl加入一些原生的關鍵字用于導
入動態插件,例如import、require之類。我在靜態插件層次提供了這個功能。即我提供了
一個libloader靜態插件,鏈接進kl解釋器程序。該靜態插件提供腳本一個名為import的函
數。該函數負責動態載入dll之類的動態庫,并調用里面的lib_open函數完成動態插件的注
冊。
C程序里調用腳本函數
這個比較簡單,通常C語言想調用一個腳本函數時,會傳入腳本函數名。因為腳本函數名
都保存在全局符號表里,kl庫從全局符號表找到該函數符號,并轉換其值為語法樹節點指針
,然后傳入解釋器模塊解釋執行。
kl庫提供struct TValue kl_call( struct klState *kl, const char *name, ArgType args );
用于在C里調用腳本函數。
代碼導讀
kllib.h/kllib.c作為一個橋接層,用于封裝其他模塊可以提供給外部模塊使用的接口,
如果將kl作為一個庫使用,用戶代碼大部分時候只需要使用kllib.h中提供出來的接口。
源碼目錄plugin下的kllibbase.c中提供了靜態插件的例子,kllibloader.c提供了裝載
動態插件的功能。
源碼目錄plugin/hge目錄下是一個封裝2D游戲引擎HGE部分接口到kl腳本中的動態插件
例子。
源碼目錄test/kl.c是一個簡單的kl解釋程序,它用于執行一段kl代碼。這個程序同之前
說的解釋器不是同一回事。當我說到解釋器時,它通常指的是klinterpret.c中實現的解釋
模塊,而解釋器程序則指的是一個使用了kl庫的獨立解釋器可執行程序。
author: Kevin Lynx email: zmhn320#163.com date: 3.11.2009
解釋器
整理出語法樹后,我們就可以根據語法樹,并配合符號表開始解釋執行腳本代碼。這就
是接下來要涉及到的解釋器。
工作原理
在第四節中講語法樹時,其實就已經提到解釋器的大致工作原理。
一個kl的hello world例子代碼大致為:
function main()
{
print( "hello world\n" );
}
在第二節中我描述了kl代碼整體上的結構,是以函數為單位的。因此,對于一個完整的
kl腳本代碼,其經過語法處理后,將建立一棵大的語法樹,該語法樹大致結構為:
fn1_node
stmt_node1
stmt_node2
...
fn2_node
stmt_node1
stmt_node2
...
fn1_node和fn2_node同屬于同一個作用域,fn1_node的sibling指針指向fn2_node,即在
整個樹結構中,每一個node通過child[3]成員連接其子節點,通過sibling指針連接其相鄰
的節點。
解釋器解釋執行時,就是從main函數所對應的節點開始遞歸執行的。對于每個節點,都
可以知道該節點對應了哪種程序邏輯:是加法運算、比較運算、還是一些控制語句等等。
以這樣的控制語句舉例:
if( 1 ) print( "true" );
對if語句而言,其語法樹結構為:
if_node
/ | \
/ | \
con_exp then_stmt else_stmt
即,if語句有最多有三個子節點(child[3]),child[0]指向if的條件表達式,child[1]
指向條件表達式為真時執行的語句序列,如果if有else部分,那么child[2]就指向else部分
的語句序列。
那么,在發現某個節點是if節點時,就首先計算其條件表達式節點。這個節點的計算方
式同腳本中其他所有表達式的計算方式相同,當然,它也是一個遞歸操作。計算完后判斷該
表達式的值是否為真,為真則遞歸執行if節點的child[1]節點,否則檢查是否有else節點,
有的話就執行child[2]節點。
其他所有節點的解釋方式都是相同的。
解釋器環境
解釋器環境指的是解釋器在解釋執行腳本代碼時,所需要的運行時環境。kl中主要是符
號表信息。一個解釋器環境會有三個符號表:全局符號表,主要保存全局變量以及腳本函數
符號;函數局部符號表,在解釋調用一個腳本函數時,會建立臨時的符號表;插件符號表,
用于保存插件注冊的函數。
如何解釋執行函數
函數主要有兩大類型:腳本內定義的函數以及插件注冊進符號表的函數。無論是哪種函
數,都會在符號表中建立對應的符號。對于前者,符號被保存于全局符號表,其保存的內容
是該函數節點的節點指針;而對于后者,則保存的插件函數的函數地址值。
每一次解釋器解釋到一個函數調用節點時,會優先在插件符號表中查找該函數符號。如
果找到,就將其值轉換為約定的插件函數類型(如同lua里注冊的C函數一樣),然后整理參
數調用之。這個時候代碼執行權轉接到插件函數里。如果沒找到,就在全局符號表里查找,
找到后就強轉為語法樹節點指針,并解釋執行該節點下的語句。
代碼導讀
解釋器的代碼位于klinterpret.h/klinterpret.c中。整體上而言沒什么特別的地方,
主要是利用語法樹的特點。
完成了這一節后,kl就已經可以解釋執行所有的腳本語句。當然,因為沒有輸出功能,
只能在調試器里看看計算結果。下一節里會講到將腳本結合進C語言,從而可以讓C語言注冊
所謂的插件函數到腳本里,也就可以讓腳本具有print這樣的輸出函數。
author: Kevin Lynx email: zmhn320#163.com date: 3.10.2009
符號表
在上一節中,當我們的解釋器解釋執行age=age+1這個語法樹時,會涉及到變量age的值
。實際上我們還需要個保存腳本中相關變量的模塊,當我們的解釋器獲取到一個ID樹節點時
,需要從這個模塊中獲取出該變量的值,并參與運算。
這個我稱之為符號表。我想到這里,我所說的概念很可能和教科書有點不一樣了。
什么是符號表?
符號表(symbol table)就如同其字面意思一樣,是一個表,更寬泛地說是一個保存符號
的容器。
腳本中諸如變量函數之類的東西都算作符號,例如age。符號表就是保存這些符號的容
器。
在kl中,符號表保存著某一個作用域里的變量。其全局符號表還保存著函數符號,對于
函數符號而言,其值為語法樹樹節點的指針值。當調用一個函數時,將該值轉換為樹節點,
然后執行。當然,這應該算做解釋執行一節的細節,不多說。
再明確下符號表的作用,舉例,在上一節中,涉及到這么一個例子函數:
value factor( TreeNode *node )
{
switch( node->type )
{
case ID:
/* 在這里,發現一個樹節點類型為ID,就需要根據ID對應的名字,也就
是age,在符號表中查找age的值 */
return age;
/* ... */
}
}
以上注釋闡述了符號表的作用。
符號表的實現
其實不管符號表如何實現,對于其他模塊而言,對符號表的唯一要求就是提供幾個類似
這樣的接口:
value sym_lookup( const char *name );
void sym_insert( const char *name, value val );
也就是說,提供查找符號值,以及插入新符號的接口。
在kl中,使用了<編譯原理與實踐>中相同的符號表數據結構實現。即使用了hash表,
hash數組中每個元素保存的是一個鏈表頭節點。每一個符號字符串通過散列函數得到hash數
組索引,然后在該索引里進行一次線性查找。很典型的hash結構。
另一方面,因為kl支持全局和函數局部兩個作用域。所以kl中有一個全局符號表,用于
保存全局變量以及所有的函數符號;同時每一次進入一個函數時,就會創建一個臨時的局部
符號表,用于存儲局部變量;后來,為了支持插件,插件函數被特定地保存在另一個全局符
號表里。
代碼導讀
kl中的符號表實現代碼在klsymtab.h/klsymtab.c中,實現比較簡單,無需多言。
author: Kevin Lynx email: zmhn320#163.com date: 3.9.2009
語法分析
語法分析接收詞法分析階段的token集合為輸入,將這些沒有關系的tokens整理為相互
之間有關系的結構。書面點的說法叫語法樹。
每一次讓我寫這些文縐縐的概念真讓我受不了:D。
語法樹
語法樹簡單來說就是一個以token作為每個節點的樹型結構。例如我們有表達式age =
age + 1;,在詞法階段它被整理為token集合:age, =, age, +, 1。那么在經過語法分析后
,這些tokens將被整理為大致如下的樹形結構:
=
/ \
age +
/ \
age 1
整理成這樣的結構有什么好處?就kl解釋器而言,最直接的好處就是我可以遞歸地解釋
這棵樹執行。例如:
value compute( TreeNode *root )
{
/* child[0]保存結果值age,child[1]是那個+表達式 */
return op_exp( root->child[1] );
}
value op_exp( TreeNode *node )
{
switch( node->op )
{
case '+':
{
/* + 表達式必然有左右操作數 */
value left = factor( node->child[0] );
value right = factor( node->child[1] );
return left + right;
}
}
}
value factor( TreeNode *node )
{
switch( node->type )
{
case ID:
/* 查找age的值 */
return age;
case CONST:
/* 1 是常量 */
return node->cvalue;
}
}
如你所見,當我們完成了語法分析階段,我們就可以完成我們的解釋器了。后面我會單
獨講解下整個解釋過程,包括每個模塊是如何協作的。我不知道其他解釋器是怎么做的,但
是我這樣做,起碼結果是對的。
如何整理出語法樹?
這里不得不提到所謂的BNF文法,很明顯你還是無法從我這里獲取編譯原理里某個概念
的講解。我這里提這個概念完全是方便我提到這個東西。
每一種語言都有其自己的BNF文法,因為萬惡的先知告訴我們,每一門語言都需要建立
其語法樹。- -!
就像詞法分析一樣,因為大部分語言的結構都差不多,所以我覺得詞法分析和語法分析
基本上都沒有任何特別之處。也就是說,別的語言的BNF你可以直接拿來改改用。
抄個BNF如下:
exp -> exp adop term | term
addop -> + | -
term -> term mulop factor | factor
mulop -> *
factor -> (exp) | number
這個BNF用來描述一般的算數表達式(+-*/)。簡單來說,一門語言的BNF就是用于描述該
語言所有語句的東西,包括if、while、函數定義之類。建議你google一下C語言的BNF,并
改造之用于你自己的語言。
那么有了BNF之后,該如何整理出語法樹呢?
通常,我們的代碼里都會直接有對應exp、term、addop之類的函數。按照我這句話的意
思,上面抄的BNF被翻譯為程序代碼后,就可能為:
exp()
{
if( ... ) left = exp()
right = term();
left addop right;
}
term()
{
if( ... ) left = term()
right = factor();
left mulop right;
}
factor()
{
if( ... ) return exp();
else return number;
}
(可能還會涉及到EBNF,用于處理重復和選擇的一些情況---不用管這句話)
每一個函數基本上都會返回一個樹節點,當然,該節點下可能會有很多子節點。
總結
語法分析基本上就是以上信息。它將詞法分析輸出的token集合整理成一顆語法樹。為
了整理出這棵語法樹,你需要找一份用于描述你語言的BNF,然后根據BNF翻譯成處理代碼。
代碼導讀
kl中的整個語法分析代碼位于klparser.c/klparser.h中,其BNF基本上取自<編譯原理與
實踐>附錄中的C_語言。