1. 程式人生 > 實用技巧 >回爐重造之重讀Windows核心程式設計-017- 記憶體對映檔案

回爐重造之重讀Windows核心程式設計-017- 記憶體對映檔案

第17章 記憶體對映檔案

對於應用程式這種型別的檔案,自然也要有被開啟和關閉這些操作,只不過有兩種方式值得爭議:是直接開啟檔案讀寫它最後關閉、還是提供一種快取的做法在檔案不同部位操作呢? Windows的方案是一種兩全其美的方法,叫記憶體對映檔案

先保留一個地址空間的區域,並將物理儲存器提交給該區域,這和虛擬記憶體一樣。只不過記憶體對映檔案沒有系統的頁檔案中轉,而是直接來自物理儲存器。一旦檔案被對映,就可以訪問它,就像它已經載入到記憶體中一樣。

記憶體對映檔案大致有三種用法:

  1. 載入exedll可以大大的節省頁檔案空間和啟動應用程式啟動執行所需要的時間。

17.1 記憶體對映的可執行檔案和DLL檔案

首先當執行緒呼叫CreateProcess時,系統會這麼辦:

  1. 找出函式的引數中設定的exe檔案,找不到就返回FALSE。
  2. 建立一個新的核心物件。
  3. 為新的程序建立一個私有的地址空間。
  4. 保留一個足夠大的地址空間區域存放exeDLL檔案檔案。然後載入到基地址0x400000(x86的情況,也可以使用連結選項/BASE設定)。
  5. exe檔案被對映到程序的地址空間後,系統還要訪問exe檔案的關於需要匯入的DLL檔案,使用LoadLibrary函式載入這些DLL
    1. 系統又要儲存足夠大的區域存放DLL檔案,基地址預設是0x10000000。由於系統提供的標準DLL檔案都有不同的基地址,這樣它們載入就不會重疊。
    2. 如果由於某種原因DLL檔案
      無法載入到預設的基地址,系統就會找另一個地址載入。但這是很不好的情況,首先這個時候如果系統沒有再定位資訊,DLL就無法載入,再則系統要在DLL中執行某些重定位操作,這需要更多的儲存器,也增加了載入DLL檔案的時間。
    3. 雖然支援已經保留區域物理儲存器是在磁碟的DLL檔案也不是系統的頁檔案,但是如果DLL檔案無法載入到預設的基地址,系統就必須做重定位操作,DLL檔案中的某些物理儲存器也已經對映到頁檔案中。

如果還是由於某些原因呼叫CreateProcess仍然失敗,返回FALSE,那麼系統會顯示一個資訊框,並釋放程序的地址空間和程序物件。可以使用GetLastError函式獲取失敗資訊。

如果都沒錯,exe

的啟動程式碼就可以被執行,系統開始管理所有的分頁、快取和高速緩衝的處理,例如缺頁異常,這是掩蓋在程式內部的,可以重複執行。

17.1.1 可執行檔案或DLL的多個例項不能共享靜態資料

其實就是說一個寫時拷貝(copy-on-wriite)這個機制。至於起因,就是因為舊舊程序A建立新的程序B被後,根據上面的描述,新的記憶體對映檔案也被建立。而如果A修改這個記憶體對映檔案中的全域性變數,B中相應的全域性變數也會被修改。
所以就需要寫時拷貝的機制來防止這種情況了。這樣一旦應用程式嘗試寫入它的記憶體對映檔案,系統就會注意到這種情況,就會分配一個新的記憶體塊用來支援寫入操作,當然這個記憶體塊是位於被寫入的程序的記憶體映像中的。 如此就可以防止當前程序的全域性變數被其他程序篡改了。

17.1.2 在可執行檔案或DLL的多個例項之間共享靜態資料

但是多個例項共享資料仍然是很常見的需求。所以還是有一種方法,允許可執行檔案中的變數成為共享的狀態。這個辦法就是說明變數是被連結到一個可以被讀寫的節裡。原理如下:

