5. 虛擬內存管理

我們知道操作系統利用體繫結構提供的VA到PA的轉換機制實現虛擬內存管理。有了共享庫的基礎知識之後,現在我們可以進一步理解虛擬內存管理了。首先分析一個例子:

$ ps
  PID TTY          TIME CMD
29977 pts/0    00:00:00 bash
30032 pts/0    00:00:00 ps
$ cat /proc/29977/maps 
08048000-080f4000 r-xp 00000000 08:15 688142     /bin/bash
080f4000-080f9000 rw-p 000ac000 08:15 688142     /bin/bash
080f9000-080fe000 rw-p 080f9000 00:00 0 
09283000-09497000 rw-p 09283000 00:00 0          [heap]
b7ca8000-b7cb2000 r-xp 00000000 08:15 581665     /lib/tls/i686/cmov/libnss_files-2.8.90.so
b7cb2000-b7cb3000 r--p 00009000 08:15 581665     /lib/tls/i686/cmov/libnss_files-2.8.90.so
b7cb3000-b7cb4000 rw-p 0000a000 08:15 581665     /lib/tls/i686/cmov/libnss_files-2.8.90.so
...
b7e15000-b7f6d000 r-xp 00000000 08:15 581656     /lib/tls/i686/cmov/libc-2.8.90.so
b7f6d000-b7f6f000 r--p 00158000 08:15 581656     /lib/tls/i686/cmov/libc-2.8.90.so
b7f6f000-b7f70000 rw-p 0015a000 08:15 581656     /lib/tls/i686/cmov/libc-2.8.90.so
...
b7fbd000-b7fd7000 r-xp 00000000 08:15 565466     /lib/ld-2.8.90.so
b7fd7000-b7fd8000 r-xp b7fd7000 00:00 0          [vdso]
b7fd8000-b7fd9000 r--p 0001a000 08:15 565466     /lib/ld-2.8.90.so
b7fd9000-b7fda000 rw-p 0001b000 08:15 565466     /lib/ld-2.8.90.so
bfac5000-bfada000 rw-p bffeb000 00:00 0          [stack]

ps命令查看當前終端下的進程,得知bash進程的id是29977,然後用cat /proc/29977/maps命令查看它的虛擬地址空間。/proc目錄中的檔案並不是真正的磁碟檔案,而是由內核虛擬出來的檔案系統,當前系統中運行的每個進程在/proc下都有一個子目錄,目錄名就是進程的id,查看目錄下的檔案可以得到該進程的相關信息。此外,用pmap 29977命令也可以得到類似的輸出結果。

圖 20.4. 進程地址空間

進程地址空間

第 4 節 “MMU”講過,x86平台的虛擬地址空間是0x0000 0000~0xffff ffff,大致上前3GB(0x0000 0000~0xbfff ffff)是用戶空間,後1GB(0xc000 0000~0xffff ffff)是內核空間,在這裡得到了印證。0x0804 8000-0x080f 4000是從/bin/bash加載到內存的,訪問權限為r-x,表示Text Segment,包含.text段、.rodata段、.plt段等。0x080f 4000-0x080f 9000也是從/bin/bash加載到內存的,訪問權限為rw-,表示Data Segment,包含.data段、.bss段等。

0x0928 3000-0x0949 7000不是從磁碟檔案加載到內存的,這段空間稱為堆(Heap),以後會講到用malloc函數動態分配內存是在這裡分配的。從0xb7ca 8000開始是共享庫的映射空間,每個共享庫也分為幾個Segment,每個Segment有不同的訪問權限。可以看到,從堆空間的結束地址(0x0949 7000)到共享庫映射空間的起始地址(0xb7ca 8000)之間有很大的地址空洞,在動態分配內存時堆空間是可以向高地址增長的。堆空間的地址上限(0x09497000)稱為Break,堆空間要向高地址增長就要抬高Break,映射新的虛擬內存頁面到物理內存,這是通過系統調用brk實現的,malloc函數也是調用brk向內核請求分配內存的。

