0%

训练营学习记录

这篇文章用来记录我在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 中已注入的虚拟中断, 而不是物理中断.

操作系统设计与实现中常用的 Rust 特性

oscamp 第一阶段的 rustlings 总结, 但因为去年写过一次 rustlings 了, 题目比较基础(除了最后的算法和数据结构实现有点麻烦)之前也接触过不少 Rust, 所以这次是总结一下二三阶段中特别需要的 Rust 特性

结构体和枚举

注意元组结构体

1
2
3
4
5
struct TupleStruct(u32, u32, u32);
let tuple_obj = TupleStruct {0, 255, 0};
assert!(tuple_obj.0, 0);

struct UnitStruct;

单元结构体主要是在不关心其关联的数据, 而只需要实现其关联函数或方法时存在
其实还比较常用的, 例如对某学基本数据类型的特化, 比如 rustlings 后面的 MinHeap, 三阶段中也有这样的情况

枚举这里需要比较熟悉 match 等模式匹配的手段:

1
2
3
4
5
6
7
match message {
Message::Move(p) => self.move_position(p),
Message::Echo(s) => self.echo(s),
Message::ChangeColor(x, y, z) => self.change_color((x, y, z)),
Message::Quit => self.quit(),
_ => ()
};

本实验中大量使用枚举来表示一些错误情况(这也是 Rust 和一些程序设计语言的常见处理方式)

二三阶段处理 Trap 时的 Trap::Exception(...) Trap::Interrupt 也都是枚举, 通过 match 模式匹配

模块

模块这里 rustlings 的考察比较基础, 这里补充一些 Cargo 管理工程的机制

  • module 用于代码结构分层, 控制作用域和可见性等

    Rustlings 只介绍了内联模块 即

    1
    2
    3
    4
    5
    mod delicious_snacks {
    pub use self::fruits::PEAR as fruit;
    pub use self::veggies::CUCUMBER as veggie;
    ...
    }

    但实际工程中还有很多创建模块的方法, 例如:

    • 拆分到一个文件中, 然后用 mod name; 声明告诉编译器这里有个模块. 声明后才会进入模块树.

    • 复杂模块应拆分到一个目录中, 并在这个目录下 mod.rs 中暴露接口和声明子模块, 这个目录下的其他文件就是这个目录的子模块, 例如 foo/mod.rs 中写下 pub mod bar;, foo/bar.rs 就成为了 foo::bar 子模块

    • mod foo; 会查找 foo/mod.rsfoo.rs

      例如三阶段 ArceOS 实验二, 需要在 axstd 下面创建 collections 子模块(创建collections/mod.rs), 然后再创建 collections::hash_map 子子模块(?)(创建 hash_map.rs 并在 collections/mod.rs 声明pub mod hash_map, pub use hash_map::HashMap)

  • crate 是可被编译的最小单位, 分为 binlib 两种, 前者可被编译为可执行程序, 后者可被编译为.rlib, 只能提供一些接口(没有 main 函数)

    三阶段需要对 crate 有一定的理解, 因为 ArceOS 的组件化是相当依赖于 Rust 的 crate 的.

  • package 是提供一系列功能的 crates 的集合, 可包含多个 bin crate 和最多一个 lib crate.

  • workspace 管理多个 packages 的编译环境工具链等的目录结构. 一般比较大型的项目会分为多个 packages 开发, 共享同一个 target/目录

    以 ArceOS 下的 Cargo.toml 为例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    [workspace]
    resolver = "2"
    members = [
    "modules/axalloc",
    ...
    ]
    [workspace.package]
    ...

    [workspace.dependencies]
    axstd = { path = "ulib/axstd" }
    axlibc = { path = "ulib/axlibc" }
    ...

Option<T>Result<T, E>

在我们本次操作系统的实验中会大量使用(其实 Rust 工程都会大量使用)

ArceOS 会封装为 AxResult

泛型与 Trait

泛型作为一种静态分发生成代码的方式, 本次实验中没有太需要这一块, 有多态基本都是在 dyn Trait 用 trait 对象动态分发

trait 在本次实验中就非常重要了, 组合形式的代码复用比继承灵活了不少, 例如:

  • rCore 中的”文件” File trait, 通过 write read 抽象地描述了一个”文件”应该具备的行为
  • ArceOS 中的 VfsOpsVfsNodeOps, 描述了文件系统应该提供的功能和一个文件节点应该提供的功能, 并且通过 Arc<dyn VfsOps> 实现对不同文件系统和不同文件节点的支持
  • 动态内存分配的接口 unsafe trait GlobalAlloc (这个其实是标准库的, 但是我们自己实现的内存分配器需要 impl GlobalAlloc for OurAllocator)

