1. 程式人生 > >記憶體分佈和棧空間---Memory Layout And The Stack

記憶體分佈和棧空間---Memory Layout And The Stack

記憶體分佈和棧空間

Memory Layout And The Stack

原作者:Peter Jay Salzman

譯者:劉瀚文

導語

想要快速的學習使用GDB,你必須理解frames資料幀的含義,資料幀又叫做堆疊幀,因為堆疊就是由資料幀構成的。想要學習堆疊的知識,我們需要了解一個執行中的程式在記憶體中是如何分佈的。這篇文章討論的主要是理論知識,但是為了讓這篇文章讀起來沒有那麼枯燥乏味,我們將引入關於在堆疊堆疊幀上使用GDB的例子。

這篇文章雖然理論性較強,但是卻能為我們提供十分有價值的學習目標:

  1. 想要使用典型的debugger比如說GDB,就必須理解堆疊。
  2. 通過學習程式的記憶體分佈將有利於我們理解什麼是segmentation fault段錯誤
    ,並且明白為什麼有時候會出現這個錯誤而有時候該出現的時候卻沒有出現(有時後者更關鍵)。總之,段錯誤是最常見的將程式變成‘定時炸彈’的原因。
  3. 瞭解程式的記憶體空間分佈常常讓我們能不通過printf、編譯器或甚至GDB就找到一些隱藏的非常深的錯誤。下一節的內容是由我們的客人、我的朋友——Mark Kim寫的,我們能看到像福爾摩斯一樣的偵探,Mark在冗長的程式碼中找到了一個隱藏的很深的bug,並且他僅僅用了5到10分鐘。他找到這個bug全靠盯著程式碼研究和運用程式記憶體分佈的知識,這個例子一定為讓讀者印象深刻的。

好了,我們就不繞彎子了,趕快來看看程式是怎麼在記憶體中分佈的吧!

Virtual Memory (VM)——虛擬記憶體

每當一個程式開始執行,系統核心就會提供一大塊能被從頭到尾訪問的physical memory實體記憶體。但是多虧了Virtual Memory虛擬記憶體,程式本身認為自己已經擁有訪問整個計算機記憶體空間的能力。你可能在有關硬體的文章中聽過虛擬記憶體這個概念,儘管那個也叫虛擬記憶體,但是實際上我們要討論的這個虛擬記憶體不是那個當實體記憶體用光了之後才使用的虛擬記憶體。我們討論的虛擬記憶體應該由以下的原則確定:

  1. 每一個程序擁有的實體記憶體塊叫做這個程序的虛擬記憶體空間。

  2. 程序不知道自己實體記憶體的實際結構,只能知道這個被分配給它的記憶體塊的大小和這個塊的地址空間是從0開始算的。

  3. 每一個程序沒辦法瞭解到其他程序擁有的虛擬記憶體塊
  4. 就算一個程序已經知道了其他虛擬記憶體塊,但是仍然沒有辦法訪問到那一塊記憶體。

每當一個程序想要對記憶體進行讀寫,它都會從VM address虛擬記憶體地址轉換成physical memory address實體記憶體地址。反之,當一個OS作業系統核心想要訪問一個虛擬記憶體,就要把實際記憶體地址轉換為虛擬記憶體地址。那麼這裡就會出現兩個問題:

  1. 計算機要不斷的訪問記憶體,這個轉換過程必定非常常見,所以這個轉換過程就必須非常的迅速。
  2. 作業系統怎麼來保證一個程序不會‘踏入’另一個程序的虛擬記憶體塊中呢?

這兩個問題的答案就是實際上作業系統本身不會去管理每個程序的虛擬記憶體,而是通過CPU的幫助。很多CPU擁有一個叫Memory Management Unit(MMU)的記憶體管理單元。作業系統MMU會共同管理虛擬記憶體、進行實體記憶體地址虛擬記憶體地址的轉換、決定程序訪問記憶體中某個位置的許可權和決定程序在(甚至是本身的)虛擬記憶體空間的讀寫許可權

