自制編程語言,六個令你迷惑的問題

自制編程語言和虛擬機,這是一個看似很深奧的課題,也涉及當今互聯網流行的主題,許多技術人員對其心馳神往,但要領悟其精髓步履維艱。

《自制編程語言》循序漸進、由淺到深地講解了豐富的基礎知識,覆蓋了常見的編譯原理入門知識,更難能可貴的是,作者講解的知識具有其獨特的理解和視角,相信本書能讓讀者能夠受益匪淺。

本文涉及一些編譯原理基礎,我擔心沒學過編譯原理的讀者會覺得吃力,因此順帶介紹了編譯原理的基礎知識。當然,不會編譯原理也無法阻止你成功寫出一門腳本語言。

因爲原理太抽象了,而且爲了嚴謹,理論總是把簡單的描述成複雜的。在實踐中你會發現,編譯器的實現比理解編譯器原理容易,你會發現——原來晦澀難懂的概念其實就是這麼簡單,以至於你是通過實踐才懂得了編譯原理。畢竟紙上得來終覺淺,絕知此事要躬行。今天我們來介紹一些自制編程語言可能令人迷惑的問題。

編譯型程序和腳本程序的異同

兩者最明顯的區別就是看它們各是誰的“菜”。兩者的共性是最終生成的指令都包含操作碼和操作數兩部分。

編譯型程序所生成的指令是二進制形式的機器碼和操作數,即二進制流。同樣是數據,和文本文件相比,這裏的數據是二進制形式,並不是文本字符串(如ASCII碼或unicode等)形式。

如果二進制流按照有無格式來劃分,無格式的便是純粹的二進制流,程序的入口便是文件的開始。另外一種是按照某種協議(即格式)組織的二進制流,比如Lnux下elf格式的可執行文件。它是硬件CPU的直接輸入,因此硬件CPU是“看得到”編譯型程序所對應的指令的,CPU親自執行它,即機器碼是CPU的菜。

編譯型語言編譯出來的程序,運行時本身就是一個進程,它是由操作系統直接調用的,也就是由操作系統加載到內存後,操作系統將CS:IP寄存器(IA32體系架構的CPU)指向這個程序的入口,使它直接上CPU運行,這就是所說的CPU“看得到”它。總之調度器在就緒隊列中能看到此進程。

腳本語言,也稱爲解釋型語言,如JavaScript、Python、Perl、Php、Shell腳本等。它們本身是文本文件,是作爲某個應用程序的輸入,這個應用程序是腳本解釋器。由於只是文本,這些腳本中的代碼在腳本解釋器看來和字符串無異。

也就是說,腳本中的代碼從來沒真正上過CPU去執行,CPU的CS:IP寄存器從來沒指向過它們,在CPU眼裏只看得到腳本解釋器,而這些腳本中的代碼,CPU從來就不知道有它們的存在,腳本程序卻因硬件CPU而間接“運行”着。

這就像家長給孩子生活費,孩子用生活費養了只狗狗,家長只關心孩子的成長,從不知道狗狗的存在,但狗狗卻間接地成長。這些腳本代碼看似在按照開發人員的邏輯在執行,本質上是腳本解釋器在時時分析這個腳本,動態根據關鍵字和語法來做出相應的行爲。

解釋器有兩大類,一類是邊解釋邊執行,另一類是分析完整個文件後再執行。如果是第一類,那麼腳本中若有語法錯誤,先前正確的部分也會被正常執行,直到遇到錯誤才退出;如果是第二類,分析整個文件後才執行的目的是爲了創建抽象語法樹或者是用與之等價的遍歷去生成指令,有了指令之後再運行這些指令以表示程序的執行,這一點和編譯型程序是一致的。

腳本程序所生成的指令是文本形式的操作碼和操作數,即數據以文本字符串的形式存在。其中的操作碼稱爲opcode,通常opcode是自定義的,所以相應的操作數也要符合opcode的規則。爲了提高效率,一個opcode的功能往往相當於幾百上千條機器指令的組合。

如果虛擬機不是爲了效率,多半是用於跨平臺模擬程序運行。這種虛擬機所處理的opcode就是另一體系架構的機器碼,比如在x86上模擬執行MIPS上的程序,運行在x86上的虛擬機所接收的opcode就是MIPS的機器碼。

除跨平臺模擬外,通常虛擬機的用途是提高執行效率,因此opcode很少按照實際機器碼來定義,否則還不如直接生成機器指令交給硬件CPU執行更快呢。故此種自定義的指令是虛擬機的輸入,即所謂虛擬機的菜。

