關於記憶體,最直觀的理解可以將其想象成一個個格子,每個格子由一個地址標記出來並且存了一個位元組的資料,對於32位的機器,可以有2^32個地址,也就是理論上可以存4GB的資料(實際的機器不一定是4G的實體記憶體)。的確,對於程式員而言這樣的理解已經足以滿足我們編寫程式的要求了,而記憶體實際的物理模型也是這個樣子的。但是,對於系統而言,這樣簡單的模型是不夠的,因為正常情況下系統中都會運行著多個程式,如果這些程式都可以直接對任意一個記憶體位址進行操作,那麼一個程式就很有可能直接的修改了另外一個程式儲存在記憶體中的資料,這種情況下會發生什麼,不好說,但肯定會悲劇。所以作業系統必須實現一些機制,來保證各個進程可以和諧友愛地使用這有限的記憶體,同時又要保證記憶體的使用效率,這些就是我本文要講的主要內容了。
1、基本概念
為瞭解決前面提到的問題,作業系統對實體記憶體做了抽象,得到一個重要的概念叫做地址空間,指可以用來訪問記憶體的地址集合,也就是0X00000000到0xFFFFFFFF,大小是4個G 。每個進程都有自己的地址空間,且在每個進程自己看來這4G就相當於實體記憶體,它可以使用任意地址去訪問他們,而不需要擔心影響到其他進程。因為這裡的地址並不是實際的物理地址,而是虛擬位址,它需要經過系統轉化成物理地址後再去訪問記憶體,且系統保證了不同的進程中的同一個虛擬位址會映射到不同的物理地址,也就不會操作到同一塊記憶體(除非那一塊記憶體是共用的)。又因為通常一個程式也不會使用到4G的記憶體,所以4G的實體記憶體可以同時存放多個程式的資料而不會重疊,即使4個G都已經放滿了,也可以通過將一部分暫時沒用的資料儲存到磁碟的方式來騰出空間放其它的資料,具體如何操作,我們之後再講,這裡只要知道,我們的程式是通過虛擬位址來訪問記憶體的,而系統保證了每個進程通過地址空間訪問到的都會是自己的資料就可以了。
有了地址空間的概念後,在討論程式如何使用記憶體的時候,我們就可以將實體記憶體的概念拋到一邊了,接下來我們就看看Linux裡的進程是如何使用地址空間的:
在Linux中,雖然每個進程有4G地址空間,但是其中只有3G是屬於它自己的,也就是所謂的使用者空間,剩下的1G則是所有進程共用的,也就是核心空間,這1G的核心空間裡儲存了重要的核心資料比如用於分頁查詢的頁表,還有之前提到的進程描述符等,這些內容在系統運行過程中將一直儲存在記憶體當中,且對於運行在使用者模式下的進程是不可見的,只有當進程切換到核心模式後,才能夠對核心空間的資源進行訪問(以及進行系統調用的許可權),又因為核心空間是所有進程共用的,所以利用核心空間進行處理序間通訊就是一件理所當然的事情了,所有IPC對象如訊息佇列,共用記憶體和訊號量都存在於核心空間中。
而使用者空間又根據邏輯功能分成了3個段:Text,Data 和 Stack,如所示。
Text段的內容是唯讀且整個段的大小不會改變,它儲存了程式的執行指令,來源於可執行檔,我們知道程式經過編譯之後會得到一個可執行檔,這個可執行檔裡就儲存了程式執行的機器指令,在運行時,就將這些指令拷貝到Text段裡然後CPU從這裡讀取指令並執行。
data段顧名思義是儲存了程式中的資料,包括各種類型的變數,數組,字串等,它包括兩個部分,一個是有初始化的資料區,儲存了程式中有初始值的資料,一個是無初始化的資料區(通常叫做 BSS),儲存了程式中沒有初始值的資料,且BSS區的資料在程式載入時會自動初始化為0。注意這裡的資料不包括函數內的局部變數,因為那是在stack段中的。舉個例子,熟悉C/C++的人知道如果我們程式中的全域變數沒有設定初始值的話,會自動初始化為0,而局部變數沒有設定初始值的話,則他們的值是不確定的,其原因就在這裡,當全域量不設初始值時,會儲存在BSS區裡,這裡自動為0,若有初始值,則在有初始化的區,而局部變數在Stack段則是沒有初始化。跟Text段不同,data段裡的資料可以被修改,而且data段的大小也可能在程式運行過程中改變,比如說當調用malloc時,data段的地址會往上擴充,而這些動態分配的記憶體就稱為堆。
stack段位於使用者空間的最頂部,可以向下增長,它被用來存放進行函數調用的棧。當程式執行時,main函數的棧最先建立,伴隨著傳進來的環境變數和執行參數,並壓入系統棧中(指stack段),當在main函數中調用另一個函數A時,系統會先在main的棧中壓入函數A的參數和返回地址,並為A建立一個新的棧並壓入系統棧中,而當A返回時,則A的棧被彈出,這樣就使得當前執行的函數總是在系統棧的頂部(這裡的頂部在中是在下方,因為stack段是往下增長的),這就是函數調用的一個粗略過程。
2、地址空間的應用
前面已經提到了地址空間的概念,進程只管使用地址空間裡的地址去讀寫資料,而不管實際的資料是放在什麼地方,接下來我們就看看系統利用這點可以幹些什麼。
(1)共用text段:我們已經知道了text段是存放程式啟動並執行機器指令的,那麼當多個進程運行同一個程式的時候,它們的text段肯定也是一樣的,在這個時候,為了節省實體記憶體,系統是不會把每個進程的text段內容都放到實體記憶體的,而是只儲存了一份,然後讓各個進程的地址空間的text都映射到這一地區,這樣做對於每個進程的運行不會有任何影響,同時又節省了寶貴的實體記憶體。實際上系統還保證了同一份指令在記憶體中只會存在一份,一個實際例子就是動態串連庫的使用。
(2)記憶體對應檔:因為進程使用地址空間的地址讀寫資料時不用管實際的資料在哪,那就意味著這些資料甚至可以不在記憶體中,記憶體對應檔就是利用了這一點,通過保留進程地址空間的一個地區,並將這塊區域對應到磁碟上的一個檔案,進程就可以像操作記憶體一樣來訪問這個檔案(即像訪問數組一樣可以使用指標,位移量等),而不用使用到檔案的IO操作,當然這其中肯定需要作業系統提供相應的機制來去實現邏輯地址到實際檔案存放位置的轉換,但這就不是我們所關心的了。使用記憶體對應檔還有一個好處就是,多個進程可以同時映射到同一個檔案,又因為此時的這一份檔案在進程看來就是記憶體,也就是說可以將其視為一塊共用記憶體,這意味著每個進程對這塊地區的修改對於其他進程都是即時可見的,當然這裡的效率會比將資料實際放在記憶體時要低,但是卻帶來了另一個好處就是磁碟空間相對於記憶體來講是無限的,因此可以實現大資料量的資料共用。
好了,到這裡我們對記憶體就有了一個比較細緻的理解,其中地址空間是一個值得細細體味的概念,下一篇文章我們再看看系統是通過怎樣的機制來使得我們的程式可以如此方便地訪問記憶體的。