過去Linux只能執行在含有MMU的CPU上(Linux沒有辦法執行在X286框架的CPU上)。然而在1998年時,Linux裝載在了沒有MMU68000上。這也是嵌入式Linux和在類似於Palm Pilot裝置上的Linux的一大進步。

練習:

  1. 讀一個關於MMU簡短百科
  2. 選做:如果想要了解更多有關於VM虛擬記憶體的介紹點選這裡,實際上這已經遠遠超過閱讀這篇文章你需要了解的範疇。

Memory Layout——記憶體分佈

上面講了虛擬記憶體是怎麼工作的。對於多數情況,每一個程序的虛擬記憶體是按照一個相似的、可預測的方式分佈的:

記憶體地址 記憶體結構 說明
高地址 Args and env vars 命令列引數和環境變數
堆疊(Stack)
未使用的記憶體空間
堆(Heap)
沒有初始化的資料段(bss) 執行時初始化為零
初始化了的資料段 執行時通過程式原始檔讀取
低地址 文字段(Text Segment) 執行時通過程式原始檔讀取

>

  • 文字段(Text Segment):文字段包含了真實執行的程式碼,通常是可以共享的,所以許多情況下程式可以共享文字段來滿足較低記憶體情況下的需求。這個段通常是標記為只讀的,好讓一個程式不能改變自己本身的結構。
  • 初始化了的資料段(Initialized Data Segment):這個資料段包含了一開始就被初始化了的全域性變數。
  • 沒有初始化的資料段(Uninitialized Data Segment):以前彙編程式中又稱為bss(block started by symbol)。這個資料段包含了一系列沒有被初始化的全域性變數。所有在這裡面的變數在執行前都被初始化為0或者NULL pointers(空指標)
  • 棧(Stack)是包含了一系列下一部分會講到的資料幀,當一個新的資料幀要被新增到棧裡面的時候(比如說當一個建構函式被呼叫的時候),會向下增長。
  • 堆(Heap):大部分動態記憶體,無論是C語言malloc()還是C++new之類的操作的變數都會從上被髮放到程式中。C語言的庫還會從中獲取動態記憶體來填充personal workspace(自己的工作空間)。因為大量的記憶體需要被不斷讀寫,所以整個會往上增長。

對於給定的Object檔案或者executable(可執行)檔案,你都可以查明程式每一個部分的大小(注意我們這裡並不是在討論記憶體分佈而是在討論一個最終會被執行的硬碟中的檔案)。

給定Makefilehello_world-1.c

1   // hello_world-1.c
2   
3   #include <stdio.h>
4   
5   int main(void)
6   {
7      printf("hello world\n");
8   
9      return 0;
10  }

編譯這個檔案並單獨的通過以下命令進行link

$ gcc -W -Wall -c hello_world-1.c
$ gcc -o hello_world-1  hello_world-1.o

你可以使用size命令來列出各個變數資料段的大小:

 $ size hello_world-1 hello_world-1.o 
 text   data   bss    dec   hex   filename
  916    256     4   1176   498   hello_world-1
   48      0     0     48    30   hello_world-1.o

資料段data由已經初始化和未初始化的資料段混合而成。dechex欄位則表示整個檔案在二進位制十六進位制下的大小。

你也可以使用objdump -hobjdump -x來得到object檔案各部分的大小:

$ objdump -h hello_world-1.o 
hello_world-1.o:     file format elf32-i386
Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00000023  00000000  00000000  00000034  2**2
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000000  00000000  00000000  00000058  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  00000000  00000000  00000058  2**2
                  ALLOC
  3 .rodata       0000000d  00000000  00000000  00000058  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .note.GNU-stack 00000000  00000000  00000000  00000065  2**0
                  CONTENTS, READONLY
  5 .comment      0000001b  00000000  00000000  00000065  2**0
                  CONTENTS, READONLY

練習:

  1. size命令並沒有列出hello_worldhello_world.o資料段,你覺得這是為什麼呢?
  2. hello_world-1.c檔案中並沒有定義全域性變數。請解釋為什麼size命令列出的結果中,object檔案的data段bss段的長度為0,而在可執行檔案中卻不為0。
  3. size命令和objdump命令給出了不同的text segment(文字段)大小。你能猜到差異從何而來嗎?(提示:這個差異有多大呢?看看在原始檔中是否有這個大小的東西。)
  4. 選做:讀讀關於object檔案格式的相關知識

