0%

本人自己其实也在用 c 重写 linux 0.11 的内容,但越往后走,越觉得自己在闭门造车,担心最后做出来的东西已经与目前主流方向脱轨,
偶然的机会接触到 opencamp 这个社区,了解到这个社区现在在做什么。发现与自己所需要的东西强吻合,故毫不犹豫的加入了这次活动。
本人是一名软件工程师,主要语言是c和go,学习rust这门相对比较新的语言其实还是有点吃力。尤其是rust语言中的一些语法糖以及trait,更是
让我这个初学者苦不堪言。由此,我把学习过程记录到了这儿。

rust学习记录

第一阶段,让我对rust的基础语法有了了解,发现rust编译器一直在思考c工程师常考虑的两个事情:这个内存什么时候被申请,这个内存什么时候要释放。
弄清楚这个之后,使用rust就变得不那么困难了,尽管学习完这个课程后还不能写出很优美的rust语法糖,但至少可以保证代码的正确性了。

本人是一名网络软件工程师,从事高性能网络相关的工作,从而有机会接触到内核相关的东西。但工作中学到的东西往往犹如隔靴搔痒,不得痛快。
于是自己也尝试在用c 重写linux 0.11。这里推下我个人的仓库,目前进行到inode操作,与rcore的进度其实高度吻合。

JOS

这个项目还是基于i386架构,在做的过程中让我感受到了与现实世界的差距。也看到过一句话,一个人可以走的快,但一群人可以走的更远。
为了更远的目标以及更贴近生产环境,我参加学习了这个基于risc-v架构rcore的几个基础课程。

虚拟内存管理

个人认为操作系统中最复杂的就是虚拟内存管理,在rcore的课程中,学习了 risc-v 的页表结构,pa 到 va的转换过程,以及va和pa的管理。

调度

这里主要讲了怎么进行上下文的切换(两个kernel stack) 的切换,特权级的切换(S to U/ U to S)。还实现了一个简单的stride调度算法。

并发

学到了怎么创建线程,以及线程间的资源管理逻辑。实现了mutex,信号量,con_var,还实现了一个简单的死锁检测。

文件系统

学了 easy-fs 的文件管理方式,以及通过高速缓冲区实现对磁盘的访问。

Rustlings

关于第一阶段的Rustlings,还是花了很多时间去学习Rust。一开始是直接去看《Rust程序设计语言》,看了大概大半个月吧,把一些较为简单的概念和程序过了一遍。也是第一次接触这类内存安全类语言,第一次看到所有权,引用的时候还有点畏惧,对于没怎么深入学习过C++的人来说学起来还是有些吃力的。后面又去看了《Rust圣经》,发现有趣多了,提供了很多代码案例,很有意思。最后也跟着写了一个rust小项目minigrep。rustlings也是边学边查文档边做,做起来很有意思很有成就感。

rcore实验

rcore批处理系统编译逻辑

  • link.ld链接脚本将程序分成.text、.rodata、.data、.bss。
  • build.py会将app目录下的bin文件进行编译,将程序的text段加载到以0x8040000开始的用来存放app代码的内存空间,且规定每块app空间为0x2000。
  • build.rs会遍历user目录下的build文件夹中刚才通过objcopy生成的bin文件,然后生成对应的link_app.S。其实就是将app下的bin文件进行装载,在每个app的内存空间开始和结尾设置标号,并暴露给os以供调用。

页表机制

单页表:一块地址空间分为用户虚拟地址和内核虚拟地址,内核虚拟地址映射到内核物理地址

单页表会出现熔断漏洞

比如在用户虚拟空间中有一段代码需要访问内核数据空间的页面,因为cpu流水线机制,数据可能已经被放在cache中。但如果这时候我们取值失败了但是由于已经把数据放在了cache中。下一次我们从用户态直接访问这几个页面的时候,总有那么一个页面访问的速度远比其他的页面快。

双页表:分为用户地址空间和内核地址空间,用户地址空间又分内核态代码和用户态代码。

那么当我们在用户态访问内核数据时,其实是不知道数据放在哪的,这样就可以避免熔断漏洞。

exec

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
/// Load a new elf to replace the original application address space and start execution
pub fn exec(&self, elf_data: &[u8]) {
// memory_set with elf program headers/trampoline/trap context/user stack
let (memory_set, user_sp, entry_point) = MemorySet::from_elf(elf_data);
let trap_cx_ppn = memory_set
.translate(VirtAddr::from(TRAP_CONTEXT_BASE).into())
.unwrap()
.ppn();

// **** access current TCB exclusively
let mut inner = self.inner_exclusive_access();
// substitute memory_set
inner.memory_set = memory_set;
// update trap_cx ppn
inner.trap_cx_ppn = trap_cx_ppn;
// initialize base_size
inner.base_size = user_sp;
// initialize trap_cx
let trap_cx = inner.get_trap_cx();
*trap_cx = TrapContext::app_init_context(
entry_point,
user_sp,
KERNEL_SPACE.exclusive_access().token(),
self.kernel_stack.get_top(),
trap_handler as usize,
);
// **** release inner automatically
}

