0%

本阶段忙于处理于学校的事情没有完全跟课。主要学习了Hypervisor虚拟化的内容,这也是我最感兴趣的部分。
通过实验学习,我了解到Hypervisor是如何模拟出多个虚拟机环境,让它们各自拥有独立的操作系统和硬件资,期望在后续的学习中对虚拟化技术有更深入的了解。

Arceos 总结

核心学习了arceos作为组件化设计的理念,抽象微内核、宏内核和VVM之间的共同点。

基于这些比如调度task,内存空间mmaddr,文件系统fs的底层组件,可以方便的完成需求。

lab1 内存分配挑战

通过lab1内存分配器的实现,对内存加深了理解,就是个大byte[],物理地址就是index。

lab1核心实现就是借用操作系统的分配思想,内存空间分堆区和栈区,堆区存放不需要回收的数据(一直增长,没有内存碎片),栈区放需要回收的,包括vec中间扩容的。这部分处理掉可以达到373,参考实现:

https://github.com/martin1847/oscamp/blob/lab1/arceos/labs/lab_allocator/src/lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
# 每轮最小生存对象
>>> a = 262144 + 65536 + 16384 + 4096 + 1024 + 256 + 64
# 每轮额外需要的临时空间
>>> tmp = sum([524288, 131072, 32768, 8192, 2048, 512, 128, 32])
699040
# [PA:0x8026f000, PA:0x88000000) free memory (READ | WRITE | FREE)
>>> total = 0x88000000 - 0x8026f000
131665920
>>> (total - tmp )/a
374.7221204907526
# 考虑每轮增加还有一部分额外内存,7 * sum[1..N]
>>> (total - tmp - 7 * sum(range(1,373)) )/a
373.33259132942686
Read more »

2024 秋冬季开源操作系统训练营 项目基础阶段


进度情况:

由于忙于区域赛和大创申请书,三阶段的时间并不充足,作业仅仅做到hypervisor前的部分,仅阅读了部分源码。

Unikernel基础与框架

题1

只需要将

println!("[WithColor]: !");

改为
println!("\x1b[33m[WithColor]: Hello, Arceos!\x1b[0m");

可以打出黄色字体。

题2

要求实现HashMap,并且提供随机数,我还没办法理解随机数和HashMap的关系,所以写了一个简单的HashMap

1
2
3
4
5
6
7
8
9
10
11
struct KeyValue<K, V> {
key: K,
value: V,
}
pub struct HashMap<K, V> {
buckets: Vec<Vec<Option<KeyValue<K, V>>>>,
}
fn hash(&self, key: &K) -> usize {
let key_ptr = key as *const _ as usize;
key_ptr % self.buckets.len()
}

Unikernel地址空间与分页、多任务支持(协作式)

题目要求:

只能修改modules/bump_allocator组件的实现,支持bump内存分配算法。不能改其它部分。

Bumpallocator 结构体

1
2
3
4
5
6
7
pub struct EarlyAllocator<const PAGE_SIZE: usize> {
heap_start: usize,
heap_end: usize,
next: usize,
allocations: usize,
page_pos: usize,
}

Base allocator需要的init 与 add_memory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

impl<const PAGE_SIZE: usize> BaseAllocator for EarlyAllocator<PAGE_SIZE> {
fn add_memory(&mut self, start: usize, size: usize) -> AllocResult {

self.heap_end = start + size;
Ok(())
}

fn init(&mut self, start: usize, size: usize) {
self.heap_start = start;
self.heap_end = size + self.heap_start;
self.next = self.heap_start;
}

}

PageAllocator因没有dealloc所以未实现

1
2
3
4
5
6
7
8
fn alloc_pages(&mut self, num_pages: usize, align_pow2: usize) -> AllocResult<usize> {
let align_mask = (1 << align_pow2) - 1;
let required_size = num_pages * PAGE_SIZE;
let aligned_pos = (self.page_pos + align_mask) & !align_mask;
let allocated_pages = aligned_pos;
self.page_pos = aligned_pos + required_size;
Ok(allocated_pages)
}