每個可執行檔案都由很多節組成,程式碼存在text節,資料則分成已初始化(.data)和未初始化(.bss)兩種。
而每一個節都有其相關的屬性:

屬性 含義
READ 該節中的位元組可以讀取
WRITE 該節中的位元組可以寫入
EXECUTE 該節中的位元組可以執行
SHARED 該節中的位元組可以被多個例項共享(此屬性可以有效地關閉copy-on-write機制)

使用Visual StudioDumpBin程式,帶上/Headers開關,可以檢視可執行程式的各個節的內容:

節名 作用
.bss 未經初始化的資料
.CRT C執行時只讀資料
.data 已經初始化的資料
.debug 除錯資訊
.didata 延遲輸入檔案名錶
.edata 輸出檔案名錶
.idata 輸入檔案名錶
.rdata 執行時只讀資訊
.reloc 重定位表資訊
.rsrc 資源
.text exe或者dll檔案的程式碼
.tls 執行緒的本地儲存
.xdata 異常處理表
// 建立一個名稱為 Shared 的節,包含一個LONG值
#pragma data_seg("Shared")  // 節的內容的起始標記
LONG g_lInstanceCount = 0; // 已經初始化
int b; // 未經初始化
#pragma data_seg() // 節的內容的結束標記

這樣當編譯器對這個程式碼進行編譯時,就會建立這個節,並將其後的全域性變數放入了這個節的宣告中。程式碼中的第5行告訴編譯器停止,將變數放入這個新節中。
Visual C++編譯器提供了一個Allocate說明符,有同樣的效用:

__declspce(allocate("Shared")) int c = 0; // 已初始化
__declspce(allocate("Shared")) int d; // 未初始化
int e = 0; // 已初始化
int f; // 未初始化

有了上面的操作,還差一步能讓這些變數共享。連結程式也需要知道,哪些節中的變數是需要共享的。通過連結命令列的/SECTION開關才能做到:

/SECTION:name,attributes // 例項
/SECTION:Shared,RWS // R代表Read,W代表Write,S代表Shared
#pragma comment(linker, "/SECTION:Shared,RWS")

雖然共享節可以有這個優勢,但是也是有風險的。有兩個原因:第一,共享記憶體破壞了系統的安全;第二,共享變數一個程式的錯誤可能影響到另一個程式的執行。黑客只需要監視到這個舉措,寫一段很短的程式,載入到你的產品的DLL中,就能監控共享資料。這樣一旦使用者輸入口令,就有機會被他們截獲。

17.1.3 AppInst示例程式

清單17-1列出的AppInst示例顯示了應用程式如何能知道每次有多少個應用程式的例項在執行。
一旦這個應用程式的新例項開始執行,新舊兩個例項的對話方塊都會發生變化。

17.2 記憶體對映資料檔案