步骤 1:创建新的 MemorySet

1
let (memory_set, user_sp, entry_point) = MemorySet::from_elf(elf_data);
  • 调用 MemorySet::from_elf 解析传入的 ELF 数据,并创建一个新的 MemorySet,即新的地址空间。
  • 该函数返回以下三个值:
    • memory_set:表示该进程的新内存映射集合,包含代码段、数据段、用户栈等信息。
    • user_sp:新用户栈的栈顶地址。
    • entry_point:新程序的入口地址,表示从此处开始执行新的 ELF 程序。

步骤 2:获取新的 trap_cx_ppn

1
2
3
4
let trap_cx_ppn = memory_set
.translate(VirtAddr::from(TRAP_CONTEXT_BASE).into())
.unwrap()
.ppn();
  • 通过 translate 方法,将 TRAP_CONTEXT_BASE 这个虚拟地址转换为物理页号(trap_cx_ppn)。
  • trap_cx_ppn 表示陷入上下文(TrapContext)所在的物理页号,用于进程的系统调用或异常处理。

步骤 3:独占访问当前进程控制块(TCB)

1
let mut inner = self.inner_exclusive_access();
  • 通过 inner_exclusive_access 方法独占访问当前进程的 TaskControlBlockInner 结构体,确保在以下步骤中可以对进程的内部状态进行修改。

步骤 4:替换 MemorySet

1
inner.memory_set = memory_set;
  • 将当前进程的 memory_set 替换为新创建的 memory_set,这样新加载的 ELF 程序就成为该进程的地址空间。
  • 这一步实现了对原应用程序地址空间的替换。

步骤 5:更新 trap_cx_ppn

1
inner.trap_cx_ppn = trap_cx_ppn;
  • 更新 trap_cx_ppn 字段,设置新的 trap_cx_ppn,确保进程的陷入上下文指针正确指向新的物理页。

步骤 6:初始化 base_size

1
inner.base_size = user_sp;
  • 更新 base_size 字段为新的用户栈顶地址 user_sp
  • base_size 用于保存用户栈的初始栈顶,便于栈空间管理。

步骤 7:初始化 trap_cx

1
2
3
4
5
6
7
8
let trap_cx = inner.get_trap_cx();
*trap_cx = TrapContext::app_init_context(
entry_point,
user_sp,
KERNEL_SPACE.exclusive_access().token(),
self.kernel_stack.get_top(),
trap_handler as usize,
);
  • 调用 get_trap_cx 获取当前进程的陷入上下文指针。

  • 使用

    1
    TrapContext::app_init_context

    函数重新初始化陷入上下文,设置新程序的执行信息:

    • entry_point:新程序的入口地址。
    • user_sp:用户栈顶地址。
    • KERNEL_SPACE.exclusive_access().token():内核空间的访问令牌,确保正确的权限。
    • self.kernel_stack.get_top():内核栈的栈顶地址,用于中断或系统调用时的上下文切换。
    • trap_handler as usize:陷入处理函数的地址,用于异常处理。

结尾:释放 inner

inner 独占访问结束时,inner_exclusive_access() 产生的独占访问会自动释放,允许其他任务对该进程进行访问。

总结

exec 方法执行以下步骤来加载和执行一个新的 ELF 程序:

  1. 从 ELF 数据中构建新的 MemorySet、用户栈顶地址、程序入口点。
  2. 获取并设置新的陷入上下文物理页号 trap_cx_ppn
  3. 独占访问当前进程控制块,并逐步替换内存集、更新陷入上下文等信息。
  4. 重新初始化陷入上下文,确保该进程从新的程序入口执行。

rcore调度策略

TaskManager任务管理器管理着一个任务就绪队列(先进先出策略),os初始化过后会在run_tasks中无限循环,取出任务及任务保存的寄存器task_cx,然后通过__switch切换idle_tasknext_task(实际就是task_cx中寄存器的切换),如果没有任务或当前任务释放控制权则会调用schedule切换到idle_task

进程间通信

管道(Pipe)

可表示为两个文件描述符加一段内核空间中的内存

1
2
// 传入数组,转换成管道的读写端的文件描叙符
int pipe(int pipefd[2]);

通过操作文件描述符来分别操作读写端进行进程间通信

  • 如何实现shell中管道符“|”功能

可以fork两个子进程,pid1的执行流可以使用dup2函数将stdout重定向到pipefd[1] (写端),并关闭管道的读写端,执行第一条命令

pid2的执行流使用dup2函数将stdin重定向到pipefd[0] (读端),关闭管道的读写端,执行第二条命令

最后父进程关闭读写端并wait两个子进程

匿名管道:只能是具有血缘关系的进程之间通信;它只能实现一个进程写另一个进程读,而如果需要两者同时进行时,就得重新打开一个管道。

