0%

阶段一

在原版的rustlings基础上加入了一些针对训练营需要的unsafe相关的知识和构建过程中build.rs的应用。属于比较基础的内容,跟着评测机一道道做过去就可以了。

阶段二

这个阶段主要是通过基于rust编写操作系统内核rcore完成几个实验,借助实验理解一个具有进程/线程管理、内存管理、文件系统、进程间通信和提供了一定同步机制的内核是如何构成并运作起来的。

比较印象深刻的有这么几个知识点:

  • 链接脚本与全局符号的使用
  • Rust的汇编嵌入
  • 第四章中,在使用分离内核空间的时候。通过设计跳板页来解决切换页表后指令执行的问题。跳板页
  • 第六章了解了文件系统,了解了块设备的概念,对文件系统的各个抽象层有了一定的了解。
  • 第七、八章了解了操作系统是如何为应用提供同步原语的

跳板

由于rcore使用了分离内核空间的设计,所以在Trap的时候需要切换页表。但在切换页表之后,pc寄存器还是忠实的在其原来的位置自加到下一条指令,如果内核内存空间程序内存空间对这段代码的映射不是在同一个位置的话,则会表现出来程序跳转到了别的地方执行的效果。因此需要设计一个跳板页,在虚存中将其映射到所有内存空间的最高页,确保在切换之后,也能正确运行下一条指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# trap.S
...
.section .text.trampoline
.globl __alltraps
.globl __restore
.align 2
__alltraps:
csrrw sp, sscratch, sp
...

# linker.ld
...
stext = .;
.text : {
*(.text.entry)
. = ALIGN(4K);
strampoline = .;
*(.text.trampoline);
. = ALIGN(4K);
*(.text .text.*)
}
...

在上面的汇编可以看到,我们给trap.S分配到了.text.trampoline段,并在链接脚本中定义了一个strampline符号来标记他的位置,这样我们可以在Rust中找到这个跳板页,映射到我们期望的位置。

但将跳板也映射到别的地方带来了新的问题,原来__alltraps中最后跳转到trap_handler使用的是call trap_handler。我们可以通过obj-dump看看编译得到的指令。

1
2
3
4
5
6
7
8
9
10
# obj-dump -Dx ...

...
80201056: 73 90 02 18 csrw satp, t0
8020105a: 73 00 00 12 sfence.vma
8020105e: 97 80 00 00 auipc ra, 0x8
80201062: e7 80 e0 0b jalr 0xbe(ra) <trap_handler> # pc+0x80be
...
000000008020911c g F .text 00000000000003b2 trap_handler
...

可以看到,这里用的是pc相对寻址,也就是基于当前指令的偏移找到trap_handler所在的位置。但是现在__alltraps已经在虚拟内存中被我们映射到最高页去了,也就是说我们实际运行代码的时候是在下面这一段内存中。

1
2
3
4
5
6
7
8
9
# gdb
>>> x /20i $pc-10
0xfffffffffffff054: ld sp,280(sp)
0xfffffffffffff056: csrw satp,t0
0xfffffffffffff05a: sfence.vma
=> 0xfffffffffffff05e: jr t1

>>> p /x $t1
$9 = 0x8020911c

很明显如果这里跳转到$pc+offset$的话,并不是跳到位于正常代码段的trap_handler。所以我们要将这里换成寄存器跳转,将trap_handler的地址放到寄存器t1中,这样才能顺利地调用到trap_handler

也就是指导书中所说的

跳转指令实际被执行时的虚拟地址和在编译器/汇编器/链接器进行后端代码生成和链接形成最终机器码时设置此指令的地址是不同的。

阶段三

这个阶段正式接触到组件化操作系统arceos

在调用路径上任意一个地方加入颜色代码就可以了,本身并不复杂,主要是了解arceos的结构。

support_hashmap

考虑实现一个hashmap比较麻烦,直接引入hashbrown,将里面HashMap包到collections里面也可以通过。

但是hashbrown默认依赖的hashfold库在no_std下所提供的RandomState是基于内存布局的,而非每次都随机
可能会带来一些安全性问题

alt_alloc

实验要求实现一个bump alloctor,是一个比较简单的分配器,在给定的接口下实现就可以了

ramfs_rename

要求在给定的文件系统中实现rename的功能。看了测例中的注释仅要求在同级下重命名,不涉及移动。

搞清楚了VfsOpsVfsNodeOps两个trait之后,在路径上把目录项的名字改掉就好了。

sys_map

要求实现系统调用mmap

利用task_extaspace提供的接口就可以完成。通过find_free_area找到空闲的区域并通过map_alloc分配,然后将给定fd的数据读进来就可以通过了。需要注意一些接口有检查传入参数是否有对齐。

simple_hv

按照提示将a0``a1寄存器设置好,并将pc寄存器偏移以跳过当前指令即可。

总结

阶段三的任务总体来说比阶段二的时候来得要更简单,感觉主要还是了解arceos的架构以及UnikernelMonolithic KernelHypervisor的不同。并体会在不同的内核需求中,arceos是如何将不同的组件组合起来以达成需求的。

一阶段

用rust其实也有快5年了所以一阶段不是什么特别难的事,因为23年已经做过一次,这次增加了一些数据结构的实现,其实不是特别难,整体数据结构实现对于之前刷过leetcode的人都会比较熟,所以轻松就过了。

二阶段