ByteAllocator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
impl<const PAGE_SIZE:usize> ByteAllocator for EarlyAllocator<PAGE_SIZE>{
fn alloc(&mut self, layout: Layout) -> AllocResult<NonNull<u8>> {
let alloc_start = self.next;
self.next = alloc_start + layout.size();
self.allocations += 1;
Ok(unsafe { NonNull::new_unchecked(alloc_start as *mut u8) })

}
fn dealloc(&mut self, pos: NonNull<u8>, layout: Layout) {
self.allocations -= 1;
if self.allocations == 0 {
self.next = self.heap_start;
}
}

从Unikernel到宏内核

题目要求:

需要补全缺页加载代码。

观察m1.0.0 task中代码,发现用 current().task_ext()可以得到TaskExt,然后TaskExt中包含一个AddSpace 结构体,该结构体实现了处理缺页加载的方法。于是按照该思路需要补充的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use axhal::{mem::VirtAddr,paging::MappingFlags};
use axhal::trap::{register_trap_handler,PAGE_FAULT};
#[register_trap_handler(PAGE_FAULT)]
fn handle_page_fault(vaddr: VirtAddr, access_flags: MappingFlags, is_user: bool) -> bool {
if is_user {
if !axtask::current()
.task_ext()
.aspace
.lock()
.handle_page_fault(vaddr, access_flags)
{
ax_println!("handle_page_fault failed, vaddr={:#x}", vaddr);
axtask::exit(-1);
} else {
true
}

}
else{
false
}

}

感想:与 Rcore 相比,似乎 ArceOS显得更加复杂,因其为了实现模块之间的兼容性,采用了多层的封装、抽象和隔离机制。然而,从我的编程体验来看,从某个特定模块的视角来看,阅读 ArceOS 的代码反而比 Rcore 更加清晰。降低了局部代码阅读难度,是值得学习的编码习惯。同时,ArceOS 的课程内容十分丰富,极大地拓宽了我的操作系统视野。

3阶段由于研究生学院和导师这边事情太多,勉勉强强完成了那个堆分配器的设计,其他很多东西都没来得及看了。

但是这个堆分配器也不是没有东西说的,正常分配不走歪门邪道的话是能够通过325个测例。

简单介绍一下这个堆分配器。这是一个参考了 musl libc 堆分配器设计思路打造的一个更轻量级的堆分配器。既然更轻量,就意味着内存空间的浪费会比 musl libc 多一些。

musl libc 与 glibc 的分配器实现思路完全不同,这个因为我是 CTF pwn 手,对这两个分配器的源代码都是有过深入研究的。musl libc 是采用大页分配后小块拆分的方式完成分配,其内部有一个数组,保存不同 chunk 的大小,在实际的内存空间中,chunk 的大小只可能是这些数组中保存的值的其中之一。对于这些不同大小的 chunk,musl libc 将相同大小的 chunk 放在一个内存页中进行分配,且一次可能会分配多个内存页,使得这些内存页能够充满 chunk。这种设计相较于 glibc 在实现上更加简单,这从 musl libc 对于堆分配器的代码实现行数就可以看得出来。因为 musl libc 强调小块内存的分配,因此每个小块的控制结构被设计地尽可能小,只要能够索引到控制结构就行。这使得一个 musl libc 中的 chunk 只需要 4 个字节的控制结构,最小的 chunk 为 16 字节,相比较下 glibc 中至少需要 32 个字节(最小的 chunk 就是 32 字节大小)。

在本人的实现中,对于数组的定义略显粗糙,使得给定的测例可能会导致较多的空间浪费,这个空间浪费最多是发生在整页的大块内存直接分配上。因为为了方便起见,大于一页的单次分配都是使用 buddy system 完成分配的,如果一次需要 0x1001 的空间,实际就会分配出去 0x2000 的空间,会造成很大的浪费,偏偏测例里面还有很多这样的内存分配,就使得最终的结果没有那么好看。

另外需要指明,本人实现的堆分配器存在天然漏洞,根本原因是没有记录 chunk 的首地址。如果一次性分配了连续的 2 页内存,再尝试从中间进行释放,是可以完成释放的。主要是因为提供的 free 接口中给出了释放的 chunk 大小,如果有办法能够欺骗外部结构,让其能够从中间释放,那么堆分配器实际上会出现问题,可能会导致 chunk 的重叠。

组件化操作系统

三阶段通过使用三周的时间,初步认识了组件化操作系统arceos,和之前的宏内核rcore,存在很大区别,组件化操作系统,通过将操作系统中通用模块进行封装、抽象,将操作系统中的各个模块分离开来,针对应用场景,对不同模块进行组合,完成功能。

不同的内核实现方式,势必都有其优势和劣势,如何针对应用场景进行扬长避短,并且快速搭建不同实现方式的内核,组件化操作系统给出了答案。

Unikernel

其思想更像是之前学习嵌入式中学习到的抽象层的概念,通过组件将通用的底层的功能进行封装,通过api接口与上层应用层进行连接,内核和应用共用地址空间,不需要特权转移,极大提高了任务运行效率。

宏内核

在组件化操作系统中,添加宏内核插件,此时通过系统调用为上层应用提供支持,在插件内部仍是通过调用组件的接口,完成系统调用功能。

宏内核插件也需要提供多任务调度、多地址空间等功能。

Hypervisor

这部分还没有进行学习,更多学习宏内核starry内容。

感悟

通过这部分的学习,初步认识了组件化操作系统,为阶段四的实习部分打下了基础,学习异构的思想,在四阶段中,也可以使用这种思想,为组件化操作系统添加更多的组件,支持更多的功能。

Areos引导机制

