清华大学开源操作系统训练营: rCore chapter4练习

练习实验书: https://learningos.cn/rCore-Tutorial-Guide-2023A/chapter4/7exercise.html

我的代码: https://github.com/LearningOS/2023a-rcore-ToniXWD

1 编程作业

  1. 重写 sys_get_timesys_task_info
  2. 实现 mmapmunmap 两个系统调用,通过所有测例

1.1 重写 sys_get_timesys_task_info

这一部分的实现很简单, 引入虚拟内存和页表后无非就是内核无法直接使用系统调用传递的地址了, 需要将这个用户空间的虚拟地址做一层手动翻译, 这样的方法已经为我们提供了: translated_struct_ptr, 因此实现很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub fn sys_get_time(_ts: *mut TimeVal, _tz: usize) -> isize {
trace!("kernel: sys_get_time");
let us = get_time_us();
let ts = translated_struct_ptr(current_user_token(), _ts);
*ts = TimeVal {
sec: us / 1_000_000,
usec: us % 1_000_000,
};
0
}
pub fn sys_task_info(_ti: *mut TaskInfo) -> isize {
trace!("kernel: sys_task_info NOT IMPLEMENTED YET!");

let ti = translated_struct_ptr(current_user_token(), _ti);

*ti = TaskInfo {
status: TaskStatus::Running,
syscall_times: get_sys_call_times(),
time: get_task_run_times(),
};
0
}

1.2 实现 mmapmunmap

mmapLinux 中主要用于在内存中映射文件, 本次实验简化它的功能,仅用于申请内存

1.2.1 MemorySet结构体

由于mmap要在地址空间中添加新的映射, 因此无非就是在MemorySet中添加一块映射的内存区域, 先看看每个内存地址空间本来的结构:

1
2
3
4
5
/// address space
pub struct MemorySet {
page_table: PageTable,
areas: Vec<MapArea>,
}

其中areas存放的是本地址空间的多个逻辑段, 每个逻辑段的地址是连续的, 但mmap是动态申请地址空间的, 多次调用会创建多哥不连续的内存区域, 因此不适合存放在areas, 因此创建一个新的成员存放:

1
2
3
4
5
6
/// address space
pub struct MemorySet {
page_table: PageTable,
areas: Vec<MapArea>,
map_tree: BTreeMap<VirtPageNum, FrameTracker>,
}

这里的map_tree还是RAII的思想, munmap调用时, 会将页帧从map_tree中释放, 触发drop trait使内存页分配器回收内存页

1.2.2 MemorySet::mmap && MemorySet::unmmap

有了上述的数据结构, mmap时只需要调用frame_alloc分配一个新的内存页即可:

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
// os/src/mm/memory_set.rs
/// mmap
pub fn mmap(&mut self, start: usize, len: usize, port: usize) -> isize {
let va_start: VirtAddr = start.into();
if !va_start.aligned() {
debug!("unmap fail don't aligned");
return -1;
}
let mut va_start: VirtPageNum = va_start.into();

let mut flags = PTEFlags::from_bits(port as u8).unwrap();
if port & 0b0000_0001 != 0 {
flags |= PTEFlags::R;
}

if port & 0b0000_0010 != 0 {
flags |= PTEFlags::W;
}

if port & 0b0000_0100 != 0 {
flags |= PTEFlags::X;
}
flags |= PTEFlags::U;
flags |= PTEFlags::V;

let va_end: VirtAddr = (start + len).into();
let va_end: VirtPageNum = va_end.ceil();

// println!(
// "start = {:x} && va_star = {} && va_end = {}",
// start, va_start.0, va_end.0
// );

while va_start != va_end {
// println!("map va_start = {}", va_start.0);
if let Some(pte) = self.page_table.translate(va_start) {
if pte.is_valid() {
// println!("mmap found exit va_start {}", va_start.0);
return -1;
}
}
if let Some(ppn) = frame_alloc() {
self.page_table.map(va_start, ppn.ppn, flags);
self.map_tree.insert(va_start, ppn);
} else {
return -1;
}
va_start.step();
}
0
}

unmmap则更简单, 从map_tree中删除即可, 这会自动触发drop trait:

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
// os/src/mm/memory_set.rs
/// unmap
pub fn unmmap(&mut self, start: usize, len: usize) -> isize {
let va_start: VirtAddr = start.into();
if !va_start.aligned() {
debug!("unmap fail don't aligned");
return -1;
}
let mut va_start: VirtPageNum = va_start.into();

let va_end: VirtAddr = (start + len).into();
let va_end: VirtPageNum = va_end.ceil();

while va_start != va_end {
// println!("unmap va_start = {}", va_start.0);
if let Some(item) = self.page_table.translate(va_start) {
if !item.is_valid() {
debug!("unmap on no map vpn");
return -1;
}
} else {
return -1;
}
self.page_table.unmap(va_start);
self.map_tree.remove(&va_start);
va_start.step();
}
0
}

2 简答作业

2.1 请列举 SV39 页表页表项的组成,描述其中的标志位有何作用?

ch4-translate