其实23年最早刷过一遍所以这个比较简单,沿用23年的一些总结,这次lab1和23年的lab1有一些不同,不过核心是不变的

  1. lab1

    其实是一个很简单的lab,系统调用的次数统计
    问答题是很好的问题,也帮助我回忆和加深了risc-v的寄存器的作用,包括trap的流程,这个很重要,直接以代码展现出来,没学rcore的时候平时听到系统调用,其实是很抽象的,并不知道系统调用是怎么从用户态切换到内核态的,而rcore非常精彩的给我解答了这个问题,并且以代码展现,不再抽象。只能说感谢开源!

  2. lab2

    mmap 和 munmap 匿名映射,对我来说其实也不难,不过反而是问答题让我再次加深了SV39的结构,页表,页表项等等这些其实理解很抽象,包括用户态是怎么用到MMU的,MMU和操作系统存储的页表这些是怎么结合的,在这一张再结合linux的一些代码就理解了。其实是riscv 使用 SATP 寄存器来保存 MMU 映射表的根地址

  3. lab3

    spawn和stride 调度算法,这个其实也不算特别复杂,在给予fork和exec代码中只需要理解 spawn和他们的区别,就很容易写出来,而stride调度算法用一个小顶堆实现即可。因为之前看了linux的task_struct的实现,所以比较轻松就能理解。

  4. lab4

    这个要求实现linkatunlinkat 这个加深了我对硬连接的理解,并且文件系统的这章让我对linux的vfs也更加理解。整体来说明显会感受到磁盘读取和内存有异曲同工之妙。

  5. lab5

    死锁检测,这个就非常考察细心了,主要就是资源的分配、分出、释放,需要格外注意,否则都无法通过,顺带这里也有一个坑,就是检测用了sleep,sleep用的是get_time,所以要实现这个api不然程序就会卡在那

三阶段

这次是新增的一个阶段,主要是为了让大家先熟悉arceos,在熟悉rcore以后其实再看arceos是比较轻松的,组件化操作系统的思想是一个很好的思想,同时也比较考验抽象能力如果做到高性能高抽象的同时又可以随意的扩展操作系统的个个组件,使用一个create引入开箱即用,个人认为是一个未来需要的方向。随着整体社会需求的发展大家对底层性能的要求越来越苛刻定制化需求也越来越多,对于操作系统也是百花齐放,同时在写一个新的操作系统的时候也总是需要重复造轮子,这个工作量其实也不算小,所以个人认为组件化操作系统在当前是一个很好的想法,高质量的组件化操作系统可以帮助个人和初创企业降低开发操作系统的难度还可以获得定制操作系统的优势来满足一些特殊的场景需要。

  1. print_color
    这个其实很简单,因为不想修改println的宏所以这里新增了一个print_color的宏来实现对颜色的打印,并再用log来包装print_color宏实现不同等级打印不同颜色的日志

    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
    #[macro_export]
    macro_rules! print_color {
    ($color:expr, $($arg:tt)*) => {{
    use axstd::io::Write;

    let mut out = $crate::io::stdout().lock();
    let _ = write!(out, "\x1B[{}m", $color);
    let _ = write!(out, $($arg)*);
    let _ = write!(out, "\x1B[0m");
    }};
    }
    pub enum LogLevel {
    Error,
    Warn,
    Info,
    Debug,
    }

    #[macro_export]
    macro_rules! log {
    (error, $($arg:tt)*) => {
    $crate::print_color!("31", concat!("[error] ", $($arg)*, "\n"));
    };
    (warn, $($arg:tt)*) => {
    $crate::print_color!("33", concat!("[warn] ", $($arg)*, "\n"));
    };
    (info, $($arg:tt)*) => {
    $crate::print_color!("32", concat!("[info] ", $($arg)*, "\n"));
    };
    (debug, $($arg:tt)*) => {
    $crate::print_color!("34", concat!("[debug] ", $($arg)*, "\n"));
    };
    }
  1. hashmap
    这个其实可以参考rust的std的rust实现,然后改一下就可以了,当然也可以从0自己实现一个最后别忘了这样才能使用std::map

    1
    2
    3
    4
    5
    6
    7
    mod map;

    #[cfg(feature = "alloc")]
    pub mod collections {
    pub use crate::map::HashMap;
    pub use alloc::collections::*;
    }
  1. bump_alloc
    这个主要需要了解什么是bump算法,在实现ByteAllocator和PageAllocator的时候需要注意b_pos和p_pos的验证,还有要注意对齐,还要理解一下align_pow2

    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
    fn alloc(&mut self, layout: Layout) -> AllocResult<NonNull<u8>> {
    let align = layout.align();
    let size = layout.size();
    let aligned = (self.b_pos + align - 1) & !(align - 1);
    let new_b_pos = aligned + size;

    if new_b_pos > self.p_pos {
    return Err(AllocError::NoMemory);
    }

    self.b_pos = new_b_pos;
    self.byte_alloc_count += 1;
    self.byte_alloc_total += size;
    Ok(NonNull::new(aligned as *mut u8).unwrap())
    }

    fn alloc(&mut self, layout: Layout) -> AllocResult<NonNull<u8>> {
    let align = layout.align();
    let size = layout.size();
    let aligned = (self.b_pos + align - 1) & !(align - 1);
    let new_b_pos = aligned + size;

    if new_b_pos > self.p_pos {
    return Err(AllocError::NoMemory);
    }

    self.b_pos = new_b_pos;
    self.byte_alloc_count += 1;
    self.byte_alloc_total += size;
    Ok(NonNull::new(aligned as *mut u8).unwrap())
    }
  1. rename
    这个需要修改一下axfs_ramfs组件大致思路是 获取当前节点(即当前目录)-> 查找要重命名的原始节点 old_node->拆解 new 路径,获得新文件名新父目录路径->获取根节点,并从中查找新父目录->从原目录中移除旧路径->将 old_node 插入新父目录,使用新文件名

  2. mmap file
    这个修改较多,在Backend新增了一个FileBacked

    1
    2
    3
    4
    5
    6
    /// File-backed mapping backend (lazy load).
    FileBacked {
    reader: ::alloc::sync::Arc<dyn crate::MmapReadFn>,
    file_offset: usize,
    area_start: VirtAddr,
    },

    在page_fault的时候进行实际内存申请

    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
    Self::FileBacked {
    reader,
    file_offset,
    area_start,
    } => {
    let va = vaddr.align_down(PAGE_SIZE_4K);
    let offset = file_offset + (va.as_usize() - area_start.as_usize());

    let vaddr = match global_allocator().alloc_pages(1, PAGE_SIZE_4K) {
    Ok(vaddr) => vaddr,
    Err(_) => return false,
    };

    let paddr = virt_to_phys(VirtAddr::from(vaddr));
    let buf = unsafe {
    core::slice::from_raw_parts_mut(axhal::mem::phys_to_virt(paddr).as_mut_ptr(), PAGE_SIZE_4K)
    };
    if !(reader)(offset, buf) {
    return false;
    }

    page_table
    .map_region(
    va,
    |_| paddr,
    PAGE_SIZE_4K,
    orig_flags,
    false,
    false,
    )
    .map(|tlb| tlb.ignore())
    .is_ok()
    }

    mmap

    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
    fn sys_mmap(
    addr: *mut usize,
    length: usize,
    prot: i32,
    flags: i32,
    fd: i32,
    offset: isize,
    ) -> isize {
    let binding = current();

    let mut aspace = binding.task_ext().aspace.lock();

    let vaddr = match aspace.find_free_area(
    VirtAddr::from(addr as usize),
    length,
    VirtAddrRange::from_start_size(aspace.base(), aspace.size()),
    ) {
    Some(base) => base,
    None => return -1,
    };

    let prot_flags = MmapProt::from_bits_truncate(prot);
    let mut map_flags = MappingFlags::USER;

    if prot_flags.contains(MmapProt::PROT_READ) {
    map_flags |= MappingFlags::READ;
    }
    if prot_flags.contains(MmapProt::PROT_WRITE) {
    map_flags |= MappingFlags::WRITE;
    }
    if prot_flags.contains(MmapProt::PROT_EXEC) {
    map_flags |= MappingFlags::EXECUTE;
    }

    let aligned_len = (length + PAGE_SIZE_4K - 1) & !(PAGE_SIZE_4K - 1);
    let hint = if addr.is_null() {
    aspace.base()
    } else {
    VirtAddr::from(addr as usize)
    };

    let file_obj = match get_file_like(fd) {
    Ok(f) => f,
    Err(_) => return -1,
    };

    let reader = alloc::sync::Arc::new(move |_offset: usize, buf: &mut [u8]| {
    file_obj.read(buf).is_ok()
    });

    if let Err(e) = aspace.mmap_file(vaddr, aligned_len, map_flags, offset as usize, reader) {
    return -1;
    }

    vaddr.as_usize() as isize
    }
  3. simple_hv
    这个其实蛮有意思的可以体验到guest操作自己没有权限的指令时候的一个流程以及体验的到page_fault的流程,第一个需要自己在VM中模拟 CSR 访问

    1
    2
    3
    ctx.guest_regs.gprs.set_reg(A1, 0x1234);
    ctx.guest_regs.sepc += 4;
    return false;

    第二个可以直接写成成对应的值

    1
    2
    3
    ctx.guest_regs.gprs.set_reg(A0, 0x6688);
    ctx.guest_regs.sepc += 4;
    return false;

