開篇
許久不碰關於這方面的知識了,前幾天同學開課提及到該部分,正好作為回顧吧。
C/C++語言很多人都比較熟悉,這基本上是每位大學生必學的一門編程語言,通常還都是作為程序設計入門語言學的,並且課程大多安排在大一(反正我是混過來的)。剛上大學,學生們還都很乖,學習也比較認真、用心。所以,C/C++語言掌握地也都不錯(說的是你麼),不用說編譯程序,就是寫個上幾百行的程序都不在話下,但是他們真的知道C/C++程序編譯的步驟麼?
很多人都不是很清楚吧,如果接下來學過“編譯原理”,也許能說個大概。VC的“舒適”開發環境屏蔽了很多編譯的細節,這無疑降低了初學者的入門門檻,但是也“剝奪”了他們“知其所以然”的權利,致使很多東西只能“死記硬背”,遇到相關問題就“丈二”。國內教育,隱匿了關於程序代碼變成計算機可執行的語言之間的一切過程,悲劇~~~所以,這部分只能自己查資料了,在此推薦兩本書,一個是老外的《深入理解計算機系統》,另外一本就是國人寫的非常優秀的一本關於底層介紹的書籍《程序員的自我修養》。
本篇僅作為關於“C/C++程序編譯步驟以及如何生成可執行文件”的簡要介紹。
正文
1、寫在前面
關於學習編程的過程,一是刷各家公司的筆試題,各種奇葩的筆試題,挖了各種坑,這樣才能讓你快速進步;二是看了liutao的《囫囵C語言》系列,寫的太精辟了,幽默的語言以及深入的理解。可以作者很久不更新了。應該是退出“神壇”了吧。
電子計算機所使用的是由“0”和“1”組成的二進制數,二進制是計算機的語言的基礎。計算機發明之初,人們只能降貴纡尊,用計算機的語言去命令計算機干這干那,一句話,就是寫出一串串由“0”和“1”組成的指令序列交由計算機執行,這種語言,就是機器語言。想象一下老前輩們在打孔機面前數著一個一個孔的情景,噓,小聲點,你的驚嚇可能使他們錯過了一個孔,結果可能是導致一艘飛船飛離軌道。
為了減輕使用機器語言編程的痛苦,人們進行了一種有益的改進:用一些簡潔的英文字母、符號串來替代一個特定的指令的二進制串,比如,用“ADD”代表加法,“MOV”代表數據傳遞等等,這樣一來,人們很容易讀懂並理解程序在干什麼,糾錯及維護都變得方便了,這種程序設計語言就稱為匯編語言,即第二代計算機語言。然而計算機是不認識這些符號的,這就需要一個專門的程序,專門負責將這些符號翻譯成二進制數的機器語言,這種翻譯程序被稱為匯編程序。因為匯編指令和機器語言之間有著一一對應的關系,這可比英譯漢或漢譯英簡單多了。
高級語言是偏向人,按照人的思維方式設計的,機器對這些可是莫名奇妙,不知所謂。魚與熊掌的故事在計算機語言中發生了。於是必須要有一個橋梁來銜接兩者,造橋可不是一件簡單的事情。當你越想方便,那橋就得越復雜。那高級語言是如何變成機器語言的呢,這個過程讓我慢慢道來。
2、轉換過程平時大家寫代碼然後編譯即可生產計算機可以執行的指令,其實這個轉換過程中有許多重要的過程,下面作詳細介紹。
編譯:將源代碼轉換為機器可認識代碼的過程。編譯程序讀取源程序(字符流),對之進行詞法和語法的分析,將高級語言指令轉換為功能等效的匯編代碼,再由匯編程序轉換為機器語言,並且按照操作系統對可執行文件格式的要求鏈接生成可執行程序。
C源程序->編譯預處理->編譯程序(生成*.s文件)->優化程序->匯編程序(生成*.o文件)->鏈接程序->可執行文件(*.out)
3、細化3.1、編譯預處理
編譯預處理:讀取c源程序,對其中的偽指令(以#開頭的指令)和特殊符號進行處理。偽指令主要包括以下四個方面:
(1)宏定義指令,如# define Name TokenString,#undef等。對於前一個偽指令,預編譯所要作得的是將程序中的所有Name用TokenString替換,但作為字符串常量的Name則不被替換。對於後者,則將取消對某個宏的定義,使以後該串的出現不再被替換。
(2)條件編譯指令,如#ifdef,#ifndef,#else,#elif,#endif,等等。這些偽指令的引入使得程序員可以通過定義不同的宏來決定編譯程序對哪些代碼進行處理。預編譯程序將根據有關的文件,將那些不必要的代碼過濾掉。
(3)頭文件包含指令,如#include "FileName"或者#include
(4)特殊符號,預編譯程序可以識別一些特殊的符號。例如在源程序中出現的LINE標識將被解釋為當前行號(十進數),FILE則被解釋為當前被編譯的C源程序的名稱。預編譯程序對於在源程序中出現的這些串將用合適的值進行替換。預編譯程序所完成的基本上是對源程序的“替代”工作。經過此種替代,生成一個沒有宏定義、沒有條件編譯指令、沒有特殊符號的輸出文件。這個文件的含義同沒有經過預處理的源文件是相同的,但內容有所不同。下一步,此輸出文件將作為編譯程序的輸入而被翻譯成為機器指令。刪除所有注釋“//”,“/* */”以及添加行號,便於編譯器編譯時產生調試用的行號信息及用於編譯時產生編譯錯誤或警告時顯示行號。
3.2、編譯階段經過預編譯得到的輸出文件中,只有常量。如數字、字符串、變量的定義,以及C語言的關鍵字,如main、if、else、for、while、{,}、+、-、*、\等等。編譯程序所要作得工作就是通過詞法分析和語法分析,在確認所有的指令都符合語法規則之後,將其翻譯成等價的中間代碼表示或匯編代碼(符號表)。
3.3、優化階段
優化處理是編譯系統中一項比較艱深的技術。它涉及到的問題不僅同編譯技術本身有關,而且同機器的硬件環境也有很大的關系。優化一部分是對中間代碼的優化。這種優化不依賴於具體的計算機。另一種優化則主要針對目標代碼的生成而進行的。上圖中,我們將優化階段放在編譯程序的後面,這是一種比較籠統的表示。
對於前一種優化,主要的工作是刪除公共表達式、循環優化(代碼外提、強度削弱、變換循環控制條件、已知量的合並等)、復寫傳播,以及無用賦值的刪除等等。後一種類型的優化同機器的硬件結構密切相關,最主要的是考慮如何充分利用機器的各個硬件寄存器存放的有關變量的值,以減少對於內存的訪問次數。另外,如何根據機器硬件執行指令的特點(如流線、RISC、CISC、VLIW等)而對指令進行一些調整使目標代碼比較短,執行的效率比較高,也是一個重要的研究課題。經過優化得到的匯編代碼必須經過匯編程序的匯編轉換成相應的機器指令,方可能被機器執行。
3.4、匯編過程匯編過程實際上指把匯編語言代碼翻譯成目標機器指令的過程。對於被翻譯系統處理的每一個匯編源程序,都將最終經過這一處理而得到相應的目標文件。目標文件中所存放的也就是與源程序等效的目標的機器語言代碼。目標文件由段組成。通常一個目標文件中至少有兩個段:
代碼段:該段中所包含的主要是程序的指令。該段一般是可讀和可執行的,但一般卻不可寫。
數據段:主要存放程序中要用到的各種全局變量或靜態的局部變量。(.rodata和 .data)
UNIX環境下主要有三種類型的目標文件:
(1)可重定位文件: 其中包含有適合於其它目標文件鏈接來創建一個可執行的或者共享的目標文件的代碼和數據。
(2)共享的目標文件: 這種文件存放了適合於在兩種上下文裡鏈接的代碼和數據。第一種是鏈接程序可把它與其它可重定位文件及共享的目標文件一起處理來創建另一個目標文件;第二種是動態鏈接程序將它與另一個可執行文件及其它的共享目標文件結合到一起,創建一個進程映象。
(3)可執行文件: 它包含了一個可以被操作系統創建一個進程來執行之的文件。匯編程序生成的實際上是第一種類型的目標文件。對於後兩種還需要其他的一些處理方能得到,這個就是鏈接程序的工作了。
3.5、鏈接程序由匯編程序生成的目標文件並不能立即就被執行,其中可能還有許多沒有解決的問題。例如,某個源文件中的函數可能引用了另一個源文件中定義的某個符號(如變量或者函數調用等);在程序中可能調用了某個庫文件中的函數,等等。所有的這些問題,都需要經鏈接程序的處理方能得以解決。
鏈接程序的主要工作就是將有關的目標文件彼此相連接,也即將在一個文件中引用的符號同該符號在另外一個文件中的定義連接起來,使得所有的這些目標文件成為一個能夠被操作系統裝入執行的統一整體。
根據開發人員指定的庫函數的鏈接方式的不同,鏈接處理可分為兩種:
(1)靜態鏈接 在這種鏈接方式下,函數的代碼(被應用程序引用的目標模塊)將從其所在地靜態鏈接庫中被拷貝到最終的可執行程序中。這樣該程序在被執行時這些代碼將被裝入到該進程的虛擬地址空間中。靜態鏈接庫實際上是一個目標文件的集合,其中的每個文件含有庫中的一個或者一組相關函數的代碼。靜態連接的劣勢:浪費內存和磁盤空間,模塊更新困難。
(2)動態鏈接 在此種方式下,函數的代碼被放到稱作是動態鏈接庫或共享對象的某個目標文件中。鏈接程序此時所作的只是在最終的可執行程序中記錄下共享對象的名字以及其它少量的登記信息。在此可執行文件被執行時,動態鏈接庫的全部內容將被映射(優點:無拷貝環節,在內存中只有一份此共享代碼,以節約存儲器空間)到運行時相應進程的虛地址空間。動態鏈接程序將根據可執行程序中記錄的信息找到相應的函數代碼。
動態連接解決了共享的目標文件多個副本浪費磁盤和內存空間的問題。在內存中共享一個目標文件模塊的好處不僅僅是節省內存,還可以減少物理頁面的換入換出,亦可以增加CPU的cache hit (關於這部分在《深入理解計算機系統》中有詳細介紹,尤其是程序的局部性原理的應用,以前寫代碼都是瞎寫,根本不知道還有這麼個優勢)。
動態連接也有其缺點:很常見的一個問題是,當程序所依賴的某個模塊更新後,由於新的模塊與舊的模塊之間接口不兼容,導致原有的程序無法運行。
下面是一些對靜態庫的介紹,幫助理解。
A static library is like abookstore, and a shared library is like... a library. With the former, you getyour own copy of the book/function to take home; with the latter you andeveryone else go to the library to use the same book/function. So anyone who wantsto use the (shared) library needs to know where it is, because you have to"go get" the book/function. With a static library, the book/functionis yours to own, and you keep it within your home/program, and once you have ityou don't care where or when you got it.The advantages of static libraries is thatthere are no dependancies required for the user running the application - e.g.they don't have to upgrade their DLL of whatever... The disadvantages is thatyour application is larger in size because you are shipping it with all the librariesit needs.
Sharedlibraries are .so (or in Windows .dll, or in OS X .dylib) files. All the coderelating to the library is in this file, and it is referenced by programs usingit at run-time. A program using a shared library only makes reference to thecode that it uses in the shared library.
Staticlibraries are .a (or in Windows .lib) files. All the code relating to thelibrary is in this file, and it is directly linked into the program at compiletime. A program using a static library takes copies of the code that it usesfrom the static library and makes it part of the program. [Windows also has.lib files which are used to reference .dll files, but they act the same way asthe first one].
Sharedlibraries reduce the amount of code that is duplicated in each program thatmakes use of the library, keeping the binaries small. It also allows you toreplace the shared object with one that is functionally equivalent, but mayhave added performance benefits without needing to recompile the program thatmakes use of it. Shared libraries will, however have a small additional costfor the execution of the functions as well as a run-time loading cost as allthe symbols in the library need to be connected to the things they use.Additionally, shared libraries can be loaded into an application at run-time,which is the general mechanism for implementing binary plug-in systems.
Staticlibraries increase the overall size of the binary, but it means that you don'tneed to carry along a copy of the library that is being used. As the code isconnected at compile time there are not any additional run-time loading costs.The code is simply there.
Personally,I prefer shared libraries, but use static libraries when needing to ensure thatthe binary does not have many external dependencies that may be difficult tomeet, such as specific versions of the C++ standard library or specificversions of the Boost C++ library.
What some people have failed to mention is thatwith static libraries the compiler knows which functions your application needsand can then optimize it by only including those functions. This can cut downon library size massively, especially if you only use a really small subset ofa really large library!
4、可執行文件對於可執行文件中的函數調用,可分別采用動態鏈接或靜態鏈接的方法。使用動態鏈接能夠使最終的可執行文件比較短小,並且當共享對象被多個進程使用時能節約一些內存,因為在內存中只需要保存一份此共享對象的代碼。但並不是使用動態鏈接就一定比使用靜態鏈接要優越。在某些情況下動態鏈接可能帶來一些性能上損害。
經過上述五個過程,C源程序就最終被轉換成可執行文件了
總結看了那麼多,是不是有了一個全新的認識呢?那麼考察一下成果吧,下面是一個小測試。
File: hw.c #include為什麼一個編譯好的簡單的Hello World程序也需要占據好幾KB的內存空間呢?int main(int argc, char *argv[]) { printf("Hello World!\n"); return 0;//小檢測:該語句可以省略嗎?Why? }