0%

一阶段

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

二阶段

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

三阶段 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 »

Rust速查表:Rust Language Cheat Sheet

Rust是什么

  • Rust可看作一个在语法层面(编译时)具有严格检查和限制的C语言上位

  • 扩展了面向对象的便捷方法绑定。

  • 编译和运行方式类似于C/C++,可以rustc xxx.rs编译,./xxx运行。

  • 有约定的项目目录格式,可使用Cargo配置toml进行包管理、编译、运行、测试等等。

  • 包资源网站为CratesIO,见src↑

  • 不支持运算符重载,支持多态。其中语句为:表达式+;,语句的值是()

    运算符重载是指为自定义的类或结构体重新定义或赋予运算符(如+、-、*、/等)新的功能。它允许程序员对已有运算符赋予多重含义,使同一运算符作用于不同类型的数据时执行不同的操作。

    特点:

    1. 扩展运算符功能:让运算符不仅能作用于基本数据类型,也能作用于自定义类型
    2. 语法简洁:使自定义类型的操作像基本类型一样自然
    3. 保持直观性:重载后的运算符功能应与原意相符,避免滥用

Rust安装

可使用编译器:VSCode + rust-analyzer,VIM,RustRover

image-20250403110554181

1
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

我最终选择使用rustRover来编译——jetBrain全家桶之一,与Pycham同源使用会比较顺手,且可以直接连接WSL:

image-20250403110943092

Rust基础

rust的编译与运行是分开的,是一种预编译静态类型语言。rust的源文件为xx.rs,最基础的编译为使用rustc xx.rs对代码进行编译。

基础语法

Cargo

Cargo是Rust的构建系统和包管理器:

  • 构建代码
  • 下载依赖库(代码所需的库叫做依赖)

1.使用Cargo构建项目

1
2
$ cargo new the_project
$ cd the_project

在Linux终端输入上面的代码创建rust项目后,会生成如下的文件与目录:

image-20250410180737109

变量

首先必须说明,Rust 是强类型语言,但具有自动判断变量类型的能力。这很容易让人与弱类型语言产生混淆。

默认情况下,Rust 中的变量是不可变的,除非使用 mut 关键字声明为可变变量。

1
2
let a = 123;       // 不可变变量
let mut b = 10; // 可变变量

如果要声明变量,需要使用 let 关键字。例如:

1
let a = 123;

变量和常量还是有区别的。在 Rust 中,以下程序是合法的:

1
2
let a = 123;   // 可以编译,但可能有警告,因为该变量没有被使用
let a = 456;

但是如果 a 是常量就不合法:

1
2
const a: i32 = 123;
let a = 456;

控制流

if 表达式

实例:

1
2
3
4
5
6
let number = 7;
if number < 5 {
println!("小于 5");
} else {
println!("大于等于 5");
}

loop 循环: loop 是 Rust 中的无限循环,可以使用 break 退出循环。

实例

1
2
3
4
5
6
7
let mut counter = 0;
loop {
counter += 1;
if counter == 10 {
break;
}
}

while 循环

1
2
3
4
5
let mut number = 3;
while number != 0 {
println!("{}!", number);
number -= 1;
}

for 循环

1
2
3
for number in 1..4 {
println!("{}!", number);
}

结构体 (Structs)

结构体用于创建自定义类型,字段可以包含多种数据类型。

实例

1
2
3
4
5
6
7
8
9
10
11
12
13
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}

let user1 = User {
username: String::from("someusername"),
email: String::from("someone@example.com"),
sign_in_count: 1,
active: true,
};

枚举 (Enums)

枚举允许定义可能的几种数据类型中的一种。

1
2
3
4
5
6
7
enum IpAddrKind {
V4,
V6,
}

let four = IpAddrKind::V4;
let six = IpAddrKind::V6;

模式匹配 (match)

match 是 Rust 中强大的控制流工具,类似于 switch 语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}

错误处理

Rust 有两种主要的错误处理方式:Result<T, E>Option

Result:

1
2
3
4
5
6
7
8
9
10
11
12
enum Result<T, E> {
Ok(T),
Err(E),
}

fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err(String::from("Division by zero"))
} else {
Ok(a / b)
}
}

Option:

1
2
3
4
5
6
7
fn get_element(index: usize, vec: &Vec<i32>) -> Option<i32> {
if index < vec.len() {
Some(vec[index])
} else {
None
}
}

