JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

mmap 與記憶體映射檔案

本階建起的每一個機制——分頁表、需求分頁、分頁錯誤、寫入時複製、每頁的保護——都匯聚在一個優雅的系統呼叫裡。mmap() 讓你只靠解參考一個指標就能觸及一個檔案,看不到任何 read() 迴圈,而核心只在你碰到那些頁時,才把它們拉進來。

一個你可以「指」的檔案

到目前為止,前面幾階教你碰一個檔案時,那些動詞總是同一組:open() 拿到一個檔案描述符、接著一個 read() 迴圈把位元組搬進你配置的緩衝區、然後 close()。資料住在磁碟上,你把它拷進你自己的記憶體、在那份拷貝上工作,若你改了它,再用 write() 把它拷回去。那種拷貝,就是一般檔案 I/O的整個樣貌。檔案和你的記憶體是兩個分開的世界,而 read() 與 write() 是它們之間的渡船。mmap() 提出一件令人吃驚的事:如果那個檔案「就是」記憶體呢?如果你能像取陣列的第 5000 個元素那樣,靠寫 `p[5000]` 就觸及一個檔案的第 5000 個位元組——沒有 read()、沒有緩衝區、沒有渡船呢?

那正是一個記憶體映射的意思。你請求核心拿一個檔案(或一塊全新的空白區域),把它織進你行程的位址空間,讓一段連續的虛擬位址從此「代表」那個檔案的內容。mmap() 遞回給你一個指向那段範圍第一個位元組的指標。從那一刻起,這個檔案就能透過單純的指標算術與解參考來觸及:`*p` 是檔案的第一個位元組、`p[100]` 是它在偏移 100 處的位元組,而寫 `p[100] = 'X'` 就改變了那個檔案。那個 read() 迴圈並沒有變快——它「消失」了。這個映射是本階建起的一切最自然的回報,因為一個解析到「檔案資料」而不是「匿名記憶體」的虛擬位址,仍然只是個虛擬位址,用同一套分頁表、以同樣的方式被轉譯。

這個呼叫,一個引數一個引數地看

mmap() 乍看很忙——六個引數——但每一個都在回答一個樸素的問題,而多數時候你都設同一組預設值。它的簽名是 `void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset)`。addr 是「要把映射擺在你位址空間哪裡」的提示;傳 NULL 讓核心自己挑,這幾乎總是你要的。length 是要映射多少位元組。prot 是你想給那些頁的保護——PROT_READ、PROT_WRITE、PROT_EXEC 或 PROT_NONE——用 OR 組起來,就和你在檔案那階看過的 open 旗標一模一樣。fdoffset 指名那個檔案、以及映射從檔案裡哪個位元組開始;offset 必須是分頁大小的整數倍。而 flags 決定這個映射最要緊的那一件事,下一節就整節在講它。

進到程式碼前,先講兩個誠實的細節。第一,你選的保護,就是 MMU 對每一次存取都強制執行的那種「每頁保護」——只用 PROT_READ 要一個唯讀映射、然後往裡面寫,你就掙得一個保護違規的分頁錯誤,它在你程式裡浮現為一個記憶體區段錯誤,正是當一個空指標或野指標亂闖進一個禁區頁時你遇過的那個 SIGSEGV。這個保護不是建議性的;硬體在每一次存取的位址轉譯上都檢查它。第二,mmap() 回報失敗時用的不是 NULL,而是特殊值 MAP_FAILED(也就是 (void *)-1),所以檢查要寫成 `if (p == MAP_FAILED)`,而不是跟 NULL 比——一個值得記住的小陷阱。

#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>

int fd = open("data.bin", O_RDONLY);
if (fd == -1) { perror("open"); return 1; }

struct stat st;
if (fstat(fd, &st) == -1) { perror("fstat"); return 1; }
size_t len = st.st_size;                 /* map the whole file */

/* read-only, shared with the file, starting at offset 0 */
char *p = mmap(NULL, len, PROT_READ, MAP_SHARED, fd, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }

close(fd);                  /* the mapping stays valid after close */

char first = p[0];          /* touching p[0] triggers a page fault */
char mid   = p[len / 2];    /* and so does this, on a different page */

munmap(p, len);             /* tear the mapping down when done */
把整個檔案以唯讀方式映射。fstat() 給出長度;映射建立後就可以關掉 fd;munmap() 釋放它。任何一頁的第一次碰觸,才是真正把那一頁的位元組從磁碟拉進來的時刻。

共享、私有,與寫入時複製

flags 引數攜帶著「定義你擁有哪一種映射」的那個決定,而它幾乎總是歸結到兩個詞之一。MAP_SHARED 代表你的寫入會「貫穿」到那個檔案,並對每一個同樣映射了該檔案的其他行程都可見——映射的頁和那個檔案是同一份資料,所以寫 `p[10] = 'Z'` 最終會把那個位元組落到磁碟上、也落到所有其他人的視野裡。這正是 mmap() 成為共享記憶體工具的方式:兩個都用 MAP_SHARED 映射同一個檔案的行程,看的是同一組實體頁,其中一方的一次寫入,立刻就是另一方的一次讀取,沒有拷貝、也不必每個位元組都做一次系統呼叫。相對地,MAP_PRIVATE 給你一個私有的視野:你能讀檔案的內容,但你做的任何寫入都只屬於你自己,永遠不碰那個檔案、也不碰任何別人。