/lib/ld-2.8.90.so就是動態連結器/lib/ld-linux.so.2,後者是前者的符號連結。標有[vdso]的地址範圍是linux-gate.so.1的映射空間,我們講過這個共享庫是由內核虛擬出來的。0xbfac 5000-0xbfad a000是棧空間,其中高地址的部分保存着進程的環境變數和命令行參數,低地址的部分保存函數棧幀,棧空間是向低地址增長的,但顯然沒有堆空間那麼大的可供增長的餘地,因為實際的應用程序動態分配大量內存的並不少見,但是有幾十層深的函數調用並且每層調用都有很多局部變數的非常少見。總之,棧空間是可能用盡的,並且比堆空間更容易用盡,在第 3 節 “遞歸”講過,無窮遞歸會用盡棧空間最終導致段錯誤。

虛擬內存管理起到了什麼作用呢?可以從以下幾個方面來理解。

第一,虛擬內存管理可以控制物理內存的訪問權限。物理內存本身是不限制訪問的,任何地址都可以讀寫,而操作系統要求不同的頁面具有不同的訪問權限,這是利用CPU模式和MMU的內存保護機制實現的。例如,Text Segment被只讀保護起來,防止被錯誤的指令意外改寫,內核地址空間也被保護起來,防止在用戶模式下執行錯誤的指令意外改寫內核數據。這樣,執行錯誤指令或惡意代碼的破壞能力受到了限制,頂多使當前進程因段錯誤終止,而不會影響整個系統的穩定性。

第二,虛擬內存管理最主要的作用是讓每個進程有獨立的地址空間。所謂獨立的地址空間是指,不同進程中的同一個VA被MMU映射到不同的PA,並且在某一個進程中訪問任何地址都不可能訪問到另外一個進程的數據,這樣使得任何一個進程由於執行錯誤指令或惡意代碼導致的非法內存訪問都不會意外改寫其它進程的數據,不會影響其它進程的運行,從而保證整個系統的穩定性。另一方面,每個進程都認為自己獨占整個虛擬地址空間,這樣連結器和加載器的實現會比較容易,不必考慮各進程的地址範圍是否衝突。

繼續前面的實驗,再打開一個終端窗口,看一下這個新的bash進程的地址空間,可以發現和先前的bash進程地址空間的佈局差不多:

$ ps
  PID TTY          TIME CMD
30697 pts/1    00:00:00 bash
30749 pts/1    00:00:00 ps
$ cat /proc/30697/maps
08048000-080f4000 r-xp 00000000 08:15 688142     /bin/bash
080f4000-080f9000 rw-p 000ac000 08:15 688142     /bin/bash
080f9000-080fe000 rw-p 080f9000 00:00 0 
082d7000-084f9000 rw-p 082d7000 00:00 0          [heap]
b7cf1000-b7cfb000 r-xp 00000000 08:15 581665     /lib/tls/i686/cmov/libnss_files-2.8.90.so
b7cfb000-b7cfc000 r--p 00009000 08:15 581665     /lib/tls/i686/cmov/libnss_files-2.8.90.so
b7cfc000-b7cfd000 rw-p 0000a000 08:15 581665     /lib/tls/i686/cmov/libnss_files-2.8.90.so
...
b7e5e000-b7fb6000 r-xp 00000000 08:15 581656     /lib/tls/i686/cmov/libc-2.8.90.so
b7fb6000-b7fb8000 r--p 00158000 08:15 581656     /lib/tls/i686/cmov/libc-2.8.90.so
b7fb8000-b7fb9000 rw-p 0015a000 08:15 581656     /lib/tls/i686/cmov/libc-2.8.90.so
...
b8006000-b8020000 r-xp 00000000 08:15 565466     /lib/ld-2.8.90.so
b8020000-b8021000 r-xp b8020000 00:00 0          [vdso]
b8021000-b8022000 r--p 0001a000 08:15 565466     /lib/ld-2.8.90.so
b8022000-b8023000 rw-p 0001b000 08:15 565466     /lib/ld-2.8.90.so
bff0e000-bff23000 rw-p bffeb000 00:00 0          [stack]