生命周期

想要对 lifetime 的理解炉火纯青是比较困难的, 幸运的是本次实验你只需要懂得一些基本的生命周期概念, 然后学习智能指针就行了(), 代码中大量使用 Arc<T> RefCell<T> 等.

但还要记住一句话: 任何时候保证引用有效(对这句话的理解应该还有一层: 任何引用本身存在的生命周期必须小于等于其引用内容的生命周期, 不然其内容死了之后这个引用就没有任何意义而且变得危险了)

迭代器

使用 obj.iter()obj.into_iter()/obj.iter_mut() 后可以链式地进行一些操作:

  • 迭代适配器: map filter for_each, 接收一个捕获迭代器元素的闭包, 执行某些操作, 但不会消耗迭代器返回值, 而是继续返回迭代器
  • 消费适配器: .collect() .sum(), 消费其中的迭代器并返回具体类型, 注意你可能需要显示标注我们需要 collect 成什么类型

智能指针

  • Box<T> 拥有其内部数据的所有权, 数据在堆上, 常用于解决递归类型无法计算大小的问题 (Box 本身是 Sized 的)

  • Rc<T>/Arc<T>: 多个智能指针共享同一个数据的所有权

    • let origin = Arc::new(data) 创建一个智能指针
    • let others = Arc::clone(&origin) 共享 origin 智能指针中的数据 data
    • Arc:strong_count(&original) 有多少指针在共享这一数据, 引用计数为 0 是释放数据空间
  • Weak<T>: 弱引用, 用于解决引用循环. 例如对于操作系统中父进程有一个 children: Vec<Arc<TaskControlBlock>>, 而子进程还需要得知自己父进程 parent: Arc<TaskControlBlock>, 这样就会导致引用计数始终不为 0, 最后数据无法被释放造成泄露, 所以需要 parent: Weak<TaskControlBlock>. Weak<T> 不需要引用计数为 0 就可以被清理, 其特性是:

    • 任何涉及弱引用的循环会在其相关的值的强引用计数为 0 时被打断
    • Rc<T> 维护一个 weak_count, 每次Rc::downgrade 创建 Weak 指针时增加弱引用计数
    • weak_count 无需计数为 0 就能使 Rc<T> 实例被清理, 因此使用 Weak 的数据时其数据是可能被清理掉的, 我们需要Weak:upgrade, 返回 Option<Rc<T>.
  • RefCell: 内部可变性设计模式

    我们可以在 RefCell<T> 本身不被绑定为可变时修改其内部的值, 在运行时进行借用检查.

    RefCell 提供的内部可变性在二阶段 rCore 的一些控制信息结构体中常用, 例如 TCB 将不可变数据作为直接的结构体成员, 可变数据放在 inner 里 (这里 UPSafeCell 就是基于 RefCell 封装的).

    我们显然不希望 process, kstack 这样逻辑上应为 immutable 的数据被改变, 所以整个 TCB 结构体应该被绑定在不可变变量上, 但是这样就导致 inner 也是不可变的了, 我们无法修改 inner 中的 trap_cx 上下文等, 所以我们需要依赖 RefCell 的运行时借用检查能力, 允许修改 inner 中的值

    根据 RefCell 的特性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    /// Task control block structure
    pub struct TaskControlBlock {
    /// immutable
    pub process: Weak<ProcessControlBlock>,
    /// Kernel stack corresponding to PID
    pub kstack: KernelStack,
    /// mutable
    inner: UPSafeCell<TaskControlBlockInner>,
    }

    pub struct TaskControlBlockInner {
    pub res: Option<TaskUserRes>,
    /// The physical page number of the frame where the trap context is placed
    pub trap_cx_ppn: PhysPageNum,
    /// Save task context
    pub task_cx: TaskContext,

    /// Maintain the execution status of the current process
    pub task_status: TaskStatus,
    /// It is set when active exit or execution error occurs
    pub exit_code: Option<i32>,
    }

    这个 UPSafeCellexclusive_access 方法不能嵌套调用, 会导致 BorrowMutError.

线程

这个实验不需要掌握太多 Rust 线程的使用方法, 因为我们需要自己实现线程机制

但是要对线程模型和同步互斥有一定的认识, 会在 rCore Tutorial Book 的 ch8 中学习到