有了記憶體對映檔案,對大量的資料進行操作是非常方便的。書中舉了一個例子:一個應用程式把檔案中的所有位元組按原來的順序倒序。裡面提出了四種思路:

  • 一個檔案,一個快取
    • 就分配足夠大的記憶體塊來存放整個檔案,然後對記憶體塊進行倒敘,再回寫到檔案中去。
    • 這樣如果檔案很大,比如超過2G,32的系統是不允許應用程式提交那麼大的實體記憶體塊的。
    • 還有如果倒敘操作的執行期間,發生了中斷,那麼檔案的內容就會被破壞。當然可以儲存一個拷貝,但這又需要更多的磁碟空間。
  • 兩個檔案,一個快取
    • 除了舊檔案,再多建立一個長度為0的新檔案。然後分配一個固定大小(例如8K)的快取,讀取舊檔案尾部8K大小的內容,倒敘,再寫入新檔案。這些操作反覆進行到舊檔案的開頭(當然如果如果檔案的大小不是8K的倍數的話,需要特殊的操作),就關閉兩個檔案,刪掉舊檔案。
    • 這樣做確實節省了內容,不過有兩個問題:
      • 這個方式需要迴圈處理,處理的速度就慢了很多。
      • 可能會消耗掉大量的硬碟空間。如果舊檔案是400MB,那麼全部操作結束前程式需要佔用800MB的空間。
  • 一個檔案,兩個快取
    • 思路和上面類似,如果有兩個快取固定大小(例如8K),就可以不用新檔案了。先把檔案頭部和尾部的資料分別寫入S和E兩個快取中,然後快取中的資料作倒敘操作,再把S裡的內容寫到檔案的結束,E中的內容寫到檔案的開頭。迴圈這個過程直到檔案內容S和E相同,說明倒敘完畢,可以關閉檔案、釋放快取了。
    • 這個方式和上面的做法相比,節省了很可觀的磁碟空間,又沒有比較離譜的記憶體開銷。
    • 當然和上面也一樣有遇到中斷,這個方法會破壞原始的資料檔案。
  • 一個檔案,沒有快取
    • 通過記憶體對映檔案直接修改檔案:
      • 開啟檔案,然後告訴系統將本程序的地址空間中的一段區域保留並倒序,再把檔案的內容寫入這段區域中。這樣就完成了檔案逆序的操作。
    • 如果出現電源故障之類的問題使得程序中斷,資料就被破壞了。

17.3 使用記憶體對映檔案

使用記憶體對映檔案有以下的步驟:

  1. 建立或開啟一個檔案核心物件,用於標識磁碟上的目標檔案;
  2. 建立一個檔案對映核心物件,告訴系統檔案的大小和訪問方式;
  3. 讓系統將檔案對映物件的全部或一部分對映到你的程序空間中;

完成對記憶體對映檔案的使用後,使用下列的步驟釋放相關資源:

  1. 告訴系統從你的程序的地址空間中撤銷檔案對映物件的映像;
  2. 關閉記憶體對映檔案核心物件;
  3. 關閉檔案核心物件;

17.3.1 步驟1:建立或開啟檔案核心物件

首先建立或者開啟一個檔案核心物件。呼叫CreateFile函式;

17.3.2 步驟2:建立或開啟檔案對映核心物件

做了上面的步驟,系統已經知道了檔案的物理儲存的位置,現在需要告訴系統你需要多少物理儲存器,這裡就呼叫函式CreateFileMapping了。

HANDLE CreateFileMapping(
	HANDLE hFile,
	PSECURITY_ATTRIBUTES psa,
	DWORD fdwProtect,
	DWORD dwMaximumSizeHigh,
	DWORD dwMaximumSizeLow,
	PCTSTR pszName);

hFile 標識對映到程序記憶體空間的檔案的控制代碼。獲得這個控制代碼的時候 。
psa 指向PSECURITY_ATTRIBUTE結構的指標,通常是NULL。
fdwProtect 當系統將hFile指向的儲存對映到程序的地址空間時,指定的保護屬性。
dwMaximumSizeHigh 32位下始終是0,只是方便64位擴充套件。
dwMaximumSizeLow 代表檔案的最大位元組數。
pszName 以0結尾的字串,用於給檔案對映物件賦予一個名字,便於和其他程序共享。可是記憶體對映資料檔案通常並不需要被共享,所以它通常是NULL。
最後,如果CreateFileMapping失敗了,返回的是NULL,不是INVALID_HANDLE_VALUE(-1)。

17.3.3 步驟3:將檔案資料對映到程序的地址空間

接下來呼叫MapViewOfFile函式。因為現在只有一個檔案對映物件,還是需要讓系統為檔案的資料保留一段空間,那麼對映到這個區域的檔案資料就可以提交。

PVOID MapViewOfFile(
	HANDLE hFileMappingObject,
	DWORD dwDesireAccess,
	DWORD dwFileOffetHigh,
	DOWRD dwFileOffetLow,
	SIZE_T dwNumberOfBytesToMap);

