歡迎光臨
每天分享高質量文章

行程眼中的線性地址空間

從文章的題目我們就知道今天是以一個行程的角度來看待自身的執行環境。我們先提出第一個問題,什麼是行程?對於這個問題,各種參考資料上給出的定義都顯得過於抽象而難以理解,下麵是我自己的定義:

行程是一個動態的概念,它是靜態的可執行檔案執行過程的描述,其包含了一個靜態程式執行時的狀態和其所佔據的系統資源的總和。

還是很抽象嗎?那麼,我們可以這樣比喻,如果說菜譜是程式程式碼,廚具是硬體的話,那麼炒菜的整個過程就是一個行程。這下理解了吧?那我們繼續。

每個程式在啟動之後都會擁有自己的虛擬地址空間(Virtual Address Space),這個虛擬地址空間的大小由計算機平臺決定,具體一點說由作業系統的位數和CPU的地址匯流排寬度所決定,其中CPU的地址匯流排寬度決定了地址空間的理論上限(先不考慮主機板…)。

比如32位的硬體平臺可編址範圍就是0x00000000~0xFFFFFFFF,即就是4GB。而64位的硬體平臺達到了理論上0x0000000000000000~0xFFFFFFFFFFFFFFFF的定址空間,即就是17179869184GB的大小(事實上我自己的64位Intel Core i3處理器也僅有36位地址匯流排,加上虛擬地址擴充套件功能也才48位,並非64位)。

為了行文的簡單,我就以32位硬體平臺來描述吧(事實上我對64位所知甚少,不敢信口開河…),同時指定環境為32位的Linux作業系統。

可能看到這裡你反而更迷惑了,我一直在說一個行程擁有4GB的線性地址空間(以下只討論32位),可是作業系統上同時在執行著N個行程,難不成每個都有4GB的線性地址空間不成?沒錯,每個都有。我們一直在使用術語“線性地址空間”而非“主儲存器(記憶體)”,因為線性地址空間並非和主存等價。我們平時只要一提到“地址”這個概念,想必大家自然而然的就想到了主儲存器。但事實上並非線性地址就一定指向主儲存器的物理地址,如果你對“線性地址空間”不理解的話,我建議你先去看看我的另一篇博文《基於Intel 80×86 CPU的IBM PC及其相容計算機的啟動流程》。

其實說到線性地址空間,就不得不提到Intel CPU保護樣式下的記憶體分段和分頁,但這偏離了文章的主旨。我們暫時只需要知道,之所以行程擁有獨立的4GB的虛擬地址,是因為CPU和作業系統提供了一種虛擬地址到實際物理地址的對映機制,在頁對映樣式下,CPU發出的是虛擬地址,即行程看到的虛擬的地址,經過MMU(Memory Management Unit)部件轉換之後就成了物理地址。

好了,下文中我將假定讀者理解了線性地址空間的概念,並認可了每個行程擁有4GB線性地址空間這一事實(物理地址擴充套件(PAE:Physical Address Extension)技術後面再說)。那麼,這4GB的線性地址空間裡都有些什麼呢?我們畫一張圖來說明一下。

記憶體高地址區域是被作業系統核心所佔據的,Linux作業系統佔據了高地址區域的1GB記憶體(Windows系統預設保留2GB給作業系統,但是可以配置為保留1GB)。如果我們想知道一個行程具體的記憶體空間佈局的話,可以去/proc目錄找以行程的pid所命名的目錄下一個叫maps的檔案,使用cat命令檢視即可(需要root許可權)。

我們從圖中可以看到,32位Linux系統中,程式碼段總是從地址0x08048000處開始的。資料段一般是在下一個4KB(分頁機制預設選擇4KB一個記憶體頁)對齊的地址處開始。執行時堆是在資料段之後又一個4KB對齊處開始的,並透過malloc()函式呼叫向上增長(Linux下的malloc()一般依靠呼叫brk()或者mmap()系統呼叫實現)。再接著跳過動態連結庫的區域就是行程的執行時棧了,需要註意的是棧是由高地址向著低地址增長的。棧空間再往上就是作業系統保留區域了,用於駐留內核的程式碼和資料。即就是在一個行程的眼裡,只有它和作業系統在一起。