这个是对Hypervisor很好的一个体验也有了一个初步的认识,包括整个项目中的实验设计是非常好的,每一个新的功能都有一个简单的实验来上手体验,而且正是因为有了arceos也免去了很多最开始操作系统要处理的事情,直接进入体验Hypervisor代码量非常少,实验在risc-v指令集下,整个Hypervisor的体验很丝滑,代码结构很好,可以立马就对VM_ENTRY和VM_EXIT这个有点抽象的概念进行了具象

总结

新增的三阶段arceos是非常棒的,整体实验设计也很不错,在有了rcore的基础以后再看arceos是不困难的,一步一步的迈向抽象度更高的操作系统,大家在arceos里已经做了非常多的事情了,使得我们可以如此简单的启动一个os,并且体验到最小化的一个Hypervisor以及宏内核,也再次加深了对操作系统的理解,以及发现软硬协同的重要性。

ArceOS Record

ArceOS 的设计可以说优雅而不失健壮性,利用rust优秀的包管理机制和crates的特性组件化地搭建OS,将复杂的OS设计解耦,各个模块功能清晰、层次鲜明,

tutorial出于教学的目的,在modules引入了dependence crates;而在主线arceos中,将解耦做到了极致,形成了清晰的Unikernel层次:dependence crates -> kernel modules -> api -> ulib -> app,下为上提供功能,上到下形成层次鲜明的抽象,这种抽象又为异构内核的实现提供支持,以宏内核为例,其既可以使用api提供的功能,又可以复用kernel modules支持更多的功能,这种自由的复用和组织可以为定制化操作系统提供极大的便利和支持,方便基于需求实现特定OS

Read more »

训练营学习记录

这篇文章用来记录我在2025春夏季开源操作系统训练营的学习过程,之所以会参加本次训练营,是因为我想进一步学习操作系统以及学习操作系统以及通过完成rcore包括通过完成组件化操作系统进行进一步磨练自己。

训练营二阶段关于rcore实验完成记录

在第二阶段的学习过程中,我收获颇丰,深入理解了 Rust 语言和操作系统的核心概念。在学习 Rust 语言时,我全面掌握了其独特的所有权、借用和生命周期规则,这些特性为 Rust 提供了强大的内存安全保障。而在操作系统方面,我不再停留在浅显的层面,而是深入探讨了内核架构,从系统启动到各个模块的交互过程有了清晰的认知。特别是在进程管理方面,我了解了进程的创建、销毁及状态转换的原理,并深入分析了不同调度算法对 CPU 资源分配的影响。

在内存管理方面,我深入研究了物理内存分配与虚拟内存映射的机制,了解了页表机制在其中扮演的关键角色,惊叹于内存管理的复杂性与精巧性。在我的个人项目中,我将 Rust 和操作系统的知识结合,参与了从设计、实现到调试的全过程,解决了许多技术难题,这一过程让我不断成长和提升。

这一阶段的学习为我打开了全新的视野,未来我将继续深入探索,将所学的知识更好地应用于实践。

训练营三阶段关于arceos实验以及挑战实验

print_with_color
通过使用 ASCII 字符,实现了简单的控制台颜色输出。