在 ArceOS 中用得非常神仙, 尤其是对过程宏的运用:

  • 借助 linke_me 利用过程宏 #[register_trap_handler(SYSCALL)] 等注册异常处理函数
  • 由于 axhalaxlog 存在的循环依赖关系, axlog 必须以 extern ABI 而不是[dependencies] 的形式依赖 axhal. 但是这个不通过 Rust 编译器的检查, 所以用过程宏封装成 trait 的形式, 减少出错概率
  • 用宏对某些特定对象生成 trait 的默认行为这些相比以上用法已经算是比较一般的了

过程宏是基于 AST 进行代码生成的, 所以灵活度非常高, 这里也不需要掌握太深入, 但是至少要能读懂过程宏, 这样在三阶段中才不会太盲人摸象

类型转换

主要在地址空间的支持中需要对 PA VA PPN VPN 进行转换等涉及到 From Into 之类的 trait

剩下的时候都在能.into().into()不能的话就强行as

: 这是不负责任的喵

最后十道题

  • 链表

    • 活用 loop+match+ 模式匹配 能写得比 for 循环迭代更清晰
    • 裸指针解引用需要 unsafe 套一下, unsafe 最好在表达式层次
  • 排序: 比较简单, 和其他语言基本没什么差别. 可以用 array.swap(idx1, idx2) 代替 std::mem:swap 交换元素

  • 二叉搜索树: 主要是 Option<Box<TreeNode<T>>> 套着比较烦, 但其实都是必要的: Option 用来时刻提醒你防止 NULL 的情况, Box 用于解决树形结构的递归类型

  • 图的存储, BFS 和 BFS: vec![vec![];n] 创建一个 Vec<Vec<T>> 用来存边

    • VecQueue 作为 Rust 中的双端队列
    • while let Some(cur) = q.pop_front() {} 相比 while(!q.empty()) { auto cur = q.front(); q.pop(); } 高下立判了()
  • 利用栈进行括号匹配: 优雅, 太优雅了

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    fn bracket_match(bracket: &str) -> bool {
    let mut s = Stack::new();
    for c in bracket.chars() {
    match c {
    '(' | '[' | '{' => s.push(c),
    ')' | ']' | '}' => {
    if matches!(s.peek(), Some(&res) if res == map_bracket(c)) {
    s.pop();
    } else {
    return false;
    }
    }
    _ => continue,
    }
    }
    s.is_empty()
    }

    这里 Some(&res) if res == ...matches! 宏提供的守卫, 宏很神奇吧

  • 用两个队列模拟栈: 每次插入时向 q2 插入元素, 然后把 q1 元素全部出队再入 q2 队, 最后交换 q1 q2, 这样 q1 的出队顺序就始终保证是原始数据应当符合的出栈顺序, 用的时候直接弹就行.

  • 二叉堆, 应该是最麻烦的一个, 需要注意有一个 items: vec![T::default()], 这个默认的 0 号节点用于占位, 这样我们就能从 1 开始构建根节点, 因此左子树是 $2 \times i$, 右子树是 $2 \times i + 1$

    • 还是利用 loop while 寻找需要的节点并反复执行操作(如弹出时交换被弹出节点与根节点后执行的下沉操作需要找的对应位置节点)
  • 邻接表存图: 注意数据类型是 HashMap<String, Vec<(String, i32)>>

以上数据结构几乎都不需要在本次实验中手动实现, 但是三阶段你要手写一个 HashMap

Rust 学习笔记

目录

  1. 基础语法
  2. 所有权系统
  3. 结构体与枚举
  4. 错误处理
  5. 并发编程
  6. 高级特性
  7. 实用工具

基础语法

变量与可变性

1
2
3
let x = 5;          // 不可变绑定
let mut y = 10; // 可变绑定
const PI: f64 = 3.14; // 常量

数据类型

  • 标量类型‌:

    • 整数: i32, u64
    • 浮点: f64
    • 布尔: bool
    • 字符: char (4字节Unicode)
  • 复合类型‌:

    1
    2
    3
    4
    5
    // 元组
    let tup: (i32, f64, char) = (500, 6.4, 'A');

    // 数组
    let arr: [i32; 3] = [1, 2, 3];

控制流

1
2
3
4
5
6
7
8
9
10
11
12
13
// if表达式
let number = if condition { 5 } else { 6 };

// 循环
for i in 1..=5 {
println!("{}", i);
}

// 模式匹配
match value {
1 => println!("one"),
_ => println!("other"),
}

所有权系统

三大规则

  1. 每个值有且只有一个所有者
  2. 值在作用域结束时自动释放
  3. 所有权可通过移动(move)转移

