1. 程式人生 > >從編寫原始碼到程式在記憶體中執行的全過程解析

從編寫原始碼到程式在記憶體中執行的全過程解析

        作為一個C/C++程式設計師,搞清楚從編寫原始碼到程式執行過程中發生的細節是很有必要的。這在之前也是困擾我的一個很大問題,因為最近在忙著找實習,一直沒有下定決心來寫這篇部落格,最近才抽時間寫。下面的程式碼除了明顯貼出來的以外,其他的都以下面的程式碼為例進行說明:

int gdata1 = 10;
int gdata2 = 0;
int gdata3;

static int gdata4 = 11;
static int gdata5 = 0;
static int gdata6;

int main(void)
{
	int a = 12;
	int b = 0;
	int c;

	static int d = 13;
	static int e = 0;
	static int f;
	return 0;
}

一、基本概念

1.什麼是資料

        大家平時口中經常說程式是由程式程式碼、資料和程序控制塊組成,但是很多人卻不知道什麼是資料。這裡我們搞清楚兩件事情,一是什麼是資料,二是資料存放在哪裡。

(1)資料

        資料指的是稱序中定義的全域性變數和靜態變數。還有一種特殊的資料叫做常量。所以上面的的gdata1、gdata2、gdata3、gdata4、gdata5、gdata6、d、e和f均是資料。

(2)資料存放在哪裡

        資料存放的區域有三個地方:.data段、.bss段和.rodata段。那麼你肯定想知道資料是如何放在這三個段中的,怎麼區分。

        對於初始化不為0的全域性變數和靜態變數存放在.data段,即gdata1、gdata4和d存放在.data段;對於未初始化或者初始化值為0的段存放在.bss段中,而且不佔目標檔案的空間,即gdata2、gdata3、gdata5、gdata6、e和f存放在.bss段。文章下面有一張關於符號表的圖,大家可以看到確實是這樣的分佈。

        而對於字串常量則存放在.rodata段中,而且對於字串而言還有一個特殊的地方,就是它在記憶體中只存在一份。下面給個程式碼來測試:

#include<stdio.h>
int main(void)
{
	const char *pStr1 = "hello,world";
	const char *pStr2 = "hello,world";
	printf("0x%x\n", pStr1);
	printf("0x%x\n", pStr2);
	return 0;
}

        大家可以驗證一下,輸出的地址肯定是一樣的。因為常量字串“hello,world”只存在一份。

2.什麼是指令

       說完了資料,那什麼是指令呢?也就是什麼是程式程式碼。很簡單,程式中除了資料,剩下的就都是指令了。這裡有一個容易混淆的地方,如下面的程式碼:

#include<stdio.h>
int main()
{
    int a = 10;
    int b = 20;
    printf("a+b=%d\n", a + b);
    return 0;
}
        大家可能會有一個疑問,就是對於上面的程式碼,a和b明明是區域性變數,難道不是資料嗎?嗯,它真的不是資料,它是一條指令,這條指令的功能是在函式的棧幀上開闢四個位元組,並向這個地址上寫入指定值。

3. 什麼是符號

        說完資料和指令,接下來是另一個基礎而且重要的概念,那就是符號。我們在編寫程式完,進行連結時會碰到這樣的錯誤:"錯誤       LNK1169    找到一個或多個多重定義的符號",即符號重定義。那什麼是符號,什麼東西會產生符號,符號的作用域又是怎樣的呢?

在程式中,所有資料都會產生符號,而對於程式碼段只有函式名會產生符號。而且符號的作用域有global和local之分,對於未用static修飾過的全域性變數和函式產生的均是global符號,這樣的變數和函式可以被其他檔案所看見和引用;而使用static修飾過的變數和函式,它們的作用域僅侷限於當前檔案,不會被其他檔案所看見,即其他檔案中也無法引用local符號的變數和函式。

        對於上面的 “找到一個或多個多重定義的符號” 錯誤原因有可能是多個檔案中定義同一個全域性變數或函式,即函式名或全域性變數名重了。

4.虛擬地址空間佈局

        對於32位作業系統,每個作業系統都有2^32位元組的虛擬地址空間,即4G的虛擬地址空間。這4G的虛擬地址空間分為兩個大部分:每個程序獨立的3G的使用者空間,和所有程序共享的1G的核心空間。具體分佈如下圖:


        這裡提個面試時碰到的問題,就是面試官問我為什麼前128M是不可訪問的,而不是256M?當時沒答上來,回來後在網上查了查,而且查了資料,沒有找到很好的解釋,如果你知道,請在文章下方留言告訴我一下哈。

二、編譯過程

