0%

项目基础阶段通过总结概述

春季营因为忙于实验室项目没有完成训练营全部内容,秋季一定要弥补遗憾,通过训练营,善始善终。

Rust 编程阶段

这个阶段时间比较短,自己只是匆匆过了遍语法基础就去刷rustlings,之后有时间还要常复习。
没接触系统内核类项目之前,对rust语言没有太深感觉,只知道它比c++多些安全检查以及借用等特性,但是当真正在实战中用到这些特性才发现其厉害之处,尤其是编译阶段检测,这个会让开发者避开很多写代码时难以发现的错误(内存泄漏),以及锁的诸多包装,所有权控制等等。

OS 设计与实现

  1. ch3就是熟悉一下相关接口,会调用,加个系统syscall计数就行
  2. ch4 难点在于检查是否有未映射的页以及页对齐问题
  3. ch5 Systrace跨页遍历,需要特殊处理。另外就是记得引用模块要注意是否public,否则需要自己写一个让外部访问的接口函数。
  4. ch6 文件系统处理,最难的一个,需要自己补充很多文件系统的调用,比如inode相关方法,文件链接建立。
  5. ch8 死锁检测,实在太繁琐了这个接口层层调用。

组件化操作系统

我在写我们实验室项目 AlloyStack 时曾研究过arceos和ruxos,对模块化操作系统还是比较熟悉的。不过自己从裸机实现hello world到unikernel组件层层编译实现再到支持虚拟化(unikernel+guestkernel)。过一遍这个流程还是很有收获的,形成了更加清晰的组件化操作系统开发思路(可惜实验都是打打补丁,实现部分接口,主要还是看ppt一步步实现)。

不过 Hypervisor实现在同一进程下还要在unikernel和guestkernel反复地址空间切换,感觉很影响性能,如果做到能统一编址(实现一个统一编址器)感觉会有很大性能提升。

总结

自我感觉操作系统内核之所以难写,是因为庞杂的系统调用以及层层嵌套的接口,要想在里面加些什么功能先要把调用逻辑链搞清楚,明白已知哪些信息,要用这些信息和手动实现算法组合出一个新模块,再像插插座一样把模块嵌入到这个内核当中。真的需要极大的耐心,幸运的是在agent时代,利用ai分析调用很方便,明确需求再让ai辅助去实现,轻松了不少,真的佩服那些真正一行行手搓内核的前辈,他们是计算机系统伟大的奠基人。

基础阶段-Rust编程

难点: 从自由的C转向Rust的学习是一个比较艰难的过程,尤其是所有权系统。对我来说,习惯了C语言写代码后,所有权是Rust的第一个难点,不能再用C这么自由的内存管理了,需要仔细处理所有权问题,也是我学Rust最艰难的一段时期——和编译器斗智斗勇。

但是Rust的所有权优点显而易见:

  • Rust会自动管理内存,防止一些C普遍遗留的内存泄漏
  • 在系统编程中使用Rust可以完全避免悬垂指针等问题
  • 性能好,不逊于C

Rust拥有好的工程管理工具Cargo,这对于系统开发有着减少大量工作量的作用,也利于实现之后的组件化操作系统Unikernel。

在完成Rustling的练习之后,我的Rust编程能力得到很大的提升,为专业阶段的OS设计与实现打下了坚实的基础。

专业阶段-OS设计与实现

到了最喜欢的阶段了,OS的每个模块都深深地吸引着我。我花了10天的时间学完并且完成了rCore-Tutorial-Guide-2025S、rCore-Tutorial-Book-v3的大多数实验。我将rCore-Tutorial-Book-v3当作理论知识教学书,rCore-Tutorial-Guide-2025S当作实验手册。

令我印象最深的: 地址空间。依靠MMU给应用创建了一个虚拟的地址空间,应用本身以为拥有全世界,其实尽在操作系统的掌控之内!

这个阶段也是我运用理论知识进行实践的一个开始。在完成大部分实验之后,我不满足在现有的框架内翻找API,并且希望更深一步地理解rCore。然后我打算学着rCore的思想自己写一个操作系统内核。那晚我开启了GitHub仓库,并且给我这个OS kernel命名为BlueStarOS! 在此贴上链接https://github.com/Dirinkbottle/BlueStarOS

我改装了rCore的Makefile快速搭建起了依赖和基础框架,基于RustSBI开始着手内核开发。在自己动手完成内核时,我才知道自己实现需要解决大量的问题。

碰到的最大难点: 调试困难,内核的小bug较难排查。