  • 首先进入axhal中平台架构的boot程序的_start函数,参数为OpenSBI传入的两个参数,一个是hartid用于识别CPU,一个是dtb_ptr传入DTB的指针。
  • 然后会建立栈用于函数调用,准备页表启动MMU分页机制,后即可进入Rust。

然后进入axruntime初始化应用运行时的环境,即根据feature条件编译各组件框架

启用页表机制

SBI配置FW_TEXT_START,BIOS负责把SBI加载到内存的起始位置

0x8000 0000存放SBI,SBI会跳转到以0x8020 0000起始的内核代码,存放.bss、.data、.rodata、.text段。

axhal中的linker_riscv64-qemu-virt.lds指导rust的链接器来实现段布局

一阶段:即上面提到_start函数中的初始化MMU,将物理地址空间恒等映射,再添偏移量。

二阶段:重建映射,一阶段的映射并没有添加权限进行管理。若启用”paging”的feature,首先会调用axmm中的init_memory_management(),进行创建内核空间(new_kernel_aspace()),将根页表地址写入stap寄存器(axhal::paging::set_kernel_page_table_root)。

多任务

启用”multitask”的feature,执行axtask::init_scheduler(),会进行就绪队列的初始化,以及系统timers的初始化。

  • 就绪队列的初始化:初始化系统任务idle、main、gc
    • main为主任务,其他任务均为其子任务
    • idle,其他任务都阻塞时会执行它
    • gc,除main之外的任务退出后,将由gc负责回收清理

调度算法

CFS

“init_vruntime”在os启动调度的时候决定调度顺序,”nice”则是优先级,优先级越大”vruntime”增长越慢,从而达到高优先。

当时间片结束后,当前任务将被为设为可抢占,当前任务释放控制权,加入就绪队列。并且如果外部条件满足抢占,则会将就绪队列中的队首弹出并切换到该任务

宏内核

m_1_0启动

  • 首先为应用创建独立的用户地址空间。

axmm::new_user_aspace

  • 将应用程序代码加载到地址空间。

load_user_app

  • 初始化用户栈

init_user_stack

  • spawn创建用户任务,以用户地址空间及应用入口、用户栈为参数

task::spawn_user_task