1.編譯

        整個編譯分為四個步驟:首先編寫原始檔main.c/main.cpp;編寫好程式碼以後進行預編譯成main.i檔案,預編譯過程中去掉註釋、進行巨集替換、增加行號資訊等;然後將main.i檔案經過語法分析、程式碼優化和彙總符號等步驟後,編譯形成main.S的彙編檔案,裡面存放的都是彙編程式碼;最後一個編譯步驟是進行彙編,從main.S變成二進位制可衝定位目標檔案main.o。

        以上四個步驟對應的在linux下的命令為:

gcc -E main.c -o main.i  #預編譯,生成main.i檔案
gcc -S main.i            #編譯,生成main.S檔案
gcc -c main.S            #彙編,生成main.o檔案
gcc main.o -o main       #連結,生成可執行檔案

2.二進位制可重定位目標檔案的結構和佈局

        首先給出一個二進位制可重定位目標檔案(linux下是*.o檔案,windows中是*.obj檔案)的總體佈局,簡單來說整個obj檔案就是由ELF header+各種段組成:


         二進位制可重定位檔案的頭部,可以看到ELF header佔64個位元組,裡面存放著檔案型別、支援的平臺、程式入口點地址等資訊,如果你對每個欄位的具體含義感興趣,可以看《程式設計師自我修養》:


        接下來就是目標檔案的各個段,從下面可以看到資料和指令在目標檔案中是按段的形式組織起來的,而且.text段的起始位置從file off欄位可以看到是0x40位置,即64位元組處,也說明.text段是接在ELF header後面。

        程式碼段的大小為0x19,起始偏移為0x40,所以.data段的起始偏移應該為0x19+0x40=0x59,但是為了位元組對齊,所以。data段的起始地址為0x5c,也即圖中file off欄位所示,後面的段以此類推。

        之後的.bss段會出現兩個問題,一個是.bss段的大小應該為4*6=24位元組,但是實際上卻是20位元組;另一個問題就是可以看到.comment段的偏移(file off)也為0x68,這說明.bss段在目標檔案中是不佔大小的,即.comment和.bss段的偏移相同。對於這兩個問題,我這裡不作詳細介紹,簡單說一下。第一個問題,涉及到C語言中的強符號和弱符號概念;第二個問題我們可以這樣理解,因為.bss段中存的是初始化為0或者未初始化的資料,而實際未初始化的資料其預設值也為0,這樣我們就沒必要存它們的初始值,相當於有一個預設值0。

        上面的圖只列出了部分段,下面檢視一下目標檔案中所有的段,一共有11個段,簡單說明一下,.comment是註釋段、.symtab是符號表段。

,其中

        接下來就是看段的詳細內容,可以看到各個段真實的儲存內容如下,下面最明顯的是.data段,裡面存放著gdata1、gdata4和d的值分配為0x0000000a(10)、0x0000000b(11)和0x0000000d(13),正好與程式碼中的初始值匹配。注意下面顯示的小端模式。


        以上就是可重定位目標檔案的組成,下面再介紹一下上面提到的符號表如下圖,第一列是符號的地址,由於編譯的時候不分配地址,所以放的是零地址或者偏移量;第二列是符號的作用域(g代表global,l代表local),前面討論了用static修飾過的符號均是local的(不明白的搜一下static關鍵字的作用),如下圖中gdata4/gdata5/gdata6等;第三列表示符號位於哪個段,在這裡也能看到gdata1、gdata4和d都存放在.data段中,初始化為0或未初始化的gdata2/gdata5/gdata6等都存放在.bss段:


        這裡特別說一下gdata3,按上面的分析來說它應該是存放在.bss段,但是我們可以看到它是*COM*,原因在於它是一個弱符號,在編譯時無法確定有沒有強符號會覆蓋它。

        以上就是編譯的詳細過程,不明白的歡迎大家留言,下面再來介紹連結。

三、連結過程

1.連結

        連結過程分為兩步,第一步是合併所有目標檔案的段,並調整段偏移和段長度,合併符號表,分配記憶體地址;第二步是連結的核心,進行符號的重定位。

(1)合併段

       所有相同屬性的段進行合併,組織在一個頁面上,這樣更節省空間。如.text段的許可權是可讀可執行,.rodata段也是可讀可執行,所以將兩者合併組織在一個頁面上;同理合併.data段和.bss段。

(2)合併符號表

       連結階段只處理所有obj檔案的global符號,local符號不作任何處理。

(3)符號解析

       符號解析指的是所有引用符號的地方都要找到符號定義的地方。

