課程總目錄
文章目錄
- 一、進程的虛擬地址空間內存劃分和布局
- 二、函數的調用堆棧詳細過程
- 三、程序編譯鏈接原理
- 1. 編譯過程
- 2. 鏈接過程
一、進程的虛擬地址空間內存劃分和布局
任何的編程語言 → \to → 產生兩種東西:指令和數據
編譯鏈接完成之后會產生一個可執行文件xxx.exe
,會把程序從磁盤加載到內存中,不可能直接加載到物理內存!!!
環境: x86 32位linux環境
程序:
int gdata1 = 10;
int gdata2 = 0;
int gdata3;static int gdata4 = 11;
static int gdata5 = 0;
static int gdata6;int main()
{int a = 12;int b = 0;int c;static int e = 13;static int f = 0;static int g;return 0;
}
linux系統會給當前進程分配一個 232(4G)大小的一塊空間(進程的虛擬地址空間),大小和環境的位數相關,如果是64位,則為8G
注意區分虛擬內存和虛擬地址空間,這是兩個不同的概念
-
0x00000000 ~ 0x08048000
這段無法被訪問,如果運行char *p = nullptr;strlen(p);
則會報錯,因為空指針在這段區域,char *src = nullptr;strcpy(dest, src);
也會報錯 -
0x08048000 ~ 0xC0000000
- .text(代碼段): 放指令(只讀)。main函數中的三個初始化 a, b, c 語句,都會轉化為一條mov指令,如
mov dword ptr[a], 0xCH
,如果cout << c
,此時的c
是什么不確定(參考文章),它是棧上的無效值;int main(){}
以及cout << c << g << endl;
都是指令,都存放在 .text中
int a = 12;
這條語句不產生符號,只產生對應的匯編指令,對應指令存放在 .text上,但是當指令運行的時候,指令做的是在棧上開辟4字節的空間將12放進去- .rodata: 只讀數據read only。
char *p = "hello world";
其中p
在棧上,常量字符串"hello world"
就存儲在 .rodata段,但是如果*p = 'a';
,通過指針讓常量字符串的第一個字符修改為a
,可以編譯但不能運行,因為這一部分是只讀的 - .data(數據段): 用于存儲已經初始化并且不為0的全局變量和靜態變量,這些變量在程序運行之初就有了確定的初始值,在程序執行之前就會被初始化,因此需要分配實際的存儲空間。
[gdata1 & gdata4 & e]
- .bss: 用于存儲未初始化和已經初始化為0的全局變量和靜態變量。
[gdata2 & gdata3 & gdata5 & gdata6 & f & g]
此時
cout << gdata3 << endl;
輸出為0,因為gdata3
存放在 .bss段。操作系統會把沒初始化的變量全部置為0- .heap:堆
- 加載共享庫:在window系統中是
*.dll
,在linux中是*.so
- stack:棧,函數運行或產生線程時,產生的棧空間,從下往上(高地址向地地址)進行增長
- 命令行參數和環境變量
- .text(代碼段): 放指令(只讀)。main函數中的三個初始化 a, b, c 語句,都會轉化為一條mov指令,如
在 Linux 中,進程在內存中一般會分為五個段,包含了從磁盤載入的程序代碼以及其他數據。即代碼段、數據段、BSS段、堆、棧
- 0xC0000000 ~ 0xFFFFFFFF
- 內核空間
每一個進程的用戶空間是私有的,但是內核空間是共享的。例如匿名管道通信,就是在內核空間中分配出一部分內存,進程1往里寫內容,進程2和3都能看見。
二、函數的調用堆棧詳細過程
int sum(int a, int b)
{int temp = 0;temp = a + b;return temp;
}int main()
{int a = 10;int b = 20;int ret = sum(a, b);cout << "ret:" << ret <<endl;return 0;
}
問題一:main函數調用sum,sum執行完后,怎么知道回到哪個函數
問題二:sum函數執行完,回到main函數后,怎么知道從哪一行指令繼續運行
程序分析:
int a = 10;
→ \to → mov dword ptr[ebp-04H], 0AH
int b = 20;
→ \to → mov dword ptr[ebp-08H], 14H
int ret = sum(a, b);
編譯后會將位置為ptr[ebp-0Ch]
命名為ret
,之后是調用函數,先從右向左向棧頂壓入形式參數a和b,同時esp也會隨之移到棧頂,即
mov eax, dword ptr[ebp-08H]
push eax
mov eax, dword ptr[ebp-04H]
push eax
call sum // 函數調用指令,會做兩件事,將下一條命令的地址(0x08124458)壓棧,進入sum
// sum函數返回后
add esp, 8 // 本條指令地址(假如地址為0x08124458)將給形參分配的地址交還給系統
mov dword ptr[ebp-0CH], eax // 將結果放到ret中
由此也可見,在函數調用過程中,形參的內存開辟是在調用函數時就分配好的
進入sum函數,在int temp = 0;
執行之前,即左括號{
和int temp = 0;
之間,會執行下面的匯編代碼
push ebp // 此時ebp指向main函數棧幀的棧底,把此地址記錄下來
mov ebp, esp // 把esp賦給ebp,此時ebp指向sum函數棧幀的棧底
sub esp, 4CH // 給sum函數開辟棧幀空間
int temp = 0;
→ \to → mov dword ptr[ebp-04H], 0
temp = a + b;
mov eax, dword ptr[ebp+0CH] // 取形參b的值存到eax
add eax, dword ptr[ebp+08H] // 取形參a的值,和b相加,存到eax
mov dword ptr[ebp-04H], eax // a+b結果存到temp
return temp;
→ \to → mov eax, dword ptr[ebp-04H]
右括號}
,回退棧幀
mov esp, ebp // 把ebp賦給esp,把棧空間歸還給系統,但并未清空棧中內容
pop ebp // 出棧,并把棧里的數值給ebp,即退回main函數棧幀的棧底,同時esp+4
ret // 出棧,把出棧內容(0x08124458)放在CPU的PC寄存器中,同時esp+4
返回main函數中
// sum函數返回后
add esp, 8 // 本條指令地址(假如地址為0x08124458)將給形參分配的地址交還給系統
mov dword ptr[ebp-0CH], eax // 將結果放到ret中
之后再打印,return,結束程序
注:
數值 ≤ 4B,通過eax寄存器帶出
4B < 數值 <= 8B,通過eax和edx兩個寄存器帶出
數值 > 8B,函數調用之前產生臨時量,再把臨時量地址入棧,被調用函數return處通過偏移ebp訪問臨時量。
三、程序編譯鏈接原理
編譯過程: 預編譯 → \to → 編譯 → \to → 匯編 → \to → 二進制可重定位的目標文件(*.obj / *.o)
鏈接過程: 編譯完成的所有.o文件 + 靜態庫文件(Linux下是*.a,Windows下是*.lib)
兩個核心步驟:(1)所有.o文件段的合并;符號表合并后,進行符號解析
???????(2)符號的重定位(重定向)【鏈接的核心】
最終在工程目錄下 → \to → win下得到xxx.exe
,Linux下得到a.out
我們需要關注的點:
*.o
文件的格式組成是什么樣子的?- 可執行文件的組成格式是什么樣子的?
- 鏈接的兩步做的是什么事情?
- 符號表的輸出 → \to → 符號,符號怎么理解?
- 符號什么時候分配虛擬地址(在用戶空間上)?
程序:
main.cpp:
//引用sum.cpp文件里面定義的全局變量以及函數
extern int gdata;
int sum(int, int);int data = 20;int main()
{int a = gdata;int b = data;int ret = sum(a, b);return 0;
}
sum.cpp:
int gdata = 10;
int sum(int a, int b)
{return a+b;
}
1. 編譯過程
C++文件 | 預編譯 | 編譯 | 匯編 | 二進制可重定位的目標文件(*.obj / *.o) |
---|---|---|---|---|
main.cpp sum.cpp | 處理# 開頭的命令 | 語法分析、語義分析、詞法分析、代碼優化 用 g++ -O 0/1/2/3 指定優化等級 | 編譯完成之后生成特定架構下的匯編代碼 | main.o sum.o |
預編譯階段:#pragma lib 和 #pragma link 例外,不是在預編譯階段完成的,而是在鏈接階段完成的,這倆是用于處理鏈接階段的外部庫文件
現在來看我們的程序
首先進行編譯g++ -c xxx.cpp
符號表:匯編器在把匯編碼轉成最終的.o
文件時就會生成一個符號表
看一下符號表objdump -t xxx.o
可以看到左邊全為0,即編譯過程中符號不分配虛擬地址,在鏈接過程中分配虛擬地址
分析:
如果引用了外部文件,也會將外部文件中的符號產生在自己的符號表中。如果定義了main函數,則在符號表中函數的符號就是函數名,放在.text
(代碼段);定義了全局變量data且值為20不等于0,因此放在.data
(數據段);引用的gdata也產生了符號gdata,sum也產生了符號_z3sumii,但他們都是*UND*
,這是符號的引用,而不是符號的定義。
在sum.o
文件的符號表中中,需要由函數名字和形參列表一起產生符號,例如這里的sumii
解釋為sum_int_int
符號表的第二列,l
表示local
,local
的符號只能在當前文件中看見;g
表示global
,global
的符號在其他文件也看得見。因此在鏈接時,所有.obj
文件在一起鏈接,鏈接器可以看見所有global
的符號,但看不見local
符號。
.o
文件的組成,可以用readelf -S main.o
打印段表,用readelf -h main.o
打印文件頭(節頭部表):
回答問題1:*.o 文件的格式組成是什么樣子的?
答:由上圖可見,是由各種段組成的(elf文件頭
.text
.data
.bss
.symtab
等等)
編譯完成后,.o
文件代碼段放入的指令如下,此時符號的地址位置填充的是0,這也是.o
文件無法運行的原因之一,可以用objdump -S main.o
打印代碼段
2. 鏈接過程
步驟一:
- 所有
.o
文件段的合并:在鏈接過程中,就要將main.o
和sum.o
的各個段進行合并,如.text
段和.text
段進行合并,.data
段和.data
段進行合并,.bss
段和.bss
段進行合并。包括段表和符號表,全部都進行合并。 - 符號表合并后,進行符號解析:所有對符號的引用,都要找到該符號定義的地方。從原本的
*UND*
找到對應的在.text
和.data
上的定義。如果鏈接器沒有找到對引用符號的定義,會報錯“符號未定義”
;如果找到多個對符號的定義(重定義),會報錯“符號重定義”
。在符號解析成功后,給所有的符號分配虛擬地址。
步驟二:
- 符號的重定位(重定向):將代碼段中的對應符號地址修改為為其分配的虛擬地址。
鏈接器指定入口并進行鏈接ld -e main *.o
,其中-e
是指定main作為入口,這樣在鏈接生成的輸出文件a.out
文件的文件頭會將main函數的第一行地址401000
作為入口點地址進行記錄
objdump -t a.out
可以看到所有符號都分配地址了,都放到對應的位置了
objdump -S a.out
readelf -S a.out
回答問題2:可執行文件的組成格式是什么樣子的?
答:由上圖可見,可執行文件也是由各種段組成的
readelf -h a.out
可以看到這是可執行文件,入口是main函數的第一行地址
401000
readelf -l a.out
可執行文件的段和重定向文件的段幾乎一致,只是多了一個program headers段,可用
readelf -l a.out
打印。運行可執行文件的時候,program headers段中LOAD哪些段,就是告訴系統把哪些段加載到內存中,如上圖,一般會將.text段和.data段加載到內存中
運行一個可執行文件:
- 加載哪些內容 → \to → 看program headers段
- 從哪里開始運行 → \to → 文件頭中的入口地址