National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 72 24°C PM2.5 13
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 14

多層頁表與寫入時複製

頁表太大怎麼辦、fork 為什麼這麼快
Interaction實作 35 minute(s)

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 發出分頁錯誤,核心接手:

  1. 查這個行程的區段清單(/proc/self/maps 看到的那些,核心裡叫 VMA):這個位址合法嗎?
  2. 不合法(不在任何區段,或權限不符):送 SIGSEGV。
  3. 合法:配置需要的頁框、填好頁表(缺的中間層也補上),回到使用者程式,重新執行剛才那個指令。

程式完全不知道中間發生過這些事。

互動練習:走 x86-64 的四層頁表

下方的統計一直看「頁表頁數」。合法的區段是程式碼、全域變數、堆積、記憶體映射區、堆疊,和第 12 課的配置一樣。

需求分頁: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)。

  • malloc 64 MB,幾乎沒有分頁錯誤(那幾次是函式庫自己的動作):記憶體還沒給你。
  • 寫過每一頁,分頁錯誤約 16384 次,正好是 64 MB ÷ 4 KB。
  • 依 malloc(3) 手冊,Linux 預設是樂觀配置,malloc 回傳非 NULL 不保證真的有那麼多記憶體。原因就在這裡:配置當下根本還沒給。

啟動程式也靠它:一支 100 MB 的程式,只有真的執行到的頁才會從磁碟讀進來。

下半段的 fork 結果下一節解釋。先在教學核心看需求分頁。

互動練習:配置 40 頁,只碰 5 頁

切到「記憶體」面板,對照「位址空間」裡的 [heap] 有多大,和「頁表」列出幾頁堆積。上方統計列的「分頁錯誤」跟著增加。一步一行,不要按「跑到結束」,否則行程結束就看不到了。

寫入時複製:fork 為什麼這麼快

fork 要讓子行程有一份和父行程一樣的記憶體。如果真的全部複製,大行程要等很久,而且常常白做:Shell 的 fork 之後馬上 exec,複製的東西立刻丟掉。

寫入時複製(copy-on-write,COW)的作法:

  1. fork 只複製頁表,父子兩邊都指向同一批頁框,並把可寫的頁都改成唯讀。
  2. 任何一方讀:沒事,繼續共用。
  3. 任何一方寫:權限不符,觸發分頁錯誤。核心發現這是寫入時複製的頁,就配置新頁框、複製內容、把這一方的頁表改成可寫,再重新執行那個指令。

依 fork(2) 手冊,fork 的代價主要是複製頁表與建立行程結構,不是複製記憶體。

回頭看上面量測的下半段:子行程把 64 MB 讀一遍,0 次分頁錯誤(頁表已經複製好了);寫一遍,正好 16384 次,一頁複製一次。父行程看到的值還是 1,完全不受影響。

這是作業系統的經典手法:先假裝做了,真的需要才做,讓常見的情況很快。代價是第一次寫入比較貴,而且複製的成本是分散、延後發生的。Redis 這類服務用 fork 做背景存檔,寫入量大時就會感受到它。

互動練習:看頁框怎麼分開

右邊「記憶體」面板的頁表看三欄:實體頁框、狀態(唯讀,寫入時複製)、共用(幾個行程共用這個頁框)。上方可以切換看哪個行程。一步一個指令,走到 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 就是這樣來的,所以幾十個行程可以共用同一份函式庫的程式碼,只佔一份實體記憶體。

互動練習:MAP_SHARED 和 MAP_PRIVATE

跑一次看父行程看到什麼,再改一個旗標。

實作:算出要幾頁頁表,和工具對答案

給一串位址,算出四層頁表總共要幾頁。規則:最上層一定有 1 頁;每個不同的 L4 索引要 1 頁 L3 表;每個不同的(L4, L3)要 1 頁 L2 表;每個不同的(L4, L3, L2)要 1 頁 L1 表。

算完可以回到上面的工具,按「清空頁表」後依序走這幾個位址,「頁表頁數」應該一樣。

你的輸出
預期輸出

確認一下

1.

為什麼每一層頁表都是 9 位元?

2.

程式 malloc(1 GB) 成功,但只寫了前 1 MB。在 Linux 預設設定下,這個行程大約多佔多少實體記憶體?

3.

一個 2 GB 的行程呼叫 fork,接著子行程馬上 exec。這段期間複製了多少資料頁?

4.

下面哪一個分頁錯誤最後會變成 SIGSEGV?

5.

這段程式會印出什麼?

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);
第 1 次作答

重點整理

  • 單層頁表每個行程要 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。

下一課:如果大家要的記憶體加起來超過實體記憶體,核心要把哪一頁請出去?

這一課完成了!
Next lesson:分頁置換與顛簸
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。