1. 程式人生 > 其它 >TLPI讀書筆記第49章-記憶體對映1

TLPI讀書筆記第49章-記憶體對映1

本章將介紹如何使用 mmap()系統呼叫來建立記憶體對映。記憶體對映可用於 IPC 以及其他很多方面。下面在深入介紹 mmap()之前首先概述一些基礎概念。

49.1 概述

mmap()系統呼叫在呼叫程序的虛擬地址空間中建立一個新記憶體對映。對映分為兩種。

1.檔案對映:檔案對映將一個檔案的一部分直接對映到呼叫程序的虛擬記憶體中。一旦一個檔案被對映之後就可以通過在相應的記憶體區域中操作位元組來訪問檔案內容了。對映的分頁會在需要的時候從檔案中(自動)載入。這種對映也被稱為基於檔案的對映或記憶體對映檔案。

2.匿名對映:一個匿名對映沒有對應的檔案。相反,這種對映的分頁會被初始化為 0。

一個程序的對映中的記憶體可以與其他程序中的對映共享(即各個程序的頁表條目指向RAM 中相同分頁)。這種行為會在兩種情況下發生。

1.當兩個程序映射了一個檔案的同一個區域時它們會共享實體記憶體的相同分頁。

2.通過 fork()建立的子程序會繼承其父程序的對映的副本,並且這些對映所引用的實體記憶體分頁與父程序中相應對映所引用的分頁相同。

當兩個或更多個程序共享相同分頁時,每個程序都有可能會看到其他程序對分頁內容做出的變更,當然這要取決於對映是私有的還是共享的。

1.私有對映( MAP_PRIVATE):

在對映內容上發生的變更對其他程序不可見,對於檔案對映來講,變更將不會在底層檔案上進行。儘管一個私有對映的分頁在上面介紹的情況中初始時是共享的,但對對映內容所做出的變更對各個程序來講則是私有的。核心使用了寫時複製( copy-on-write)技術完成了這個任務。這意味著每當一個程序試圖修改一個分頁的內容時,核心首先會為該程序建立一個新分頁並將需修改的分頁中的內容複製到新分頁中(以及調整程序的頁表)。正因為這個原因,MAP_PRIVATE 對映有時候會被稱為私有、寫時複製對映。

2.共享對映( MAP_SHARED):

在對映內容上發生的變更對所有共享同一個對映的其他程序都可見,對於檔案對映來講,變更將會發生在底層的檔案上。

四種不同的記憶體對映的建立和使用方式

1.私有檔案對映:

對映的內容被初始化為一個檔案區域中的內容。多個對映同一個檔案的程序初始時會共享同樣的記憶體物理分頁,但系統使用寫時複製技術使得一個程序對對映所做出的變更對其他程序不可見。這種對映的主要用途是使用一個檔案的內容來初始化一塊記憶體區域。一些常見的例子包括根據二進位制可執行檔案或共享庫檔案的相應部分來初始化一個程序的文字和資料段。

2.私有匿名對映:

每次呼叫 mmap()建立一個私有匿名對映時都會產生一個新對映,該對映與同一(或不同)程序建立的其他匿名對映是不同的(即不會共享物理分頁)。 儘管子程序會繼承其父程序的對映,但寫時複製語義確保在 fork()之後父程序和子程序不會看到其他程序對對映所做出的變更。私有匿名對映的主要用途是為一個程序分配新(用零填充)記憶體

(如在分配大塊記憶體時 malloc()會為此而使用 mmap())。

3.共享檔案對映:

所有對映一個檔案的同一區域的程序會共享同樣的記憶體物理分頁,這些分頁的內容將被初始化為該檔案區域。對對映內容的修改將直接在檔案中進行。這種對映主要用於兩個用途。

第一,它允許記憶體對映 I/O,這表示一個檔案會被載入到程序的虛擬記憶體中的一個區域中並且對該塊內容的變更會自動被寫入到這個檔案中。因此, 記憶體對映 I/O 為使用 read()和 write()來執行檔案 I/O 這種做法提供了一種替代方案。

第二種用途是允許無關程序共享一塊內容以便以一種類似於 System V共享記憶體段(第 48 章)的方式來執行 IPC。

4.共享匿名對映:

與私有匿名對映一樣,每次呼叫 mmap()建立一個共享匿名對映時都會產生一個新的、與任何其他對映不共享分頁的截然不同的對映。這裡的差別在於對映的分頁不會被寫時複製。這意味著當一個子程序在 fork()之後繼承對映時,父程序和子程序共享同樣的 RAM 分頁,並且一個程序對對映內容所做出的變更會對其他程序可見。共享匿名對映允許以一種類似於 System V 共享記憶體段的方式來進行 IPC,但只有相關程序之間才能這麼做。

一個程序在執行 exec()時對映會丟失,但通過 fork()建立的子程序會繼承對映,對映型別( MAP_PRIVATE 或 MAP_SHARED)也會被繼承。

通過 Linux 特有的/proc/PID/maps 檔案能夠檢視在 48.5 節中介紹過的與一個程序的對映有關的所有資訊。

49.2 建立一個對映: mmap()

mmap()系統呼叫在呼叫程序的虛擬地址空間中建立一個新對映。

#include<mman.h>
void *mmap(void *addr,size_t length,int prot,int flags,int fd,off_t offset)
/* 檔案對映需要傳fd,offset,匿名對映不需要*/

addr 引數指定了對映被放置的虛擬地址。如果將 addr 指定為 NULL,那麼核心會為對映選擇一個合適的地址。這是建立對映的首選做法。或者在 addr 中指定一個非 NULL 值時,核心會在選擇將對映放置在何處時將這個引數值作為一個提示資訊來處理。

在實踐中,核心至少會將指定的地址舍入到最近的一個分頁邊界處。不管採用何種方式,核心會選擇一個不與任何既有對映衝突的地址。(如果在 flags 包含了 MAP_FIXED,那麼 addr 必須是分頁對齊的。在 49.10 節中將會對這個標記進行介紹。 )

成功時 mmap()會返回新對映的起始地址。發生錯誤時 mmap()會返回 MAP_FAILED。

length 引數指定了對映的位元組數。儘管 length 無需是一個系統分頁大小( sysconf(_SC_PAGESIZE)返回值)的倍數,但核心會以分頁大小為單位來建立對映,因此實際上 length 會被向上提升為分頁大小的下一個倍數。 prot 參 數 是 一 個 位 掩 碼 , 它 指 定 了 施 加 於 映 射 之 上 的 保 護 信 息 , 其 取 值 要 麼 是PROT_NONE,要麼是表 49-2 中列出的其他三個標記的組合(取 OR)。

表 49-2:prot引數記憶體保護值

描 述
PROT_NONE 區域無法訪問
PROT_READ PROT_WRITE PROT_EXEC 區域內容可讀取 區域內容可修改 區域內容可執行

flags 引數是一個控制對映操作各個方面的選項的位掩碼。這個掩碼必須只包含下列值中一個。

MAP_PRIVATE 建立一個私有對映。區域中內容上所發生的變更對使用同一對映的其他程序是不可見的,對於檔案對映來講,所發生的變更將不會反應在底層檔案上。 MAP_SHARED 建立一個共享對映。區域中內容上所發生的變更對使用 MAP_SHARED 特性對映同一區域的程序是可見的,對於檔案對映來講,所發生的變更將直接反應在底層檔案上。對檔案的更新將無法確保立即生效,具體可參加 49.5 節中對 msync()系統呼叫的介紹。

除了 MAP_PRIVATE 和 MAP_SHARED 之外,在 flags 中還可以有選擇地對其他標記取OR。在 49.6 和 49.10 節中將會對這些標記進行介紹。

剩餘的引數 fd 和 offset 是用於檔案對映的(匿名對映將忽略它們)。 fd 引數是一個標識被對映的檔案的檔案描述符。 offset 引數指定了對映在檔案中的起點,它必須是系統分頁大小的倍數。要對映整個檔案就需要將 offset 指定為 0 並且將 length 指定為檔案大小。

前 面 提 過 mmap() prot 參 數 指 定 了 新 內 存 映 射 上 的 保 護 信 息 。 這 個 參 數 可 以 取PROT_NONE 或者 PROT_READ、 PROT_WRITE、以及 PROT_EXEC 中一個或多個標記的掩碼。如果一個程序在訪問一個記憶體區域時違反了該區域上的保護位,那麼核心會向該程序傳送一個 SIGSEGV 訊號。