hFileMappingObject標識檔案對映物件的控制代碼,產生於CreateFileMapping函式或OpenFileMapping函式,dwDesireAccess用於標識如何訪問該資料,可以是下面表中的任意一個。

含義
FILE_MAP_WRITE 可以讀取和寫入資料,CreateFileMapping函式必須同時傳遞PAGE_READWRITE標誌以呼叫
FILE_MAP_READ 可以讀取資料,CreateFileMapping函式可以通過PAGE_READONLY、PAGE_READWRITE或者PAGE_WRITECOPY
FILE_MAP_ALL_ACCESS 和FILE_MAP_WRITE相同
FILE_MAP_COPY 可以讀取和寫入資料。如果寫入資料,可以建立一個頁面的私有拷貝,CreateFileMapping函式中必須用了PAGE_READONLY、PAGE_READWRITE或者PAGE_WRITECOPY其中一個保護屬性

如此這般的保護屬性或許已經讓人不勝其煩,不過也同時顯示出記憶體的保護有多重要。

剩餘的3個引數與保留地址空間及物理儲存器對映到這個空間相關。當你將一個檔案對映到你的程序的地址空間中時,其實不必一次性把所有檔案對映進去,而是隻對映一部分。這一部分檔案被稱為“檢視”,這也是函式叫做MapViewOfFIle的原因。

接下來就是所要對映的檔案內容了。系統必須知道資料檔案中的哪一個位元組應該作為檢視的第一個位元組來對映,可以用引數dwFileOffetHigh和dwFileOffetLow來指定。由於Windows支援的檔案最大可以到16EB,因此必須用一個64位的值來描述它。這個64位值的高位用dwFileOffetHigh引數描述高32位、dwFileOffetLow引數描述低32位。當然,這兩個值也必須是一個頁面的大小的整數倍。頁面的大小後面會提到。

有了資料的起始,還需要資料的大小,知道有多少位元組要對映到地址空間。描述這個資料的大小的就是引數dwNumberOfBytesToMap。如果這個值是0,那麼檔案中所有的內容都會被對映到地址空間中。

如果MapViewOfFile函式被呼叫時,引數設定了FILE_MAP_COPY,頁檔案就要參與到進來了。由於頁檔案其實也是記憶體(或者說是RAM和檔案映像檢視),只是因為不常用而被交換出來的。設定了引數FILE_MAP_COPY之後,只要有程序中的任何執行緒往檔案映像檢視中寫入資料,那麼系統就會取出頁檔案中的一個頁面,將原始資料拷貝到這個頁面中,再對映到到程序的地址空間。至此,那個執行緒就要訪問本地的拷貝,不能讀取或修改原始資料了。當系統製作原始頁面的拷貝時,系統吧頁面的保護屬性從PAGE_WRITECOPY改為PAGE_READWRITE。

17.3.4 步驟4:從程序的地址空間中撤銷檔案資料的映像

當你不再需要已經對映到地址空間的檔案資料的時候,可以通過下面這個函式將它釋放:

BOOL UnmapViewOfFile(PVOID pvBaseAddress);

該函式唯一的引數就是MapViewOfFile的返回值。記得必須呼叫這個函式,不然這個對映在你的程序結束之前所保留的區域是不會被釋放的,畢竟MapViewOfFile永遠會找一片新的區域,而舊的將不會被釋放。

如果需要確保你對資料的修改被寫入磁碟,可以強制系統將修改過的資料(的部分)寫入磁碟映像中,方法是呼叫下面這個函式:

BOOL FlushViewOfFile(
	PVOID pvAddress,
	SIZE_T dwNumberOfByteToFlush);

兩個引數分別代表了需要寫入的資料的起始位置和位元組數。
對於有工作站的情形,FlushViewOfFile也許就只是將檢視寫入伺服器的快取記憶體而不是磁碟了。若要保證資料被寫入磁碟,你需要將FILE_FLAG_WRITE_THROUGH標誌傳遞給CreateFile。這樣僅當檔案的所有資料都已經存放在伺服器時,FlushViewOf'File才返回。