示例

1
2
3
let s1 = String::from("hello");
let s2 = s1; // s1的所有权转移到s2
// println!("{}", s1); // 错误!s1已失效

借用规则

  • 同一时间,要么:
    • 只能有一个可变引用(&mut T)
    • 或多个不可变引用(&T)
  • 引用必须总是有效的
1
2
3
fn calculate_length(s: &String) -> usize {
s.len()
}

结构体与枚举

结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct User {
username: String,
email: String,
sign_in_count: u64,
}

impl User {
// 关联函数
fn new(name: String, email: String) -> Self {
User {
username: name,
email,
sign_in_count: 1,
}
}

// 方法
fn greet(&self) {
println!("Hello, {}!", self.username);
}
}

枚举与模式匹配

1
2
3
4
5
6
7
8
9
10
11
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);

match home {
IpAddr::V4(a, b, c, d) => println!("IPv4: {}.{}.{}.{}", a, b, c, d),
IpAddr::V6(s) => println!("IPv6: {}", s),
}

错误处理

Result类型

1
2
3
4
5
6
7
8
use std::fs::File;

let f = File::open("hello.txt");

let f = match f {
Ok(file) => file,
Err(error) => panic!("打开文件失败: {:?}", error),
};

?运算符

1
2
3
4
5
6
7
8
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
let mut s = String::new();
File::open("hello.txt")?.read_to_string(&mut s)?;
Ok(s)
}

并发编程

线程

1
2
3
4
5
6
7
use std::thread;

let handle = thread::spawn(|| {
println!("来自线程的消息");
});

handle.join().unwrap();

通道

1
2
3
4
5
6
7
8
9
10
use std::sync::mpsc;

let (tx, rx) = mpsc::channel();

thread::spawn(move || {
tx.send(42).unwrap();
});

let received = rx.recv().unwrap();
println!("收到: {}", received);

高级特性

生命周期

1
2
3
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}

Trait

1
2
3
4
5
6
7
8
9
trait Greet {
fn greet(&self);
}

impl Greet for String {
fn greet(&self) {
println!("Hello, {}!", self);
}
}

实用工具

常用Cargo命令

1
2
3
4
5
cargo new project_name  # 创建新项目
cargo build # 编译项目
cargo run # 编译并运行
cargo test # 运行测试
cargo doc --open # 生成文档并打开

推荐工具链

  • rustup: Rust版本管理工具
  • rust-analyzer: IDE插件
  • clippy: 代码检查工具

学习资源

rcore 学习笔记

目录

  1. 批处理系统
  2. 多道程序与分时多任务
  3. 地址空间
  4. 进程与进程管理
  5. 文件系统与I/O重定向
  6. 进程间通信
  7. 并发

批处理系统

一、批处理系统概述

批处理系统(Batch System)是一种用于管理无需或仅需少量用户交互即可运行程序的操作系统模型‌。它能够自动安排程序的执行顺序,在资源允许的情况下高效运行多个程序‌。批处理系统的主要特点包括:

  • 自动调度多个作业顺序执行
  • 提高CPU和I/O设备利用率
  • 减少人工干预
  • 适用于计算密集型任务‌

二、RISC-V特权级架构

批处理系统实现的基础是RISC-V的特权级机制‌:

  1. 用户模式(U-mode)‌:应用程序运行的特权级
  2. 监督者模式(S-mode)‌:操作系统内核运行的特权级
  3. 机器模式(M-mode)‌:最底层硬件操作特权级

特权级机制的根本原因是确保操作系统的安全性,限制应用程序的两种行为:

  • 不能访问任意的地址空间
  • 不能执行某些可能危害系统的指令‌

三、批处理系统实现要点

3.1 应用程序设计
  1. 内存布局‌:通过链接脚本调整应用程序的内存布局‌
  2. 系统调用‌:应用程序通过ecall指令请求操作系统服务‌
  3. 二进制转换‌:将应用程序从ELF格式转化为binary格式‌
3.2 系统实现流程
  1. 初始化Trap机制‌:
    • 设置stvec寄存器指向trap处理入口(__alltraps)‌
    • 定义TrapContext结构保存寄存器状态‌
  2. 任务调度‌:
    • 依次加载并执行内存中的多个程序‌
    • 通过ecall指令实现特权级切换‌
  3. 上下文保存与恢复‌:
    • 保存用户程序寄存器状态到TrapContext
    • 处理完成后恢复上下文继续执行‌

四、关键代码分析

