作為一個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位的偏移量即可得到局部變量的物理地址。、