記憶體管理:虛擬記憶體與分頁
記憶體是電腦最珍貴的資源之一。如果同時開啟 10 個程式,每個程式都需要記憶體,但你的電腦只有 16GB RAM——它是怎麼做到的?
答案是:虛擬記憶體 (Virtual Memory)。
🔥 Vibe Prompt
「用 Python 實作一個分頁式記憶體模擬器:支援邏輯位址到實體位址的轉換、分頁表 (Page Table)、TLB 快取、以及 LRU 分頁替換演算法。」
為什麼需要虛擬記憶體?
在早期系統中,程式直接存取實體記憶體。這導致了三個問題:
| 問題 | 說明 | |------|------| | 安全性 | 任何程式都能讀寫其他程式的記憶體 | | 碎片化 | 程式載入/卸載後產生大量記憶體碎片 | | 限制 | 程式大小不能超過實體記憶體 |
虛擬記憶體解決了這些問題:每個程式有自己的「虛擬位址空間」,由作業系統映射到實體記憶體。
分頁 (Paging) 機制
分頁是實現虛擬記憶體的核心技術:
邏輯位址 → [MMU + 分頁表] → 實體位址
- 頁 (Page): 虛擬記憶體被分割成固定大小的區塊(通常 4KB)
- 頁框 (Frame): 實體記憶體也被分割成同樣大小的區塊
- 分頁表 (Page Table): 記錄每個虛擬頁對應到哪個實體頁框
class PageTable:
"""簡易分頁表模擬"""
def __init__(self, page_size=4096):
self.page_size = page_size
# 虛擬頁號 → 實體頁框號
self.mapping = {}
# 實體頁框使用狀態
self.frames = set()
def map_page(self, virtual_page, physical_frame):
self.mapping[virtual_page] = physical_frame
self.frames.add(physical_frame)
def translate(self, virtual_address):
"""將邏輯位址轉換為實體位址"""
page_number = virtual_address // self.page_size
offset = virtual_address % self.page_size
if page_number not in self.mapping:
raise Exception("Page fault! 頁面不存在於記憶體中")
physical_frame = self.mapping[page_number]
physical_address = physical_frame * self.page_size + offset
return physical_address
# 測試
pt = PageTable()
pt.map_page(0, 5) # 虛擬頁 0 → 實體頁框 5
pt.map_page(1, 10) # 虛擬頁 1 → 實體頁框 10
pt.map_page(2, 3) # 虛擬頁 2 → 實體頁框 3
print(f"虛擬位址 0 → 實體位址 {pt.translate(0)}")
print(f"虛擬位址 4096 → 實體位址 {pt.translate(4096)}")
print(f"虛擬位址 8192 → 實體位址 {pt.translate(8192)}")
TLB (Translation Lookaside Buffer)
每次位址轉換都需要查分頁表——如果分頁表在記憶體中,這會很慢。
TLB 是一個硬體快取,儲存最近使用過的位址轉換。有了 TLB,大部分位址轉換可以在 1 個 CPU 週期內完成。
class TLB:
"""TLB 快取模擬(使用 LRU 策略)"""
def __init__(self, size=16):
self.size = size
self.cache = {} # virtual_page → physical_frame
self.access_order = []
def lookup(self, virtual_page):
"""查詢 TLB,回傳實體頁框或 None"""
if virtual_page in self.cache:
# 更新存取順序(LRU)
self.access_order.remove(virtual_page)
self.access_order.append(virtual_page)
return self.cache[virtual_page]
return None
def update(self, virtual_page, physical_frame):
"""更新 TLB"""
if virtual_page in self.cache:
self.access_order.remove(virtual_page)
elif len(self.cache) >= self.size:
# LRU:移除最久未使用的
oldest = self.access_order.pop(0)
del self.cache[oldest]
self.cache[virtual_page] = physical_frame
self.access_order.append(virtual_page)
# 模擬 TLB 效果
tlb = TLB(size=4)
tlb.update(0, 5)
tlb.update(1, 10)
tlb.update(2, 3)
tlb.update(3, 7)
print(f"TLB 查詢頁 0: {tlb.lookup(0)}") # 命中
print(f"TLB 查詢頁 4: {tlb.lookup(4)}") # 未命中
print(f"TLB 查詢頁 1: {tlb.lookup(1)}") # 命中
分頁替換演算法
當實體記憶體已滿,需要載入新頁面時,必須替換掉某個現有頁面。常見演算法:
| 演算法 | 策略 | 優點 | 缺點 | |--------|------|------|------| | FIFO | 替換最早載入的頁面 | 實作簡單 | 可能替換掉常用頁面 | | LRU | 替換最久未使用的頁面 | 效能好 | 需要硬體支援 | | Clock | 近似 LRU,使用參考位元 | 效率與效能的平衡 | 比 LRU 差一點 |
from collections import deque
def lru_page_replacement(page_sequence, num_frames):
"""
LRU 分頁替換演算法
page_sequence: 頁面存取序列
num_frames: 實體頁框數量
回傳: (page_faults, 最終頁框狀態)
"""
frames = []
page_faults = 0
for page in page_sequence:
if page not in frames:
if len(frames) < num_frames:
frames.append(page)
else:
frames.pop(0) # 移除最久未使用的
frames.append(page)
page_faults += 1
else:
# 頁面已存在,移動到最後(表示最近使用)
frames.remove(page)
frames.append(page)
return page_faults, frames
# 測試
sequence = [7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1]
for frames in [3, 4]:
faults, final = lru_page_replacement(sequence, frames)
print(f"{frames} 個頁框: {faults} 次 page fault")
多層分頁表 (Multi-Level Page Table)
現代 64 位元作業系統的虛擬位址空間極大(2^64 = 16 EB),如果使用單層分頁表,分頁表本身就會消耗大量記憶體。
解決方案:多層分頁表 — 將分頁表本身也分頁,只載入需要的部分。
虛擬位址分解(4 層分頁,x86-64):
┌─────────┬──────────┬──────────┬──────────┬──────────┐
│ PML4 │ 目錄指標器 │ 目錄 │ 頁表 │ 偏移量 │
│ (9 bit) │ (9 bit) │ (9 bit) │ (9 bit) │ (12 bit) │
└─────────┴──────────┴──────────┴──────────┴──────────┘
= 48 位元虛擬位址
class MultiLevelPageTable:
"""簡化版多層分頁表(2 層)"""
def __init__(self):
# 第一層:目錄(1024 個項目)
self.page_directory = {}
self.page_size = 4096 # 4KB
def map_page(self, virtual_addr, physical_frame):
page_number = virtual_addr // self.page_size
# 分割頁號為兩部分
dir_index = page_number >> 10 # 高 22 bit(第一層)
table_index = page_number & 0x3FF # 低 10 bit(第二層)
if dir_index not in self.page_directory:
self.page_directory[dir_index] = {}
self.page_directory[dir_index][table_index] = physical_frame
def translate(self, virtual_addr):
page_number = virtual_addr // self.page_size
dir_index = page_number >> 10
table_index = page_number & 0x3FF
if dir_index not in self.page_directory:
raise Exception("Page fault!")
if table_index not in self.page_directory[dir_index]:
raise Exception("Page fault!")
physical_frame = self.page_directory[dir_index][table_index]
offset = virtual_addr % self.page_size
return physical_frame * self.page_size + offset
多層分頁表的優點
| 優點 | 說明 | |------|------| | 節省記憶體 | 只有使用的分頁表層級才會被分配 | | 稀疏位址空間支援 | 程式通常只使用極小部分的虛擬位址空間 | | 較小的連續記憶體需求 | 不需要一大塊連續的實體記憶體來存放分頁表 |
頁面錯誤 (Page Fault) 處理
當程式存取一個不在實體記憶體中的頁面時,MMU 會引發 頁面錯誤 (Page Fault),交由 OS 處理:
程式存取位址 → MMU 查分頁表 → 頁面不存在 →
↓
CPU 觸發 Page Fault 中斷 → OS 中斷處理程式接手
↓
1. 檢查位址是否合法(若非法 → Segmentation Fault)
2. 尋找空閒的頁框(若無 → 執行頁面置換演算法)
3. 從磁碟(swap 分區)讀取頁面內容到頁框
4. 更新分頁表
5. 重新執行觸發錯誤的指令
class PageFaultHandler:
"""簡化版頁面錯誤處理模擬"""
def __init__(self, disk, ram_size=4):
self.disk = disk # 模擬 swap 磁碟
self.ram = {} # 模擬實體記憶體
self.ram_size = ram_size
self.page_table = {}
def access(self, virtual_page):
if virtual_page in self.ram:
return self.ram[virtual_page] # 正常存取
print(f"⚠️ Page Fault! 頁面 {virtual_page} 不在實體記憶體中")
# 從磁碟讀取
if virtual_page not in self.disk:
raise Exception("Invalid address!")
# 實體記憶體是否已滿
if len(self.ram) >= self.ram_size:
# LRU 置換
evicted = next(iter(self.ram))
print(f" 置換頁面 {evicted} 到磁碟")
self.disk[evicted] = self.ram.pop(evicted)
# 載入頁面
self.ram[virtual_page] = self.disk[virtual_page]
print(f" 載入頁面 {virtual_page} 到實體記憶體")
return self.ram[virtual_page]
Copy-on-Write (COW)
當行程用 fork() 建立子行程時,如果直接複製整個記憶體空間會非常昂貴。Copy-on-Write 技術解決了這個問題:
fork() 時:父子行程共享相同的實體記憶體頁面(全部設為唯讀)
↓
任一行程嘗試寫入 → 觸發 Page Fault →
OS 複製該頁面 → 分配新頁框 → 各自擁有獨立副本
class COWPageTable:
"""寫入時複製模擬"""
def __init__(self):
self.pages = {} # 頁面資料
self.writable = {} # 是否可寫
self.ref_count = {} # 引用計數
def fork(self):
"""父行程 fork 子行程:共享頁面,全部設為唯讀"""
child = COWPageTable()
for page_id, data in self.pages.items():
child.pages[page_id] = data
child.writable[page_id] = False
self.writable[page_id] = False
self.ref_count[page_id] = self.ref_count.get(page_id, 1) + 1
child.ref_count[page_id] = self.ref_count[page_id]
return child
def write(self, page_id, data):
if page_id not in self.pages:
self.pages[page_id] = data
self.writable[page_id] = True
return
if not self.writable[page_id]:
# COW:複製頁面
print(f"📋 COW! 複製頁面 {page_id}")
new_page_id = f"{page_id}_copy"
self.pages[new_page_id] = self.pages[page_id].copy()
self.writable[new_page_id] = True
self.pages[new_page_id] = data
return
self.pages[page_id] = data
分段 (Segmentation)
除了分頁之外,另一種記憶體管理方式是 分段 (Segmentation):
| 面向 | 分頁 (Paging) | 分段 (Segmentation) | |------|--------------|-------------------| | 單位 | 固定大小(4KB) | 可變大小(邏輯區段) | | 程式觀點 | 透明(程式看不到分頁) | 可見(程式知道區段) | | 碎片化 | 內部碎片(頁內未用空間) | 外部碎片(區段間空隙) | | 共享 | 難以共享頁面 | 容易共享區段(如 shared library) | | 保護 | 頁層級權限 | 區段層級權限(讀/寫/執行) |
現代作業系統(Linux、Windows)使用 分頁 + 分段混合 或純分頁方式。
ASLR (Address Space Layout Randomization)
ASLR 是一種安全機制,每次行程啟動時隨機化位址空間的配置:
正常配置:
stack: 0x7fff00000000
heap: 0x00600000
code: 0x00400000
ASLR 啟用:
stack: 0x7fff3a4b2000(每次不同)
heap: 0x01a3c000(每次不同)
code: 0x0055e000(每次不同)
這使得攻擊者難以預測記憶體位址,大幅增加了緩衝區溢位等攻擊的難度。
本章總結
| 概念 | 核心重點 | |------|---------| | 虛擬記憶體 | 每個程式有獨立位址空間,不受實體記憶體限制 | | 分頁 | 固定大小分割,消除外部碎片 | | 多層分頁表 | 節省分頁表記憶體,支援稀疏位址空間 | | TLB | 硬體快取加速位址轉換 | | Page Fault | 缺頁時 OS 從磁碟載入頁面 | | 頁面置換 | LRU、FIFO、Clock 演算法 | | COW | fork 時共享頁面,寫入時才複製 | | 分段 | 可變大小區段,程式可見 | | ASLR | 隨機化位址空間以提升安全性 |
實戰練習
💡 Vibe Coding 練習:請 AI 建立一個「記憶體管理互動式學習工具」:
- 位址轉換模擬器:輸入虛擬位址,顯示分頁表查詢過程與結果
- TLB 效能分析:模擬不同 TLB 大小(16/64/256 項目)的命中率
- 頁面置換可視化:動態展示 FIFO、LRU、Clock 的置換過程
- 多層分頁表瀏覽器:顯示 x86-64 四層分頁表的位址分解
- COW fork 模擬:展示 fork 前後的記憶體共享與複製
- Page Fault 統計:記錄並圖表化缺頁率