記住UnmapViewOfFile函式的一個很重要的特性。如果原先使用FILE_MAP_COPY標誌來對映檢視,那麼你對檔案的資料做的任何修改,實際上是對存放在系統的頁檔案中的檔案資料的拷貝作的修改。此時如果呼叫UnmapViewOfFile,磁碟上是不會有更新的,只會釋放頁檔案中的檔案,從而導致資料丟失。為了儲存修改後的資料,必須採用其他的措施。

17.3.5 步驟4和6:關閉檔案對映物件和檔案物件

核心物件作為一種系統資源總是要被關閉的。如果忘記關閉,就會發生記憶體洩漏的問題。當然當你的程序終止時系統會自動關閉你忘記關閉任何物件,不過”正確“的事總是要被做的,資源洩漏可不是小事。

17.3.6 檔案倒序示例應用程式

FileRev應用程式在清單17-2中列出。

17.4 使用記憶體對映檔案來處理大檔案

上面介紹瞭如何將一個16EB的檔案對映到一個較小的地址空間中,當然這一點是不能做到的,你只能對映一個包含一小部分檔案資料的檔案檢視。首先對映一個檔案的開頭的檢視,完成對它的訪問,就可以取消它的映像。然後對映一個從檔案中的一個更深的位移開始的新檢視。重複這個操作,直至訪問了整個檔案。這樣的方式使得大型記憶體對映檔案的處理並不方便。

17.5 記憶體對映檔案與資料檢視的相關性

系統允許你對映一個檔案的相同資料的多個檢視。這樣,系統就會保證對映的檢視的相關性。例如一個文字檔案有多個相同位置的對映檢視的話,其中一個檢視一旦被改變,那麼其他的檢視也會更新這個變化。這是因為資料是放在單個RAM頁面上。

然而沒有理由使得其他程序無法用CreateFile函式開啟自己已經對映的同一個檔案,即使是呼叫ReadFile和WriteFile也是可以的。在讀寫檔案的時候的緩衝區就需要使用者自己提供了,不能直接使用對映檔案使用的記憶體緩衝區。
這樣又會產生資源爭奪的問題,就是多個執行緒可以同時修改這個檔案的資料了。為了避免這種情況,呼叫CreateFile函式時dwShareMode引數的值就要設定成0。這樣就可以告訴系統,當前的程序需要單獨訪問這個檔案,其他檔案不能開啟它。
只讀檔案就沒有這樣的問題了。

17.6 設定記憶體對映檔案的基地址

還有一個函式MapViewOfFileEx,是用來確定一個檔案被對映到某個特定的地址的:

PVOID MapViewOfFileEx(
  HANDLE hFileMappingObject,
  DWORD dwDesiredAccess,
	DWORD dwFileOffsetHigh,
	DWORD dwFileOffsetLow,
	SIZE_T dwNumberOfBytesToMap,
	PVOID pvBaseAddress);

不同的只是最後一個引數pvBaseAddress。這個值當然也必須是64KB的倍數。
這個值pvBaseAddress如果是NULL,那麼MapViewOfFileEx和MapViewOfFile就並沒有不一樣的地方。

17.7 實現記憶體對映檔案的具體方法

比較了Win98和Win2k之間使用記憶體對映檔案的區別。

17.8 使用記憶體對映檔案在程序之間共享資料

Windows中提供了各種機制用於應用程式間共享資料,這些機制包括RPC、COM、OLE、DDE、視窗訊息(WM_COPYDATA)、剪貼簿、管道、套接字等。不過最常用的還是記憶體對映檔案,特別是對效能和開銷都有要求的時候。

多個程序只需要對映同一個檔案就可以達到共享的效果,這意味著它們共享物理儲存器的同一個頁面。其中一個程序改變了檔案的資料,其他執行緒都會得到這個變更。只是有一點需要注意,就是這些程序需要同樣的名字來表示這個檔案對映物件。