遇到的问题:

  • 在我建立内核地址空间时,我错误地把PageTable的页号×页大小直接当作页表指针(也可能是当时脑袋发昏),导致之后切换页表后无法翻译内核地址空间的数据
  • Rust中的编译优化导致的玄学问题,例如特意的占位符会被优化掉
  • 内存屏障问题…

在此期间,我学会了如何快速使用QEMU配合GDB进行调试,GDB帮了大忙。之后我一直开发这个内核,到了目前基本完成了:

  • 内存管理
  • 系统调用
  • 陷阱处理
  • 大部分任务调度
  • 内存文件系统

这些实现都是最小化的,利于我理解和学习。

项目基础阶段-组件化操作系统

得益于Rust高效便捷的包管理系统,组件化操作系统可以方便地实现。在这一阶段我主要学到了内核开发中组件化的思想。

组件化的优势:

  • 不用手动造轮子实现每个组件,例如内存管理模块,这部分内核之间基本通用
  • Unikernel的组件化赋予了它极其灵活的特性,既可以针对单个应用进行定制,让整个操作系统内核全部针对这个应用来设计
  • 不仅提高了应用的运行性能,而且还提升了应用的安全性
  • Unikernel可以最小化组建应用的执行环境和运行环境然后快速上线
  • 去除了冗余功能和不必要的模块,正如一些文章所说,”我都没有shell你拿啥反弹”
  • Unikernel也可以扩展为宏内核,拥有功能完备的操作系统所有该有的模块

这一阶段的实验难点: 项目文件比较大,系统结构较rCore有很大不同。我花了几天时间合理利用AI来帮助理解Unikernel的构建思想和分层以及一些小语法。在实验过程中我也对Unikernel的设计思想理解更深一步!

遇到的问题: 今天遇到了一个棘手的小问题,在simple-hv实验中完成实验后本地运行测试会卡住。这期间我修改了很多次代码依然如此。经过问题排查得知是QEMU的事件型驱动出的岔子,在用管道传递运行结果时大部分时间会卡住。调整了一下脚本就解决了问题。


马上将要进入真正的项目阶段,我满怀期待,希望在项目阶段能学到更多的操作系统的知识!

arceOS 学习总结

前言

arceOS 是一个用 Rust 编写的模块化操作系统,与 rcore 相比,arceOS 采用了更加模块化的设计思路。

这次学习 arceOS 的练习,我花了不少时间。虽然之前做过 rcore 的实验,但 arceOS 的模块化设计和实现方式还是让我遇到了不少挑战。特别是在虚拟化部分,两级地址转换机制让我困惑了很久,好在最后通过看源码和调试还有网站上的博客,终于理解了。

这篇博客主要记录我在完成这些练习过程中的学习体会、遇到的问题以及解决思路。内核的学习实际上最重要的部分可能更是在于解决问题,也就是在针对特定问题的排查思路上。

练习概览

本次学习完成了以下练习:

  1. print_with_color - 带颜色的控制台输出
  2. alt_alloc - 备用分配器测试
  3. ramfs_rename - 文件系统重命名功能
  4. support_hashmap - 内存管理测试
  5. sys_map - 系统调用映射实现
  6. simple_hv - 简单虚拟化器实现

其中 simple_hv 是最复杂的,花了我最多时间。其他练习相对简单,但每个练习都让我对 arceOS 的设计有了更深入的理解。


练习一:print_with_color

学习过程

这个练习看似简单,但一开始我也遇到了一些困惑。在无标准库(no_std)的环境下,如何实现带颜色的输出?我之前在 Linux 上用过 ANSI 转义序列,但在 Rust 的字符串字面量中如何表示 ESC 字符?

实现

最终实现的代码很简单:

1
2
3
4
5
6
7
8
9
10
11
#![cfg_attr(feature = "axstd", no_std)]
#![cfg_attr(feature = "axstd", no_main)]

#[cfg(feature = "axstd")]
use axstd::println;

#[cfg_attr(feature = "axstd", no_mangle)]
fn main() {
// ANSI color code: \u{1B}[32m for green
println!("\u{1B}[32m[WithColor]: Hello, Arceos!\u{1B}[m");
}

关键点:

  • 使用no_std特性,不依赖标准库
  • 使用axstd::println!宏而不是标准库的println!
  • ANSI 转义序列使用 Unicode 转义\u{1B}表示 ESC 字符

通过查看 arceOS 源码,我发现 arceOS 的打印系统(axlog模块)已经内置了对 ANSI 转义序列的支持,但with_color!宏是模块内部的私有宏,无法在外部直接使用。所以直接使用 ANSI 转义序列是合理的选择。