所有权与借用的生命周期

Rust 使用生命周期来确保引用的有效性。生命周期标注用 ‘a 等来表示,但常见的情况下,编译器会自动推导。

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

重影(Shadowing)

重影的概念与其他面向对象语言里的”重写”(Override)或”重载”(Overload)是不一样的。重影就是刚才讲述的所谓”重新绑定”,之所以加引号就是为了在没有介绍这个概念的时候代替一下概念。

重影就是指变量的名称可以被重新使用的机制:

1
2
3
4
5
6
fn main() {
let x = 5;
let x = x + 1;
let x = x * 2;
println!("The value of x is: {}", x);
}

数据类型

整数型(Integer)

整数型简称整型,按照比特位长度和有无符号分为以下种类:

位长度 有符号 无符号
8-bit i8 u8
16-bit i16 u16
32-bit i32 u32
64-bit i64 u64
128-bit i128 u128
arch isize usize

isize 和 usize 两种整数类型是用来衡量数据大小的,它们的位长度取决于所运行的目标平台,如果是 32 位架构的处理器将使用 32 位位长度整型。

浮点数型(Floating-Point)

Rust 与其它语言一样支持 32 位浮点数(f32)和 64 位浮点数(f64)。默认情况下,64.0 将表示 64 位浮点数,因为现代计算机处理器对两种浮点数计算的速度几乎相同,但 64 位浮点数精度更高。

1
2
3
4
fn main() {
let x = 2.0; *// f64*
let y: f32 = 3.0; *// f32*
}

布尔型

布尔型用 bool 表示,值只能为 true 或 false。

字符型

字符型用 char 表示。

Rust的 char 类型大小为 4 个字节,代表 Unicode标量值,这意味着它可以支持中文,日文和韩文字符等非英文字符甚至表情符号和零宽度空格在 Rust 中都是有效的 char 值。

注释

1
2
3
4
5
6
7
8
9
// 这是第一种注释方式

/* 这是第二种注释方式 */

/*
* 多行注释
* 多行注释
* 多行注释
*/

函数

1
fn <函数名> ( <参数> ) <函数体>

函数参数

Rust 中定义函数如果需要具备参数必须声明参数名称和类型:

1
2
3
4
5
6
7
8
fn main() {
another_function(5, 6);
}

fn another_function(x: i32, y: i32) {
println!("x 的值为 : {}", x);
println!("y 的值为 : {}", y);
}

函数返回值

在函数体中,随时都可以以 return 关键字结束函数运行并返回一个类型合适的值。这也是最接近大多数开发者经验的做法:

1
2
3
fn add(a: i32, b: i32) -> i32 {
return a + b;
}

但是 Rust 不支持自动返回值类型判断!如果没有明确声明函数返回值的类型,函数将被认为是”纯过程”,不允许产生返回值,return 后面不能有返回值表达式。这样做的目的是为了让公开的函数能够形成可见的公报。

Rust特色

所有权

Rust 中的所有权是独特的内存管理机制,核心概念包括所有权 (ownership)、借用 (borrowing) 和引用 (reference)。

所有权规则:

  • Rust 中的每个值都有一个所有者。
  • 每个值在任意时刻只能有一个所有者。
  • 当所有者超出作用域时,值会被删除。
1
2
3
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权被转移给了 s2
// println!("{}", s1); // 此处编译会报错,因为 s1 已不再拥有该值

借用和引用: 借用允许引用数据而不获取所有权,通过 & 符号实现。

1
2
3
4
5
6
7
8
9
fn main() {
let s = String::from("hello");
let len = calculate_length(&s); // 借用
println!("The length of '{}' is {}.", s, len);
}

fn calculate_length(s: &String) -> usize {
s.len()
}

组织管理

Rust 中有三个重要的组织概念:箱、包、模块。

箱(Crate)

“箱”是二进制程序文件或者库文件,存在于”包”中。

“箱”是树状结构的,它的树根是编译器开始运行时编译的源文件所编译的程序。

注意:”二进制程序文件”不一定是”二进制可执行文件”,只能确定是是包含目标机器语言的文件,文件格式随编译环境的不同而不同。

包(Package)