  • 当前任务释放CPU,使用户任务运行

user_task.join

用户空间

页表管理内核地址空间和用户地址空间

内核地址空间只有高端(高地址)存放内核代码数据

用户地址空间高端存放内核,但这部分的页表项设为用户不可见,只有陷入内核态之后才能访问。低端地址存放应用

每个用户地址空间高端的内核均相同,只有存放应用的区域不同

地址空间后端Backend:

Linear:目标物理地址空间已经存在,直接建立映射关系,物理页帧必须连续

Alloc:仅建立空映射,当真正被访问时将会触发缺页异常,然后在缺页响应函数内部完成物理页帧的申请和补齐映射,也就是Lazy方式。物理页帧通常情况下不连续

ELF格式应用加载

代码段加载无偏移。

数据段加载到虚拟地址空间分为.data、.bss。但.bss在ELF文件中为节省空间紧凑存储不做存放,仅做标记位置和长度。内核直接预留空间并清零

缺页异常

当发生缺页异常时,由aspace的handle_page_fault来完成对物理页帧的申请与映射

首先检查发生页面错误的虚拟地址 vaddr 是否在当前虚拟地址范围 va_range 内。

然后找到vaddr的区域area,进入area的后端,缺页异常通常由后端Alloc的Lazy策略造成,故进入Alloc分支调用handle_page_fault_alloc对页表remap完成页表映射

Hypervisor

虚拟化基于RISCV64的S特权级的H扩展,Host将在HS特权级下进行VM_ENTRY以及VM_EXIT

S特权模式进行H扩展后,原有的s[xxx]寄存器组作用不变,将新增hs[xxx]和vs[xxx]

  • hs[xxx]寄存器组的作用:面向Guest进行路径控制
  • vs[xxx]寄存器组的作用:直接操纵Guset域中的VS,为其准备或设置状态

设置hstatus的SPV位(指示特权级模式的来源,1为VS,0为U)SPVP位(指示HS对V模式下地址空间是否由操作权限)

启动Guest之前,设置Guest的sstatus,设置初始特权级为Supervisor

设置sepc为OS启动入口地址VM_ENTRY,地址为0x8020 0000

run_guest切换:保存Host上下文,将Guest上下文(第一次切换为伪造的上下文)载入寄存器组

VM_EXIT返回Host:保存Guest上下文,将Host上下文载入寄存器组

Guest和Host地址空间

Hypervisor负责基于Host物理地址空间HPA面向Guest映射Guest物理地址空间GPA

Guest会认为GPA是实际的物理地址空间,它基于satp映射内部的GVA虚拟空间

启用RISC64指令集的G扩展:

  • 引入vsatp用于第一阶段的页表翻译,即将Guest的虚拟地址空间映射为Guest的物理地址空间
  • 引入hgatp用于第二阶段的页表翻译,即将Guest的物理地址空间映射到Host的物理地址空间

虚拟机物理地址空间布局

低端区域留给设备空间和DMA

0x8000 0000在Host中是存放SBI的,但是虚拟机没有M模式,是无法访问SBI,所以这部分进行保留

0x8020 0000存放内核

高于内核的地址用作物理内存区

Hypervisor主逻辑

  • 准备VM的资源:VM地址空间和单个vCPU
  • 切换到进入Guest的代码
  • 响应VM_EXIT各种原因的代码

实现流程:

  1. 创建地址空间 为目标虚拟机创建物理地址空间 axmm
  2. 建立区域布局 在地址空间中为Guest内核和设备建立区域 axmm
  3. 加载内核 把Guest内核Image加载到内核区域 axmm, axfs
  4. 初始化vCPU 把vCPU的启动入口设置为Guest内核入口,同时设置EPT页表地址

最后在Host和Guest环境循环切换,支持虚拟机持续运行

VCPU的准备:

