從HelloWorld說(shuō)程序運行機制
開(kāi)篇
本文引用地址:http://dyxdggzs.com/article/202306/448005.htm學(xué)習任何一門(mén)編程語(yǔ)言,都會(huì )從hello world開(kāi)始。對于一門(mén)從未接觸過(guò)的語(yǔ)言,在短時(shí)間內我們都能用這種語(yǔ)言寫(xiě)出它的hello world。然而,對于hello world這個(gè)簡(jiǎn)單程序的內部運行機制,相信還有很多人都不是很清楚。
hello world 這些信息是如何通顯示器過(guò)顯示的?cpu執行的代碼和程序中我們寫(xiě)的的代碼肯定不一樣,她是什么樣子的?又是如何從我們寫(xiě)的代碼變成cpu能執行的代碼的?程序運行時(shí)代碼是在什么地方?她們是如何組織的?程序中的變量存儲在什么地方?函數調用是怎樣是現的?
這篇文章將簡(jiǎn)單的討論程序的運行機制
開(kāi)發(fā)平臺隱藏的過(guò)程
每一種語(yǔ)言都有自己的開(kāi)發(fā)平臺,我們的程序大多是也都是在這里誕生的。從程序源代碼到可執行文件的轉化過(guò)程其實(shí)是分很多步而且是很復雜的,只是而現在的開(kāi)發(fā)平臺把所有的這些事情都自己承擔了,給我們帶來(lái)方便的同時(shí)也隱藏了大量的實(shí)現細節。所以大多程序員只負責編寫(xiě)代碼,其它的復雜的轉換工作則由開(kāi)發(fā)平臺默默完成。
簡(jiǎn)單的說(shuō)從源代碼到可執行文件的過(guò)程可分為以下幾個(gè)階段:
· 從源代碼到機器語(yǔ)言并將產(chǎn)生的機器語(yǔ)言按照一定的規律組織起來(lái)。我們暫且稱(chēng)為文件A。
· 把文件A和運行A需要的文件B(如庫函數)鏈接起來(lái),形成文件A+
· 把文件A+裝載進(jìn)入內存,運行文件
(其實(shí)如果是看參考書(shū)或者其他資料的話(huà)可能不止這幾步,只是這里為了簡(jiǎn)化把它歸納為3步)
這些事形成可執行文件的關(guān)鍵步驟,缺一不可?,F在看到被開(kāi)發(fā)平臺“蒙蔽”了吧。下面的部分將撥開(kāi)迷霧,還你開(kāi)發(fā)平臺的真面目。
目標文件
在計算機領(lǐng)域有過(guò)一句經(jīng)典的話(huà):
“any problem in computer science can be sloved by another layer of indirecition”
“計算機科學(xué)領(lǐng)域的任何問(wèn)題都可以通過(guò)增加一個(gè)中間層來(lái)解決”
比如說(shuō)要實(shí)現從A到B的轉換,可以先把A轉換為文件A+,再把文件A+轉換為我們需要的文件B。(其實(shí)在波利亞的《how to slove it》里面對這種方法也有敘述。在解題的時(shí)候可以通過(guò)增加中間層來(lái)簡(jiǎn)化問(wèn)題)
那么從源代碼到可執行文件的過(guò)程可以這樣理解。從源代碼到可執行文件也是一樣的, 通過(guò)(不斷的)在他們之間增加中間層,來(lái)解決問(wèn)題。和上文說(shuō)的, 先把源程序轉化為中間文件A,再把中間文件轉化為我們需要的目標文件。
在處理文件的時(shí)候就是按照這種思路來(lái)的。
其實(shí)上面說(shuō)的文件A更專(zhuān)業(yè)的說(shuō)法是:目標文件。它不是可執行程序,需要和其它的目標文件進(jìn)行鏈接、裝載后才能執行。對于一個(gè)源程序, 開(kāi)發(fā)平臺首先要做的就是把源程序翻譯成機器語(yǔ)言。其中很重要的一部就是編譯。相信很多人都知道,就是把源代碼翻譯成機器語(yǔ)言(其實(shí)就是一堆二進(jìn)制代碼)。
目標文件格式:
現在來(lái)看一下上面說(shuō)的目標文件是如何組織的(也就是存放結構)。
起源:
想象一下如果是你來(lái)設計會(huì )如何組織這些二進(jìn)制代碼?就像書(shū)桌上的物品要分類(lèi)放置才整潔一樣,為了便于管理翻譯出來(lái)的二進(jìn)制代碼也分類(lèi)存放,把表示代碼的放在一起,表示數據的放在一起。這樣,二進(jìn)制代碼就分為了不同的塊來(lái)存放。這樣的一個(gè)區域就是被稱(chēng)為段(segment)的東西。
標準:
和計算機科學(xué)中的很多東西一樣,為了方便人們的交流、程序的兼容等問(wèn)題。也為這種二進(jìn)制的存放方式制訂了標準,于是COFF(common object file format)就誕生了?,F在的windows、Linux、等主流操作系統下的目標文件格式和COFF大同小異,都可以認為是它的變種。
a.out:
a.out是目標文件的默認名字。也就是說(shuō),當編譯一個(gè)文件的時(shí)候,如果不對編譯后的目標文件重命名,編譯后就會(huì )產(chǎn)生一個(gè)名字為a.out的文件。
下面的圖可以讓你更直觀(guān)的了解目標文件:
上圖是目標文件的典型結構,實(shí)際的情況可能會(huì )有所差別,但都是在這個(gè)基礎上衍生出來(lái)的。
ELF文件頭:即上圖中的第一個(gè)段。其中的header是目標文件的頭部,里面包含了這個(gè)目標文件的一些基本信息。如該文件的版本、目標機器型號、程序入口地址等等。
文本段:里面的數據主要是程序中的代碼部分。
數據段:程序中的數據部分,比如說(shuō)變量。
重定位段:
重定位段包括了文本重定位和數據重定位,里面包含了重定位信息。一般來(lái)說(shuō),代碼中都會(huì )存在引用了外部的函數,或者變量的情況。既然是引用,那么這些函數、變量并沒(méi)存在該目標文件內。在使用他們的時(shí)候, 就要給出他們的實(shí)際地址(這個(gè)過(guò)程發(fā)生在鏈接的時(shí)候)。正是這些重定位表,提供了尋找這些實(shí)際地址的信息。理解了上面之后,文本重定位和數據重定位也就不難理解了。
符號表:符號表包含了源代碼中所有的符號信息 。包括每個(gè)變量名、函數名等等。里面記錄了每個(gè)符號的信息,比如說(shuō)代碼中有“student”這個(gè)符號,對應的在符號表中就包括這個(gè)符號的信息。包括這個(gè)符號所在的段、它的屬性(讀寫(xiě)權限)等相關(guān)信息。
其實(shí)符號表最初的來(lái)源可以說(shuō)是在編譯的詞法分析階段。在做詞法分析的時(shí)候,就把代碼中的每個(gè)符號及其屬性都記錄在符號表中。
字符串表:和符號表差不多的功能,存放了一些字符串信息。
其中還有一點(diǎn)要說(shuō)的是:目標文件都是以二進(jìn)制來(lái)存儲的,它本身就是二進(jìn)制文件。
現實(shí)中的目標文件會(huì )比這個(gè)模型要復雜些,但是它的思路都是一樣的,就是按照類(lèi)型來(lái)存儲,再加上一些描述目標文件信息的段和鏈接中需要的信息。
a.out剖分
Hello World
空口無(wú)憑,我們現在就來(lái)研究一下hello world編譯后形成的目標文件,這里用 C 來(lái)描述。
簡(jiǎn)單的hellow world 源碼:
/*hello.c*/
#include
intmain()
{
inta=5;
printf("hellow world n");
}
為了在數據段中也有數據可放,這里增加了“int a=5”。
如果在VC上的話(huà),點(diǎn)擊運行便能看到結果。為了能看清楚內部到底是如何處理的,我們使用GCC來(lái)編譯。
運行
gcc hello.c
再看我們的目錄下,就多了目標文件a.out。
現在想做的是看看a.out里到底有什么,可能有回想到用vim文本查看。但a.out是何等東西,怎能這么簡(jiǎn)單就暴露出來(lái)呢。是的,vim不行?!拔覀冇龅降膯?wèn)題大多是前人就已經(jīng)遇到并且已經(jīng)解決的”,對,其中有一個(gè)很強悍的工具叫做objdump。有了它,我們就能徹底的去了解目標文件的各種細節,當然還有一個(gè)叫做readelf也很有用,這個(gè)在后面介紹。
注:
這里的代碼主要是在Linux下用GCC編譯,查看目標文件用的是Objdump、readelf。
下面是a.out的組織結構:(每段的起始地址、大小等等)
查看目標文件的命令是 objdump -h a.out
就和上文中描述的目標文件的格式一樣,可以看出是分類(lèi)存儲的。目標文件被分為了6段。
從左到右,第一列(Idx Name)是段的名字,第二列(Size)是大小 ,VMA為虛擬地址,LMA為物理地址,File off是文件內的偏移。也就是這段相對于段中某一參考(一般是段起始)的距離,最后的Algn是對段屬性的說(shuō)明。
“text”段:代碼段。
“data”段:也就是上面說(shuō)的數據段,保存了源代碼中的數據,一般是以初始化的數據。
“bss”段:也是數據段,存放那些未初始化的數據,因為這些數據還未分配空間,所以單獨存放。
“rodata”段:只讀數據段,里面存放的數據是只讀的。
“cmment”存放的是編譯器版本信息。
注:
這里的目標文件格式只是列出實(shí)際情況中主要部分。實(shí)際情況還有一些表未列出。如果在用Linux,可以用objdump -X列出更詳細的段內容。
深入a.out
上面部分通過(guò)實(shí)例說(shuō)了目標文件中的典型的段,主要是段的信息,如大小等相關(guān)的屬性。
那么這些段里面究竟有些什么東西呢,“text”段里到底存了什么東西,還是用我們的objdump。
objdump -s a.out 通過(guò)-s選項就可以查看目標文件的十六進(jìn)制格式。
查看結果如下:
如上圖所示,列出了各段的十六進(jìn)制表示形式。可以看出圖中共分為兩欄,左邊的一欄是十六進(jìn)制的表示, 右邊則顯示相應的信息。比較明顯的如“rodata”只讀數據段中就有 “hello world”。
也可以查看“hellow world”的ASCII值,對應的十六進(jìn)制就是里面的內容了。“comment”上文中說(shuō)的這個(gè)段包含了一些編譯器的版本信息,這個(gè)段后面的內容就是了:GCC編譯器,后面的是版本號。
a.out反匯編
編譯的過(guò)程總是先把源文先變?yōu)閰R編形式,再翻譯為機器語(yǔ)言。(添加中間層嘛)看了這么多的a.out,再研究一下匯編形式是有必要的。
objdump -d a.out可以列出文件的匯編形式。不過(guò)這里只列出了主要部分,即main函數部分,其實(shí)在main函數執行的開(kāi)始和main函數執行以后都還有多工作要做。即初始化函數執行環(huán)境以及釋放函數占用的空間等。
上面的圖中,左邊是代碼的十六進(jìn)制形式,左邊是匯編形式。
a.out頭文件
在介紹目標文件格式的時(shí)候,提到過(guò)頭文件這個(gè)概念,里面包含了這個(gè)目標文件的一些基本信息。如該文件的版本、目標機器型號、程序入口地址等等。
下圖是文件頭的形式:
可以用readelf -h 來(lái)查看。(下圖中查看的是 hello.o,它是源文件hello.c編譯但未鏈接的文件。 這個(gè)和查看a.out 大部分是一樣的)
圖中分為兩欄,左邊一欄表示的是屬性,右邊是屬性值。第一行常被稱(chēng)為魔數。接下來(lái)的是一些和目標文件相關(guān)的信息。
上面是內容用具體的實(shí)例說(shuō)了目標文件內部的組織形式,目標文件只是產(chǎn)生可執行文件過(guò)程中的一個(gè)中間過(guò)程,對于程序是如何運行的還沒(méi)做討論,目標文件是如何轉變?yōu)榭蓤绦形募约翱蓤绦形募侨绾螆绦械膶⒃谙旅娴牟糠种杏懻摗?/p>
對鏈接的簡(jiǎn)單認識
鏈接通俗的說(shuō)就是把幾個(gè)可執行文件。如果程序A中引用了文件B中定義的函數,為了A中的函數能正常執行,就需要把B中的函數部分也放在A(yíng)的源代碼中,那么將A和B合并成一個(gè)文件的過(guò)程就是鏈接了。有專(zhuān)門(mén)的過(guò)程用來(lái)鏈接程序,稱(chēng)為鏈接器。他將一些輸入的目標文件加工后合成一個(gè)輸出文件。這些目標文件中往往有相互的數據、函數引用。
上文中我們看過(guò)了hello world的反匯編形式,是一個(gè)還沒(méi)有經(jīng)過(guò)鏈接的文件,也就是說(shuō)當引用外部函數的時(shí)候是不知道其地址的,如下圖:
上圖中,cal指令就是調用了printf()函數,因為這時(shí)候printf()函數并不在這個(gè)文件中,所以無(wú)法確定它的地址,在十六進(jìn)制中就用“ff ff ff ”來(lái)表示它的地址。等經(jīng)過(guò)鏈接以后,這個(gè)地址就會(huì )變?yōu)楹瘮档膶?shí)際地址,應為連接后這個(gè)函數已經(jīng)被加載進(jìn)入這個(gè)文件中了。
鏈接的分類(lèi):按把A相關(guān)的數據或函數合并為一個(gè)文件的先后可以把鏈接分為靜態(tài)鏈接和動(dòng)態(tài)鏈接。
靜態(tài)鏈接:
在程序執行之前就完成鏈接工作。也就是等鏈接完成后文件才能執行。但是這有一個(gè)明顯的缺點(diǎn),比如說(shuō)庫函數。如果文件A和文件B都需要用到某個(gè)庫函數,鏈接完成后他們連接后的文件中都有這個(gè)庫函數。當A和B同時(shí)執行時(shí),內存中就存在該庫函數的兩份拷貝,這無(wú)疑浪費了存儲空間。當規模擴大的時(shí)候,這種浪費尤為明顯。靜態(tài)鏈接還有不容易升級等缺點(diǎn)。為了解決這些問(wèn)題,現在的很多程序都用動(dòng)態(tài)鏈接。
動(dòng)態(tài)鏈接:
和靜態(tài)鏈接不一樣,動(dòng)態(tài)鏈接是在程序執行的時(shí)候才進(jìn)行鏈接。也就是當程序加載執行的時(shí)候。還是上面的例子 ,如果A和B都用到了庫函數Fun(),A和B執行的時(shí)候內存中就只需要有Fun()的一個(gè)拷貝。
對裝載的簡(jiǎn)單解釋
我們知道,程序要運行是必然要把程序加載到內存中的。在過(guò)去的機器里都是把整個(gè)程序都加載進(jìn)入物理內存中,現在一般都采用了虛擬存儲機制,即每個(gè)進(jìn)程都有完整的地址空間,給人的感覺(jué)好像每個(gè)進(jìn)程都能使用完成的內存。然后由一個(gè)內存管理器把虛擬地址映射到實(shí)際的物理內存地址。
按照上文的敘述, 程序的地址可以分為虛擬地址和實(shí)際地址。虛擬地址即虛擬內存空間中的地址,物理地址就是被加載的實(shí)際地址。
在上文中查看段的時(shí)候或許你已經(jīng)注意到了,由于文件是未鏈接、未加載的,所以每個(gè)段的虛擬地址和物理地址都是0。
加載的過(guò)程可以這樣理解:先為程序中的各部分分配好虛擬地址,然后再建立虛擬地址到物理地址的映射。其實(shí)關(guān)鍵的部分就是虛擬地址到物理地址的映射過(guò)程。程序裝在完成之后,cpu的程序計數器pc就指向文件中的代碼起始位置,然后程序就按順序執行。
評論