最近在看rCore操作系统实验,在做到虚拟地址到物理地址转换这一部分花的时间比较多。之前主要是从CPU的角度去理解MMU如何进行虚拟地址到物理地址的转换,多级页表是如何寻找的,知道应该有这么一个页表专门存储下一级页表的地址数组,但是一开始的时候由于没有通读源码,所以一直没找到这部分。所以细读了一下rCore实验在lab4_ref给出的mm部分的代码,主要是带着两个问题读的:

  1. 在satp(Supervisor Address Translation and Protection,监管者地址转换和保护)寄存器发生地址模式切换以后,内核代码中对原来实地址模式下的数据的访问如何继续生效
  2. OS视角构建页表索引的细节

项目目录下有以下几个部分:

模块 功能
mm 内存管理模块
sync 多线程同步模块
syscall 系统调用模块
task 进程管理模块
trap 中断模块

虚拟地址到物理地址的映射和管理主要在内存管理模块中,内存管理模块主要完成了以下几个功能:

内核堆内存管理

按照之前嵌入式实时系统上的经验,操作系统本身直接与硬件打交道,因此会完成“开天辟地”的过程,也就是说需要构建程序运行的环境。类似于应用程序中内存分配功能是要自己来实现的。因此,常用的做法是,通过静态全局变量数组来占据一段连续的内存空间当作堆内存的内存池。内核堆内存管理则主要是负责对这块内存池的使用进行管理。

heap_allocator.rs文件中主要完成了内核的堆内存分配。在正常的应用程序编写时,我们可以使用std库来完成我们在堆上的内存分配,这个操作一般是new,而在裸机环境中是没有std环境的,这种情况下可以使用alloc库,它的主要介绍如下:

This library provides smart pointers and collections for managing heap-allocated values.

This library, like libcore, normally doesn’t need to be used directly since its contents are re-exported in the std crate. Crates that use the #![no_std] attribute however will typically not depend on std, so they’d use this crate instead.

当使用alloc库进行分配时,可以通过 #[global_allocator] 声明将要使用的分配器,可以看到在 heap_allocator.rs 中指定了堆内存分配器:

1
2
3
4
#[global_allocator]
// heap allocator instance

static HEAP_ALLOCATOR: LockedHeap = LockedHeap::empty();

HEAP_ALLOCATOR需要实现 GlobalAlloc trait方可被alloc库正确调用,buddy-system_allocator 中的 LockedHeap 有如下实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
unsafe impl GlobalAlloc for LockedHeap {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
self.0
.lock()
.alloc(layout)
.ok()
.map_or(0 as *mut u8, |allocation| allocation.as_ptr())
}

unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
self.0.lock().dealloc(NonNull::new_unchecked(ptr), layout)
}
}

因此内核堆内存管理主要是利用 allocbuddy_system_allocator 来实现的。

页表内存分配和回收

内核会管理全部的内存地址,因此除去内核所占的内存空间外,从链接脚本的 ekernel(end of kernel) 开始直到 MAX_MEMORY 都将被内核以页表的形式动态管理。这部分的代码在 frame_allocator.rs,通过 StackFrameAllocator 来进行 stack(栈) 风格的内存管理:

1
2
3
4
5
6
// StackFrameAllocator
pub struct StackFrameAllocator {
current: usize,
end: usize,
recycled: Vec<usize>,
}

在初始分配内存时,会通过current的递增来实现。在有内存需要回收的时候,将其地址存入recycled列表中表示其可用,这种情况下再次收到分配内存的请求就会优先从recycled中进行分配。

FrameTracker 则是内核对分配的物理页表的一个handler,可以通过它记录物理页表的物理地址,当该handler生命周期结束后,则会自动对指定的页表进行释放。

1
2
3
4
5
impl Drop for FrameTracker {
fn drop(&mut self) {
frame_dealloc(self.ppn);
}
}

页表目录管理

在拥有上一节对全部物理内存的简单分配和回收功能后,开启虚拟地址模式的最后一部分工作就是建立虚拟地址到物理地址的映射关系了。
virtualAddr_to_phy

如图所示,连续的虚拟地址不一定对应连续的物理地址,而这个对应关系需要操作系统按照CPU支持的格式进行维护。由虚拟地址到物理地址的转换是CPU MMU器件自动发生的硬件行为。