Stack Frames And The Stack——資料幀和棧

我們剛剛學習了關於程式的記憶體分佈,在程式的記憶體分佈中有一個被stack frames(資料幀)填滿的資料段叫做stack(棧)。每一個資料幀都代表了一個function call(函式呼叫)。當一個函式被呼叫的時候,資料幀的數量增加然後整個棧空間就增長了。反過來,當一個函式返回了,資料幀的數量就減少了然後整個棧空間就縮小了。這一節中我們會來學習什麼是資料幀,這一節會給出很詳細解釋,當然也會著重看看我們需要重點了解的部分。

一個程式被或多或少、互相呼叫的函式組成。每當一個函式被呼叫,一塊記憶體被擱到一塊兒放在一旁,這個記憶體塊就是在裡的資料幀。這一塊記憶體空間有許多至關重要的資訊,比如說:

  1. 為新呼叫的這個函式的變數提供儲存空間。
  2. 當這個函式返回的時候應該返回的line number(行號)
  3. 這個函式所呼叫的引數。

每一個函式呼叫都會得到一個自己私有的資料幀。總的來說所有資料幀組成了一個call stack(呼叫棧)。這裡提供一個hello_world-2.c來作為例子:

1   #include <stdio.h>
2   void first_function(void);
3   void second_function(int);
4   
5   int main(void)
6   {
7      printf("hello world\n");
8      first_function();
9      printf("goodbye goodbye\n");
10  
11     return 0;
12  }
13  
14  
15  void first_function(void)
16  {
17     int imidate = 3;
18     char broiled = 'c';
19     void *where_prohibited = NULL;
20  
21     second_function(imidate);
22     imidate = 10;
23  }
24  
25  
26  void second_function(int a)
27  {
28     int b = a;
29  }

當這個程式開始執行就會有一個資料幀,這個資料幀屬於主函式main()。因為主函式並沒有變數、引數和返回函式,所以這個資料幀沒有什麼觀察價值。下面是主函式還沒有呼叫first_function()的時候的記憶體分佈:

記憶體分佈
main()的資料幀

當程式呼叫first_function()的時候,未使用的棧空間記憶體被用來為first_function()建立一個新的資料幀。這個資料幀由四個東西組成,分別為一個int、一個char、一個void*和返回函式main()的所在行號。下面是剛剛呼叫first_function()時候的記憶體分佈:

記憶體分佈
main()的資料幀
first_function()的資料幀:【int】【char】【void*】【返回函式行號9

同樣的,當我們呼叫second_function()的時候,未使用的棧空間記憶體被用來為second_function()建立一個新的資料幀。這個資料幀由三個東西組成,分別為:返回函式行號、一個int、一個int引數a。下面是剛剛呼叫second_function()時候的記憶體分佈:

記憶體分佈
main()的資料幀
first_function()的資料幀:【int】【char】【void*】【返回函式行號9
second_function()的資料幀:【int】【a】【返回函式行號22

second_function()返回時,它的資料幀會查明返回到哪裡(first_function()22行),然後返回並且該資料幀出棧。這是剛剛返回了second_function()時候的記憶體分佈:

記憶體分佈
main()的資料幀
first_function()的資料幀:【int】【char】【void*】【返回函式行號9

first_function()返回時,它的資料幀會查明返回到哪裡(main()9行),然後返回並且該資料幀出棧。這是剛剛返回了first_function()時候的記憶體分佈:

記憶體分佈
main()的資料幀

練習

  1. 假如一個程式呼叫了5個函式,那麼裡面有多少資料幀呢?
  2. 會不會有可能在中間的資料幀返回到了沒有使用的記憶體空間中呢?如果有可能,那麼這對於程式來說意味著什麼呢?
  3. goto()函式能讓一個在中部的資料幀出棧嗎?答案是不能,為什麼呢?
  4. loogjump()函式能讓一個在中部的資料幀出棧嗎?