我們剛開始接觸程式設計的時候,最先完成的小專案就是“hello world”,在短時間內我們都能用這種語言寫出它的hello world。但別看這只是一個小小的幾個字母然而,對於hello world這個簡單程式的內部運作機制,大部分人還是說不清楚的,所以我們今天就為大家講講程式運作的機制。
hello world這些資訊是如何通顯示器過顯示的? cpu執行的程式碼和程式中我們寫的程式碼肯定不一樣,她是什麼樣子的?又是如何從我們寫的程式碼變成cpu能執行的程式碼的?程式運行時程式碼是在什麼地方?她們是如何組織的?程式中的變數儲存在什麼地方?函數呼叫是怎樣是現的?這篇文章將簡單的討論程式的運作機制。
開發平台隱藏的過程
每一種語言都有自己的開發平台,我們的程式大多是也都是在這裡誕生的。從程式原始碼到可執行檔的轉換過程其實是分很多步而且是很複雜的,只是而現在的開發平台把所有的這些事情都自己承擔了,給我們帶來方便的同時她也影藏了大量的實作細節。所以大多程式設計師只負責寫程式碼,其它的複雜的轉換工作則由開發平台默默完成。
依照我的理解,簡單的說從原始碼到執行檔的過程可分為以下幾個階段:
1、從原始碼到機器語言並將產生的機器語言按照一定的規律組織起來。我們暫且稱為文件A。
2、把檔案A和運行A需要的檔案B(如函式庫函數)連結起來,形成檔案A+
3、把檔案A+裝載進入內存,運行檔案
(其實如果是看參考書或其他資料的話可能不只這幾步,只是這裡為了簡化我把它歸納為3步)
這些事形成可執行文件的關鍵步驟,缺一不可。現在看到被開發平台「蒙蔽」了吧。下面的部分將撥開迷霧,還你開發平台的真面目。
目標檔案
在電腦領域有過一句經典的話:
「any problem in computer science can be sloved by another layer of indirecition」
# 「電腦科學領域的任何問題都可以透過增加一個中間層來解決」
比如說要實現從A到B的轉換,可以先把A轉換為檔案A+,再把檔案A+轉換為我們需要的文件B。 (其實在波利亞的《how to slove it》裡面對這種方法也有敘述。在解題的時候可以透過增加中間層來簡化問題)
那麼從原始碼到可執行檔的過程可以這樣理解。從原始碼到可執行檔也是一樣的,透過(不斷的)在他們之間增加中間層,來解決問題。
和上文說的,先把原始程式轉換成中間檔案A,再把中間檔案轉換成我們需要的目標檔案。
在處理文件的時候就是按照這種想法來的。
其實上面說的文件A更專業的說法是:目標檔。她不是可執行程序,需要和其它的目標檔案進行連結、裝載後才能執行。對於一個原始程序,開發平台首先要做的就是把原始程式翻譯成機器語言。其中很重要的一部就是編譯。相信很多人都知道,就是把原始碼翻譯成機器語言(其實就是一堆二進位代碼)。編譯知識很重要,卻不是本文的重點,有興趣的可自行google。
目標檔案格式:
現在來看一下上面說的目標檔案是如何組織的(也就是存放結構)。
起源:
想像一下如果是你來設計會如何組織這些二進位程式碼?就像書桌上的物品要分類放置才整潔一樣,為了方便管理翻譯出來的二進位代碼也分類存放,把表示代碼的放在一起,表示數據的放在一起。這樣,二進位代碼就分為了不同的區塊來存放。這樣的一個區域就是被稱為段(segment)的東西。
標準:
和電腦科學中的許多東西一樣,為了方便人們的溝通、程式的相容等問題。也為這種二元的存放方式訂定了標準,於是COFF(common object file format)就誕生了。現在的windows、Linux、等主流作業系統下的目標檔案格式和COFF大同小異,都可以認為是它的變種。
a.out:
a.out是目標檔案的預設名字。也就是說,當編譯一個檔案的時候,如果不對編譯後的目標檔案重新命名,編譯後就會產生一個名字為a.out的檔案。
具體的為什麼會用這個名字這裡就不在深究了。有興趣的可以自己google。
下面的圖可以讓你更直覺的了解目標檔:
#上圖是目標檔的典型結構,實際的情況可能會有所差別,但都是在這個基礎上衍生出來的。
ELF檔案頭:即上圖中的第一個段落。其中的header是目標檔案的頭部,裡麵包含了這個目標檔案的一些基本資訊。如該檔案的版本、目標機器型號、程式入口位址等等。
文字段:裡面的資料主要是程式中的程式碼部分。
資料段:程式中的資料部分,比如說變數。
重定位段:
重定位段包含了文字重定位和資料重定位,裡麵包含了重定位資訊。一般來說,程式碼中都會存在引用了外部的函數,或是變數的情況。既然是引用,那麼這些函數、變數就沒存在該目標檔內。在使用他們的時候,就要給出他們的實際地址(這個過程發生在連結的時候)。正是這些重定位表,提供了尋找這些實際地址的資訊。在理解了上面之後,文字重定位和資料重定位也就不難理解了。
符號表:符號表包含了原始程式碼中所有的符號資訊。包括每個變數名、函數名等等。裡面記錄了每個符號的訊息,比如說程式碼中有「student」這個符號,對應的在符號表中就包含這個符號的資訊。包括這個符號所在的段、它的屬性(讀寫權限)等相關資訊。
其實符號表最初的來源可以說是在編譯的詞法分析階段。在做詞法分析的時候,就把程式碼中的每個符號及其屬性都記錄在符號表中。
字串表:和符號表差不多的功能,存放了一些字串資訊。
其中還有一點要說嗎的是:目標檔案都是以二進位來儲存的,它本身就是二進位檔案。
現實中的目標文件會比這個模型要複雜些,但是它的思路都是一樣的,就是按照類型來存儲,再加上一些描述目標文件信息的段和鏈接中需要的信息。
a.out剖分
Hello World
空口無憑,我們現在就來研究hello world編譯後形成的目標文件,這裡用C來描述。
簡單的hellow world原始碼:
#為了在資料段中也有資料可放,這裡增加了“ int a=5”。
如果在VC上的話,點擊運行便能看到結果。
為了能看清楚內部到底是如何處理的,我們使用GCC來編譯。
運行
gcc hello.c
再看我們的目錄下,就多了目標檔a.out。
現在我們想做的是看看a.out裡面到底有什麼,可能有童鞋回想到用vim文字查看,當時我也是這麼天真的認為。但a.out是何等東西,怎能這麼簡單就暴露出來呢。是的,vim不行。 “我們遇到的問題大多是前人就已經遇到並且已經解決的”,對,其中有一個很強悍的工具叫做objdump。有了它,我們就能徹底的去了解目標檔案的各種細節,當然還有一個叫做readelf也很有用,這個在後面介紹。
這兩個工具一般Linux裡面都會自帶有,可以自行google
註:這裡的程式碼主要是在Linux下用GCC編譯,查看目標檔用的是Objdump、 readelf。但我會把所有的運行結果都上圖,所以之前沒有接觸過Linux的童鞋來看下面的內容也完全沒問題哦。我用的是ubuntu,感覺挺好~
下面是a.out的組織架構:(每段的起始位址、、大小等等)
查看目標檔的指令是objdump -h a.out
就和上文所述的目標檔案的格式一樣,可以看出是分類儲存的。目標文件被分為了6段。
從左到右,第一列(Idx Name)是段的名字,第二列(Size)是大小,VMA為虛擬位址,LMA為實體位址,File off是檔案內的偏移。也就是這段相對於段中某一參考(一般是段起始)的距離。最後的Algn是對段屬性的說明,暫時不用理會
「text」段:程式碼段。
「data」段:也就是上面說的數據段,保存了原始碼中的數據,一般是以初始化的數據。
「bss」段:也是資料段,存放那些未初始化的數據,因為這些數據還未分配空間,所以單獨存放。
「rodata」段:只讀資料段,裡面存放的資料是唯讀的。
「cmment」存放的是編譯器版本資訊。
剩下的兩段對我們的討論沒有實際意義,就不再介紹。認為他們包含了一些連結、編譯、裝在的資訊就可。
註:
這裡的目標檔案格式只是列出實際情況中主要部分。實際情況還有一些表未列出。如果你也在用Linux,可以用objdump -X列出更詳細的段落內容。
深入a.out
上面部分透過實例說了目標檔案中的典型的段,主要是段的信息,如大小等相關的屬性。
那麼這些段落裡面究竟有些什麼東西呢,「text」段裡到底存了什麼東西,還是用我們的objdump。
objdump -s a.out 透過-s選項就可以檢視目標檔案的十六進位格式。
查看結果如下:
#如上圖所示,列出了各段的十六進位表示形式。可以看出圖中共分為兩欄,左邊的一欄是十六進位的表示, 右邊則顯示對應的資訊。
比較明顯的如「rodata」只讀資料段中就有「hello world」。 。汗,好像程式裡的“hello”打錯了,後面多加了一個“w”,截圖麻煩,。原諒下哈。
你也可以查看「hellow world」的ASCII值,對應的十六進位就是裡面的內容了。
「comment」上文中說的這個段包含了一些編譯器的版本信息,這個段後面的內容就是了:GCC編譯器,後面的是版本號。
a.out反組譯
編譯的過程總是先把源文先變成組合形式,再翻譯為機器語言。 (加上中間層嘛)看了這麼多的a.out,再研究一下他的彙編形式是恨必要的
objdump -d a.out可以列出文件的彙編形式。不過這裡只列出了主要部分,即main函數部分,其實在main函數執行的開始和main函數執行以後都還有多工作要做。
即初始化函數執行環境以及釋放函數所佔用的空間等。
在上面的圖中,左邊是程式碼的十六進位形式,左邊是組合形式。對彙編熟悉的童鞋應該可以看懂大部分,這裡就不在多述。
a.out頭檔
在介紹目標檔案格式的時候,提到過頭檔這個概念,裡麵包含了這個目標檔案的一些基本資訊。如該檔案的版本、目標機器型號、程式入口位址等等。
下圖是檔案頭的形式:
可以用readelf -h來檢視。 (下圖中查看的是hello.o,它是原始檔hello.c編譯但未連結的檔案。 這個和查看a.out大部分是一樣的)
圖中分成兩個欄,左邊一欄表示的是屬性,右邊是屬性值。第一行常稱為魔數。後面是一連串的數字,其中的具體意義就不多說了,可以自己去google。
接下來的是一些和目標檔案相關的資訊。由於跟我們要討論的問題關係不大,這裡就不展開討論了。
上面是內容用具體的實例說了目標檔案內部的組織形式,目標檔案只是產生可執行檔過程中的一個中間過程,對於程式是如何運作的還沒做討論,目標檔案是如何轉變為可執行檔以及可執行檔是如何執行的將在下面的部分中討論
對連結的簡單認識
連結通俗的說就是把幾個可執行檔。
如果程式A中引用了檔案B中定義的函數,為了A中的函數能正常執行,就需要把B中的函數部分也放在A的原始碼中,那麼將A和B合併成一個檔案的過程就是連結了。
有專門的過程用來連結程序,稱為連結器。他將一些輸入的目標檔案加工後合成一個輸出檔。這些目標檔案中往往有相互的資料、函數引用。
上文中我們看過了hello world的反彙編形式,是一個還沒有經過連結的文件,也就是說當引用外部函數的時候是不知道其地址的:
如下圖:
上圖中,cal指令就是呼叫了printf()函數,因為這時候printf()函數並不在這個檔案中,所以無法確定它的位址,在十六進位中就用「ff ff ff」來表示它的位址。等經過連結以後,這個地址就會變成函數的實際地址,而應為連接後這個函數已經被載入進入這個檔案了。
連結的分類:按把A相關的資料或函數合併為一個檔案的先後可以把連結分成靜態連結和動態連結。
靜態連結:
在程式執行之前就完成連結工作。也就是等連結完成後文件才能執行。但是這有一個明顯的缺點,比如說庫函數。如果檔案A和檔案B都需要用到某個函式庫函數,連結完成後他們連線後的檔案中都有這個函式庫函數。當A和B同時執行時,記憶體中就存在該函式庫函數的兩份拷貝,這無疑浪費了儲存空間。當規模擴大的時候,這種浪費尤其明顯。靜態連結還有不容易升級等缺點。為了解決這些問題,現在的許多程式都用動態連結。
動態連結:和靜態連結不一樣,動態連結是在程式執行的時候才進行連結。也就是當程式載入執行的時候。還是上面的例子,如果A和B都用到了函式庫函數Fun(),A和B執行的時候記憶體就只需要有Fun()的一個拷貝。
關於連結還有很多知識,以後會用專門的文章來談。這裡就不展開講了。
對裝載的簡單解釋
我們知道,程式要運行是必然要把程式載入到記憶體中的。在過去的機器裡都是把整個程式都載入進入實體記憶體中,現在一般都採用了虛擬儲存機制,也就是每個行程都有完整的位址空間,給人的感覺好像每個行程都能使用完成的內存。然後由一個記憶體管理器把虛擬位址映射到實際的實體記憶體位址。
依照上文的敘述,程式的位址可以分成虛擬位址和實際位址。虛擬地址即她在她的虛擬記憶體空間中的地址,實體地址就是她被載入的實際地址。
在上文中查看段落的時候或許你已經注意到了,由於檔案是未連結、未載入的,所以每個段的虛擬位址和實體位址都是0.
載入的過程可以這樣理解:先為程式中的各部分指派好虛擬位址,然後再建立虛擬位址到實體位址的對應。其實關鍵的部分就是虛擬位址到實體位址的映射過程。程式裝在完成之後,cpu的程式計數器pc就指向檔案中的程式碼起始位置,然後程式就依序執行。
寫這篇文章的目的在於梳理程式運作的機制,在一個執行檔執行的背後都隱藏了什麼。從原始程式碼到可執行檔通常要經歷許多中間步驟,每個中間步驟都會產生一個中間檔案。只是現在的整合開發環境都吧這些步驟影藏了,習慣於整合開發環境的我們也就逐漸的忽略了這些重要的技術內幕。這篇文章也只是介紹了一下這個過程的主線而已。其中的每一個細節展開來講都可足已用一篇文章來論述。
我想看完這篇文章之後,大家就不會覺得「hello world」只是很簡單的一個小實驗吧,也希望大家透過此篇文章了解到什麼是程式的運作機制以及是怎樣運行的。
相關建議:
以上是由hello world 談程式運行機制的詳細內容。更多資訊請關注PHP中文網其他相關文章!