support_hashmap
为了快速实现功能,引入了一个现成的库来处理哈希映射。

alt_alloc
由于测试用例较为简单,实现难度较低。严格按照要求实现后,我对是否完全正确也并没有特别的把握。

shell
在原有的 Shell 实现中,已经有了 rename 功能。为了简化,我直接调用了现有库来处理 rename,同时利用文件创建、复制文件内容和删除原文件的方式,模拟了 mv 命令的功能。

sys_map
通过使用 find_free_area 来找到合适的内存区域并进行数据读取,尽管 find_free_area 找到的内存地址并不完全符合 man mmap 的描述,但依旧能够实现所需的功能。

page_fault
难度适中,相比于原先的实现,这部分内容更多是基于 rcore 的基础进行了延伸与补充。

simple_hv
通过修改 guest 的 sepc 寄存器值,并设置 a0、a1 的值,成功实现了一个基础的 Hypervisor 操作。

通过第三阶段的学习,我理解了组件化操作系统内核的设计理念。相较于2阶段的rcore,这种组件化内核更像是可以随意拼接的积木,可以极大程度的根据自己的需求适配或灵活的扩展内核。开始时,我学习Unikernel这种内核结构,并阅读了如axhal、axruntime、axalloc等关键部分的代码,初步掌握acreos的运行逻辑和代码架构;尝试将arceos扩展为宏内核,也让我进一步体验到组件化内核的奇妙;同时根据PPT初步了解了虚拟化的原理和技术。Arceos的模块化内核很好的结合rust模块的特性,也让我思考模块化内核和微内核是否能够结合起来呢,这或许也是一个扩展方向吧。我第四阶段打算做rust异步运行时,希望能做成一个比较完备的项目!

一、前言

在过去两周,我学习了Unikernel, Monolithic Kernel, Hypervisor三种内核架构。经过学习,我对组件化操作系统有了初步的认识和掌握。以下是我对这两周学习过程的总结。

二、学习内容

  1. Unikernel

学习了Unikernel的基础与框架,包括如何从汇编代码进入到rust代码再进入到内核,并通过axhal -> axruntime -> arceos_api -> axstd 实现控制台的打印输出。

接下来引入了动态内存分配组件,以支持Rust Collections类型。通过引入axalloc模块,实现对内存的管理,并学习了动态内存分配的相关算法。通过这部分的学习,让我理解了rCore中为什么到后面的章节就可以使用Vec等集合类型。

之后引入任务数据结构并构建了通用调度框架,实现了抢占式调度。并实现了文件系统的初始化和文件操作。

实践作业:

实现带颜色的打印输出,理清控制台的打印输出的调用链即可, 可以在不同层次的组件上修改。

手写HashMap,我使用拉链法实现哈希表,并通过引入axhal提供的随机数增强鲁棒性。

实现bump分配算法,根据代码框架,实现EarlyAllocator的初始化和分配函数。

实现rename,首先是需要追踪是如何使用axfs_ramfa的,通过调试,可以发现底层实现是在DirNode,并且源数据结构其实就是btreemap,具体操作并不复杂。

2.Monolithic Kernel

在unikernel的基础上,引入用户态、系统调用等即可完成到宏内核的跨越,这一部分的学习让我更深刻的理解了组件化的优势,扩展task属性实现宏内核的进程管理以及分离调度属性和资源属性的策略更是让我眼前一亮。

实践作业:

实现sys_mmap系统调用,先使用fd读取源文件的内容,分配所需的内存空间,再查找用户态的页表得到相应的物理地址,将源文件内容写入即可。

3.Hypervisor

引入RISC-V H扩展,使原来的S态增强为HS态,并加入了VS态和VU态,通过对特权寄存器的修改,即可跨越到Hypervisor。

主要学习了VM-EXIT,由于Guest不存在M态,所以超出当前特权态的处理能力时会经历 VU -> VS -> (H)S -> M 的过程,本部分的作业也是和 VM-EXIT相关的,通过修改 vmexit_handler 函数以完成作业的要求。

一阶段总结

二刷了,也很快写完了,感觉没有什么好说的()

二阶段总结

又复习了一遍 rcore,顺便对比了一下去年写的代码,感觉去年写的代码明显很差,经常出现一些莫名其妙的的数据结构和函数调用,
今年对很多地方进行了重新编写,感觉自己对 rcore 的理解又上了一层。

今年担任助教的过程中,也主动被动的学了一些之前没有怎么关注的事情,对操作系统的理解也上了一层

三阶段总结

说起来这个三阶段去年是不做实验要求的,今年做了要求,但是因为之前在 arceos 进行了一个月的重度开发加上去年都写过了,这次
很快就写完了。

在写实验的过程中也发现,之前在写内核的时候有很多对情况的遗漏,现在也对这些情况进行了填补。

第一阶段

第一阶段主要是看了 rust权威指南 和b站杨旭的rust编程语言入门, 以及参考rust的官方文档

第二阶段

总结

第二阶段主要是参考rCore-Tutorial-Book 第三版的文档,以及每个ch都详细通过gdb观察函数调用关系,最后整理出完整的函数调用步骤,方便更好的理解内核态和用户态的交互

ch1

通过在switch和syscall函数打断点,读取寄存器的数据,更好的理解了内核态到用户态的跳转步骤,以及上下文切换的具体步骤

ch2

通过正确的对齐操作,和虚拟地址与物理地址的转换,进行map,并进行正确的错误处理:包括overlap以及传参错误的处理。

ch3

通过source breakpoint.txt快速发现函数调用路径和调用关系。

1
2
3
4
5
6
7
8
9
10
11
break run_tasks
break src/task/processor.rs:62
break trap_handler
break __switch
break syscall::syscall
break __restore
break __switch
break trap_return
break src/task/processor.rs:99
break exit_current_and_run_next
break suspend_current_and_run_next

通过对照fork与exec对spawn进行实现。

ch4

