0%

组件化内核的心得和实践经验

应用场景多样化->多种内核场景的出现

Unikernel->安全性换高效性->为一个APP适配一个内核

宏内核就是典型的Linux之类的操作系统

微内核主要是安全->用形式化证明安全性->反复切换用户态以至于很慢

虚拟机管理程序->hypervisor->多个内核每个内核认为自己独享了整个设备

关注点在于组件化场景下的异构内核的快速实现.理解概念和优势.

不同的需求对应了不同的内核->使用不同的组件实现不同的内核

使用宏内核+hypervisor的架构也可以实现这个功能,但是会产生性能瓶颈.

利用对unikernel的几个部件的连接方式的修改,加一个宏内核插件,这样就可以变成宏内核.

通过对unikernel对于hypervisor插件的调用,就可以变成hypervisor的系统.

其实上边论述的是优势所在.

BACKBONE层的重要性:把共性放在下层内容.
TASK的拓展:把任务看成是内核资源的集合.

未来工作:扩展泛型化——同时引入不同类型扩展 -> 甚至能到异构内核

回顾与展望

之前就是做了一系列的实验建立了unikernel的框架.

通过unikernel的形式通过增加一些组件来跨过这个边界,来实现一个宏内核.

从Unikernel到宏内核

通过跨越模式边界,弄一个简单的系统调用的操作.

增加用户特权级和特权级上下文切换是变成宏内核的关键.

实验1

rust-analyzer不能正常解析代码的原因,需要在.vscode/settings.json里加入"rust-analyzer.cargo.target": "riscv64gc-unknown-none-elf"

实验命令行:

1
2
3
make payload
./update_disk.sh ./payload/origin/origin
make run A=tour/m_1_0 BLK=y

如果不能执行payload说明代码版本太老了,需要先git fetch origin然后再git merge origin到当前的分支

这里注意如果make payload报错Error,那么一定是因为没有配置好musl的环境变量,注意看一下~/.bashrc,记得更新完~/.bashrc要进行狠狠的source ~/.bashrc

对于./update_disk.sh ./payload/origin/origin的操作对于我这种没操作过的人来说是非常神奇的操作.这一步实际上是把disk.img挂载在linux的文件系统里,然后在直接用linux的指令直接往里边拷贝应用文件的数据.

然后make run A=tour/m_1_0 BLK=y就和上一节课的实验一样了.

跑出来的结果是:

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
58
59
60
61
62
63
64
65
66
67
68
69
70
OpenSBI v0.9
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
| | | |_ __ ___ _ __ | (___ | |_) || |
| | | | '_ \ / _ \ '_ \ \___ \| _ < | |
| |__| | |_) | __/ | | |____) | |_) || |_
\____/| .__/ \___|_| |_|_____/|____/_____|
| |
|_|

Platform Name : riscv-virtio,qemu
Platform Features : timer,mfdeleg
Platform HART Count : 1
Firmware Base : 0x80000000
Firmware Size : 100 KB
Runtime SBI Version : 0.2

Domain0 Name : root
Domain0 Boot HART : 0
Domain0 HARTs : 0*
Domain0 Region00 : 0x0000000080000000-0x000000008001ffff ()
Domain0 Region01 : 0x0000000000000000-0xffffffffffffffff (R,W,X)
Domain0 Next Address : 0x0000000080200000
Domain0 Next Arg1 : 0x0000000087000000
Domain0 Next Mode : S-mode
Domain0 SysReset : yes

Boot HART ID : 0
Boot HART Domain : root
Boot HART ISA : rv64imafdcsu
Boot HART Features : scounteren,mcounteren,time
Boot HART PMP Count : 16
Boot HART PMP Granularity : 4
Boot HART PMP Address Bits: 54
Boot HART MHPM Count : 0
Boot HART MHPM Count : 0
Boot HART MIDELEG : 0x0000000000000222
Boot HART MEDELEG : 0x000000000000b109