  • 通过set_entry(VM_ENTRY)设置sepc来配置入口地址0x8020 0000
  • 通过set_ept_root向hgatp设置模式(SV39)和根页表的页帧

axruntime和axhal时钟中断处理

axhal::irq::register_handler 通过update_timer在中断向量表中注册对应的中断响应函数,更新时钟。然后axtask::on_timer_tick()触发定时任务

内容

第三阶段由于最近比较忙,我只做了unikernel部分的内容,其他部分后续会继续学习和补充。

support_hashmap: 支持HashMap类型

alt_alloc: 为内存分配器实现新的内存算法bump

shell: 在交互式shell的子命令集中增加对rename和mv的支持

总结和收获

这一部分的exercises不算难,但是通过学习,对unikernel的理解加深了很多。老师的ppt非常清晰,直观地感受到了组件化内核的特点。
通过这一阶段的学习,我更加理解了组件化内核的优势,如减少内核中的冗余部分,提高系统的灵活性和可维护性。与传统操作系统相比,Unikernel 的模块化设计有助于减少资源占用。

宏内核

到宏内核的特点就是:

  1. syscall
  2. app和kernel分离
    1. 特权级分离
    2. 独立空间
    3. 可以加载应用

用户空间降级方式:伪造返回现场到新任务, sret
过程:

  1. 初始化用户栈
  2. 创建新任务
  3. 让出CPU

几个重要的点

1. 任务扩展属性

将调度属性和资源属性分离,从而复用调度组件


2. 系统调用

用的是linkme这个库
linkme 是一个 Rust 的库,主要用于在编译期将数据链接到程序的特定部分(如全局变量),从而实现类似插件或模块化的功能。它可以动态扩展程序的功能,而无需显式修改原始代码。以下是对 linkme 的详细介绍,包括其用法和原理。

核心功能

linkme 提供了一种机制,使多个 Rust 模块中的静态变量可以被聚合到一个全局列表中。常见的使用场景包括:

