Learning Objectives
- 說明虛擬位址帶來的兩個好處:行程彼此隔離、實體記憶體不必連續
- 把虛擬位址切成頁號與位移,查頁表換成實體位址
- 說明 SIGSEGV 怎麼從頁表的權限位元來
- 解釋 TLB 為什麼有效,並預測循序、跨頁、隨機存取的命中率
- 說明內容切換對 TLB 的影響,以及 ASID 解決什麼問題
同一個位址,兩個行程讀到不同的值
父行程和子行程印出同一個變數的位址和值:
child: &data = 0x404038, data = 2
parent: &data = 0x404038, data = 1
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
位址一模一樣,值卻不同。如果 0x404038 是記憶體晶片上的真實位置,這不可能發生。
程式裡看到的都是虛擬位址(virtual address)。CPU 每一次讀寫,都由硬體裡的 MMU(記憶體管理單元)換成實體位址(physical address),換算表由核心替每個行程各準備一份。所以同一個虛擬位址,在兩個行程可以對到不同的實體位置。
這樣設計換來的東西:
| 好處 | 怎麼做到 |
|---|---|
| 隔離 | 行程根本說不出別人的實體位址,它能用的位址都要經過自己的換算表 |
| 不必連續 | 程式看到連續的陣列,實體上可以散在任何地方,外部碎片的問題就消失了 |
| 用到才配置 | 換算表可以先不填,真的存取時才給實體記憶體(下一課) |
| 共用 | 兩個行程的不同位址可以對到同一塊實體記憶體,例如同一份 libc |
代價是每一次記憶體存取都多一道換算。這一課後半段就在處理這個代價。
頁、頁框與位移
如果每個位元組都記一筆換算,表會比記憶體還大。所以把記憶體切成固定大小的塊來對應:虛擬位址空間的一塊叫頁(page),實體記憶體的一塊叫頁框(frame),大小相同,x86-64 Linux 上常見的是 4 KB(2¹² 位元組)。
一頁裡的位元組順序不會變,所以只需要換算「第幾頁」,頁內的位置直接照抄:
虛擬位址 0x404abc
------ ---
頁號 位移(最低 12 位元,也就是十六進位最後 3 位)
0x404 0xabc
查頁表:第 0x404 頁在頁框 0x1f2
實體位址 = 0x1f2 × 4096 + 0xabc = 0x1f2abc
頁表(page table)就是這張「頁號 → 頁框」的對照表,由核心維護,放在記憶體裡,CPU 的 MMU 依照它換算。每個行程一份,切換行程時也切換頁表。
頁表的一項除了頁框編號,還有幾個位元:
| 位元 | 意思 |
|---|---|
| Present | 這頁現在有沒有在記憶體裡 |
| Writable | 能不能寫 |
| User | 使用者模式能不能存取(核心的頁在這裡擋住) |
| NX | 不能當指令執行 |
| Accessed、Dirty | 硬體記錄「最近用過」「改過」,第 15 課會用到 |
下面這個工具用 16 位元的小位址空間來練習,真實的 x86-64 是 48 位元,第 14 課會講。
SIGSEGV 就是從這裡來的
每次記憶體存取,MMU 都會檢查權限。不符合就觸發分頁錯誤(page fault),控制權交給核心,由核心決定怎麼辦。如果這個存取本來就不合法,核心送 SIGSEGV 給行程。
char array[] = "hello"; // 陣列在堆疊上,可以改
const char *literal = "hello"; // 字串常數在唯讀的頁上
真實 Linux 上,子行程寫字串常數被終止:
array: Hello
child killed by signal 11 (SIGSEGV is 11)
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
char array[]是把字串複製一份到堆疊,可寫。char *s = "hello"只是讓指標指向唯讀區段裡的字串,寫它就是違反權限位元。- 讀
NULL(位址 0)同理:第 0 頁故意不對應任何東西,所以空指標會當場被抓到,不會默默讀到垃圾。
所以 SIGSEGV 不是 C 語言在檢查你的程式,C 根本不檢查;是硬體加核心在保護這個位址空間。陣列超出邊界如果還落在合法的頁裡,不會有任何錯誤。
查頁表太慢:TLB
頁表放在記憶體裡。如果每次存取都要先讀頁表,一次記憶體存取就變成兩次;x86-64 的頁表有四層(下一課),未快取時要多讀四次。
解法是快取最近的換算結果:MMU 裡有一小塊很快的硬體叫 TLB(Translation Lookaside Buffer),記著最近用過的「頁號 → 頁框」。
虛擬位址 → 拆出頁號 → 查 TLB
├─ 命中:立刻得到頁框
└─ 未命中:查頁表,把結果放進 TLB(可能擠掉別項)
TLB 通常只有幾十到幾千項,卻能涵蓋大部分存取,靠的是區域性(locality):
- 空間區域性:用到一個位址,很快會用到旁邊的。一頁有 4096 位元組,循序讀
int陣列時,同一頁連續命中 1023 次才換下一頁。 - 時間區域性:用過的很快會再用,例如迴圈裡的變數。
下面這個工具真的產生每一次存取的位址,在一個 LRU 的 TLB 裡查,命中率是算出來的。先猜猜看,再按下去。
真實機器上看得到這個差距嗎
下面是在 Linux 上用真的記憶體量的:隨機順序讀 N 頁,每頁讀一個位元組(每頁取不同位置,避開資料快取的衝突),印出平均每次讀多久。
第 1 次: 16 pages: 0.6 ns per read
256 pages: 0.7 ns per read
4096 pages: 9.6 ns per read
16384 pages: 9.1 ns per read
(sum 15995392)
第 2 次: 16 pages: 0.6 ns per read
256 pages: 0.8 ns per read
4096 pages: 7.4 ns per read
16384 pages: 9.0 ns per read
(sum 15995392)
第 3 次: 16 pages: 1.9 ns per read
256 pages: 2.4 ns per read
4096 pages: 10.0 ns per read
16384 pages: 10.2 ns per read
(sum 15995392)
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
頁數超過幾千之後明顯變慢,三次都有這個趨勢,數字本身每次不同。這個實驗不是純粹的 TLB 效應:頁數變多時,資料快取也開始裝不下,兩者一起造成了變慢。
寫程式時的實務結論和上面一樣:走訪資料的順序會影響速度。二維陣列照記憶體順序(C 語言是逐列)走,比跳著走快;資料放得緊密,比用指標串得到處都是快。
用程式寫一個 TLB
只有 2 項的 TLB,看 LRU 怎麼決定擠掉誰。注意第四個位址:第 1 頁被用過之後,變成「最近用過」,所以後面被擠掉的是第 2 頁。
內容切換時的 TLB
TLB 記的是「頁號 → 頁框」,但兩個行程的同一個頁號對到不同的頁框。切換行程之後,舊的項目就是錯的。
- 最簡單的作法:切換時清空 TLB。 新行程的前幾次存取幾乎都未命中,這是內容切換隱藏的成本,比存暫存器還貴。
- ASID(Address Space ID,x86 上叫 PCID):每個 TLB 項目標上屬於哪個位址空間,切換時不用清空,各查各的。
回到上面的工具,用「行程切換」選項比較一下(選「隨機、16 頁、TLB 64」):兩個行程輪流跑,每 1000 次存取切換一次。清空 TLB 時命中率會掉;有 ASID 時,64 項裝得下兩個行程的頁,幾乎不受影響。
同一個行程裡的執行緒切換不換頁表,TLB 不受影響,這也是執行緒切換比行程切換便宜的原因之一。
實作:算出命中次數,和工具對答案
補完 simulate,它接受一串位址和 TLB 大小,回傳命中與未命中次數。三組輸入對應工具裡的三種設定(循序 16 頁、跨頁 64 頁 2 遍、跨頁 16 頁 2 遍),你可以回到工具對照表格裡的數字。
確認一下
頁大小是 4 KB,虛擬位址 0x7ab3c 的頁號和位移是什麼?
下面這段程式會怎樣?
int a[10];
a[12] = 5;
一個程式走訪一個 1 GB 的陣列,每個元素 8 位元組,循序讀一次。頁大小 4 KB,TLB 有 64 項。TLB 命中率大約多少?
切換到另一個行程後,如果 CPU 沒有 ASID 也沒有清空 TLB,會發生什麼事?
同一個行程裡切換執行緒,TLB 需要清空嗎?
重點整理
- 程式看到的是虛擬位址,MMU 依每個行程自己的頁表換成實體位址,所以行程彼此隔離,實體記憶體也不必連續。
- 位址切成頁號和位移,只換算頁號;4 KB 的頁,位移是最低 12 位元。
- 頁表項還有權限位元。違反權限或沒有對應時觸發分頁錯誤,不合法就是 SIGSEGV;陣列越界如果落在合法的頁裡,硬體看不出來。
- TLB 快取最近的換算結果,靠區域性才有效:循序存取幾乎全部命中,隨機存取的命中率約等於 TLB 能裝下的比例,迴圈走的頁數超過 TLB 時可能完全不命中。
- 切換行程時舊項目是錯的,要清空或用 ASID;切換同一行程的執行緒不必。
延伸閱讀:OSTEP 的 Paging: Introduction、Paging: Faster Translations (TLBs)。
下一課:4 KB 一頁、48 位元的位址,頁表本身要多大?fork 又為什麼能這麼快?