虛擬機分爲兩大類,一類是模擬CPU,也就是用軟件來模擬硬件CPU的行爲,這種往往是給語言解釋器用的,比如Python虛擬機。另一類是要虛擬一套完整的計算機硬件,比如用數組虛擬寄存器,用文件虛擬硬盤等,這種虛擬機往往是用來運行操作系統的,比如VMware,因爲只有操作系統纔會操作硬件。

腳本程序是文本字符流(即字符串),其以文本文件的形式存儲在磁盤上。具體的文本格式由文本編譯器決定,執行時由解釋器將其讀到內存後,逐行語句地分析並執行。

執行過程可能是先生成操作碼,然後交給虛擬機逐句執行,此時虛擬機起到的就是CPU的作用,操作碼便是虛擬機器的輸入。

當然也可以不通過虛擬機而直接解析,因爲解析源碼的順序就是按照程序的邏輯執行的順序,也就是生成語法樹的順序,因此在解析過程中就可以同時執行了,比如解析到 2+3 時就可以直接輸出 5 了。

但方便是有限的,實現複雜的功能就不容易了,因爲計算過程中需要額外的數據結構,比較對於函數調用來說總該有個運行時棧來存儲參數和局部變量以及函數運行過程中對棧的需求開銷。因此對於複雜功能,多數情況下還是專門寫個虛擬機來完成。

順便猜想一下解釋型語言是如何執行的。我們在執行一個PHP腳本時,其實就是啓動一個C語言編寫出來的解釋器而已。這個解釋器就是一個進程,和一般的進程是沒有區別的,只是這個進程的輸入則是這個PHP腳本。在PHP解釋器中,這個腳本就是個長一些的字符串,根本不是什麼指令代碼之類。

只是這種解釋器瞭解這種語法,按照語法規則來輸出罷了。舉個例子,假設下面是文件名爲a.php的PHP代碼。

php解釋器分析文本文件a.php時,發現裏面的echo關鍵字,將其後面的參數獲取後就調用C語言中提供的輸出函數,比如printf((echo的參數))。PHP解釋器對於PHP腳本,就相當於瀏覽器對於JavaScript一樣。

不過這個完全是我猜測的,我不知道PHP解釋器裏面的具體工作,以上只是爲了說清楚我的想法,請大家辯證地看。

說到最後,也許你有疑問,如果CPU的操作數是字符串的話,那CPU就能直接執行腳本語言了,爲什麼CPU不直接支持字符串作爲指令呢?後面會有分享。

腳本語言的分類

腳本語言大致可分爲以下4類。

(1)基於命令的語言系統

在這種語言系統中,每一行的代碼實際上就是命令和相應的參數,早期的彙編語言就是這種形式。此類語言系統編寫的程序就是解決某一問題的一系列步驟,程序的執行過程就是解決問題的過程,就像做菜一樣,步驟是提前寫好在腦子裏(或菜譜中)的。如以下炒菜腳本。

以上步驟中第1列都是命令,後面是命令的參數。其中把菜放進鍋後不斷地攪拌(示意而已,不用太嚴謹),由於命令式語言系統中沒有循環語句,需要連續填入多個stir以實現連續多個相同的操作。會有一個解釋器逐行分析此文件,執行相應命令的處理函數。以下是一個解釋器示例。

(2)基於規則的語言系統

此類語言的執行是基於條件規則,當滿足規則時便觸發相應的動作。其語言結構是謂詞邏輯→動作,如圖1-1所示。

圖1-1

因此此類語言常稱爲邏輯語言,常用於自然語言處理及人工智能方面,典型的代表有Prolog。

(3)面向過程的語言系統

面向過程的語言系統我們都比較熟悉,批處理腳本和shell腳本,perl、lua等屬於此類,和基於命令的語言系統相比,它可以把一系列命令封裝成一個代碼塊供反覆調用。此代碼塊便是借用了數學中函數的概念,一個x對應一個y,即給一個輸入便有一個輸出,於是這個代碼塊便稱爲函數。

(4)面向對象的語言系統

現代腳本語言基本上都是面向對象,大夥兒用的都挺多的,比如python。很多讀者誤以爲只要語言中含有關鍵字class,那麼該語言就是面向對象的語言,這就不嚴謹了。因爲在perl語言中也可以通過關鍵字class定義一個類,但其內部實現上並不是完全面向對象,其本質是面向過程的語言。世界上第一款血統純正的面嚮對象語言是smalltalk,它在實現上就是一切皆對象,具有完全面向對象的基因。

