0%

先说说为什么会参加这个训练营。

起因是在配置arch linux的过程中用到了很多rust写的工具,而且这些工具用起来非常顺手,引起了我对rust的兴趣。在看完《Rust 程序设计语言 中文版》后,我开始寻找一个rust相关的项目练手。正好在知乎上看到有人推荐rcore,加上之前写过xv6(s081),对操作系统有不小的兴趣,就这样开启了这段rocre之旅。

第一阶段

这一阶段主要是熟悉rust语言。

由于我有java、python的语言基础,这一阶段还是比较轻松的。
难点是在写链表时因为有所有权限制,要对node作多层包装。刚开始写时并不顺利,其他语言的基础成了障碍,总是会按python的写法往下写,导致所有权出问题。直到真正理解所有权的含义才写出正确的代码。

所有权是我第一次真切感受到rust和其他语言的不同,它把一些原来从未考虑的可能出现的数据竞争提前暴露出来,迫使编写者去思考数据关系,这种体验是从未有过的。

第二阶段

受益于xv6,第二阶段的实验基本没有卡点。这些实验让我深入理解了riscv的trap、页表等机制,重新梳理了操作系统的进程切换、文件系统等相关知识。

第三阶段

这一阶段的实验比第二阶段的实验要难一些,花费的时间也更多。

对于hashmap这个实验,我自己实现了一个固定buckets的hashmap,但看到blog中大家基本是引网上的包,感觉自己有些笨笨的,还是要多和群友交流。

后面在mmap这个实验卡了很久,一直在想怎么实现文件的lazy load和对应,最后是面向测例编程,直接文件内容读到内存中通过测例完事。

学习总结 - Blankke

rcore与arceOS可以理解成两种不同思路编写而来的内核,而内核基本原理上估计是不会有太大区别的。
抱着这样的想法,三阶段只给了3周的时间,我就直接上了。
因为内核赛的时候基本上是整个内核都写了一遍,除了文件系统是调ext4库了没有太管里面怎么实现的,其他的原理明白了做起来还是挺快的。
我不知道有什么荣誉准则要求,这话说起来其实挺不好的,但是我的准则是不管copy还是llm生成,只要我觉得下一次遇到一样的问题我能一眼看出来用这个方法解决,那我就觉得这个学习是有效的。
所以这次的arceOS学习我也是抱着这样的态度去做的,并且内核的学习实际上最重要的部分可能更是在于解决问题,也就是在针对特定问题的排查思路上。

rcore

rcore部分我本身没有记录很多,只记了一些学习rust的时候的笔记,毕竟rcore的代码量实在是太大了,想要全部理解需要花费大量时间。然后在完成练习的时候我还不知道要写blog,所以只有一点点感悟和笔记。

ch3

为什么在 TaskManager 中添加方法而不是直接返回 TaskControlBlock

这确实是 Rust 所有权系统的限制,与 C++ 有本质区别:
Rust 所有权问题:

1
2
3
4
5
6
// 这样的设计在 Rust 中是不可能的:
pub fn get_current_task_mut(&self) -> &mut TaskControlBlock {
let mut inner = self.inner.exclusive_access();
let current = inner.current_task;
&mut inner.tasks[current] // ❌ 编译错误!
}
  • inner 是一个临时变量,当函数返回时会被销毁
  • 返回的 &mut TaskControlBlock 引用了inner的内容
  • Rust 编译器检测到”悬垂引用”(dangling reference)问题

C++ vs Rust

1
2
3
4
5
// C++ 可以这样做(自己管理)
TaskControlBlock& TaskManager::getCurrentTask() {
auto lock = inner.lock();
return tasks[current_task]; // 返回引用,但锁可能已释放
}
1
2
3
4
5
6
7
8
9
// Rust 强制我们使用更安全的封装方法
impl TaskManager {
pub fn increment_current_syscall_count(&self, syscall_id: usize) {
let mut inner = self.inner.exclusive_access(); // 获取锁
let current = inner.current_task;
inner.tasks[current].increment_syscall_count(syscall_id);
// 锁在这里自动释放
}
}

Clone trait 和 new 方法的关系

  • Copy:浅拷贝,按位复制,用于简单类型(如整数)
  • Clone:深拷贝,可能涉及堆内存分配,用于复杂类型
    但添加 Vec<(usize, usize)> 后:
    1
    2
    3
    4
    5
    6
    pub struct TaskControlBlock {
    pub syscall_counts: Vec<(usize, usize)>, // Vec 不能实现 Copy
    }
    let vec1 = vec![1, 2, 3]; // 在堆上分配内存
    let vec2 = vec1; // 如果是 Copy,会有两个指针指向同一块内存
    // 当 vec1 和 vec2 都被销毁时,会导致 double free!
    原来的初始化方式不再适用:
    1
    2
    3
    4
    5
    // 旧代码 - 数组字面量初始化
    let tasks = [TaskControlBlock {
    task_cx: TaskContext::zero_init(),
    task_status: TaskStatus::UnInit,
    }; MAX_APP_NUM]; // ❌ 需要 Copy trait
    新的初始化方式:
    1
    2
    3
    4
    // 新代码 - 使用 core::array::from_fn
    let tasks: [TaskControlBlock; MAX_APP_NUM] = core::array::from_fn(|_| {
    TaskControlBlock::new(TaskContext::zero_init(), TaskStatus::UnInit)
    });

ch4

主要新建的函数有

1
2
3
4
5
6
7
8
9
/// Translate a user pointer to a mutable reference
pub fn translate_user_ptr<T>(ptr: *mut T) -> Option<&'static mut T> {
TASK_MANAGER.translate_user_ptr(ptr)
}

/// Translate a user pointer to a reference
pub fn translate_user_ptr_readonly<T>(ptr: *const T) -> Option<&'static T> {
TASK_MANAGER.translate_user_ptr_readonly(ptr)
}

内部使用页表进行翻译,获得的(可变)引用可以用unsafe的类指针操作直接修改内存。
mmap的实现与cpp的方法无异,只是对应的层级是task层,由taskmanager调用获取当前的task,当前的task使用mmap,所以mmap是task的类方法。

ArceOS

Unikernel

T1 print-with-color

这个看了一下,可以在log层打印,也可以直接改std。

1
2
3
4
5
6
arch = riscv64
platform = riscv64-qemu-virt
target = riscv64gc-unknown-none-elf
smp = 1
build_mode = release
log_level = warn

像这种信息就是在log层里打出来的,如果修改axlog模块的lib.rs,那么这些打印信息就会变色

1
2
3
4
5
6
7
8
9
10
11
12
13
/// axlog/lib.rs
/// Prints to the console, with a newline.
#[macro_export]
macro_rules! ax_println {
() => { $crate::ax_print!("\n") };
($($arg:tt)*) => {
$crate::__print_impl($crate::with_color!(
$crate::ColorCode::BrightGreen,
"{}\n",
format_args!($($arg)*)
));
}
}

但是根据题目要求,我们打印的那句话其实是axstd里面的,所以我其实只在这个macro.rs里添加了色号就可以了。

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!("\u{1B}[92m{}\u{1B}[m\n", format_args!($($arg)*)));
}
}

这是绿色

T2 support-hashmap

在axstd等组件中,支持collections::HashMap
先读了一下axstd,原本的情况是这样的

1
pub use alloc::{boxed, collections, format, string, vec};

这里有一个collections,是从alloc模块过来的,那么实际上是标准库里的(我认为就是内核环境不支持标准库的哈希表),所以要替换成一个自己实现的HashMap。
上网查了一下hashbrown是一个常用的哈希表实现(hashbrown n. 薯饼),所以添加了依赖,用这现成的模块。

1
2
axhal = { workspace = true }
hashbrown = { version = "0.14", default-features = false }

接下来就是在axstd/src里面添加一个collection.rs然后将对应使用过的函数都用hashbrown进行对应实现就可以了。注意new()一定需要有对应的实现否则报错找不到。

T3 bump-allocator

这个很简单。
当时内核赛的时候瞎装了一万个分配器到自己的内核中,经过痛苦的阅读代码后了解过buddy、slab、liballocator的分配原理,这个bump分配器简单看一下原理似乎是堆分配器。然后需要实现页分配以及细粒度的字节分配,也就是多层级的分配。那就跟linux的slab&buddy的做法差不多了。
代码中todo写的很明确,每一步需要干什么,不会漏掉隐秘的细节,不像当初写内核一样自己出一堆找不到的bug在后面回来找。

