電腦為什麼能夠讀懂程式程式碼?
- 2020 年 3 月 10 日
- 筆記
來源:業餘碼農
作者:Amazing10
01 引子
上一回,我們的主人公小A初次亮相,憑藉基礎的前後端理解,從技術實現的層面為我們剖析了微信掃碼登錄的幾秒鐘里,到底發生了什麼。可能很多人因此會好奇,小A到底是做什麼的呢?為什麼能夠弄懂這些原理呢?
其實,小A是一名業餘碼農。為什麼要叫業餘碼農呢,是因為他覺得自己屬於半路出家,很多電腦基礎思想都不夠專業,還有很大的進步空間,因此稱自己為業餘碼農。
但是興趣總是最好的老師,這不,小A正又盯著螢幕上的幾行程式碼發愁:
#include <iostream> int main(){ std::cout << "Hello World!" << std::endl; return 0; }
編譯並運行這段 C++ 程式碼就能夠完美列印出Hello World!,似乎沒啥毛病呀!
「電腦是怎麼知道我敲的這些程式碼的意思呢?」小A 苦皺著眉頭,喃喃道。原來,我們的業餘碼農小A 是沒想明白電腦是如何將這些一串串的字元轉變成電腦能夠執行的機器碼的,這其實不就是編譯原理嘛。
小A 回想起之前上過的數電模電課,知道電腦的世界裡都是數字化的,也就是說電腦只知道二進位 0 和 1 。不同數量 01 的組合在電腦的內部構成了不同的指令,而不同指令的組合又構成了不同的操作。
這就好比流水線的生產模式,假如把電腦看作一條流水線,那麼在這條流水線上有不同的工位,每一個工位代表著不同的指令。生產不同的產品就需要不同工位的一同參與,可能按順序執行,也有可能並列執行。
想到這,小A意識到其實這些由 0 和 1 構成的指令應該就是電腦能夠執行的機器碼。不過那這些機器碼好像與上面的 C++ 程式碼還相差甚遠,中間肯定是經歷了一系列的轉換。嗯?這個過程有點像是翻譯的過程,好像是將程式程式碼翻譯成了機器碼!
小A 茅塞頓開,好像又找回了之前英語四級怒考 605 分的自信。看來,英語沒白學!
電腦理解程式程式碼的過程是不是就像是將英文翻譯成了另一種語言呢?一想到英語的那些高階語法,小A 就開始忍不住頭疼,「不會這編程還得學個什麼時態轉換語態切換從句倒裝吧…」。
不過頭疼歸頭疼,該學的還是得耐著性子學。小A 知道,在電腦真正運行 C++ 程式程式碼之前,還需要經過複雜的編譯過程,這個編譯過程似乎對電腦理解程式程式碼起著關鍵性作用。
02 C++編譯過程
找到了分析問題的方向,小A 迫不及待的到處查詢 C++ 編譯過程到底是如何發生的。他發現 C++ 的整個編譯過程包含多項操作,主要可分為四個階段:
1.編譯預處理 2.編譯優化階段 3.彙編過程 4.鏈接過程
這四個階段按順序執行,每一個階段分別處理上一個階段的輸出程式碼,並輸入下一個階段。每個階段的作用分別為:
0x00 編譯預處理
讀取 C++ 源程式碼,對其中的偽指令和特殊符號進行處理。這個預處理實際上可看作是將源程式中的一些特殊指令或者符號進行替換。經過預處理的替換,就會生成一個沒有特殊指令、沒有特殊符號的輸出文件。這個文件的含義和源文件本質上是相同的,但內容和表達方式有所不同。
特殊指令:稱為偽指令,包括宏定義指令、條件編譯指令、頭文件包含指令。比如上述 C++ 程式碼中第一行的 #include 就是頭文件包含指令,會在編譯預處理階段被替換。
0x01 編譯優化階段
經過預編譯後的輸出文件會經過編譯優化階段,將原始程式碼轉化為彙編語言。這個階段是整個編譯過程的核心,也是起到 「 翻譯 」 作用的關鍵。整個階段的工作過程一般可分為六個步驟:
1.詞法分析 2.語法分析 3.語義分析 4.中間程式碼生成 5.程式碼優化 6.目標程式碼生成
在進行編譯時,會經過詞法分析、語法分析和語義分析將高級語言程式碼一步步分解剖析,按照定義的語法將不同的程式碼語句拆解,並根據一些標準來對程式碼語句進行分析檢查,最後生成中間形式的程式碼用於優化。而優化步驟則是對中間程式碼進行優化改進,力圖提升生成的彙編程式碼的效率。
0x02 彙編過程
彙編語言可看做是一種低級語言,十分接近於機器碼的實現。
彙編語言:用於硬體底層編程的低級語言,常用助記符代替機器指令,用地址符號或標號代替指令或操作數的地址。特定的彙編語言和特定的機器語言指令集一一對應,通過彙編過程轉換成機器指令。
由此可見,彙編過程實際上就是將彙編語言翻譯成為了機器碼,這些機器碼就是 C++ 源程式碼的底層表達,理論上電腦可以通過執行這些機器碼來實現對源程式碼的運行。
0x03 鏈接過程
但是要知道,一個普通的高級語言程式,都不單單只包含一個文件。可能某個源文件就會調用其它庫文件中的函數或者其它源文件中定義的符號函數等。因此多個文件在經過編譯彙編之後,還需要通過鏈接過程將不同的目標文件連接起來,建立起引用和調用的聯繫。直至這步完成之後,程式語言程式碼才能夠真正意義上的被電腦理解和運行。
反覆思索 C++ 編譯的整個過程,小A 感覺那幾行簡潔的程式碼彷彿經過了千錘百鍊一般,雖然最終似乎面目全非,但是卻變成了最原始最純潔的樣子。
小A 忍不住一陣感嘆整個編譯過程的環環相扣以及精巧絕倫,同時對編譯階段的原理產生了更大的興趣。
03 編譯原理
編譯階段的過程是通過編譯器所實現的,編譯器通過六個步驟將由數字、字元串以及一些關鍵字組成的字元流進行解析,最後經過優化生成彙編程式碼。