爲什麼CPU要用數字作爲指令 

在之前小節“編譯型程序和腳本程序的異同”的結束處我們討論過,爲什麼CPU不直接支持字符串作爲指令。我估計有的讀者會誤以爲CPU將直接執行彙編代碼,這是不對的,因爲彙編代碼是機器碼的符號化表示,幾乎是與機器碼一一對應,但彙編代碼絕對不是機器語言。

你想,如果彙編代碼是機器指令的話,那麼CPU看到的輸入便是字符串,比如以下彙編代碼用於計算1+10-2。

彙編語言其實是彙編器的輸入,對於彙編器來說,彙編代碼文件也是文本,因此其中mov指令也是字符串。如果讓CPU直接讀取彙編文件逐行分析各種字符串以判斷指令,這效率必然非常低下。

畢竟要比較的字符數太多,比較的次數多了效率當然就低了,因此把指令編號爲數字,這樣比較數字多省事。而且最主要的是,CPU更擅長處理數字,它本身的基因就是數字電路,數字計算是建立在數值處理的基礎上,這就是本質上二進制數據比文本ASCII碼更快更緊湊的原因。

爲什麼腳本語言比編譯型語言慢

 而腳本語言的編譯有兩類,一類是邊解釋邊執行,不產生指令,這個解釋過程最佔時間的部分就是字符串的比較過程,字符串比較的時間複雜度是O(n),也就是在比較n次之後解釋器才確定了操作碼是什麼,然後再去獲取操作碼的操作數,你看能不慢嗎?而編譯型語言編譯後是機器碼,是二進制數字,因此可直接上CPU運行,而CPU擅長處理數字,比較一次數字便可確定操作碼。

另一類腳本語言是先編譯,再生成操作碼,最後交給虛擬機執行,這樣多了一個生成操作碼的過程,似乎“顯得”更慢了。其實這都不是主要的。

你看,程序“執行”速度的快慢是比較出來的,編譯型語言在執行時已經是二進制語言了,而大多數腳本語言在執行時還是文本,必然要先有個編譯過程。

這裏面全是字符串處理,整個腳本的源碼對於編譯器來說就是一個長長的字符串,都要完整地進行各種比較,因此多了一個冗長的步驟,必然要慢。有些腳本系統爲減少編譯的過程,第一次編譯後將編譯結果緩存爲文件,如Python會將.py文件編譯後存儲爲.pyc文件,下次無須編譯直接運行便可。

但是,這樣無須二次編譯的腳本語言就能和編譯型程序媲美嗎?不見得磁盤IO是整個系統最慢的部分,解釋器讀取緩存文件難道不需要時間嗎?等等,有讀者說了,編譯型的程序被操作系統加載時也要從磁盤上讀取啊,這不一樣嗎?

當然不一樣,別忘了,腳本程序在執行時先要加載解釋器,解釋器也是位於硬盤上的文件,只是二進制可執行文件而已,依然需要讀取硬盤,然後解釋器再去從硬盤上讀取腳本語言文件並編譯腳本文件。

你看,編譯型程序在執行時只有1個IO,而腳本程序在執行時有兩個,比前者多了1個低速的IO操作,因此,腳本語言更慢一些是註定的。

既然腳本語言比較慢,爲什麼大家還要用

這裏的語言是指語言的編譯器或解釋器,以下簡稱爲語言。

語言慢並不影響整個系統,影響整個系統速度的短板並不是語言本身,目前來說系統的瓶頸普遍是在IO部分。語言再慢也比IO快一個數量級,並不是語言執行速度快10倍後整個系統就快10倍,語言慢了,整個系統依然不受影響,這要看瓶頸是哪塊兒。

這就像動物園運送動物的船超載了,人們不會埋怨某些人太胖了,而是清楚地知道佔分量的主要是船上的大象,人的體重和大象根本就不是一個量級。

再說,即使是語言提速後,由於IO這塊跟不上,依然會被阻塞(由於是腳本語言,這裏阻塞的是腳本解釋器),而且由於語言太慢而顯得阻塞時間更漫長。

