其實好久以前就想開始動手寫這麼一篇文章了,不過一直沒有時間。現在正好剛考完最後一門期中考試,還是編譯原理,就把這篇文章寫出來。
要閱讀本文,不需要太高深的編譯原理知識,甚至不需要編譯相關的知識。但是本文也不是面向對電腦一無所知的讀者的,你至少要知道:
1. 不管是exe可執行檔還是Linux下的程式,都是一些二進位代碼,我們稱之為機器語言。這些代碼的執行和系統以及CPU都有關。
2. 大部分情況下,編譯器是一種將進階語言翻譯成機器語言的程式。而任何程式本身也是一些機器語言的代碼。
3. 無論是進階語言、組合語言還是機器語言,實際上都是等價的,唯一有區別的是,越進階的語言寫起來越容易(所以我們傾向於盡量多書寫進階語言代碼);同時,機器語言是可以直接啟動並執行。
如果你覺得上面三條可以理解或者可以理解一部分,那麼請繼續閱讀。
另外,本文中提到的無論是編譯的過程還是語言的分類甚至是一些例子,都可以看做是一個簡化的模型。實際上,很少有一個exe程式就可以啟動並執行編譯器,現代的編譯器都是十分複雜的。不過,考慮這些簡化模型對我們沒有任何壞處,它們和實際情況相差並不很大。
首先我來詳細解釋一下進階語言和機器語言。
語言的所謂“進階”,實際上界定不是那麼明確。不過我們可以確定的是:C++、Java、Python之流比組合語言更進階,而組合語言比機器語言更進階。
這裡提到一個組合語言和機器語言的區分。可能有些讀者不明白這兩個概念的區別,實際上很簡單,組合語言是我們會看到的那些MOV、JMP等命令組成的語言,而機器語言則純粹是是各種01串。我想沒有人會願意寫一個機器語言的程式——實際上,我們學校的電腦群組成實驗都是用Verilog HDL這種進階語言來完成的,而嵌入式原理實驗也是用一種組合語言完成的;即使是做處理器和單片機,也不會有人願意去寫機器語言,畢竟一大堆01串太坑爹了。
試想,讓你用C++寫一個從1輸出到100的程式,幾行代碼就可以搞定;而組合語言則可能需要幾十行;機器語言呢,Oh my god,一大堆01000101110110101011111001……,看都看不懂,還寫啥啊。
但是上面也提到了,機器語言和進階語言的區別是,它可以直接運行。比如exe程式,實際上它內部儲存的就是一些機器語言的代碼,機器可以直接閱讀這些代碼並在處理器中運行它們(這裡說的不是完全準確,比如.NET編譯出的exe程式實際上是一段中間代碼,由CLR解釋成機器代碼才能運行——不過這可以暫且忽略,就當作我說的是一個簡化的模型)。當然程式的運行是依賴機器架構和系統的,不然Wine什麼的也就沒有用了(什麼是Wine?WINE = WINE Is Not an Emulator!有趣的名稱遞迴定義還有很多,不過與本文無關,請自行Google)。
而實際上,是機器架構不同還是系統不同,並不是我們考慮的問題。我們考慮的問題只是,一段代碼在A機器和X系統下能否運行,換到B機器和Y系統下又能否運行。就算在A機器和X系統下能運行,如果換成了A機器和Y系統之後不能運行了,那對於我們這也可以看做兩台不同的機器(也就是說,A機器和Y系統實際上就可以看做一個新的機器B)。所以之後的描述中,我們不考慮作業系統的情況,而是只考慮機器,我們編號為a、b等,而它們上面可以啟動並執行機器語言我們編號為A、B等。
以上幾段的關鍵點是:
無論是機器語言還是進階語言,實際上都是代碼。在之後的討論中,我們不應該將它們區別對待。
雖然機器語言代碼和進階語言略有不同,它可以直接在機器上運行,但是對於編譯過程,它們沒有任何區別。
上面的問題瞭解了之後,我再來簡單說一下編譯器。那編譯器是什嗎?這要從兩個方面來解釋。
首先,編譯器本身的功能是,將一種語言S的代碼轉化為一種語言T的代碼。這裡稍微瞭解編譯器的讀者可能有疑問了:我用的gcc之類的編譯器,明明是把C語言代碼編譯成了一個exe程式,並不是把S語言的代碼編譯成了T語言的代碼啊?如果你也有這個疑問,請重新閱讀以上幾段——我在前面已經提過了,“任何程式本身也是一些機器語言的代碼”。也就是說,我們這裡把機器語言也看作一種語言,只不過是很低級的語言;而編譯器就可以將C語言這種進階語言S轉化為機器語言這種低級語言T,恰好這種低級語言還是可以在機器t上直接啟動並執行。
其次,大部分情況下,編譯器自己也是一段程式,那麼它也可以看做是一段代碼。而編譯器也會有它的原始碼,這個原始碼就是一種進階語言的代碼,我們這時仍然叫它編譯器。隨便舉個例子,對於gcc.exe,這個程式是一種機器語言A的代碼,它可以直接在a機器上運行。而它的功能則是,將C語言的代碼轉化為機器語言A的代碼(一個C語言的編譯器),使得本身無法直接啟動並執行C語言程式變得可以在a機器上運行。而在得到gcc.exe之前,我們一定也有一段代碼,它是用來產生gcc.exe這個程式的,這段代碼可能是一個進階語言的代碼,比如組合語言。那這時候,這段組合語言的代碼即使不能直接在電腦上運行,我們依然說這是一個C語言的編譯器。
有了這兩點,我們就可以總結出一個編譯器的特徵:
1. 本身是一段代碼,假設是A語言的代碼,A可以是機器語言,也可以不是;
2. 可以接收一段代碼,假設是S語言的代碼,S一般來說是進階語言,但理論上也可以不是;
3. 可以將接收到的S語言代碼在內部轉換後輸出一段代碼,假設是T語言的代碼,T有可能是機器語言,但也有可能是一種進階語言。
這樣,一個編譯器就可以被表示為A(S –> T),表示編譯器本身的代碼是A語言,可以接受一個S語言代碼作為輸入,同時產生相同功能的T語言代碼作為輸出。之後我們的編譯器都會這麼表示,請務必牢記。
以上幾段的關鍵點是:
編譯器是什嗎?從本質上看是一段代碼,而從功能上看可以將一段代碼等價地轉化為另一段代碼。
所以,我們用A(S –> T)表示一個編譯器,這個編譯器本身是由A語言寫成的,它的功能是將S語言代碼等價地轉換為T語言代碼。當A語言是機器語言的時候,這個編譯器就是可以使用的。
有了之前的知識基礎,終於可以開始講自展了。
不知道讀了之前的內容,你有沒有這樣一個疑問:既然編譯器本身是一段代碼,那麼如果想編譯一個編譯器,就需要更早的編譯器來進行編譯操作。而編譯這個更早的編譯器還需要更更早的編譯器——長此以往,第一個編譯器是怎麼產生的呢?難道是直接用機器語言書寫的?
如果能這麼想,那麼你就猜對了。第一個編譯器一定是用機器語言寫出的。實際上,很久以前的程式員還在紙帶上打孔編程呢,他們也照樣樂在其中。機器語言只是很難書寫,並不是不能書寫。
但是,畢竟用這麼難書寫的語言寫一個C++編譯器,誰都不會願意去幹。所以,第一個編譯器的功能一定是很簡單的。而人們會用這很簡單的編譯器的語言,去寫一個稍微複雜一些的編譯器;然後再用這個新的編譯器,去寫一個更複雜的編譯器;最終得到一個很複雜的編譯器,比如C++編譯器——好了,如果能夠理解這個過程,那麼你實際上幾乎就理解了自展。
所謂自展,實際上就是用一個功能不太完善的編譯器支援的語言寫代碼,然後放到這個編譯器上去編譯,產生一個比自己更完善的編譯器的過程。用一個不太恰當的例子來描述,就是我們已經有了一個C語言的編譯器,然後我們用C語言寫一個C++的編譯器代碼,並用C語言編譯器編譯這個代碼產生可以啟動並執行C++編譯器(之所以不太恰當,是因為C語言不是C++語言的嚴格子集)。
也就是說,我們有一個編譯器A(C –> A),現在寫一個編譯器C(C++ –> A),將後者放入前者中進行編譯,即A(C(C++ –> A) –> A),得到一個可以執行的編譯器A(C++ –> A)。
以上幾段的關鍵點是:
自展過程,實際上就是用低級語言先實現一個簡單的編譯器,然後用這個編譯器的語言再去編寫一個更進階的編譯器——這個新編譯器是舊編譯器的擴充——的過程。
自展很簡單?那下面就是本文的重頭戲——交叉編譯了。
交叉編譯這個概念,沒有自展這個概念那麼準確。所以先看一個問題:如果你想寫一個手機系統上的程式,你會怎麼做呢?我們可能會想到,開發一個手機上的編譯器,然後把程式放到上面編譯成手機可以啟動並執行機器代碼。但是這樣就有一個很大的問題了,那就是手機的運行速度和電腦相比十分緩慢(雖然現在手機的CPU已經很強悍了,但是手機的記憶體一向很小,不太足以運行編譯器)。
實際上,在現實中我們的做法是,在電腦上完成編譯過程,然後直接把在手機上可以啟動並執行機器代碼拷貝到手機內。這樣就不受限於編譯時間手機的記憶體和CPU限制了。這樣一個過程,就叫做交叉編譯。
當然交叉編譯也有其它的應用,比如有時候我們也需要在一台電腦a上產生另外一台電腦b上同一個語言的編譯器,比如在Windows下編譯一個Linux的編譯器。這個問題比前面的問題還要多一步編譯操作,我們不妨在後面的討論中將這兩個問題稱作問題1(前者)和問題2(後者)。
為了方便起見,我們把這兩個問題用之前的表示方法來書寫一下。
首先,將電腦看做是機器a,手機看作是機器b。我們現在手裡擁有的是某語言S在a機器上的編譯器A(S –> A)。對於問題1,我們需要最終產生一個在a機器上編譯b機器代碼的編譯器,即A(S –> B)。而對於問題2,我們則是需要一個在b機器上產生b機器代碼的編譯器,B(S –> B)。
而我們可以做到的是,用進階語言S寫一個自身的編譯器(可以是a機器的也可以是b機器的,不過這個問題中只有後者會被用到),即S(S –> B)。
好,現在我們就有了兩個代碼,A(S –> A)和S(S –> B),其中只有第一個代碼是可以在a機器上啟動並執行。我們要從這兩個代碼中得到兩個新的代碼,那就是A(S –> B)和B(S –> B)。
首先先考慮如何得到一個A(S –> B),即一個在a機器上啟動並執行可以將S語言代碼編譯為b機器代碼的編譯器。
其實寫成現在的形式,這一點如何做到已經很簡單了。那就是將S(S –> B)放入A(S –> A)中編譯,即A(S(S –> B) –> A),這樣就可以得到A(S –> B)。這一步因為使用的編譯器是A(S –> A),所以只需要在A機器上執行。
這時,可以發現第一個問題已經解決了。我們這時就可以利用得到的A(S –> B)在a機器(電腦)上編譯b機器(手機)的代碼了。
然後在考慮如何得到一個B(S –> B)。
可以看到,我們之前一步的時候有了一個A(S –> B),而最開始還擁有一個S(S –> B)。這樣,我們只需要將S(S –> B)放入A(S –> B)中編譯,即A(S(S –> B) –> B),這樣就可以得到一個B(S –> B)了。
這樣,我們用前一步得到的編譯器A(S –> B),在a機器(Windows)上運行,又得到了一個新的編譯器B(S –> B),它可以在b機器(Linux)下編譯b機器(Linux)的代碼。
可以看到,我們從最開始的編譯器A(S –> A)和代碼S(S –> B)得到最終代碼B(S –> B)的過程中,所有的步驟都是在A機器上啟動並執行,完全沒有用到B機器。這就是交叉編譯的有趣之處!使用交叉編譯,可以在很多情況下省去不少的麻煩。
另外解釋一個問題,為什麼不直接編寫一個A(S –> B)?因為A是機器代碼,而之前也提到過了,“我們傾向於盡量多書寫進階語言代碼”,因為機器語言十分難以理解和書寫。所以,我們寫了一個進階語言代碼S(S –> B),將它放到已有的A(S –> A)上去編譯(為什麼是已有的?這個問題實際上不需要回答,因為這裡假設a機器是一台很常用的機器(比如安裝了Windows系統的PC機),所以A(S –> A)是一個很普通的編譯器,它的存在性無需證明;A(S –> A)的書寫可以利用之前提到的自展完成),最終得到了一個需要的B(S
–> B)。
以上幾段的關鍵點是:
關鍵點太多了有木有!!!這個怎麼總結呢……
如果看不太懂交叉編譯的過程的話,可以暫且把a機器當作一個安裝了Windows的PC機,把b機器當作一台手機或者一個裝了Linux的PC機,然後再帶著這個理解重新閱讀整個過程。
實際上,交叉編譯解決的兩個問題分別都只需要一步操作,所以交叉編譯沒有聽起來那麼複雜!不要自己嚇到自己就好了~