如上图所示, 每个页表项是PPN + Flags组成, PPN指向下一级页表的物理页地址, Flags为各种标志位:

  • V: 页表项是否有效
  • R/W/X: 控制对应页是否具有读、写、执行权限。这些权限位为操作系统实现内存保护提供了机制。
  • U: 控制页的访问级别,标明用户空间是否可以访问。
  • G: 全局位用于性能优化,它允许某些页表项在多个上下文之间共享,减少TLB刷新。
  • A/D: 访问和脏位用于页面替换策略,帮助操作系统确定哪些页面是活跃的,哪些页面可以被换出内存。
  • RSW: 保留位, 留给自定义的实现, 常用于实现COW写时复制,用于实现fork系统调用时的内存效率优化。

2.2 缺页

缺页指的是进程访问页面时页面不在页表中或在页表中无效的现象,此时 MMU 将会返回一个中断, 告知 os 进程内存访问出了问题。os 选择填补页表并重新执行异常指令或者杀死进程

2.2.1 请问哪些异常可能是缺页导致的?

  • 访问了一个没有映射的虚拟地址。
  • 访问了映射了虚拟地址但尚未加载到物理内存的页面。
  • 访问违反了权限的页面,如对只读页面进行写操作。

2.2.2 发生缺页时,描述相关重要寄存器的值,上次实验描述过的可以简略。

  • PC (Program Counter): 保存发生异常指令的地址。
  • stval/utval (Store/Trap Value Register): 保存造成异常的虚拟地址。
  • satp (Supervisor Address Translation and Protection Register): 包含页表的基址和一些控制位,用于地址转换。

2.2.3 Lazy

缺页有两个常见的原因,其一是 Lazy 策略,也就是直到内存页面被访问才实际进行页表操作。 比如,一个程序被执行时,进程的代码段理论上需要从磁盘加载到内存。但是 os 并不会马上这样做, 而是会保存 .text 段在磁盘的位置信息,在这些代码第一次被执行时才完成从磁盘的加载操作。
这样做有哪些好处?

  • 减少了初始化时的加载时间,加快了程序启动速度。
  • 仅加载实际使用的内存页面,节省了物理内存资源。
  • 避免了不必要的I/O操作,提高了系统整体性能。

2.2.4 估算页表内存消耗

其实,我们的 mmap 也可以采取 Lazy 策略,比如:一个用户进程先后申请了 10G 的内存空间, 然后用了其中 1M 就直接退出了。按照现在的做法,我们显然亏大了,进行了很多没有意义的页表操作。
处理 10G 连续的内存页面,对应的 SV39 页表大致占用多少内存 (估算数量级即可)?

  1. 每个页表项占用8字节,对于10G内存:
  2. 一级页表(PTEs): 10G / 4K(每页大小)= 2560K 页表项 = 2560K * 8 字节。
  3. 二级页表(PTEs): 2560K / 512(每级页表条目数)= 5K 页表项 = 5K * 8 字节。
  4. 三级页表(PTEs): 5K / 512 = 10 页表项 = 10 * 8 字节。
  5. 总计约为2560K * 8字节 加上较小的二级和三级页表大小,数量级在几 M 字节。

2.2.5 实现Lazy

请简单思考如何才能实现 Lazy 策略,缺页时又如何处理?描述合理即可,不需要考虑实现。

  • 在进程开始时,只为虚拟地址空间分配页表结构,不分配实际的物理页面。
  • 页面第一次被访问时,通过缺页中断处理函数分配物理页面,并更新页表项。
  • 缺页处理时,查找磁盘上相应的数据(如果之前已经映射),然后加载到内存中。

2.2.6 Swap

缺页的另一个常见原因是 swap 策略,也就是内存页面可能被换到磁盘上了,导致对应页面失效。
此时页面失效如何表现在页表项(PTE)上?

  • 有效位(V): 如果页面被换出到磁盘,页表项的有效位会被清除。
  • 使用RSW位来存储额外的状态信息,区分真正的无效的页表项和被换到磁盘上的页表项

2.3 双页表与单页表

为了防范侧信道攻击,我们的 os 使用了双页表。但是传统的设计一直是单页表的,也就是说, 用户线程和对应的内核线程共用同一张页表,只不过内核对应的地址只允许在内核态访问。 (备注:这里的单/双的说法仅为自创的通俗说法,并无这个名词概念,详情见 KPTI )

  1. 在单页表情况下,如何更换页表?
    单页表不需要切换, 因为页表已经包含了用户空间和内核空间的映射。但是,当切换进程时,需要更换页表以加载新进程的地址空间。通过更新 satp寄存器来完成,这个寄存器包含了当前活动页表的物理地址。

  2. 单页表情况下,如何控制用户态无法访问内核页面?
    内核页表的页表项不设置U位即可

  3. 单页表有何优势?
    陷入内核时不需要切换页表, 同时仍然可以用硬件完成地址翻译, 效率更高

  4. 双页表实现下,何时需要更换页表?假设你写一个单页表操作系统,你会选择何时更换页表(回答合理即可)?

    1. 双页表实现下,上下文切换和陷入内核都需要切换页表
    2. 单页表实现下, 切换不同的进程时才需要切换页表