内容

虚拟机运行的实验内核是第一周的u_3_0:从pflash设备读出数据,验证开头部分。
有两种处理方式:
- 模拟模式 - 为虚拟机模拟一个pflash,以file1为后备文件。当Guest读该设备时,提供file1文件的内容。
- 透传模式 - 直接把宿主物理机(即qemu)的pflash透传给虚拟机。
优劣势:模拟模式可为不同虚拟机提供不同的pflash内容,但效率低;透传模式效率高,但是捆绑了设备。
应用场景多样化->多种内核场景的出现
Unikernel->安全性换高效性->为一个APP适配一个内核
宏内核就是典型的Linux之类的操作系统
微内核主要是安全->用形式化证明安全性->反复切换用户态以至于很慢
虚拟机管理程序->hypervisor->多个内核每个内核认为自己独享了整个设备
关注点在于组件化场景下的异构内核的快速实现.理解概念和优势.
不同的需求对应了不同的内核->使用不同的组件实现不同的内核
使用宏内核+hypervisor的架构也可以实现这个功能,但是会产生性能瓶颈.
利用对unikernel的几个部件的连接方式的修改,加一个宏内核插件,这样就可以变成宏内核.
通过对unikernel对于hypervisor插件的调用,就可以变成hypervisor的系统.
其实上边论述的是优势所在.
BACKBONE层的重要性:把共性放在下层内容.
TASK的拓展:把任务看成是内核资源的集合.
未来工作:扩展泛型化——同时引入不同类型扩展 -> 甚至能到异构内核
之前就是做了一系列的实验建立了unikernel的框架.
通过unikernel的形式通过增加一些组件来跨过这个边界,来实现一个宏内核.
通过跨越模式边界,弄一个简单的系统调用的操作.
增加用户特权级和特权级上下文切换是变成宏内核的关键.
rust-analyzer不能正常解析代码的原因,需要在.vscode/settings.json里加入"rust-analyzer.cargo.target": "riscv64gc-unknown-none-elf"
实验命令行:
1 | make payload |
如果不能执行
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 | OpenSBI v0.9 |
让我们看一下orgin的app内容:
1 | #[no_mangle] |
很容易懂的,就是调用了第93号syscall.
主要是要理解
AddrSpace在map_alloc的时候的populating选项.
根据在rCore中学到的经验,去查看源码,我们的结构是这样的.

就是在创建MemoryArea的时候要传入一个泛型Backend.
应该就是和这边页的懒加载有关的内容.
调用到最后调用的是modules/axmm/src/backend/alloc.rs这个文件里的map_alloc,因为层层抽象,这里各个参数都还原成了最开始tour/m_1_0/src/main.rs里的变量名称.