为了使任意两个进程之间能够通信,就提出了命名管道(named pipe 或 FIFO)。
1、与管道的区别:提供了一个路径名与之关联,以FIFO文件的形式存储于文件系统中,能够实现任何两个进程之间通信。而匿名管道对于文件系统是不可见的,它仅限于在父子进程之间的通信。
2、FIFO是一个设备文件,在文件系统中以文件名的形式存在,因此即使进程与创建FIFO的进程不存在血缘关系也依然可以通信,前提是可以访问该路径。
3、FIFO(first input first output)总是遵循先进先出的原则,即第一个进来的数据会第一个被读走。

消息队列

信号(Signal)

在rCore中,当trap发生进入trap_handler函数,其中会调用handle_signals,循环调用check_pending_signals检测进程结构体中的成员来判断是否有signal到来,如果是内核信号,则在内核执行处理函数call_kernel_signal_handler(signal),如果是用户信号则需要返回用户态执行处理函数call_user_signal_handler(sig, signal)

一、前言

参加这次训练营时,我刚刚才进入大三,大三是个非常关键的时期,在之前的时间里已经积累了很多的基础,我在之前所学习到的知识将在这个时间段被检验被拷打,而翻过这座山后,学习的高度将建立在这个这个时期的过渡,所以不仅为了巩固与纠正之前的知识还为了能在这个过渡期将知识储备量进一步提高层次。我选择了这个训练营。我不仅能学到一门强大,前沿,更现代的技术,我还能对硬件与程序之间的桥梁——操作系统有更加深入的了解。能够得到丰富收获的同时也面临一系列的挑战。但是每当遇到困难与压力,我都够挺过来。难题来临,不得不顶上去,敢问路在何方,路就在脚下。

二、第一阶段

1.总述

第一阶段主要涉及的是Rust这门语言的学习,初次接触到Rust这门语言,可能最直观的感受是它的要求很多,比如可变引用于不可变引用、所有权、trait、mod等等。但是这也是Rust与其他语言不同的地方,它会在错误发生之前就尽力扼杀全部错误,这是其他语言所不具备的,这得益于Rust强大的编译器。另外,Rust编程的主要思想仍然是面向对象编程。这使得Rust在大型工程里会使代码更加模块化。

2.特殊机制

Rust的基础特性与其他语言类似这里不做描述,比如基本数据类型、结构体、枚举。这里只列举一些,有待补充。

(1) Option类型

这是一个枚举的变体(是基于枚举类型实现的),它在各种语义里发挥作用,其根本特别之处在于它的None和Some(),它与传统意义的Null不同,例如C语言里的Null表示为空值。它的设计能对Null做出处理而不简单停留在标识空值,他提供了更多选项。

(2) 生命周期

这是Rust强大的编译器带来的特性,它会检查在程序里每处被定义的内存的存在周期,以在编译阶段就避免悬垂指针的出现,这样的话内存的安全性将大大提高。

(3) 所有权机制

这是Rust独一档的特性,它会让Rust每处内存同一时间只会所属于一个变量,而其他变量只能引用这处内存,这样的定义使得Rust制定出了所有权规则,为内存提供了特殊的定义机制,这使得引用内存变得更加安全可靠。

(4) 模式匹配

针对模式匹配,Rust提供了match、if let、while let等来实现模式匹配,这使得我们在Rust可以对变量进行类型判断,增加了每条判断语句灵活性。

三、第二阶段

1.总述

第二阶段主要聚焦于基于risc-v架构的类Unix操作系统,我们采用了Rust语言来编写rcore内核,这是因为Rust在这方面有得天独厚的优势。操作系统在各领域都有所涉及,例如计算机体系架构、计算机组成、计算机网络、数据结构等。所以学习操作系统更够窥探更多计算机的秘密,因此我操作系统有非常浓厚的兴趣。

从裸机运行到单道批处理系统

  • 没有任何操作系统,能够直接硬件的程序运行被称为裸机程序。这样做有一点历史原因,虽然这样做对程序员来说十分自由、没有限制。但这样会造成许多问题,例如安全问题、不具通用性难以移植以及对硬件资源的利用率很低等等。
  • 于是有了单道批操作系统,在rcore表现为加入了初步的简陋的操作系统。为了安全性,借助硬件上划分的运行特权级将程序的执行环境人为与操作系统分隔开,同时也添加了系统调用,因此可以将多个程序按照一定的批次放在内存里约定的位置,以达到运行多个程序的目的。
  • 此时会将所有的程序编译并链接后,生成bin文件,将bin文件先加载到内存里,然后操作系统执行时会将需要执行的程序加载到0x80400000这个约定的地址。