d8888 .d88888b. .d8888b.
d88888 d88P" "Y88b d88P Y88b
d88P888 888 888 Y88b.
d88P 888 888d888 .d8888b .d88b. 888 888 "Y888b.
d88P 888 888P" d88P" d8P Y8b 888 888 "Y88b.
d88P 888 888 888 88888888 888 888 "888
d8888888888 888 Y88b. Y8b. Y88b. .d88P Y88b d88P
d88P 888 888 "Y8888P "Y8888 "Y88888P" "Y8888P"

arch = riscv64
platform = riscv64-qemu-virt
target = riscv64gc-unknown-none-elf
smp = 1
build_mode = release
log_level = warn

[ 21.794824 0 fatfs::dir:139] Is a directory
[ 22.065035 0 fatfs::dir:139] Is a directory
[ 22.359963 0 fatfs::dir:139] Is a directory
[ 22.490439 0 fatfs::dir:139] Is a directory
app: /sbin/origin
paddr: PA:0x80642000
Mapping user stack: VA:0x3fffff0000 -> VA:0x4000000000
New user address space: AddrSpace {
va_range: VA:0x0..VA:0x4000000000,
page_table_root: PA:0x80641000,
}
Enter user space: entry=0x1000, ustack=0x4000000000, kstack=VA:0xffffffc080697010
handle_syscall ...
[SYS_EXIT]: process is exiting ..
monolithic kernel exit [Some(0)] normally!

让我们看一下orgin的app内容:

1
2
3
4
5
6
7
8
9
10
#[no_mangle]
unsafe extern "C" fn _start() -> ! {
core::arch::asm!(
"addi sp, sp, -4",
"sw a0, (sp)",
"li a7, 93",
"ecall",
options(noreturn)
)
}

很容易懂的,就是调用了第93syscall.

课后练习

主要是要理解AddrSpacemap_alloc的时候的populating选项.

根据在rCore中学到的经验,去查看源码,我们的结构是这样的.

|800

就是在创建MemoryArea的时候要传入一个泛型Backend.

应该就是和这边页的懒加载有关的内容.

调用到最后调用的是modules/axmm/src/backend/alloc.rs这个文件里的map_alloc,因为层层抽象,这里各个参数都还原成了最开始tour/m_1_0/src/main.rs里的变量名称.

|800

然后关键代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if populate {
// allocate all possible physical frames for populated mapping.
for addr in PageIter4K::new(start, start + size).unwrap() {
if let Some(frame) = alloc_frame(true) {
if let Ok(tlb) = pt.map(addr, frame, PageSize::Size4K, flags) {
tlb.ignore(); // TLB flush on map is unnecessary, as there are no outdated mappings.
} else {
return false;
}
}
}
true
} else {
// Map to a empty entry for on-demand mapping.
let flags = MappingFlags::empty();
pt.map_region(start, |_| 0.into(), size, flags, false, false)
.map(|tlb| tlb.ignore())
.is_ok()
}

这里假如我们的poplulate是选定的true,那么就会立即根据4k一个大小的frame进行内存申请,然后把这个虚拟地址和刚刚申请到的framepage_table中映射起来.

但是如果我们选定populatefalse,那么直接把虚拟地址和0这个错误的物理地址映射起来.

那么这时候实际上就需要我们在访问到这个物理地址的时候,再进行物理页申请.

那么在访问到这个地址的时候会发生缺页异常.

这时候我们运行一下应用:

1
2
3
make payload
./update_disk.sh payload/origin/origin
make run A=tour/m_1_0/ BLK=y

这是对应的log:

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
OpenSBI v0.9
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
| | | |_ __ ___ _ __ | (___ | |_) || |
| | | | '_ \ / _ \ '_ \ \___ \| _ < | |
| |__| | |_) | __/ | | |____) | |_) || |_
\____/| .__/ \___|_| |_|_____/|____/_____|
| |
|_|

Platform Name : riscv-virtio,qemu
Platform Features : timer,mfdeleg
Platform HART Count : 1
Firmware Base : 0x80000000
Firmware Size : 100 KB
Runtime SBI Version : 0.2