也許你會問,那麼一個行程如何修改另外一個行程的執行時資料呢?比如所謂的外掛程式。我們想想,一個行程不知道另一個行程,那誰知道所有的行程呢?作業系統唄,沒錯,作業系統提供了這種抽象,它也就擁有訪問所有行程地址空間的能力。答案就是,一個行程倘若要修改不屬於自己的行程空間的資料,就需要作業系統提供相關的系統呼叫(或API函式)的支援來實現。

我們具體來看看程式碼段,以C語言為例,程式程式碼段的入口_start地址處的啟動程式碼(startup code)是在標的檔案ctr1.o(屬於C執行時庫的部分)中定義的,對於特定平臺上的C程式都一樣。其執行流程如下:

1
2
3
4
5
6
7
0x080480c0 <_start>:

   呼叫 __libc_init_first 函式
   呼叫 _init 函式
   呼叫 atexit 函式
   呼叫 main 函式
   呼叫 _exit 函式

而我們平時寫的main函式只是整個C程式執行過程中所呼叫的一環而已。

我們給出一段程式碼來看看一個C語言程式編譯連結之後如何安排各個元素的記憶體位置吧,程式碼和註釋如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include 
#include

int main(int argc, char *argv[])
{
   int a = 1;              // 棧區
   static int b = 2;       // 全域性靜態區(讀/寫段)
   const int c = 3;        // 只讀段
   char *str1 = "Hello";   // str1指標在棧區,"hello"字串在只讀段
   char str2[] = "world";             // 只在棧區(字串)
   char *str3 = (char *)malloc(20);   // str3變數在棧區,指標指向堆區
   
   return EXIT_SUCCESS;
}

註釋中我們看到了各個元素所在記憶體段的位置。而編譯好的main函式本身是存在於程式碼區的(一般程式碼段也是隻讀段)。我們這個程式執行後如果是動態連結的C語言執行時庫的話,動態庫會存在圖示的動態庫對映區。其實無論使用C語言執行時庫的程式無論有多少,執行時庫的程式碼在記憶體裡只會有一份。對於不同的程式,進行地址對映即可。

接下來我們簡單說說棧(stack),關於棧的基本概念到處都是,如果大家不明白可以自己去查查。其實這裡的棧就是把一段位於使用者線性地址空間最高處的一段連續記憶體以棧的思想來使用罷了。大家不要覺得線性空間有4GB,棧佔據了很大。其實棧大小預設就幾MB罷了。Linux可以在終端下執行 ulimit -a命令檢視限制。如圖所示:

我這裡不過也就預設8192KB(8MB)大小,不過可以使用ulimit命令調整(調整隻在本次bash執行過程中有效,下次需要重新設定)。

棧也經常被叫做棧幀(Stack Frame)或者活動記錄(Activate Record)。棧裡通常儲存以下內容:

函式的臨時變數;
函式的傳回地址和引數;
函式呼叫過程中儲存的背景關係。

在i386中,使用esp和ebp暫存器劃定範圍。esp暫存器始終指向棧頂,隨著壓棧和出棧操作而改變值。ebp暫存器隨著呼叫過程,暫時的指向一個固定的棧位置,便於定址操作的進行。

我們畫一張圖來看看吧:

這裡照抄網上的函式呼叫流程:

  1. 把所有的引數壓入棧(有時候是一部分引數,剩餘引數透過暫存器傳遞)

  2. 把當前指令的下一條指令的地址壓入棧

  3. 跳轉到函式體執行

我繼續續上後面的操作:

  1. 在棧裡繼續建立該函式的臨時變數和其他資料

  2. 函式程式碼執行完之後棧後退到區域性變數之上的位置

  3. 恢復之前儲存的所有暫存器

  4. 取出原先儲存的傳回地址,跳轉回去

  5. eax暫存器儲存了函式的傳回值(浮點數是把傳回值放在第一個浮點暫存器上%st(0) )

為了不讓大家變的過於糾結,我就不貼出相關的彙編程式碼了,有興趣的同學可以自己研究編譯器生成的組合語言。具體方法在《編譯和連結那點事》和《淺談緩衝區上限溢位之棧上限溢位》中有詳細的描述。

好了,本篇暫時結束,下文以後再說。

贊(0)

分享創造快樂