从单道批处理到进程调度

  • 单道批处理系统已经明显的区别于裸机运行,但是这样的系统仍然还有进步空间,对硬件资源的利用率还是不高,比如说是cpu的处理能力没有充分利用以及内存资源的分配等等。
  • 因此在单道批处理系统的基础上,我们不只是将程序加载进内存后让其滞留在那里而是每个程序都可以运行起来,于是提出了时间片的概念,让每个程序尽量都能被cpu执行,这样cpu的空闲时间会下降,相反性能将大幅提高,同时还让持有内存资源的程序能够及时得到执行,提高了内存利用率。
  • 在rcore里,他是risc-v架构,使用内部的定时器来对运行时间进行切分,同时为了能成功切换程序,建立了新的数据结构TCB来维护与更新每个程序的关键信息。

内存地址空间的管理策略

  • 以往所提到的地址或是所使用的地址都是物理地址,意思为所有数据都是直接承载在物理地址上的,且所存与所访问的地址是一一对应的。但是这样的话会带来很多问题,例如内存空间的不安全以及局限性等等。
  • 因此提出了多种存储管理策略来使程序能够重定位,这极大地提高了内存地址空间的灵活性,同时使访问内存变得更加安全。实现这一算法需要硬件上的支持,因为如果在软件层面上让操作系统来翻译性能会大幅降低,对于硬件资源更适合去干这份工作。因此诞生了mmu。
  • 这里的rcore使用的是SV39这种内存管理策略。依赖的是页机制,且使用的是多级页表。这我们对整个物理内存划分为一个很小的页面,这样我们对内存的分配更加灵活、安全、高效。

对外部存储空间的抽象与管理

  • 内存虽说是直接与cpu交互,但内存的大小十分有限,我们不能够仅仅依靠内存来存储数据。我们使用计算机通常会产生、使用以及删除十分庞大数据,因此我们不得不依靠外部存储器件来存储数据,但这部分器件往往无法被cpu直接访问,因此我们需要操作系统来操作接口与设备来使数据放在内存里。
  • 但是管理一个庞大的存储空间不是一件简单的事,并且内存与外存的存储结构通常是有很大差别的,所以我们需要实现对存储空间进行抽象然后再建立新的数据结构进行管理。这里引入的就是文件系统。
  • rcore里的文件系统将每个文件(站在人的视角看叫做文件)抽象成了多个存储节点(站在存储器件的角度上),每个节点的数据结构里含有数据索引,名称,权限,文件关系等重要信息。这样我们访问文件时,操作系统会根据我们的需要去检索节点然后去访问数据。
  • 在类Unix操作系统里文件系统采用了统一的框架“”fd(文件描述符)”去描述每个“文件”。由于许多外部设备的访问操作时相似的,例如“打开”、“读”、“写”等,因此对许多类似的设备重定向,建立适配该框架的数据结构,使得可以利用这个框架去访问外部设备。

线程与进程

  • 之前我们通常将每个程序作为进程受操作系统管理然后运行,操作系统分配资源的对象为进程,cpu运行的单位也是进程。但是这样的设计仍然会带有一定的局限性,因此我们进程再度细分,将进程分为了线程,进程可以申请线程。
  • 进程本身就是一个最大的主线程,它作为线程但又区别于子线程。每个线程都在在进程的环境里运行,所得到的资源也为该进程。因此操作系统分配资源的对象仍为进程,但cpu运行的单位变成了线程。这样提高了进程的运行响应,同时优化了操作系统的调度运行。
  • 但这样也带来了一定的问题,会出现一些线程间的冲突,因此我们需要调和线程之间的运行。这里rcore就使用到了信号量和互斥。

四、总结

在这两个阶段里,我学到了很多,一门新的语言Rust、一种类Unix基于risc-v架构的操作系统。从9月29日开始到现在第二阶段11月10日的结束,无论是知识上的或是技术上的,我收获到了许多。我知道这只是基础,我渴望能在第三阶段学习到更成熟的操作系统,以此能加强我对Rust的学习、对risc-v架构的理解、对类Unix系统的理解以及更前沿的编程技术。感谢。

第一阶段的Rustlings

关于第一阶段的Rustlings,还是花了很多时间去学习Rust。一开始是直接去看《Rust程序设计语言》,随后还看了《Rust圣经》,把一些较为简单的概念和程序过了一遍。可是发现做题时还是难以掌握,随后只能带着疑问和不懂的地方边做边查,做完一套后终于有点入门的感觉了,我感觉rust对我或者别的语言的用户来说,一大难点就是自造的概念太多了+ 第一次接触时的api暴露太多了,有一点不知如何下手。然后再结合上生命周期,就更难上手了。

rcore实验

Lab 1

这个实验主要是实现一个简单sys_task_info多任务系统. 通过这个实验, 了硬件是如何在不同的特权级之间切换的, 以及操作系统是如何管理这些特权级的,知道什么是系统调用和特权级的应用

Lab 2

lab2是实现在启用虚拟地址的情况下重写sys_get_time和sys_task_info, 并实现sys_mmap和sys_munmpa系统调用, 因为启用了分页机制, 学习到了地址空间的概念, 应用程序只需要关心自己的地址空间, 而不需要关心其他应用程序的地址空间, 在实现过程中我对操作系统对代码中的地址空间, 页表的地址转换有了更深的理解

