Learning Objectives
- 算出單層頁表要多大,說明多層頁表怎麼省下空間
- 把 48 位元的虛擬位址拆成 9+9+9+9+12,逐層查到實體位址
- 說明分頁錯誤的處理流程,區分合法的缺頁與 SIGSEGV
- 用需求分頁解釋「malloc 成功不等於佔用了記憶體」
- 看懂 fork 之後頁框怎麼共用、寫入才複製,以及 MAP_SHARED 與 MAP_PRIVATE 的差別
頁表放不下:先算一次
x86-64 Linux 常見的設定是 48 位元的虛擬位址、4 KB 一頁。如果用一張簡單的陣列當頁表:
頁數 = 2⁴⁸ ÷ 2¹² = 2³⁶ 頁
每一項 8 位元組
單層頁表 = 2³⁶ × 8 = 512 GB
每個行程 512 GB,只為了放頁表。可是一個行程實際用到的頁通常很少,集中在幾個地方(上一課的位址空間:程式碼、堆積、堆疊),中間那一大片根本沒有東西。
單層頁表的問題是:沒用到的頁也要佔一項。解法和你整理檔案的方式一樣:不要開一個一億格的櫃子,開一個目錄,只有真的有東西的區段才建立下一層。
四層頁表
x86-64 把 36 位元的頁號切成四段,每段 9 位元:
| 位元 | 47~39 | 38~30 | 29~21 | 20~12 | 11~0 |
|---|---|---|---|---|---|
| 用途 | L4 索引 | L3 索引 | L2 索引 | L1 索引 | 頁內位移 |
| 長度 | 9 | 9 | 9 | 9 | 12 |
每一層都是一張 512 項(2⁹)的表,每項 8 位元組,正好 4 KB,剛好放進一頁。CPU 的 CR3 暫存器指向最上層,往下每一層的項目指向下一層,最後一層指向資料的頁框。整片區域沒用到時,上層那一項空著,底下的表根本不用存在。
較新的 CPU 和 Linux 支援五層分頁(57 位元位址)。依 Linux 核心文件,四層時使用者空間的上限約 128 TB,五層約 64 PB。
上面的位元切法用 Python 寫出來只有幾行。& 取低位元,>> 右移丟掉低位元:
把位址拆成 9+9+9+9+12
0xfff 是 12 個 1,0x1ff 是 9 個 1,和它做 & 就是取出最低的 12 或 9 位元。先取出來的是最低的 L1,所以用 insert(0, …) 放到最前面。
逐層走一次
接下來用工具走真正的查表。一開始只有最上層的表,裡面全部是空的。工具裡的頁框編號是自己依序發的(示意值),頁表頁數與佔用大小由目前的表實際數出來。
走到不存在的項目時,CPU 發出分頁錯誤,核心接手:
- 查這個行程的區段清單(
/proc/self/maps看到的那些,核心裡叫 VMA):這個位址合法嗎? - 不合法(不在任何區段,或權限不符):送 SIGSEGV。
- 合法:配置需要的頁框、填好頁表(缺的中間層也補上),回到使用者程式,重新執行剛才那個指令。
程式完全不知道中間發生過這些事。
需求分頁:malloc 不等於佔用
剛才看到的「用到才配置」有個名字:需求分頁(demand paging)。malloc 或 mmap 只是在區段清單上多記一筆,頁表還是空的,也沒有配置任何實體頁框。第一次真的碰到某一頁時才發生分頁錯誤、拿到頁框。
這種不用讀磁碟就能處理的分頁錯誤叫 minor fault;要從磁碟讀資料的(程式檔案還沒載入、頁被換到 swap)叫 major fault。在 Linux 上量一次:
malloc 64 MB: 3 minor faults
write every page: 16390 minor faults
child reads all: 0 minor faults (sum 16384)
child writes all: 16384 minor faults
64 MB / 4 KB = 16384 pages
parent still sees: 1
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
malloc64 MB,幾乎沒有分頁錯誤(那幾次是函式庫自己的動作):記憶體還沒給你。- 寫過每一頁,分頁錯誤約 16384 次,正好是 64 MB ÷ 4 KB。
- 依
malloc(3)手冊,Linux 預設是樂觀配置,malloc回傳非 NULL 不保證真的有那麼多記憶體。原因就在這裡:配置當下根本還沒給。
啟動程式也靠它:一支 100 MB 的程式,只有真的執行到的頁才會從磁碟讀進來。
下半段的 fork 結果下一節解釋。先在教學核心看需求分頁。
寫入時複製:fork 為什麼這麼快
fork 要讓子行程有一份和父行程一樣的記憶體。如果真的全部複製,大行程要等很久,而且常常白做:Shell 的 fork 之後馬上 exec,複製的東西立刻丟掉。
寫入時複製(copy-on-write,COW)的作法:
fork只複製頁表,父子兩邊都指向同一批頁框,並把可寫的頁都改成唯讀。- 任何一方讀:沒事,繼續共用。
- 任何一方寫:權限不符,觸發分頁錯誤。核心發現這是寫入時複製的頁,就配置新頁框、複製內容、把這一方的頁表改成可寫,再重新執行那個指令。
依 fork(2) 手冊,fork 的代價主要是複製頁表與建立行程結構,不是複製記憶體。
回頭看上面量測的下半段:子行程把 64 MB 讀一遍,0 次分頁錯誤(頁表已經複製好了);寫一遍,正好 16384 次,一頁複製一次。父行程看到的值還是 1,完全不受影響。
這是作業系統的經典手法:先假裝做了,真的需要才做,讓常見的情況很快。代價是第一次寫入比較貴,而且複製的成本是分散、延後發生的。Redis 這類服務用 fork 做背景存檔,寫入量大時就會感受到它。
mmap:讓兩個行程真的共用
COW 讓父子「看起來各有一份」。如果想要真的共用,用 mmap 建立共用映射:
int *m = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
| 旗標 | 意思 |
|---|---|
MAP_ANONYMOUS |
不對應檔案,內容從全部是 0 開始 |
MAP_SHARED |
改動其他映射同一區域的行程都看得到;fork 之後父子共用同一批頁框 |
MAP_PRIVATE |
寫入時複製,改動別人看不到 |
依 mmap(2) 手冊,這些映射在 fork 之後以相同屬性保留。真實結果:
MAP_SHARED: parent sees 100
MAP_PRIVATE: parent sees 1
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
共享記憶體是最快的行程間通訊:資料不用經過核心複製,兩邊直接讀寫同一批頁框。代價是要自己處理同步,第 8~11 課的競爭條件在這裡全部都會發生。
mmap 也能對應檔案:把檔案內容對應到位址空間,讀取時才載入。第 12 課 /proc/self/maps 裡的 libc.so.6 就是這樣來的,所以幾十個行程可以共用同一份函式庫的程式碼,只佔一份實體記憶體。
實作:算出要幾頁頁表,和工具對答案
給一串位址,算出四層頁表總共要幾頁。規則:最上層一定有 1 頁;每個不同的 L4 索引要 1 頁 L3 表;每個不同的(L4, L3)要 1 頁 L2 表;每個不同的(L4, L3, L2)要 1 頁 L1 表。
算完可以回到上面的工具,按「清空頁表」後依序走這幾個位址,「頁表頁數」應該一樣。
確認一下
為什麼每一層頁表都是 9 位元?
程式 malloc(1 GB) 成功,但只寫了前 1 MB。在 Linux 預設設定下,這個行程大約多佔多少實體記憶體?
一個 2 GB 的行程呼叫 fork,接著子行程馬上 exec。這段期間複製了多少資料頁?
下面哪一個分頁錯誤最後會變成 SIGSEGV?
這段程式會印出什麼?
int *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
*p = 1;
if (fork() == 0) { *p = 2; _exit(0); }
wait(NULL);
printf("%d\n", *p);
重點整理
- 單層頁表每個行程要 512 GB。多層頁表把頁號切成 9+9+9+9,每張表正好一頁;沒用到的區域不必建表,代價是查表要走更多層,所以需要 TLB。
- 分頁錯誤不等於錯誤:核心查區段,合法就補好頁表、重新執行那個指令,不合法才送 SIGSEGV。
- 需求分頁:
malloc和mmap只記一筆區段,第一次碰到才配置頁框。 - 寫入時複製:
fork只複製頁表並設成唯讀,誰寫誰拿新頁框,所以fork+exec很便宜。 MAP_SHARED讓行程真的共用頁框,是最快的行程間通訊,但要自己同步;MAP_PRIVATE是寫入時複製。
延伸閱讀:OSTEP 的 Paging: Smaller Tables、Beyond Physical Memory: Mechanisms。
下一課:如果大家要的記憶體加起來超過實體記憶體,核心要把哪一頁請出去?