然后关键代码是:
1 | if populate { |
这里假如我们的poplulate是选定的true,那么就会立即根据4k一个大小的frame进行内存申请,然后把这个虚拟地址和刚刚申请到的frame在page_table中映射起来.
但是如果我们选定populate为false,那么直接把虚拟地址和0这个错误的物理地址映射起来.
那么这时候实际上就需要我们在访问到这个物理地址的时候,再进行物理页申请.
那么在访问到这个地址的时候会发生缺页异常.
这时候我们运行一下应用:
1 | make payload |
这是对应的log:
1 | OpenSBI v0.9 |
实现方法tour/m_2_0里的实现:
1 | #[register_trap_handler(PAGE_FAULT)] |
这里主要是调用了aspace也即当前任务地址空间中处理缺页故障的方法.
就像我们之前在上一节分析到的Backend的map方法一样,还是调用了Backend的remap方法.
就是当即分配一个frame,然后把当前出问题的va虚拟地址重新映射到frame.
第四阶段参加了项目四基于协程异步机制的 OS,主要学习了协程异步的基本原理,阅读了 Tokio 源码,还了解了 io_uring。最终实现了简单的异步任务调度的操作系统,实现了异步任务调度功能,已经用于模拟异步延迟的 delay 函数,下一阶段还要继续实现异步的 I/O。
第四阶段还旁听了项目一题目一 Unikernel 支持 LinuxApp 第一周的学习,并参与项目实验,切身体验到了单内核应用开发所遇到的问题和难点。第二周是关于实现 Linux 应用支持的,因为本身对 Linux 应用了解就较少,也没精力和时间投入学习,只能战略性先放弃了。
我觉得经过两个项目的学习,我已经有了一个构建单内核异步操作系统的想法:内核像库一样提供,开发者只需选择需要使用的 future,然后像构建普通应用一样构建单内核操作系统,可以主要应用于嵌入式系统。接下来就是尝试实现这个框架,OS 库实现内存分配,异步任务调度等功能,使开发者调用库即可构建支持 Http Server 的单内核 OS,并运行到真机上。
同时,我也输出了两篇笔记,内容如下:
协程的目的在于解决并发问题。常规并发是借助操作系统提供的线程来实现,其过程包含生成线程,通过系统调用执行 I/O 操作,并且在 I/O 执行期间会阻塞相应线程直至操作完成。在此过程中,存在两大显著问题:
协程主要通过以下两种方式来解决上述并发问题:
协程的概念相对模糊,它本质上是一种可以暂停后再恢复执行的函数。不过其暂停机制存在歧义,可分为显式的通过语法函数实现(对应协作式调度)以及隐式地由运行时执行(对应抢占式调度)这两种情况。像知名的 Golang 使用的是堆栈式的抢占式调度方案,在 Rust 术语里,将这种类似操作系统线程的、堆栈式的抢占式调度方案定义为 “绿色线程” 或 “虚拟线程”。从本质上看,它们除了是在用户态实现之外,和操作系统线程并无根本差异。而严格意义上的协程,理应是无栈的协作式调度。
协作式调度的特点是,任务若不主动让出执行权(yield),就会持续执行下去。与之相反,抢占式调度则是任务随时可能被切换出去。现代操作系统出于避免恶意程序长时间占用 CPU 的考量,大多采用抢占式调度方式。然而,抢占式调度存在明显缺点,由于任务随时可能被切换,所以必须保存任务的堆栈,如此一来,当任务再次被切回时,才能恢复到切换出去时的状态。这就导致在大规模并发场景下,需要耗费大量内存来保存众多任务的堆栈。
有栈协程就是上述提到的在抢占式调度场景下,需要保存任务堆栈的协程类型。那么无栈协程是如何实现的呢?在协作式调度中,因为任务不会被外部强制切出,所以可以在主动让出执行权(yield)时,仅保存必要的状态信息,无需像有栈协程那样完整保存计算过程中的数据。更进一步来说,甚至可以直接利用状态机来实现,从而彻底摆脱对堆栈保存的依赖。
早期 Rust 曾有过一个堆栈式协程的方案,但在 1.0 版本发布前被移除了。对于 Rust 而言,绿色线程需要解决的关键问题是怎样减小预分配堆栈的大小,进而降低内存开销,毕竟若不能比操作系统线程更节省内存,那使用操作系统线程就好了,没必要再另辟蹊径。
其中 Golang 采用的一种方法是堆栈复制,即先分配一个较小的堆栈,待其达到上限时,再将数据转移至更大的堆栈。但这种方式会引发两个问题:一方面,需要跟踪并更新原先指向堆栈的指针,这一过程本质上和垃圾回收器类似,只是将释放内存变成了移动内存;另一方面,内存复制操作会带来额外的性能开销。而 Golang 本身就有垃圾回收器且能接受额外的性能开销,所以在某些方面可以应对这种方式带来的问题。但对于注重性能和内存管理效率的 Rust 来说,这两点都是难以接受的,因此 Rust 最终选择使用无栈式的协程方案,其实现原理是将代码编译成状态机,虽然这种方式相对较难理解,但社区中已有不少优秀文章对此进行了清晰讲解,例如 blog-os 的 async-await 章节。以下是编译成状态机后的大致伪代码示例:
1 | enum ExampleStateMachine { |
协程主要是用于解决并发问题,而非性能问题。
Golang 中的协程属于 “有栈协程”,与操作系统中基于堆栈式抢占式调度的线程本质相同,在 Rust 中被称作 “绿色线程”。而 Rust 实现的协程是 “无栈协程”,采用无堆栈的协作式调度方案,其核心原理是将代码编译成状态机。
网上常见对于 async Rust 的批判,认为其提升不了多少性能,却需要投入大量资源进行开发,还增加了开发复杂度,甚至会导致 “
函数着色” 问题。实际上,这些批判有一定道理,但需要明确的是协程本身旨在解决并发问题,而非聚焦于性能提升。
需要了解操作系统基础知识,实现简单的操作系统内核框架,并且要实现堆内存分配,因为必须要使用 Box 和 Pin,还要实现打印功能用于测试,还需要实现时间获取,用于模拟异步延迟。
Rust 提供了 Future trait 用于实现异步操作,其结构定义如下:
1 | pub trait Future { |
poll 方法接受两个参数,Pin<&mut Self> 其实和 &mut Self 类似,只是需要 Pin 来固定内存地址,cx 参数是用于传入一个唤醒器,在异步任务完成时可以通过唤醒器发出信号。poll 方法返回一个 Poll 枚举:
1 | pub enum Poll<T> { |
大致工作原理其实很简单,调用异步函数的 poll 方法,如果返回的是 Pending 表示值还不可用,CPU 可以先去执行其他任务,稍候再试。返回 Ready 则表示任务已完成,可以接着执行往下的程序。
知道基本原理后,基于异步来构建操作系统的思路就很清晰了,即遇到 Pending 就切换到另一个任务,直到所有任务都完成。
先创建一个 Runtime 结构体,包含 tasks 队列,用于存储需要执行的任务。spawn 方法用于将任务添加到队列,run 方法用于执行异步任务,其逻辑是先取出队列中一个任务,通过 loop 不断尝试执行异步任务,如果任务 Pending,则先去执行另一个任务,以此实现非阻塞,直到队列任务全部为空。
poll 方法需要传入 Waker 参数,用于在异步任务完成后发出信号,因为目前是 loop 盲等的机制,并没有实现真正的唤醒,所以先采用一个虚假唤醒器。
1 | pub struct Runtime { |
任务的结构也很简单,含有一个内部 future,在 poll 时执行 future 的 poll。内部 future 定义很长但是不复杂,基于的 future 结构是 Future<Output = ()>,然后需要一个 'static 生命周期,同时需要 Send 和 Sync 实现跨线程共享,虽然现在没用到,但是 Rust 编译器可不同意你不写。dyn 声明动态类型也是必须要,同时还需要使用 Box 包裹来使编译器确定闭包大小,Pin 用于固定内存位置不可以动。
1 | struct Task { |
无需了解过多,真正实现唤醒器的时候才需深入了解。
1 | fn dummy_waker() -> Waker { |
基础框架实现完后,还需要实现一个延迟任务,用于模拟耗时操作。我打算实现一个 delay 方法模拟 sleep,以测试任务 sleep 的时候运行时会切换到下一个任务。代码很简单,DelayFuture 结构体包含一个 target_time 和一个 waker,target_time 表示延迟到什么时候,waker 用于在延迟完成后发出信号。poll 方法中判断当前时间是否大于 target_time,如果大于则返回 Ready,否则返回 Pending。
1 | pub async fn delay(ms: usize) { |
rust_main 操作系统的 rust 入口,task1 和 task2 是两个异步任务,task1 先打印 start task 1,然后延迟 200ms,再打印 end task 1。task2 也是类似,只是延迟时间更长。运行时先执行 task1,然后切换到 task2,再切换到 task1,最后切换到 task2。
1 | #[no_mangle] |
打印结果:
1 | start task 1 |
Rust 的异步编程还是很方便的,但是目前的实现还是很粗糙,比如没有实现真正的唤醒器,也没有实现真正的异步操作,只是模拟了异步延迟。下一步是实现真正的唤醒器,然后尝试实现异步的 I/O。
我选择的方向是arceos宏内核扩展,主要工作是在新的宏内核扩展starry-next上,补全系统调用。我希望通过这个过程能更好的理解宏内核的内部的结构,和组件化操作系统的抽象解耦思想,也希望自己的代码能够更好的实现宏内核扩展和基座的解耦,虽然现在还是以先实现功能为前提,但是先完成后完美,之后逐步完善功能,并且抽离耦合的部分,让系统调用的实现更加优雅。
目前已经完成绝大部分系统调用的实现,最花费时间的是任务clone的相关系统调用,将arceos的多任务切换(时钟中断抢占机制),地址空间(与rcore的地址空间排布不同,复制内核的地址空间)等相关的知识进行研究之后,才将clone的简单功能fork实现,之后关于任务相关的系统调用就很顺利了。
接下来,对arceos的文件系统进行研究,研究了线程间资源独立的新机制namespace,使用了一个很好的方法,实现了unikernel资源全局共享,使用宏内核扩展后,资源线程间独立或共享的机制。实现文件系统相关的api主要也就是调用axfs提供的api进行实现,需要解决的是axfs不支持open目录,但是openat这个系统调用需要打开目录,所以需要解决这个问题。
通过四阶段的学习,学习了组件化操作系统实现宏内核的思想和方法,经历过这三个月的学习,从不会rust和操作系统的小白,慢慢一步一步做,从学习rust,到完成rcore,再到学习arceos,逐步学习操作系统多任务、地址空间、文件系统等等,更加理解了riscv特权级结构,sv39页表结构等risv体系结构知识,总结这三个月,收获非常大,在这里也希望开源操作系统训练营可以越办越好,更多的人可以在这里学到知识,收获成长。
在该阶段中,我完成了前期的练习,着重学习了ArceOS宏内核模式下是如何支持Linux应用的,并期望基于此,实现劫持ecall以在Unikernel形态下支持Linux应用。虽然这个方案最终感觉不太可行,但在这个过程中,我还是学到了很多系统相关的知识,收获颇丰,期待下一次的训练营!
这个项目阶段个人感觉时间还是太短了,且靠近学期末。如果可以把项目阶段放到假期,或者把前期训练阶段放到假期、把项目阶段放到学期开始,可能大家的参与度都会更高一些。
个人博客网页:https://yumu20030130.github.io/
行文匆忙,多有不严谨之处,后续会在个人博客中完善。
Unikernel内核模式下支持Linux多应用的前期准备:https://yumu20030130.github.io/2024/12/21/Unikernel%E5%86%85%E6%A0%B8%E6%A8%A1%E5%BC%8F%E4%B8%8B%E6%94%AF%E6%8C%81Linux%E5%A4%9A%E5%BA%94%E7%94%A8%E7%9A%84%E5%89%8D%E6%9C%9F%E5%87%86%E5%A4%87/
ArceOS宏内核模式基本应用支持:https://yumu20030130.github.io/2024/11/30/%E6%9C%80%E7%AE%80%E5%AE%8F%E5%86%85%E6%A0%B8%E6%A8%A1%E5%BC%8F%E5%86%85%E6%A0%B8%E6%9E%84%E5%BB%BA/
ArceOS宏内核模式如何支持Linux应用的运行:https://yumu20030130.github.io/2024/12/20/ArceOS%E5%AE%8F%E5%86%85%E6%A0%B8%E6%A8%A1%E5%BC%8F%E5%A6%82%E4%BD%95%E6%94%AF%E6%8C%81Linux%E5%BA%94%E7%94%A8%E7%9A%84%E8%BF%90%E8%A1%8C/
这个阶段,我选择了unikernel方向一的任务,为arceos实现linux的app移植,其中我进行了如下方向的探索:
这个任务对我来说属实难度比较大,总结一下有两点没有做好:
往后顺着PPT上的东西继续探索吧,我不是特别在意一定要在训练营之中去做什么事儿。出于对这个项目的兴趣,我打算保持跟这里的交流,往后继续探索,验证一下自己的想法。然后看看寒假有没有机会去实习,吧这个项目完善完善,而且家里也没人,假期回家的意义也不大。
在这个仓库中 https://github.com/ghostdragonzero/arceos_test 我直接修改了ixgbe的pid vid匹配参数来匹配igb网卡,并且通过修改axdriver到引用我自己的仓库。以此来实现替换ixgbe的目的。
拉取后可以直接执行make A=examples/httpserver PLATFORM=aarch64-qemu-virt LOG=debug SMP=2 NET=y FEATURES=driver-ixgbe NET_DEV=user run
这个仓库 https://github.com/ghostdragonzero/igb-driver/tree/my_driver
是我在share_test框架下实现的驱动完成了 link_up rx_init tx_init 初始化流程也是我在这个框架下实现然后搬到ixgbe框架里在arceos内运行没问题的
但是这里实现的memry是使用的arceos中hal的实现 ring结构是直接使用的ixgbe的ring
这是我第一次成功完成这个训练营,之前参加了很多次都在各种阶段就没有坚持下去,这次能成功完成是相当开心的。
并且这次的驱动开发给我感受最深的就是,硬件(虽然是模拟的外设)的逻辑是很固定的出现了预期之外的行为,99%是自己写的存在错误
记录我认为在我开发的时候我查看时间最久的问题:
这次学习给我最大的收获就是真的从datasheet来一步步的开发一个驱动的经验,并且在开发的过程中一步一步的调试,通过设置断点来看寄存器的值。这个是我最喜欢的一个功能,因为我在平时驱动的开发时候最难受的就是修改需要编译版本才能做验证,找不到一个快速验证的办法导致问题进展缓慢。如果能有多一点的可以这样来验证的控制就好了。
再来说一下,这次学习中我的不足,实际上我算是让这个网卡能用起来了 但是我还是不理解他更上层的东西,不清楚他的ring是怎样收发信息的,不明白他这个网络是怎么跑通的,群友说的报文又是什么。体现在开发过程中就是,出现问题我只能再次去查看linux的igb驱动都配置了哪些寄存器,ixgbe有什么流程但是对于这些流程的意义我不明白,只是datasheet上写了,或者看到其他驱动做了于是我也就去做试试看。
感觉还需要继续学习一下网络相关的基本概念才能真的完成这个网卡驱动
因为我是直接鸠占鹊巢的方式在arceos里成功用起来igb,所以我认为既然能够这样用起来 是不是侧面说明对于ixgb_hal和ixgbe_nic的抽象就是一个能够在igb上(或者同一E100系列的网卡)复用的结构。后续是不是可以直接通过条件编译或者直接在网卡驱动的匹配处来区分出来,这样可以减少重复的代码结构,并且我认为上层本来也不关心下层实际的控制器。
因为之前在忙别的事情,所以晚了一周进组,写代码更是只剩两天时间,所以对代码基本是能跑就行的态度。
不过也有好处,因为晚开始所以资料会比一开始弄多点,开始写的时候就基本能 link up 了。
最重要的参考资料自然是 Intel 82576 手册。不过如果有代码参考肯定是更好的。
ArceOS 自己项目里面就有一个 ixgbe 的驱动,虽然是不同型号的网卡,但是部分逻辑可以参考,而且是 Rust 编写,很好理解。
其次就是 igb 官方驱动,在 github 上可以找到,参考资料里也给了链接。
我简单瞟了两眼,里面感觉到这估计在 linux kernel 源码里也有一份,一搜果然有。
正好我机器上有一份之前用来学习的 linux 源码,配置一下正好可以看看。
把 CONFIG_IGB 开起来,编译一下再 gen_compile_commands.py 生成下 compile_commands.json 就可以愉快的跳转了。
驱动初始化代码在 __igb_open 里,感觉把这玩意实现了应该就可以了。
为了方便实现,我直接跳过了 Flow Control 的部分,感觉应该不会有太大问题。
参考里一直到初始化 link 和 phy 的部分都挺好懂,但是到初始化 rx 和 tx 的时候就开始有点艰难了。
于是我又转头看 github 上 igb 驱动的代码,不过我特意切换到了 v1.0.1 的 tag 上,一般越早期的代码越简单越好懂。
果然这里的初始化很简单,搞了几个寄存器就完了。
不过 v1.0.1 用的地址都是 alias 地址,我还是自己查了查手册,把正确的地址定义到常量上搞完的。
开启中断的方式在代码里也挺简单的。
不过让我觉得意外的是,老师给的关中断的方式和 igb C 驱动的方式并不一致,我最终决定参考 C 驱动的方式来关闭中断和开启中断。
到了 Ring 部分就困难了,我直接放弃了自己写,正好群里有人提到 ixgbe 改改就能用,我就决定直接把我写完的初始化部分放到 ixgbe 里头跑。
把 ixgbe fork 下来,加一层 axdriver_net 的实现,把类型名重置一下,再在 ArceOS 的 Cargo.toml 里头改下依赖名字,把 ixgbe.rs 里的 Hal 实现复制一份到 driver.rs 里,一通操作下来先让程序可以编译。
把原来的东西直接改写进去就能跑 httpclient 了,非常的快乐。
但是跑 http server 时会遇到 curl 不通的情况。原因是没有在 RCTL 里加入 BAM flag, 加上就好了。
我自己 debug 的时候发现现象是收不到包,所以中途加过 BAM flag, 但是因为图省事写成 0x8000 的形式,没有单独弄一个 const value 去存。
结果当时 typo 打成了 0x800 或者 0x80000, 导致 bug 还在,后来 revert 的时候就删掉了。
我跑 http client 的时候发现实际上是可以正常收包的,所以怀疑是 host 机器不能直接发包到 qemu 里,于是在 http server 源码里开了个 http client 来 request, 结果就 OK 了。
当时不明所以,不过群里问了下发现正确做法是加 BAM flag, 于是就可以直接在 host 机器上连接到 server 了。