Lab 3

lab3是实现sys_spawn和stride调度算法, 学习到了进程是如何创建的, 以及进程是如何执行的

Lab 4

lab4是实现硬链接和获取文件信息的系统调用, 需要对inode和disk_inode有较深的理解, 学习了文件系统是如何与物理存储设备交互的

Lab 5

lab5是实现死锁检测, 需要理解死锁检测算法 need矩阵

第一阶段

补充第一阶段的内容:主要就是学习rust的使用,基本上问题都能够解决

在阶段二让我对操作系统和rust有了更加深刻的理解

操作系统部分

其实我是有linux内核和驱动的开发经验的,所以对虚拟地址、物理地址、页表、等等知识都是有接触的,但是主要还是停留在书本或者内核提供的api,这五次作业,让我用rust这门最开始接触的语言来实现底层,也还是难度不小。

最开始的页表啊、映射啊、以及一些环境搭建还有git使用对我来说基本没有难度,但是到了第四个lab和第五个lab就开始难了。

文件系统:这个本身我对其就没有太清楚的了解过内部结构,只是会用,这次lab4的作业,强制我要去看源码实现,对超级区块,以及bitmap管理分配的索引,以及bitmap管理内容区块,再到系统层面通过OSInode来管理每个文件的inode,以及管理到物理磁盘上的inode,以及文件名啥的相互联系,让我从底层的磁盘到上层OS的管理链路有了比较清晰的认识,并且对rust的使用有了更加深刻的理解(没错,特指生命周期,和mut ref),因为这两个没少出现借用的问题。

死锁检测:这个部分也是耗时很长,其实我认为这部分的理解不难,因为我很早就接触了锁、信号量这些,我觉得这部分最难的在于调试。由于是并发编程,遇到的情况千奇百怪,以及难以定位。所以我写了很多的log,并且以非常人性化的输出来查看,虽然这部分耗时比较久,但是打好log对后续的开发是如虎添翼。也不会有陷入log中无法自拔的问题。并且通过实现了这个算法,算是开辟了新的知识点,以前从来没有想到还能死锁检测。

对同学的建议

总的来说难度不小,并且其实资料是比较有限的(对于无从下手的新手来说),所以如果卡关了还是要多去搜索资料,以及看源码(这个很重要),去看具体的实现,以及画流程图和结构体之间的关系图,以及标注重要的api(找到合适的api可以事半功倍)

对项目建议

总的来说操作系统的重点都有涉及,知识面还是很充足的!!

但是在编写过程中明显感觉到由于前向兼容导致测试用例的编译越到后期越慢,每次run光编译就会花费一分钟,其实可以优化makefile来单独编译某些测例来减少测试开发的时间。

以及最后两个lab难度上升有点大,并且文章 中是对功能的描述居多(很多对于实验有用的点会被淹没在其中)虽然在边做lab边反复查看资料时可以发现,但是对于没有接触过的同学可能很多api看过就没印象了,在后续做lab中也找不到,所以可以适当增加一下强调或者提示,来减少难度增加坡度。

最后还可以收集一下大家的调试方法,这样对于后续对debug无从下手的同学也有参考意义。

前言

因为我已经工作有12年了, Rust也写了有几万行, 所以第一阶段对我来说没什么难度. 但是我还是按照训练营的要求, 完成了rustlings的练习.

rustlings

rustlings的练习很简单, 但是对于新手来说, 是一个很好的入门练习. 通过这些练习, 可以很快的了解rust的基本语法和特性.

不过我仍然从中学到了一些新东西, 比如 BinaryHeap. 之前我在编写应用程序的时候一直用 Vec 来实现优先队列, 现在我知道了 BinaryHeap 这个更好的实现.

总结

理解了 Drop 就入门了 Rust.
理解了 Trait 就熟悉了 Rust.

前言

就Rust语言来说,我认为这是开创新时代的语言,一直在努力学习。
我自己学Rust语言的过程,分了三个阶段,最初纯粹是好奇。
用Rust自己写了一些小程序后,对Rust有了实际的体验,感觉体验很好、值得信赖。
于是有了更深入学习的想法,前面参加了InfiniTensor训练营,了解到使用Rust开发更复杂程序的方法。
现在参加操作系统训练营,是对自己更高的挑战。

开发一个操作系统内核是一个宏大的课题,需要认真的思考、深入地研究。训练营在短时间内整个拉了一遍,
给了学员一个宏观的体验,这非常宝贵,这是我参加训练营的原因。同时,在这个过程中,通过交流和学习,
也触发了我个人的深层思考,这对我个人尤为宝贵。
限于时间,我没法把所有问题思考透彻,但又不能感兴趣的问题轻易丢弃,于是写这篇总结,留待以后。
标题叫Something Not yet done,就是我想做、想探寻但还没有答案的东西。