当我们使用 Cargo 执行 new 命令创建 Rust 工程时,工程目录下会建立一个 Cargo.toml 文件。工程的实质就是一个包,包必须由一个 Cargo.toml 文件来管理,该文件描述了包的基本信息以及依赖项。

一个包最多包含一个库”箱”,可以包含任意数量的二进制”箱”,但是至少包含一个”箱”(不管是库还是二进制”箱”)。

当使用 cargo new 命令创建完包之后,src 目录下会生成一个 main.rs 源文件,Cargo 默认这个文件为二进制箱的根,编译之后的二进制箱将与包名相同。

模块(Module)

对于一个软件工程来说,我们往往按照所使用的编程语言的组织规范来进行组织,组织模块的主要结构往往是树。Java 组织功能模块的主要单位是类,而 JavaScript 组织模块的主要方式是 function。

这些先进的语言的组织单位可以层层包含,就像文件系统的目录结构一样。Rust 中的组织单位是模块(Module)。

Rust 宏(Macros)是一种在编译时生成代码的强大工具,它允许你在编写代码时创建自定义语法扩展。

宏(Macro)是一种在代码中进行元编程(Metaprogramming)的技术,它允许在编译时生成代码,宏可以帮助简化代码,提高代码的可读性和可维护性,同时允许开发者在编译时执行一些代码生成的操作。

宏在 Rust 中有两种类型:声明式宏(Declarative Macros)和过程宏(Procedural Macros)。

本文主要介绍声明式宏。

宏的定义

在 Rust 中,使用 macro_rules! 关键字来定义声明式宏。

1
2
3
4
5
6
7
macro_rules! my_macro {
// 模式匹配和展开
($arg:expr) => {
// 生成的代码
// 使用 $arg 来代替匹配到的表达式
};
}

声明式宏使用 macro_rules! 关键字进行定义,它们被称为 “macro_rules” 宏。这种宏的定义是基于模式匹配的,可以匹配代码的结构并根据匹配的模式生成相应的代码。这样的宏在不引入新的语法结构的情况下,可以用来简化一些通用的代码模式。

注意:

  • 模式匹配:宏通过模式匹配来匹配传递给宏的代码片段,模式是宏规则的左侧部分,用于捕获不同的代码结构。
  • 规则:宏规则是一组由 $ 引导的模式和相应的展开代码,规则由分号分隔。
  • 宏的展开:当宏被调用时,匹配的模式将被替换为相应的展开代码,展开代码是宏规则的右侧部分。
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
*// 宏的定义*
macro_rules! vec {
*// 基本情况,空的情况*
() => {
Vec::new()
};

*// 递归情况,带有元素的情况*
($($element:expr),+ $(,)?) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($element);
)+
temp_vec
}
};
}

fn main() {
*// 调用宏*
let my_vec = vec![1, 2, 3];
println!("{:?}", my_vec); *// 输出: [1, 2, 3]*

let empty_vec = vec![];
println!("{:?}", empty_vec); *// 输出: []*
}

在这个例子中,vec! 宏使用了模式匹配,以及 $($element:expr),+ $(,)?) 这样的语法来捕获传递给宏的元素,并用它们创建一个 Vec。

注意,$(,)?) 用于处理末尾的逗号,使得在不同的使用情境下都能正常工作。

过程宏(Procedural Macros)

过程宏是一种更为灵活和强大的宏,允许在编译时通过自定义代码生成过程来操作抽象语法树(AST)。过程宏在功能上更接近于函数,但是它们在编写和使用上更加复杂。

过程宏的类型:

  • 派生宏(Derive Macros):用于自动实现trait(比如CopyDebug)的宏。
  • 属性宏(Attribute Macros):用于在声明上附加额外的元数据,如#[derive(Debug)]

过程宏的实现通常需要使用 proc_macro 库提供的功能,例如 TokenStream 和 TokenTree,以便更直接地操纵源代码。

智能指针

智能指针(Smart pointers)是一种在 Rust 中常见的数据结构,它们提供了额外的功能和安全性保证,以帮助管理内存和数据。

在 Rust 中,智能指针是一种封装了对动态分配内存的所有权和生命周期管理的数据类型。

智能指针通常封装了一个原始指针,并提供了一些额外的功能,比如引用计数、所有权转移、生命周期管理等。

在 Rust 中,标准库提供了几种常见的智能指针类型,例如 Box、Rc、Arc 和 RefCell。