(4)分配記憶體地址

       在編譯過程中不分配地址(給的是零地址和偏移),直到符號解析完成以後才分配地址。如下圖,資料的零地址:

                                          

(5)符號重定位

        因為在編譯過程中不分配地址,所以在目標檔案所以資料出現的地方都給的是零地址,所有函式呼叫的地方給的是相對於下一條指令的地址的偏移量。在符號重定位時,要把分配的地址回填到資料和函式調用出現的地方,而且對於資料而言填的是絕對地址,而對函式呼叫而言填的是偏移量。

                                                   

        從上圖中我們可以看到gdata1等變數的地址不再是0,而是0x080490e4,正確回填了絕對地址。

四、可執行程式

        連結完成以後形成了可執行檔案,下面來解析可執行檔案是如何執行起來的。同樣,首先給出可執行檔案的總體佈局,然後再來深入解析。

    

        首先看一下可執行檔案的頭部,如下圖,裡面記錄了函式的入口點地址為0x08048094(後面會解釋這個值的來由),還有就是size of this headers,程式頭部佔52個位元組,然後還有三個program headers,每個program headers佔32位元組,共佔3*32=96位元組,所以程式頭部+program heades=52+96=0x94,而從虛擬地址空間佈局可知.text段正好是從0x08048000開始的,所以可執行程式的入口點就是0x08048000+0x94=0x08048094:


        然後看看這三個program headers裡面的內容,第一個load項的屬性是可讀可執行,其實存放的就是程式碼段;第二個load項的屬性是可讀可寫,其實存放的就是資料段。這兩個load項的意義在於它指示了哪些段會被載入到同一個頁面中:


            可以看到這兩個load項的對齊方式是頁面對齊(32位linux作業系統頁面大小為4K)。

         當雙擊一個可執行程式時,首先解析其檔案頭部ELF header獲取entry point address程式入口點地址,然後按照兩個load項的指示將相應的段通過mmap()函式對映到虛擬頁面中(虛擬頁面存在於虛擬地址空間中),最後再通過多級頁表對映將虛擬頁面對映到物理頁面中。


        說完編譯連結,最後說明如何將VP對映到PP就打工告成了。

分為三步,1.首先是建立虛擬地址到實體記憶體的對映(建立核心地址對映結構體),建立頁目錄和頁表;2. 再就是載入程式碼段和資料段;3.把可執行檔案的入口地址寫到CPU的PC暫存器中。

五、地址對映過程

        實驗環境是在32位Linux作業系統下的虛擬地址對映過程。先將邏輯地址通過GDTR/LDTR轉換為線性地址(也叫虛擬地址),然後再通過多級頁表對映(32位地址需要兩級頁表對映)將線性地址轉換為實體地址。       

        以某個函式中區域性變數的地址對映過程為例進行說明。

        我們知道在保護模式下,區域性變數存放在棧中,而棧的資訊存放在棧暫存器SS中,首先我們通過棧暫存器的低兩位判斷是存在使用者空間中還是核心空間中,應用程式肯定是在使用者空間中。然後通過第3位判斷使用的是LDT(區域性段描述符表)還是GDT(全域性段描述符表),實驗發現32位Linux下使用的是LDT,此時SS的高13位則作為索引,判斷該區域性變數的存放的段的資訊在LDT的哪一項。

        GDT中存放的是LDT每一項的具體資訊,如LDT的其實地址等資訊。此時要根據LDTR來找到該資訊存放到了GDT的哪一項,此時可以通過LDTR作為GDT的索引,找到LDT的起始地址。

        找到LDT的起始地址以後,再根據SS暫存器中的高13位作為索引,找到段的存放資料的段的起始地址(32位),將起始地址加上偏移量即可得到線性地址。那這個偏移量又怎麼得到呢,很簡單,這個偏移量也就是我們所謂的邏輯地址,也是CPU發出來的地址,我們可以通過在程式中對該區域性變數取地址即可得到。

        得到線性地址以後,檢視CR0暫存器的最高位PG位,這一位為0表示沒有開啟記憶體分頁,如果為1則表示開啟了記憶體分頁。Linux下基本都會開啟記憶體分頁機制。此時得到的線性地址也叫做虛擬地址。這個地址總共32位,分成10+10+12三段,其中高10位地址指示頁目錄項,次高10位地址指示也表項,最後的12位指示該區域性變數在實體記憶體頁面中的偏移量。    

        從線性地址到實體地址的具體對映過程如下。首先根據CR3暫存器中的值得到頁目錄的起始地址,然後根據高10位找到指示的頁表項,再根據次高10位找到對應的物理頁面的起始地址,最後加上低12位的偏移量即可得到區域性變數的實體地址。、