4.1 Trap初始化代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// os/src/main.rs
#[no_mangle]
pub fn rust_main() -> ! {
clear_bss();
println!("[kernel] Hello, world!");
trap::init();
...
}

// os/trap/mod.rs
global_asm!(include_str!("trap.S"));

/// initialize CSR `stvec` as the entry of `__alltraps`
pub fn init() {
extern "C" { fn __alltraps(); }
unsafe {
stvec::write(__alltraps as usize, TrapMode::Direct);
}
}
4.2 Trap上下文结构
1
2
3
4
5
6
7
8
9
// os/trap/context.rs
pub struct TrapContext {
/// general regs[0..31]
pub x: [usize; 32],
/// CSR sstatus
pub sstatus: Sstatus,
/// CSR sepc
pub sepc: usize,
}

五、批处理系统工作流程

  1. 系统启动时初始化Trap机制‌
  2. 加载多个应用程序到内存‌
  3. 依次执行每个应用程序:
    • 应用程序通过ecall触发系统调用‌
    • CPU切换到S模式,跳转到__alltraps‌
    • 保存应用程序上下文到TrapContext‌
    • 执行系统调用处理程序
    • 恢复上下文,返回用户程序继续执行‌
  4. 当前程序执行完成后,加载并执行下一个程序‌

六、与后续章节的关联

批处理系统是多道程序与分时多任务系统的基础‌。后续章节将在批处理系统基础上实现:

  • 提前加载应用程序到内存减少切换开销‌
  • 协作机制支持程序主动放弃处理器‌
  • 抢占机制支持程序被动放弃处理器‌

多道程序与分时多任务

一、基本概念与设计目标

多道程序设计是指允许多个程序同时进入计算机主存储器并启动计算的方法‌。分时系统是多道程序设计的延伸,通过高频率的任务切换实现用户与系统的交互‌。rCore实现这两种机制的核心目标是:

  • 提高系统性能和效率
  • 减少应用程序切换开销
  • 通过协作机制支持程序主动放弃处理器
  • 通过抢占机制保证处理器资源使用的公平性‌

二、多道程序的放置与加载

2.1 内存布局设计

在rCore中,每个应用程序需要按照编号被分别放置到内存中不同位置,这与第二章将所有程序复制到同一内存区域不同‌。实现方法包括:

  • 通过链接脚本指定每个应用程序的起始地址
  • 内核运行时正确获取地址并将应用代码放置到指定位置‌
2.2 地址空间问题

每个应用程序需要知道自己运行时在内存中的位置,这给编写带来麻烦。操作系统也需要知道每个程序的位置,不能任意移动应用程序所在的内存空间‌。这种限制导致:

  • 无法在运行时根据内存空闲情况动态调整程序位置
  • 可能影响后续对内存碎片空间的利用‌

三、任务切换机制

3.1 基本概念
  • 任务‌:应用程序的一个计算阶段的执行过程‌
  • 任务切换‌:从一个程序的任务切换到另一个程序的任务‌
  • 任务上下文‌:任务切换和恢复时相关的寄存器集合‌
3.2 切换流程

任务切换通过内核栈上的task_context压入和弹出实现‌,具体分为五个阶段:

  1. Trap执行流A调用__switch前,A内核栈只有Trap上下文和调用栈信息
  2. A在内核栈分配任务上下文空间保存寄存器快照,更新task_cx_ptr
  3. 读取B的task_cx_ptr获取B内核栈栈顶位置,切换sp寄存器实现执行流切换
  4. CPU从B内核栈取出任务上下文恢复寄存器状态
  5. B从调用__switch位置继续执行‌

四、关键数据结构与实现

4.1 TrapContext结构
1
2
3
4
5
6
#[repr(C)]
pub struct TrapContext {
pub x: [usize; 32], // 通用寄存器
pub sstatus: Sstatus, // 状态寄存器
pub sepc: usize, // 异常程序计数器
}

该结构用于保存和恢复任务状态,在os/src/trap/context.rs中定义‌。

4.2 系统调用处理

在trap_handler中对用户态环境调用(Exception::UserEnvCall)的处理:

1
2
3
4
Trap::Exception(Exception::UserEnvCall) => {
cx.sepc += 4;
cx.x = syscall(cx.x[...]);
}

通过修改sepc和x寄存器实现系统调用返回‌。

五、分时多任务实现机制

5.1 协作式调度
  • 程序通过主动调用yield系统调用放弃CPU
  • 提高系统执行效率但依赖程序配合‌
5.2 抢占式调度
  • 通过时钟中断强制任务切换
  • 保证不同程序对处理器资源的公平使用
  • 提高对I/O事件的响应效率‌