標記為 PROT_NONE 的分頁記憶體的一個用途是作為一個程序分配的記憶體區域的起始位置或結束位置的守護分頁。如果程序意外地訪問了其中一個被標記為PROT_NONE 的分頁,那麼核心會通過生成一個 SIGSEGV 訊號來通知該程序這樣一個事實。

記憶體保護資訊駐留在程序私有的虛擬記憶體表中。因此,不同的程序可能會使用不同的保護位來對映同一個記憶體區域。

使用 mprotect()系統呼叫( 50.1 節)能夠修改記憶體保護位。

在一些 UNIX 實現上,實際施加於一個對映分頁上的保護位於在 prot 中指定的資訊可能不完全一致。特別地,底層硬體在保護粒度上的限制(如老式的 x86-32 架構)意味著在很多UNIX 實現上 PROT_READ 會隱含 PROT_EXEC,反之亦然,並且在一些實現上指定PROT_WRITE 會隱含 PROT_READ。但應用程式不應該依賴於這種行為; prot 指定的資訊應該總是與所需的記憶體保護資訊一致

標準中規定的對 offset 和 addr 的對齊約束

SUSv3 規定 mmap()的 offset 引數必須要與分頁對齊,而 addr 引數在指定了 MAP_FIXED 的情況下也必須要與分頁對齊。 Linux 遵循了這些要求,但後面又發現 SUSv3 的要求與之前的標準提出的要求是不同的,之前的標準對這些引數的要求要低一些。 SUSv3 中的措辭會(不必要地)導致一些之前符合標準的實現變得不符合標準了。 SUSv4 則放寬了這方面的要求:

1.一個實現可能會要求 offset 為系統分頁大小的倍數。

2.如果指定了 MAP_FIXED,那麼一個實現可能會要求 addr 是分頁對齊的。

3.如果指定了 MAP_FIXED 並且 addr 為非零值,那麼 addr 和 offset 除以系統分頁大小所得的餘數應該相等

49.3 解除對映區域: munmap()

munmap()系統呼叫執行與 mmap()相反的操作,即從呼叫程序的虛擬地址空間中刪除一個對映。

#include<mman.h>
void *munmap(void *addr,size_t length)

addr 引數是待解除對映的地址範圍的起始地址,它必須與一個分頁邊界對齊。( SUSv3 規定 addr 必須是分頁對齊的。 SUSv4 表示一個實現可以要求這個引數是分頁對齊的。 ) length 引數是一個非負整數,它指定了待解除對映區域的大小(位元組數)。範圍為系統分頁大小的下一個倍數的地址空間將會被解除對映。

一般來講通常會解除整個對映。因此可以將 addr 指定為上一個 mmap()呼叫返回的地址,並且 length 的值與 mmap()呼叫中使用的 length 的值一樣。下面是一個例子。

或者也可以解除一個對映中的部分對映,這樣原來的對映要麼會收縮,要麼會被分成兩個,這取決於在何處開始解除對映。還可以指定一個跨越多個對映的地址範圍,這樣的話所有在範圍內的對映都會被解除。

如果在由 addr 和 length 指定的地址範圍中不存在對映, 那麼 munmap()將不起任何作用並返回 0(表示成功)。 在解除對映期間,核心會刪除程序持有的在指定地址範圍內的所有記憶體鎖。(記憶體鎖是通過mlock()或 mlockall()來建立的, 50.2 節將會對此予以介紹。 ) 當一個程序終止或執行了一個 exec()之後程序中所有的對映會自動被解除。 為確保一個共享檔案對映的內容會被寫入到底層檔案中,在使用 munmap()解除一個對映之前需要呼叫 msync()

49.4 檔案對映

要建立一個檔案對映需要執行下面的步驟。

1. 獲取檔案的一個描述符,通常通過呼叫 open()來完成。

2. 將檔案描述符作為 fd 引數傳入 mmap()呼叫。