通过使用UPSafeCell块与VEC新建了一个全局LINK_VEC,记录已经链接的变量的inode与链接计数。并修改Inode中的create函数,实现创建链接。最后实现fstat获取文件状态。

ch5

通过类银行家算法实现检测死锁。

第三阶段

1
$crate::io::__print_impl(format_args!("\x1b[36m{}\x1b[0m\n", format_args!($($arg)*)));

通过传入颜色文本格式进行println打印

support_hashmap

通过两种方法实现了hashmap

  1. 通过直接use hashbrown::HashMap as InnerMap;导入hashmap
  2. 使用use axhal::misc::random;和vec自己构建了一个简单的hashmap

alt_alloc

使用bytes_pospages_pos构建了简单的EarlyAllocator
通过正确的align_upcheck is_valid_range,进行alloc和dealloc。

ramfs_rename

通过在btreemap中获取值并insert到其children中,进行rename。

sys_map

通过#[register_trap_handler(PAGE_FAULT)]进行注册PAGE_FAULT的trap_handler。
通过find_free_area,寻找一块可以放置对齐后的uspace的区域。
实现正确的给权限操作后,进行map_alloc
再获取对应fd的file,读取值写入buf中,再写入刚map_alloc的空间。

simple_hv

进行A0和A1寄存器的正确设置,并正确调整sepc的偏移量。

一阶段

这个内容不难,看文档+坚持就行了

二阶段

这个内容上了些难度,主要是对内容的结构理解, 代码上问题不大

三阶段 6道题

解题思路

  1. 修改sbi::putchar的内容, 添加颜色信息.
1
2
3
4
5
6
7
8
9
10
11
12
13
/// Writes a byte to the console.
pub fn putchar(c: u8) {
// #[allow(deprecated)]
// for b in b"\x1b[31m" {
// sbi_rt::legacy::console_putchar(*b as usize);
// }
#[allow(deprecated)]
sbi_rt::legacy::console_putchar(c as usize);
// #[allow(deprecated)]
// for b in b"\x1b[31m" {
// sbi_rt::legacy::console_putchar(*b as usize);
// }
}
  1. 修改marcos的内容, 添加颜色信息.
1
2
3
4
5
6
7
8
/// Prints to the standard output, with a newline.
#[macro_export]
macro_rules! println {
() => { $crate::print!("\n") };
($($arg:tt)*) => {
$crate::io::__print_impl(format_args!("\x1b[31m{}\x1b[0m\n", format_args!($($arg)*)));
}
}
  1. 修改main.rs的内容,直接在输出结果上添加颜色信息 .
1
2
3
fn main() {
println!("\x1b[31m[WithColor]: Hello, Arceos!\x1b[0m");
}

hashmap

解题思路

  1. 修改std的内容,直接引入rust std hashbrown作为hashmap算法
  1. 给axstdhashmap.rs 手动实现hahsmap的内容
1
2
pub mod hashmap; 
pub use hashmap as collections;

难点 :

1. 涉及一些hashmap的原理思想. 图简单,我直接设置了比较大的bucket , 冲突相关的内容也没有多管

2. 需要掌握iter的内容,因为main.rs中有关于iter方法的调用

bump_alloc

题目思路

非常简单, 因为只有lib.rs需要修改

题目难点

  1. Byte 分配需要关注对齐 . 对齐公式 通过gpt分享得知 .具体的推导我仅仅手动算了一遍

  2. Page 分配需要理解num_pages 和 align_pow2的概念 .

    首先一个是分配的页数,这个不太困难; align_pow2,这个通过查询资料可知 是页面的对齐. 它跟Byte
    的对齐有什么区别呢? 毕竟有page_size这种天然的对齐标准. gpt提供了关于dma组建的对齐示例, 也
    就是os 可能需要对齐更大的页

  3. 对AllocResult 的理解 ,需要针对不同的情况返回不同的Err值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// The error type used for allocation.
#[derive(Debug)]
pub enum AllocError {
/// Invalid `size` or `align_pow2`. (e.g. unaligned)
InvalidParam,
/// Memory added by `add_memory` overlapped with existed memory.
MemoryOverlap,
/// No enough memory to allocate.
NoMemory,
/// Deallocate an unallocated memory region.
NotAllocated,
}

/// A [`Result`] type with [`AllocError`] as the error type.
pub type AllocResult<T = ()> = Result<T, AllocError>;

rename

题目难点

关于这道题目,我仅仅描述一下我自己的思考

  1. 首先要明白如何对文件系统(fs)进行抽象与支持。

    • 文件系统通常包含 Dir(目录)、File(文件)节点,以及一个统一对外的文件系统抽象框架。
  2. 了解了不同节点(node)之间的关系以及它们各自的职责之后,我们需要进一步确认“名称(name)”的管理部分。

    • 通过查看代码可以发现,名称相关的信息主要存储在 Dir 中,通常使用如 BTreeMap 或 HashMap 等结构来维护。
  3. 确定 rename 操作的实现思路:

    1. 查找并从原目录中移除 old 节点;

    2. 查看目标路径 new 的节点是否已存在;

      • 如果存在,先将其删除;
    3. 将 old 节点插入到新路径对应的目录中。

关于项目结构的理解

ArceOS 采用组件化的设计理念,通过 define_api_type!axfs_vfs::impl_vfs_dir_default! 封装了具体实现与默认行为。

可能需要改进的地方

当前的 rename 实现仅支持同一文件系统(或相同根节点)内的路径;当 old 和 new 不在同一根目录下时,操作会失败。

sys_mmap

题目难点

