6.3 記憶體管理與簡易頁表#
本章目標#
在 6.2 節的裸機環境上實作記憶體管理的三塊基石:
- 分頁(paging):虛擬位址 → 實體位址的翻譯機制——第 5.3 節 WASM 沙箱、第 4.2 節 JIT 都依賴的底層
- 物理頁框分配器:管理「哪些 4KB 頁是空閒的」
- 堆積分配器:
Box/Vec的地基——第 1 章以來所有alloc的源頭
完成後,你的 mini-os 就能 Box::new 了。
分頁:硬體的翻譯機制#
x86-64 用四級頁表把 48 位元虛擬位址翻譯成實體位址:
graph LR
V[虛擬位址 48 bits] --> P4[Level 4 表<br/>取 bits 39-47]
P4 --> P3[Level 3 表<br/>取 bits 30-38]
P3 --> P2[Level 2 表<br/>取 bits 21-29]
P2 --> P1[Level 1 表<br/>取 bits 12-20]
P1 --> E[實體頁框號<br/>+ bits 0-11 偏移]每一級表是一個 4KB 頁,含 512 個 8 位元組條目。條目不只是「位址」,還帶權限位元:
| 位元 | 名稱 | 意義 |
|---|---|---|
| 0 | PRESENT | 此頁在記憶體中嗎? |
| 1 | WRITABLE | 可寫嗎? |
| 2 | USER | 使用者態可存取嗎? |
| 4 | ACCESSED | CPU 存取過(核心用於換頁決策) |
| 5 | DIRTY | 被寫過(swap 決策) |
| 63 | NO_EXECUTE | 不可執行(W^X 的硬體位元!) |
第 4.2 節的 W^X、第 5.3 節的沙箱、Linux 的頁保護,全部落在這幾個位元上。核心的工作就是維護這些表,MMU 每次記憶體存取都走一遍這個翻譯(結果快取在 TLB)。
在 Rust 中操作頁表#
用 x86_64 crate 的型別化 API(它把頁表條目的每個位元都做成型別安全欄位):
// src/mem.rs
use x86_64::structures::paging::page_table::{PageTable, PageTableFlags};
use x86_64::{PhysAddr, VirtAddr};
pub struct PageTableManager {
pub l4: &'static mut PageTable, // Level 4 表
phys_offset: VirtAddr, // 物理記憶體在虛擬空間的映射起點
}
impl PageTableManager {
/// 關鍵技巧:核心自己也在虛擬位址上執行,
/// 要改「實體位址 P 的頁表」,得先知道 P 對應的虛擬位址。
/// bootloader 已建立「全物理記憶體映射到 phys_offset 起」的區域,
/// 所以 phys_offset + p 就是答案。
fn phys_to_virt(&self, phys: PhysAddr) -> *mut PageTable {
(self.phys_offset + phys.as_u64()).as_mut_ptr()
}
/// 建立新映射:虛擬頁 → 物理頁框
pub unsafe fn map_page(&mut self, virt: VirtAddr, phys: PhysAddr) {
let p4 = self.l4;
let idx4 = p4_index(virt);
let idx3 = p3_index(virt);
let idx2 = p2_index(virt);
let idx1 = p1_index(virt);
// 沿途建立中間級表(不存在就分配)
let p3 = self.next_table_or_create(p4, idx4);
let p2 = self.next_table_or_create(p3, idx3);
let p1 = self.next_table_or_create(p2, idx2);
// 最後一級寫入映射 + 權限位元
p1[idx1].set_addr(phys, PageTableFlags::PRESENT | PageTableFlags::WRITABLE);
}
unsafe fn next_table_or_create<'a>(
&mut self,
table: &'a mut PageTable,
idx: usize,
) -> &'a mut PageTable {
if table[idx].is_unused() {
let frame = alloc_frame().expect("out of physical frames");
table[idx].set_frame(frame, PageTableFlags::PRESENT | PageTableFlags::WRITABLE);
// 新表要清零——髒的頁表條目 = 隨機映射 = 安全漏洞
let pt = self.phys_to_virt(frame.start_address());
core::ptr::write_bytes(pt as *mut u8, 0, 4096);
}
&mut *self.phys_to_virt(table[idx].addr())
}
}
三個深度教學點:
- 「核心怎麼改頁表」是雞生蛋問題:頁表本身在實體記憶體,核心跑在虛擬位址上。解法(bootloader 預建的 phys_offset 大映射)是 Linux 的
__va/__pa巨集的同一思想。看懂這裡就看懂了「核心也是被虛擬記憶體管理的程式」——只是它管理著那套機制。 - 新表必須清零:分配到的物理頁框內容是未定義的(可能是上一個行程的資料!),不清零的頁表 = 隨機映射 = 資訊洩漏。這是真實核心 CVE 的種類(kernel heap 未初始化洩漏)。
unsafe的責任邊界:整個函式 unsafe——因為它做「任意虛擬位址 → 任意物理位址」的映射,這正是 Rust 型別系統不可能驗證的操作。核心 =unsafe的集中地,而 Rust 的貢獻是把 unsafe 的總量壓到最小並顯式標註。
物理頁框分配器#
分頁的原料是 4KB 物理頁框。最簡單可行的分配器——位圖(bitmap):
// src/frame.rs
pub struct FrameAllocator {
bitmap: &'static mut [u8], // 每位元對應一個 4KB 框
next_free: usize,
}
impl FrameAllocator {
pub fn alloc(&mut self) -> Option<PhysFrame> {
// 找到第一個 0 位元
for i in self.next_free..self.bitmap.len() * 8 {
if !self.is_used(i) {
self.set_used(i);
self.next_free = i + 1;
return Some(PhysFrame::containing_address(PhysAddr::new(i as u64 * 4096)));
}
}
None
}
pub fn dealloc(&mut self, frame: PhysFrame) {
let idx = frame.start_address().as_u64() as usize / 4096;
self.set_free(idx);
self.next_free = self.next_free.min(idx);
}
}
真實核心用夥伴系統(buddy allocator)(Linux 的核心做法):以 2 的冪次管理空閒塊,分配與合併都是 O(log n),並解決外部碎片。位圖版夠教學,但請讀者查 Knuth 對 buddy 系統的原始討論(TAOCP)。
堆積分配器:Box 的地基#
有了頁框,再往上做位元組級分配。經典選項:
| 演算法 | 原理 | 適用 |
|---|---|---|
| 線性(bump) | 一個指標往前推 | 快,但無法釋放 |
| 空閒鏈表 | 空閒塊串鏈表,first/best fit | 通用,碎片化 |
| slab | 每種大小一個池 | Linux 的 kmalloc、Rust 的 typed arena |
裸機第一步用 bump(能動就好),然後接上 alloc crate 讓 Rust 的 Box/Vec 可用:
// src/heap.rs
use linked_list_allocator::LockedHeap;
#[global_allocator]
static ALLOCATOR: LockedHeap = LockedHeap::empty();
pub fn init_heap(phys_offset: VirtAddr, ptm: &mut PageTableManager) {
const HEAP_START: u64 = 0o_000_000_000_000; // 選個不衝突的虛擬位址
const HEAP_SIZE: u64 = 100 * 1024; // 100 KiB
// 1. 向頁框分配器要物理頁
// 2. map_page 把 [HEAP_START, HEAP_START+HEAP_SIZE] 映射上去
for page in Page::range_inclusive(
Page::containing_address(VirtAddr::new(HEAP_START)),
Page::containing_address(VirtAddr::new(HEAP_START + HEAP_SIZE - 1)),
) {
let frame = alloc_frame().unwrap();
unsafe { ptm.map_page(page.start_address(), frame.start_address()); }
}
// 3. 告訴全域分配器這段範圍可用
unsafe { ALLOCATOR.lock().init(HEAP_START as *mut u8, HEAP_SIZE as usize); }
}
// 現在裸機上可以:
let v = vec![1, 2, 3]; // Vec → malloc → 空閒鏈表 → 頁框 → 頁表 → 實體記憶體
let b = Box::new(42); // 整條鏈第一次全部點亮
完整的層次圖#
graph TD
A[Box/Vec/alloc crate] --> B[全域分配器<br/>空閒鏈表]
B --> C[虛擬頁請求<br/>map_page]
C --> D[頁表操作<br/>四級查詢/權限位元]
C --> E[物理頁框分配器<br/>位圖]
D --> F[MMU 硬體<br/>每次存取翻譯]
E --> G[實體記憶體]
F --> G對照前幾章的依賴鏈:
- 第 1 章
Box<T>的 drop → 空閒鏈表歸還 → (未做)頁回收 - 第 4.2 節 JIT 的
mmap→ 在真核心中就是 map_page + NO_EXECUTE 位元的管理 - 第 5.3 節 WASM 保護頁 → 頁表權限 + 頁錯誤訊號 → 本章的頁表就是那個訊號的源頭
至此,本書的信任鏈完全閉合:從 Box 的 drop(第 1 章)到頁表位元(本章),從 JIT 的機器碼(第 4 章)到 W^X 硬體位元(本章),從 WASM 沙箱(第 5 章)到 USER 位元與頁錯誤(本章)。
習題#
- 在
map_page中移除清零步驟,然後(在 QEMU 中)驗證新分配的頁表初始內容是否為隨機資料。 - 把 bump 分配器加上「重置」功能,實作第 5.2 節 arena 分配的想法,比較 Box 與 arena 在連續分配 1000 個小物件時的效能。
- Linux 的伙伴系統如何處理「分配 3 個頁」這種非 2 冪請求?(提示:拆分與合併,讀
include/linux/mmzone.h的 MAX_ORDER 註解。) - 思考題:為什麼核心需要「實體位址 → 虛擬位址」的直接映射區?如果沒有它,改頁表要怎麼做?(提示:先想辦法臨時映射頁表本身——Linux 的 fixmap。)