執行上述步驟之後 mmap()會將開啟的檔案的內容對映到呼叫程序的地址空間中。一旦mmap()被呼叫之後就能夠關閉檔案描述符了,而不會對對映產生任何影響。但在一些情況下,將這個檔案描述符保持在開啟狀態可能是有用的 除了普通的磁碟檔案,使用 mmap()還能夠對映各種真實和虛擬裝置的內容,如硬碟、光碟以及/dev/mem。 在開啟描述符 fd 引用的檔案時必須要具備與 prot 和 flags 引數值匹配的許可權。特別地,檔案必須總是被開啟以允許讀取,並且如果在 flags 中指定了 PROT_WRITE 和 MAP_SHARED,那麼檔案必須總是被開啟以允許讀取和寫入。 offset 引數指定了從檔案區域中的哪個位元組開始對映,它必須是系統分頁大小的倍數。將offset 指定為 0 會導致從檔案的起始位置開始對映。 length 引數指定了對映的位元組數。 offset和length 引數一起確定了檔案的哪個區域會被對映進記憶體,如圖 49-1 所示。

49.4.1 私有檔案對映

私有檔案對映最常見的兩個用途如下所述。

1.允許多個執行同一個程式或使用同一個共享庫的程序共享同樣的(只讀的)文字段,它是從底層可執行檔案或庫檔案的相應部分對映而來的。

2.對映一個可執行檔案或共享庫的初始化資料段。這種對映會被處理成私有使得對對映資料段內容的變更不會發生在底層檔案上。

mmap()的這兩種用法通常對程式是不可見的,因為這些對映是由程式載入器和動態連結器建立的。讀者可以在 48.5 節中給出的/proc/PID/maps 的輸出中發現這兩種對映。

私有檔案對映的另一個不太常見的用途是簡化程式的檔案輸入邏輯。這與使用共享檔案對映來完成記憶體對映I/O類似,但它只允許檔案輸入。

49.4.2 共享檔案對映

當多個程序建立了同一個檔案區域的共享對映時,它們會共享同樣的記憶體物理分頁。此外,對對映內容的變更將會反應到檔案上。實際上,這個檔案被當成了該塊記憶體區域的分頁儲存,如圖 49-2 所示。

共享檔案對映存在兩個用途:記憶體對映 I/O 和 IPC。下面將分別介紹這兩種用途。

記憶體對映 I/O

由於共享檔案對映中的內容是從檔案初始化而來的,並且對對映內容所做出的變更都會自動反應到檔案上,因此可以簡單地通過訪問記憶體中的位元組來執行檔案 I/O,而依靠核心來確保對記憶體的變更會被傳遞到對映檔案中。(一般來講,一個程式會定義一個結構化資料型別來與磁碟檔案中的內容對應起來,然後使用該資料型別來轉換對映的內容。 )這項技術被稱為記憶體對映 I/O,它是使用 read()和 write()來訪問檔案內容這種方法的替代方案。

記憶體對映 I/O 具備兩個潛在的優勢。

1.使用記憶體訪問來取代 read()和 write()系統呼叫能夠簡化一些應用程式的邏輯。

2.在一些情況下,它能夠比使用傳統的 I/O 系統呼叫執行檔案 I/O 這種做法提供更好的效能。

記憶體對映 I/O 之所以能夠帶來效能優勢的原因如下。

1.正常的 read()或 write()需要兩次傳輸:一次是在檔案和核心高速緩衝區之間,另一次是在核心高速緩衝區和使用者空間緩衝區之間。使用 mmap()就無需第二次傳輸了。對於輸入來講,一旦核心將相應的檔案塊對映進記憶體之後使用者程序就能夠使用這些資料了。 對於輸出來講,使用者程序僅僅需要修改記憶體中的內容,然後可以依靠核心記憶體管理器來自動更新底層的檔案。

2.除了節省了核心空間和使用者空間之間的一次傳輸之外, mmap()還能夠通過減少所需使用的記憶體來提升效能。當使用 read()或 write()時,資料將被儲存在兩個緩衝區中:一個位於使用者空間,另一個位於核心空間。當使用 mmap()時,核心空間和使用者空間會共享同一個緩衝區。此外,如果多個程序正在在同一個檔案上執行 I/O,那麼它們通過使用 mmap()就能夠共享同一個核心緩衝區,從而又能夠節省記憶體的消耗。

