内存管理作为操作系统的重要功能之一,与操作系统的其他部分联系紧密。内存管理的主要工作是物理内存的分配和回收,而一个好的操作系统还应满足一些非功能需求:
- 尽可能提高内存利用率,减少内存空间的浪费
- 可以为进程提供充足的内存,并且多个进程之间不会争抢内存资源
- 进程与进程、进程与内核之间应互不干扰,以保证安全性
我们采用分页管理,单个页面的大小设置为4KiB,每个虚拟页面和物理页帧都对齐到这个页面大小,也就是说虚拟/物理地址区间 [0,4KiB)为第0个虚拟页面/物理页帧,而[4KiB,8KiB)为第1个,以此类推。 4KiB需要用 12 位字节地址来表示,因此虚拟地址和物理地址都被分成两部分:它们的低 12 位,即[11:0]被称为 页内偏移 (Page Offset) ,它描述一个地址指向的字节在它所在页面中的相对位置。而虚拟地址的高 27 位,即 [38:12] 为它的虚拟页号 VPN,同理物理地址的高 44 位,即[55:12]为它的物理页号 PPN,页号可以用来定位一个虚拟/物理地址属于哪一个虚拟页面/物理页帧。
地址转换是以页为单位进行的,在地址转换的前后地址的页内偏移部分不变。可以认为 MMU 只是从虚拟地址中取出 27 位虚拟页号,在页表中查到其对应的物理页号(如果存在的话),最后将得到的44位的物理页号与虚拟地址的12位页内偏移依序拼接到一起就变成了56位的物理地址。
// os/src/mm/address.rs
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct PhysAddr(pub usize);
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct VirtAddr(pub usize);
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct PhysPageNum(pub usize);
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct VirtPageNum(pub usize);
我们为虚拟地址与物理地址都定义了结构体,虽然结构体的内部实际上都是一个usize,但根本上我们在使用时可以非常方便的区分虚拟与物理地址,为我们编程提高了很大的便利,同时我们为其实现了From方法方法,该方法可以很方便的将usize和结构体进行转换,方便对结构体进行各种修改
impl From<usize> for PhysAddr {
fn from(v: usize) -> Self { Self(v & ( (1 << PA_WIDTH_SV39) - 1 )) }
}
impl From<usize> for PhysPageNum {
fn from(v: usize) -> Self { Self(v & ( (1 << PPN_WIDTH_SV39) - 1 )) }
}
impl From<PhysAddr> for usize {
fn from(v: PhysAddr) -> Self { v.0 }
}
impl From<PhysPageNum> for usize {
fn from(v: PhysPageNum) -> Self { v.0 }
}
我们将内存按照每4Kib进行分块,按块分配应用程序所需要的内存空间,这样可以避免内存出现外部碎片。同时我们的块足够小,也不会产生过量的内存碎片。 SV39 中虚拟页号被分为三级 页索引 (Page Index) ,因此这是一种三级页表。在这种三级页表的树结构中,自上而下分为三种不同的节点:一级/二级/三级页表节点。树的根节点被称为一级页表节点;一级页表节点可以通过一级页索引找到二级页表节点;二级页表节点可以通过二级页索引找到三级页表节点;三级页表节点是树的叶节点,通过三级页索引可以找到一个页表项。 对于每一个非叶子节点,我们同时定义标志位,定义不同状态对内存的读写权限。
bitflags! {
pub struct PTEFlags: u8 {
const V = 1 << 0;
const R = 1 << 1;
const W = 1 << 2;
const X = 1 << 3;
const U = 1 << 4;
const G = 1 << 5;
const A = 1 << 6;
const D = 1 << 7;
}
}
在 SV39 模式中我们采用三级页表,即将 27 位的虚拟页号分为三个等长的部分,第 26-18 位为一级页索引VPN0 ,第 17-9 位为二级页索引VPN1,第 8-0 位为三级页索引 VPN2。
我们也将页表分为一级页表(多级页表的根节点),二级页表,三级页表(多级页表的叶节点)。每个页表都用 9 位索引,因此有512个页表项,而每个页表项都是 8 字节,因此每个页表大小都为 4KiB。正好是一个物理页的大小。我们可以把一个页表放到一个物理页中,并用一个物理页号来描述它。事实上,一级页表的每个页表项中的物理页号可描述一个二级页表;二级页表的每个页表项中的物理页号可描述一个三级页表;三级页表中的页表项内容内容包含物理页号,即描述一个要映射到的物理页。
我们需要使用MMU的块表(TLB, Translation Lookaside Buffer),在我们切换地址空间后,需要执行sfence.vma
指令刷新TLB
操作系统通过对不同页表的管理,来完成对不同应用和操作系统自身所在的虚拟内存,以及虚拟内存与物理内存映射关系的全面管理。这种管理是建立在 地址空间 的抽象上,用来表明正在运行的应用或内核自身所在执行环境中的可访问的内存空间。
我们为每一个内存也创建一个FrameTracker,其本质上也就一个usize,用来表示使用的物理内存,我们为其实现Drop特性,在Drop中,调用内存空间的回收机制,该回收机制就是通知内存管理器该内存被释放这样,当一个应用被销毁时,其PCB中的 Memory_Set 也被销毁,Memory_Set 中 FrameTracker就被销毁,销毁前调用Drop,通知内存的回收,通过这个巧妙的设计实现内存的分配回收。
impl Drop for FrameTracker {
fn drop(&mut self) {
frame_dealloc(self.ppn);
}
}
对于内存的地址空间,由于初始化前,我们并未开启内存分页机制,如果在内核运行过程开启,将会十分复杂麻烦,因此我们对于内核空间,采取直接映射的方法,毕竟内核加载时,内存为空,不需要考虑冲突的问题,开启分页前后内存地址不变。
println!("mapping .text section");
memory_set.push(
MapArea::new(
(stext as usize).into(),
(etext as usize).into(),
MapType::Identical,
MapPermission::R | MapPermission::X,
),
None,
);
对于其他应用程序,我们采取正常的映射策略,为虚拟地址映射对应的物理地址
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]),
);
一旦使能了分页机制,我们必须在这个过程中同时完成地址空间的切换。具体来说,当 __alltraps 保存 Trap 上下文的时候,我们必须通过修改 satp 从应用地址空间切换到内核地址空间,因为 trap handler 只有在内核地址空间中才能访问;同理,在 __restore 恢复 Trap 上下文的时候,我们也必须从内核地址空间切换回应用地址空间,因为应用的代码和数据只能在它自己的地址空间中才能访问,应用是看不到内核地址空间的。这样就要求地址空间的切换不能影响指令的连续执行,即要求应用和内核地址空间在切换地址空间指令附近是平滑的。 我们将 trap.S 中的整段汇编代码放置在 .text.trampoline 段,并在调整内存布局的时候将它对齐到代码段的一个页面中:
stext = .;
.text : {
*(.text.entry)
+ . = ALIGN(4K);
+ strampoline = .;
+ *(.text.trampoline);
+ . = ALIGN(4K);
*(.text .text.*)
}
这样,这段汇编代码放在一个物理页帧中,且 __alltraps 恰好位于这个物理页帧的开头,其物理地址被外部符号 strampoline 标记。在开启分页模式之后,内核和应用代码都只能看到各自的虚拟地址空间,而在它们的视角中,这段汇编代码都被放在它们各自地址空间的最高虚拟页面上,由于这段汇编代码在执行的时候涉及到地址空间切换,故而被称为跳板页面。
stext = .;
.text : {
*(.text.entry)
. = ALIGN(4K);
strampoline = .;
*(.text.trampoline);
. = ALIGN(4K);
ssignaltrampoline = .;
KEEP(*(.text.signaltrampoline));
. = ALIGN(4K);
*(.text .text.*)
}
由于部分用户级的信号处理机制并不提供返回地址,因此我们需要手动为线程执行sigreturn,但是同样由于内存空间的切换,需要为处理机制提供跳板,来实现处理的返回。
mmap需要将文件的内容映射到内存当中,且分配后我们只会整片整片的收回,因此我们不需要考虑太高的灵活性,只需要考虑保证一定数量的mmap。其基本实现跟之前的内存映射并无太大区别,在该系统中,我们在每个MemorySet中预留了一个mmap_area,可以通过数组保存MapArea,每个MapArea对应虚拟地址连续的一片内存空间。在现阶段系统上运行的程序,每个用户的虚拟内存空间高达512G,十分宽裕,所以我们在没有指定address时,完全可以指定一个固定的虚拟地址,用于mmap存储的起始地址。
// memory_set.rs
pub fn mmap(
&mut self,
start_addr: usize,
len: usize,
offset: usize,
context: Vec<u8>,
) -> isize {
let mmap_end = VirtAddr::from(((start_addr + len) + PAGE_SIZE - 1) & (!(PAGE_SIZE - 1)));
let mmap_area = MapArea::new(
VirtAddr::from(start_addr),
mmap_end,
MapType::Framed,
MapPermission::R | MapPermission::W | MapPermission::U,
);
let subcontext = context[offset..(len + offset)].to_vec();
self.push(mmap_area, Some(subcontext.as_slice()));
0
}
pub struct MapArea {
vpn_range: VPNRange,
data_frames: BTreeMap<VirtPageNum, FrameTracker>,
map_type: MapType,
map_perm: MapPermission,
}
在rCore中,应用程序自己开辟了一块数组,作为heap空间,对于正规的heap,我们需要在内存中动态的申请内存空间,heap需要多次动态的申请内存空间,因此我们需要有一个方便添加的内存空间。由于heap是针对于应用的,因此我们在进程的PCB中记录heap的起始结束地址,在进程的内存空间Memory_Set中跟踪heap的内存空间。在具体执行时,我们需要分别计算需要的地址需要的页表号和当前页表号的最大地址,对比后再决定是否申请新的内存空间。
pub fn sys_brk(addr: usize) -> isize {
let process = current_process();
let mut inner = process.inner_exclusive_access();
if addr == 0 {
inner.heap_end.0 as isize
} else if addr < inner.heap_base.0 {
-1
} else {
// We need to calculate to determine if we need a new page table
// current end page address
let align_addr = ((addr) + PAGE_SIZE - 1) & (!(PAGE_SIZE - 1));
// the end of 'addr' value
let align_end = ((inner.heap_end.0) + PAGE_SIZE) & (!(PAGE_SIZE - 1));
if align_end > addr {
inner.heap_end = addr.into();
align_addr as isize
} else {
let heap_end = inner.heap_end;
// map heap
inner.memory_set.map_heap(heap_end, align_addr.into());
inner.heap_end = align_end.into();
addr as isize
}
}
}
内核通过多种方式来管理内存,提供了极大的灵活性,我们使用了两种风格迥异的内存持有结构体:
pub struct MapArea {
vpn_range: VPNRange,
data_frames: BTreeMap<VirtPageNum, FrameTracker>,
map_type: MapType,
map_perm: MapPermission,
}
管理整块的内存,对于各种应用的加载、空间的映射有着极大的方便性,这些空间的特点就是一起销毁、一起申请,较少出现零碎的内存空间,因此使用这种内存管理方式。
BTreeMap<VirtPageNum, FrameTracker>
B+树进行的内存管理,多用于brk等零碎的系统调用,为每一个内存空间提供单独的索引,因此对于零碎空间的加载和销毁十分方便。
#[derive(Copy, Clone, PartialEq, Debug)]
pub enum MapType {
Identical,
Framed,
Marked,
}
对于内核空间,我们需要直观的管理所有的内存,因此我们会采用直接映射的方法,虚拟地址和物理地址相等;对于用户应用,我们需要妥善管理,给用户提供虚假的内存空间,因此需要提供映射的内存模式,用户以为自己是在物理内存上运行,实际上都是由内核进行管理,分散在各处的内存空间;我们的文件系统会通过cache来暂存数据,对于这部分数据的传输,如果采用传统的方法,使用数组,会浪费大量的堆栈空间,不如我们直接对其进行管理,将文件系统持有的cache标记在内核中,内核并不拥有内存,这是内核为自己提供的一段虚拟空间,不管文件cache是否连续,都可以连城一片真是的虚拟空间,实现文件的快速加载。