地址空间

一、地址空间基本概念

1.1 虚拟地址与物理地址
  • 虚拟地址‌:应用程序使用的逻辑地址,由操作系统和硬件共同维护的抽象层‌
  • 物理地址‌:实际内存硬件使用的地址,由CPU地址线直接访问‌
  • 转换机制‌:通过MMU(Memory Management Unit)硬件单元实现虚拟地址到物理地址的转换‌
1.2 地址空间定义

地址空间是指程序在运行时用于访问内存的逻辑地址集合,包含:

  • 用户地址空间:每个应用程序独占的虚拟地址范围‌
  • 内核地址空间:操作系统内核使用的虚拟地址范围‌

二、地址空间实现原理

2.1 SV39分页机制

RISC-V采用SV39分页方案,主要特点包括:

  • 39位虚拟地址空间,支持512GB寻址‌
  • 三级页表结构(页全局目录、页中间目录、页表)‌
  • 页大小为4KB,作为基本映射单位‌
2.2 页表管理
  • ‌satp寄存器:控制分页模式,存储根页表物理地址‌
    • MODE字段:设置为8表示启用SV39分页‌
    • PPN字段:存储一级页表物理页号‌
  • 页表项结构‌:包含物理页号、访问权限等控制位‌

三、地址空间隔离与保护

3.1 隔离机制
  • 应用间隔离‌:每个应用有独立的页表,V标记位控制有效访问范围‌
  • 内核保护‌:页表项U位控制用户态访问权限‌
  • 空分复用‌:不同应用可使用相同虚拟地址映射到不同物理页‌
3.2 内存安全

通过地址空间机制实现:

  • 防止应用随意访问其他应用或内核数据‌
  • 避免物理内存布局冲突,简化应用开发‌
  • 增强系统整体安全性和稳定性‌

四、关键数据结构与实现

4.1 页表相关结构
1
2
3
4
5
6
7
8
9
10
// 页表项定义
struct PageTableEntry {
bits: usize, // 存储物理页号和标志位
}

impl PageTableEntry {
fn ppn(&self) -> PhysPageNum { /*...*/ }
fn flags(&self) -> PTEFlags { /*...*/ }
fn is_valid(&self) -> bool { /*...*/ }
}
4.2 地址空间管理
  • ‌MemorySet结构:管理应用的地址空间‌
    • 包含页表、内存区域映射等信息
    • 实现地址映射的建立与销毁

五、地址空间工作流程

  1. 初始化阶段‌:
    • 创建内核地址空间‌
    • 设置satp寄存器启用分页‌
  2. 应用加载‌:
    • 为应用创建独立地址空间‌
    • 建立代码段、数据段等内存映射‌
  3. 运行时‌:
    • MMU自动完成地址转换‌
    • 页错误异常处理‌

进程与进程管理

一、进程基本概念

1.1 进程定义

进程是正在运行并使用计算机资源的程序,是操作系统资源分配的基本单位‌。在rCore中,进程由以下部分组成:

  • 程序代码和数据段
  • 虚拟内存空间
  • 进程控制块(PCB)‌
1.2 进程与程序区别
  • 程序‌:静态的可执行文件
  • 进程‌:动态的执行实体,具有生命周期‌
1.3 进程控制块(PCB)

PCB是进程存在的唯一标志,包含:

  • 进程标识符(PID)
  • 处理机状态(寄存器值等)
  • 进程调度信息
  • 进程控制信息‌

二、rCore进程管理实现

2.1 进程相关系统调用

rCore实现了以下关键进程管理系统调用:

  • fork():创建与当前进程相同的子进程‌
  • wait_pid():父进程等待子进程结束‌
  • exec():用新程序替换当前进程‌
2.2 进程创建流程
  1. 系统启动时加载初始进程(initproc)‌
  2. initproc通过fork创建shell进程‌
  3. shell根据用户输入创建其他进程‌
2.3 进程调度

rCore采用以下调度策略:

  • 协作式调度:进程主动放弃CPU
  • 抢占式调度:通过时钟中断强制切换‌

三、关键数据结构

3.1 进程控制块实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 进程状态定义
pub enum TaskStatus {
Ready,
Running,
Blocked,
}

// 进程控制块结构
pub struct TaskControlBlock {
pub pid: usize, // 进程ID
pub status: TaskStatus, // 进程状态
pub context: TaskContext,// 进程上下文
pub memory_set: MemorySet, // 地址空间
// 其他字段...
}
3.2 进程上下文
1
2
3
4
5
6
#[repr(C)]
pub struct TaskContext {
pub ra: usize, // 返回地址
pub sp: usize, // 栈指针
pub s: [usize; 12], // 保存的寄存器
}