T4 rename-for-ramfs

ramfs就是一个最基础的文件系统,不需要回写,不需要驱动,基本上意思就是在内存里进行书写,关机后不会存下来,这个rename也不会再下次开机后保存下来。
学习正常的rename,以前从没看过底层的inode操作,都是直接调用ext_rename()就完工了,所以这次对照着加抄袭整了个版本。
明确这个操作是在ramfs模块下的就简单了,这是个结点操作,所以要在两个地方添加操作(这是我的做法),一个是impl VfsNodeOps for DirNode,一个是DirNode内部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// Renames a node from `src_name` to `dst_name` in this directory.
pub fn rename_node(&self, src_name: &str, dst_name: &str) -> VfsResult {
let mut children = self.children.write();

let node = children.get(src_name).ok_or(VfsError::NotFound)?.clone();

if children.contains_key(dst_name) {
return Err(VfsError::AlreadyExists);
}

// Remove from old name and insert with new name
children.remove(src_name);
children.insert(dst_name.into(), node);

Ok(())
}

fn rename(&self, src_path: &str, dst_path: &str) -> VfsResult {中可以照着别的函数写法形成模板,最后一步调用上面的rename就好了

Macro

宏内核部分比较熟悉也简单,就略写了。

T1 Page-fault

这也是老朋友了,这个提示很明显,在axhal/trap里面,很多异常的处理方法都写在其中了。

T2 mmap

这更是老朋友,xv6就做过这个实验,rcore也是有。根据posix标准从堆内存找到空闲位置,扩大堆空间。
这里评测环境错误很久没过差点以为是我的问题,所以自己添加了一个get_brk()函数,结果又在本地爆了,这个实验似乎就是让我们使用find_free_area就可以了,并没有按照posix标准去处理那么多flag,也没有匿名映射。
后面所以我又改成了最简单的版本

1
2
3
4
5
6
7
8
9
10
11
12
13
let start_addr = if addr.is_null() {
// Use find_free_area to find a suitable address
let hint = VirtAddr::from(0x10000000usize);
let limit = VirtAddrRange::from_start_size(aspace.base(), aspace.size());
aspace.find_free_area(hint, aligned_length, limit)
.ok_or(LinuxError::ENOMEM)?
} else {
VirtAddr::from(addr as usize)
};

// Map memory in user address space
aspace.map_alloc(start_addr, aligned_length, mapping_flags, true)
.map_err(|_| LinuxError::ENOMEM)?;

这属于有点ltp后遗症,写了linux的标准错误号。然后后面其实也处理了fd是-1且不是MAP_ANONYMOUS的情况。

Hypervisor

这个虚拟化有点超纲了,以前确实没见过这种虚拟机的做法。我想理解成用户进程,这样的话自己有一个cpu对象,用户进程的地址空间也是连续的,但是在内核中不连续。

T1 simple-hv

这个有两个退出原因(据悉是这样):
IllegalInstruction (非法指令异常)和 LoadGuestPageFault (Guest 页面错误),需要在vm_exit的时候判断这几次错误并处理。实际上操作的方法有点像写cpu,直接对epc等寄存器进行+4这样。这个错误原因估计还要下到trap模块才能判断,就是csr寄存器里会记录错误原因。
为了调试这几种原因,我先添了几句调试输出,没想到其实本来它的打印就是有输出的。
可以读到代码中期望的输出就是这两个寄存器要放正确的值,而这之前就不要有vmexit

1
2
3
4
5
6
7
8
let a0 = ctx.guest_regs.gprs.reg(A0);
let a1 = ctx.guest_regs.gprs.reg(A1);
ax_println!("a0 = {:#x}, a1 = {:#x}", a0, a1);
assert_eq!(a0, 0x6688);
assert_eq!(a1, 0x1234);
ax_println!("Shutdown vm normally!");
ctx.guest_regs.sepc += 4;
return true;

所以就在对应的错误处理处改0x6688和0x1234就可以,对应指令sepc+4跳过

T2 pflash

从上面为止练习题其实就做完了。说来非常惭愧,我参加训练营有点面向做题的学习,从rcore开始都是学习的目标就是做完所有练习题就收工了,觉得解决问题才是做这个训练营的精华。这个练习也是个示例,不用我做自己就是好的。所以后面学起来有点没有动力。
分析了一下运行指令,其实是先编译了u_3_0的内核,然后把内核的bin文件写进了disk.img里,最后make一个虚拟机出来。运行之后里面也是显示了两次ArceOS(虚拟机里运行了内核),然后从guest里面试图去读host
很好看的一点是在make u_3_0的时候直接编译,而运行虚拟机h_2_0的时候可以把log开成info,这样的话就可以看到那句 Starting virtualization…,以及是如何装载虚拟机到虚拟地址的。
内核赛的时候很惊讶,因为听说第一名的Starry Mix可以在里面运行xv6,让我直接震惊了。后面了解到StarryOS就是在arceOS基础上改的,现在我才知道原来就是基于了这样的Hypervisor模式,真的长知识了。

2025春操作系统训练营四阶段报告

第一阶段 - Rust编程

  • 这一阶段主要就是了解学习Rust的语法和特性, 以及完成所有的rustlings题目, 以及实现一些基础的数据结构和算法, 为之后的项目内容做准备.
  • 我对rust的语法已经比较熟悉了, 其实rustlings之前也做过一遍, 所以很快, 但数据结构和算法的rust实现有点忘了, 这部分花了点时间

第二阶段 - OS设计实现

  • 仓库连接: https://github.com/LearningOS/2025s-rcore-jizhaoqin
  • 这一阶段花了挺多时间, 因为一个完整的os内核内容和代码真的很多
  • chapter1 应用程序与基本执行环境
    • 这一届内容比较少, 主要是关于如何实现一个最小内核, 以及RustSBI使用的一些问题
  • chapter2 批处理系统:
    • 由于我以前没有接触过risc-v指令集和汇编语言, 所以在理解汇编指令, 链接脚本, 还有特权级的切换方面花了很多时间, 最后也是能够理解实验里给的代码都有什么作用
    • 另外, 我找了内容更多的v3版文档阅读前两章, 发现内容确实很详细, 但不适合做实验, 因为对我来说是在是太多了, 当个参考书挺好, 遇到camp文档疑惑的地方去查以下非常有用, 因为逻辑是一样的, 但是跟着v3文档一步一步写代码实在是很痛苦, 主要是进度太慢了, 没有反馈动力不足继续做下去了.
    • 所以后来我就主要看camp文档了, 而且不再一步一步跟着自己实现, 主要是在每一章的练习部分, 在实现的过程中再去仔细理解每一个模块的作用, 这样会好很多对我来说, 理解地也很快.
  • chapter3 多道程序与分时多任务
    • 这一章实现了简单的抢占式任务调度, 主要内容是程序上文的保存和切换
  • chapter4 地址空间:
    • 这一章内存虚拟化是非常重要的内容, 之后内核态和用户态就会在内存映射上隔离, 区分更明显了, 同时对上下文切换的汇编代码进行了一些补充修改
    • 这一章对多级页表的介绍其实不详细, 但是我以前实现过x86架构上一个简单的4级页表, 以及动态内存分配器, 所以整体理解没有问题, 但是需要了解更多关于SV39多级页表的一些细节
  • chapter5 进程及进程管理:
    • 介绍了进程的抽象的实现, 在这个实验里相对于内存虚拟化来说并不难理解
    • 通过进程实现经典的sys_fork()和sys_exec()系统调用
    • 而且从这一张开始有了一个简单的shell了
  • chapter6 文件系统与I/O重定向:
    • os的又一大关键功能, 提供文件抽象和接口
    • 这一章是目前为止最费劲的一章, 因为抽象层数太多了, 实现系统调用的时候非常容易搞乱, 而且到这里代码已经很多了, 层数也很深, 花了非常多的时间去理解调用的每一层都干了什么事, 但一些没有直接用到的API没有多看. 好在最后实现完成后, 本地测试一遍就通过了, 还是挺好的.
  • chapter7 进程间通信:
    • 主要实现进程间管道通信, 这里基于文件抽象来实现
    • 在shell里还实现了重定向符号>, <
  • chapter8
    • 简单的笔记:
      • 线程的用户态栈:确保在用户态的线程能正常执行函数调用;
      • 线程的内核态栈:确保线程陷入内核后能正常执行函数调用;
      • 线程的跳板页:确保线程能正确的进行用户态<–>内核态切换;
      • 线程上下文:即线程用到的寄存器信息,用于线程切换。
    • 线程抽象, 并发的要求, 锁的实现, (一般)信号量与实现, 条件变量
    • 这一节还好, 对锁还算比较了解, 然后互斥锁是2元信号量, 也比较好理解, 就是条件变量不熟悉, 因为结尾的练习似乎不太需要, 对这一部分也没有改动.

第三阶段 - 组件化操作系统

  • 仓库连接: https://github.com/LearningOS/2025s-arceos-jizhaoqin
  • [print_with_color]:
    • 可以使用ANSI转义序列, 修改终端输出的颜色
    • 可以在用户层println!, axstd的输出宏定义处, 或者axhal处修改putchar, 影响的范围也不同
  • [support_hashmap]:
    • 语言提供的alloccrate里中提供了一些常用的集合类型比如VecBTreeMap, 禁用标准库时只需要提供全局动态分配器就可以使用, 在Acreos里打开allocfeature就行
    • 但是HashMap除了分配器还需要提供随机数生成器, 所以不在alloccrate里需要自己实现.
  • [alt_alloc]:
    • 实现一个简单的分配器BumpAllocator
  • [ramfs_rename]:
    • 文件系统相关的实现和API
    • 对于rename来说, 其实就是在目录文件的数据块里删除一个条目, 同时新增一个条目, 但都指向相同的索引节点, 理论上也可以直接修改条目中的文件名, 但更复杂需要做出大量修改(因为现有条目储存用的是BTreeMap把文件名作为key不支持修改), 会引入额外的逻辑开销而且不会提升性能, 所以使用现有的删除和新增功能就好了
  • [sys_mmap]:
    • 同rCore里sys_mmap的实现类似
  • [simple_hv]:
    • hypervisor虚拟化相关的内容

第四阶段 - 项目三: 基于协程异步机制的操作系统/驱动

  • 仓库连接: https://github.com/jizhaoqin/arceos/tree/dev-async-irq
  • 报告ppt连接: https://docs.google.com/presentation/d/1VZuvpDa1Ot9joiWxl2y-eviw-mX34QQXLZYLFYfCR1c/edit?usp=sharing
  • 选题方向:
    • 主要目标是尝试对部分非实时中断异步化, 具体以uart串口通信为例, 实时意味着需要立即处理完毕, 非实时中断则不要求中断信息能马上处理完毕, 对这种中断我们可以将其放在后台运行而不阻塞当前逻辑, 比较适合将其转化为异步任务进行处理.
    • 需要注意的是, 我们一般要求所有中断都要求立即返回, 但这并不意味着中断已经处理完毕, 比如网络包下载, 或者高负载串口通信, 有些信息处理比较耗时, 这时为了快速结束中断, 我们可以将未经处理的数据放入缓冲区队列, 然后在结束中断前通知异步任务进行处理.
    • 另外由于目前Arceos对几乎所有外部设备都采用轮询方式, 所以在异步化之前, 先要将其改造为基于中断的方式.
  • 过程:
    • 架构和具体目标: aarch64 qemu virt platform的uart中断异步改造.
    • 首先在axhal中给对应platform注册uart中断并启用, qemu将terminal的用户输入模拟为串口通信, 经测试arceos能够按照预期以中断的方式接收串口信号.
      • 这一部分的难点在与梳理清楚arceos的中断架构, 注册和调用流程, 以及各架构axhal, axruntime, axstd, arceos_api之间以及内部的代码和依赖结构.
    • 之后尝试异步改造uart的中断处理函数. 如之前提到的, 我们将中断处理分为两个部分:
      • 第一部分是同步的, 需要原封不动地接受所有信息并将其推送到缓冲区队列, 并发送信号表明有数据需要处理.
        • 这一部分的难点在于如果处理程序是异步的, 我们如何从同步的中断处理函数中发出合适的通知信号.
      • 第二部分是异步的, 为数据处理程序, 在这里我们采取异步的方式, 由一个异步运行时维护有多个中断第二部分数据处理程序的队列(由于目前只注册了一个中断, 队列中只有uart中断的异步处理任务), 并进行调度, 与内核线程调度不同的是, 这些处理任务都在同一个线程中.
        • 这一部分的难点在与, 如何构建一个非标准库环境下的异步执行器, 来轮询和调度这些异步任务.
  • 成果:
    • 最终完成了aarch64 uart的中断注册,
    • 实现了中断处理函数的异步改造,
    • 构建一个内核异步运行时并进行调试, 以完成异步中断处理的执行和调度工作.
  • 未来可能的方向:
    • 整理代码结构, 符合arceos的规范, 形成良好的文档
    • 尝试兼容更多中断类型, 用统一的异步中断处理异步运行时处理更多类型的非实时中断.
    • 兼容更多架构
    • 优化异步运行时的调度逻辑, 以及实现优先级调度等功能.
    • 将异步运行时替换为更成熟的embassy
  • 实现过程中遇到的困难:
    • 尝试注册键盘中断进行测试, 发现需要开启qemu graphic实现显示设备驱动, 而且兼容性差, 所以不搞键盘中断了, 直接搞串口中断, 目前arceos的实现都是轮询;
    • x86_64 qemu q35 平台没查到COM1 uart的中断向量, 导致一直没能成功注册中断, 而且x86的x2apic架构比较复杂, 执行了irq映射难梳理, 最后花了很多时间也没搞明白中断向量到底是啥;
    • 后来转向aarch64, 成功注册uart中断, 并测试表现良好符合预期;
    • 尝试异步化改造中断处理, 查看embassy的实现有些复杂不好拆解, 最后决定从头手写一个简单的异步执行器, 花了好大力气才搞定, 测试能工作;
    • 尝试把异步运行时和中断处理结合起来的时候总是有交叉依赖的问题, 最后把异步任务通知逻辑和执行逻辑分别放在axhalaxruntime, 才最终解决交叉依赖.
    • 执行器阻塞线程不主动yield, 直到一个周期后被抢占才切换到其他线程, 而其他线程正常yield, 这使得执行器线程占用了几乎所有CPU时间
    • 最后又更改了中断处理流程, 添加了两个缓冲区才最终把逻辑跑通
  • 其他:
    • 为较大项目添加特性是一件很困难的事, 除了考虑本特性的实现, 还要嵌合进整个项目的组织框架和编译逻辑中, 不敢想象如果没有好的代码架构, 抽象以及解耦, 可以想象越到后来, 最终将达到一个极限, 使得这一工作几乎不可能完成.

异步通信框架: evering

evering 是受 io_uring 启发的的异步通信框架,作为本文实现异步 IPC 的基石,它

  • 🦀 基于 Rust 异步模型
  • 🐧 基于共享内存
  • 📡 提供双向 SPSC 消息队列
  • 🎬 提供灵活的资源管理机制

evering 使用两个单生产者单消费者(SPSC)并发队列进行通信,

1
2
3
4
5
6
7
8
9
10
       Client                                     Server
| |
.------->| |<---------.
| | Request Queue | |
| (Send Request) -->-(3)->-(2)->-(1)->-- (Receive Response) |
| | | |
| | | |
| (Receive Response) --<-(1)-<-(2)-<-(3)-<-- (Send Request) |
| | Response Queue | |
'--------' '----------'

受限于 SPSC 通信,目前 evering 只能遵循 thread-per-core 模型,不同于 tokio、async-std 等使用 work-stealing 的运行时.在基于 thread-per-core 的异步模型中,每个线程有一个局部的运行时,不同线程之间保持最小的通信,甚至完全独立.而基于 work-stealing 的异步模型中,任何异步任务都可能由于运行时的调度而被“偷”到其他线程上运行.二者相比有以下异同:

  • 在 thread-per-core 模型中,异步任务不必担心同步问题,即在 Rust 中不需要使用诸如 impl Send + Future 的类型,这可以大大简化异步编码体验.而在 work-stealing 模型中,几乎所有的异步任务都需要注意线程之间的同步,这样难免会带来一些额外的开销,也使得异步编码更为繁琐^1
  • 正如 work-stealing 这名称所示,在此类模型中,运行时会根据程序实时状态对不同线程上的异步任务进行负载均衡,使得每个线程都不会陷入长时间的忙碌或空间.而对于 thread-per-core 模型,由于异步任务不能在多线程之间共享,当处理某个 CPU 密集型任务时,很可能因为长时间阻塞而导致该线程上的其他任务迟迟得不到处理,最终出现较高的响应延迟.因此 thread-per-core 不适合 I/O 密集混合 CPU 密集的场景.

实验性异步 IPC 模型: evering-ipc

evering-ipc 在 evering 的基础上,同时利用 Linux 的共享内存机制实现了异步 IPC.具体而言,要通信的两个进程使用以下所示的共享内存区进行同步:

1
2
3
4
5
6
7
.-----------------------------------------------------------------------------.
| | | | |
| [1] uring offsets | [2] allocator | [3] uring buffers | [4] free memory ... |
| ^ | | | ^ |
'-|-------------------------------------------------------------------------|-'
'-- start of the shared memory (page aligned) |
end of the shared memory --'
  • [1] 是整个共享内存区的起始,通过 mmap(2) 映射到地址空间中,因此是页对齐的.
  • [1] 中包含 evering 的两个通信队列的偏移量,基于这个偏移量配合 evering 提供的构造队列的接口,可以确保两个进程看到的状态是相同的.
  • [2] 中包含一个单线程的内存分配器,这个分配器通常由客户端使用.
  • [3] 是通信队列的实际所在位置.
  • [4] 是其余全部的空闲内存,这些内存由 [2] 中的分配器管理.

在 evering-ipc 中,uring 仅用于传递静态类型且通常仅有十几到几十个字节的消息.而对于更大的以及编译期间无法确定大小的数据,它利用共享的空闲内存来传递.具体而言,

  1. 客户端获取内存分配器,从中分配合适的内存块,并写入请求体作为待传递数据
  2. 客户端将该内存块指针以及其他信息构造成请求头作为消息写入请求队列.
  3. 服务端接收并解析请求,得到请求头请求体
  4. 服务端处理请求,将处理结果构造响应头作为消息写入响应队列.
  5. 客户端收到响应,检查结果并执行后续流程.

当服务端同样也需要传响应体作为数据时,有多种思路可以实现:

  1. 将分配器加锁使它能同时在服务端和客户端使用.对于请求频率特别高的场景,锁可能会降低整体性能.
  2. 增加一个分配器供服务端使用.对于空闲内存分配比例的设定可能不容易权衡.
  3. 结合前两个思路,使用两个分配器,但空闲内存加锁,根据程序运行状况动态调整分配给两方的空闲内存.
  4. 空闲内存完全由客户端管理,在请求时,客户端分配合适大小的内存块供服务端写入响应体.实际中,客户端可能需要预请求一次来确定响应体的大小.

evering-ipc 目前采用第 4 种思路.此外,不难发现,在 evering-ipc 中,数据都是通过指针进行传递的.而在程序运行时,共享内存通常被映射到不同的地址,因此实际传递的是数据块在共享内存中的偏移量.同时,共享内存区的起始地址都是页对齐的,这保证了所有基于相对偏移量得到的地址,即使在两个进程间不同,对齐也是一致的,从而满足 Rust 对不同类型内存布局的要求.

多种 IPC 方案的性能测试: ipc-benchmark

ipc-benchmark 针对多种 IPC 方案进行了关于通信延迟的性能测试,这些方案包括:

  • 基于 evering + memfd 的 IPC
  • 基于 shmipc + memfd 的 IPC
  • 基于 tokio + epoll + UDS 的 IPC
  • 基于 tokio + io_uring + UDS 的 IPC
  • 基于 monoio + io_uring + UDS 的 IPC

其中,

  • memfd 是 create_memfd(2) 创建的匿名内存.
  • UDS 是指 Unix Domain Socket.
  • shmipc 是基于共享内存的高性能 IPC 框架.
  • tokio 是基于 epoll(7) 和 work-stealing 模型的异步 I/O 运行时.
  • tokio-uring 为 tokio 提供了基于 io_uring 的异步 I/O 实现.
  • monoio 是基于 io_uring 和 thread-per-core 模型的异步 I/O 运行时.

对于每个框架我们采用如下测试方案:

  1. 启动两个线程作为客户端和服务端.
  2. 客户端写入 i32 的请求头以及大小为 buf_size 的请求体.
  3. 服务端接收并校验请求头和请求体,随后同样写入 i32 的响应头和 buf_size 大小的响应体.
  4. 客户端接收并校验响应头和响应体.

其中,buf_size 以字节为单位,由 4B 逐渐增长到 4M.

结果对比

详细的性能测试的结果见 https://github.com/loichyan/openoscamp-2025s/tree/ipc-benchmark,下面我们将对该结果进行简略的分析.

此图对比了完整的测试结果,其中 $x$ 轴是上述的 buf_size,而 $y$ 轴则是完成一次测试所需的平均时间.可以看到,随着数据大小的增长 evering 和 shmipc 明显优于其他三者,并且相对于表现最差的 tokio_epoll,二者分别有接近 50% 和 30% 的性能提升.

此图对比了前五个测试的结果,此时数据并不算大,都在通常范围之内.这里能发现相对于另外三者,evering 和 shmipc 都有超过 80% 的性能提升.

此图对比了中间五轮测试的结果,此时数据大小开始逐渐出现大幅度的增长.可以看到,除了 evering 和 shmipc 外的三者针对大块数据的传输并无明显差异.

此图对比了最后五轮测试的结果,此时数据大小已接近极端情况.这里能观察到与第一个对比图同样的结果.

测试结论

单从性能的角度来看,对于上述五种 IPC 方案,evering > shmipc >> monoio > tokio_uring > tokio_epoll

对比前两者,shmipc 支持 MPSC(多生产者多消费者)的通信方式,而本测试中仅使用 SPSC 的模型进行测试,因此无法发挥其完整的优势.另外,对共享内存处理的方式不同也可能导致了一些性能差异.而对于另外三者,由于使用 UDS 需要将数据在用户空间和内核空间来回拷贝,在面对大块数据时,这将大大降低整体性能.而对于极小的数据块,又由于系统调用等带来的开销,最终需要接近 10 倍的额外时间来完成测试.这一点可以在火焰图^2中体现:

evering (buf_size=4B) tokio-epoll (buf_size=4B)

此图中展示了在 4B 数据下,性能测试主体函数中各子过程的占比.其中,蓝色高亮部分是校验数据过程,用作参照.不难发现,evering 中主要时间都消耗在传递消息所需的多线程同步上了.而在 tokio-epoll 中则是多个与内核交互的函数调用占用主要时间.在后几轮测试中,当数据变得非常大时,这些消耗则变得无关紧要,此时的性能热点是数据传递引起的内存拷贝.下面的火焰图可以佐证:

evering (buf_size=4M) tokio-epoll (buf_size=4M)

此图与上面两个图相同,不过这里的数据大小是 4M.很明显,当数据非常大时,evering 中绝大部分时间用来初始化需要传递的数据,但传递的过程几乎不占用太多时间.而 tokio-epoll 中的情况更加复杂,除了拷贝数据以外,还花费了相当一大部分时间执行内存分配,这些内存用于放置从内核空间传递来的数据.

至于后三者的性能差别,我们猜测主要是由于:

  1. 设计架构不同,monoio 是单线程的 thread-per-core 模型,因此与我们的测试相性更好.
  2. 基于 io_uring 实现的异步 I/O 相对于基于 epoll 的,理论上会花费更少的时间在与内核交互上.并且测试中我们利用 io_uring_register(2) 做了进一步的优化,减少了传递小数据时内核处理的开销.

未完成的任务

让 evering 支持 MPSC

正如开头所述,evering 目前只实现了 SPSC 队列,受限于此,不能很好的处理复杂的应用场景.而实现 MPSC 队列之后,客户端就能更好的与 tokio 等运行时协同工作.要实现这个目标,主要是对 evering 的队列结构 RawUring 和操作驱动结构 Driver 进行多线程改造,并且尽可能要实现无锁的数据结构.对于 RawUring 的改造可以参考现有的并发队列实现.而 Driver 底层依赖于 slab::Slab 结构体,因此改造难度稍高.不过,目前 evering 已经支持使用固定大小的 Slab,基于这一点可以大幅降低多线程化的难度.

基于 evering 实现系统调用

evering-ipc 只适用于用户进程之间的通信,而本项目最初的目标是实现用户进程和内核的通信.对于这个目标,除了处理共享内存以外,可能还会遇到页表隔离的问题,以及用户进程意外退出后,对应内核异步任务的清理问题.更长远来看,当实现异步系统调用之后,那么可以将用户进程转彻底换成类似 Rust 中 Future 的无栈协程,从而将开销进一步降低.此外,迁移到异步系统调用时,不可避免的会破坏与现有同步代码的兼容性.因此,实现基于有栈绿色线程的异步系统调用也是一个值得尝试的目标.

基于Iouring的异步运行时

我首先选择完成的任务是基于iouring的用户态异步运行时,支持常见的文件和网络(Tcp)io。在我的实践经历来看,构建一个高性能的用户态异步运行时,就像是在应用程序内部再造了一个微型的操作系统,它接管了传统上由内核负责的部分调度和I/O管理职责,目的是为了消除内核/用户态切换的开销,并最大化I/O吞吐量。

为了实现的简单,我选择了thread-per-core 的任务调度模型。简单来说,thread-per-core可以理解为每个CPU核心分配一个独立的执行线程,每个线程(每个核心)都拥有自己的任务队列。当一个异步任务被提交时,它会被放入相应核心的任务队列中。这种设计有几个优势,一是减少竞争,由于每个线程操作自己的队列,线程间避免了共享锁的开销;二是缓存友好:任务和数据在特定核心上处理,能更好地利用CPU缓存,减少缓存失效,提高数据访问速度;三是不需要对每个任务有Send的限制。

IO接口的异步封装,负责与将os暴露的io接口改造成rust async/await异步语法的形式。传统的I/O模型(如 select, poll, epoll)虽然是非阻塞的,但它们本质上是“事件通知”模型——通知你有事件发生了,你再去读取数据。这依然涉及用户态和内核态之间的多次上下文切换。io_uring 则是提供了更为本质的异步io接口,一种全新的提交-完成模型————队列 (SQ):用户态应用程序将各种I/O操作(如文件读写、网络套接字的发送接收等)封装成请求,批量地放入一个共享的内核提交队列中;完成队列 (CQ):内核处理完这些I/O请求后,会将结果(成功与否、处理了多少字节等)批量地放入另一个共享的内核完成队列中。应用程序只需定期检查这个完成队列,就能得知哪些I/O操作已经完成,以及它们的结果。

无锁ringbuffer BBQ

为了进一步优化我们比赛的OS内核的任务队列,我选择参考BBQ paper实现的无锁ringbuffer,虽然最终性能并不理想,但实现该结构是一个很有趣的历程。大多数lock-free ringbuffer基于version+idx组成的 Atmoicusize 作为头尾指针,并通过loop + CAS方式更新头尾指针,version主要用于解决ABA问题;而BBQ通过将数组分块,头尾指针变为头尾块指针,并且在每个块的内部额外维护2个指针(allocated/reserved)以及2个计数(committed/consumed),一个显然的好处是头节点可以直接通过FAA指令获取分配位置。我对我实现的bbq进行了性能测试,目前实现的BBQ的性能表现非常糟糕,对比crossbeam-arrayqueue,尤其在SPMC、MPMC场景下吞吐差距在10倍以上甚至更多。并且我在实践中认为算法本身还有些边缘情形处理的问题,感兴趣的同学可移步讨论区。无锁的设计总是“危险”而精妙的,哪怕论文给出算法伪代码,实现的过程依然是相当曲折的,内存序的问题,aba问题,以及如何调整测试复现特定的bug,这个过程只有踩过坑才能知道痛。

os内核赛中组件化和异步化尝试

关于我们的比赛内核,我和我的队友在原先宏内核的基础上做了大量的改动,内容聚焦在组件化拆分以及异步化改造,前者主要集中在工作量上的庞大,如果确定好组件的依赖,如何设计出合适接口,这都需要仔细考量;异步化的改造客观来说工作量也很大,这是async传染性带来的必然,(如果重头构建一个异步内核可能相对好点),所以说目前我们为了必然大范围的传染性,会使用block_on语义的函数做一个暂时解决方案。异步os一个大的优势是不需要对每个task分配内核栈,这确实会节约相当大的内存开销,但任务异步化引入的问题之一就是内核抢占,2024届内核赛获奖内核Phoenix给出的解决方案是通过设置抢占标志允许至多一次的内核抢占,这是一个不错的方案,但通用性能否做的更好一点呢?或许早先组会上听到有无栈的结合是最佳的解决方案,但由于内核比赛测试临近,最近的工作在不停的修syscall,暂时没时间研究,希望能在决赛时拿出我们认为优秀的解决方案。

相关参考资料
monoio设计介绍[https://rustmagazine.github.io/rust_magazine_2021/chapter_12/monoio.html]
iouring介绍[https://arthurchiao.art/blog/intro-to-io-uring-zh/]
BBQ论文[https://www.usenix.org/conference/atc22/presentation/wang-jiawei]
AsyncOs[https://asyncos.github.io]

序言

非常高兴能参加开源操作系统训练营第四阶段的学习,并与大家共同进步。

在此,我首先要感谢陈渝老师,您在每周学习中给予的指导和鼓励,成为了我前进的坚实支柱。同时,我也非常感谢其他同学,在第四阶段的学习中,我从大家那里学到了很多,无论是便捷地获取学习资料和代码,还是在遇到疑惑时能找到理解并深入探讨技术的伙伴,都让我受益匪浅。

经历完这四个阶段,我取得了显著的收获:不仅深刻理解了操作系统内部的运行机制,更掌握了通过组件化管理实现现代化操作系统的方法。具体而言,我成功完成了arceos-org/oscampaarch64架构支持,并为starry-next适配了iperfTCP部分。

任务1 完成arceos-org/oscampaarch64架构的支持

  1. 使用QEMUmonitor info mtreemonitor info block 找出pflash区域,并在aarch64-qemu-virt.toml中增加正确的映射区域,在tour代码中写入正确的PFLASH_START

  2. 增加了aarch64部分的uspace代码,进行适配

  3. makefile内的规则进行修改,修改payloadmk_pflash对其他架构进行适配

  4. 增加payload(https://github.com/879650736/oscamp/blob/main/arceos/payload/hello_c/Makefile )(https://github.com/879650736/oscamp/blob/main/arceos/payload/origin/Makefile )内其他架构的编译规则,使payload对其他架构也能适配

  5. 增加aarch64的CI测试,并测试通过

  6. pr:https://github.com/arceos-org/oscamp/pull/9

  7. fork仓库:https://github.com/879650736/oscamp

任务2 为starry-next适配iperf

  1. 提交https://github.com/oscomp/starry-next/pull/56/files 。修复了oscomp/starry-next中的c中的sockaddrarceos/axnet中的SocketAddr的类型转换问题,改为使用trait直接将sockaddr 转换为SocketAddr,而不需要加一个中间层SockAddr,并测试通过。已合并。

  2. 提交https://github.com/oscomp/testsuits-for-oskernel/pull/52 。在为 starry-next 兼容 iperf的过程中,我发现一个段错误问题。具体来说,如果在cJSON_New_Item 函数中未对全局变量 global_hooks进行初始化,会导致空指针访问。然而,当我单独编译cJSON的相关代码时,并未复现此异常。我推测这可能是由于编译为 ELF 文件时,编译器进行了某种优化所致。将 global_hooks的初始化操作增加到cJSON_New_Item函数的起始位置后,该段错误便得以消除。

  3. muslopenssl库,使用build_riscv.sh, 进行openssl库的交叉编译

  4. 创建iperf_wrap, 进行本地编译载入测试

  5. arceos/modules/axfs/src/mount.rs中增加/dev/urandom的挂载,并增加了一个简单的urandom的实现

  6. 修改iperf中 autoreconfconfigure.ac,增加--disable-xxxx选项的支持

  7. 实现可增加--disable参数去除部分 Linux 特有的选项如SO_MAX_PACING_RATESO_BINDTODEVICEIP_MTU_DISCOVER等,为交叉编译提供支持,参考 build.sh,宏定义生成结果可通过src/iperf_config.h查看,也为调试提供方便。

  8. 允许用户在配置 iperf3 时,通过命令行参数禁用特定的功能或特性,特别是那些可能与特定操作系统(如 Linux)紧密相关的特性,以便于在其他平台或进行交叉编译时避免兼容性问题。

    • configure.ac 文件中使用 AC_ARG_ENABLE 宏来定义新的配置选项。

    • --disable-have-dont-fragment 为例

      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
      # Check if Don't Fragment support should be disabled
      AC_ARG_ENABLE([have-dont-fragment],
      [AS_HELP_STRING([--disable-have-dont-fragment], [Disable Don't Fragment (DF) packet support])],
      [
      case "$enableval" in
      yes|"")
      disable_have_dont_fragment=false
      ;;
      no)
      disable_have_dont_fragment=true
      ;;
      *)
      AC_MSG_ERROR([Invalid --enable-have-dont-fragment value])
      ;;
      esac
      ],
      [disable_have_dont_fragment=false]
      )

      if test "x$disable_have_dont_fragment" = "xtrue"; then
      AC_MSG_WARN([Don't Fragment (DF) packet support disabled by user])
      else
      if test "x$iperf3_cv_header_dontfragment" = "xyes"; then
      AC_DEFINE([HAVE_DONT_FRAGMENT], [1], [Have IP_MTU_DISCOVER/IP_DONTFRAG/IP_DONTFRAGMENT sockopt.])
      fi
      fi

      AC_ARG_ENABLE([have-dont-fragment], ...) 定义了 --disable-have-dont-fragment 选项。
      如果用户指定了 --disable-have-dont-fragment,则 disable_have_dont_fragment 变量被设置为 true
      如果 disable_have_dont_fragmenttrue,则会发出警告,并且不会定义 HAVE_DONT_FRAGMENT 宏。
      否则(用户未禁用),并且如果 Autoconf 之前的检查 (iperf3_cv_header_dontfragment) 确认系统支持 IP_MTU_DISCOVER 等选项,则会定义 HAVE_DONT_FRAGMENT 宏。

    • 针对 Linux 特有的套接字选项(如 SO_MAX_PACING_RATESO_BINDTODEVICE、IP_MTU_DISCOVER),提供 --disable 选项,以便在非 Linux 环境下(如交叉编译到嵌入式系统或其他操作系统)能够顺利编译,避免因缺少这些特性而导致的在其他环境下的运行错误。

    • 其通用模式

      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
      # 定义一个名为 'have-feature-name' 的选项
      AC_ARG_ENABLE([have-feature-name],
      [AS_HELP_STRING([--disable-have-feature-name], [Disable support for Feature Name])],
      [
      case "$enableval" in
      yes|"")
      disable_feature_name=false
      ;;
      no)
      disable_feature_name=true
      ;;
      *)
      AC_MSG_ERROR([Invalid --enable-have-feature-name value])
      ;;
      esac
      ],
      [disable_feature_name=false] # 默认启用
      )

      # 根据用户选择和系统检测结果,决定是否定义宏
      if test "x$disable_feature_name" = "xtrue"; then
      AC_MSG_WARN([Feature Name support disabled by user])
      else
      # 这里可以添加额外的系统特性检测,例如检查头文件、函数或套接字选项
      # if test "x$ac_cv_header_some_header" = "xyes"; then
      AC_DEFINE([HAVE_FEATURE_NAME], [1], [Description of the feature macro.])
      # fi
      fi
    • 当修改了 configure.ac 文件后,仅仅保存文件是不够的。configure.acAutoconf 的输入文件,它需要被处理才能生成实际的 configure 脚本。这个处理过程就是通过运行 autoreconf 命令来完成的。

    • autoreconf 命令会执行一系列工具(如 aclocal, autoconf, autoheader, automake 等),它们会:

      1. 处理 configure.ac: 将 configure.ac 中的 Autoconf 宏转换为可执行的 shell 脚本代码,生成 configure 脚本。
      2. 生成 config.h.in: 如果你的 configure.ac 中使用了 AC_CONFIG_HEADERSautoheader 会根据 AC_DEFINE 等宏生成 config.h.in 文件,这是一个模板文件,最终会被 configure 脚本处理成 config.h
      3. 处理 Makefile.am: 如果项目使用了 Automakeautomake 会处理 Makefile.am 文件,生成 Makefile.in
        因此,每次修改 configure.ac 后,你都必须在项目根目录运行 autoreconf -fi 命令,以确保这些修改能够体现在新生成的 configure 脚本中。 否则,你新添加的 --disable-xxxx 选项将不会被识别。
    • build.sh 脚本中,可以根据编译目标或环境变量来决定是否添加这些 --disable 参数。

      1
      2
      ./configure --disable-have-dont-fragment --disable-openssl --disable-cpu-affinity  
      ........
  9. api/src/imp中进行syscall的适配

  10. 对于跨平台elf调试,使用

    1
    2
    int i = 1;
    assert(i == 0);

    进行手动打断点结合printf一步步调试,最终找到https://github.com/oscomp/testsuits-for-oskernel/pull/52 的段错误的具体问题。

  11. iperf3测量原理

    • 基本工作流程:
    1. 服务器端启动: 一台机器作为服务器端,启动 iperf3 并监听特定端口,等待客户端连接。
    2. 客户端启动: 另一台机器作为客户端,启动 iperf3 并指定服务器的IP地址和端口,发起连接请求。
    3. 数据传输: 连接建立后,客户端或服务器(取决于测试模式)开始发送数据包。
    4. 性能测量: 双方在数据传输过程中记录时间、传输数据量、丢包等信息。
    5. 结果报告: 传输结束后,客户端和/或服务器会计算并报告测量的网络性能指标。
    • 在本机apt install iperf3后,自动安装并自启动了/usr/lib/systemd/system/iperf3.service
    text
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    iperf3.service - iperf3 server
    Loaded: loaded (/usr/lib/systemd/system/iperf3.service; enabled; preset: enabled)
    Active: active (running) since Fri 2025-06-20 05:23:30 UTC; 7h ago
    Docs: man:iperf3(1)
    Main PID: 1326 (iperf3)
    Tasks: 1 (limit: 9434)
    Memory: 472.0K (peak: 5.4M swap: 440.0K swap peak: 440.0K)
    CPU: 30.580s
    CGroup: /system.slice/iperf3.service
    └─1326 /usr/bin/iperf3 --server --interval 0

    每次开机后,systemd 会根据 iperf3.service 的定义,自动启动 /usr/bin/iperf3 --server --interval 0 命令,使其作为后台服务持续运行,等待客户端连接。

    • 当你在本机运行 iperf3 -c 127.0.0.1 时,这个命令会启动一个 iperf3 客户端进程。这个客户端进程会尝试连接到 127.0.0.1(即本机)上正在监听的 iperf3 服务器。iperf3 -c 127.0.0.1 会向服务器发送数据包,服务器接收这些包并进行统计。客户端也会统计发送的数据量和时间,最终报告发送端的吞吐量。

    • 客户端和服务器之间建立 TCP 连接(默认)。客户端以尽可能快的速度向服务器发送数据,服务器接收并记录数据量。双方都记录开始和结束时间。通过传输的数据量除以传输时间,即可计算出吞吐量。

    • qemu内运行的starry-next同理,因为qemu与主机是通过NAT。在 qemu 虚拟机内部运行的 starry-next(假设它也包含 iperf3 客户端或服务器)与主机之间的网络通信,会经过 qemu 的网络虚拟化层。

    • qemu 使用 NAT(网络地址转换)模式时,虚拟机拥有一个私有 IP 地址,它通过主机的 IP 地址访问外部网络。对于虚拟机来说,主机看起来像一个路由器。

    • 场景 : qemu 内的 iperf3 客户端连接到主机上的 iperf3 服务器。

    • qemu 虚拟机内的 iperf3 -c <主机IP地址>

    • 数据流:qemu 客户端 -> qemu 虚拟网卡 -> qemu NAT 转换 -> 主机物理网卡 -> 主机 iperf3 服务器。

    • 这种测试测量的是虚拟机到主机之间的网络性能,包括 qemu NAT 层的开销。

    • 无论哪种场景,iperf3 的基本客户端-服务器通信原理不变。qemu 的 NAT 模式只是在网络路径中增加了一个虚拟化的层,iperf3 测量的是经过这个虚拟化层后的实际吞吐量。

    • 关键设计点:

    • 处理程序中断信号(如 Ctrl+C)的机制。它使用了 signalsetjmp/longjmp 组合来实现非局部跳转,以便在接收到中断信号时能够优雅地退出并报告结果。

    • iperf_catch_sigend 函数

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      void
      iperf_catch_sigend(void (*handler)(int))
      {
      #ifdef SIGINT
      signal(SIGINT, handler);
      #endif
      #ifdef SIGTERM
      signal(SIGTERM, handler);
      #endif
      #ifdef SIGHUP
      signal(SIGHUP, handler);
      #endif
      }

      这段代码将 sigend_handler 函数注册为 SIGINT, SIGTERM, SIGHUP 这三个信号的处理函数。这意味着当程序接收到这些信号中的任何一个时,sigend_handler 函数就会被调用。

    • 信号处理的设置和跳转点

      1
      2
      3
      4
      5
      iperf_catch_sigend(sigend_handler); // 注册信号处理函数
      if (setjmp(sigend_jmp_buf)){ // 设置跳转点
      printf("caught SIGEND\n");
      iperf_got_sigend(test);
      }
      • if (setjmp(sigend_jmp_buf)): 这是 setjmp/longjmp 机制的关键。
      • setjmp(sigend_jmp_buf)
        第一次调用时(正常执行流程): 它会保存当前程序的执行上下文到sigend_jmp_buf 中,并返回 0。因此,if (setjmp(...))条件为假,程序会继续执行 if 语句块后面的代码。
      • longjmp被调用时(从信号处理函数中):longjmp 会使用 sigend_jmp_buf中保存的上下文,使程序“跳回”到 setjmp 被调用的位置。此时,setjmp 会返回longjmp 传递的非零值(这里是 1)。因此,if (setjmp(...))条件为真,if 语句块内的代码会被执行。
    • sigend_handler 函数

      1
      2
      3
      4
      5
      6
      static jmp_buf sigend_jmp_buf; // 用于存储跳转上下文的缓冲区
      static void __attribute__ ((noreturn))
      sigend_handler(int sig)
      {
      longjmp(sigend_jmp_buf, 1);
      }
      • 这是实际的信号处理函数。
        __attribute__ ((noreturn)): 这是一个 GCC 扩展属性,告诉编译器这个函数不会返回(即它会通过 longjmp 跳转出去,而不是正常返回)。这有助于编译器进行优化,并避免一些警告。
        longjmp(sigend_jmp_buf, 1);: 这是核心操作。当SIGINTSIGTERMSIGHUP 信号被捕获时,这个函数会被调用,然后它会执行longjmp
      • longjmp 会将程序的执行流从当前位置(信号处理函数内部)直接跳转到 setjmp(sigend_jmp_buf)所在的位置。
    • iperf_got_sigend 函数

      • 捕获到中断信号后,实际执行清理、报告和退出的函数
    • 这段代码实现了一个健壮的信号处理机制,确保 iperf3 在接收到中断信号(如 Ctrl+C)时,能够:

      1. 立即停止当前的数据传输。
      2. 收集并报告截至中断时的所有统计数据。
      3. 通过控制连接通知另一端的 iperf3 进程,以便对方也能感知到测试的结束并进行相应的处理。
      4. 最终优雅地退出程序。

适配成功:

iperf-V
iperf-c

序言

非常高兴能参加今年的操作系统训练营,经过这四个阶段的学习,对操作系统的理解逐渐加深。在此过程中,对陈渝老师和郑友捷老师的耐心指导表示衷心感谢。

在第四阶段的学习中,我主要做了以下工作:

  1. starry-next支持新特性:大页分配

    1. 后端映射实现

      1. 修改后端数据结构,为LinearAlloc这两种映射方式增加对齐参数align,该参数的类型为PageSize。根据对齐方式,分配相应的内存块。
      2. 修改物理页帧的分配方法alloc_frame,以4KB为单位,根据对齐参数大小,计算页面数量num_pages,调用全局分配器,分配num_pages个连续的物理页面,将这段连续内存的地址返回并映射到页表中,作为一整个页面。
      3. 修改释放方法dealloc_frame,基于以上alloc_frame,物理页帧的释放同理,根据对齐参数,以4KB为单位,计算需要释放的物理页帧数量,并调用全局分配器,释放这一段连续的物理内存。
      4. 修改空闲内存查找方法find_free_area,为适配不同的对齐要求,该函数首先对建议的起始地址hint进行对齐,然后执行两轮扫描,第一轮扫描处理hint之前的区域,以确定起始搜索位置,第二轮扫描检查各内存区域之间的空隙,跳过重叠和已占用的区域,检查满足对齐要求和大小的区域,最后检查末尾区域,验证并返回找到的地址。
      5. 修改unmap方法,考虑取消映射的内存区域可能存在不同对齐的情况,对unmap方法进行改进。首先验证起始地址和大小是否满足4K对齐,然后查找定位取消映射的内存块,根据每个内存块的对齐要求,验证对齐,最后执行取消映射。
      6. 增加一个page_iter_wraper.rs文件,包装PageIter4KPageIter2MPageIter1G为一个PageIterWrapper,方便遍历。
      7. 对线性映射,文件读写,DMA和懒分配的支持,在相关函数如new_linearprocess_area_datahandle_page_fault中增加对齐参数支持不同页面的对齐要求。
    2. 内存扩展

      1. 为测试大页尤其是1G大页的分配,读写和回收等情况,需要扩展平台内存以运行测例。
        1. 修改aarch64架构配置文件,修改其物理内存大小为4G
        2. 修改riscv64架构配置文件,修改其物理内存大小为4G
        3. 修改loongarch64架构的配置文件,扩展其物理内存,并修改其物理基址和内核基址以避免内存重叠
        4. 修改x86_64架构的配置文件,扩展其物理内存,在mmio-regions新增高地址支持,修改modules/axhal/src/mem.rs文件,新增free_memory_region,扩展x86_pc中的multiboot.S支持高位地址空间
      2. 扩展内存之后在所有架构下测试
      3. 将以上所做的修改工作落实成文档提交
  2. 分析和改进axalloc

    1. 分析axalloc代码逻辑,撰写文档
    2. 改进axalloc
      1. 新增buddy_page.rs,基于buddy算法,实现了一个页分配器,并添加相关的测试模块。实现思路如下:
        1. 初始化过程:将起始地址和结束地址对齐到页边界,内存基址对齐到1G边界(与arceos的BitmapPageAllocator保持一致),将整个内存区域分解为最大可能的2的幂次个块。
        2. 分配算法:检查需要分配的页面数量和页面大小,验证其是否与PAGE_SIZE对齐,根据所需页面数量和对齐,计算所需阶数,查找可用块,将大块分割到所需大小,将多余部分加入对应的空闲链表,标记页面为已分配,更新使用统计。
        3. 释放与合并算法:首先标记当前块为空闲,然后递归检查伙伴块是否空闲,如果是则合并,将最终合并的块加入到对应阶数的空闲链表。
      2. 新增tests.rs,提供一个基准测试模块,用于比较BitmapPageAllocatorBuddyPageAllocator两种页分配器的性能表现。该测试模块包含两个主要测试函数,用于评估不同分配器在碎片化和内存合并方面的表现 ,测试结果如下:image-20250621162956014
        1. 合并效率测试结果:两个分配器都显示了 100% 的合并效率,这意味着它们都能完美地将释放的单页重新合并成大的连续内存块
        2. 碎片化测试结果分析:
          • Random Small Pattern(随机小块分配):
            • Bitmap: 50.0% 碎片化率
            • Buddy: 83.3% 碎片化率
            • BitmapPageAllocator 表现更好
          • Mixed Size Pattern(混合大小分配):
            • Bitmap: 41.7% 碎片化率
            • Buddy: 33.3% 碎片化率
            • BuddyPageAllocator 表现更好
          • Power-of-2 Pattern(2的幂次分配):
            • Bitmap: 16.7% 碎片化率
            • Buddy: 33.3% 碎片化率
            • BitmapPageAllocator 表现更好

开源操作系统训练营第四阶段总结-改进Starry文档

学习心得

赶着入职前进入到了第四阶段,留给我的时间不多,都不到一周,但是陈老师没有放弃我,仍然耐心的给与了我如何开始学习Starry的指导,我非常感动。四阶段项目一群中的群友也给与了我很大的帮助,让我感受到了开源社区的魅力。

在四阶段学习中,我收获良多,由于不是计算机专业的学生,有许许多多的第一次:

  • 第一次学习操作系统:还是 RISCV 指令集的,在MacOS上跑代码没少折腾。很多内容让我回想起了本科学习计算机组成原理的时光(还是很不相同)。
  • 第一次使用 docker:本来倔强的我想在 MacOS 上坚持到最后,没想到还是在第三阶段涉及到交叉编译的部分败下阵来,配置 docker 环境的时候可能由于网络问题也没少折腾。
  • 第一次提交 PR:之前使用GitHub也只是上传论文的代码,单打独斗。在刚进入第四阶段的时候,陈老师很耐心的用一个修改 README.md 的机会指导我怎么提交 PR。第一次了解了团队合作乐趣。
  • 第一次了解 GitHub 工作流:前三阶段大概知道有这么一个东西在给我们提交的文档打分,在第三阶段中 musl 被官方屏蔽了微软的访问之后一直访问超时,我才开始去理解其中的含义。

闲聊了这么多,还是因为我入门尚浅,翻来覆去看代码也只知道还要学习的有很多,难以下手,留给我的时间又只有一周。还好陈老师给我指了一条明路——完善文档,接下来我就介绍一下这一周我的工作以及将来的计划。

本周工作

我的工作主要是分析starry-next,并阅读 starry-next tutorial book,从各个层面改进starry-next tutorial book,帮助自己和其他初学者更好地学习操作系统开发。

在开始完善文档之前,我阅读了郑友捷同学的综合论文训练——《组件化操作系统 ArceOS 的异构实践》,宏观的了解了 StarryOS 的设计理念和目标。在完善文档的过程中,郑友捷同学也给予了我很多建议和指导,在此表示感谢。

我的工作具体如下:

  1. PR#23 修复了指导书不能切换主题的bug, 可以切换到深色模式,便于完善文档时的调试(一边黑一边亮容易被吸引注意力)。

  2. PR#24 在阅读完郑同学的论文之后,修改了文档的欢迎页,说明了 StarryOS 和 ArceOS 之间的关系和差别。因为从第三阶段到第四阶段,作为初学者的我一开始是一头雾水,不知道 Starry 要实现一个什么样的目标,所以我也在欢迎页添加了设计目标的说明(仍需完善)。在我与郑同学的交流中感觉到 Starry 的目标可能是:

    • 在 crate 层中希望能够开发一些独立的组件,一部分就像 ArceOS 一样,能够被任意类型内核复用, 一部分则是能够被其他的宏内核使用。

    • 使 Starry 能够兼容 Linux 程序,即提供 POSIX API 接口。

    • 完成作为一个宏内核该有的功能,包括进程管理、信号处理等。

      作为一个第一次接触 Starry 的开发者,我觉得可能还需要一个更宏大的目标,或者更明确的商用的可能性来吸引更多的人加入我们的开源社区,并做出贡献。

  3. pr#25 在进入到第四阶段的时候,配置完环境后,只是按照 README.md 中给的指令运行了一遍,但是依然没有理解具体 Starry 究竟做了些什么,他的目标是什么。而且我在创建镜像的过程中遇到了一些问题——loop设备满了导致无法加载,我也是通过读了一遍Makefile的流程才定位到这个错误。因而我写了一个一个案例快速上手 Starry,介绍了这些指令内部的一些细节,帮助初学者快速理解。PS: 今天我再读的时候发现了其中的一些错误(镜像文件应该是给QEMU加载的,不是ArceOS的文件系统),而且没有介绍案例 nimbos 的主要作用,可能对于学习过操作系统的同学来说,不用说也知道是一个测试操作系统功能的测试集,从一个门外汉的角度来看,他就是测试了几个testcase而已。我会在后续的工作中对文档进行修正。

  4. pr#26 可能对于大佬来说,各种 git 指令都已经理解透彻,烂熟于心,但对于初学者来说,只会用一个

    1
    2
    3
    git add .
    git commit "update XXX"
    git push

    在这之前我都没有创建过分支来提交PR,导致了一些混乱,因此在附录中添加了一个创建分支提交PR的标准流程。此外对于理解StarryOS究竟在做什么,理解他的工作流很有必要,因此我也在附录中添加了对于工作流相关的说明。

总结和将来的工作

由于时间不多,我的水平有限,相比其他同学对社区的贡献,我的工作可能微不足道。如果社区的大佬们不嫌弃,入职工作之后我也愿意继续帮助完成文档(主要因为这个项目里面用到的Rust语法很全面,我想要学习rust, 还包含了很多汇编和c,将来也能对用rust驱动硬件做为一个参考)。

可能有一部分同学和我一样,在阶段一到阶段三主要注重于做题(阅读测例->知道预期的结果是什么->查看相关的接口和需要用到的函数->实现功能),完成任务即可。没有特别理解整体的设计和原理,因而到了阶段四之后没有了题目之后感到迷茫。

因而为了帮助第三阶段的同学能够丝滑的进入到第四阶段,我觉得当务之急是需要完善ArceOS的文档(我第三阶段完全就是参考PPT完成的,对于很多细节没有掌握)我接下来的工作就是理解ArceOS的细节,说明Starry如何使用了ArceOS的接口和模块。然后首先实现文档整体框架的从无到有,最后在掌握了细节和整体设计思路之后再完善指导书并修正其中的错误。也希望有大佬能够一起加入到完善文档队伍中,一起交流。

序言

过去一个月,由于我对 ArceOS 的架构理解较少,为了快速掌握 ArceOS 的架构,Unikernel宏内核,我的主要目标改进 oscamp,为其完善对 x86_64 的支持。同时还做了对内核组件 x86_rtc 说明文档和测试的完善。
最直接的收获有两点:

  • Unikernel 思想 —— “用户态就是内核的一部分” 的最小可信边界,让我重新审视传统多进程操作系统里“内核/用户”硬隔离的成本。
  • 宏内核工程学 —— 模块划分、内核线程、系统调用网关、设备驱动归一化,这些都在 Starry/ArceOS 的设计里有了“先行者版本”。

工作记录

第一周

为了快速掌握 ArceOS 的架构,Unikernel宏内核,我选择了改进 oscamp,为其完善对 x86_64 的支持这一项工作。并开始了对 x86_64 的学习。

第二、三周

主要是做代码工作,以下是一些总结
RISC‑V 是通过 scause + stval;x86‑64 要区分 Exception Class(#PF/#UD)与 IRQ Vector,且栈布局不一样。
x86_64 的支持这一项工作需要完成以下的功能实现:

  1. 改进 context.rs,保存相关的寄存器,并完善 context_switchenter_uspace
    • 保存/恢复的寄存器集对齐 SysV x86‑64 调用约定:RBX RBP R12‑R15 + CR3 + RFLAGSFPU/AVX 延后到 lazy fp 任务。
    • context_switch(old, new) = 保存旧任务栈顶 → 恢复新任务栈顶 → iretq;为支持 SMP,加了 core::arch::asm!("swapgs") 保证每 CPU 的 GS 基址切换。
    • 进入用户态 (enter_uspace):手动构造 iretq 帧:SS|RSP|RFLAGS|CS|RIP,再写 CR3 = user_pml4; 关中断→加载帧→开中断→ iretq
  2. 改进 trap.S
    • IDT 256 项0x20 时钟、0x80 软中断、0x0E #PF……全部指向统一的 trap_entry;硬中断通过 APIC 自动切到 IST[0] emergency stack 防止内核栈溢出。
    • trap.rs 根据向量号派发到 handle_page_fault / handle_irq / handle_syscall
  3. 改进 syscall.rssyscall.S
    • SYSCALL/SYSRET 而非 INT 0x80;入口先 swapgs 用 GS 保存/恢复用户栈。
    • 按 SysV ABI RAX=nr,RDI RSI RDX R10 R8 R9 传六参 —— 在汇编里把寄存器序列化到栈,统一传给 x86_syscall_handler()
    • 退出路径:恢复通用寄存器 → swapgssysretq

第四周

对内核组件 x86_rtc 说明文档和测试的完善。
repo

未来展望

  • Transparent HugePages:复用前期完成的巨页 API,引入 khugepaged 合并线程。
  • vDSO:把高频 clock_gettime 胶水放到用户态,加速 Sys‑API。