一支編譯後的程式,其記憶體被分成五個區段:text、data、bss、heap、stack。每個區段代表為特定目的保留的一塊記憶體。
text 區段#
text 區段有時也叫 code 區段,存放程式組譯後的機器語言指令。
由於高階控制結構與函式會被編譯成組合語言的分支、跳躍與呼叫指令,這個區段中的指令執行是非線性的。程式執行時 EIP 被設到 text 區段的第一條指令,接著處理器進入以下執行迴圈:
- 讀取 EIP 指向的指令
- 把該指令的位元組長度加到 EIP
- 執行步驟 1 讀到的指令
- 回到步驟 1
有時指令是跳躍或呼叫,會把 EIP 改成另一個記憶體位址。處理器不在意這種改變,因為它本來就預期非線性的執行——若 EIP 在步驟 3 被改變,它只會回到步驟 1,讀取 EIP 新位址上的指令。
唯讀還有另一個好處:這個區段能在程式的不同副本間共享,讓程式可同時多次執行而不出問題。此外,既然裡面的東西從不改變,這個區段的大小是固定的。
data 與 bss 區段#
這兩個區段用來存放全域與靜態變數:
- data 區段:存放已初始化的全域與靜態變數。
- bss 區段:存放未初始化的對應變數。
這兩個區段雖可寫入,大小同樣固定。
還記得全域變數不管在哪個函式脈絡中都持續存在嗎(前面例子中的變數
j)?全域與靜態變數之所以能持續存在,正是因為它們被存在自己專屬的記憶體區段中。
heap 區段#
heap(堆積)是程式設計師能直接控制的記憶體區段:這裡的記憶體區塊可依需要配置與使用。
heap 區段一個值得注意的特點是:它的大小不固定,可依需要變大或變小。
heap 內的所有記憶體由**配置器(allocator)與釋放器(deallocator)**演算法管理——前者保留 heap 中的一塊區域供使用,後者移除保留讓那塊記憶體能被之後的請求重複使用。使用 heap 配置函式的程式設計師因此能即時保留與釋放記憶體。
heap 的成長方向是向下,朝更高的記憶體位址。
stack 區段#
stack(堆疊)同樣大小可變,被當作暫存區使用,存放函式呼叫過程中的區域函式變數與脈絡——GDB 的 backtrace 命令看的就是它。
當程式呼叫函式時,該函式會有自己的一組傳入變數,而函式的程式碼位在 text 區段的另一個記憶體位置。既然呼叫函式時脈絡與 EIP 都必須改變,堆疊就被用來記住:
- 所有傳入的變數
- 函式結束後 EIP 該回到的位置
- 該函式使用的所有區域變數
這些資訊一起存在堆疊上,合稱一個堆疊框架(stack frame)。堆疊中含有許多堆疊框架。
FILO:先進後出#
在一般計算機科學術語中,stack 是一種常用的抽象資料結構,具有**先進後出(FILO,first-in, last-out)**的次序:最先放進堆疊的項目最後才出來。
把它想成把珠子串到一條一端打結的線上——不把其他珠子取下,就拿不到第一顆珠子。放入項目叫 push,取出項目叫 pop。
記憶體的 stack 區段確實就是一個含有堆疊框架的堆疊資料結構。ESP 暫存器用來追蹤堆疊尾端的位址,它隨著推入與彈出不斷改變。既然行為如此動態,stack 自然也不是固定大小。
與 heap 相反,stack 在記憶體的視覺列表中向上成長,朝更低的記憶體位址。
堆疊框架的組成#
FILO 的性質看似古怪,但因為堆疊是用來存放脈絡的,這個特性非常有用。函式被呼叫時,數樣東西會一起被推入堆疊,形成一個堆疊框架。
EBP 暫存器——有時稱為框架指標(FP,frame pointer)或區域基底指標(LB,local base pointer)——用來引用當前堆疊框架中的區域函式變數。每個堆疊框架含有:
- 函式的參數
- 它的區域變數
- 兩個「把東西還原」所必需的指標:
- 已存框架指標(SFP,saved frame pointer):用來把 EBP 還原成先前的值
- 返回位址(return address):用來把 EIP 還原成函式呼叫之後的下一條指令
還原這兩者,就還原了前一個堆疊框架的函式脈絡。
實例:追蹤堆疊框架的建構#
// stack_example.c
void test_function(int a, int b, int c, int d) {
int flag;
char buffer[10];
flag = 31337;
buffer[0] = 'A';
}
int main() {
test_function(1, 2, 3, 4);
}這些變數的記憶體在 stack 區段,而函式程式碼的機器指令則存在 text 區段。反組譯後:
(gdb) disass main
0x08048357 <main+0>: push ebp
0x08048358 <main+1>: mov ebp,esp
0x0804835a <main+3>: sub esp,0x18
0x0804835d <main+6>: and esp,0xfffffff0
0x08048360 <main+9>: mov eax,0x0
0x08048365 <main+14>: sub esp,eax
0x08048367 <main+16>: mov DWORD PTR [esp+12],0x4
0x0804836f <main+24>: mov DWORD PTR [esp+8],0x3
0x08048377 <main+32>: mov DWORD PTR [esp+4],0x2
0x0804837f <main+40>: mov DWORD PTR [esp],0x1
0x08048386 <main+47>: call 0x8048344 <test_function>
0x0804838b <main+52>: leave
0x0804838c <main+53>: ret
(gdb) disass test_function
0x08048344 <test_function+0>: push ebp
0x08048345 <test_function+1>: mov ebp,esp
0x08048347 <test_function+3>: sub esp,0x28
0x0804834a <test_function+6>: mov DWORD PTR [ebp-12],0x7a69
0x08048351 <test_function+13>: mov BYTE PTR [ebp-40],0x41
0x08048355 <test_function+17>: leave
0x08048356 <test_function+18>: ret每個函式開頭那幾條指令合稱程序序言/函式序言(procedure prologue / function prologue):它們把框架指標存到堆疊上,並為區域函式變數保留堆疊記憶體,有時還處理堆疊對齊。確切的序言指令會因編譯器與選項而有很大差異,但總體而言就是在建構堆疊框架。
建構過程#
- 推入引數——
test_function()被呼叫時,函式引數以相反順序推入堆疊(因為是 FILO):先 4、再 3、2,最後 1,分別對應函式中的d、c、b、a。 - 推入返回位址——執行
call指令時,返回位址被推入堆疊,執行流程跳到test_function()起點。返回位址是當前 EIP 之後那條指令的位置(此例為main()中0x0804838b的leave)。 - 存下 SFP——
call同時完成儲存返回位址與跳躍,接著test_function()的序言指令完成堆疊框架:當前 EBP 值被推入堆疊,這就是 SFP。 - 設定新框架指標——當前 ESP 值被複製進 EBP,成為新的框架指標,用來引用函式的區域變數(
flag與buffer)。 - 保留區域變數空間——藉由對 ESP 做減法完成。
用 GDB 觀察,在 test_function() 序言之後檢視堆疊:
(gdb) i r esp ebp eip
esp 0xbffff7c0 0xbffff7c0
ebp 0xbffff7e8 0xbffff7e8
eip 0x804834a 0x804834a <test_function+6>
(gdb) print $ebp-12
$1 = (void *) 0xbffff7dc
(gdb) print $ebp-40
$2 = (void *) 0xbffff7c0
(gdb) x/16xw $esp
0xbffff7c0: 0x00000000 0x08049548 0xbffff7d8 0x08048249
0xbffff7d0: 0xb7f9f729 0xb7fd6ff4 0xbffff808 0x080483b9
0xbffff7e0: 0xb7fd6ff4 0xbffff89c 0xbffff808 0x0804838b
0xbffff7f0: 0x00000001 0x00000002 0x00000003 0x00000004
從堆疊上可以讀出整個框架:
- 最底部是函式的四個引數(
0x00000001到0x00000004)。 - 緊接其上是返回位址(
0x0804838b)。 - 再上面是已存框架指標
0xbffff808,也就是前一個堆疊框架中的 EBP。 - 其餘記憶體保留給區域變數
flag(在$ebp-12,即0xbffff7dc)與buffer(在$ebp-40,即0xbffff7c0)。框架中多出的空間只是填補(padding)。

