1. 程式人生 > >函式,從編輯到編譯 (下) -- 一文帶你瞭解編譯 連結

函式,從編輯到編譯 (下) -- 一文帶你瞭解編譯 連結

上篇的連結在這裡:
函式,從編輯到編譯 (上) --帶你瞭解預編譯做了什麼

下面繼續:

2. 編譯

所謂編譯過程,就是把預處理完的檔案進行一系列詞法分析,語法分析,語義分析及優化後生產相應的彙編程式碼檔案。這一步是整個程式構建的核心部分,也是最容易出錯的一部分。

從現在開始,步驟就變得十分複雜了。

對函式來說,這一階段是最繁瑣也是最為危險的:稍有不慎,輕則 warning 重則 error

我見過許多出錯的函式,他們連著行號被編譯器帶到視窗,當街示眾。

也有些函式和 #pragma 關係比較好,小錯誤被遮掩過去,免去了示眾的命運。

2.1 掃描

我們要先經過一臺掃描器 (Scanner),這機器如此龐大,以至於我根本看不出內部的細節。

我對大型機器充滿好奇,編譯器給了我一本手冊——《編譯寶典》,他說裡面有講掃描器的實現。

可我看不懂。

編譯器告訴我,想要參透這本寶典,需要付出代價。

“代價?像嶽不群那樣?”

“你想哪兒去了!你說的那是《葵花寶典》,我說的代價是時間和精力!編譯器這種龐大的工程,需要一個團隊來合作完成,除非你是打算寫寫玩具編譯器。”

所以我放棄了造出這些機器的想法,因為函式的一生太短了,希望你能實現我的願望。

在掃描器裡的體驗不太舒服,它像一臺 X 光機,把我的身體裡裡外外看了個遍,給我的感覺很不妙。

出了機器,會收到一個檢查報告,像這樣(篇幅有限,只拿一個表示式舉例子):

拿著這份報告,就該去進行語法分析了。

2.2 語法分析

語法分析器(Grammar Parser)就不需要我整個躺進去,只用把掃描器生成的檢查報告交給他。

分析好之後,我拿到了一個盆栽 新的報告 —— 一棵樹,或者,準確一點,一棵語法樹(Syntax Tree)。

樹的枝葉一切正常,表示我的表示式是合法的。毫無疑問,我再次通過了檢查。

但有的函式就不這麼幸運了,他們會在這一步檢查出問題,比如括號不匹配,表示式中缺少操作符等等,這些錯誤會上報編譯器,最後報告給程式設計師。——他們面臨著整改的命運。

2.3 語義分析

剛剛的語法分析器,顧名思義,只完成了語法層面的分析,但他不瞭解表示式是不是真的有意義。

比如讓兩個指標做乘法,在語法上是合法的,但這是沒有意義的。語義分析器(Semantic Analyzer)就能夠檢查出這個錯誤。

但語義分析也不是萬能的,它也有侷限性——語義分析僅僅能分析靜態語句。

你問我什麼是靜態語義?

我不知道,因為我只是一個函式。

所謂靜態語義,是能在編譯期間可以確定的語義,與之相對的動態語義,就是隻有在執行期才能確定的語義。

int a = 6 / 0;

從靜態語義上看,這句話是合法的,編譯期間不會報錯,但等到程式執行到這句時,就會報出 devided by 0 的錯誤,造成程式異常退出。

2.4 程式碼生成與優化

走到這,編譯部分也算快結束了。

剩下的兩臺機器,一臺叫程式碼生成器(Code Generator),一臺叫目的碼優化器(Target Code Optimizer)。

目的碼優化器總是嫌棄程式碼生成器,因為程式碼生成器生成的程式碼效率低,還需要他花大功夫來優化。

用優化器的話講:“生成器那傢伙,每次生成一堆低效率的程式碼,我還得從頭讀到尾,進行基於資料流分析(data-flow analyse)技術的全域性優化,太累了。”

其實程式碼生成器有做優化,叫做區域性程式碼優化,只是優化程度遠遠不及優化器,所以他不好意思反駁優化器。

不過這不代表程式碼生成器結構就簡單了,它生成程式碼的過程十分依賴於目標機器——這意味著它要適配許許多多的機器,不同的機器有著不同的字長、暫存器、整數資料型別和浮點數資料型別等,它要考慮的事情太多了。

