i春秋做家:immenmaphp
原文來自:從虛擬機架構到編譯器實現導引【一本書的長度】html
在說些什麼實現的東西以前,筆者仍然想話嘮嘮叨下虛擬機這個話題,一是給一些在這方面不甚熟悉的讀者簡單介紹下虛擬機的做用和包治百病的功效,二來是題頭好好吹一吹牛,有助於提高筆者在讀者心中的逼格,這樣在文中犯錯的時候,不至於一下就讓本身高大上的形象瞬間崩塌。java
是的,當前虛擬化技術已經在計算機及互聯網中烈火燎原了,從服務器到我的PC上直至手機甚至手錶上,幾乎都離不開它的身影,儘管當前提及虛擬機,更多的人想到的是Java是運行在JVM上的,而JVM是個虛擬機,或者你們很是熟悉的虛擬機軟件VMWare或者是Virtual
Box,KVM等大牌虛擬機軟件。但實際上虛擬化技術早而有之甚至不那麼容易界定明顯,例如,咱們在PC上想玩紅白機遊戲,那麼FC模擬器就是個虛擬機,咱們想在PC上作作電路實驗,試試咱們在89C51上編寫的跑馬燈好很差使,proteus也是虛擬機,甚至於作作虛擬儀器的業內標杆軟件Labview,這個無需解釋估計是最直觀的虛擬機了,大多數虛擬機本質上是執行在宿主的一款程序,對宿主機CPU沒辦法直接執行的指令字節流或別的一些什麼東西進行解釋執行,最終達到某種功能或目的的一種程序,那麼按照這種推論,瀏覽器裏的JavaScript,Lua腳本語言運行環境,或者是Python算不算虛擬機呢,實際上大多數人更願意使用解釋器來描述這些語言的執行環境,可是無論怎麼說,乍看之下其彷佛也徹底符合虛擬機的特性,無論是文字解釋仍是將代碼編譯爲字節流再進行解釋本質上並無多少區別,所以筆者認爲將它們歸爲虛擬機也一點毛病也沒有,糾結這些毫無心義.算法
那麼簡單來說虛擬機究竟是什麼呢,簡單來講,假如你只會中文而不會英文,某天一個老外要和你交談,語言不通怎麼辦,請翻譯啊,那麼,這個翻譯的做用就像虛擬機.編程
<ignore_js_op>windows
相信在文章的開篇就有人跳出來提出這個問題了,爲何要本身作個虛擬機,如今那麼多現成的執行體系很差麼,爲何要重複造輪子,況且這個輪子還未必有別人的圓,固然筆者寫這篇文章的目的也並非吹噓本身寫的虛擬機有多好,但筆者相信只要你在計算機開發的遊戲引擎或二進制安全或圖形圖像或是人機交互PLC工控…等等等領域之一混的足夠久,你就會知道本身擁有本身的一套虛擬機系統所帶來的好處和必要性了,筆者相信時間和經驗會告訴你作一件事有沒有這種必要,而別人說再多話也是別人說的,固然,就像別人設計的虛擬機再好也是別人的,本身設計的虛擬機再挫也是本身的.當你在一套虛擬系統中有所疑惑或者是須要額外的功能實現時,你就知道」就算別人的花兒再漂亮,本身種的終歸是本身種的」這句話的深入含義了.數組
那麼,一套虛擬機系統有什麼好處呢?筆者總結了部分功能.瀏覽器
<ignore_js_op>安全
執行環境可控,畢竟每一條指令都由虛擬機負責解釋執行,這意味着能夠對程序執行的每個流程進行控制,虛擬機做爲一個沙盒環境,對權限控制,調試都有諸多好處。尤爲是在權限的控制中,能夠避免一些惡意程序對原生系統的破壞,固然,這有沒有讓你有種主宰世界的感受。服務器
<ignore_js_op>
固然若是說讀者讀完這篇文章就能立刻擼出一個虛擬機+完整的編譯系統,那真是難爲筆者了,這也就是爲何題目中筆者加了導引兩個字,這是一個龐大的工程,筆者沒法在寥寥萬餘字中將每個技術細節都說明白,當中涉及的東西太多不只在書裏更要在實現中去體會而不是紙上談兵(相信我,那些把編譯理論吹得稀爛的大多本身也沒有寫過編譯器最多就使用LLVM描了個邊,當他們真正從底層去處理詞法和語法上的問題基本也是一臉懵,那一刻就會體會到真正要他們解決的玩意書和那套聽不懂的裝逼理論並不會告訴他們),雖然書不能幫你解決全部問題,但筆者仍然向有興趣寫編譯器虛擬機的推薦\<\<編譯原理\>\>\<\<遊戲腳本高級編程\>\>這兩本書,前者有一套完整的編譯理論的構架對學習編譯優化很是有好處,固然其實很大一部分理論用不到,後者那本書則是結結實實的寫了一套完整的虛擬機和編譯器,不過也厚的多,足足2本書近千頁的內容(固然,仍是沒法徹底說起每個技術細節:),但很是有參考價值值得一讀.最後是筆者的這篇文章了,是的,雖然沒法讓讀者直接寫出個編譯器和虛擬機,可是至少能夠說個大概,2w字的篇幅說長不長說短不短,半個小時就能讀完,買不了吃虧買不了上當,可讓你初略地瞭解下虛擬機和編譯器究竟是怎麼回事.
在碼農界設計一個虛擬機一直被當作高端大氣上檔次的事.然而事實上虛擬機的設計並無什麼複雜的內容,乃至於說解釋器裏的語法和詞法分析都比虛擬機複雜的多,當前,假如你想設計一款和VMWare或者Virtual
Box同樣的的虛擬機軟件,那當我以前的話沒說,在本文中,筆者經過設計一款較爲簡單的虛擬機程序而且在虛擬機完成咱們須要的功能.
所以,在開始這個項目以前,咱們首先要確立如下目標
咱們要虛擬機主要功能是什麼,功能的不一樣,將很大程度改變虛擬機的架構,例如是咱們的虛擬機實做爲遊戲引擎的腳本控制,那麼咱們的指令設計就應該儘量的精簡穩定並便於優化,這樣使用虛擬機設計的遊戲引擎纔不至少不至於在虛擬機方面卡成PPT讓每個玩家罵娘,而若是是作算法的反逆向保護,那麼咱們就要好好的藏好咱們指令集「真正的」功能,甚至不作優化讓破解者繞圈圈,這個時候冗餘設計反而有助於保護咱們的算法。
咱們的虛擬機開發環境是什麼,用在什麼地方,固然,這包括使用什麼語言,什麼環境來開發咱們的虛擬機都在考慮的範圍以內,固然,當前至關一部分有名的虛擬機環境都選擇使用C語言進行開發,這有不少好處,首先目前絕大部分的運行環境都提供C語言的編譯器,虛擬機寫好後,能夠很是方便地在多平臺進行移植,再者只要編譯器給力,C語言編譯出來的指令流相對執行效率優秀,這對容易帶來明顯性能損失的虛擬機尤其有利,最後,C語言學習較爲容易,學習成本不高,能讓咱們把更多的注意力放在「如何實現」而不是「如何讓別人以爲我代碼語法糖寫的有多牛逼」上。
咱們的虛擬機的指令集如何實現,固然這是一個籠統的說法,這還包括如何咱們虛擬機如何對內存進行管理,須要哪些寄存器,這些寄存器分別由什麼用,指令的數據結構是怎麼樣的之類多種的問題,不過不用擔憂,咱們有不少現成的指令集能夠供咱們參考,例如MIPS這種指令集至今仍做爲衆多CPU粉樂於用虛擬機模擬實現的指令集,這原於這個指令集的精簡併容易實現,所以每一年的畢設上,總能看到相關的設計論文,相對的x86
ARM指令集就複雜得多,但不用擔憂,咱們能夠學習他們部分的實現,管中窺豹可見一斑,即便咱們只完成了一部分實現,對於咱們的虛擬機而言,大部分的功能也足夠實現了.
如何設計調試器方便咱們的虛擬機調試,這個是很是重要的一點,假如你設計的虛擬機沒有對應的調試方案,那麼即便是做爲虛擬機做者的你編寫的虛擬機程序進行親自調試,也將會是一場噩夢,所以在你真正動手開發虛擬機以前,你最好想好你的調試器如何架構在你的虛擬機之上.
虛擬機的運行方式及IO,簡單來講就是你得想好你的虛擬機如何去解析指令執行,另外虛擬機執行完後也不能光運行啊,算法把結果算出來了總得把結果輸出來,這就涉及到了虛擬機和本地代碼的數據交互,這些都須要提早考慮好.
<ignore_js_op>
項目開始的第一步,天然沒多少難度,不過但凡幹大事者,都要先立個名號,雖說這並不算什麼大事,但筆者自認爲是一個比較中二病的人,所以,冥思苦想仍是得給這個虛擬機項目取個名字.好比終結者,雙子星,地球毀滅者,宇宙收割機之類的,不過好像又過了那個年紀,仍是務實一點,固然,本篇文章的事例虛擬機取自筆者早前已經寫好的一個虛擬機項目,它被用在遊戲引擎,嵌入式系統控制及UI界面當中,名字是早已訂好了叫StoryVM,屬於StoryEngine(遊戲/圖像引擎)的一部分
<ignore_js_op>
至於爲何叫StoryVM,筆者本身也不是很清楚.就以爲叫着舒服,固然,本篇文章的目的也是告訴你們這個虛擬到底如何實現的,讀者們若是有興趣,不妨也花點時間爲本身的虛擬機項目起個霸氣的名字和LOGO,萬一火了,那麼就能夠爲這個虛擬機名字怎麼來的想個故事了.
在開始部署咱們的虛擬機程序以前,咱們先來複習一下計算機專業的經典知識點,馮諾依曼的計算機體系結構和哈佛結構,相信計算機系的看官們應該並不陌生畢竟多多少少都有幾位是栽在他們手上的
<ignore_js_op>
馮諾依曼和哈佛結構主要的不一樣點是程序和運行數據是不是存儲在同一個空間中,實際上兩種體系虛擬機都可以實現,畢竟時間有限,所以,筆者沒法將兩種體系的虛擬機實現都說一遍,出於演示和儘量偷懶原則,筆者在本文中採用的是哈佛結構體系的虛擬機架構,爲何使用哈佛結構(程序和數據分開存儲)呢,其中有如下幾點好處
從執行安全性考慮,方便進行越界檢查,當指令地址不在指令的存儲範圍內時,確定是無效指令越界訪問了.
二進制漏洞十有八九都是越界訪問或者缺乏邊界檢查的數據修改形成的,程序修改程序形成遠程代碼執行的事兒我們也不是第一天見過了,所以,分開存儲有利於提升腳本的安全性與可控性.
那麼,這個虛擬機的數據空間看起來是怎麼樣的呢:
<ignore_js_op>
是的,筆者將虛擬機的各個數據都進行分類存儲,一來不只便於訪問,二則方便管理及權限控制,只要設計得當,常量區的只讀數據就能獲得很好的保護,程序代碼區也不會被修改,須要注意的是,筆者仍然將棧空間和堆空間設計在同一空間裏,固然這是有必定緣由的,這點我會在後面的章節中說出緣由.
那麼從如今開始,咱們就要開始接觸一些編碼方面的東西了,固然,爲了保證這篇文章儘量的受衆面廣,筆者並不打算過於地強調用何種語言來編寫這個虛擬機,但筆者編寫這個虛擬機使用的是C語言進行開發的,所以在不少的地方,仍然不可避免須要使用C語言中的一些代碼對功能的實現進行說明,明顯的,本文並不打算寫有關C語言怎麼寫之類的問題,所以若是讀者不熟悉C語言的話,筆者仍然建議讀者自行查閱相關資料.
在虛擬機開發的第一步,咱們先來了解一下元數據
什麼是元數據呢,簡單來講就是虛擬機可以定義的最小單位,咱們以C語言爲例,C語言排除結構體和修飾符外,那麼可以定義char
short int float double
long….等幾種類型,其中,char類型不論在哪一種編譯器下一定佔據一字節,排除位域或編譯器額外的實現,char是C語言可以定義的最小數據大小,所以咱們稱char爲元數據類型
而在Basic語言中,定義則簡單的多,能夠直接使用dim a=3141,或者dim b=」hello
world」來定義類型,在這個時候,類型所需的內存空間再也不是一個定數.在這裏定義的元數據類型能夠是一個整數小數或者是字符串類型.
在不少的狀況下,咱們把相似於C語言的類型稱爲強類型,表示類型間沒法直接相互轉換,而Basic則稱爲弱類型,不一樣類型間能夠相互轉換.
固然,對於那些依靠CPU直接執行的指令,基本不會採用弱類型的數據訪問方式,這將致使電路實現過於複雜,可是虛擬機徹底能夠採用弱類型的方式來編碼數據的訪問,這主要有如下幾個優勢.
編寫程序時簡便的多,這意味着開發虛擬機程序時不用花過多的心思在數據轉換上
寫起來方便,看起來直觀,好比」Hello」+」World」這樣的字符串相加運算,順手
固然,弱類型帶來了優勢,缺點也很多
顯然的,虛擬機的實現要複雜的多,必須考慮資源分配、深淺拷貝、垃圾回收等問題。
一定帶來性能損失,尤爲是面臨深拷貝和垃圾回收。
不論內存管理如何優秀,這種分配回收機制都不可避免引起更多的內存碎片
對一些特殊類型的操做,好比字符串,可能須要額外增長一些關鍵字來增強對類型功能的使用,好比字符串不可避免須要引入strlen函數統計其長度,或者用[]運算符修改其某個字符.
「數字:」+4294967295的結果應該是字符串’’數字:4294967295」麼?,要知道,4294967295在32位類型中和數字-1是同樣的,若是」數字:」+4294967295是字符串」數字:4294967295」那」數字」+-1又該是什麼,你能夠說給類型定義有符號仍是無符號的標識啊,那麼好,定義一個類型爲100,那麼這個100是有符號仍是無符號的,你能夠說加修飾符來修飾啊,那問題又來了,既然要加修飾符,那我還用弱類型作什麼,繞個彎子再自找麻煩麼.
總結了弱類型的幾個好處,但同時咱們也發現其糟糕的地方也很多,那咱們虛擬機須要設計成支持弱類型的訪問麼,固然要,弱類型有如此多的好處,不能由於他存在某些缺點就全盤否認,但這也是爲何本章標題筆者起名爲元數據而非」都聽好了,咱們要設計一個弱類型數據訪問型的虛擬機」,爲了規避弱類型訪問的一些缺點,咱們須要對虛擬機的數據結構進一步改造,咱們能夠這樣規定,一個元數據(多是常規寄存器,堆棧裏的某一數據)能夠是一個整數,小數,或者是字符串或數據流類型,可是,不一樣的數據類型不可以直接進行運算,須要使用特殊的指令進行操做,這樣咱們就解決了弱類型帶來的歧義的問題.
說完了理論,咱們來看看實踐,咱們先來看看C語言如何定義一個元數據
typedef struct
{
int type;
union
{
char _byte;
char _char;
word _word;
dword _dword;
short _short;
int _int;
uint _uint;
float _float;
string _string;
memory _memory;
};
} VARIABLE;
觀察結構體VARIABLE定義,其中type表示該變量的類型,在該虛擬機中,有以下枚舉定義
typedef enum
{
VARIABLE_TYPE_INT,
VARIABLE_TYPE_FLOAT,
VARIABLE_TYPE_STRING,
VARIABLE_TYPE_MEMORY,
} VARIABLE_TYPE;
VARIABLE_TYPE_INT,表示這個數據是一個整數類型定義,
VARIABLE_TYPE_FLOAT表示這個數據是一個浮點類型,
VARIABLE_TYPE_STRING表示這是一個字符串類型定義
VARIABLE_TYPE_MEMORY 表示這是一個數據流類型
接下來是一個聯合體,數據類型公用一塊內存儘量節省一個元類型佔用的內存空間
若是在高中數學的角度上來講,6和6.00這兩個數字並無什麼區別,6.00後面的兩個0能夠省略,可是在不少的編程語言當中,6和6.00有着本質上的區別,6是一個整數,6.00是一個浮點數,它們在內存中的佈局經常天差地別,同時,6/4和6.00/4的結果也大相徑庭
筆者在本章開頭寫這個,目的並非給讀者講解整數和浮點數編碼的區別,而是但願說起一點,數據的不一樣寫法所表達出的數據也大相徑庭,那麼StoryVM支持幾種數據呢,在上一章節咱們已經講過元數據的組成方式,從結構體定義咱們能夠看到,支持char
short int uint float string memroy幾種類型(word ,dword..本質上是unsigned
short和unsigned
int),可是筆者並不打算讓StoryVM關注於如此多的類型,所以,在筆者設計的StoryVM中,僅僅支持int
float string memroy四種類型
讀者可能會表示疑問.若是我須要表示一個無符號數,或者只須要表示一字節那怎麼辦,int類型不是隻能表示有符號整數呢
其實按照讀者的設計,在方便的時候,數據長度能夠寧多不寧少,int類型徹底能夠用來表示字節類型,無非是使用時本身注意點將它當作字節類型來用時不要超過255就好了,而有符號無符號類型在內存中表示其實並無什麼出入,例如,-1和4294967295在內存中並無什麼區別,而有符號數適用面更爲普遍,至於到底顯示出來時是有符號或者是無符號,徹底能夠靠本身把握.
在StoryVM中,如何使用匯編表示一個數據類型呢
顯然的 int類型能夠直接使用數字來表示,例如
12345,這個是一個合法的int類型,固然,爲了方便,還引入了十六進制表達,例如0xffffffff也是一個合法的int類型,固然,須要注意的是StoryVM最大支持32位的整數類型,這也意味着十六進制範圍是0\~0xffffffff,最後是字符類型,例如‘A’表示字符A的asc碼值,也是一個合法的整數類型,’B’表示字符B的ascii碼值…..以此類推
Float類型應該無需筆者多說了,1.0,3.14,6.66都是合法的float類型
String也就是字符串類型和C語言的字符串表示保持一致,」Hello
World」這就是一個合法的字符串類型,用雙引號包含,固然,和C語言有些不一樣的是,字符串類型中僅支持\r\n\t三種類型轉義
最後是數據流類型,這個是StoryVM中自定的一種數據類型,理解起來並不複雜,例如
\@0102030405060708090A0B0C0D0F\@這就是一個數據流類型,一個數據流類型使用兩個\@包含,當中的文本是一個十六進制表示的數據流,兩兩爲一對爲一字節,這也就意味着當中的字符數必須在範圍0-F中,而且一定是偶數個.
在開始設計具體的指令前,咱們先來考慮下虛擬機指令集如何設計,固然,當前的指令集大多以以下的模式設計:
<ignore_js_op>
其中,操做碼錶示這條指令的具體做用,例如x86彙編中的MOV eax,1中的mov就是操做碼
緊接在操做碼以後的是操做數類型(或者也能夠叫參數類型),例如上上面這條彙編指令中一共有2個操做數(參數),分別是eax和數字1,它們分別表示一個寄存器類型和一個當即數類型,最後是操做數了,也就是咱們常說的參數.
固然,上述的規則適用於大多數的指令編碼格式,對於一些很是經常使用的指令,甚至會將一些操做數給」集成」到操做碼中,例如上述的MOV
eax,1指令中,mov
eax,被直接用E8代替,而操做數1則直接使用一個dword來設置,在這條指令中,只有一個操做碼和一個操做數.
如此的設計能夠保證編譯的程序儘快能的小,可是做爲代價,執行對於的指令集的CPU或虛擬機也須要設計更多的實現而變得愈來愈複雜
設計出x86相似的複雜指令集須要耗費大量的心血,但在咱們的虛擬機系統中,咱們無需使用如此複雜的指令集設計
筆者斟酌了定長指令和不定長指令的一些特色,設計出以下的一套指令集規範
操做碼以1字節進行標識
接着是3字節的操做數類型
<ignore_js_op>
這意味着咱們的指令設計每一個指令至少佔4字節寬度,而且最多隻能接受3個操做數.
在CPU設計,寄存器用於數據的暫存,在電路設計中,這不一樣的寄存器被賦予不一樣的意義,在筆者的虛擬機架構中,並不須要關注電路設計如此複雜的內容,但筆者仍然將寄存器設計分爲兩種寄存器,一種是臨時數據寄存器,一種是特殊寄存器.
其中,臨時數據寄存器本質上就是以前提到的元數據,它與堆棧中的元數據並無別的區別,訪問臨時寄存器用R+寄存器標號的方式訪問,在筆者設計的虛擬機中,每一個虛擬機實例一共有16個這樣的臨時寄存器,用R0\~R15對他們進行訪問.
以後是三個特殊寄存器,SP,IP,BP,若是有閱讀過彙編代碼的讀者應該對這三個寄存器再熟悉不過了,SP永遠指向棧頂,IP寄存器指向當前正在執行的指令,BP更可能是爲了支持函數調用中尋找參數的偏移地址用的,提早將它加進來爲後期設計高級語言的編譯器作下準備
這三個特殊寄存器都是dword類型,這意味這咱們的虛擬機最大的尋址範圍是4GB.
在虛擬機的堆棧是由元數據構建起來的,固然,棧的增加方向爲高地址向低地址,而堆的方向則是低地址到高地址
<ignore_js_op>
在StoryVM中,通常使用GLOBAL[索引號]訪問堆棧的元數據,通常使用LOCAL[索引號]來訪問棧數據
固然
GLOBAL[BP+i]和LOCAL[i]是等價的,LOCAL表示在偏移量加上一個BP寄存器的值,主要用來訪問參數和臨時變量.
實際上不只僅是虛擬機,目前咱們見到的大部分的計算機架構均可以把程序當作一張很長的寫滿指令的紙條,而計算姬要作的就是從頭讀到尾,並從頭執行到尾,咱們的虛擬機一樣遵循着這樣的」執行守則」
當一個腳本被編譯爲指令流後,虛擬機依次讀取一條指令而後執行,固然,指令也並非徹底按照順序讀取,由於指令當中也包含一些」跳轉」指令,這將會讓虛擬機」跳轉」到紙條的其它地方執行指令.
固然,虛擬機設計是一個龐大的須要深思熟慮的系統,若是考慮IO(輸入輸出)及中斷,多線程的線程調度的話,咱們沒法簡簡單單用一個字條來描述一個虛擬機的執行過程,可是在文章的開始,初學者依照這個比喻,對虛擬機是如何運行的有個初步的概念.
<ignore_js_op>
筆者在最初設計StoryVM的時候,指令只有短短的幾條,一個指令集的完善,不能僅僅是靠初期想固然的腦補,在StoryVM部署到實際的項目以後,筆者再不斷去添加那些須要的指令,固然,在本文當中筆者並不打算演示全部的指令實現,筆者決定挑選幾個很是具備表明性的指令進行講解,固然,首當其衝的就是mov這條指令,這也就是爲何本章筆者並不把標題起爲虛擬機指令設計,筆者認爲,有幾個特殊指令是值得專門花費一章節去講解的.
那麼,MOV指令是怎麼回事,有什麼用呢
其實很是簡單的說法,這是一個數據傳送(賦值)指令,好比下面的算式
i = 314
就是把變量i賦值爲314
固然,若是把這條語句寫爲StoryVM的彙編代碼形式,那麼就是
MOV\ i,314
固然,i在彙編中並不存在,假設它是一個全局變量,那麼它應該在堆中,假設它在GLOBAL[0]的位置,那麼,應該寫成
MOV\ GLOBAL\lbrack 0\rbrack,314
這樣,GLOBAL[0]就被正式賦值爲一個整數,爲314,固然看到這裏,你應該會以爲MOV指令很是簡單,例如,下面的彙編語句即便筆者不說讀者也很容易理解要表達的意思
MOV R1,123 //R1寄存器賦值爲123
MOV R2,3.14 //R2寄存器賦值爲3.14
MOV R3,」Hello World」 //R3寄存器賦值爲字符串」Hello World」
MOV R4,\@0102\@//R4寄存器賦值爲兩字節長度的0x01 0x02
上面的語句沒什麼問題,其中,R1和寄存器R2順利地被賦值到了元數據寄存器中,可是R3和R4不得不說起一下,當一個元數據被賦值爲一個字符串和數據流類型時,不可避免地涉及到了內存分配的問題,但還好,這實現起來並不複雜,當一個寄存器被賦值爲了字符串或者數據流類型時,從內存池中劃出一塊內存將字符串或數據類型存儲進去,而後這個元數據中指定一個字符串指針指向它就好了.
<ignore_js_op>
那麼咱們來看看下面的兩個語句
MOV R1,123
MOV R1,」Hello」
先將寄存器R1賦值爲123,而後再將字符串賦值到寄存器R1中(在內存池申請內存,而後將元),這沒什麼問題,可是咱們將兩個語句換一下,那麼問題就來了
MOV R1,」Hello」
MOV R1,123
首先,R1寄存器被賦值爲」Hello」,在這以後,它又被賦值爲123,這將會帶來一個問題,若是將R1直接賦值爲123,爲字符串hello在內存池分配的空間將得不到釋放,所以,當一個字符串或是數據流時,在內存池分配的空間都應該被釋放
從上一章節MOV的討論中咱們能夠看出當一個元數據由一種類型變換爲另外一種類型時,一是可能伴隨着內存的分配,既然有了分配那就應該有回收機制,例如當一個元數據由int類型變爲了string類型,那麼,在內存池中必須申請一塊內存區域用於存儲字符串類型,而由string類型變爲了int類型,將伴隨着string類型所佔用的內存釋放,也就是內存的回收,那麼在何時內存須要分配而何時須要回收呢,筆者總結了如下幾種狀況
當一個數據由其餘類型變爲了string或者是memory類型時,須要在內存池中爲其分配內存空間.
即以下代碼
MOV R1,」ABC」
MOV R2,R1 //須要爲R2分配內存以進行字符串類型的拷貝
當一個元數據由string或memory類型變爲其它類型時,固然,這也包括string類型變爲memory或者是memroy類型變爲了string類型,須要對原內存進行回收
MOV R1,」Hi」
MOV R1,」Hello」
那麼,R1所在的字符串所在內存可能由於字符串長度的改變須要進行從新分配,須要分配與回收
由於採用了這種」元數據」的存儲方式,對於字符串及數據流類型,不可避免地就須要好好思考下如何管理內存了,畢竟內存泄漏在虛擬機的執行過程當中是決不容許的,誰也不但願本身的程序跑着跑着內存就被一點點吃光最後致使崩潰.這種內存管理機制咱們經常稱之爲GC(garbage
collection 垃圾回收機制)
不過在StoryVM中,咱們只要遵循並注意這個」元數據」的類型切換時內存的管理就能夠避免內存泄漏了,除此以外咱們也注意到了,內存的回收分配機制,是一個很是耗費性能的調度機制,而且優化的難度大且難以免,這也難怪在網上常常看獲得對java這種重度依賴GC的語言被各類的吐槽,不過幸虧在StoryVM中咱們使用的是自行架構的內存池方案,避免了直接使用malloc/free等須要syscall的API額外調用開銷.
固然,不只僅是mov指令,全部對元數據形成修改(無論它是寄存器仍是堆棧中的元數據)的指令咱們都須要遵循上述的gc規則進行管理,否者結果一定是災難性的,筆者使用mov指令作」拋磚引玉」之用,是由於Mov指令太具備表明性了,這個看上去最簡單的指令,其實現倒是storyVM中最複雜的指令,不過讀者們也無需擔憂,在mov指令設計完成後,剩下的指令要設計起來就簡單多了.
若是說讓個小學生作個加減乘除運算,想必並也並非什麼複雜的事情,但在StoryVM上,咱們考慮的就有點多了.
首先咱們先來看看下面兩個表達式
1+1=2
1+1.0=2.0
在數學的意義上,1和1.0是等價的,上面兩個表達式一樣是個等價的表達式運算,可是,在計算機當中,數字的不一樣表示方式可能致使大相徑庭的運算規則,首先,整數和浮點數的編碼在計算機中是不一樣的這也就意味着
1+1是一個整數運算而1+1.0是一個浮點類型的運算
出於精度的考慮,在計算的結果中咱們會以精度更高的表達方式進行表達,所以.當一個整數和一個浮點數進行運算後,它的結果也是一個浮點數.這點在StoryVM中須要被認真的考慮,與此同時的,在編程開發時,咱們也經常使用的到浮點截斷(去掉小數點後面的數值),所以,必須也設計相應的指令,將浮點數轉換成整數,或者是將一個整數轉換成浮點數的表示方式
在StoryVM的指令運算設計中,雙目運算符(須要兩個數字進行運算的操做符)遵循如下的運算規律
整數與整數運算,獲得的也是一個整數
整數與浮點數運算,獲得一個浮點數
同時,浮點數的編碼方式也須要被嚴格的考慮,若是由於編譯環境的不一樣而致使浮點的編碼方式不一樣,那麼腳本在跨平臺運行方面就會出現錯誤,但幸運的是,StoryVM使用C語言進行編寫開發,而C語言的編譯器基本都使用IEEE
754的標準對浮點數進行編碼.
在StoryVM中,參考了x86指令中加減乘除的助記符,加減乘除的彙編指令分別爲
ADD SUB MUL DIV
寫起來也基本相似,例如要實現1+1=2的這個表達式方式,指令編寫以下
MOV R1,1 //寄存器R1賦值爲1
ADD R1,1 //R1+1=2
在ADD R1,1指令中,ADD稱之爲操做碼,R1,1稱之爲操做數,其中R1位操做數1,數字1爲操做數2
實際上嚴格來講,ADD應該稱之爲加法操做碼對應的助記符(mnemonic),R1是寄存器1對應的助記符,1是一個常量,固然,ADD函數遵循着運算隱式轉換的規則,若是指令改成
ADD R1,1.0
那麼寄存器R1對應的元數據類型也會相應的轉換爲一個浮點數據類型.
若是彙編器將ADD,R1,1這個指令編譯成指令流,那麼,它應該是下面這個樣子的
<ignore_js_op>
參照以前提到的編碼格式,其對應的指令流爲0x02 0x02 0x01 0x00 0x00000001
0x00000001一共12字節.
下面,咱們用opcode表示操做碼.op1表示操做數1,op2表示操做數2,GLOBAL表示接受堆數據數據類型,LOCAL表示接受棧數據數據類型,REG表示接受寄存器數據類型,int,float,string,memory分別表示接受整形,浮點型,字符串型和數據流型常量.num表示接受一個數字,便可是是浮點類型也但是整數類型.
例如減法指令sub的描述以下
減法指令,op1=op1-op2,將操做數1的值減去操做數2的值,而後將結果賦值給操做數1
固然,操做數1必須是一個寄存器或者是堆棧中的元數據,由於常量不能被賦值,操做數2能夠是一個寄存器或者堆棧中的元數據或者一個數字常量均可.
sub [reg,local,global],[num,reg,local,global]
依次類推,那麼,在StoryVM中,幾個運算指令的描述以下(爲了說明方便,後面的表達式用C語言的運算符進一步描述)
add
加法指令,op1=op1+op2
add [reg,local,global],[num,reg,local,global]
sub
減法指令,op1=op1-op2
sub [reg,local,global],[num,reg,local,global]
neg
符號求反指令,op1=-op1
neg [reg,local,global]
div
除法指令,op1=op1/op2
div [reg,local,global],[num,reg,local,global]
mul
乘法指令,op1=op1*op2
mul [reg,local,global],[num,reg,local,global]
mod
餘數指令,兩個操做數必須爲整數 op1=op1%op2
mod [reg,local,global],[int,reg,local,global]
shl
左移位指令,兩個操做數必須爲整數 op1=op1\<\<op2
shl [reg,local,global],[int,reg,local,global]
shr
右移位指令,兩個操做數必須爲整數 op1=op1\>\>op2
shr [reg,local,global],[int,reg,local,global]
and
與運算指令,op1=op1&op2
and [reg,local,global],[num,reg,local,global]
or
或運算指令,op1=op1|op2
or [reg,local,global],[num,reg,local,global]
xor
異或運算指令op1=op1\^op2
xor [reg,local,global],[num,reg,local,global]
inv
位取反指令,op1=\~op1
inv [reg,local,global]
not
邏輯非指令 op1=!op1
not [reg,local,global]
andl
邏輯與指令 op1=op1&&op2
andl [reg,local,global],[num,reg,local,global]
orl
邏輯或指令 op1=op1||op2
andl [reg,local,global],[num,reg,local,global]
pow
階乘指令(op1爲底數,op2爲指數,結果在op1中) op1=op1_op2
pow [reg,local,global],[num,reg,local,global]
sin
正弦函數op1=sin(op2)
sin [reg,local,global],[num,reg,local,global]
cos
餘弦函數 op1=cos(op2)
cos [reg,local,global],[num,reg,local,global]
int
強制類型轉換爲int型(原類型float)
int [reg,local,global]
flt
強制類型轉換爲float型
flt [reg,local,global]
相比於可能修改元數據須要當心翼翼管理內存的指令,條件跳轉指令的實現可就簡單的多了,惟一須要注意的是,如何肯定跳轉的位置,在x86指令集中,跳轉指令的設計就複雜的多了,有近跳轉,遠跳轉,相對跳轉和絕對跳轉,可是在StoryVM中,跳轉指令並不須要設計的那麼複雜,全部的跳轉指令都爲絕對跳轉.
在StoryVM中,使用JMP指令表示一個無條件跳轉指令,例如
JMP 10表示程序跳轉到地址爲10的位置執行
這麼設計固然沒有一點問題,可是咱們不可能在編寫程序時,手工去計算咱們要跳轉的位置,那麼問題就是如何肯定跳轉的地址了,幸運的是,每條指令的長度均可以很方便的進行計算,咱們只須要設計一個標誌,就能夠很容易計算出標誌所在的地址了
在StoryVM中,標誌的表示方式是一個助記符加上一個冒號,例如
FLAG:
MNEMONIC:
ADDR:
TRUE:
都是合法的標誌類型,不少時候爲了表現這是一個函數,在標號前能夠加入描述符FUNC
例如
FUNC FLAG:
和
FLAG是等價的,FUNC這個助記符對源代碼沒有任何的影響,只是爲了代碼方便查看及分類添加的一個沒有意義的關鍵字
如今觀察下面的指令
MOV R1,1 //長度爲12字節
ADD R1,2 //長度爲12字節
FLAG: //偏移地址爲24
ADD R1,2//長度爲12字節
JMP FLAG//跳轉到FLAG處開始執行
須要注意的一點是,若是一個彙編程序從開始編譯到結束,那麼,標號必須在JMP指令以前,也就是說JMP必須是向前跳轉的,這顯然不符合一個跳轉指令應該具備的功能,要解決這一個問題實際也並不複雜,在彙編指令的編譯期間,對源代碼進行兩次掃描,第一次掃描肯定全部的標號對應的位置,第二次掃描纔將JMP指令」鏈接」到對應的標號中實現跳轉,
<ignore_js_op>
除了無條件跳轉指令,固然還有一系列的跳轉指令,具體描述以下
je
條件跳轉,當op1等於op2,跳轉到op3
je [num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jne
條件跳轉,當op1不等於op2,跳轉到op3
jne [num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jl
條件跳轉,當op1小於op2,跳轉到op3
jl [num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jle
條件跳轉,當op1小於等於op2,跳轉到op3
jle [num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jg
條件跳轉,當op1大於op2,跳轉到op3
jg [num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jge
條件跳轉,當op1大於等於op2,跳轉到op3
堆棧操做自己屬於數據結構的一種,固然,堆棧的操做和特殊的寄存器SP,BP相關,就和咱們以前說的那樣LOCAL[i]和GLOBAL[BP+i]是等價的,而SP指令和
PUSH
POP
兩個指令相關
PUSH x指令實際上和下面的指令等價
SUB SP,1
MOV GLOBAL[SP],x
也就是說,每當執行一條PUSH指令,SP寄存器的值會被減去1,而後將PUSH的值賦值到對應的堆空間GLOBAL[SP]中
而POP x指令和PUSH指令恰好相反,其等價於
ADD SP,1
MOV x,GLOBAL[SP]
在指令的編寫及設計中,PUSH指令和POP指令經常是成對出現的,也就是咱們經常說的要保證堆棧平衡,若是PUSH
POP不成對出現,每每容易致使內存泄漏,越界訪問等異常現象.
實際上虛擬機有上述指令就已經能夠完成大部分的工做了,但除了通常的跳轉指令,在StoryVM中還設計了一個特殊的指令CALL指令,CALL指令自己並無什麼特別的地方,它的做用是將下一條指令的地址壓棧,而後跳轉到目的標號中,CALL指令的設計一樣是一種數據結構上的調度處理,當咱們爲這個彙編語言設計高級語言編譯器的時候,CALL指令就會被常常的用到.
介紹完幾種關鍵的指令後,最後貼上StoryVM所支持的全部指令集,讀者能夠參照這些指令自行實現
mov
賦值指令,將op2的值賦值給op1
mov [reg,local,global],[num,string,reg,local,global]
add
加法指令,op1=op1+op2
add [reg,local,global],[num,reg,local,global]
sub
減法指令,op1=op1-op2
sub [reg,local,global],[num,reg,local,global]
neg
符號求反指令,op1=-op1
neg [reg,local,global]
div
除法指令,op1=op1/op2
div [reg,local,global],[num,reg,local,global]
mul
乘法指令,op1=op1*op2
mul [reg,local,global],[num,reg,local,global]
mod
餘數指令,兩個操做數必須爲整數 op1=op1%op2
mod [reg,local,global],[int,reg,local,global]
shl
左移位指令,兩個操做數必須爲整數 op1=op1\<\<op2
shl [reg,local,global],[int,reg,local,global]
shr
右移位指令,兩個操做數必須爲整數 op1=op1\>\>op2
shr [reg,local,global],[int,reg,local,global]
and
與運算指令,op1=op1&op2
and [reg,local,global],[num,reg,local,global]
or
或運算指令,op1=op1|op2
or [reg,local,global],[num,reg,local,global]
xor
異或運算指令op1=op1\^op2
xor [reg,local,global],[num,reg,local,global]
inv
取反指令,op1=\~op1
inv [reg,local,global]
not
邏輯非指令 op1=!op1
not [reg,local,global]
andl
邏輯與指令 op1=op1&&op2
andl [reg,local,global],[num,reg,local,global]
orl
邏輯或指令 op1=op1||op2
andl [reg,local,global],[num,reg,local,global]
pow
階乘指令(op1爲底數,op2爲指數,結果在op1中) op1=op1_op2
pow [reg,local,global],[num,reg,local,global]
sin
正弦函數op1=sin(op2)
sin [reg,local,global],[num,reg,local,global]
cos
餘弦函數 op1=cos(op2)
cos [reg,local,global],[num,reg,local,global]
int
強制類型轉換爲int型(原類型float)
int [reg,local,global]
flt
強制類型轉換爲float型
flt [reg,local,global]
strlen
字符型長度指令
op1=strlen(op2)
strlen [reg,local,global],[reg,local,global,string]
strcat
字符型拼接指令
strcat(op1,op2)
strcat [reg,local,global],[int,reg,local,global,string]
strrep
字符串替換函數
將op1存在的op2字符串替換爲op3中的字符串, 注意:op2 op3必須爲字符串類型
strrep [reg,local,global],[reg,local,global,string],[reg,local,global,string]
strchr
將op2在索引op3中的字存儲在op1中, 注意:op2必須爲字符串類型
strchr [reg,local,global],[reg,local,global,string],[reg,local,global,int]
strtoi
將op2轉換爲整數保存在op1中,注意:op2必須爲字符串類型
strtoi [reg,local,global],[reg,local,global,string]
strtof
將op2轉換爲浮點數保存在op1中,注意:op2必須爲字符串類型
strtof [reg,local,global],[reg,local,global,string]
strfri
將op2整數類型轉換爲字符串類型保存在op1中
strfri [reg,local,global],[reg,local,global,int]
strfrf
將op2浮點類型轉換爲字符串類型保存在op1中
strfrf [reg,local,global],[reg,local,global,float]
strset
將op1所在字符串索引爲op2 int的字符置換爲op3
若是op3爲一個int,則取asc碼(第八位1字節),若是op3爲一個字符串,則取第一個字母
strset [reg,local,global],[reg,local,global,int],[reg,local,global,string,int]
strtmem
將op1字符串類型轉換爲內存類型
strfrf [reg,local,global]
asc
將op2的第一個字母以asc碼的形式
asc [reg,local,global],[reg,local,global,string]
membyte
將op3 內存類型對應op2索引複製到op1中,這個類型是一個int類型(小於256)
membyte [reg,local,global],[reg,local,global,int],[reg,local,global,memory]
memset
設置op1對應op2索引的內存爲op3
memset [reg,local,global],[reg,local,global,int],[reg,local,global ,int]
memtrm
將op1內存進行裁剪,其中,op2爲開始位置,op2爲大小
memcpy [reg,local,global],[reg,local,global,int],[reg,local,global,memory]
memfind
查找op2對應於op3內存所在的索引位置,返回結果存儲在op1中,若是沒有找到,op1將會置爲-1
memfind [reg,local,global],[reg,local,global,memory],[reg,local,global,memory]
memlen
將op2的內存長度存儲在op1中
memlen [reg,local,global],[reg,local,global,memory]
memcat
將op2的內存拼接到op1的尾部
memcat [reg,local,global],[int,reg,local,global,memory]
memtstr
將op1內存類型轉換爲字符串類型,若是op1的內存結尾不爲0,將會被強制置爲0
memtstr [reg,local,global]
datacpy
複製虛擬機data數據,從地址op2到地址op1,長度爲op3
datacpy [reg,local,global,int], [reg,local,global,int], [reg,local,global,int]
jmp
跳轉指令 跳轉到op1地址
jmp [reg,num,local,global,label]
je
條件跳轉,當op1等於op2,跳轉到op3
je
[num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jne
條件跳轉,當op1不等於op2,跳轉到op3
jne
[num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jl
條件跳轉,當op1小於op2,跳轉到op3
jl
[num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jle
條件跳轉,當op1小於等於op2,跳轉到op3
jle
[num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jg
條件跳轉,當op1大於op2,跳轉到op3
jg
[num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
jge
條件跳轉,當op1大於等於op2,跳轉到op3
jge
[num,string,reg,local,global],[num,string,reg,local,global],[reg,int,local,global,label]
lge
邏輯比較指令,當op2等於op3時將op1置1,不然爲0
lge [reg,local,global], [num,string,reg,local,global] ,
[num,string,reg,local,global]
lgne
邏輯比較指令,當op2等於op3時將op1置0,不然爲1
lge [reg,local,global], [num,string,reg,local,global] ,
[num,string,reg,local,global]
lgz
邏輯比較指令,當op1等於0時將op1置1,不然爲0
lgz [reg,local,global]
lggz
邏輯比較指令,當op1大於0時將op1置1,不然爲0
lggz [reg,local,global]
lggez
邏輯比較指令,當op1大於等於0時將op1置1,不然爲0
lggez [reg,local,global]
lglz
邏輯比較指令,當op1小於0時將op1置1,不然爲0
lglz [reg,local,global]
lglez
邏輯比較指令,當op1小於等於0時將op1置1,不然爲0
lglez [reg,local,global]
call
調用指令,若是op1是本地地址則將當期下一條指令地址壓棧,而後跳轉到op1,若是op1是一個host地址,則該call爲一個hostcall,hostcall不會將返回地址壓棧
call [reg,int,local,global,label,host]
*Host Call的返回值在r[0]中
*由被調用者清理堆棧
push
將op1壓棧 sp-1,stack[0]=op1
push [num,reg,local,global,string,label]
pop
出棧,並將該值
pop [reg,local,global]
adr
取堆棧的絕對地址,返回該堆棧的絕對地址
ADR [reg,local,global], [local,global]
popn
將op1個元素出棧
popn [reg,local,global]
ret
返回,pop一個返回地址,跳轉到該地址.
wait
等待一個信號量置爲0,否者這個虛擬機實例將被暫時掛起(但並不不影響suspend標準位),在每一個虛擬機實例中都有16個信號量,經過signal指令對這些信號量進行設置
signal
等待op1對應索引的信號量置爲op2,
在每一個虛擬機實例中都有16個信號量,這意味着op1的範圍是0-15,當一個信號量被設置爲非0值時,執行wait指令後改虛擬機實例會被阻塞,直到這個信號量被置爲0時才能繼續執行後續指令
bpx
若是啓動了調試器,將會在該指令上斷點,否者做爲一個空指令
nop
空指令
也許製做一個高級語言的編譯器會複雜得多,可是編寫一個彙編編譯器並不複雜,在大部分的時候,彙編編譯器所作的工做無非是將助記符」編譯」爲指令數據流,這有如下幾點好處
指令流每每所佔的空間更小
指令流解析速度遠遠快於直接解析助記符
一個指令是否被翻譯爲指令流也經常被當作是這個語言是解釋型語言仍是編譯型語言的分水嶺,可是就和以前提到的,其實本質上都是對指令的解釋只是方法不一樣,並無特別的區別
在將指令編譯爲指令集以前,咱們須要先處理幾種狀況覺得開始編譯作準備.
首先要處理的是以前提到的標誌,標誌用於指明跳轉指令的跳轉位置,所以在編譯以前,掃描整個源文件全部的標號,並將標號記錄在表中.
<ignore_js_op>
在第二次正式開始編譯時,更新對應標號的跳轉指令,讓他們指向正確的位置
<ignore_js_op>
這樣,對標號的處理就算完成了,固然除了標號,還有其餘的數據須要處理,觀察下面的幾句彙編代碼:
<ignore_js_op>
首先咱們編譯MOV
R1,255,當操做數做爲寄存器和數字常量這沒有一點問題,咱們很容易就能寫出其編譯後對應的指令流,可是,MOV
R1,」Hello
World」就沒有那麼簡單了,依照StoryVM的指令編碼標準,每一個操做數對應一個1字節的操做數類型和一個4字節的值,顯然,Hello
World這個字符串已經超過了四字節所能容納的範圍了,所以和FLAG同樣,咱們也要將全部的字符串和數據流類型在第一次掃描時提取出來,將他們放入一個表中
<ignore_js_op>
當第二次正式編譯時,咱們一樣和FLAG同樣的方式,將字符串對應的索引號連接到對應的操做數當中.
<ignore_js_op>
<ignore_js_op>
固然除了字符串,對數據流也採用一樣的辦法創建一張表並創建映射關係,
最後是關鍵字ASSUME的處理,這是一條僞指令,其做用相似於C語言的define
例以下面的代碼
ASSUME NUM,123
MOV R1,NUM
它等價於代碼
MOV R1,123
由於內存空間是有限的,所以對堆棧的大小必須有一個限制,在C語言或者其餘的高級語言中,堆的大小能夠從全局或者靜態變量計算出來,而若是棧沒有被明確的設定,那麼編譯器會選擇一個默認值做爲棧的大小,以visual
studio的MSVC爲例,默認的棧大小是2M,正常狀況下這個大小是足夠了.
在StoryVM中,由於採用了元數據的存儲方式,這也就意味着咱們開闢的棧大小實際佔用的內存會是這個棧的大小十倍乃至二十幾倍,所以控制棧的大小就變得很是有必要,正常狀況下,筆者使用65535個元數據做爲棧(實際佔用了將近2M的內存空間),可是在不少狀況下除非使用深度的迭代並在局部變量中創建了大數組,實際並不須要那麼大的棧空間,咱們沒法預測用戶實際上會用到多少的棧空間,所以在StoryVM的彙編中,咱們引入了.GLOBAL和.STACK兩個關鍵字
例如
.GLOBAL 100
.STACK 1024
表示創建一個大小爲100個元數據的堆,大小爲1024個元數據的棧,它在內存中其實是這樣排布的
<ignore_js_op>
能夠看到,堆棧其實是訪問了同一塊內存空間,也就是說,若是訪問GLOBAL[101]實際上已經訪問了棧的區域了,須要注意的是,當棧溢出後,它將會覆蓋堆中的數據,固然,StoryVM中作邊界檢查並不複雜.
實際上在以前已經有過很是多的指令編譯的討論了,如下圖爲例
上面的指令流其實是MOV
R1,255的編譯,其中MOV被編譯爲了01表示MOV指令的實際操做碼就是01,R1的操做數類型是寄存器,其中,02就表示這個操做數是一個寄存器,由於他是寄存器1,因此其對應的操做碼參數也是1,最後是255,他是一個常量,01表示這是一個常量類型的操做數,它的值是255,也就是十六進制的000000ff
實際上,筆者總結了操做數的類型主要爲如下幾種enum PX_SCRIPT_ASM_OPTYPE
{
PX_SCRIPT_ASM_OPTYPE_INT, //整數常量,操做數參數爲這個常量的值
PX_SCRIPT_ASM_OPTYPE_FLOAT, //浮點常量,操做數參數爲這個常量的值
PX_SCRIPT_ASM_OPTYPE_REG, //寄存器,操做數參數爲這個寄存器的索引號
PX_SCRIPT_ASM_OPTYPE_LOCAL, //局部變量類型
PX_SCRIPT_ASM_OPTYPE_LOCAL_CONST, //局部變量引用,例如LOCAL[5],操做數參數就是5
PX_SCRIPT_ASM_OPTYPE_LOCAL_REGREF,
//局部變量的寄存器引用,例如LOCAL[R1],就是一個寄存器引用,操做數參數是對應寄存器的索引
PX_SCRIPT_ASM_OPTYPE_LOCAL_GLOBALREF,
//局部變量的全局變量引用,例如LOCAL[GLOBAL[1]],就是一個全局變量引用,操做數參數是對應全局變量的偏移量,例如這裏就是1
PX_SCRIPT_ASM_OPTYPE_LOCAL_LOCALREF,
//局部變量的局部變量引用,例如LOCAL[LOCAL[2]],就是一個局部變量引用,操做數參數是對應局部變量的偏移量,例如這裏就是2
PX_SCRIPT_ASM_OPTYPE_GLOBAL,//全局變量類型
PX_SCRIPT_ASM_OPTYPE_GLOBAL_CONST, //全局變量引用,例如GLOBAL[5],操做數參數就是5
PX_SCRIPT_ASM_OPTYPE_GLOBAL_REGREF,
//全局變量的寄存器引用,例如GLOBAL[R1],就是一個寄存器引用,操做數參數是對應寄存器的索引
PX_SCRIPT_ASM_OPTYPE_GLOBAL_GLOBALREF,
//全局變量的全局變量引用,例如GLOBAL[GLOBAL[1]],就是一個全局變量引用,操做數參數是對應全局變量的偏移量,例如這裏就是1
PX_SCRIPT_ASM_OPTYPE_GLOBAL_LOCALREF,
//全局變量的局部變量引用,例如GLOBAL[LOCAL[2]],就是一個局部變量引用,操做數參數是對應局部變量的偏移量,例如這裏就是2
PX_SCRIPT_ASM_OPTYPE_GLOBAL_SPREF,
//全局變量的SP寄存器引用,例如GLOBAL[SP],就是一個SP寄存器引用,操做數參數是對應局部變量的偏移量,例如這裏就是2
PX_SCRIPT_ASM_OPTYPE_STRING,//字符串常量,操做數參數就是以前所述的對應字符串索引
PX_SCRIPT_ASM_OPTYPE_LABEL,//標籤,實際上標籤並不會被編譯成實體的指令流
PX_SCRIPT_ASM_OPTYPE_HOST,//Host函數標籤,以後再說
PX_SCRIPT_ASM_OPTYPE_MEMORY,//數據流類型常量,操做數參數就是以前所述的對應字符串索引
PX_SCRIPT_ASM_OPTYPE_BP,//BP寄存器
PX_SCRIPT_ASM_OPTYPE_SP,//SP寄存器
PX_SCRIPT_ASM_OPTYPE_IP,//IP寄存器
};
操做數類型由以上定義完成,而操做碼讀者能夠自行設計任意一個值,例如筆者設定的StoryVM中
MOV指令的操做碼是01
ADD 是02
SUB是03
MUL 是04
DIV是 05
……..讀者能夠根據本身的須要自行設計
和原生二進制編譯的代碼有所不一樣的是,咱們編譯出的程序沒法直接供外部調用執行,而後腳本的主要做用就是供虛擬機調用須要的函數來執行咱們所須要的功能,所以,咱們須要將咱們的FLAG暴露給外部供原生的程序調用,同時在不少的時候,咱們的腳本也須要調用原生代碼裏的一些函數來完成交互,可是目前咱們的虛擬機設計仍然沒有辦法知足這一功能
例如以前咱們所說的下面的程序
MOV R1,1
ADD R1,2
FUNC:
MOV R1,3
JMP FUNC
在這裏,FUNC這個標誌僅能供給程序中的跳轉,但卻沒法在虛擬機中直接調用.所以,筆者引入了一個關鍵字EXPORT意爲導出函數,當一個標號被加上了EXPORT關鍵字後,程序編譯期間將會把這個標號對應的地址記錄在文件當中.以方便虛擬機中進行調用
<ignore_js_op>
一樣的,不少時候也須要在腳本中調用原生的代碼函數,這種函數咱們通常稱之爲host函數
爲此,在虛擬機中咱們須要設計一個對應的隱射關係表,將host函數名與其地址對應起來以方便腳本進行調用,例如,假如腳本須要調用一個叫print的函數,那麼,對應的腳本代碼應該相似於這樣編寫
MOV R1,」Hello World」
Push R1
Call \$print
注意,在CALL這條指令中,print這個標號在這個腳本文件中自己並不存在,但取而代之的是在其前綴中添加了一個\$號表示這是一個host函數,當虛擬機執行到這條指令的時候,將會在host函數表中查找是否有對應的函數,若是有,那麼就會跳轉到該函數進行執行,若是沒有那麼虛擬機會拋出一個異常
<ignore_js_op>
最後就是關於返回值的問題了,在通常狀況下,默認規定函數的返回值都存儲在寄存器R1中,若是須要獲取返回值,讀取R1寄存器就能夠了
最終,咱們須要將全部的信息進行進一步的整合,以便於虛擬機更好地執行咱們編譯出來的指令流,首先咱們能夠參照PE格式的可執行文件,爲咱們的可執行文件設置一個文件頭在筆者設計的StoryVM的編譯文件中,其設計知足如下的描述
<ignore_js_op>
其中,文件頭包含如下定義,其中px_dword表示4字節
typedef struct __PX_SCRIPT_ASM_HEADER
{
//////////////////////////////////////////////////////////////////////////
px_dword magic;//Magic Numeric必定是PASM
px_dword CRC;//CRC校驗
//////////////////////////////////////////////////////////////////////////
px_dword stacksize;//棧大小
px_dword globalsize;//堆大小
px_dword threadcount;//最大執行線程數量
px_dword oftbin;//到代碼區的偏移量
px_dword oftfunc;//到導出表的偏移量
px_dword funcCount;//導出函數數量
px_dword ofthost;//到導入表的偏移量,也就是host函數
px_dword oftmem;//到數據流常量區偏移量
px_dword memsize;//數據流常量區大小
px_dword hostCount;//導入函數數量
px_dword oftString;//到字符串常量區偏移量
px_dword stringSize;//字符串常量區大小
px_dword binsize;//代碼區大小
px_dword reserved[6];//保留
}PX_SCRIPT_ASM_HEADER;
將代碼進行整理打包後,一個能夠供虛擬機執行的編譯腳本也就算製做完成了.
當虛擬機載入一個編譯後的可執行腳本後,通常要進行如下幾個步驟
驗證文件頭中的Magic和CRC,驗證這個腳本是不是一個完整的編譯型腳本,固然,最好引入編譯器的版本號,若是之後對虛擬機的環境有修改,並規定該虛擬機能夠運行哪些版本的腳本,這個字段將尤其重要.
完成了驗證以後,以後就是對常量區進行一系列初始化了,從文件中讀取字符串及數據流常量區將它們存儲在運行內存中以供調用.
初始化host函數表,固然,只是初始化一個空表,在這裏咱們並不急着將導入函數映射到這個表當中.
如今要準備運行環境了,第一步固然是爲堆和棧分配空間,在這裏這個大小是能夠計算的,它等於(堆大小+棧大小*最大支持線程數)*元數據的大小,能夠看到這裏咱們引入了一個最大支持的線程數,這點咱們將在以後討論.
在開始運行虛擬機代碼以前,筆者先來聊一聊多線程的問題,若是你是一名開發人員那麼這個問題應該是再熟悉不過了,不過若是你並不瞭解多線程是什麼玩意,你能夠理解爲一我的同時作多件事情.
在你的PC上你能夠看見計算機同時運行着多個軟件,彷彿全部的程序都在同時運行着,在CPU仍是單核的年代,這是如何辦到的呢,其實要理解這點也很是的簡單,你能夠理解爲計算機在一秒鐘,先執行某一程序的一些指令,而後馬上切換到另外一個程序中執行另外一個程序的指令而不是等待第一個程序的代碼執行完成後再執行另外一個程序,若是重複這個過程而且切換的速度很是的快的話,這些程序看上去就像是同時運行的.
那麼問題就是如何進行這種切換了,在多線程當中,存在着數據共享區,也有那些獨立的區域,共享數據區好說,就是堆區了,無論哪個線程都訪問同一個堆,獨立的區域那就是棧了,這關係到函數調用問題,除了棧以外,寄存器也很是重要,所以每一次切換咱們都要保存當前線程的棧和寄存器狀態,當再次執行到這個線程的時候,再把這個棧和寄存器進行恢復,這就是咱們說的上下文切換,能夠說,上下文切換時多線程實現的關鍵技術.
瞭解了以上這幾點,那麼就能夠解釋以前在分配運行時內存空間爲何是參者這個(堆大小+棧大小*最大支持線程數)*元數據的公式了,是的,咱們須要爲每個線程分配一個獨立的棧空間,而且咱們爲每個線程設定一個寄存器實例,那麼每個線程使用的寄存器其實是分開的.
<ignore_js_op>
所以在虛擬機的設計中,執行的步驟是,先執行某一線程的一些指令,而後切換到下一個線程執行另外一些指令,這樣就產生了一種多個程序同時執行的狀況,但咱們也注意到多線程運行的同時也附帶着上下文切換帶來的性能開銷,不過幸運的是,因爲在StoryVM中全部的線程都有本身獨立的寄存器實例與獨立的棧空間,所以上下文切換帶來的性能開銷基本能夠忽略不計,咱們要作的就是根據實際狀況看給線程分配多少的時間片了(每一個線程每次執行多少條指令後切換).
咱們注意到多線程的引入產生了一些新的問題,那就是如何解決多線程的資源競爭問題(多個線程同時訪問修改一個數據),在windows中,提供了互斥體才避免這種狀況的發送,相信讀者也發現了在多線程章節給出的說明圖中,多出了一個信號量最高東西,實際上這和互斥體相似,一樣是爲了解決資源競爭所帶來的問題的
在以前的StoryVM指令表中有如下兩個特殊的指令
wait
等待一個信號量置爲0,否者這個虛擬機實例將被暫時掛起(但並不不影響suspend標誌位),在每一個虛擬機實例中都有16個信號量,經過signal指令對這些信號量進行設置
signal
等待op1對應索引的信號量置爲op2,
在每一個虛擬機實例中都有16個信號量,這意味着op1的範圍是0-15,當一個信號量被設置爲非0值時,執行wait指令後改虛擬機實例會被阻塞,直到這個信號量被置爲0時才能繼續執行後續指令
這也就意味着,當一個線程須要訪問一片資源時,他須要先進行wait某一信號量.若是不須要等待,再使用signal對其置爲非0值防止其餘線程對其進行訪問,最後完成後再對signal歸0操做
終於到實際執行指令的時候了,那麼程序從哪裏開始運行呢,固然,StoryVM中並無像PE文件裏那樣有一個OEP(程序最開始執行的地址),但咱們的程序的的確確須要執行一些必須先執行的指令,在StoryVM中筆者定義其爲_BOOT,通常狀況下它是代碼區中的第一條指令,還記得咱們以前提到的導出標籤麼,是的,_BOOT實際上就是一個導出標籤,在_BOOT標籤後緊跟着是一些全局變量的初始化操做,固然這是後話了,在目前咱們提到的彙編中,尚未全局變量初始化這一律念.這點咱們將在後期StoryScript的高級語言中討論,但如今咱們須要執行代碼怎麼辦,固然,這些都由用戶本身決定,若是你但願從某處開始執行,那麼你就應該在這裏添加一個導出標籤,例如以下代碼
EXPORT _MAIN:
MOV R1,」Hello,從這裏開始運行」
Ret
如今從哪裏運行解決了,那麼程序怎麼結束呢,注意上面的代碼有一個ret指令,這個指令的做用是從棧中彈出一個值,並將這個值做爲這個函數的返回地址,能夠看到,在這個函數前咱們並無對棧進行操做,是的,虛擬機在調用這個導出標籤的時候,將會在棧中壓入一個值爲-1的返回地址,當程序執行到ret時,發現返回地址是-1的話,就意味着這個程序已經執行完成了,那麼就會對程序進行資源回收,若是這是一個多線程調用的話,就會註銷這個線程,若是虛擬機中已經沒有須要執行的線程的話,就會掛起這個虛擬機.
若是說彙編語言是爲了簡化直接編寫機器語言而誕生的,那麼大多數的高級語言的出現就是爲了進一步簡化彙編語言而帶來的繁瑣.
在前幾章節中,筆者闡述了彙編語言的編譯與虛擬機的工做流程,但要將虛擬機實際應用這些還遠遠不夠,咱們須要更具備效率的編程語言來提升開發的效率,在接下來的章節中主要討論高級語言編譯器的實現技術,固然鑑於篇幅的關係,咱們仍然沒法討論全部的技術細節,若是你有相關的須要,你可能須要在網上或書中查找更多相關的資料.
毫無疑問的是,編寫一個高級語言的編譯器須要花費大量的心血,其工做量甚至比以前的虛擬機和彙編編譯器加起來還多,不過幸運的是,彙編器已經爲咱們完成了大量的重要工做,好比字符串數據流資源的整理,符號的掃描和集成的彙編指令的實現.
在開始以前,咱們仍然須要探討咱們到底須要編寫一個什麼樣的高級語言編譯器,關於這點筆者參考了多種方案,最終使用了一個類C語言的方案來編寫StoryScript編譯器.這主要有如下幾個優勢.
有現成的語言作參考,C語言有至關多的資料
函數式語言,過程化,實現起來相對簡單
筆者編寫C語言程序估摸算算也有十多年之久了,是的,筆者也使用過Java Pascal
C++但最終仍是回到了C語言的懷抱.
C語言用的人也很多
IDE就更多了,甚至不少狀況下能夠直接使用C語言的IDE來編寫StoryScript
那麼說了那麼多,到底StoryScript是怎麼樣的呢,筆者先寫一段StoryScript看看
#name 「Main」
#runtime thread 4
#runtime stack 4096
#define Num 9
Host void Print(string t);
String a,b;
Void print9x9(int c,int d)
{
Int I,j;
For(i=1;i<= Num;i++)
{
For(j=1;j<=I;j++)
Print(string(i)+」」+string(j)+」=」+string(ij)+」\t」);
Print(「\n」);
}
}
Export int start()
{
Print9x9(1,2);
}
這是一個輸出99乘法表的程序,你能夠注意到i這個變量,不過這不要緊.,StoryScript是一個大小寫無關的語言.下面咱們對這段程序進行分析,並最終闡述編譯器是如何工做的.
和彙編腳本語言同樣,StoryScript一樣有預處理指令,在這章節中將這段程序的預處理命令一塊兒討論,首先咱們先明確一點,StoryScript的編譯器其最終目的並非編譯出指令流,而是將StoryScript高級語言轉換爲符合其語法規則的彙編語言,以後再由彙編編譯器將其編譯成指令流.
首先咱們要處理的是
#name這個預處理,這個是StoryScript特有的一個標示語句,每個StoryScript必須在源代碼的開頭有這個語句,他的做用有點像這個源代碼起個名字,固然,每一個源文件有且必須有一個名字,這樣當其餘的源文件須要包含這個源文件時就能夠用#include
」Name」來包含這個源文件了,其功能和C語言中的#include
「xxxx.h」是同樣的,你可能會問爲何要畫蛇添足加上這個name專門去指定這個源文件的名字呢.直接用文件名很差麼,但筆者設計StoryScript的目的是,這個語言能夠執行在任何可移植StoryVM的平臺上,這也意味着其編譯器能夠一併移植到任意只要能提供C語言編譯環境的平臺上(這也任意平臺不只能夠執行編譯後的文件,還能夠直接提供源代碼執行,也就是便可以當編譯型腳本用,也能夠當解釋型腳本用),在不少的嵌入式平臺中,並不提供文件系統這一律念(實際上,StoryVM的虛擬機和編譯器的C語言代碼不包含任何的C語言標準庫,從內存池實現到數學運算所有都從新實現了一遍),所以筆者最終採用了這個方案來標識每一個源代碼的名稱.
接下來是
#runtime thread 4
#runtime stack 4096
兩個語句,這兩個語句,在彙編中會直接被編譯爲
.Thread 4
.Stack 4096
如你所見,他設置了這個腳本所支持的最大線程數量和默認的堆棧大小,固然,這兩個是可選的參數,若是你不寫這兩條語句,那麼,線程數會被默認設置爲1,棧大小會被默認設置爲65535
#define Num 9
這條語句和C語言的#define等價,也就是說,源文件中全部的Num會被替換爲數字9
最後是Host void Print(string t);
這是一個函數定義,前面的Host關鍵字表面,這個函數在源文件中並不存在,它是一個host函數,host函數的做用在以前已經討論過了,它是虛擬機使用原生代碼實現的函數,是腳本調用原生代碼的一種方式,在以後的代碼中,若是調用這個Print函數,其彙編代碼會被翻譯爲
Call \$Print
這樣的指令.
接下來就是start函數的定義和實現了
Void print9x9(int c,int d)
函數的調用一貫是函數式語言的工做方式,從在StoryScript中並無與C語言相似的main函數,從哪裏開始執行是由用戶定義的,但從上面的這個語句來看這顯然是一個標準的函數定義,
在函數的定義期間,並不會生成實際工做的彙編代碼,但它會產生一個標號,並肯定函數的棧分配和調用方式.
談及函數的調用關係,難以不說起當今主流的兩種比較有表明性的函數調用方式stdcall和cdecl,這兩種調用方式的參數傳遞方式都是從右向左壓棧,但惟一不一樣的是,stdcall在函數結束時由函數來維持堆棧平衡,而cdecl則是由調用方來維持堆棧平衡,爲了演示這兩種調用方式的不一樣,咱們查看兩種調用方案的彙編代碼
首先是stdcall的
push d
push c
call print9x9
在print9x9中須要彈出2個棧元素
而後是cdecl的
push d
push c
call print9x9
popn 2
在StoryScript中,咱們默認採用的是cdecl的模式由調用者來平衡堆棧,這也就意味着調用者必須清理棧來保證堆棧平衡.
最後是關於訪問參數的問題了,正如咱們以前提到的,LOCAL[x]實際訪問的是GLOBAL[x+BP],咱們知道,每次CALL指令調用後,會將函數的返回地址壓人棧中,所以按照咱們的思惟來講,LOCAl[1]訪問的是參數c,LOCAL[2]訪問的是參數d,這也就意味着,咱們必須在函數的開頭執行
MOV BP,SP將BP寄存器與棧進行掛鉤
<ignore_js_op>
這能夠很好的工做沒有什麼問題,可是這樣的設計是存在缺陷的,當咱們引入局部變量後,這種方式多多少少會引來不便.
咱們在代碼中注意到,在代碼中有兩個變量的定義
其中一個是全局變量string a,b
另外一個是局部變量int I,j;
那麼變量是如何隱射到堆棧中的呢,其中全局變量很好理解,咱們只須要在堆中進行線性排布就能夠了,例如,a其實是GLOBAL[0],b其實是GLOBAL[1]
那麼I,j如何處理呢,以前談論的方案很好的解決了參數的問題,但卻沒有解決好局部變量的問題,爲了繼續容納局部變量I,j,咱們須要對棧結構進行從新部署,在函數的開始時就爲局部變量預留好空間,那麼在print9x9這個函數中,在MOV
BP,SP以前,咱們須要爲局部變量開闢棧空間
SUB SP,2
MOV BP,SP
那麼,實際的棧結構變成了下面這個樣子
<ignore_js_op>
所以咱們最終發現,參數和局部變量實際上存儲在同一個區域並無什麼差異,但要注意的是,局部變量的釋放在函數的結束必定要作,例如這裏在函數的結尾必定要加上popn
2否者程序就會崩潰,而在執行ret指令後,須要在作一個popn 2把壓入的參數釋放掉.
abstract syntax tree
(AST)抽象語法樹,那麼什麼是語法樹呢,簡單來講就是把代碼轉換爲一個數結構便於分析的數據結構方案
<ignore_js_op>
那麼什麼是遞歸降低分析法呢,簡單來講就是模仿語法樹的分析方案創建起的一套算法規則,準確來講,遞歸降低分析法主要是用來分析代碼中的表達式的.那麼表達式是什麼呢,通俗點說就是算式
例以下面的表達式
1+2*3
這個式子創建起的AST樹相似於這個樣子
<ignore_js_op>
由於2,3節點深度比1節點大,所以先計算2*3,而後結果再和1進行相加獲得最終的結果,與AST樹稍有不一樣的是,遞歸降低分析法是基於堆棧式的語言,咱們先來看看遞歸降低分析法是如何解決上述式子的解析的
爲了方便描述,咱們創建了兩個棧,一個叫作操做碼棧,一個叫操做數棧,操做碼棧簡單而言就是存放數字的,而操做數棧就是存放運算符例如加減乘除的,在進一步討論以前,咱們須要先認識到運算符優先級這一個概念,小學的知識告訴咱們,乘法的優先級比加法的高,所以,咱們須要先計算乘法再計算加法,例以下面的表達式
1+2*3+4/5
由於乘法和除法的運算優先級比加法高,所以,2*3和4/5會被優先計算,那麼遞歸降低分析法會如何處理呢,實際上,遞歸降低分析法會先計算2*3,而後結果與1進行相加,以後再計算4/5,再與以前的結果相加
遞歸降低分析法的核顯是,從左到右依次讀取運算符,若是讀取的運算符優先級比上一個運算符優先級小或處於同一優先級,則進行計算.爲了讓讀者更好地理解遞歸降低表達式的做用,咱們一步一步對1+2*3+4/5用遞歸降低分析法進行運算
1.讀取第一個操做數1
操做數棧: 1
操做碼棧:
2.讀取操做碼+
操做數棧:1
操做碼棧:+
3.讀取操做數2
操做數棧:1,2
操做碼棧:+
4.讀取操做碼*
操做數棧:1,2
操做碼棧:+,*
5.讀取操做數3
操做數棧:1,2,3
操做碼棧:+,*
6.讀取操做碼+
操做數棧:1,2,3
操做碼棧:+,*
注意,在這個時候,由於加法的運算符優先級比乘法的小,所以咱們須要進行計算,由於乘法是一個雙目運算符,所以從操做數棧中彈出兩個操做數2,3計算2*3獲得結果6,在這個時候,再把6壓入操做數棧中獲得
操做數棧:1,6
操做碼棧:+
注意,由於加法的運算級和以前那個加號是同等運算優先級,所以,會在進行計算,由於加法是雙目運算符,所以,從操做數棧彈出2個操做數繼續計算1+6得7,那麼最終結果變爲了
操做數棧:7
操做碼棧:
最後,別忘了把讀取到的加號再次壓入棧中,因而就有
操做數棧:7
操做碼棧:+
7.讀取操做數4
操做數棧:7,4
操做碼棧:+
8.讀取操做碼 /
操做數棧:7,4
操做碼棧:+ /
9.讀取操做數5
操做數棧:7,4,5
操做碼棧:+ /
10.表達式結束,這個時候,看成讀取了一個優先級爲最低的運算符,所以須要處理全部尚未處理的操做碼,,先進行除法運算,4/5的0.8
操做數棧:7,0.8
操做碼棧:+
執行加法運算
操做數棧:7.8
操做碼棧:
至此表達式結束,取得最終的結果7.8
從上面的遞歸降低分析中,咱們很好地處理了這個表達式的解析關係,由於這個表達式的計算都是常量,計算起來也沒有那麼多的障礙,在StoryScript中,雖然一樣使用遞歸降低分析法來分析表達式問題,但由於存在變量的引用,實際產生的分析步驟卻複雜的多
例以下代碼
int a=2,b;
b=1+2*a;
那麼,下面的表達式是如何變成彙編代碼的呢
咱們如今觀察遞歸降低中的堆棧變換,並最終將上述的表達式變爲可執行的彙編代碼
1.讀取第一個操做數b
操做數棧: b
操做碼棧:
2.讀取操做碼=
操做數棧: b
操做碼棧:=
3.讀取操做數1
操做數棧: b,1
操做碼棧:=
4.讀取操做碼+
操做數棧: b,1
操做碼棧:=,+
5.讀取操做數2
操做數棧: b,1,2
操做碼棧:=,+
6.讀取操做碼*
操做數棧: b,1,2
操做碼棧:=,+,*
7.讀取操做數a
操做數棧: b,1,2,a
操做碼棧:=,+,*
8.表達式結束,按照遞歸降低的規則,咱們先對乘法進行運算,獲得2*a這個算式,爲了完成這個算式,咱們須要使用寄存器進行操做
MOV R1,2
MUL R1,a
這樣,R1的值就存儲着咱們所須要的值了
那麼,棧是否變爲了
操做數棧: b,1,R1
操做碼棧:=,+
呢,在這個表達式中,這樣固然沒有問題,然而實際的狀況是若是咱們直接將R1做爲操做數壓入棧中,那麼碰上
b=2*a+4*a這樣的表達式就會出現問題了,由於2*a將結果放在了R1中,那麼4*a就不能再使用R1使用了否者會將原來的值覆蓋掉,那麼就只能選擇R2寄存器了,可是寄存器是有限的,若是這個表達式足夠的長,那麼咱們的算法體系就會瀕臨崩潰,所以咱們不能直接將R1做爲操做數壓入棧中,咱們須要將R1做爲一個值變成彙編代碼壓入運行的棧中變成這個樣子
MOV R1,2
MUL R1,a
PUSH R1,那麼,操做數棧實際變爲了
操做數棧: b,1,POP
操做碼棧:=,+
那麼咱們進行了下一步.下一步是一個加法運算,爲了取得前一次計算的值咱們須要進行一次pop操做,並將這個值放入R2寄存器當中,一樣的在計算結束後,結果仍然在R1中,咱們還須要再次將R1進行壓棧
MOV R1,1
POP R2
ADD R1,R2
PUSH R1
這個時候,遞歸降低的兩個棧變成了
操做數棧: b,POP
操做碼棧:=
最後一次運算了
POP R2
MOV b,R2
MOV R1,b
PUSH R1
讀者可能會疑惑,爲何還要MOV R1,b再PUSH R1呢,到MOV
b,R2不是已經完成了這個表達式麼,固然,咱們設計一個程序不能只看到這樣的一個表達式,在不少的時候咱們必需要保證其通用性例如當碰上
b=(a=1)這樣的表達式時,你就知道爲何咱們須要」畫蛇添足」了,爲了保證咱們編譯的彙編程序的準確性,咱們須要考慮各類不一樣的狀況,儘管這可能會引入一系列的冗餘代碼,但這是值的的,冗餘的代碼咱們能夠在優化的部分再作處理,最後相信讀者也知道了,這個表達式最終的結果是什麼
MOV R1,2
MUL R1,a
PUSH R1
MOV R1,1
POP R2
ADD R1,R2
PUSH R1
POP R2
MOV b,R2
MOV R1,b
PUSH R1
POP R1
最後的POP
R1,表示取得表達式的最終計算結果.並將它放入寄存器R1當中,固然咱們也發現了這個表達式中仍然有很是多的冗餘代碼能夠優化,但這是後話了.固然,最後別忘了彙編代碼中的a,b其實被映射到了LOCAL[]的棧元素中,筆者直接寫a,b只是爲了方便讀者觀看,最終a,b會被替換成LOCAL[x]這種格式.
雖然遞歸降低分析法爲咱們解決了很多的問題,可是仍然有不少額外的問題須要咱們解決,其中一個就是類型在表達式中的做用,其中要說明的一點是StoryScript屬於強類型語言,一共支持四種類型
int---整數型
float----浮點型
string----字符串類型
memory-----數據流類型
除了int和float類型能夠互相運算操做,string,其它類型間不容許直接進行計算
例如
string a;
a=」hello」+」world」
這個表達式是一個合法的表達式
但a=」hello」+123這個不是一個合法的表達式,在進行遞歸降低分析時,一樣須要對錶達式的類型進行進一步的檢查,上面的字符串類型和一個整數類型進行加法運算顯然不是一個合法的表達式類型的運算,所以在檢查到這類沒法類型匹配的表達式時,編譯器應該要拋出一個錯誤.
除了類型匹配以外,還須要注意的是運算符匹配,例如位運算中的與或非異或等操做面向的是整數型,若是表達式結果是其它類型一樣須要拋出一個錯誤
例如
int a=1;
a=a\&1;這是一個合法的表達式
可是
int a=1;
a=(a+1.5)&1倒是一個不合法的表達式,由於a+1.5的運算結果是一個浮點數
不一樣類型間若是須要進行計算,須要專門的函數對其進行轉換,在StoryVM中,這些特殊的函數被直接編程到彙編指令中專門進行這種轉換操做,不少時候也管這種函數稱之爲關鍵字,其做用相似於C語言中的sizeof,但有所不一樣的是,sizeof是編譯期間就已經完成的,而StoryScript中的關鍵字卻會變編譯成實體的彙編指令.
繼續觀察代碼,咱們來到了For(j=0;j\<=I;j++)這條語句,之因此在這個代碼中使用for語句做爲示範是由於這個for語句最具備表明性,它包括了while和if語句的實現細節,能夠說,若是你能夠編譯For語句,你也能夠很順利地編譯while和if語句
觀察for語句的結構基本由以下這種方式來完成
for(初始化表達式;條件判斷表達式;末尾循環體)
{
for循環體
}
咱們以前已經說了遞歸降低分析表達式的方法,那麼剩下的就是如何構造for的語句結構了,爲了方便說明筆者用一張圖來表示for語句結構的剖析
<ignore_js_op>
當執行到for語句的時候首先執行的是初始化代碼,執行完成後將會跳轉到條件判斷代碼中判斷代碼是否成立,若是不成立則直接結束,若是成立則會執行for循環體,當for循環體執行完成後再跳轉到末位循環體中,讀者可能會有所疑問,爲何末位循環體被前置到那個位置,安裝邏輯不該該在for循環體以後麼,雖然道理你們都懂可是從編譯的角度進行分析,在咱們編譯for語句的時候,最早被解析的表達式就是初始化代碼區,條件判斷和末位循環體的表達式,而for循環體中的代碼可能很複雜還可能包含有各類的嵌套結構,所以若是將末位循環體放在for循環體以後,其實現起來會複雜的多,一個東西越複雜,那麼其就越有可能出錯,所以咱們採用了這種折中的方式來實現for循環體,效果同樣卻節省了大量的代碼,這也是筆者爲何一直強調,不少問題只有在你真正動手去作了你才知道怎麼回事,有些東西書本上沒法告訴你,那套聽上去吊炸天的架構和公式也沒法最終幫你解決不少問題
最後,咱們手寫下For(j=0;j\<=I;j++)的編譯結果
//初始化代碼區
mov R1,0
mov j,R1
JMP _FOR_condition
//末位循環體
_FOR_LOOPEXPR:
ADD I,1
//條件判斷
MOV R1,j
MOV R2,i
LGLE R1,R2 //若是R1小於R2,則R1爲1否者爲0
JE R1,0,_FOR_END;//若是R1位0,for語句結束
for循環體的彙編代碼
JMP _FOR_LOOP_EXPR
_FOR_END;
相對於for語句的實現,if語句的實現就簡單的多了,IF語句的格式以下
if(條件表達式)
{
IF語句塊
}
else
{
ELSE處理
}
以下圖所示
<ignore_js_op>
一開始到達的是條件判斷代碼區,若是條件判斷爲假,則跳轉到else語句塊中若是爲真就繼續執行,固然在if語句塊執行結束後也要跳轉到結束,否則就繼續執行else語句塊裏面的代碼了
相信要編寫相應的彙編代碼並不複雜.筆者就不繼續複述了.
while語句估計是三種語句中最簡單的一種了,其語法格式以下
while(條件判斷)
{
while語句塊
}
就直接看圖吧
<ignore_js_op>
首先執行條件判斷,若是爲假,跳轉到結束,否者執行while語句塊也就是循環體,循環體執行結束後再跳轉到條件判斷中繼續執行.
固然有了上述幾種結構後,對於其它結構怎麼來的讀者應該能夠自行發揮想象了,像do
while,switch等應該均可以按照上述的思路去完成,在碼農界經常稱這些結構爲語法糖,但無論語法糖怎麼造,實際上while和if語句幾乎就能夠解決全部的邏輯問題了,所以無論一門語言怎麼變,瞭解其最根本的東西纔是關鍵的,無論一個語言的語法糖有多好,真正適合本身的,能作出本身須要功能的語言纔是一門好語言
固然在StoryScript中筆者自行實現了Compare語句做爲switch語句的替代品
其語法格式爲
Compare(比較表達式1)
{
with(比較表達式2;比較表達式3…..)
{
with語句塊
}
………
}
其做用爲當比較表達式1和with中其中一個表達式的結果相等,那麼就執行with語句塊中的代碼,實際上一個compare語句中能夠包含多個with語句,其和switch語句稍有不一樣的是,with語句中的表達式不一樣於case須要是一個常量,所以它比switch語句用起來會方便的多,但相對的,其比switch語句在比較較多的條件下性能確定不如switch,不過既然是一門腳本語言,咱們天然不能在性能上奢求太多,畢竟咱們沒法作到面面俱到.一門語言着眼於作好一類問題,其它方面儘量好就好了.
在StoryScript編譯器的最後,筆者想最後聊聊關於語句嵌套的問題,那麼什麼是語句嵌套呢,觀察下面的代碼
if(a\>10)
{
if(a\>20)
{
}
}
這是一個標準的嵌套語句,由2個IF語句構成,雖然它們都是IF語句,可是處理起來卻不肯意,由於if(a\>20)是包含在if(a\>10)的語句塊裏的,實際上在處理語句嵌套的問題上,編譯器採用棧的方式對這些嵌套語句進行處理,當一個語句塊結束後,再將它從棧中彈出來而且添加其結束代碼(語句塊結束有一個很明顯的特徵,那就是有一個右花括號)
例如在上面的代碼中,咱們把第一個IF叫作IF1,第二個IF叫作IF2,從以前的代碼生成咱們知道,語句塊的控制基本是有標籤+跳轉來實現的,那麼由於名字的不一樣,這兩個if語句塊將會生成不一樣的標籤
所以實際上述代碼的實際處理流程其實是這樣的
碰到了第一個if語句,將它叫作if1,而後將這個if語句壓棧
碰到了第二個if語句,將它叫作if2,而後將這個if語句壓棧
碰到了花括號},從棧中彈出一個語句塊,發現這個花括號屬於if2的那麼添加if2的跳轉
碰到了花括號},從棧中彈出一個語句塊,發現這個花括號屬於if1的那麼添加if1的跳轉
這個過程適用於任何的語句嵌套,例如
for()
{
if()
{
}
}
這種格式的或者是
while()
{
for()
{
}
}
這種格式的嵌套,每次處理到花括號}時,都從棧中彈出一個結構,而後再添加相應的處理代碼,這種棧式的處理方式可以正確的處理嵌套語句的代碼生成,同時這也爲咱們處理continue和break兩個特殊指令的關鍵字提供了思路
衆所周知的continue和break語句僅對while for switch(這裏應該是compare) do
while語句生效,所以當找到這類特殊指令時,應該從棧頂開始搜索並找到第一個符合條件的結構,而後添加對應的處理代碼.
無論怎麼說,設計一套虛擬機和編譯器系統是一個龐大的工程,從詞法到語法分析到整個虛擬機系統的設計和編譯器的優化筆者將近使用了5w餘行的代碼來完成其具體實現,當你真正動手寫一個東西時,你會發現有不少以前你都沒能考慮到或者是考慮周全的問題,所以儘管碼農界一直在說不要重複造輪子,但有一些輪子你不本身造一造恐怕你永遠不知道他具體是怎麼回事.
最後,筆者將之上一章節的99乘法表代碼使用這套編譯系統運行,你能夠在本文的附件中找到這段代碼的DEMO,同時你也能夠修改這個代碼本身嘗試一下這個編譯器編譯的過程和結果.
點擊文件夾,找到StoryScript Console.exe
<ignore_js_op>
運行
<ignore_js_op>
點擊Load,而後在文件對話框中選中99乘法表示範程序.txt,觀察輸出結果
<ignore_js_op>
你能夠任意修改示範程序中的代碼,在示範程序中,程序由Main函數開始執行,有一個導入的host函數爲Print,意爲在控制檯輸出消息
<ignore_js_op>
同時你會發如今腳本的同一目錄下生成了兩個新的文件,一個爲.asm後綴的文件,一個是st後綴文件
<ignore_js_op>
你能夠打開asm文件查看編譯器是如何將StoryScript編譯爲彙編代碼的,也能夠使用hex打開st文件查看編譯後的文件結果
<ignore_js_op>
<ignore_js_op>
固然在最後,你能夠直接Load編譯後的程序,那麼它將直接運行出99乘法表一樣的結果.