Domain0 Name : root
Domain0 Boot HART : 0
Domain0 HARTs : 0*
Domain0 Region00 : 0x0000000080000000-0x000000008001ffff ()
Domain0 Region01 : 0x0000000000000000-0xffffffffffffffff (R,W,X)
Domain0 Next Address : 0x0000000080200000
Domain0 Next Arg1 : 0x0000000087000000
Domain0 Next Mode : S-mode
Domain0 SysReset : yes

Boot HART ID : 0
Boot HART Domain : root
Boot HART ISA : rv64imafdcsu
Boot HART Features : scounteren,mcounteren,time
Boot HART PMP Count : 16
Boot HART PMP Granularity : 4
Boot HART PMP Address Bits: 54
Boot HART MHPM Count : 0
Boot HART MHPM Count : 0
Boot HART MIDELEG : 0x0000000000000222
Boot HART MEDELEG : 0x000000000000b109

d8888 .d88888b. .d8888b.
d88888 d88P" "Y88b d88P Y88b
d88P888 888 888 Y88b.
d88P 888 888d888 .d8888b .d88b. 888 888 "Y888b.
d88P 888 888P" d88P" d8P Y8b 888 888 "Y88b.
d88P 888 888 888 88888888 888 888 "888
d8888888888 888 Y88b. Y8b. Y88b. .d88P Y88b d88P
d88P 888 888 "Y8888P "Y8888 "Y88888P" "Y8888P"

arch = riscv64
platform = riscv64-qemu-virt
target = riscv64gc-unknown-none-elf
smp = 1
build_mode = release
log_level = warn

[ 21.690418 0 fatfs::dir:139] Is a directory
[ 21.963457 0 fatfs::dir:139] Is a directory
[ 22.252957 0 fatfs::dir:139] Is a directory
[ 22.383790 0 fatfs::dir:139] Is a directory
app: /sbin/origin
paddr: PA:0x80642000
Mapping user stack: VA:0x3fffff0000 -> VA:0x4000000000
New user address space: AddrSpace {
va_range: VA:0x0..VA:0x4000000000,
page_table_root: PA:0x80641000,
}
Enter user space: entry=0x1000, ustack=0x4000000000, kstack=VA:0xffffffc080687010
[ 23.235085 0:4 axhal::arch::riscv::trap:24] No registered handler for trap PAGE_FAULT
[ 23.319751 0:4 axruntime::lang_items:5] panicked at modules/axhal/src/arch/riscv/trap.rs:25:9:
Unhandled User Page Fault @ 0x1002, fault_vaddr=VA:0x3ffffffffc (WRITE | USER):
TrapFrame {
regs: GeneralRegisters {
ra: 0x0,
sp: 0x3ffffffffc,
gp: 0x0,
tp: 0x0,
t0: 0x0,
t1: 0x0,
t2: 0x0,
s0: 0x0,
s1: 0x0,
a0: 0x0,
a1: 0x0,
a2: 0x0,
a3: 0x0,
a4: 0x0,
a5: 0x0,
a6: 0x0,
a7: 0x0,
s2: 0x0,
s3: 0x0,
s4: 0x0,
s5: 0x0,
s6: 0x0,
s7: 0x0,
s8: 0x0,
s9: 0x0,
s10: 0x0,
s11: 0x0,
t3: 0x0,
t4: 0x0,
t5: 0x0,
t6: 0x0,
},
sepc: 0x1002,
sstatus: 0x40020,
}

实现方法tour/m_2_0里的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#[register_trap_handler(PAGE_FAULT)]
fn handle_page_fault(vaddr: VirtAddr, access_flags: MappingFlags, is_user: bool) -> bool {
if is_user {
if !axtask::current()
.task_ext()
.aspace
.lock()
.handle_page_fault(vaddr, access_flags)
{
ax_println!("{}: segmentation fault, exit!", axtask::current().id_name());
axtask::exit(-1);
} else {
ax_println!("{}: handle page fault OK!", axtask::current().id_name());
}
true
} else {
false
}
}

这里主要是调用了aspace也即当前任务地址空间中处理缺页故障的方法.