智能指针的使用场景:

  • 当需要在堆上分配内存时,使用 Box<T>
  • 当需要多处共享所有权时,使用 Rc<T>Arc<T>
  • 当需要内部可变性时,使用 RefCell<T>
  • 当需要线程安全的共享所有权时,使用 Arc<T>
  • 当需要互斥访问数据时,使用 Mutex<T>
  • 当需要读取-写入访问数据时,使用 RwLock<T>
  • 当需要解决循环引用问题时,使用 Weak<T>

错误处理

Rust 有一套独特的处理异常情况的机制,它并不像其它语言中的 try 机制那样简单。

首先,程序中一般会出现两种错误:可恢复错误和不可恢复错误。

可恢复错误的典型案例是文件访问错误,如果访问一个文件失败,有可能是因为它正在被占用,是正常的,我们可以通过等待来解决。

但还有一种错误是由编程中无法解决的逻辑错误导致的,例如访问数组末尾以外的位置。

大多数编程语言不区分这两种错误,并用 Exception (异常)类来表示错误。在 Rust 中没有 Exception。

对于可恢复错误用 Result<T, E> 类来处理,对于不可恢复错误使用 panic! 宏来处理。

Read more »

一、前言

通过阅读实验指导书,我跟着操作系统的发展历程,学习了批处理系统、多道程序与分时多任务、虚拟地址空间、进程管理、文件系统、进程通信和并发等核心概念,并对rCore的实现有了更深入的认识。以下是我对这两周学习过程的总结。

二、学习内容

  1. 搭建执行环境:

    学习了平台与目标三元组,理解了应用程序执行环境。

  2. 批处理系统:

    学习了批处理系统的基本原理,包括作业调度、作业执行过程。

  3. 多道程序与分时多任务:

    掌握了多道程序设计的基本概念,以及如何实现分时多任务调度。

  4. 虚拟地址空间:

    理解了虚拟内存的概念,包括页表、地址映射。

  5. 进程管理:

    学习了进程的管理、调度,更加深入的理解了fork+exec。

  6. 文件系统:

    掌握了文件系统的基本结构,包括目录、文件。

  7. 进程通信:

    学习了父子进程之间的通信机制——管道。

  8. 并发:

    学习了线程管理机制的设计与实现,理解了同步互斥的实现,包括锁、信号量、条件变量。

三、学习心得

第二次学习rCore,加之前段时间学习xv6的经历,对rCore有了更深入的认识,包括trap的过程、地址空间的切换等,和群里同学的讨论也加深了我对代码的理解。

通过学习rCore,我对操作系统的原理有了更深入的理解,虽然考研的时候较为系统的学习了操作系统的知识,但是基本上还是停留在理论知识方面。这次rCore学习之旅,我获取PCB对进程进行操作、实现课本上学习过的系统调用、深入汇编代码理解什么是 ‘陷入’ ,让我对操作系统的设计理念、计算机的体系结构有了具象化的认识。

在学习过程中,我也遇到了许多挑战,解决了旧问题又出现了新问题,对操作系统有了更深入的认识反而产生了更多的问题,但是我相信计算机没有魔法,多查资料多看源码一定能把疑惑解开。

两周的rCore学习之旅让我受益匪浅。通过学习rCore,我对操作系统的设计和实现有了更深刻的认识,同时也提升了我的编程技能。我相信,这些知识和经验将对我未来的学习和职业发展产生积极影响。

一、前言

通读了一遍《Rust程序设计语言》书籍并完成了训练营的Rustlings练习。经过两周的学习,我对Rust有了初步的认识和掌握。以下是我对这两周学习过程的总结。

二、学习内容

  1. Rust基础知识
  • Rust编程语言的基本语法,包括变量、数据类型、运算符、控制流等。
  • Rust的所有权系统,包括所有权、借用、生命周期等概念。
  1. Rustlings练习
  • 通过完成一系列练习,巩固对Rust基础知识的理解和应用。
  • 练习涵盖了Rust的所有权、借用、生命周期、错误处理、宏、模式匹配等方面的内容。

三、学习心得