rCore采用的是Sv39的转换模式。与地址转换相关的寄存器为 satp 寄存器,它的布局是:
satp, 其中PPN存放的是页表的根地址,即多级页表中的第一级页表的地址。当通过页表来进行地址转换时,流程如下:

  1. 通过satp中的ppn访问第一级页表的物理地址
  2. 获取该地址下,虚拟地址的第一级的偏移,找到对应的 PTE 节点信息,继而判断这个页表项指向的是下一级页表还是物理的内存页,如果是内存页则直接开始读写操作,如果仍旧不是叶结点则继续查找下一级。(关于PTE,虚拟地址和物理地址的结构信息,可以查看risc-v的特权级别架构手册

在没有细读源码的时候,我是没找到这样一张表的,因为从来没有在操作系统的角度看过页表的使用,因此不清楚它是随着使用而不断的改变的。

这部分的源码在 page_table.rs 中,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
impl PageTable {
// .......
fn find_pte_create(&mut self, vpn: VirtPageNum) -> Option<&mut PageTableEntry> {
let mut idxs = vpn.indexes();
let mut ppn = self.root_ppn;
let mut result: Option<&mut PageTableEntry> = None;
for (i, idx) in idxs.iter_mut().enumerate() {
let pte = &mut ppn.get_pte_array()[*idx];
if i == 2 {
result = Some(pte);
break;
}
if !pte.is_valid() {
let frame = frame_alloc().unwrap();
*pte = PageTableEntry::new(frame.ppn, PTEFlags::V);
self.frames.push(frame);
}
ppn = pte.ppn();
}
result
}
fn find_pte(&self, vpn: VirtPageNum) -> Option<&PageTableEntry> {
let idxs = vpn.indexes();
let mut ppn = self.root_ppn;
let mut result: Option<&PageTableEntry> = None;
for (i, idx) in idxs.iter().enumerate() {
let pte = &ppn.get_pte_array()[*idx];
if i == 2 {
result = Some(pte);
break;
}
if !pte.is_valid() {
return None;
}
ppn = pte.ppn();
}
result
}
}

可以看到,在 find_pte_create 方法中,pte 非法时会通过上段提到的页表内存分配接口进行动态的分配,并将其物理地址 frame.ppn 写入 pte 中,而当合法时,则直接返回。

通过上述的几段分析,基本上涵盖了内核所遇到的内存方面的管理。那还有最后一个疑问是,当处理器的地址模式由实地址变为虚地址时,原来内核部分的代码如何正常运行,要知道内核中并不是所有变量用的都是相对地址,包括一些物理外设的访问地址都是固定的。

我去对比了一下带 mm 模块前的 os 实验,发现编译器方面并没有添加什么与之相关的编译参数,只能是在代码里做的手脚,发现这部分的处理在 memory_set.rs 文件中,

1
2
3
4
5
6
7
8
9
10
11
pub enum MapType {
Identical,
Framed,
}

pub struct MapArea {
vpn_range: VPNRange,
data_frames: BTreeMap<VirtPageNum, FrameTracker>,
map_type: MapType,
map_perm: MapPermission,
}

这里定义了两种的虚拟地址到物理地址映射方式,其中 Framed 的方式就是前文提到的分配内存的方式,那这种虚拟地址和物理地址的映射是没有字面上的联系的,也就是不查表的情况下,是看不出关联的,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
pub fn new_kernel() -> Self {
let mut memory_set = Self::new_bare();
// map trampoline
memory_set.map_trampoline();
// map kernel sections
info!(".text [{:#x}, {:#x})", stext as usize, etext as usize);
info!(".rodata [{:#x}, {:#x})", srodata as usize, erodata as usize);
info!(".data [{:#x}, {:#x})", sdata as usize, edata as usize);
info!(
".bss [{:#x}, {:#x})",
sbss_with_stack as usize, ebss as usize
);
info!("mapping .text section");
memory_set.push(
MapArea::new(
(stext as usize).into(),
(etext as usize).into(),
MapType::Identical,
MapPermission::R | MapPermission::X,
),
None,
);
info!("mapping .rodata section");
memory_set.push(
MapArea::new(
(srodata as usize).into(),
(erodata as usize).into(),
MapType::Identical,
MapPermission::R,
),
None,
);
info!("mapping .data section");
memory_set.push(
MapArea::new(
(sdata as usize).into(),
(edata as usize).into(),
MapType::Identical,
MapPermission::R | MapPermission::W,
),
None,
);
info!("mapping .bss section");
memory_set.push(
MapArea::new(
(sbss_with_stack as usize).into(),
(ebss as usize).into(),
MapType::Identical,
MapPermission::R | MapPermission::W,
),
None,
);
info!("mapping physical memory");
memory_set.push(
MapArea::new(
(ekernel as usize).into(),
MEMORY_END.into(),
MapType::Identical,
MapPermission::R | MapPermission::W,
),
None,
);
memory_set
}

在定义 new_kernel 方法的时候,引用了大量的链接文件中定义的全局变量,比如 sbss,ebss,stext,etext 等,可以看到,这些已有的程序需要使用的地址都进行了 Identical 类型的映射。

MapAreamap_one 方法中,我们可以发现, Identical 是不需要再次分配内存来进行映射的,而是直接把传进来的地址作为物理地址使用,也就是说,为了保证在切换地址模式后,内核的代码仍旧能够正常工作,它用同样的虚拟地址指向了原来的物理地址,即建立了0x80000000(虚拟地址)到0x80000000(物理地址)的联系,这部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pub fn map_one(&mut self, page_table: &mut PageTable, vpn: VirtPageNum) {
let ppn: PhysPageNum;
match self.map_type {
MapType::Identical => {
ppn = PhysPageNum(vpn.0);
}
MapType::Framed => {
let frame = frame_alloc().unwrap();
ppn = frame.ppn;
self.data_frames.insert(vpn, frame);
}
}
let pte_flags = PTEFlags::from_bits(self.map_perm.bits).unwrap();
page_table.map(vpn, ppn, pte_flags);
}