遇到的问题和解决方案

问题 1:Unicode 转义序列的写法

问题:最初尝试使用\x1B但编译失败。

原因:Rust 中字符串字面量需要使用 Unicode 转义序列\u{1B}而不是十六进制转义\x1B

解决:使用\u{1B}来表示 ESC 字符。

问题 2:颜色代码的理解

问题:不清楚不同颜色对应的 ANSI 代码。

学习

  • 30-37:标准颜色(黑、红、绿、黄、蓝、品红、青、白)
  • 90-97:亮色(高亮版本)
  • 32:绿色(本练习使用)

问题 3:终端是否支持颜色

问题:在某些终端中,ANSI 转义序列可能被过滤或不被支持。

解决:测试脚本会检查输出中是否实际包含 ANSI 转义序列,确保不仅输出了文本,还输出了颜色控制码。

学习感悟

这个练习虽然简单,但让我理解了 arceOS 的模块化设计。即使是简单的功能,也需要考虑底层实现。另外,在 Rust 中表示特殊字符需要使用 Unicode 转义,这也是一个小的知识点。


练习二:alt_alloc

学习过程

这个练习主要是测试备用分配器(bump allocator)的功能。虽然代码很简单,但让我理解了 arceOS 的模块化设计——可以通过 feature 标志在编译时选择不同的分配器实现。

实现

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
#![cfg_attr(feature = "axstd", no_std)]
#![cfg_attr(feature = "axstd", no_main)]

#[macro_use]
#[cfg(feature = "axstd")]
extern crate axstd as std;
extern crate alloc;

use alloc::vec::Vec;

#[cfg_attr(feature = "axstd", no_mangle)]
fn main() {
println!("Running bump tests...");

const N: usize = 3_000_000;
let mut v = Vec::with_capacity(N);
for i in 0..N {
v.push(i);
}
v.sort();
for i in 0..N - 1 {
assert!(v[i] <= v[i + 1]);
}

println!("Bump tests run OK!");
}

遇到的问题和解决方案

问题:在分配 300 万个元素时,程序偶尔会崩溃。

原因:bump allocator 是线性分配器,在内存不足时会失败。

解决:通过查看 alt_axalloc 的源码,理解了 bump allocator 的工作原理。它从内存池的一端开始分配,只能向前推进,不能释放单个分配。这让我理解了为什么需要不同的分配器策略。

学习感悟

这个练习虽然简单,但让我看到了 arceOS 模块化设计的优势。通过 feature 标志,可以在编译时选择不同的实现,这种设计非常灵活。


练习三:ramfs_rename

学习过程

这个练习主要是实现文件系统重命名功能。一开始我以为会很简单,但实际做起来发现需要理解 arceOS 的文件系统抽象。特别是 ramfs.rs 的实现,让我理解了文件系统接口的设计。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
fn create_file(fname: &str, text: &str) -> io::Result<()> {
println!("Create '{}' and write [{}] ...", fname, text);
let mut file = File::create(fname)?;
file.write_all(text.as_bytes())
}

// Only support rename, NOT move.
fn rename_file(src: &str, dst: &str) -> io::Result<()> {
println!("Rename '{}' to '{}' ...", src, dst);
fs::rename(src, dst)
}

fn print_file(fname: &str) -> io::Result<()> {
let mut buf = [0; 1024];
let mut file = File::open(fname)?;
loop {
let n = file.read(&mut buf)?;
if n > 0 {
print!("Read '{}' content: [", fname);
io::stdout().write_all(&buf[..n])?;
println!("] ok!");
} else {
return Ok(());
}
}
}

fn process() -> io::Result<()> {
create_file("/tmp/f1", "hello")?;
// Just rename, NOT move.
// So this must happen in the same directory.
rename_file("/tmp/f1", "/tmp/f2")?;
print_file("/tmp/f2")
}

遇到的问题和解决方案

问题:最初不理解为什么需要 ramfs.rs 这个文件,以及它和主代码的关系。

原因:arceOS 使用 trait 来抽象文件系统,需要实现 MyFileSystemIf trait。

解决:通过查看 ramfs.rs 的代码,理解了文件系统接口的设计。它使用 crate_interface::impl_interface 来实现接口,这是 arceOS 模块化设计的一部分。

学习感悟

通过这个练习,我理解了 arceOS 文件系统的抽象设计。虽然实现简单,但背后的设计理念很清晰。在 no_std 环境下实现类似标准库的 API,这种设计很优雅。


