一般來講,應用程序使用的內存空間里有如下的默認區域:
1 棧:用於維護函數調用的上下文。棧通常在用戶空間的最高地址出分配,通常有數兆字節的大小
2 堆:堆是用來容納應用程序動態分配的內存區域。比如使用malloc和new分配內存就從堆里分配。
3 可執行文件鏡像:這里存儲着可執行文件在內存里的映射
首先來介紹棧:
在操作系統中,棧總是向下增長的,棧頂由稱為esp的寄存器進行定位,壓棧的操作使棧頂的地址減小,彈出的操作使棧頂的地址增大。棧保存了一個函數調用所需要維護的信息,這通常稱為堆棧幀或活動記錄。堆棧幀包括如下幾個方面的內容:
1 函數返回地址和參數
2 臨時變量:包括函數的非靜態局部變量以及編譯器自動生成的其他臨時變量
3 保存的上下文:包括在函數調用前后需要保持不變的寄存器。
在i386中,一個函數的活動記錄用ebp和esp這兩個寄存器划定范圍。esp寄存器始終指向棧的頂部,ebp寄存器指向了函數活動記錄的一個固定位置,ebp寄存器又稱為幀指針。常見的活動記錄如下圖所示:
在ebp之前首先是這個函數的返回地址,它的地址是ebp-4, 再往前是壓入棧中的參數,它們的地址分別是ebp-8,ebp-12等等。ebp所直接指向的的數據是調用該函數前ebp的值,這樣函數在返回的時候,ebp可以讀取這個值恢復到調用前的值。所以一個i386的程序調用順序如下:
1 把所有或者一部分參數壓入棧中,如果有其他參數沒有入棧,那么使用某些特定的寄存器傳遞
2 把當前指令的下一條指令的地址壓入棧中
3 跳轉到函數體執行
其中2,3由執行call一起執行。跳轉到函數體之后就開始執行函數。I386函數體的標准開頭過程如下:
1 push ebp: 把ebp壓入棧中,也就是old ebp
2 move ebp,esp: ebp=esp(ebp指向棧頂,此時棧頂就是old ebp)
3 sub esp,xxx 在棧上分配xxx字節的臨時空間
4 push xxx 保存名為xxx的寄存器
把ebp壓入棧中,是為了在函數返回的時候便於恢復以前的ebp值,函數返回的時候過程正好相反。
1 pop xxx
2 mov esp,ebp 恢復esp同時收回局部變量空間
3 pop ebp:從棧中恢復保存的ebp的值
4 ret: 從棧中取得返回地址,並跳轉到該位置。
我們用一個簡單的函數調用然后查看匯編代碼來看下這個過程
#include <stdio.h>
int foo()
{
return 123;
}
int main()
{
int ret;
ret=foo();
return 1;
}
objdump –d stack_test.o 可以看到如下結果
00000000000005fa <foo>:
5fa: 55 push %ebp
5fb: 48 89 e5 mov %esp,%ebp
5fe: b8 7b 00 00 00 mov $0x7b,%eax
603: 5d pop %ebp
604: c3 retq
在main中首先是將ebp進棧,然后是將esp賦值為ebp。同時將esp減去0x10,也就是開辟了0x10的棧空間。同樣的過程在foo對應的匯編也可以看到。mov $0x7b,%eax 這條指令是將返回值123賦值給eax,同時將ebp值出棧
0000000000000605 <main>:
605: 55 push %ebp
606: 48 89 e5 mov %esp,%ebp
609: 48 83 ec 10 sub $0x10,%esp
60d: b8 00 00 00 00 mov $0x0,%eax
612: e8 e3 ff ff ff callq 5fa <foo>
617: 89 45 fc mov %eax,-0x4(%ebp)
61a: b8 01 00 00 00 mov $0x1,%eax
61f: c9 leaveq
620: c3 retq
我們在把函數變更下使得foo函數帶參數
#include <stdio.h>
int foo(int i, int j)
{
return 123;
}
int main()
{
int ret;
ret=foo(1,2);
return 1;
}
再看下匯編代碼:可以看到參數i和j的入棧過程,首先在main中將參數值分別賦給esi和edi寄存器。然后在foo中分別將edi和esi的值存入到ebp+0x04和ebp-0x08的地址中。
00000000000005fa <foo>:
5fa: 55 push %ebp
5fb: 48 89 e5 mov %esp,%ebp
5fe: 89 7d fc mov %edi,-0x4(%ebp)
601: 89 75 f8 mov %esi,-0x8(%ebp)
604: b8 7b 00 00 00 mov $0x7b,%eax
609: 5d pop %ebp
60a: c3 retq
000000000000060b <main>:
60b: 55 push %ebp
60c: 48 89 e5 mov %esp,%ebp
60f: 48 83 ec 10 sub $0x10,%esp
613: be 02 00 00 00 mov $0x2,%esi
618: bf 01 00 00 00 mov $0x1,%edi
61d: e8 d8 ff ff ff callq 5fa <foo>
622: 89 45 fc mov %eax,-0x4(%ebp)
625: b8 01 00 00 00 mov $0x1,%eax
62a: c9 leaveq
62b: c3 retq
62c: 0f 1f 40 00 nopl 0x0(%rax)
以一個框圖來表示調用關系
函數返回值傳遞
除了參數的傳遞外,函數與調用方的另一個交互就是返回值。前面可以看到eax寄存器是傳遞返回值的通道。但是eax本身只有4個字節,那么大於4字節的返回值是如何傳遞的呢。對於返回5-8字節的情況,需要聯合eax和edx聯合返回的方式進行。eax存儲低4字節,edx存儲高4字節。但是對於超過8個字節的情況,就比較復雜了。以下面的例子為例:
#include <stdio.h>
typedef struct big_thing
{
char buf[128];
}big_thing;
big_thing return_test()
{
big_thing b;
b.buf[0]=0;
return b;
}
int main()
{
big_thing n=return_test();
}
對應的匯編代碼: 將棧上的一個地址ebp-1D0h存儲在eax中,然后將eax入棧,再調用return_test
lea eax,[ebp-1D0h]
push eax
call _return_test
這就相當於將eax的值作為了return_test的參數。但是實際上return_test是沒有參數的,因此這個也被稱為隱含參數。
下面這4行是一個整體,rep movs是一個復合指令,意思是重復movs指令知道ecx寄存器為0,於是rep movs a,b的意思就是將b指向位置的若干個雙字節拷貝到a指向的位置上。相當於memcpy(ebp-88h,eax,0x20*4) ebp-88h就是n的地址
mov ecx ,20h
mov esi,eax
lea edi,[ebp-88h]
rep movs dword ptr es:[edi],dword ptr [esi]
再來看下return_test的實現。
lea esi, [ebp-88h]
mov edi,dword ptr [ebp+8]
rep movs dword ptr es:[edi], dword ptr [esi]
ebp+8指向函數的參數,ebp-88h指向的是變量b的位置,因此rep movs dword ptr es:[edi], dword ptr [esi]相當於memcpy([ebp+8],&b,128)。也就是將變量b地址的內容拷貝到傳入的參數也就是ebp-1D0h這個地址去。
那么整個流程如下:
1 main在棧中開辟一段空間,並將這塊空間的一部分作為傳遞返回值的臨時對象,稱為temp
2 將temp對象的地址作為隱藏參數傳遞給return_test參數
3 return_test將數據拷貝到temp對象
4 return_test返回之后,main函數將eax指向的temp對象拷貝給n