基础阶段

内容

基础段主要涉及Rust语言的学习。

  • Rust语言的unsafe部分
  • Rust语言的异步和并行部分

Rust语言的unsafe部分

第一次涉及到unsafe部分代码的编写,经历了程序的崩溃,意识到unsafe的危险;到使用安全方法怎么也无
法实现想要的功能,明白了Rust的哲学,unsafe的重要性。从享受Rust的安全编程,转变为谨慎地编写unsafe
然后再享受Rust的安全编程,对于Rust的编程理念有了更近一步的了解。
但如何安全地编写unsafe,仍然有很多知识需要学习和实践,包括:

  • unsafe function
  • unsafe trait
  • unsafe extern

要完全掌握Rust,unsafe是必须跨越的一步。

Rust语言的异步和并行部分

对于如何编写高性能的Rust程序,还缺乏实践。

专业阶段

内容

专业阶段主要涉及对rCore的代码分析、学习,和部分功能实现。

  • 系统调用
  • 虚拟地址
  • 进程管理和调度
  • 文件系统
  • 并行控制

系统调用

这部分功能比较简单。

虚拟地址

引入虚拟地址后,所有系统调用的参数传递,都需要进行地址转换。目前都在系统调用处理函数中,复制粘贴
代码来实现,格外得丑陋。希望在第三阶段的时候,对这部分进行封装。

进程管理和调度

stride是比较简单的调度算法,希望在后面能够尝试将Linux的调度算法移植过来。

文件系统

在实现功能的过程中,在File trait中增加的一个方法。不知道有没有破坏原有的抽象,三阶段看看完整的
项目是如何解决File到Inode转换的。

并行控制

死锁检测的实现中,感觉对于列表的实现有点丑陋,目前可用的集合类就只有VecDeque,三阶段看看有没有
其他实现方式。

项目阶段

待补充。

rustlings 总结

Rustlings 是学习 Rust 编程语言的极佳练习工具,它包含了多个由浅入深的练习题目,帮助学习者快速掌握 Rust 的基础知识和重要概念。

1. 变量与可变性

  • Rust 中的变量默认是不可变的(immutable),即变量在声明后无法更改。要让变量可变,必须显式添加 mut 关键字。
  • 这种默认不可变性帮助开发者避免无意的状态变化,提高代码的安全性和可维护性。

2. 数据类型

  • Rust 是静态类型语言,编译器会在编译阶段检查数据类型。
  • Rust 支持多种数据类型,包括标量类型(整型、浮点型、布尔型、字符)和复合类型(元组、数组等)。

3. 所有权机制

  • Rust 的所有权系统是其内存安全性和性能的重要保障。
  • 每个值在同一时间只能有一个所有者,当所有者变量超出作用域时,内存会自动释放。所有权的转移、借用和引用(可变和不可变)是理解 Rust 内存管理的关键。

4. 借用与引用

  • 借用(Borrowing)允许在不转移所有权的情况下使用数据。
  • Rust 有严格的借用规则:在同一作用域中,只允许一个可变引用或多个不可变引用,确保内存安全。

5. 结构体与枚举

  • 结构体(Struct)用于将不同的数据组合成一个复合类型,枚举(Enum)用于定义一组可能的状态或值。
  • Rust 的枚举非常强大,支持绑定数据,并且可以与模式匹配一起使用,帮助更清晰地处理复杂的逻辑分支。

6. 模式匹配

  • match 表达式和 if let 是 Rust 中处理分支的主要工具,尤其是当处理枚举和结果类型(Result)时。
  • match 语法不仅简洁,还能避免遗漏某些分支,确保代码的健壮性。

7. 错误处理

  • Rust 提供了 ResultOption 类型来进行错误处理和空值处理。
  • 使用 unwrapexpectmatch 等方式处理这些类型,开发者可以编写出更健壮的代码,避免程序在运行时崩溃。

8. 所有权的移动与复制

  • 移动(Move):当变量的所有权被转移时,源变量将不可用。
  • 复制(Copy):对于实现了 Copy 特征的类型(如基本数据类型),赋值不会转移所有权,而是直接复制。

9. 特征

  • Rust 中的特征类似于其他语言的接口,用于定义一组方法签名,供结构体或枚举实现。
  • 特征使得 Rust 支持多态,通过泛型和特征约束实现代码的复用和接口一致性。

10. 智能指针

  • Rust 的标准库中提供了 BoxRcRefCell 等智能指针类型,帮助管理内存和共享数据。
  • Box 实现堆分配,Rc 实现引用计数,RefCell 提供运行时的可变性检查,用于实现更复杂的数据结构。

11. 并发编程

  • Rust 的所有权机制让多线程编程更加安全。
  • 使用 std::thread 库可以方便地创建线程,并且借助 ArcMutex 等类型来实现线程间的数据共享和同步。