經過生成器,表示式的樣子發生了巨大的變化(這裡以 x86 的組合語言來表示):

movl index, %ecx          ; value of index to ecx 
addl $4, %ecx             ; ecx = ecx + 4 
mull $8, %ecx             ; ecx = ecx * 8 
movl index, %eax          ; value of index to eax 
movl %ecx, array(,eax,4)  ; array[index] = ecx

優化器對上面的程式碼又做了一番深層次的優化,包括選擇定址方式,刪除多餘指令等。(程式碼比較短,所以優化效果並不明顯。)

movl  index, %edx 
leal  32(,%edx,8), %eax 
movl  %eax, array(,%edx,4)

每次走過這些流程,我都不得不感嘆於編譯器複雜的結構,也只有優秀的程式設計師們,才能夠完成這麼偉大的工程吧。

函式的編譯,就是這麼繁瑣,且枯燥。

今天令我驚訝的是,所有函式都完美的通過了編譯階段。

“Nice~ 這次可以早點休息了!”不止是我,其他函式也是這麼想的吧。

我們有說有笑,悠然等待著連結程式來做最後的收尾工作。

但萬萬沒想到,危機竟出現在連結階段。

3. 連結

我聽長輩們說,連結器,擁有比編譯器更為悠久的歷史。

每當我把這個事實告訴新來的函式時,他們總是一臉不可思議:

“我們都是先編譯,再連結的,怎麼會先有連結器,再有編譯器?這又不是先有雞還是先有蛋的哲學問題。”

我第一次聽說的時候,也有這樣的疑惑。

“連結是在組合語言時代就出現了的概念。在那之前,是機器語言的時代。但是想要對機器語言進行修改,那就太困難了,因為機器指令的修改經常造成具體指令地址的改變,牽一髮而動全身。所以組合語言產生了,用符號來標記位置,而符號與實際地址的對映工作,就是連結器來做的。”我向他解釋道。

“我明白了,因為高階語言出現在後面,所以從高階語言到組合語言的步驟——編譯,要比連結來的晚一些。”

是啊,程式語言的發展,從機器語言,到組合語言,再到現在的高階語言,經過了幾十年的時間。但儘管是現代,我們編譯型高階語言,想要執行,還是得回到組合語言,再被翻譯成機器語言,看起來是繞了一個大圈,但人類程式設計師的生產力,卻得到了質的飛躍。

人類總是能想出各種辦法來減輕他們的工作量。

...

連結過程主要包括了地址和空間分配(Address and Storage Allocation)、符號決議(Symbol Resolution)和重定位(Relocation)等這些步驟。

看起來挺高大上,其實連結器做的和早期程式設計師人工調整地址沒什麼兩樣,只是更加複雜而已——你不要指望現在的語言特性比早期簡單。

但從本質上說,就是把指令對其他符號地址的引用加以修正。連結的重點就是兩個不同的目標檔案。

這一階段本來是很容易通過的,但今天,居然出現了大錯誤。

問題出在 main.c 中。

出乎所有函式的意料,包括 main。

4. 尾聲

回到編輯器,我們檢查遍了 main 函式內部的所有函式,從他們的宣告,再到他們的實現,全都沒有問題。

“會不會是 #include 的時候出了什麼問題?”有函式提出了自己的看法。

我們決定分頭行動,一部分和其他檔案協作檢查函式宣告,剩下一部分負責排查有沒有出現迴圈 #include 問題。

不知過了多少 CPU 週期,大家回來了,一無所獲,兩種問題都沒有出現。

我們一籌莫展。

“ main.c ,連結出錯...”我滿腦子都在想可能原因,“不會是 main 函式本身出了問題吧!”

“快,去看看巨集定義有沒有異常!”

巨集定義?雖然大家有些疑惑,但還是照做了。

果然,發現了異常:

...
...
#define main mian
...
...

我心裡怒罵“誰這麼缺德,幹這種事情?!”

好在刪掉這條“間諜”指令後,一切恢復正常,完美通過編譯連結。

我們終於可以休息了。

PS:危險指令,請勿模仿。除非,,,你想挨一頓毒打。

PPS:函式的執行以後也會寫到。

PPPS:如果大家對文章有什麼看法和意見,歡迎提出來~ 如果覺得文章有意思,點個贊再走吧

文中插圖來自《程式設計師的自我修養》。

宣告:原創文章,未經授權,禁止轉