关于这道题目,我仅仅描述一下我自己的思考:

  1. 首先要明确 mmap 的核心目标是将文件的某一段内容直接映射到用户虚拟地址空间,实现对文件的“内存视图”。

    • 用户可以像访问内存一样访问文件内容,不需要中间 read/write 的过程;
    • 需要完成虚拟地址到物理页框的映射,并填充内容。
  2. mmap 涉及的几个关键抽象:

    • 用户地址空间(AddrSpace):表示一个进程所拥有的虚拟内存范围,负责查找空闲地址、执行实际映射等;
    • 页表管理(PageTable):负责建立虚拟地址到物理地址的实际映射;
    • 文件抽象(FileLike):通过 file descriptor 查找到内核抽象的文件,支持 read 方法填充页框。
  3. mmap 系统调用参数设计灵活但容易传错:

    • addr 是用户建议的映射地址,不一定被采用;
    • length 需页对齐;
    • prot 表示权限,如 PROT_READ
    • flags 控制共享/私有等策略;
    • fd 是文件描述符;
    • _offset 是映射文件的偏移,暂未处理。
  4. 着重理解mmap 的参数及设计思想

  • addr:建议映射地址。用户可以建议映射起始地址,但系统可能忽略这个值,选择更合理的地址(比如避开冲突、留出 guard page 等)。传 NULL 表示让内核自动选择。

  • length:映射的内存大小。必须是页大小的整数倍,否则需要向上对齐。映射区域大小必须足够容纳所需的文件内容。

  • prot:映射内存页的访问权限,如:

    • PROT_READ: 映射的页可读;

    • PROT_WRITE: 可写;

    • PROT_EXEC: 可执行;

    • PROT_NONE: 禁止访问。

  • flags:映射策略,如:

    • MAP_SHARED: 多个进程共享映射(写操作对原始文件可见);

    • MAP_PRIVATE: 拷贝写(写时拷贝,不影响原始文件);

    • MAP_FIXED : 强制映射到 addr 所指定的地址 ;

    • MAP_PRIVATE:私有映射(写时复制,写入不会影响原文件);

    • MAP_SHARED:共享映射(多个进程映射同一页,写入会同步影响原文件);

    • MAP_ANONYMOUS:匿名映射,不与任何文件关联,fd 被忽略,内容初始为零;

    • MAP_FIXED:强制映射到 addr 所指定的地址,可能覆盖已有映射,风险较高;

    • MAP_POPULATE:提前分配并加载所有页(非懒加载),避免首次访问缺页异常;

    • MAP_STACK:指定这是一个栈区域(部分系统支持,可能用于自动扩展);

  • fd:文件描述符。指向将被映射的文件。若使用 MAP_ANONYMOUS,则忽略该参数。

  • offset:映射起始位置在文件中的偏移。要求是页大小的倍数,表示从文件中的哪个位置开始映射。


关于项目结构的理解

ArceOS 使用组件化与模块边界清晰的方式封装 mmap 逻辑,其核心设计涉及:

  1. sys_mmap:系统调用入口,接收用户传参,执行空间分配、权限设定、数据写入等完整过程。

  2. AddrSpace 结构体(位于 axmm 模块):

    • 代表进程虚拟地址空间;
    • 提供 find_free_area 方法搜索空闲虚拟内存区域;
    • 提供 map_alloc 映射物理内存页;
    • 提供 write 将文件内容写入内存页。
  3. get_file_like:将用户传入的 fd 映射为 FileLike 类型,用于后续 read

  4. 参数处理:

    • 映射的起始地址使用 addr + 0x20_0000 作为搜索 hint 避免与低地址冲突;
    • 映射长度自动对齐为页大小;
    • 文件内容读取后通过 uspace.write() 写入虚拟地址空间。

可能需要改进的地方

目前仅仅针对测例进行编程, 一些mmap 应当实现的功能还未进行

simple_hv

hv 两种类型

  1. 直接运行在硬件上,如 KVM、Xen

  2. 运行在操作系统中,如 VirtualBox

题目解题思路

解决Trap的不同处理方式.

直接通过跳过指令, sepc + 4 的方式实现

三阶段挑战题

难点剖析

首先分析一下Rust的alloc 过程.

alloc是通过传入的参数layout进行处理的 , layout是通过申请大小进行生成的. 这里就涉及到了layout的两个重点参数: size , align . size大小就是核心的数值, 类似malloc的空间范围. align可以用来作为对齐的要求,也可以用来反推alloc数据的类型.

默认先进行alloc, 如果在alloc的过程中返回了NoMemory,会调用pageAllocator进行page上的alloc添加新的内存空间; 如果page的空间也被分配完了进程就会keilled.

观察main.rs

每次会分配15个Vec ,然后释放8个, 也就是说偶数项的Vec其实只是临时存在,尽可能分开作为临时alloc空间.

为图简洁,采用tlsfAllocator进行长期存活Vec的分配行为

解题思路

按对齐分类分配:

align == 8 → 使用 TlsfByteAllocator,适合长期存活数据。

其他对齐 → 自定义分配器,用 bump 分配方式管理。

自定义分配策略:

每 15 次分配中偶数位 → 从 end 向前分配(短期内存)。

其余 → 从 start 向后分配(长期内存)。

释放策略:

align == 8 → 交由 TLSF 回收。

其他只支持最近短期分配的内存释放。

目的:

模拟临时与持久内存的分离分配,提高模拟 allocator 的灵活性。

组件化操作系统设计与实现

oscamp 第三节阶段总结. 由于部分操作系统原理性质的内容在二阶段中已经学过了, 因此总结主要针对两部分:

  1. 新概念: ArceOS 在传统内核上的创新
  2. 老概念: 与 rCore 的实现不同的部分, 以及分析原因

为什么要组件化?

ArceOS 的优势区间在于快速针对特定领域构建出一个最合适的内核, 主要解决的痛点就是”从头开发一个操作系统太繁琐”, 而”现成的方案并不完全适用于应用场景”. 一些操作系统的可扩展性通过内核编译选项或者配置文件来实现, 但是这种方法无法在更深层次修改组装一个操作系统内核, 因此 ArceOS 采用了组件化的方案灵活组装某些功能.