就像我们之前在上一节分析到的Backendmap方法一样,还是调用了Backendremap方法.

就是当即分配一个frame,然后把当前出问题的va虚拟地址重新映射到frame.

stage3总结

u1.0

很简单,定位到 axstd 里修改 println 的输出,前后加上转义字符就好了

u2.0

找到 axstd 里,把 collection 替换成自己的 collection 包就可以,在自己的 collection 包实现 HashMap 结构体,只需要简单实现接口,覆盖测试用例即可。另外自己在替换 axstd 的 collection 的时候,遇到的问题是模块的导出关系没弄清导致的刚开始测试用例找不到我的代码.

u3.0

做的过程中,先按照自己的想法做了一下,发现编译不过哈哈,然后看群里消息,学到了 const generic 这个知识点。然后顺便看到了 blogOS 对于 bump allocator 的介绍,就拿过来参考了一下,这个 bump allocator 确实简单的不可思议。 最后写完之后还是不过,打了日志才发现是自己初始化漏了 b_pos 和 p_pos 的初始化.

h1.0 & h2.0

实验内容还是很简单的,但是虚拟化这块之前没有了解过,但是比较感兴趣。中间一周把宏内核实验翘掉读了虚拟化原理那本书。在听老师讲解过程中感觉,对一个不熟悉的东西,尽量简化,然后做出一个最小 work 的样板,然后不断迭代添加复杂功能,是非常好的学习方式。

stage4总结

stage 4 算是很扎实的一个阶段。这个阶段从头开始学习了我原来不怎么熟悉的异步运行时。整体上来说算是比较系统的学习了如何构建一个异步运行时,同时也阅读了不少tokio这种生产环境级别的异步运行时代码,受益良多。
本阶段还学习了一些关于 uring io 的知识,同时也顺便比较系统的梳理了 linux io 这块的知识点。通过每周一次的交流活动中能比较有效的调整自己的学习方向,同时也能补充很多有用的信息。很多同学水平很高,在交流过程中深感差距,还需要不断学习。

stage4 工作进度

本周工作(第一周):

阅读以下两个链接中文档,比较系统的了解了 rust 的异步原理

####下周安排(第二周):

  • 阅读 tokio 源码,学习生产环境中的异步运行时实现

本周工作(第二周):

由于本项目最终要实现一个基于 uring io 的异步运行时,于是决定从 io 的角度切入 tokio 的源码阅读。在阅读过程中发现 tokio 的文件 io 部分都是转发给某个独立线程,是基于阻塞式的操作。为了对比文件 io ,还阅读了部分 tcp 相关的源码,证实了的确网络 io 是使用了基于 epoll 的 mio 来做管理。而且本周对 uring io 有一个粗略地了解,目前看来 uring io 在文件 io 方面可能优势会更明显。 网络 io 这块相较于 epoll 优势没那么大,那么接下来第三周可能要优先实现基于 uring io 的文件 io 异步运行时的相关工作。

此外本周还阅读了以下资料

下周安排(第三周):

编写一个简易的基于 uring io 的文件 io 的异步运行时。

本周工作(第三周):

本周首先调研了一下在 smol 中实现基于 uring io 的可能性。首先 smol 社区中已经有一个 PR
async-io PR:Integrate io_uring in the Reactor
● 简要介绍了实现思路,
● 作者说要等等 polling Expose raw handles for the Poller 这个 issuing合并.
大概粗略浏览了一下,但是由于对 uring io 不太熟悉用法,还没有看懂,暂时搁置。

然后继续阅读了 uring io 的相关资料,包括

最后是实现我自己的uring io异步运行时
我的async-fs-uring,这里实现了一个建议的基于 reactor 模式异步运行时,并实现了基于uring io的文件读。主要思想就是把 io 委托给 reactor 去提交,然后 reactor 不断轮询,如果有 io 完成了,就返回给对应的异步任务。实现过程中比较困难的点就是buf 管理,需要保证 buf 在异步读过程中一直有效。我这里做法是直接把 buf 的所有权移交给 UringReadFuture.这只是一个权宜之计,因为我这里实现的比较简单,在异步读进行过程中 UringReadFuture不会被 drop 掉。实际上后来也阅读了 tokio-uring 的相关设计文档,也了解到了一些更合理的设计方案,但是还没有时间来实现。