爲什麼會阻塞呢?這種阻塞往往是由於程序後續的指令需要從IO設備讀取到的數據,也就是說程序後面的步驟依賴這些數據,沒這些數據程序運行沒意義。比如說Web服務器先要讀取硬盤上的數據然後通過網卡發送給用戶,必須獲得硬盤數據後,web服務器進程中那部分操作網卡發送數據的指令才能上CPU上執行。

由於語言的解釋器是由CPU處理的,CPU速率肯定比IO設備快太多,因此在等待IO設備響應的過程中啥也幹不了。操作系統爲了讓寶貴的CPU資源得到最大的利用,肯定會把進程(二進制可執行程序或腳本語言的解釋器)加入阻塞隊列,讓其他可直接運行的、不需要阻塞的進程使用CPU(阻塞指的是並不會上CPU運行,也就是將該進程從操作系統調度器的就緒隊列中去掉)。

而語言(腳本語言解釋器)再慢也比IO設備快,因此依然會因爲更慢的IO而難逃阻塞的命運。也就是說,拖慢整個系統後腿的一定是系統中最慢的部分,而無論腳本語言多慢,IO設備總是會比語言更慢,因此“影響系統性能”這個黑鍋,腳本語言不能背。

另一方面大夥兒喜歡用腳本語言的原因是開發效率高,這也是腳本語言被髮明的初衷,很多在C中需要多個步驟才能實現的功能在腳本語言中一句話就搞定,當然更受開發人員歡迎了。

什麼是中間代碼

很多編譯器會將源語言先編譯爲中間代碼,最後再編譯爲目標代碼,但中間語言並不是必需的。中間代碼簡稱IR,是介於源程序和機器語言之間的語言,有N元式(如三元式、四元式)、逆波蘭、樹等形式。

目標代碼是指運行在目標機器上的代碼,與目標機器的體系架構直接相關,編譯器幹嗎不直接生成目標代碼,多這一道程序有什麼好處呢?

(1)可以跨平臺

由於中間代碼並不是目標代碼,因此可以作爲所有平臺的公共語言,從而可通過中間代碼實現前後端分離。比如在多平臺、多語言的環境下開發可提高開發效率,只要在某一平臺上編譯出中間代碼後,中間代碼到目標代碼的剩餘工作可以由目標平臺的編譯器繼續完成。

(2)便於優化

中間代碼更接近於源代碼,對於優化來說更直接有效。而且可以在一種平臺上優化好中間代碼,再發送到其他平臺編譯爲目標機器,提高優化效率。

什麼是編譯器的前端、後端

 

編譯器的前後端是由中間代碼來劃分的,如圖1-2所示。

圖1-2

前端主要負責讀取源碼,對源碼進行預處理,通過詞法分析把單詞變成Token流,然後進行語法分析,語義分析,將源碼轉換爲中間代碼。

後端負責把中間代碼優化後轉換爲目標代碼。

詞法分析、語法分析、語義分析和生成代碼並不是串行執行

很多教材上會把編譯階段分爲幾個獨立的部分:

(1)詞法分析;

(2)語法分析;

(3)語義分析;

(4)生成中間代碼;

(5)優化中間代碼;

(6)生成目標代碼。

這容易給人造成“這幾個步驟是串行執行”的錯覺,即“從源碼到目標代碼必須要順序地執行這6個步驟”,其實不是這樣子的,至少一個高效的編譯器絕不會這樣做。

這只是在功能邏輯上的步驟,就拿前4步來說,它們是以語法分析爲主線,以並行的、穿插的方式在一起執行的,即這4個步驟是隨語法分析同時開始,同時結束。

每個步驟的功能實現由其實際的模塊完成,負責詞法分析的模塊稱爲詞法分析器,負責生成代碼的模塊稱爲代碼生成器,負責語法分析的模塊稱爲語法分析器。

我們所說的編譯器就是由詞法分析器、語法分析器和代碼生成器組成的(如果有目標代碼優化的話還包括優化模塊)。

編譯工作的入口是語法分析,因此編譯是以調用語法分析器爲開始的,語法分析器會把詞法分析器和代碼生成器視爲兩個子例程去調用。換句話說,詞法分析器和代碼生成器只會被語法分析器調用,如果沒有語法分析器,它們就沒有“露臉兒”的機會。

因此說編譯是以語法分析器爲主線,由語法分析器穿插調用詞法分析器和代碼生成器並行完成的。

語法分析和語義分析儘管是兩個功能,但這其實可以合併爲一個。因爲在語法分析過後便知道了其語義。這個很好理解,畢竟語法就是語義的規則,規則是由編譯器(的設計者)制定的,那麼編譯器(的設計者)分析了自己設定的規則後當然就明白了語義(不可能不明白自己所制定規則的意義)。