該進程也占用了0x0000 0000-0xbfff ffff的地址空間,Text Segment也是0x0804 8000-0x080f 4000,Data Segment也是0x080f 4000-0x080f 9000,和先前的進程一模一樣,因為這些地址是在編譯連結時寫進/bin/bash這個執行檔的,兩個進程都加載它。這兩個進程在同一個系統中同時運行着,它們的Data Segment占用相同的VA,但是兩個進程各自干各自的事情,顯然Data Segment中的數據應該是不同的,相同的VA怎麼會有不同的數據呢?因為它們被映射到不同的PA。如下圖所示。

圖 20.5. 進程地址空間是獨立的

進程地址空間是獨立的

從圖中還可以看到,兩個進程都是bash進程,Text Segment是一樣的,並且Text Segment是隻讀的,不會被改寫,因此操作系統會安排兩個進程的Text Segment共享相同的物理頁面。由於每個進程都有自己的一套VA到PA的映射表,整個地址空間中的任何VA都在每個進程自己的映射表中查找相應的PA,因此不可能訪問到其它進程的地址,也就沒有可能意外改寫其它進程的數據。

另外,注意到兩個進程的共享庫加載地址並不相同,共享庫的加載地址是在運行時決定的,而不是寫在/bin/bash這個執行檔中。但即使如此,也不影響兩個進程共享相同物理頁面中的共享庫,當然,只有隻讀的部分是共享的,可讀可寫的部分不共享。

使用共享庫可以大大節省內存。比如libc,系統中几乎所有的進程都映射libc到自己的進程地址空間,而libc的只讀部分在物理內存中只需要存在一份,就可以被所有進程共享,這就是“共享庫”這個名稱的由來了。

現在我們也可以理解為什麼共享庫必須是位置無關代碼了。比如libc,不同的進程雖然共享libc所在的物理頁面,但這些物理頁面被映射到各進程的虛擬地址空間時卻位於不同的地址,所以要求libc的代碼不管加載到什麼地址都能正確執行。

第三,VA到PA的映射會給分配和釋放內存帶來方便,物理地址不連續的幾塊內存可以映射成虛擬地址連續的一塊內存。比如要用malloc分配一塊很大的內存空間,雖然有足夠多的空閒物理內存,卻沒有足夠大的連續空閒內存,這時就可以分配多個不連續的物理頁面而映射到連續的虛擬地址範圍。如下圖所示。

圖 20.6. 不連續的PA可以映射為連續的VA

不連續的PA可以映射為連續的VA

第四,一個系統如果同時運行着很多進程,為各進程分配的內存之和可能會大於實際可用的物理內存,虛擬內存管理使得這種情況下各進程仍然能夠正常運行。因為各進程分配的只不過是虛擬內存的頁面,這些頁面的數據可以映射到物理頁面,也可以臨時保存到磁碟上而不占用物理頁面,在磁碟上臨時保存虛擬內存頁面的可能是一個磁碟分區,也可能是一個磁碟檔案,稱為交換設備(Swap Device)。當物理內存不夠用時,將一些不常用的物理頁面中的數據臨時保存到交換設備,然後這個物理頁面就認為是空閒的了,可以重新分配給進程使用,這個過程稱為換出(Page out)。如果進程要用到被換出的頁面,就從交換設備再加載回物理內存,這稱為換入(Page in)。換出和換入操作統稱為換頁(Paging),因此:

系統中可分配的內存總量 = 物理內存的大小 + 交換設備的大小

如下圖所示。第一張圖是換出,將物理頁面中的數據保存到磁碟,並解除地址映射,釋放物理頁面。第二張圖是換入,從空閒的物理頁面中分配一個,將磁碟暫存的頁面加載回內存,並建立地址映射。

圖 20.7. 換頁

換頁