未来计划:通过实现一个建议的基于 uring io 的异步运行时让我对 uring io 有了基本的了解。后续可能会进一步了解生产环境级的基于 uring io 的异步运行时的实现以及与传统阻塞式和epoll结合的异步运行时的实现差异

第一阶段练习总结

作为一名Cpper,我之前对Rust的了解可以说是毫无了解,这次受到朋友的邀请,第一次接触Rust,刚开始以为就是像C一样的过程语言。然而,当我真正开始深入学习和使用Rust时,我发现它远比我预想的要有趣和富有挑战性。特别是在进行Rustlings练习的过程中,我收获颇丰,也深刻体会到了Rust语言的强大和魅力。

Rustlings是一个设计精良的Rust语言练习项目,通过一系列由简到难的练习题,帮助开发者逐步掌握Rust的语法和特性。在练习的过程中,我遇到了许多看似简单但实则深奥的问题,这些问题让我不断思考和探索,也让我对Rust有了更深入的理解。

通过Rustlings的练习,我只能感叹面向编译器编程的魅力。

此外,Rustlings的练习还让我认识到了自己在编程思维方面的不足总的来说,Rustlings练习是我学习Rust过程中的一个重要环节。它不仅让我掌握了Rust的基本语法和特性,还锻炼了我的编程思维和解决问题的能力。我相信,在未来的学习和工作中,我会继续利用Rustlings等资源来深化对Rust的理解和应用,并不断提升自己的编程水平。同时,我也期待在未来的项目中能够充分发挥Rust的优势,写出更加健壮、高效的程序。

2024秋冬季OS2EDU第三阶段总结报告

概述

在2024秋冬季开源操作系统训练营的第三阶段中,我深入研究并实现了一个高效的内存分配器,该分配器结合了Slab分配器和TLSF(Two-Level Segregated Fit)分配器的优势。本报告将详细分析每个链接中的源码,并探讨我实现的新调度器的优势。

Slab分配器

源码分析

Slab分配器的核心思想是将内存分割成固定大小的块,并将这些块组织成一个或多个列表,以便于快速分配和释放。每个Slab结构体维护了一个特定大小的块列表。

1
2
3
4
pub struct Slab<const BLK_SIZE: usize> {
free_block_list: FreeBlockList<BLK_SIZE>,
total_blocks: usize,
}

Slab结构体包含一个free_block_list,这是一个双向链表,用于存储可用的块,以及total_blocks,记录总块数。Slab的实现提供了newgrowallocatedeallocate等方法,用于初始化、扩展、分配和释放内存块。

优势分析

  • 固定大小的块:由于每个Slab只管理固定大小的块,因此分配和释放操作可以非常快速。
  • 缓存局部性Slab分配器倾向于将最近释放的块重新分配给请求,这有助于提高缓存局部性。
  • 减少外部碎片:通过固定大小的块分配,Slab分配器可以减少外部碎片。

TLSF分配器

源码分析

TLSF分配器实现了一种动态内存分配算法,它通过维护多个空闲块列表来优化内存分配。TLSF分配器的关键特点是其二级索引结构,它允许快速找到合适的空闲块。

1
2
3
4
5
pub struct TlsfByteAllocator {
inner: Tlsf<'static, u32, u32, 16, 2>,
total_bytes: usize,
used_bytes: usize,
}

TlsfByteAllocator结构体封装了rlsf::Tlsf,并提供了initadd_memoryallocdealloc等方法,用于初始化、添加内存、分配和释放内存。

优势分析

  • 高内存利用率:TLSF通过低粒度(SLLEN为2)的索引结构,可以更有效地利用内存,减少内部碎片。
  • 快速分配和释放:TLSF的二级索引结构允许快速定位空闲块,从而加快分配和释放速度。

新调度器实现

设计理念