比如讀英文句子,尤其是複雜的長句,先找到句子謂語動詞,以謂語動詞爲分界線把句子拆分主謂兩大部分,在前一部分中找主語,後一部分中找賓語等,在分析完語法後句子的意思就搞清楚了。

也就是說,語法分析和語義分析是同時,又是前後腳的事兒,因此合併到一起並不奇怪。你看,語法分析和語義分析確實是並行。

爲了語法分析的效率,詞法分析器往往是作爲一個子例程被語法分析器調用,即每次語法分析器需要一個單詞的token時就調用詞法分析器。你看,語法分析和詞法分析確實也是並行。

最後說生成代碼。目前生成代碼的方式叫語法制導,什麼是語法制導呢?就是在分析語法的“同時”生成目標代碼或中間代碼,實際上就是以語法分析爲導向,語法分析器在瞭解源碼語義後立即調用代碼生成器生成目標代碼或中間代碼,因此這也是和語法分析器並行。

提醒一下,並不是在語法分析器分析完整個源碼後,再一次性地生成整個源碼對應的目標代碼或中間代碼,而是分析一部分源碼後就立即生成該部分源碼對應的目標代碼或中間代碼,這樣做比較高效且更容易實現。

舉個例子,比如源碼文件中有10行代碼,語法分析器不斷調用詞法分析器,每次獲得一個單詞的token,把前3行源碼都讀完後確定了源碼的語義,立即生成與這3行源碼同等意義的目標代碼或中間代碼。

然後語法分析器繼續調用詞法分析器讀取第4行之後的源碼,重複分析語法、生成代碼的過程。總之是以語法分析爲主線,語法分析把源碼按照語法來拆分成多個小部分,每次生成這一小部分的目標代碼或中間代碼。

總結,爲了使編譯更加高效,詞法分析、語法分析、語義分析和生成代碼是以語法分析爲中心並行執行的,詞法分析和生成代碼都是被語法分析器調用的子例程。

什麼是符號表

 把符號表列出來是因爲這個詞聽上去“挺唬”人的,由於看不見摸不着,很多初學者都以爲它是個非常神祕的東西。其實符號表就是存儲符號的表,就是這麼簡單。

你想,源碼中的那些符號總該存儲在某個地方,這樣在引用的時候才能找得到,因此符號表的用途就是記錄文件中的符號。符號包括字符串、方法名、變量名、變量值等。符號放在表中的另一個重要原因是便於生成指令,使指令格式統一。

編譯器會把符號在符號表中的索引作爲指令的操作數,如果不用索引的話,指令就會很亂,比如若直接用函數名或字符串作爲操作數,指令就冗長了。“表”在計算機中並不專指“表格”,“表”是個籠統的概念,用以表示一切可供增、刪、改、查的數據結構,因此符號表可以用任何結構來實現,比如鏈表、散列表、數組等。

《自制編程語言》

鄭鋼  著

本書全面從腳本語言和虛擬機介紹開始,講解了詞法分析的實現、一些底層數據結構的實現、符號表及類的結構符號表,常量存儲,局部變量,模塊變量,方法存儲、虛擬機原理、運行時棧實現、編譯的實現、語法分析和語法制導自頂向下算符優先構造規則、調試、查看指令流、查看運行時棧、給類添加更多的方法、垃圾回收實現、添加命令行支持命令行接口。


《操作系統真象還原》

鄭鋼  著

大學及研究生都有操作系統課程,這類人羣具有很高的學術能力,但書中講的過於抽象與晦澀,以至於很多學生對於此門課程恐懼到都提不出問題,只有會的人才能提出問題。操作系統理論書是無法讓讀者理解什麼是操作系統的,學操作系統不能靠想像,他們需要看到具體的東西。 

絕大多數技術人都對操作系統懷着好奇的心,他們渴望一本告訴操作系統到底是什麼的書,裏面不要摻雜太多無關的管理性的東西,代碼量不大且是現代操作系統雛形,他們渴望很快看到本質而不花費大量的時間成本。

今日互動

你想爲自己的成長挑選哪本書?爲什麼?截止9月5日17時,留言+轉本活動到朋友圈,小編將抽獎選出2名讀者贈送紙書1本。(參與活動直達微信端自制編程語言,六個令你迷惑的問題


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章