圖 0x200-1:test_function() 呼叫後堆疊上的堆疊框架結構
執行結束後,整個堆疊框架被彈出堆疊,EIP 被設為返回位址,程式得以繼續執行。若函式內又呼叫了另一個函式,就再推入一個堆疊框架,依此類推。每個函式結束時,它的堆疊框架就被彈出,執行流程回到前一個函式——這正是這段記憶體採用 FILO 結構的原因。
區段的排列方向#
各記憶體區段依前述順序排列,從較低位址到較高位址。
由於多數人習慣看由上往下遞增的編號清單,本書一律把較小的記憶體位址畫在上方。有些書把它反過來,容易造成混淆。多數除錯器也採用本書這種顯示方式。
由於 heap 與 stack 都是動態的,兩者朝彼此相對的方向成長。這能把浪費的空間降到最低:heap 小的時候 stack 就能更大,反之亦然。

圖 0x200-2:五個記憶體區段的排列,heap 向下、stack 向上朝彼此成長
C 中的記憶體區段#
在 C(以及其他編譯語言)中,編譯後的程式碼進入 text 區段,變數則落在其餘區段。變數會被放進哪個區段,取決於它如何被定義:
| 變數種類 | 所在區段 |
|---|---|
| 已初始化的全域/靜態變數 | data |
| 未初始化的全域/靜態變數 | bss |
以 malloc() 配置的記憶體 | heap(通常用指標引用) |
| 其餘函式變數 | stack |
由於堆疊可含許多不同的堆疊框架,堆疊變數能在不同函式脈絡中維持各自的獨立性。
memory_segments.c 印出各類變數的位址,結果一目了然:
global_initialized_var is at address 0x080497ec
static_initialized_var is at address 0x080497f0
static_var is at address 0x080497f8
global_var is at address 0x080497fc
heap_var is at address 0x0804a008
stack_var is at address 0xbffff834
the function's stack_var is at address 0xbffff814
- 前兩個已初始化變數位址最低,因為它們在 data 區段。
static_var與global_var未初始化,落在 bss 區段,位址略大——因為 bss 位於 data 之下。這兩個區段編譯後大小固定,幾乎沒有浪費空間,位址也相距不遠。- heap 變數存在 heap 區段配置的空間中,就在 bss 之下。這個區段的記憶體並不固定,之後還能動態配置更多。
- 最後兩個
stack_var位址非常大,因為它們在 stack 區段。stack 的記憶體同樣不固定,但它從底部開始、向後朝 heap 區段成長。
使用 heap#
使用其他記憶體區段只是「怎麼宣告變數」的問題,但使用 heap 需要多一點功夫。
malloc():接受大小作為唯一引數,在 heap 區段保留該大小的空間,並以 void 指標回傳這塊記憶體的起始位址。若因某種原因無法配置,它會回傳值為 0 的 NULL 指標。free():接受一個指標作為唯一引數,釋放 heap 上那塊空間以便日後再利用。
由於
malloc()不知道自己配置的是什麼型別的記憶體,它回傳的 void 指標必須被轉型成適當的型別。每次malloc()呼叫之後都應該檢查是否配置失敗。
char_ptr = (char *) malloc(mem_size); // Allocating heap memory
if(char_ptr == NULL) { // Error checking, in case malloc() fails
fprintf(stderr, "Error: could not allocate heap memory.\n");
exit(-1);
}執行 heap_example.c 觀察配置行為:
reader@hacking:~/booksrc $ ./heap_example
[+] allocating 50 bytes of memory on the heap for char_ptr
char_ptr (0x804a008) --> 'This is memory is located on the heap.'
[+] allocating 12 bytes of memory on the heap for int_ptr
int_ptr (0x804a040) --> 31337
[-] freeing char_ptr's heap memory...
[+] allocating another 15 bytes for char_ptr
char_ptr (0x804a050) --> 'new memory'
每塊記憶體在 heap 中的位址遞增。即使最初的 50 位元組已被釋放,之後請求 15 位元組時仍被放在 int_ptr 那 12 位元組之後。但若把初始配置改大:
reader@hacking:~/booksrc $ ./heap_example 100
[+] allocating 100 bytes of memory on the heap for char_ptr
char_ptr (0x804a008) --> 'This is memory is located on the heap.'
[+] allocating 12 bytes of memory on the heap for int_ptr
int_ptr (0x804a070) --> 31337
[-] freeing char_ptr's heap memory...
[+] allocating another 15 bytes for char_ptr
char_ptr (0x804a008) --> 'new memory'
配置一塊較大的記憶體再釋放後,最後那 15 位元組的配置就落在那塊被釋放的空間裡。
用不同的值實驗,就能摸清配置函式究竟在什麼時機決定回收已釋放的空間。簡單的
printf()敘述加上一點實驗,往往就能揭露底層系統的許多祕密。
用函式收攏錯誤檢查#
多次呼叫 malloc() 時,錯誤檢查程式碼得重複出現在多處,讓程式碼顯得雜亂;要修改檢查邏輯或新增 malloc() 呼叫時也很不方便。既然每次的錯誤檢查基本相同,這正是使用函式的絕佳場合:
void *errorchecked_malloc(unsigned int); // 函式原型
void *errorchecked_malloc(unsigned int size) {
void *ptr;
ptr = malloc(size);
if(ptr == NULL) {
fprintf(stderr, "Error: could not allocate heap memory.\n");
exit(-1);
}
return ptr;
}這個自訂的 errorchecked_malloc() 可以取代一般的 malloc(),免去後續重複的錯誤檢查。
這開始凸顯出以函式來寫程式的用處。