圖 一個編譯器的各個步驟
那是如何進行解析的呢?小A這時候想到了中英文中的主謂賓結構,難道也可以把程式程式碼劃分為主語、謂語、賓語嗎?不妨舉個栗子來分析好了,小A 熟練的寫下了一行程式碼:
position = initial + rate * 60
不如就來分析這一行賦值語句的翻譯過程吧。
0x00 詞法分析
最先輸入編譯器的是源程式程式碼的字元流,如上述例子所示的是由英文、符號和數字組成的字元串。詞法分析的過程就是將字元流中有意義的詞或符號進行提取並分類表示,同時保存在符號表中,並映射為『詞法單元』。
比方說上述程式碼中的詞position,可映射為詞法單元<id, 1>。id 表示的是標誌符(identifier),而 1 表示符號表中的第一個條目。
但是,符號=卻不會保存在符號表中,因為其不具有值的概念,只是一個賦值符號。所以其對應的詞法單元直接用它本身來表示<=>。
對上述程式碼所有詞及符號進行詞法分析後,可獲得詞法單元:
|
詞及符號 |
詞法單元 |
|---|---|
|
position |
<id, 1> |
|
= |
< = > |
|
initial |
<id, 2> |
|
+ |
< + > |
|
rate |
<id, 3> |
|
* |
< * > |
|
60 |
<60> |
對應的符號表為
|
|
|
|
|---|---|---|
|
1 |
position |
… |
|
2 |
initial |
… |
|
3 |
rate |
… |
因此該上述賦值語句程式碼可用詞法單元表示:
<id, 1><=><id, 2><+><id, 3><*><60>
這樣一來,通過詞法分析就把程式碼語句給剝離抽象化,清晰的展現出語句的結構性。
0x01 語法分析
語法分析,故名思義就是檢查語言的表述是否符合已經設定的語法規則。而在語法分析器中,這樣的規則稱之為『文法』。
文法:通過集合來描述語法結構的規則。如主謂賓結構就可看作一種文法。
每一種程式語言都有其對應的文法,根據制定的文法規則可以對詞法分析產生的詞法單元串進行解析。文法解析的方法有多種,優劣勢不一,但目的都是為了構建一顆語法分析樹。這同時也是語法分析階段輸出的結果。
對於上述賦值語句而言,根據不同運算符的執行順序,將賦值運算符=作為根節點,可得到語法分析樹:

獲得語法分析樹之後,整個程式碼結構用樹的形式進行表示,從而方便後續進一步對源程式進行分析。
0x02 語義分析
語義分析是使用語法樹和符號表中的資訊來檢查源程式是否和語言定義的語義一致。如果說語法的分析是對程式語句的結構進行分析,那麼語義分析則是對語句的邏輯性和合理性進行分析。比方說:
語句:猴子是程式設計師
語法分析得到主謂賓結構,『猴子』是主語,『是』是謂語,『程式設計師』是賓語。從語法上來說並沒有錯誤。
但是很明顯,語義上是有問題的。
因此在語義分析環節很重要的部分就是對程式語句進行類型檢查,比方說應保證運算符兩邊的數值類型一致。這本質就是要檢查出『猴子是程式設計師』這樣的錯誤。
對於上述的賦值語句,假設position、initial、rate已被聲明為浮點數類型,那麼表面上整數60應與rate的類型不同,在語義分析的時候就會找出這樣的問題。
只不過在很多語言中允許自動類型轉換,會將整數60轉換成浮點數從而滿足語義的要求。因此經過語義分析後,語法樹會新增inttofloat節點以達到類型轉換的目的:

0x03 中間程式碼生成
在翻譯源程式的過程中,往往會使用多個中間表示形式進行以方便不同的運算處理。一般常用一種稱為『三地址程式碼』的中間表示形式將語法樹的結構進行改寫。該形式根據運算完成的順序,生成臨時名字以存放運算的值。如上述賦值語句的中間程式碼:
t1 = inttofloat(60) t2 = id3 * t1 t3 = id2 + t2 id1 = t3
0x04 程式碼優化
程式碼優化階段試圖改進中間程式碼,以達到提高效率或者其它更有優勢的目的。優化階段會根據一些既有的規則去對中間程式碼進行改進,不同的編譯器之間往往具有差異性。上述中間程式碼可以將inttofloat操作進行優化,使用浮點數60.0來代替整數60從而滿足語義分析。中間程式碼優化為:
t1 = id3 * 60.0 id1 = id2 + t1
0x05 目標程式碼生成
目標程式碼的生成是將中間程式碼翻譯為彙編語言。在這個過程中,需要為變數合理地分配暫存器,選擇記憶體位置。之後再根據彙編語言的操作完成翻譯。上述賦值語句對應的彙編程式碼為:
LDF R2, id3 MULF R2, R2, #60.0 LDF R1, id2 ADDF R1, R1, R2 STF id1, R1
在上面的程式碼中,每個指令的第一個運算分量指定了目標地址以存放計算結果。這樣的操作已經是從硬體層面對數值操作和運算執行。之後通過彙編過程即可獲得真正的機器指令序列。
看到這,小A 已經快有些迷糊了。儘管例子里對賦值語句的編譯過程看起來簡單明了,但是一想到其它程式程式碼里無數的關鍵字、變數和函數調用還是忍不住微微嘆了口氣。
畢竟,這些內容還只不過《編譯原理》的第一章。真正每一階段的實現需要考究的東西還有太多。不過學習都是循序漸進的,學到這小A 已經大致清楚 C++ 程式從源程式碼到運行起來的經過了。
04 解釋器
此外,他還發現一個彩蛋。原來除了編譯器能夠起到翻譯的作用,還有一種稱作「解釋器」的東西同樣可以起到翻譯作用。
簡單來說,編譯器是將源程式碼完整轉換為機器碼;而解釋器是將源程式碼直接生成機器碼並交由硬體執行。因此編譯器事先需要將整個程式編譯成另外的程式碼,而解釋器可一行一行讀取程式,然後翻譯執行。
|
解釋性語言 |
編譯性語言 |
|---|---|
|
不生成目標程式 |
生成目標程式 |
|
一邊解釋,一邊執行 |
整體編譯,一次執行 |
|
每個語句執行時都要進行翻譯 |
可只翻譯一次,可多次執行 |
|
一般程式執行速度慢 |
一般程式執行速度快 |
|
跨平台性好 |
跨平台性差 |
|
C/C++/elphi等為編譯性語言 |
Python/JavaScript / Perl /Shell等為解釋性語言 |
05 後話
看到這,小A 已經能夠明白電腦是如何理解程式程式碼的了,但是其中的奧秘還仍需要不斷的學習探索。