ArceOS 的主要结构 (Unikernel)

  1. ArceOS 提供 axhal (Hardware Abstraction Layer) 封装了硬件相关的接口和 driver, 整理统一为 axhal 组件对外的接口
  2. 基于 axhal, 我们有 axruntime 作为运行时.
  3. 应用与内核处于同一特权级, 编译为一个 Image 运行, 在实际应用中, 非传统 PC 上的操作系统往往不需要支持多种多样的任务(包括这个实验到最后也没有并行的实现, 只有任务并发调度)
    • 应用于内核的交互在当前 Unikernel 阶段下是通过 axstd (对标 Rust 标准库), 而 axstd 会使用 arceos_api 提供的操作系统 API
    • 在未来宏内核拓展时则是通过实际的系统调用, 经过 Trap 在中断向量表中异常处理程序调用 arceos_posix_api 提供的操作系统 API, POSIX 标准的 API 是为了能够让 glibc / musl-libc 的 Linux 应用程序能够被兼容

ArceOS 的引导过程(Unikernel)

  1. 链接脚本指定的起始地址 _start, 这个过程里:
    1. 先建立栈, 以便可以调用函数
     2. 准备页表, 启用 MMU
     3. 切换地址空间后偏移一下栈指针
     4. 调用 `axhal` 中的 `rust_entry`
  2. axhal 组建的 rust_entry, 这个过程:
    • 清理 bss
    • 设置中断向量表 stvec 寄存器
    • 调用 axruntimerust_main
  3. axruntimerust_main, 这个过程:
    • 相当于内核正式启动, 打印 logo 和必要信息等
    • 重映射, 以及一些必要的初始化(如设备和中断初始化)
    • 调用应用程序中的 main() 函数

ArceOS 中一些特定功能的实现

组件组装编译(通过 Rust 的 feature 进行条件编译)

分页机制

还是熟悉的 SV39. 但是注意 Hypervisor 下拓展了两位, 不过我们暂时并没有管这两位.

内核启动早期恒等映射 SBI 和 Kernel, 然后再把这一段映射到高地址(0xffff_ffc0_0000_0000 以后), 目的是为未来宏内核拓展留下足够空间, 让 Unikernel 在高地址运行. 同时 pc, sp 也同样要增加偏移.

物理页帧分配与动态内存分配

ArceOS 中的虚拟页面是通过 MemoryArea::new(start, size, flags, Backend::new_alloc(populate)) 来将 [start, start + size) 映射到 Backend 新创建的物理页面上的, 而 Backend 则是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// axmm/src/backend/alloc.rs
fn alloc_frame(zeroed: bool) -> Option<PhysAddr> {
let vaddr = VirtAddr::from(global_allocator().alloc_pages(1, PAGE_SIZE_4K).ok()?);
if zeroed {
unsafe { core::ptr::write_bytes(vaddr.as_mut_ptr(), 0, PAGE_SIZE_4K) };
}
let paddr = virt_to_phys(vaddr);
Some(paddr)
}

fn dealloc_frame(frame: PhysAddr) {
let vaddr = phys_to_virt(frame);
global_allocator().dealloc_pages(vaddr.as_usize(), 1);
}

通过动态内存分配器分配一段地址(返回的是虚拟地址), 再将虚拟地址转换为物理地址返回, 这样就新 allocate 了一个物理页面.

ArceOS 的动态内存分配器提供两个功能: 按字节分配和按页分配. 后者是通常的动态内存调用, 前者是为了操作系统本身服务的, 相当于 rCore 的 FrameAllocator, 对物理地址进行管理, 分配页面. 字节分配器不够了也会向页分配器发送请求要求追加内存.

ArceOS 的动态内存分配器框架向下封装算法, 向上暴露接口. 常见的(字节)动态内存分配算法有: TLSF, Buddy, Slab 等. 设计动态内存分配算法是挑战题目之一. 但我还一点没做 (页分配是通过 Bitmap 管理的)

任务与任务调度

数据结构方面和 rCore 差不多, 不过似乎没有内部可变性模式了, 不知道是出于什么考虑.

ArceOS 的调度框架由 “当前可被调度的任务队列”run_queue 和 “当前阻塞中不可被调度的任务队列” wait_queue 和一个 “现在在运行的任务” task 组成 (系统还有 idle 待机任务 和 gc 回收清理任务); 向下需要 TaskContext 切换和特定调度算法的支持, 向上暴露 yield sleep spawn 等 API. 大部分 API 都非常直观, 操作相应含义的队列和任务控制块即可. 因此我们主要介绍向下需求的算法: 上下文切换和调度算法.

  1. 上下文切换与 rCore 几乎一致, 保存 ra, sp 和 RISCV 调用约定规定的通用寄存器, 保存并切换到下一个任务的上下文即可

  2. 协作式 FIFO 调度算法: 字面意思, 按照队列的先进先出模式按照特定顺序调度

  3. 抢占式 Round Robin 调度算法:

    被抢占 = 内部条件 && 外部条件

    外部条件是当前任务的抢占开关, 在”禁用抢占”->”启用抢占”的临界边缘触发. 这里我们在时钟中断时执行的 on_time_tick 里更新当前任务的外部抢占开关.

    内部条件是内部设置的抢占 guard, 以及自旋锁等, 防止在任务不希望的时候被打断.

    新任务仍会在队尾加入, 调度顺序不变, 只是多了一个抢占机制.

  4. 抢占式 CFS 调度算法(Completely Fair Scheduled, “绝对公平”的调度算法)

    $\text{vruntime} = \text{init_vruntime} + \frac{\delta}{weight(\text{prior})}$

    • $\text{vruntime}$ 最小的任务优先执行
    • 新任务的 $ \text{init_vruntime}$ 初始化为 $min(\text{vruntime}_{tid})$, 以便让新任务尽快被调度
    • 每次触发时钟中断对当前任务的 $\delta$ 按照上述式子递增, $\text{prior}$ 是优先级, 优先级越高递增越缓慢也就越容易处在前列并被调度; 然后把 $\text{vruntime}$ 最小的任务放在队首

存储设备(以及其他设备)的管理框架 (与 rCore 不同)

存储底层设备是 QEMU PFlash 模拟的闪存磁盘, 通过 MMIO 方式将文件映射到特定内存地址(在 SBI 起始的 0x80000000 之前, 在 qemu 的设备树之后)

