需要实现什么
虚拟内存机制
拥有什么
使用固定内核栈和用户栈的分时多任务系统
思路
将 loader.rs 中的固定内核栈和用户栈转为根据虚拟内存机制创建的内核空间和用户空间。
用户:
将用户进程初始化从TASK_MANAGER移到TaskControlBlock::new()中,实现根据elf自动创建用户地址空间。
当访问一段虚拟地址时,向satp寄存器中写入页表根地址并启动 MMU 三级页表机制。根据虚拟地址中的三个9字节长度的页表项地址逐级从页表中找到三个页表项,最后从最后一个页表项中获取物理页号。
因此,我们需要实现的内容有:
- 物理地址pa、虚拟地址va、物理页号ppn、虚拟页号vpn
- 地址空间 MemorySet、页表 PageTable、页表项 PageTableEntry
- 两个用于自动回收内存的 FrameTracker 和 逻辑段 MapArea
内核:
初始化内存管理单元,类似用户进程创建内核的内存空间与页表并写入 satp 寄存器。将 MapArea 中的 MapType 设置为 Identical 使用恒等映射操纵内存。
TASK_MANAGER 从 elf 文件中统一加载进程并创建用户空间。
改进 Trap 处理和 sys_write 的实现。
本章代码树
├── os
│ ├── ...
│ └── src
│ ├── ...
│ ├── config.rs(修改:新增一些内存管理的相关配置)
│ ├── linker.ld(修改:将跳板页引入内存布局)
│ ├── loader.rs(修改:仅保留获取应用数量和数据的功能)
│ ├── main.rs(修改)
│ ├── mm(新增:内存管理的 mm 子模块)
│ │ ├── address.rs(物理/虚拟 地址/页号的 Rust 抽象)
│ │ ├── frame_allocator.rs(物理页帧分配器)
│ │ ├── heap_allocator.rs(内核动态内存分配器)
│ │ ├── memory_set.rs(引入地址空间 MemorySet 及逻辑段 MemoryArea 等)
│ │ ├── mod.rs(定义了 mm 模块初始化方法 init)
│ │ └── page_table.rs(多级页表抽象 PageTable 以及其他内容)
│ ├── syscall
│ │ ├── fs.rs(修改:基于地址空间的 sys_write 实现)
│ │ ├── mod.rs
│ │ └── process.rs
│ ├── task
│ │ ├── context.rs(修改:构造一个跳转到不同位置的初始任务上下文)
│ │ ├── mod.rs(修改,详见文档)
│ │ ├── switch.rs
│ │ ├── switch.S
│ │ └── task.rs(修改,详见文档)
│ └── trap
│ ├── context.rs(修改:在 Trap 上下文中加入了更多内容)
│ ├── mod.rs(修改:基于地址空间修改了 Trap 机制,详见文档)
│ └── trap.S(修改:基于地址空间修改了 Trap 上下文保存与恢复汇编代码)
└── user
├── build.py(编译时不再使用)
├── ...
└── src
├── linker.ld(修改:将所有应用放在各自地址空间中固定的位置)
└── ...
实现过程
物理地址PhysAddr,虚拟地址VirtAddr,物理页号PhysPageNum,虚拟页号VirtPageNum
实现了地址页号间相互转换、从物理地址中读取数据(页表项、页、任意类型指针)、从虚拟地址中读取各级页表项地址。
用户进程
user用户程序中 linker.ld(修改:将所有应用放在各自地址空间中固定的位置)
任务控制块TaskControlBlock
每个任务都有自己的地址空间 MemorySet 。
地址空间 MemorySet
地址空间是一系列有关联的逻辑段, 用来表明正在运行的应用所在执行环境中的可访问内存空间,在这个内存空间中,包含了页表和一系列的不一定连续的逻辑段。 这样我们就有任务的地址空间、内核的地址空间等说法了。
// os/src/mm/memory_set.rs
pub struct MemorySet {
page_table: PageTable,
areas: Vec<MapArea>,
}
PageTable 下挂着所有多级页表的节点所在的物理页帧,而每个 MapArea 下则挂着对应逻辑段中的数据所在的物理页帧,这两部分 合在一起构成了一个地址空间所需的所有物理页帧。这同样是一种 RAII 风格,当一个地址空间 MemorySet 生命周期结束后, 这些物理页帧都会被回收。
提供from_elf方法用于创建用户程序地址空间。
其中.text即为代码段,为只读;.data为已初始化读写数据段;.rodata为只读数据段;.bss段包含程序中未初始化的全局变量和static变量。
from_elf:将跳板插入到应用地址空间(Trampoline)->解析elf并写入逻辑段MapArea中(Framed)->在目前涉及到的最大的虚拟页号 max_end_vpn 上面加入保护页和用户栈(Guard Page和User Stack)->映射次高页面来存放 Trap 上下文(TrapContext)。
/// Include sections in elf and trampoline and TrapContext and user stack,
/// also returns user_sp_base and entry point.
pub fn from_elf(elf_data: &[u8]) -> (Self, usize, usize) {
let mut memory_set = Self::new_bare();
// map trampoline
memory_set.map_trampoline();
// map program headers of elf, with U flag
let elf = xmas_elf::ElfFile::new(elf_data).unwrap();
let elf_header = elf.header;
let magic = elf_header.pt1.magic;
assert_eq!(magic, [0x7f, 0x45, 0x4c, 0x46], "invalid elf!");
let ph_count = elf_header.pt2.ph_count();
let mut max_end_vpn = VirtPageNum(0);
for i in 0..ph_count {
let ph = elf.program_header(i).unwrap();
if ph.get_type().unwrap() == xmas_elf::program::Type::Load {
let start_va: VirtAddr = (ph.virtual_addr() as usize).into();
let end_va: VirtAddr = ((ph.virtual_addr() + ph.mem_size()) as usize).into();
let mut map_perm = MapPermission::U;
let ph_flags = ph.flags();
if ph_flags.is_read() {
map_perm |= MapPermission::R;
}
if ph_flags.is_write() {
map_perm |= MapPermission::W;
}
if ph_flags.is_execute() {
map_perm |= MapPermission::X;
}
let map_area = MapArea::new(start_va, end_va, MapType::Framed, map_perm);
max_end_vpn = map_area.vpn_range.get_end();
memory_set.push(
map_area,
Some(&elf.input[ph.offset() as usize..(ph.offset() + ph.file_size()) as usize]),
);
}
}
// map user stack with U flags
let max_end_va: VirtAddr = max_end_vpn.into();
let mut user_stack_bottom: usize = max_end_va.into();
// guard page
user_stack_bottom += PAGE_SIZE;
let user_stack_top = user_stack_bottom + USER_STACK_SIZE;
memory_set.push(
MapArea::new(
user_stack_bottom.into(),
user_stack_top.into(),
MapType::Framed,
MapPermission::R | MapPermission::W | MapPermission::U,
),
None,
);
// used in sbrk
memory_set.push(
MapArea::new(
user_stack_top.into(),
user_stack_top.into(),
MapType::Framed,
MapPermission::R | MapPermission::W | MapPermission::U,
),
None,
);
// map TrapContext
memory_set.push(
MapArea::new(
TRAP_CONTEXT_BASE.into(),
TRAMPOLINE.into(),
MapType::Framed,
MapPermission::R | MapPermission::W,
),
None,
);
(
memory_set,
user_stack_top,
elf.header.pt2.entry_point() as usize,
)
}
页表 PageTable
/// page table structure
pub struct PageTable {
root_ppn: PhysPageNum,
frames: Vec<FrameTracker>,
}
root_ppn保存页表根节点的物理页号。frames仅用于触发FrameTracker的Drop自动回收物理页。
页表中保存了定长的页表项数组,通过根节点物理页号的get_pte_array()访问。
// os/src/mm/address.rs
impl PhysPageNum {
// 页表
pub fn get_pte_array(&self) -> &'static mut [PageTableEntry] {
let pa: PhysAddr = self.clone().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.clone().into();
unsafe {
core::slice::from_raw_parts_mut(pa.0 as *mut u8, 4096)
}
}
}
当访问一段虚拟地址时,向satp寄存器中写入页表根地址并启动 MMU 三级页表机制。根据虚拟地址中的三个9字节长度的页表项地址从页表中逐级获取到三个页表项,最后从最后一个页表项中获取物理页号。
// os\src\mm\page_table.rs
/// Find PageTableEntry by VirtPageNum
fn find_pte(&self, vpn: VirtPageNum) -> Option<&mut PageTableEntry> {
let idxs = vpn.indexes();
let mut ppn = self.root_ppn;
let mut result: Option<&mut PageTableEntry> = None;
for (i, idx) in idxs.iter().enumerate() {
let pte = &mut ppn.get_pte_array()[*idx];
if i == 2 {
result = Some(pte);
break;
}
if !pte.is_valid() {
return None;
}
ppn = pte.ppn();
}
result
}
/// get the page table entry from the virtual page number
pub fn translate(&self, vpn: VirtPageNum) -> Option<PageTableEntry> {
self.find_pte(vpn).map(|pte| *pte)
}
let ppn = page_table.translate(vpn).unwrap().ppn();
文档中 va 和 pte 的图片应该是有问题的,三级页表是在 va 中实现的而非 pte 中。
页表项 PageTableEntry
PTE结构体中保存了64位页表项,提供了获取物理地址和标志位的函数。
// os\src\mm\page_table.rs
pub struct PageTableEntry {
/// bits of page table entry
pub bits: usize,
}
impl PageTableEntry {
/// Create a new page table entry
pub fn new(ppn: PhysPageNum, flags: PTEFlags) -> Self {
PageTableEntry {
bits: ppn.0 << 10 | flags.bits as usize,
}
}
...
/// Get the physical page number from the page table entry
pub fn ppn(&self) -> PhysPageNum {
(self.bits >> 10 & ((1usize << 44) - 1)).into()
}
/// Get the flags from the page table entry
pub fn flags(&self) -> PTEFlags {
PTEFlags::from_bits(self.bits as u8).unwrap()
}
...
}
FrameTracker
包含物理页号ppn。实现了新建时将该物理页清零,销毁时调用全局物理页帧分配器自动回收内存。
// os/src/mm/frame_allocator.rs
pub struct FrameTracker {
pub ppn: PhysPageNum,
}
逻辑段 MapArea
我们以逻辑段 MapArea 为单位描述一段连续地址的虚拟内存。所谓逻辑段,就是指地址区间中的一段实际可用(即 MMU 通过查多级页表 可以正确完成地址转换)的地址连续的虚拟地址区间,该区间内包含的所有虚拟页面都以一种相同的方式映射到物理页帧,具有可读/可写/可执行等属性。用于销毁时自动释放资源。
// os/src/mm/memory_set.rs
pub struct MapArea {
vpn_range: VPNRange,
data_frames: BTreeMap<VirtPageNum, FrameTracker>,
map_type: MapType,
map_perm: MapPermission,
}
MapType有两种:Identical 表示恒等映射,虚拟地址=物理地址,内核用它来直接访问物理内存;而 Framed 则表示对于每个虚拟页面都需要映射到一个新分配的物理页帧。
MapPermission:包括U/R/W/X 四个标志位。
内核
初始化内存管理单元
初始化全局内核动态内存分配器、全局物理页帧分配器、全局内核地址空间。
// os/src/mm/mod.rs
pub use memory_set::KERNEL_SPACE;
pub fn init() {
heap_allocator::init_heap();
frame_allocator::init_frame_allocator();
KERNEL_SPACE.exclusive_access().activate();
}
// os\src\mm\memory_set.rs
/// Change page table by writing satp CSR Register.
pub fn activate(&self) {
let satp = self.page_table.token();
unsafe {
satp::write(satp);
asm!("sfence.vma");
}
}
PageTable::token 会按照 satp CSR 格式要求 构造一个无符号 64 位无符号整数,使得其 分页模式为 SV39 ,且将当前多级页表的根节点所在的物理页号填充进去。在 activate 中,我们将这个值写入当前 CPU 的 satp CSR ,从这一刻开始 SV39 分页模式就被启用了,而且 MMU 会使用内核地址空间的多级页表进行地址转换。
加载用户进程
loader.rs仅保留获取应用数量和数据的功能,TASK_MANAGER 从 elf 文件中统一加载进程并创建用户空间。
// os\src\task\mod.rs
lazy_static! {
pub static ref TASK_MANAGER: TaskManager = {
println!("init TASK_MANAGER");
let num_app = get_num_app();
println!("num_app = {}", num_app);
let mut tasks: Vec<TaskControlBlock> = Vec::new();
for i in 0..num_app {
tasks.push(TaskControlBlock::new(get_app_data(i), i));
}
...
};
}
// os\src\task\task.rs
impl TaskControlBlock {
/// Based on the elf info in program, build the contents of task in a new address space
pub fn new(elf_data: &[u8], app_id: usize) -> Self {
// memory_set with elf program headers/trampoline/trap context/user stack
let (memory_set, user_sp, entry_point) = MemorySet::from_elf(elf_data);
...
}
改进 Trap 处理和 sys_write 的实现
trap
详见文档
以下节选自文档:
由于应用的 Trap 上下文不在内核地址空间,因此我们调用 current_trap_cx 来获取当前应用的 Trap 上下文的可变引用 而不是像之前那样作为参数传入 trap_handler 。至于 Trap 处理的过程则没有发生什么变化。
注意到,在 trap_handler 的开头还调用 set_kernel_trap_entry 将 stvec 修改为同模块下另一个函数 trap_from_kernel 的地址。这就是说,一旦进入内核后再次触发到 S 的 Trap,则会在硬件设置一些 CSR 之后跳过寄存器 的保存过程直接跳转到 trap_from_kernel 函数,在这里我们直接 panic 退出。这是因为内核和应用的地址空间分离 之后,从 U 还是从 S Trap 到 S 的 Trap 上下文保存与恢复实现方式和 Trap 处理逻辑有很大差别,我们不得不实现两遍而 不太可能将二者整合起来。这里简单起见我们弱化了从 S 到 S 的 Trap ,省略了 Trap 上下文保存过程而直接 panic 。
在 trap_handler 完成 Trap 处理之后,我们需要调用 trap_return 返回用户态。
sys_write
由于os使用恒等映射直接访问内存,而应用触发系统调用时用户向内核传的是用户程序的虚拟地址。内核处理该指针时须首先将其翻译为物理地址。
// user\src\syscall.rs
pub fn sys_write(fd: usize, buffer: &[u8]) -> isize {
syscall(SYSCALL_WRITE, [fd, buffer.as_ptr() as usize, buffer.len()])
}
// os\src\syscall\fs.rs
pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
trace!("kernel: sys_write");
match fd {
FD_STDOUT => {
let buffers = translated_byte_buffer(current_user_token(), buf, len);
for buffer in buffers {
print!("{}", core::str::from_utf8(buffer).unwrap());
}
len as isize
}
_ => {
panic!("Unsupported fd in sys_write!");
}
}
}
// os\src\mm\page_table.rs
/// Translate&Copy a ptr[u8] array with LENGTH len to a mutable u8 Vec through page table
pub fn translated_byte_buffer(token: usize, ptr: *const u8, len: usize) -> Vec<&'static mut [u8]> {
let page_table = PageTable::from_token(token);
let mut start = ptr as usize;
let end = start + len;
let mut v = Vec::new();
while start < end {
let start_va = VirtAddr::from(start);
let mut vpn = start_va.floor();
let ppn = page_table.translate(vpn).unwrap().ppn();
vpn.step();
let mut end_va: VirtAddr = vpn.into();
end_va = end_va.min(VirtAddr::from(end));
if end_va.page_offset() == 0 {
v.push(&mut ppn.get_bytes_array()[start_va.page_offset()..]);
} else {
v.push(&mut ppn.get_bytes_array()[start_va.page_offset()..end_va.page_offset()]);
}
start = end_va.into();
}
v
}
translated_byte_buffer 从satp寄存器中获取token并找到对应的页表,根据页表将虚拟地址翻译为物理地址。
chapter4练习
编程作业
重写 sys_get_time 和 sys_task_info
仿照 sys_write 先将虚拟地址翻译为物理地址。
mmap 和 munmap 匿名映射
问答作业
请列举 SV39 页表页表项的组成,描述其中的标志位有何作用?
物理页号(Physical Page Number, PPN):
占据了页表项的第53-10位,总共44位,表示这个虚拟页号映射到的物理页号。
状态信息位:
占据了页表项的第9-0位,用于描述页的相关状态信息。
其中,V(Valid)表示页表项是否有效。
R、W、X分别代表页是否可读(Readable)、可写(Writable)、可执行(Executable)。当这三位全为0时,表示该页表项是非叶节点,即存储的是下一级页表的物理地址。
U(User-accessible)表示用户态是否可访问该页。
G(Global)表示是否全局地址代换。
A(Accessed)表示该页是否已被访问过。
D(Dirty)表示该页的内容是否被修改过。
RSW(Reserved)是保留位,用于未来可能的扩展或特定用途。
缺页
缺页指的是进程访问页面时页面不在页表中或在页表中无效的现象,此时 MMU 将会返回一个中断, 告知 os 进程内存访问出了问题。os 选择填补页表并重新执行异常指令或者杀死进程。
- 请问哪些异常可能是缺页导致的?
StoreFault, StorePageFault, LoadFault, LoadPageFault 均有可能是缺页导致的。 - 发生缺页时,描述相关重要寄存器的值,上次实验描述过的可以简略。
sstatus, sepc, sscratch, stval, scause, satp 寄存器,它们分别用于存储引发缺页异常的指令地址、具体的异常原因、引发缺页的页表地址。
缺页有两个常见的原因,其一是 Lazy 策略,也就是直到内存页面被访问才实际进行页表操作。 比如,一个程序被执行时,进程的代码段理论上需要从磁盘加载到内存。但是 os 并不会马上这样做, 而是会保存 .text 段在磁盘的位置信息,在这些代码第一次被执行时才完成从磁盘的加载操作。
- 这样做有哪些好处?
加快程序被执行时加载速度,节省程序占用的内存空间。
其实,我们的 mmap 也可以采取 Lazy 策略,比如:一个用户进程先后申请了 10G 的内存空间, 然后用了其中 1M 就直接退出了。按照现在的做法,我们显然亏大了,进行了很多没有意义的页表操作。
- 处理 10G 连续的内存页面,对应的 SV39 页表大致占用多少内存 (估算数量级即可)?
10G/4k*8B大约占用 10 MB 内存。 - 请简单思考如何才能实现 Lazy 策略,缺页时又如何处理?描述合理即可,不需要考虑实现。
mmap时不分配内存,缺页时触发中断,分配内存即可。
缺页的另一个常见原因是 swap 策略,也就是内存页面可能被换到磁盘上了,导致对应页面失效。
- 此时页面失效如何表现在页表项(PTE)上?
V标志位为0
双页表与单页表
为了防范侧信道攻击,我们的 os 使用了双页表。但是传统的设计一直是单页表的,也就是说, 用户线程和对应的内核线程共用同一张页表,只不过内核对应的地址只允许在内核态访问。 (备注:这里的单/双的说法仅为自创的通俗说法,并无这个名词概念,详情见 KPTI )
- 在单页表情况下,如何更换页表?
切换标志位访问权限 - 单页表情况下,如何控制用户态无法访问内核页面?(tips:看看上一题最后一问)
U标志位设置为0 - 单页表有何优势?(回答合理即可)
节省内存,加快访问速度。 - 双页表实现下,何时需要更换页表?假设你写一个单页表操作系统,你会选择何时更换页表(回答合理即可)?
切换用户态内核态时更换页表。