記憶體對映 I/O 所帶來的效能優勢在在大型檔案中執行重複隨機訪問時最有可能體現出來。如果順序地訪問一個檔案, 並假設執行 I/O 時使用的緩衝區大小足夠大以至於能夠避免執行大量的 I/O 系統呼叫,那麼與 read()和 write()相比, mmap()帶來的效能上的提升就非常有限或者說根本就沒有帶來效能上的提升。效能提升的幅度之所以非常有限的原因是不管使用何種技術,整個檔案的內容在磁碟和記憶體之間只傳輸一次,效率的提高主要得益於減少了使用者空間和核心空間之間的一次資料傳輸,並且與磁碟 I/O 所需的時間相比,記憶體使用量的降低通常是可以忽略的。

使用共享檔案對映的 IPC

由於所有使用同樣檔案區域的共享對映的程序共享同樣的記憶體物理分頁,因此共享檔案對映的第二個用途是作為一種 IPC 方法。

這種共享記憶體區域與 System V 共享記憶體物件之間的區別在於區域中內容上的變更會反應到底層的對映檔案上。這種特性對那些需要共享記憶體內容在應用程式或系統重啟時能夠持久化的應用程式來講是非常有用的。

49.4.3 邊界情況

在很多情況下,一個對映的大小是系統分頁大小的整數倍,並且對映會完全落入對映檔案的範圍之內。但這種要求不是必需的,下面來看一下當這些條件不滿足時會發生什麼事情。

圖 49-3 描繪了對映完全落入對映檔案的範圍之內但區域的大小並不是系統分頁大小的一個整數倍的情況(在這個討論中假設分頁大小為 4096 位元組)。

由於對映的大小不是系統分頁大小的整數倍,因此它會被向上舍入到系統分頁大小的下一個整數倍。 由於檔案的大小要大於這個被向上舍入的大小, 因此檔案中對應位元組會像圖 49-3中那樣被對映。

試圖訪問對映結尾之外的位元組將會導致 SIGSEGV 訊號的產生(假設在該位置處不存在其他對映)。這個訊號的預設動作是終止程序並打印出一個 core dump。 當對映擴充過了底層檔案的結尾處時(參見圖 49-4)情況就變得更加複雜了。與之前一樣,由於對映的大小不是系統分頁大小的整數倍,因此它會被向上舍入。但在這種情況下,雖然在向上舍入區域(即圖中 2200 位元組和 4095 位元組)中的位元組是可訪問的,但它們不會被對映到底層檔案上(由於在檔案中不存在對應的位元組),並且它們會被初始化為 0( SUSv3 對此進行了規定)。當然,這些位元組也不會與對映同一個檔案的其他程序共享,即使它們指定了足夠大的 length 引數。對這些位元組做出的變更不會被寫入到檔案中

如果對映中包含了超出向上舍入區域中(即圖 49-4 中 4096 以及之後的位元組)的分頁,那麼試圖訪問這些分頁中的地址將會導致 SIGBUS 訊號量的產生, 即警告程序檔案中沒有區域與這些地址對應。與之前一樣,試圖訪問超過對映結尾處的地址將會導致 SIGSEGV 訊號的產生。 從上面的描述中可以看出,建立一個大小超過底層檔案大小的對映可能是無意義的。但通過擴充套件檔案的大小(如使用 ftruncate()或 write()),可以使得這種對映中之前不可訪問的部分變得可用。

49.4.4 記憶體保護和檔案訪問模式互動

到目前為止還沒有詳細解釋的一點是通過 mmap() prot 引數指定的記憶體保護與對映檔案被開啟的模式之間的互動。

從一般原則來講, PROT_READ 和 PROT_EXEC 保護要求被對映的檔案使用 O_RDONLY 或 O_RDWR 開啟,而 PROT_WRITE 保護要求被對映的檔案使用O_WRONLY 或 O_RDWR 開啟。 然而,由於一些硬體架構提供的記憶體保護粒度有限,因此情況會變得複雜起來(參見 49.2節)。對於這種架構,下列結論是適用的。

1.所有記憶體保護組合與使用 O_RDWR 標記開啟檔案是相容的。

2.沒有記憶體保護組合——哪怕僅僅是 PROT_WRITE——與使用 O_WRONLY 標記開啟的檔案是相容的(導致 EACCES 錯誤的發生)。這與一些硬體架構不允許對一個分頁的只寫訪問這樣一個事實是一致的。在 49.2 節中指出過在那些架構上 PROT_WRITE隱含了 PROT_READ,這意味著如果分頁可寫入,那麼它也能被讀取。而讀取操作與O_WRONLY 是不相容的,該操作是不能暴露檔案的初始內容的。