练习四:support_hashmap

学习过程

这个练习主要是测试 arceOS 对集合类型(BTreeMap)的支持。虽然代码很简单,但让我理解了 arceOS 如何在 no_std 环境下提供标准库的替代实现。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn test_hashmap() {
const N: u32 = 50_000;
let mut m = BTreeMap::new();
for value in 0..N {
let key = format!("key_{value}");
m.insert(key, value);
}
for (k, v) in m.iter() {
if let Some(k) = k.strip_prefix("key_") {
assert_eq!(k.parse::<u32>().unwrap(), *v);
}
}
println!("test_hashmap() OK!");
}

遇到的问题和解决方案

问题:在插入大量数据时,程序运行时间较长。

原因:BTreeMap 使用 B 树实现,插入操作需要维护树结构。

解决:通过测试,理解了 BTreeMap 的性能特征。虽然插入较慢,但查找和遍历操作比较高效。

学习感悟

这个练习让我理解了 arceOS 如何提供标准库的替代实现。在 no_std 环境下,需要自己实现这些数据结构,但这种设计让系统更加可控。


练习五:sys_map

学习过程

这个练习比较复杂,涉及系统调用的实现。一开始我对用户程序加载和地址空间管理不太理解,特别是 sys_mmap 的实现,花了我不少时间。

实现

这个练习主要包括:

  1. 用户程序加载:从文件系统加载 ELF 文件到用户地址空间
  2. 系统调用处理:实现 mmapopenreadwriteexit 等系统调用
  3. 内存映射:实现 sys_mmap,支持匿名映射和文件映射

关键实现 - sys_mmap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#[allow(unused_variables)]
fn sys_mmap(
addr: *mut usize,
length: usize,
prot: i32,
flags: i32,
fd: i32,
_offset: isize,
) -> isize {
syscall_body!(sys_mmap, {
// Get the user space address space
let curr = current();
let task_ext = curr.task_ext();
let mut aspace = task_ext.aspace.lock();

// Parse flags
let mmap_flags = MmapFlags::from_bits_truncate(flags);
let mmap_prot = MmapProt::from_bits_truncate(prot);

// Align address and size to page boundaries first
let aligned_length = (length + PAGE_SIZE_4K - 1) & !(PAGE_SIZE_4K - 1);

// Allocate virtual address if addr is NULL
let aligned_vaddr = if !addr.is_null() {
VirtAddr::from(addr as usize).align_down_4k()
} else {
// Find a suitable virtual address below stack
// Stack is at [aspace.end() - USER_STACK_SIZE, aspace.end())
// So we place mmap at aspace.end() - USER_STACK_SIZE - aligned_length
(aspace.end() - USER_STACK_SIZE - aligned_length).align_down_4k()
};

// Map the memory
let mapping_flags = MappingFlags::from(mmap_prot) | MappingFlags::USER;
aspace.map_alloc(aligned_vaddr, aligned_length, mapping_flags, true)?;

// If it's a file mapping, read the file and write to the mapped memory
if !mmap_flags.contains(MmapFlags::MAP_ANONYMOUS) && fd >= 0 {
// Read from file
let mut data = vec![0u8; aligned_length];
let mut read_count = 0;
while read_count < length {
let buf = &mut data[read_count..];
let n = api::sys_read(fd, buf.as_mut_ptr() as *mut c_void, buf.len());
if n <= 0 {
break;
}
read_count += n as usize;
}

// Write to mapped memory
aspace.write(aligned_vaddr, &data[..read_count])?;
}

Ok(aligned_vaddr.as_usize())
})
}

遇到的问题和解决方案

问题 1sys_mmap 的地址对齐问题

原因:内存映射需要按页对齐,但用户传入的地址和长度可能不对齐。

解决:使用 align_down_4k() 和按页对齐长度:(length + PAGE_SIZE_4K - 1) & !(PAGE_SIZE_4K - 1)

问题 2:文件映射的实现

问题:如何将文件内容映射到内存?

解决:先读取文件内容到缓冲区,然后使用 aspace.write() 写入映射的内存区域。

问题 3:虚拟地址分配

问题:当 addr 为 NULL 时,如何选择合适的虚拟地址?

解决:在栈下方分配,计算 aspace.end() - USER_STACK_SIZE - aligned_length,确保不与栈冲突。

学习感悟

这个练习让我深入理解了系统调用的实现机制。特别是地址空间管理和内存映射,让我对操作系统内核有了更深入的理解。在实现过程中,需要仔细处理地址对齐、权限等问题,这些都是系统编程中需要注意的细节。