現在仔細看 MAP_PRIVATE 必須承諾什麼,你就會認出第 4 篇的一位老朋友。核心必須讓許多行程共用檔案那些頁的同一份唯讀拷貝以節省記憶體,卻又要保證:在「你」一寫到某一頁的那一刻,你的更動就留在私有狀態。那正是寫入時複製。在你寫之前,你的私有映射指向的,就是檔案唯讀資料所佔的那些完全相同的實體頁,共用著、並在你的分頁表裡被標成唯讀。你對這樣一頁的第一次寫入會以「寫保護錯誤」陷入;核心悄悄地只替那一頁做一份私有拷貝、把你的分頁表項重新指向那份拷貝、把它標成可寫、再讓你的寫入完成。你寫的是那份拷貝;原本的檔案頁毫髮無傷。fork() 對整個位址空間重用的正是這個招數——mmap() 對一個檔案重用它。

它為什麼快:需求分頁在替你做工

你第一次走過一個映射的檔案時,看看那套機制實際做了什麼,因為這就是第 3 篇在運作。mmap() 瞬間就回傳、什麼也沒讀;你的分頁表現在對整段映射範圍都有了項目,但它們被標成「不在場」。在你解參考一個指向「你還沒碰過的頁」的指標的那一刻,MMU 發現那個不在場的項目、就拋出一個分頁錯誤。核心攔下它、看出那個位址落在一個檔案映射裡、把檔案裡剛好那一從磁碟讀進一個空閒的實體框、修好那個分頁表項讓它指向那個框並標成在場、然後繼續你的指令。你的 `*p` 像什麼都沒發生過似地完成了。這就是最純粹的需求分頁:檔案的頁「只有在」、而且「只有當」你真的碰它時,才被載入。

這就是為什麼映射一個 2 GiB 的檔案、然後從它正中間讀出一個結構,幾乎不花什麼。一支用 read() 的程式得定位、再至少拷貝一個區塊;用 mmap() 的版本碰一頁、吃一次分頁錯誤、就完事了——其餘 2 GiB 的檔案頁永遠不會被帶進來,因為你從沒指向它們。而且一旦一頁進來了,它的轉譯就能住進 TLB,所以對「同一頁」的「第二次」存取根本不額外花什麼——沒有錯誤、沒有核心,只是一次硬體般快的轉譯。整個成本模型翻轉了:用 read(),你為「你可能會用到的一切」預先付一次系統呼叫加一次拷貝;用 mmap(),你只為「你真正碰到的那些頁」、懶惰地、各付一次分頁錯誤。

匿名映射、持久性,與那些尖角

並非每個映射都有檔案在背後撐著。在 flags 裡傳 MAP_ANONYMOUS(並設 fd = -1),mmap() 就給你一塊背後什麼都沒有、只由零填充的頁所支撐的區域——一塊全新的空白記憶體。安靜地說,這正是你很多記憶體早就來自的地方:一個大的 malloc() 往往根本不是用 sbrk() 去擴張堆積,而是向 mmap() 要一塊匿名區域,再由 C 函式庫把它的一片切給你。一個在 fork() 「之前」做好的匿名 MAP_SHARED 映射,也是讓父行程與子行程共享記憶體的乾淨方式,因為那些共享頁會存活進子行程,而一塊 MAP_PRIVATE 的寫入時複製區域則會在那裡分裂開來。同一套機器同時處理檔案支撐的與匿名的記憶體;唯一的差別是一個全新的頁是從什麼填出來的——一個磁碟區塊,或是零。

現在講誠實的危險,因為 mmap() 用 read() 迴圈的彆扭,換來了另一組會打到自己腳的槍。第一,「持久性不是自動的」。當你寫進一個 MAP_SHARED 的頁,你改的是記憶體裡的一頁;核心會按它自己的排程把它寫回磁碟,可能晚得多,而這中間若當機就可能丟掉那些寫入。若你需要那個更動「現在」就在磁碟上——一次資料庫提交、一筆日誌——你呼叫 msync(p, len, MS_SYNC) 強制把那些髒頁推出去,這是你在 write() 之後會用的 fsync() 在 mmap() 世界的對應物。第二,這個映射的大小在 mmap() 當下就凍結了;你不能靠「透過映射寫到檔案結尾之外」來把檔案撐大,而碰觸「落在檔案實際長度之外」的映射頁,會掙得一個 SIGBUS——它是 SIGSEGV 的表親,明確意指「這個映射位址背後沒有支撐」。

  1. open() 那個檔案並檢查是否為 -1,然後對它做 fstat() 以得知它確切的長度——映射不可越過檔案的真實大小。
  2. 用 NULL 的 addr、那個長度、你需要的 prot 位元、以及 MAP_SHARED 或 MAP_PRIVATE 呼叫 mmap();把結果跟 MAP_FAILED 比,而不是跟 NULL。
  3. 你現在就可以 close() 那個檔案描述符——映射會自己讓那個檔案存活,與 fd 無關。
  4. 把那塊區域當成單純記憶體用:用 *p / p[i] 讀,對一個可寫的共享映射,用 p[i] = ... 寫;每一頁的第一次碰觸會把它的資料錯入。
  5. 若持久性要緊,在依賴那些更動之前先對髒頁 msync();完成後再 munmap(p, len) 釋放這個映射。

退一步,看看 mmap() 是多麼徹底地是本階的一個「結果」、而不是一個新點子。它回傳的指標是一個虛擬位址;檔案到頁的對應住在分頁表裡;位元組靠著一次分頁錯誤上的需求分頁而抵達;一個私有映射靠寫入時複製保持私有;而讀/寫/執行的規則,是 MMU 在每次轉譯上都檢查的每頁保護。mmap() 沒有加進任何機制——它給了你一個把手,握住核心一直在替你的記憶體運轉的那套機制。這是虛擬記憶體一個恰當的收尾:這層抽象從來不只是把硬體藏起來不讓你看;只用一個系統呼叫,它就把鑰匙交到你手裡。