这一阶段印象最深的还是最后的算法部份,尤其是前两道关于链表的题目,其实之前一直是使用c++做算法题,对链表也较为熟悉了,但是由于对rust的特性不熟悉以及对链表没有深刻理解,让我有一种有力使不出的感觉,后面通过阅读题目的框架,以及对书本知识的巩固,终于是对rust中的链表有了初步的认识,写完链表的题目后,后续的题目也很快完成了。rust语言的特性让我对编程和计算机有了更深的理解,尽管现在还是写得磕磕绊绊,但是相信通过不断学习和时间,将来我也能够编写出优秀的rust代码。

与同学们互相讨论

在本次学习过程中,我对Rust异步编程中的一些核心挑战进行了深入思考:

1. 函数着色和异步Drop的挑战

目前Rust异步编程中最棘手的问题是函数着色和async drop机制。特别是在资源管理方面,由于future是基于poll的机制,在处理background task和事务时面临两个主要场景的挑战:

  • 结构体持有background task(如连接或stream)时的资源释放
  • 需要uncancelable语义的事务处理场景

2. 资源安全释放的困境

在系统信号处理方面存在显著挑战。特别是在进程接收信号时的安全资源释放问题仍然没有完善的解决方案。虽然对于使用事务的SQL系统影响相对较小,但在文件系统或WAL文件等场景中,这个问题尤为突出。

3. 互斥锁的使用策略

在异步编程中,关于互斥锁的选择需要特别注意。Tokio提供了一个重要的见解:在异步代码中使用标准库的普通Mutex通常是可行且推荐的。异步互斥锁的主要优势在于能够在.await点保持锁定状态,但这也使其比阻塞式互斥锁更昂贵。因此,对于单纯的数据访问,使用标准库的阻塞式互斥锁是更合适的选择。

4. 操作系统层面的应用

在操作系统内核中,协程的应用主要可以考虑用于替代传统的自旋锁场景,而io_uring的引入则可能带来更广泛的应用空间。

5. 异步编程模型的适用场景

从资源管理的角度来看,异步编程模型在不同场景下有其独特的优势和局限。特别是在处理IO密集型任务时,异步模型能够提供更好的性能和资源利用率。

第一周:

  • 复习并重新学习WASM上的async runtime相关知识
  • 计划完善OS仓库,准备移植文件系统和SMP多核启动

第二周:

  • 阅读Rust语言相关RFC文档:
    • async_trait与async fn in trait
    • Object Safety
    • pin_project_lite
  • 为内核态编写async runtime
  • 开始实现文件系统与block device
  • 计划将async runtime合并到OS仓库中

第三周:

  • 因家人住院需要陪护,本周暂无工作产出

概要设计

众所周知,操作系统内核对性能和时延的需求都是非常高的。当应用程序通过传统系统调用使用操作系统能力时,CPU需要经过两次特权模式转换、用户栈的保存与恢复等操作,这会消耗数十甚至数百CPU时钟周期。因此,在操作系统内核中引入异步协程机制时,不应增加这些基础开销。

在应用程序通过操作系统与外部设备交互时,由于大多数外部设备的性能都慢于CPU执行速度,且受限于信号传输延迟,响应延迟是不可避免的。操作系统在等待外部响应时采用自旋等待是非常低效的。因此,我的设想是在IO部分引入协程机制,通过用户进程的IO操作与内核线程通信的方式,为内核代码赋予异步能力。

在二阶段学习rCoreOS时,我发现其中与virtIO block-device的交互部分非常适合进行异步协程改造。

然而,我不赞成将所有内核代码和锁都替换为异步协程版本。如前所述,操作系统内核代码对性能非常敏感。虽然无栈协程的切换开销较小,但在内核开发中这种开销仍然不容忽视。此外,由于CPU大部分时间都会交由用户线程执行以最大化资源利用率,载体线程(CPU核心)会尽可能运行用户代码,这在某种程度上违反了协程使用原则,可能导致其他任务出现饥饿现象。

总结

四阶段开始前, 我曾立下这个目标: 希望能开发出我自己的操作系统内核, 并在 Lichee Pi 4A 真机设备上成功启动.

无奈上周夫人孕期遭遇大出血, 紧急住院保胎一周. 我作为陪护家属, 暂停了一周的学习与工作照顾夫人. 故未能完成四阶段开始前立下的目标😔.

前言

在阶段4我进入了项目四:基于协程异步机制的操作系统,由于之前缺乏对相关知识的了解,前期花了大量时间来阅读源码和理解,最后才实现了在OS中boot了一个简单的的异步executor。

async keyword