在我的新调度器设计中,我结合了SlabTLSF的优点,将较小的块分配任务交给Slab分配器,而将较大的块分配任务交给TLSF分配器。这种设计允许我复用已经分配的指定大小的块,从而提高内存分配的效率。

优势分析

  • 灵活性:新调度器可以根据请求的大小灵活选择使用SlabTLSF,这使得它可以适应不同的内存分配需求。
  • 内存利用率:通过将较大的块分配给TLSF,我可以利用其高内存利用率的优势,同时Slab分配器可以快速处理小规模的分配请求。
  • 性能优化:新调度器通过复用已经分配的块,减少了分配和释放操作的开销,提高了整体性能。

学习感想

通过参与2024秋冬季开源操作系统训练营,我对内存管理有了更深入的理解。实现SlabTLSF分配器的过程让我体会到了操作系统中内存管理的复杂性和重要性。我学到了如何设计和实现高效的内存分配策略,这对于我未来的职业生涯和学术研究都是非常宝贵的经验。此外,我也认识到了团队合作的重要性,因为在实现过程中,我与队友们进行了深入的讨论和协作,共同解决了遇到的技术难题。总的来说,这次训练营不仅提升了我的技术能力,也锻炼了我的团队合作能力。

结论

通过实现基于SlabTLSF的内存分配器,我成功地提高了内存分配的效率和内存利用率。新调度器的设计不仅灵活,而且能够根据请求的大小选择合适的分配器,这在操作系统的内存管理中是非常重要的。我的实现展示了如何在保证性能的同时,优化内存使用,这对于任何需要高效内存管理的系统都是至关重要的。

一阶段学习体会

早就听说过大名鼎鼎的 rustlings, 这次借着 oscamp 的机会来做一做。做的过程中发现 oscamp 的 rustlings 似乎比官方的 rustlings 加了很多私货,难度大了不少,
但是也的确把编写 很多有用的 rust 特性 和 os 过程中会用得到的一些 rust 的 feature 都涵盖了。在做的过程中,也学习到了很多以前自己没有注意或者使用过的 rust 特性。
更重要的是,通过一些题目的学习,能摆脱原来写 c++ 的思维,写出一些更 rust 风格的代码,比如返回值多用 Option 和 Result,更多的使用 match 和 if let, 而不是用原来 c++/c 风格的错误判断,
以及更多的用迭代器等方法。

一阶段学完之后,尤其是经历了大量和编译器的斗智斗勇之后,我对 rust 的理解加深很多,也对 rust 带来的相比 c++ 的很多优势有了更深的体会。相信 rust 和 c++ 的学习会互相促进。

二阶段学习体会

本科期间零零散散一致有学过 rust 和 rcore 的一些东西,由于各种各样的事情没能坚持下来。
读硕士之后刚好又系统学习了一下 rust ,正巧有同学来约我参加 oscamp , 于是就来参加。

如今再做 rcore 感觉要比本科期间轻松了很多。在游刃有余之后,能从 rcore 中汲取的知识也就更多了。
文件系统的组织方式、并发的实现和管理等,这些本科期间令我头晕转向的知识点,现在都能比较轻松地理解。也能从中窥探出一个真实生产环境下的操作系统是如何设计的。

本次实验中,前四个实验都比较轻松,思路清晰地拿下了。最让我头晕转向的是第五个实验,也就是死锁检测的实现。
刚开始被银行家算法迷惑,感觉很难实现。后来又做了尝试做了环路检测来检测死锁。但是做的过程中发现环路检测可能比较难处理信号量。于是又回过头来思考银行家算法。
结合前面尝试做环路检测的过程,觉得只要实现一套类似银行家算法的检测机制就好了。于是把代码全部推翻,从头来过很快就拿下了。

这一次 rcore 学习体验不错,希望接下来的三阶段能学到一些有意思、更贴近实际场景的东西。

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 的无栈协程,从而将开销进一步降低.此外,迁移到异步系统调用时,不可避免的会破坏与现有同步代码的兼容性.因此,实现基于有栈绿色线程的异步系统调用也是一个值得尝试的目标.