练习六:simple_hv

学习过程

这个练习是最复杂的,花了我最多时间。一开始我对虚拟化的概念不太理解,特别是两级地址转换机制,让我困惑了很久。看了很多资料,包括 RISC-V 虚拟化扩展规范,才慢慢理解了。

后来在调试过程中,遇到了很多问题,比如 htinst 寄存器不可用、地址转换错误等。好在最后通过看源码和调试,终于理解了。

实现

这个练习涉及虚拟化技术的多个核心方面:

  1. 客户机镜像加载:从文件系统加载 ELF 或二进制格式的客户机镜像
  2. 地址转换:实现两级地址转换(GVA → GPA → HPA)
  3. 虚拟 CPU 管理:保存和恢复虚拟 CPU 的寄存器状态
  4. 陷阱处理:处理客户机产生的陷阱,包括非法指令、页面错误、环境调用等

关键点:

  • 使用身份映射(GPA = HPA)简化实现
  • 禁用 VSATP,让客户机直接使用 GPA
  • 通过指令模拟实现特权指令的功能

遇到的问题和解决方案

问题 1:地址转换的复杂性

问题:最初不理解两级地址转换的工作原理,特别是 VSATP 和 HGATP 的关系。

原因:RISC-V H 扩展的地址转换机制比较复杂,需要理解:

  • VSATP 将 GVA 转换为 GPA
  • HGATP 将 GPA 转换为 HPA
  • 两者需要配合工作

解决

  • 通过阅读 RISC-V 虚拟化扩展规范理解地址转换流程
  • 使用身份映射简化实现
  • 在代码中添加详细注释说明地址转换过程

问题 2:htinst 寄存器不可用

问题:当 VSATP 禁用时,htinst 寄存器可能为 0,无法获取导致非法指令异常的指令。

原因htinst 寄存器在某些情况下(如 VSATP 禁用)可能不包含有效信息。

解决

  • 检查 htinst 是否为 0
  • 如果为 0,从 sepc 指定的物理地址读取指令
  • 使用内核的直接映射将 GPA 转换为 KVA 后读取

问题 3:身份映射的必要性

问题:不理解为什么需要为 GPA 创建身份映射。

原因:HGATP 使用 GPA 作为虚拟地址来访问页表,因此需要将 GPA 映射到 HPA(在身份映射中,GPA = HPA)。

解决

  • 理解 HGATP 的地址转换机制
  • 为页表根和入口地址创建身份映射
  • 确保 HGATP 可以正确访问页表页面

学习感悟

通过这个练习,我深入理解了虚拟化技术的核心原理。特别是两级地址转换机制,让我对 RISC-V H 扩展有了更深入的理解。

在调试过程中,我学会了如何排查虚拟化相关的问题,包括地址转换错误、寄存器状态错误等。这些经验对我理解操作系统内核非常有帮助。

虽然这个练习很复杂,但完成后的成就感也很强。虚拟化是操作系统中最复杂的技术之一,通过这个练习,我对操作系统有了更深入的理解。


总体学习收获

通过完成这些练习,我学到了很多:

  1. 模块化设计:arceOS 的模块化设计让我印象深刻。通过 feature 标志,可以在编译时选择不同的实现,这种设计非常灵活。

  2. no_std 环境编程:理解了在无标准库环境下如何实现常用功能。虽然代码量增加了,但这种设计让系统更加可控。

  3. 底层系统编程:深入理解了操作系统核心机制,包括内存管理、进程管理、虚拟化等。这些知识对我理解操作系统内核非常有帮助。

  4. 问题解决能力:在调试过程中,我学会了如何排查系统级问题。特别是通过看源码和调试,理解了很多之前不理解的概念。

  5. Rust 系统编程:通过实践加深了对 Rust 在系统编程中的应用理解。Rust 的所有权系统和类型安全,让系统编程更加安全。

后续思考

arceOS 的模块化设计给我留下了深刻印象。即使是简单的功能,也需要考虑底层实现。这种设计使得每个组件都可以独立测试和替换,为系统开发和调试带来了极大的便利。

通过这次学习,我不仅掌握了操作系统的基本原理,还深入理解了 Rust 在系统编程中的优势。内核的学习实际上最重要的部分可能更是在于解决问题,也就是在针对特定问题的排查思路上。

很感谢这次训练营的主办方,给了这么好的学习机会。虽然学习过程中遇到了很多困难,但完成后的成就感也很强。未来如果有机会,我还会继续深入学习操作系统内核。

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

起因是在配置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