3.使用 O_RDONLY 標記開啟一個檔案的結果依賴於在呼叫 mmap()時是否指定了MAP_PRIVATE 或MAP_SHARED。對於一個 MAP_PRIVATE 對映來講,在 mmap()中可以指定任意的記憶體保護組合——因為對 MAP_PRIVATE 分頁內容做出的變更不會被寫入到檔案中,因此無法寫入檔案不會成為問題。對於一個MAP_SHARED 對映來講 , 唯 一 與 O_RDONLY 兼 容 的 內 存 保 護 是 PROT_REA 和 (PROT_READ |PROT_EXEC)。這是符合邏輯的,因為一個 PROT_WRITE, MAP_SHARED 對映允許更新被對映的檔案。

49.5 同步對映區域: msync()

核心會自動將發生在 MAP_SHARED 對映內容上的變更寫入到底層檔案中,但在預設情況下,核心不保證這種同步操作會在何時發生。 ( SUSv3 要求一個實現提供這種保證。 )

msync()系統呼叫讓應用程式能夠顯式地控制何時完成共享對映與對映檔案之間的同步。

#include<sys/mman.h>
int msync(void *addr,size_t length,int flags);

同步一個對映與底層檔案在多種情況下都是非常有用的。如,為確保資料完整性,一個數據庫應用程式可能會呼叫 msync()強制將資料寫入到磁碟上。呼叫 msync()還允許一個應用程式確保在可寫入對映上發生的更新會對在該檔案上執行 read()的其他程序可見。

傳給 msync()的 addr 和 length 引數指定了需同步的記憶體區域的起始地址和大小。在 addr中指定的地址必須是分頁對齊的, length 會被向上舍入到系統分頁大小的下一個整數倍。 ( SUSv3 規定 addr 必須要分頁對齊。 SUSv4 表示一個實現可以要求這個引數是分頁對齊的。 ) flags 引數的可取值為下列值中的一個。 MS_SYNC 執行一個同步的檔案寫入。這個呼叫會阻塞直到記憶體區域中所有被修改過的分頁被寫入到底盤為止。 MS_ASYNC 執行一個非同步的檔案寫入。記憶體區域中被修改過的分頁會在後面某個時刻被寫入磁碟並立即對在相應檔案區域中執行 read()的其他程序可見。 另一種區分這兩個值的方式可以表述為在 MS_SYNC 操作之後,記憶體區域會與磁碟同步,而在 MS_ASYNC 操作之後,記憶體區域僅僅是與核心高速緩衝區同步

在 flags 引數中還可以加上下面這個值。 MS_INVALIDATE 使對映資料的快取副本失效。當記憶體區域中所有被修改過的分頁被同步到檔案中之後,記憶體區域中所有與底層檔案不一致的分頁會被標記為無效。當下次引用這些分頁時會從檔案的相應位置處複製相應的分頁內容,其結果是其他程序對檔案做出的所有更新將會在記憶體區域中可見。

與很多其他現代 UNIX 實現一樣, Linux 提供了一個所謂的同一虛擬記憶體系統。這表示記憶體對映和高速緩衝區塊會盡可能地共享同樣的實體記憶體分頁。因此通過對映獲取的檔案檢視與通過 I/O 系統呼叫( read()、 write()等)獲得的檔案檢視總是一致的,而 msync()的唯一用途就是強制將一個對映區域中的內容寫入到磁碟。

不管怎樣, SUSv3 並沒有要求實現統一虛擬記憶體系統,並且並不是所有的 UNIX 實現都提供了同一虛擬記憶體系統。在這類系統上需要呼叫 msync()來使得一個對映上發生的變更對其他 read()該檔案的程序可見,並且在執行逆操作時需要使用 MS_INVALIDATE 標記來使得其他程序對檔案所做出的寫入對對映區域可見。使用 mmap()和 I/O 系統呼叫操作同一個檔案的多程序應用程式如果希望可被移植到不具備統一虛擬記憶體系統的系統之上的話就需要恰當使用 msync()。