1. 程式人生 > >從 Hello World 說程式執行機制

從 Hello World 說程式執行機制

開篇

學習任何一門程式語言,都會從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 原始碼:

C
1234567 /*hello.c*/#include<stdio.h>intmain(){  inta=5;  printf("hellow world \n");}

為了在資料段中也有資料可放,這裡增加了“int a=5”。

如果在VC上的話,點選執行便能看到結果。為了能看清楚內部到底是如何處理的,我們使用GCC來編譯。

執行

1  gcc hello.c

再看我們的目錄下,就多了目標檔案a.out。
2012030416254326

現在我們想做的是看看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選項就可以檢視目標檔案的十六進位制格式。

檢視結果如下:

2012030416293749
如上圖所示,列出了各段的十六進位制表示形式。可以看出圖中共分為兩欄,左邊的一欄是十六進位制的表示, 右邊則顯示相應的資訊。比較明顯的如“rodata”只讀資料段中就有 “hello world”。。汗,好像程式裡的“hello”打錯了,後面多加了一個“w”,截圖麻煩。原諒下哈。

你也可以檢視“hellow world”的ASCII值,對應的十六進位制就是裡面的內容了。“comment”上文中說的這個段包含了一些編譯器的版本資訊,這個段後面的內容就是了:GCC編譯器,後面的是版本號。

a.out反彙編
編譯的過程總是先把源文先變為彙編形式,再翻譯為機器語言。(新增中間層嘛)看了這麼多的a.out,再研究一下他的彙編形式是恨必要的

objdump -d a.out可以列出檔案的彙編形式。不過這裡只列出了主要部分,即main函式部分,其實在main函式執行的開始和main函式執行以後都還有多工作要做。即初始化函式執行環境以及釋放函式佔用的空間等。

2012030416311179

上面的圖中,左邊是程式碼的十六進位制形式,左邊是彙編形式。對彙編熟悉的童鞋應該能看懂大部分,這裡就不在多述。

a.out標頭檔案
在介紹目標檔案格式的時候,提到過標頭檔案這個概念,裡面包含了這個目標檔案的一些基本資訊。如該檔案的版本、目標機器型號、程式入口地址等等。

下圖是檔案頭的形式:
可以用readelf -h 來檢視。(下圖中檢視的是 hello.o,它是原始檔hello.c編譯但未連結的檔案。 這個和檢視a.out 大部分是一樣的)

2012030416324049

圖中分為兩欄,左邊一欄表示的是屬性,右邊是屬性值。第一行常被稱為魔數。後面是一連串的數字,其中的具體含義就不多說了,可以自己去google。

接下來的是一些和目標檔案相關的資訊。由於和我們要討論的問題關係不大,這裡就不展開討論了。

上面是內容用具體的例項說了目標檔案內部的組織形式,目標檔案只是產生可執行檔案過程中的一箇中間過程,對於程式是如何執行的還沒做討論,目標檔案是如何轉變為可執行檔案以及可執行檔案是如何執行的將在下面的部分中討論

對連結的簡單認識

連結通俗的說就是把幾個可執行檔案。如果程式A中引用了檔案B中定義的函式,為了A中的函式能正常執行,就需要把B中的函式部分也放在A的原始碼中,那麼將A和B合併成一個檔案的過程就是連結了。有專門的過程用來連結程式,稱為連結器。他將一些輸入的目標檔案加工後合成一個輸出檔案。這些目標檔案中往往有相互的資料、函式引用。

上文中我們看過了hello world的反彙編形式,是一個還沒有經過連結的檔案,也就是說當引用外部函式的時候是不知道其地址的,如下圖:

2012030512415488

上圖中,cal指令就是呼叫了printf()函式,因為這時候printf()函式並不在這個檔案中,所以無法確定它的地址,在十六進位制中就用“ff ff ff ”來表示它的地址。等經過連結以後,這個地址就會變為函式的實際地址,應為連線後這個函式已經被載入進入這個檔案中了。

連結的分類:按把A相關的資料或函式合併為一個檔案的先後可以把連結分為靜態連結和動態連結。

靜態連結:
在程式執行之前就完成連結工作。也就是等連結完成後檔案才能執行。但是這有一個明顯的缺點,比如說庫函式。如果檔案A 和檔案B 都需要用到某個庫函式,連結完成後他們連線後的檔案中都有這個庫函式。當A和B同時執行時,記憶體中就存在該庫函式的兩份拷貝,這無疑浪費了儲存空間。當規模擴大的時候,這種浪費尤為明顯。靜態連結還有不容易升級等缺點。為了解決這些問題,現在的很多程式都用動態連結。

動態連結:和靜態連結不一樣,動態連結是在程式執行的時候才進行連結。也就是當程式載入執行的時候。還是上面的例子 ,如果A和B都用到了庫函式Fun(),A和B執行的時候記憶體中就只需要有Fun()的一個拷貝。
關於連結還有很多知識,以後會用專門的文章來談。這裡就不展開講了。

對裝載的簡單解釋

我們知道,程式要執行是必然要把程式載入到記憶體中的。在過去的機器裡都是把整個程式都載入進入實體記憶體中,現在一般都採用了虛擬儲存機制,即每個程序都有完整的地址空間,給人的感覺好像每個程序都能使用完成的記憶體。然後由一個記憶體管理器把虛擬地址對映到實際的實體記憶體地址。

按照上文的敘述, 程式的地址可以分為虛擬地址和實際地址。虛擬地址即她在她的虛擬記憶體空間中的地址,實體地址就是她被載入的實際地址。

2012030512583948

 在上文中檢視段 的時候或許你已經注意到了,由於檔案是未連結、未載入的,所以每個段的虛擬地址和實體地址都是0.

載入的過程可以這樣理解:先為程式中的各部分分配好虛擬地址,然後再建立虛擬地址到實體地址的對映。其實關鍵的部分就是虛擬地址到實體地址的對映過程。程式裝在完成之後,cpu的程式計數器pc就指向檔案中的程式碼起始位置,然後程式就按順序執行。

小結一下

寫這篇文章的目的在於梳理程式執行的機制,在一個可執行檔案執行的背後都隱藏了什麼。從原始碼到可執行檔案通常要經歷許多中間步驟,每一箇中間步驟都生成一箇中間檔案。只是現在的整合開發環境都吧這些步驟影藏了,習慣於整合開發環境的我們也就逐漸的忽略了這些重要的技術內幕。

這篇文章也只是介紹了一下這個過程的主線而已。其中的每一個細節展開來講都可足已用一篇文章來論述。上面寫的多是我個人的理解和看法。有不足的地方、還望能不吝賜教。