12. 生命周期(Lifetimes)

  • Rust 使用生命周期注解来管理引用的生命周期,确保程序不会引用无效数据。
  • 生命周期的概念是 Rust 内存安全的重要组成部分,编译器会自动推断大部分生命周期,但有时需要显式标注。

OS 实现

第二阶段主要分为八个章节,每个章节层层递进,深入揭示操作系统的底层逻辑以及实现原理。

lab1

为了实现 sys_task_info 系统调用,首先在 TaskManager 中为任务控制块 (TCB) 扩展结构体,加入如下字段:sys_call_times:[u32;MAX_SYSCALL_NUM],然后在在mod.rs中增加increase_sys_call和get_sys_call_times函数,进而在syscall函数中调用increase_sys_call函数,

在系统调用处理逻辑中,维护当前任务的系统调用次数计数,每次进入系统调用时在数组中相应位置加一。在 sys_task_info 系统调用实现中,将当前任务的状态(应为 Running)、系统调用次数、以及通过 get_time() 获取的任务总运行时间写入 TaskInfo 结构体。

lab2

  • 完成sys_get_time和sys_task_info函数,需要定义一个translated_struct_ptr,它通过页表(PageTable)将一个指向结构体(*mut T)的指针翻译为对应的物理地址,并返回一个可变引用(&’static mut T),这样可以直接操作映射后的物理内存。在sys_task_info函数中,需要获取系统调用时间和任务运行时间,所以需要在task.rs中定义并实现它们。
  • 完成sys_mmap和sys_munmap函数,用于申请和释放虚拟内存映射。sys_mmap 通过指定的起始地址 start、长度 len 和内存页属性 port 来映射一段物理内存到虚拟地址空间。该函数会检查起始地址对齐情况、port 的合法性,并将内存页映射为可读、可写或可执行。sys_munmap 则用于取消内存映射,释放从 start 开始的一段虚拟内存。(注意,需要判断地址是否对齐)

lab3

  • 实现了自定义的spawn的系统调用来创建新进程,以便简化进程创建的过程而无需使用fork+exec的组合操作。通过sys_spawn,直接从指定的路径启动目标程序,成功返回子程序的进程id,否则返回-1。
  • 实现了stride调度算法,为每个进程设置优先级与其调度权重,使得系统资源能够更公平分配。stride调度通过设置初始优先级与动态步长(pass值),优先调度累计步长最小的进程,并对选中的进程步长进行累加调整,确保各进程的运行比例与优先级成正比。我还新增了 sys_set_priority 系统调用,以便动态调整进程优先级,增强调度灵活性。

lab4

在本次作业中,我实现了三个系统调用:linkat、unlinkat 和 fstat,以支持硬链接和文件状态获取。

  1. linkat:该调用用于创建一个文件的硬链接。它接收原有文件路径和新链接路径作为参数,并将新路径指向与原文件相同的磁盘块。在实现中,我确保在创建链接时不允许同名文件的存在,避免潜在的未定义行为。
  2. unlinkat:此调用用于删除文件的链接。当调用 unlinkat 时,如果文件的引用计数降至零,它将回收与文件关联的 inode 及其数据块。我的实现确保正确处理文件的彻底删除,维护文件系统的一致性。
  3. fstat:该调用获取指定文件描述符的状态信息。它将文件状态结构体填充以提供有关文件的详细信息,如大小、权限和时间戳等,便于用户或其他系统调用进行进一步处理。
    通过这三个系统调用的实现,我的文件系统支持了硬链接的创建与删除,以及文件状态的查询,从而增强了其功能和灵活性。

lab5

我实现的功能包括对进程和线程的资源管理,主要是通过互斥量(mutex)和信号量(semaphore)来控制并发访问。具体而言,为每个线程维护了四个关键数据结构:m_allocation 和 s_allocation 用于记录已分配的互斥量和信号量的数量,m_need 和 s_need 则用于跟踪线程尚需的资源数量。在系统调用中,加入了对死锁的检测逻辑,确保在尝试获取新资源之前检查当前资源的可用性。如果发现潜在的死锁情况,则会拒绝资源请求,并返回相应的错误代码。此外,还实现了调整资源需求的方法,以便在资源分配和释放时动态更新状态。

1
make -C /home/linux/vscode/arceos A=/home/linux/vscode/my_arceos_app ARCH=aarch64 LOG=debug SMP=1 run

U.1.0 HelloWorld