也可以从块设备读取其数据 (drv_blockdrv_virtio 模块)

代码结构上, AllDevices 管理 block net display 等所有的设备, 设备用 struct AxDeviceContainer<D>(Vec<D>) 统一管理, 可以管理不同类型的 device.

驱动是在 axruntime::rust_main 调用的 init_drivers 中初始化的, 先基于总线 probe 设备, 然后通过宏生成代码放入 AllDevices

文件系统

文件设备基于块设备, 块设备的行为描述为 trait BlockDriverOps.

块设备以上的层次结构如下:

ArceOS 的文件设备相对 rCore 来说更加贴近现实, 如目录和挂载的概念: 目录项是一个 DirNode, 可以把一个 fs 文件系统挂载到这个目录上, 挂载 (mount) 的意义实际上就是把磁盘上扁平的数据关系建立为树形结构

宏内核支持

目前为止我们的 Unikernel 架构 ArceOS 与宏内核 (Monolithic) 架构八竿子打不着还有很大差别:

  • 没有特权级切换
  • 没有应用自己的地址空间
  • 没有实现系统调用
  • 没有实现加载应用

那么逻辑就很清楚了, 我们要按照上面的点增量构建一个宏内核(这也是组件化的有点, 高复用性):

  1. 先创建用户地址空间: 一切操作都需要对数据进行, 而数据需要在地址空间上
  2. 加载应用数据并建立用户栈到地址空间
  3. 伪造现场(临时上下文)并放入新任务的内核栈上(此时仍然在内核态)
  4. 调用 sret 从 S 特权级返回 U 特权级, 进入用户态, 从刚刚加载的应用入口开始执行
  5. 用户应用调用了系统调用, 通过异常中断向量表 Trap 进 S 特权级, 内核处理这个调用

用户地址空间的内存分布

  • 地址空间的高地址区域是内核空间(内核栈等可以共享)
  • 在内核根页表中只有高区域, 低地址区域是空
  • 以这个内核根页表为模板为应用进程复制了低地址区域的应用空间后, 所有页表中高地址那部分的内核空间的虚拟地址映射到相同的物理地址, 实现了共享.

加载用户数据

读到内存缓存区, 再到内核地址空间, 再在用户地址空间中与内核地址空间存应用的那部分映射到相同的部分

任务控制块扩展

宏内核需要一些 Unikernel 不具备的任务信息, 如用户地址空间, 上下文等. 这里用 def_task_ext! 宏注册扩展任务控制块的结构体类型.

这样, 记下 sepc 寄存器值, 标记 sstatus CSR 为”切换前来自用户态”(因为 RISCV 不存在专门从内核态到用户态的切换的指令, 只能假装当前在内核态是因为刚刚从用户态过来的, 然后返回回去)

系统调用

ArceOS Monolithic 中系统调用的实现路径是 Trap 进内核态后, 由 axhal::arch::trap 通过 link_me 从应用程序的 syscall 处理函数中调用到 arcesos_posix_api 来实现功能的

link_me: 对系统调用的响应函数通过 #[register_trap_handler(SYSCALL)] 注册.

Linux App 支持

我们已经有了一个最小化的宏内核, 但是还不能直接跑 Linux ELF 可执行文件:

  • 地址空间太 plain 了, 没有逻辑分段 (.text .data 等都要模仿 Linux)

    逻辑段实现上是一个 BTreeMap<address, area>, 通过 mmap 映射一段地址到一段页面上(以及设置访问权限等). 映射后端包含 LinearAlloc 两种方式, 前者是带偏移的连续地址映射到一个段, 后者是可能不连续的多个物理页帧的集合映射到一个段.

  • Linux App 大多数需要与 glibcmusl-libc 进行参数协同

    加载 ELF 文件, 然后只需要读取 r-xrw- 的部分 (.text.data), 即 Type 为 Load 的两个段.

    内存中和文件中代码段的长度和地址一般是恒等映射的, 毕竟就在第一个

    但是由于 BSS 段的存在, 需要重新计算并映射 .bss.data 段作为数据段

    初始化用户栈, 把命令行参数加入进去(参数和环境变量), 这个二阶段管道那一章 ch7 有说过

    musl-libc 启动较为简洁, 需要实现的系统调用较少.

  • 其他缺少于 Linux 的部分: procfs, sysfs.

Hypervisor

Hypervisor 是 Guest 与 Host 相同指令集情况下高效(虚拟化消耗可忽略)地对硬件资源进行管理的虚拟机

虚拟化模式的寄存器相关变化

  • misa[7] = 1 表示启用 Hypervisor 虚拟化
  • s* 寄存器作用不变(sstatus stvec sepc), Host 新增 hs* 寄存器用于对 Guest 的路径控制(异常/中断委托等)
  • vs* 寄存器操作 Guest 中的 S 特权级寄存器, 如 vsepc 要设置为 0x80200000, vsstatus 要设置为初始 S (VS) 特权级
  • hstatus[7] 记录上一次进入 HS 特权级前的模式 sret 根据这个决定是返回虚拟化用户态还是 Host

控制流

虚拟化实际就是在 Guest 和 Host 直接来回转换, 之所以要切回 Host 是因为有些操作必须让 Host 来执行(比如 sbi_call 或 中断注入)

每个 vCPU (在这里为了简单, 仅对应一个 CPU 核心) 维护一组上下文状态, Guest 或者 Host.

切换到另一个状态时保存当前上下文并切换到对应的上下文, Guest 到 Host 是 VM_Exit, 反之是 sret

分页处理: 嵌套分页: GVA -> (vsatp) -> GPA -> (hgatp) -> HPA

虚拟设备: 透传模式或模拟模式

中断处理(中断注入): 只有 hvip 的对应位被设置为 1, Guest 的 vstvec 才会被触发, 否则 Guest 完全不知道有中断发生. vstvec 是 Guest 的中断入口, 但它仅响应 hvip 中已注入的虚拟中断, 而不是物理中断.