我從一生下來就呆在這個昏暗的地方。
我不明白為什麼程式設計師這麼喜歡 Dark Mode,Brighten Mode 才是我的最愛。聽說最近連 iphone 都開始支持 Dark Mode 了,沒話講。。。說好的絕不妥協呢?
我周圍是熙熙攘攘的函數群,穿插著變量聲明和宏定義。
在我們這裡,函數是一等公民。
當然,不光在 C++,在面向過程的 C 語言、面向對象的 Java ,尤其是在那些函數式編程的語言裡,我們都扮演著舉足輕重的角色。
能力越大,責任越大。我和一群函數夥伴們就負責維護著程序的功能。每個函數的一小步,合起來就是功能模塊的一大步。
作為一門靜態編譯型語言,我們不像那些解釋語言一樣,寫完就能直接運行,而是要先經過編譯這一道坎,成為機器語言,才能夠運行在我們賴以生存的機器上。
這道坎不是那麼好過的,再頂尖的程式設計師,也會在這上面栽跟頭。
放在往常,雖然程序偶爾會出 bug ,但大家齊心協力,可謂蟲(bug)擋殺蟲,過五關斬六將,整個程序也稱得上是井井有條。
但這次,我們遇到了大問題。
1、預編譯今天的一切看起來都很平凡,至少我是這麼認為的。
屏幕外的程式設計師像平常一樣敲著代碼,我們像平常迎接著新函數的到來,像平常一樣嬉笑怒罵,像平常一樣期待著預編譯進程的到來。
預編譯進程是整個編譯進程的先鋒。
像往常一樣,我們從磁碟出發,沿著總線來到了內存。這裡就是進程的工作車間。
預編譯進程第一步會 刪除所有 #define,展開宏定義。處理條件預編譯指令。
#define WINDOWS
#define BUFSIZE 1024
#define DEPTH 4
#define DECODE "utf-8"
...
上面的就是宏定義,每次我們都要在預編譯進程的指揮下,把語句裡出現的宏替換成對應的值。
這一步其實本來不需要我們幹的,程式設計師怕麻煩,想要做到「一處修改,處處更改」,就發明了宏定義,讓編譯器來幹這些「髒活累活」。
處理條件預編譯指令就有點不一樣了:
#ifdef WINDOWS
<experssion1>
#else
<experssion2>
#endif
如果宏定義有 WINDOWS,就留下 <experssion1>,沒有的話就只留 <experssion2> 。說白了,這就是個預編譯階段能執行的 if... else ... 語句。上面的語句一處理,就變成了:
<experssion1>
對,注釋也會被刪除。
可憐那些注釋,這一輩子都不曾領略 CPU 裡的風景。
第二步是處理 #include預編譯指令。
這一步就比上面的複雜多了。用專業的話來說,處理 「#include 」預編譯指令,就是將被包含的文件插入到該預編譯指令的位置。這個過程是遞歸進行的,也就是說被包含的文件可能還包含其他文件。
#include "config.h"
<expressions>
別看他們現在就只有短短兩句,等把 config.h文件內容複製過來,信息量一下子就大了。
#ifndef _CONFIG_H_
#define _CONFIG_H_
#define VERSION "1.0.0"
#define MODE 1
...
...
#endif
<expressions>
補充一句,這個 .h 後綴的傢伙,叫頭文件。他是我們與其他文件的函數公民的溝通渠道。
頭文件這個傢伙和源文件不太一樣,他是包含功能函數、數據接口聲明的載體文件,主要用於保存程序的聲明。也就是說,頭文件裡是沒有函數的——我們曾多次試圖佔領頭文件的領地,但都沒有成功——都是因為程式設計師的約束。
每個頭文件都會帶有一組條件預編譯語句,用來防止自己被多次編譯。
至於怎麼做到的,這太簡單了,我不說你也能想出來。
聽說有的編譯器還支持 #pragma once ,添在頭文件第一行就能做到相同的事情。可惜我們的編譯器有點舊,不兼容他們。
最後這步就比較快了,添加行號和文件名標識。
走到這裡,我們已經得到了編譯器調試的需要的行號信息,如果編譯到哪一步出錯,或者出現 warning 這樣的警告,就能把行號顯示出來,方便程式設計師及時發現問題源頭。
今天的預編譯比我想像中要快一點,可能這次沒什麼進程跟我們搶 CPU 資源吧。
預編譯階段結束,# 的數量大大減少,僅剩下幾個 #pragma 指令留在這裡。
和其他宏定義指令不一樣的是,#pragma 是能夠跟編譯器平起平坐的存在,預編譯進程見了都得避讓三分。
#pragma warning( disable: 4507 34; once: 4385; error: 164 )
像這條指令,就是專門給編譯器看的,意思是 『不顯示4507和34號警告信息 ,4385號警告信息僅報告一次,把164號警告信息作為一個錯誤』 。可以說,她是程式設計師和編譯器之間的信鴿。
<!--要注意的是,這裡面的第幾步第幾步是不嚴謹的,預編譯沒有劃分這樣的界限-->
對於我來說,預編譯階段是比較輕鬆的,最複雜也只是處理條件預編譯指令——刪除幾行代碼罷了。
2、編譯所謂編譯過程,就是把預處理完的文件進行一系列詞法分析,語法分析,語義分析及優化後生產相應的彙編代碼文件。這一步是整個程序構建的核心部分,也是最容易出錯的一部分。
從現在開始,步驟就變得十分複雜了。
對函數來說,這一階段是最繁瑣也是最為危險的:稍有不慎,輕則 warning 重則 error 。
我見過許多出錯的函數,他們連著行號被編譯器帶到窗口,當街示眾。
也有些函數和 #pragma 關係比較好,小錯誤被遮掩過去,免去了示眾的命運。
2.1 掃描我們要先經過一臺掃描器 (Scanner),這機器如此龐大,以至於我根本看不出內部的細節。
我對大型機器充滿好奇,編譯器給了我一本手冊——《編譯寶典》,他說裡面有講掃描器的實現。
可我看不懂。
編譯器告訴我,想要參透這本寶典,需要付出代價。
「代價?像嶽不群那樣?」
「你想哪兒去了!你說的那是《葵花寶典》,我說的代價是時間和精力!編譯器這種龐大的工程,需要一個團隊來合作完成,除非你是打算寫寫玩具編譯器。」
所以我放棄了造出這些機器的想法,因為函數的一生太短了,希望你能實現我的願望。
在掃描器裡的體驗不太舒服,它像一臺 X 光機,把我的身體裡裡外外看了個遍,給我的感覺很不妙。
出了機器,會收到一個檢查報告,像這樣(篇幅有限,只拿一個表達式舉例子):
拿著這份報告,就該去進行語法分析了。
2.2 語法分析語法分析器(Grammar Parser)就不需要我整個躺進去,只用把掃描器生成的檢查報告交給他。
分析好之後,我拿到了一個盆栽 新的報告 —— 一棵樹,或者,準確一點,一棵語法樹(Syntax Tree)。
樹的枝葉一切正常,表示我的表達式是合法的。毫無疑問,我再次通過了檢查。
但有的函數就不這麼幸運了,他們會在這一步檢查出問題,比如括號不匹配,表達式中缺少操作符等等,這些錯誤會上報編譯器,最後報告給程式設計師。——他們面臨著整改的命運。
2.3 語義分析剛剛的語法分析器,顧名思義,只完成了語法層面的分析,但他不了解表達式是不是真的有意義。
比如讓兩個指針做乘法,在語法上是合法的,但這是沒有意義的。語義分析器(Semantic Analyzer)就能夠檢查出這個錯誤。
但語義分析也不是萬能的,它也有局限性——語義分析僅僅能分析靜態語句。
你問我什麼是靜態語義?
我不知道,因為我只是一個函數。
所謂靜態語義,是能在編譯期間可以確定的語義,與之相對的動態語義,就是只有在運行期才能確定的語義。
從靜態語義上看,這句話是合法的,編譯期間不會報錯,但等到程序運行到這句時,就會報出 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:如果大家對文章有什麼看法和意見,歡迎提出來~ 如果覺得文章有意思,點個讚再走吧
文中插圖來自《程式設計師的自我修養》。
●編號601,輸入編號直達本文
●輸入m獲取文章目錄
分享C/C++技術文章