在 Rust 中,使用 async 关键字修饰的函数会返回一个实现了 Future trait 的匿名类型

1
2
3
4
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}

例如

1
2
3
async fn my_async_function() -> u32 {
42
}

编译器会将其转换为类似以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn my_async_function() -> impl Future<Output = u32> {
struct MyAsyncFunction {
// 异步函数的状态
}

impl Future for MyAsyncFunction {
type Output = u32;

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// 异步函数的执行逻辑
Poll::Ready(42)
}
}

MyAsyncFunction {
// 初始化异步函数的状态
}
}

这种设计允许编译器生成最优化的异步执行代码,同时提供了灵活性和类型安全性。

当然,对于rust的异步编程还有许多深入的概念,例如关于自引用的 Pin<> , 关于优化轮询的 Waker 等。

why async-OS

所以为什么要实现一个基于协程异步机制的操作系统呢?答案当然是为了并发的性能。内核可以通过轻量的内核线程和优化的异步调度执行来提升对系统调用的批处理速度。

参考async-module关于系统调用的优化,存在两种方向:

  1. 减少由于系统调用导致的特权级以及上下文切换开销
  2. 异步批处理

在高并发场景下,使用类似dpdk/spdk等通过用户态轮询完全绕过内核是可行的,但是如果仍然使用系统调用,那么当应用通过系统调用同步地进入内核态时,内核就可以对这些系统调用进行异步批处理,从而提升性能。
并且异步调度的 poll / wake 机制更适合设备驱动的工作状态。

design

考虑一种简单的情形,在OS初始化阶段,把栈初始化之后,直接开始运行全局的executor,负责对内核中的异步协程进行调度。这样,我们所有的系统调用都可以写成async的形式。

那么,当用户程序需要调用系统调用时,会先同步的进入内核态并设置scause寄存器的值来指定系统调用号,然后调用syscall之后await,在系统调用执行完之后再切换回用户态。所以这里的syscall api实现了一个异步和同步切换的过程。
当然另一种思路是,通过内核向用户态发送通知或者共享内存等方式,实现完全的异步系统调用,这里不再讨论。

implement

所以,我们在内核态需要建立自己的异步运行时,在抽象上的第一个问题是,Rust 欠缺对 async-trait 的支持。

例如

1
2
3
pub trait Mutex {
async fn wait(&mut self) -> FutexWait;
}

Rust 编译器默认不支持 async trait function。编译器提示说使用 async-trait 这个 crate。可惜的是,这个 crate 不是零开销的, 会将返回值改写成 Box 的形式。

还是继续考虑对futex的实现。我们既然想让对互斥锁的wait支持异步,那么就先实现一个 Future。

1
2
3
4
5
6
7
8
9
10
11
12
13
impl Future for FutexWait {
type Output = ();

fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = &mut *self;
loop {
match this.queue.poll(&mut this.id, cx.waker()) {
Ok(poll) => break poll,
Err(queue) => this.queue = queue,
}
}
}
}

如果可以从互斥锁队列queue中拿到结果,那么就返回poll, 否则就对等待队列进行更新并继续循环。

接下来考虑进程的执行单元task,Task 结构体包含了任务的各种属性,如可执行文件、父任务、子任务、任务 ID、时间信息、信号相关的字段等。如果我们想要把task交给executor执行,就需要为task的返回值实现 Future 。

也就是说,我们把task的返回值当成 Output 以实现一个 TaskFut:Future 的结构体 , 接着将这个 task 封装成一个异步的 loop 传入 executor 中。 在 loop 中 ,我们通过 trap 切换回用户空间 , 并且捕获用户空间的中断和异常 , 在切换回内核空间之后继续处理。

try

接着,在老师的指导下,我进行了将二阶段 rCore-tutorial 操作系统实现异步的尝试。

在 rust 的入口处,我们使用mm::init()来使用HEAP_ALLOCATOR来初始化堆内存,接下来我们就可以直接在堆内存上建立executor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#[no_mangle]
/// the rust entry-point of os
pub fn rust_main() -> ! {
clear_bss();
println!("[kernel] Hello, world!");
logging::init();
mm::init();
mm::remap_test();

let mut rt = RUNTIME.execlusive_access();
rt.spawn(task1)
rt.run()
}

async fn task1() {
println!("[kernel] task1: hello async world!");
}

这样就是最简单的内核态的async执行测试。