  1. 插件系统:收集并注册动态功能。
  2. 初始化系统:在程序启动时执行一系列初始化函数。
  3. 静态配置集合:在不同的模块中定义配置项,并将它们汇总到一个位置。
    使用方法
    1. 添加依赖
    Cargo.toml 中添加:
    1
    `[dependencies] linkme = "0.3"`
    2. 定义集合

使用 #[linkme::distributed_slice] 定义一个全局的切片,用于收集静态变量。例如:

1
2
3
use linkme::distributed_slice;  
#[distributed_slice]
pub static MY_SLICE: [fn()] = [..];`

这里,MY_SLICE 是一个切片,它的类型是 fn(),表示可以存放多个函数指针。

3. 添加元素

在其他模块中,使用 #[distributed_slice] 将元素插入到切片中:

1
2
3
4
5
6
7
use linkme::distributed_slice;

#[distributed_slice(MY_SLICE)]
static FIRST_FUNCTION: fn() = || println!("First function");

#[distributed_slice(MY_SLICE)]
static SECOND_FUNCTION: fn() = || println!("Second function");
4. 使用切片

在程序中,你可以遍历切片的所有元素,并执行对应逻辑:

1
2
3
4
5
fn main() {
for func in MY_SLICE {
func();
}
}

得到

1
2
First function
Second function
原理解析

linkme 的实现基于 Rust 的链接器特性和目标平台的支持。以下是其工作原理:

  1. 分布式切片的定义
    • #[distributed_slice] 宏定义了一个全局符号,分配了一段内存区域,专门用于存储相关的元素。
  2. 分段存储
    • 每次使用 #[distributed_slice] 添加元素时,linkme 会将这些元素放置到编译产物的特定段(section)中。
  3. 链接器聚合
    • 链接器在链接阶段会将所有模块中定义的元素合并到一个段中,使得这些元素在运行时可以统一访问。

课后题

  1. 注册 PAGE_FAULT
  2. 完善注册函数
    1. 获得当前的task
    2. 得到aspace
    3. 调用其handle_page_fault方法填充

第二节

内存管理方式:

其他的和rcore很像,最后会在backend中处理映射

  • alloc(支持lazy)
  • linear (连续映射)

lazy映射:先map出虚拟地址但是没有关联物理地址,在page fault后在处理函数中多次嵌套调用handle,最后在aspace中完成关联

课后题

mmap的实现和page fault的处理很像,

  1. 找到一个free的va(最开始这里卡了很久)
  2. 读取文件内容,填充到buffer
  3. map alloc出一个页
  4. 用buffer填充这个页

hypervisor

第一节

hypervisor和虚拟机的区别是:支撑其物理运行的体系结构和其虚拟运行的环境是否一样(同构). 所以hypervisor比虚拟机更加高效.

我的理解,hypervisor也是一种类似于OS软件,如果是U的指令可以直接执行,如果需要特权级就在hypervisor中捕获处理.
hypervisor的扩展指令也是为了加速这个过程

资源管理:

  1. vcpu(直接绑定到一个CPU)
  2. vmem(提供自己的页表转换)
  3. vdevice(virtual io/ 直接映射 / 模拟)
  4. vutilities(中断/总线..)

练习题

panic将系统shut,所以需要去掉panic改成(ax_println!),然后将sepc+4跳转到下一个指令,再设置一下a0,a1的值就可以了

第二节

主要学习了两段映射

  1. Guest缺页,guset os向hypervisor查找
  2. hypervisor也缺页,向实际物理机申请

第二的部分有两个模式

  1. 透传
  2. 模拟

透传:直接把宿主物理机(即qemu)的pflash透传给虚拟机。(快 捆绑设备)
模拟:模拟一个pflash,当读取的时候传递(慢 不依赖硬件)

切换

具体的汇编:

练习题

将pflash.img写入img的/sbin/下后,在 h_2_0/src/main.rc 中将其read出来,然后将第一页的内容填充到buf中,aspace.write进去就可

第三节课

实验课正在做

虚拟时钟

总的思路是:通过关键的寄存器hvip中的VSTIP(时钟中断)来向hypervisor响应虚拟机的设置时钟中断,然后当时钟中断来的时候退出vm,并重新设置时钟中断hvip,回到vm处理.

宏内核的支持

主要是flash这种设备的处理,这个在前一个的实验中已经解决了.

虚拟设备管理:通过mmio,注册device的地址,当发生page fault的时候判断一下,如果是在mem中则正常处理,如果是在device则去对应的设备处理.

unikernel

特点:

  • 内核组件化
  • 内核和应用处于同一内核级,共享同一个地址空间,既是应用又是内核

课后练习:

如果希望只有通过println宏打印出来的文字有颜色,就应该选择在println定义的地方进行修改,即在axstd中修改
在/arceos/ulib/axstd/src/macros.rs中找到println宏的定义,进行颜色的添加
以下是修改前的输出

以下是修改后的输出

如果希望包括启动信息在内的内容都以某个颜色打印,就需要修改更底层的位置,即修改axhal
找到了axhal中调用输入输出的地方,进行颜色的添加
修改后的输出

课后练习<支持HashMap类型>

hashbrown 是一个高性能的哈希集合和哈希映射库,提供了 Rust 标准库中 HashMapHashSet 的实现。实际上,Rust 标准库的哈希集合和哈希映射类型(如 std::collections::HashMapstd::collections::HashSet)在底层就依赖于 hashbrown

将hashbrown::HashMap引进就可以了
建立以下路径的文件
/arceos/ulib/axstd/src/collections/mod.rs
添加引用

1
pub use hashbrown::HashMap;

然后得到结果

课后练习<为shell增加文件操作命令>

底层已经提供了rename有关的接口,直接调用就实现了rename
关于mv,可以分两种情况,mv的是文件还是文件夹:
如果是文件,其实mv的本质就是rename,将文件夹的路径修改到文件名的前面
如果是文件夹,我认为可以递归文件夹下的所有文件和文件夹,进行rename

课后练习<bump内存分配算法>

根据给的图示完善结构体,

1
2
3
4
5
6
pub struct EarlyAllocator <const PAGE_SIZE: usize>{
    start: usize,
    end: usize,
    b_pos: usize,
    p_pos: usize,
}

alloc时,先对现有的b_pos向上取整对齐,再加上新分配的长度
对于页分配,就多考虑一个页面大小