![![[Pasted image 20241111091146.png]]](<2024年开源操作系统训练营第三阶段总结-silent12rt/Pasted image 20241111091146.png>)
该图展示了一个应用程序(hello_world)在系统中各个模块(如axruntimeaxhalaxstdarceos)之间的结构和交互关系。以下是各个部分的功能说明:

  1. axhal(硬件抽象层):该层对硬件细节进行抽象,为更高层提供一个屏蔽底层硬件的基础,使上层不需要直接管理底层硬件。

  2. axruntime(运行时环境):该层管理应用程序的运行时环境,包括内存分配、线程调度和其他运行时服务,是应用程序正常运行的基础。

  3. ulib(axstd):这是一个标准库,为应用层提供通用功能和实用工具,可能包含基本的I/O操作、数据处理等辅助功能。

  4. api(arceos):这是与底层操作系统(ArceOS)的应用编程接口,允许应用程序执行系统级操作,如文件管理、进程间通信等。

  5. app(hello_world):这是用户的应用程序,利用axstdarceos提供的库和接口来执行特定任务。

  6. 执行流程

    • 左侧的准备环境(蓝色箭头)表示系统的准备阶段,在此阶段,配置和初始化必要的资源和环境。
    • 右侧的调用功能(橙色箭头)表示应用程序在运行时与axstdarceos进行的交互,通过这些库和API执行特定功能。

U.2.0 Collections

Buddy System(伙伴系统)

  1. 分配内存单元
    设置最小分配单元(通常是 2 的幂次方大小),而不是按1字节来分配。这种划分可以提高分配效率,并且降低管理开销。例如,如果分配单元是 8 字节,最小分配的内存块将是 8 字节。在 Buddy System 中,不同大小的块按 2 的幂次划分(即 8、16、32、64 等),每个大小被称为一个 order。每个 order 是一种特定大小的块。
  2. 分配过程
    • 寻找最小满足请求的块。当程序请求内存时,分配器首先确定需要分配的块的大小(例如 64 字节)。然后分配器会在内存池中找到最小的能满足此请求的 order 块(例如 128 字节的块,若没有 64 字节的块)。
    • 二分切割。如果找到的 order 大于所需的大小,那么分配器将不断地对该块进行 二分切割,直到得到匹配所需大小的块。
    • 返回分配的块:分配器返回一个与请求大小匹配的块,并将它从空闲列表中移除。此时,程序可以使用该块。
  3. 释放过程
    • 检查是否有空闲的邻居块:当程序释放某块内存时,分配器会检查该块是否有“伙伴”块(即同一级的邻居块)也是空闲的。两个邻居块的地址通常具有某种关系,使分配器可以根据地址快速定位伙伴块。
    • 合并到高 order:如果找到空闲的伙伴块,分配器会将两个相邻的空闲块合并成一个更大的块。这个合并过程会尽量继续进行,直到不能再合并为止。
    • 挂到 Order List:如果无法进一步合并,最终的空闲块会挂到相应的空闲列表(Order List)中,以备后续分配使用。

内存分配算法-Slab

![![[Pasted image 20241111191653.png]]](<2024年开源操作系统训练营第三阶段总结-silent12rt/Pasted image 20241111191653 1.png>)
分配过程

  1. 找到合适的OrderList
    根据请求的内存大小,找到合适的 OrderList。OrderList 会匹配内存大小,确保分配合适的 Slab。
  2. 从 Slab 的空闲块链表中获取 block
    • 从空闲块链表 (Free Block List) 中弹出一个 block,完成分配。
    • 如果空闲块链表中没有可用的 block,则进入下一步。
  3. 调用 BuddyAllocator 分配新块:
    • 当空闲链表中没有足够的块时,向 Buddy Allocator 请求一个较大的内存块。
    • 将分配到的较大内存块切分为符合 Slab 需求大小的 block,然后加入到该 Slab 的空闲块链表。
    • 最终,分配请求从空闲块链表中取出一个 block 返回。

释放过程

  1. 释放 block 到空闲块链表
    • 释放时,将 block 放回对应 Slab 的空闲块链表。
    • 这样,当后续需要分配类似大小的块时,可以直接从该空闲块链表中分配,避免重复分配和释放较大块的开销。
  2. 管理内存回收
    • 如果某个 Slab 变得完全空闲(即所有 block 都释放),可以选择将该 Slab 的内存归还给 Buddy Allocator,以释放更多内存供其他用途。

U.3.0 Collections

U.4.0 Collections

核心算法:context_switch

任务上下文Context: 保存任务状态的最小的寄存器状态集合。
![![[Pasted image 20241112144224.png]]](<2024年开源操作系统训练营第三阶段总结-silent12rt/Pasted image 20241112144224.png>)
ra: 函数返回地址寄存器,这个切换实现了任务执行指令流的切换。
sp: 任务即线程,这个是线程栈
s0~s11:按照riscv规范,callee不能改这组寄存器的信息,所以需要保存。

抢占式调度算法ROUND_ROBIN

在协作式调度FIFO的基础上,由定时器定时递减当前任务的时间片,耗尽时允许调度,一旦外部条件符合,边沿触发抢占,当前任务排到队尾,如此完成各个任务的循环排列。
![![[Pasted image 20241112151551.png]]](<2024年开源操作系统训练营第三阶段总结-silent12rt/Pasted image 20241112151551.png>)

抢占式调度算法CFS(Completely Fair Scheduler)

![![[Pasted image 20241112151735.png]]](<2024年开源操作系统训练营第三阶段总结-silent12rt/Pasted image 20241112151735.png>)