關於內存,最直觀的理解可以將其想象成一個個格子,每個格子由一個地址標記出來並且存了一個字節的數據,對於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操作,當然這其中肯定需要操作系統提供相應的機制來去實現邏輯地址到實際文件存放位置的轉換,但這就不是我們所關心的了。使用內存映射文件還有一個好處就是,多個進程可以同時映射到同一個文件,又因為此時的這一份文件在進程看來就是內存,也就是說可以將其視為一塊共享內存,這意味着每個進程對這塊區域的修改對於其他進程都是實時可見的,當然這里的效率會比將數據實際放在內存時要低,但是卻帶來了另一個好處就是磁盤空間相對於內存來講是無限的,因此可以實現大數據量的數據共享。
好了,到這里我們對內存就有了一個比較細致的理解,其中地址空間是一個值得細細體味的概念,下一篇文章我們再看看系統是通過怎樣的機制來使得我們的程序可以如此方便地訪問內存的。