一支編譯後的程式,其記憶體被分成五個區段:text、data、bss、heap、stack。每個區段代表為特定目的保留的一塊記憶體。

text 區段#

text 區段有時也叫 code 區段,存放程式組譯後的機器語言指令。

由於高階控制結構與函式會被編譯成組合語言的分支、跳躍與呼叫指令,這個區段中的指令執行是非線性的。程式執行時 EIP 被設到 text 區段的第一條指令,接著處理器進入以下執行迴圈:

  1. 讀取 EIP 指向的指令
  2. 把該指令的位元組長度加到 EIP
  3. 執行步驟 1 讀到的指令
  4. 回到步驟 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):它們把框架指標存到堆疊上,並為區域函式變數保留堆疊記憶體,有時還處理堆疊對齊。確切的序言指令會因編譯器與選項而有很大差異,但總體而言就是在建構堆疊框架。

建構過程#

  1. 推入引數——test_function() 被呼叫時,函式引數以相反順序推入堆疊(因為是 FILO):先 4、再 3、2,最後 1,分別對應函式中的 dcba
  2. 推入返回位址——執行 call 指令時,返回位址被推入堆疊,執行流程跳到 test_function() 起點。返回位址是當前 EIP 之後那條指令的位置(此例為 main()0x0804838bleave)。
  3. 存下 SFP——call 同時完成儲存返回位址與跳躍,接著 test_function() 的序言指令完成堆疊框架:當前 EBP 值被推入堆疊,這就是 SFP
  4. 設定新框架指標——當前 ESP 值被複製進 EBP,成為新的框架指標,用來引用函式的區域變數(flagbuffer)。
  5. 保留區域變數空間——藉由對 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

從堆疊上可以讀出整個框架:

  • 最底部是函式的四個引數(0x000000010x00000004)。
  • 緊接其上是返回位址(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_varglobal_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(),免去後續重複的錯誤檢查。

這開始凸顯出以函式來寫程式的用處。