四、进程生命周期管理

4.1 进程创建
  • 分配新的PID
  • 创建地址空间
  • 初始化进程上下文‌
4.2 进程切换
  1. 保存当前进程上下文
  2. 选择下一个要运行的进程
  3. 恢复新进程上下文‌
4.3 进程终止
  • 释放占用的资源
  • 通知父进程
  • 从进程表中移除‌

五、进程间通信

5.1 共享内存

通过地址空间机制实现进程间数据共享‌

5.2 信号量

提供同步原语,协调进程执行顺序‌

文件系统与I/O重定向

一、文件系统基础概念

1.1 文件系统作用

文件系统是操作系统用于持久存储数据的关键组件,主要解决内存易失性与外存持久性之间的矛盾‌。rCore文件系统实现了:

  • 数据持久化存储
  • 文件命名与组织
  • 访问控制与权限管理‌
1.2 存储设备分类

UNIX系统将I/O设备分为三类:

  1. 块设备‌:如磁盘,以固定大小块(512B-32KB)为单位传输‌
  2. 字符设备‌:如键盘/串口,以字符流为单位传输‌
  3. 网络设备‌:面向报文传输,BSD引入socket接口处理‌

二、文件系统核心结构

2.1 磁盘布局

rCore文件系统采用类UNIX布局:

  • 超级块‌:记录文件系统元信息
  • inode区‌:文件索引节点存储
  • 数据块区‌:实际文件内容存储‌
2.2 关键数据结构
1
2
3
4
5
6
7
8
9
// 文件系统超级块
struct SuperBlock {
magic: u32, // 文件系统魔数
blocks: u32, // 总块数
inode_bitmap: u32, // inode位图起始块
data_bitmap: u32, // 数据块位图起始块
inode_area: u32, // inode区域起始块
data_area: u32, // 数据区域起始块
}

三、文件操作接口

3.1 系统调用接口

rCore实现以下核心文件操作:

  • open():打开/创建文件‌
  • read()/write():文件读写‌
  • lseek():调整文件指针位置‌
  • close():关闭文件描述符‌
3.2 文件描述符

每个进程维护文件描述符表:

  • 0:标准输入(stdin)
  • 1:标准输出(stdout)
  • 2:标准错误(stderr)
  • ≥3:用户打开的文件‌

四、I/O重定向机制

4.1 重定向类型
  • 输入重定向‌:< 将文件内容作为程序输入‌
  • 输出重定向‌:> 覆盖写入文件,>> 追加写入‌
  • 错误重定向‌:2> 重定向stderr‌
4.2 实现原理

通过修改进程文件描述符表实现:

  1. 打开目标文件获取新fd
  2. 关闭原标准流fd(0/1/2)
  3. 复制新fd到标准流位置‌

五、管道通信机制

5.1 管道特点
  • 半双工通信,包含读端和写端‌
  • 基于队列的有限缓冲区‌
  • 读空/写满时阻塞等待‌
5.2 使用示例
1
2
3
4
5
6
7
8
9
// 创建管道
let mut pipe_fd = [0usize; 2];
pipe(&mut pipe_fd);

// 子进程读管道
if fork() == 0 {
close(pipe_fd); // 关闭写端
read(pipe_fd, &mut buffer);
}

六、设备驱动实现

6.1 virtio驱动

rCore通过virtio协议访问块设备:

  • 设备树信息由OpenSBI提供‌
  • 使用DMA提高传输效率‌
  • 实现磁盘块缓存优化性能‌
6.2 三种I/O方式
  1. PIO‌:CPU直接控制I/O‌
  2. 中断驱动‌:设备就绪后通知CPU‌
  3. DMA‌:设备直接访问内存‌

并发

一、并发基础概念

1.1 并发与并行区别
  • 并发‌:多个任务交替执行,宏观上”同时”运行‌
  • 并行‌:多个任务真正同时执行,需要多核硬件支持‌
1.2 并发实现方式

rCore主要采用以下并发模型:

  • 多道程序:通过任务切换实现并发‌
  • 分时多任务:基于时间片轮转调度‌
  • 多线程:轻量级执行单元共享地址空间‌

二、任务调度机制

2.1 任务控制块(TCB)
1
2
3
4
5
struct TaskControlBlock {
task_cx: TaskContext, // 任务上下文
task_status: TaskStatus, // 任务状态
// 其他调度相关信息...
}
2.2 调度策略

