为了在代码层面清晰地区分物理地址、虚拟地址以及页号,我们利用 Rust 的强类型系统定义了一系列新的类型。此外,本节还将介绍物理页帧的分配与回收机制。
为了防止混淆物理地址和虚拟地址,我们在 kernel/src/mm/address.rs 中定义了四个独立的结构体:
pub struct PhysAddr(pub usize); // 物理地址(56位有效)
pub struct VirtAddr(pub usize); // 虚拟地址(39位有效)
pub struct PhysPageNum(pub usize); // 物理页号 PPN(44位有效)
pub struct VirtPageNum(pub usize); // 虚拟页号 VPN(27位有效)
这些类型之间不能隐式转换,必须显式调用转换函数。这从编译期根除了”将物理地址误当虚拟地址使用”的 Bug。
地址与页号之间的转换关系如下:
>> 12 &(PAGE_SIZE-1)
PhysAddr ──────► PhysPageNum PhysAddr ─────────► 页内偏移
▲ │
│ << 12 │
└──────────────────┘
>> 12 &(PAGE_SIZE-1)
VirtAddr ──────► VirtPageNum VirtAddr ─────────► 页内偏移
▲ │
│ << 12 │
└──────────────────┘
VirtAddr 提供了方便的方法来获取对应的页号或页内偏移:
impl VirtAddr {
/// 向下取整到页号(floor),用于计算区间起始页
pub fn floor(&self) -> VirtPageNum {
VirtPageNum(self.0 / PAGE_SIZE)
}
/// 向上取整到页号(ceil),用于计算区间结束页
pub fn ceil(&self) -> VirtPageNum {
VirtPageNum((self.0 - 1 + PAGE_SIZE) / PAGE_SIZE)
}
/// 提取页内偏移
pub fn page_offset(&self) -> usize {
self.0 & (PAGE_SIZE - 1)
}
}
在 SV39 分页模式下,一个 27 位的虚拟页号(VPN)需要被切分为三个 9 位的索引,分别用于索引一级、二级和三级页表。VirtPageNum 提供了 indexes 方法来完成这一提取过程:
impl VirtPageNum {
pub fn indexes(&self) -> [usize; 3] {
let mut vpn = self.0;
let mut idx = [0usize; 3];
for i in (0..3).rev() {
idx[i] = vpn & 511; // 取出低 9 位 (mask: 0b1_1111_1111 = 511)
vpn >>= 9; // 右移 9 位,准备处理下一级
}
idx
}
}
该方法返回的数组 [idx[0], idx[1], idx[2]] 对应于 [VPN[2], VPN[1], VPN[0]]。这种顺序非常适合页表查找逻辑,先用 VPN[2] 查一级页表,再用 VPN[1] 查二级页表,最后用 VPN[0] 查三级页表。
物理内存被划分为固定大小的页帧 (Page Frame)。操作系统需要通过分配器来管理这些页帧的分配与回收。PhysPageNum 代表一个物理页帧,我们可以将其转换为不同的切片形式进行访问:
impl PhysPageNum {
/// 将物理页解释为页表项数组(用于访问页表节点)
pub fn get_pte_array(&self) -> &'static mut [PageTableEntry] {
let pa: PhysAddr = (*self).into();
unsafe {
core::slice::from_raw_parts_mut(pa.0 as *mut PageTableEntry, 512)
}
}
/// 将物理页解释为字节数组(用于数据拷贝或清零)
pub fn get_bytes_array(&self) -> &'static mut [u8] {
let pa: PhysAddr = (*self).into();
unsafe {
core::slice::from_raw_parts_mut(pa.0 as *mut u8, PAGE_SIZE)
}
}
}
注意:上述代码假设了物理内存是直接映射或已经可以通过特定方式访问的。
我们需要一个分配器来管理从 ekernel 到 MEMORY_END 的全部可用物理页。本实验采用简单的栈式分配策略:
所有空闲物理页按地址顺序排列,current 指向第一个空闲页 ekernel,end 指向内存末尾 MEMORY_END。在分配时,优先检查 recycled 栈(存放已回收的页号),如果有则弹出复用;否则,检查 current 是否小于 end,如果小于则分配 current 并自增;如果都无法分配,则物理内存耗尽。最后回收,将释放的物理页号压入 recycled 栈。
Physical Memory Layout:
[0, ekernel) [ekernel, MEMORY_END)
┌───────────────────────────┬───────────────────────────────────────┐
│ Kernel Code & Data │ Free Frames (Managed by Allocator) │
└───────────────────────────┴───────────────────────────────────────┘
↑ ↑
current end
(Move ->)
Allocator State:
current: usize // 指向下一个线性分配的物理页号
end: usize // 物理内存上限
recycled: Vec<usize> // 回收栈(LIFO,优先分配)
pub struct StackFrameAllocator {
current: usize,
end: usize,
recycled: Vec<usize>,
}
为了消除手动释放内存带来的 Memory Leak 风险,我们利用 Rust 的 RAII 机制实现了 FrameTracker。它是物理页帧的所有权包装器 (Wrapper).
pub struct FrameTracker {
pub ppn: PhysPageNum,
}
impl Drop for FrameTracker {
fn drop(&mut self) {
frame_dealloc(self.ppn); // 离开作用域时自动归还给分配器
}
}
用法示例:
{
// 分配一个物理帧,获取所有权
let frame: FrameTracker = frame_alloc().unwrap();
// 使用帧...
} // 离开作用域,frame 自动 drop,物理帧被回收
此外,FrameTracker 在创建时会自动清零物理页内存。这是出于安全性考虑,防止新分配的进程读取到旧进程遗留的数据。
impl FrameTracker {
pub fn new(ppn: PhysPageNum) -> Self {
let bytes_array = ppn.get_bytes_array();
for i in bytes_array {
*i = 0; // 内存清零
}
Self { ppn }
}
}
为了更方便地管理复杂的地址空间,我们引入了更高层次的抽象:逻辑段(MapArea)和地址空间(MemorySet)。
MapArea 描述了一段连续的虚拟地址区间及其属性(如 .text 段、.data 段)。
pub struct MapArea {
vpn_range: VPNRange, // 虚拟页号区间 [start_vpn, end_vpn)
data_frames: BTreeMap<VirtPageNum, FrameTracker>, // 虚拟页到物理页帧的映射关系
map_type: MapType, // 映射类型
map_perm: MapPermission, // 权限标志 (R/W/X/U)
}
| 类型 | 说明 | 适用场景 |
|---|
| Identical | 恒等映射 (VA == PA)。虚拟地址直接对应相同的物理地址。 | 内核代码段、数据段。因为内核本身也是直接在物理内存上运行的程序。 |
| Framed | 帧映射。虚拟地址映射到随机分配的物理帧。 | 用户程序代码、数据、栈。操作系统为其动态分配离散的物理页。 |
pub enum MapType {
Identical,
Framed,
}
基于 bitflags 宏定义的权限位,对应页表项中的标志位:
bitflags! {
pub struct MapPermission: u8 {
const R = 1 << 1; // Readable
const W = 1 << 2; // Writable
const X = 1 << 3; // Executable
const U = 1 << 4; // User-accessible
}
}
MemorySet 是一个任务的所有内存映像的集合。它包含了一个多级页表(根节点)和一系列逻辑段。
pub struct MemorySet {
page_table: PageTable, // 根页表(持有页表节点的所有权)
areas: Vec<MapArea>, // 逻辑段列表(持有数据帧的所有权)
}
MemorySet 的结构可以概括为:
MemorySet
├── PageTable (Root)
│ └── frames: Vec<FrameTracker> // 管理所有页表节点占用的物理帧
│
└── areas: Vec<MapArea>
├── MapArea (.text) // 代码段
├── MapArea (.data) // 数据段
└── MapArea (User Stack) // 用户栈
└── data_frames // 该段拥有的数据物理帧
├── VPN_0 -> FrameTracker(PPN_a)
├── VPN_1 -> FrameTracker(PPN_b)
└── ...
自动资源回收:得益于 Rust 的所有权机制,当一个 MemorySet 被销毁时:
areas 被销毁 -> 其中的 FrameTracker 被 drop -> 用户数据物理帧被回收。
page_table 被销毁 -> 其中的 frames 被 drop -> 页表节点物理帧被回收。
这极大地简化了内核开发中的内存管理负担。