rCore实现了两种基本调度方式:

  1. 协作式调度‌:任务主动yield让出CPU‌
  2. 抢占式调度‌:通过时钟中断强制任务切换‌

三、同步原语实现

3.1 自旋锁
1
2
3
4
pub struct SpinLock<T> {
locked: AtomicBool,
data: UnsafeCell<T>,
}
  • 基于原子操作实现‌
  • 获取锁时忙等待‌
3.2 信号量
1
2
3
4
pub struct Semaphore {
count: isize,
wait_queue: VecDeque<TaskControlBlock>,
}
  • 维护计数器+等待队列‌
  • 提供P/V操作接口‌

四、中断与异常处理

4.1 中断处理流程
  1. 保存当前任务上下文‌
  2. 执行中断服务例程(ISR)‌
  3. 恢复或切换任务上下文‌
4.2 特权级切换
  • 用户态(U-Mode)通过ecall进入内核态(S-Mode)‌
  • 内核通过sret返回用户态‌

五、并发编程实践

5.1 线程创建
1
2
3
4
5
fn thread_create(entry: fn()) -> Tid {
// 分配线程栈
// 初始化线程上下文
// 加入调度队列
}
5.2 互斥访问示例
1
2
3
4
5
let lock = SpinLock::new(shared_data);
{
let mut guard = lock.lock();
*guard += 1; // 临界区操作
} // 自动释放锁

六、性能优化技术

6.1 无锁数据结构
  • 基于原子操作实现‌
  • 适用于高并发场景‌
6.2 读写锁
1
2
3
4
pub struct RwLock<T> {
state: AtomicIsize,
data: UnsafeCell<T>,
}
  • 区分读写访问‌
  • 提高读多写少场景性能‌

二阶段 rCore 实验总结 - 折鸦

实验环境配置

用 Rust 开发操作系统内核源代码, 通过 rustc 交叉编译到 riscv64gc-unknown-none-elf (一般情况下是 x86_64-unknown-linux-gnu), 通过 rust-objcopy 提取出 bin, 然后放到 qemu-system-riscv64 模拟器进行模拟, 大概是这么个工具链.

QEMU 最好装 7.0.0 版本的, 从源码编译安装的话需要注意一下依赖, 部分发行版的依赖可以在 Running 64- and 32-bit RISC-V Linux on QEMU — RISC-V - Getting Started Guide 找到

Arch Linux 仓库里是 QEMU 9, 需要修改一下 RustSBI 的版本. 注意如果你想直接 downgrade7.0.0 的话可能会需要连带降级一些非常核心的软件包, 非常不建议尝试. 有需要也可以自行寻找依赖包然后从源代码编译, 但是有一些接口变动可能会导致编译失败, 所以最佳方案还是替换 RustSBI 版本, 这里不再赘述.

构建一个能跑但仅仅能跑的操作系统

根据 OSTEP 的说法, 操作系统的主要三个任务部分在于: 虚拟化, 并发, 可持久化

  • 虚拟化主要表现在:
    • 对内存的抽象: 每个进程有自己的虚拟地址空间, 造成每个进程独占一个主存的假象(学过 CSAPP 可以回忆一下第九章, 博客还在补)
    • 对 CPU 的虚拟化: 主要表现在操作系统内核对各个任务的调度, 使得每个任务产生独占 CPU 的假象(这就是一种并发)
    • 对外设设备的虚拟化等等
  • 并发主要表现在:
    • 进程概念的抽象和实现, 进程间通信
    • 多线程的实现
  • 可持久化主要涉及文件系统

而形式上, 操作系统是一个二进制文件或二进制文件镜像, 被 bootloader 加载进内存的特定位置, 驻留在内存中的特定代码, 这些代码负责一些加载应用程序(简单来说就是把可执行文件加载到内存), 管理资源(设备/文件)并提供访问的任务, 这些任务以系统调用(syscall)的形式暴露给应用程序, 只是系统调用函数比较敏感特殊, 下面会仔细介绍.

那么我们的任务就比较明确了:

  • 先设计一个基本的能把应用程序加载到内存的功能 (当然因为现在内核没有任何调度能力也没有让应用程序启动其他应用程序的必要(这依赖进程的实现), 所以我们暂时不需要设计 execve 系统调用)
  • 实现标准输出能力 (实际上标准输出就是调用系统调用 write, 目标为 1 (标准输入))
  • 实现退出程序的能力 (exit 系统调用)
Read more »