初识OS
操作系统是计算机专业一门非常重要的必修课,同时也是很多骚操作的基础,在将来找工作面视时也是绕不开的一环,其重要程度不亚于计组,希望诸位能好好学习。操作系统分为理论和实验两部分,理论部分如果觉得课程组提供的PPT难以下咽的话可以参考王道408。实验部分主要有两类:
- 每次只做exam就跑路:课下代码填空可以不求甚解,只要把pre-exam掌握就可以通关课上exam
- 希望尝试extra:课下代码最好参考pre-extra考察板块详细理解,最起码搞明白某些关键变量的含义以及函数功能,在考试时调用做到相对熟练。
个人认为最理想的学习方式是弄懂整个操作系统的运作流程,不强求知道每一行代码的作用,但是必须要能梳理清楚典型功能例如内存管理,文件管理等的函数调用逻辑。
当然实验的核心素养还在于C语言的熟练coding,建议大一程序设计没学好的同学巩固一下。
本片博客的创作原因是实在看不懂指导书,所以就用AI尽可能把所有代码梳理了一遍,形成了一版较为浅显易懂(可能吧)的个人心得体会,偶尔会包含上机实验的些许灵感。读者可以把他当作一个辅助理解来使用,同时请保持批判性思维,欢迎指出表述有歧义或是不准确的地方,感激不尽。
lab 0
lab0 的主要内容是 Linux 命令行指令的熟悉。可以参见这篇博客。以下是上机考试的题目,大家可以当作练手。
lab0-exam
题目要求:
- casegen:编译生成输入生成程序 casegen;
- ver1: 使用共同依赖,以及依赖 calc1.c, 编译生成程序 ver1;
- ver2: 使用共同依赖,以及依赖 calc2.c, 编译生成程序 ver2;
- all: 编译生成上述所有程序 casegen, ver1, ver2;
- run: 先执行 casegen 获取输入, 再分别执行 ver1, ver2 获取输出;
- clean: 清理编译好的可执行文件 casegen, ver1, ver2,以及运行过程中产生的所有后缀为 .txt 的临时文件。
思路分析:
我们观察到 ver1 和 ver2 都是使用共同依赖加上各自的依赖来编译生成程序。因此共同依赖可以提取出来 COMMON_FILE = main.c post_calc.c common.h,ver1 和 ver2 就可以写成:
|
别忘了变量引用时要加上 $ 符号。接下来比较困难的就是run。执行带参数的程序时要在读懂题意的基础上合理地传参,综上所述,最后的 Makefile 如下:
|
lab0-extra
题目要求我们从 0 开始写一个 .sh 脚本来实现要求的功能。我们可以逐条分析题目要求。注意:本题一定要善用路径变量,否则你会很痛苦。
- 如果日期没有给出,就打印
analyze.sh: No date provided到标准输出,并立即退出。如果日期给出则向下分析。此处的难点是参数个数统计,bash中可以通过$#来获取参数个数。于是我们可以写:
|
- 新建报告根目录 reports ,与日志根目录同级,报告均保存在以对应日期命名的子目录中。新建所有目录的权限为 rwxrwxr-x ,所有普通文件的权限为 rw-rw-r– 。我们根据要求新建目录并设置权限:
|
- 在指定日期的错误日志中检查是否存在含有 ERROR 字样的记录,如果有则将这类记录筛选出来,按原顺序保存到相应报告子目录下的 error.log ,否则不要创建此文件。我们查找 ERROR 字样可以使用
grep命令。那么怎么保留记录呢,我们可以使用字符串变量errors=$(grep "ERROR" "${error_src}"),并判断字符串是否为空来决定是否创建 error.log 文件:
|
- 在指定日期的访问日志中将 /127.0.0.1 字样全部替换为 /localhost ,其它内容保持不变,全文保存到相应报告子目录下的 access.log ,不要更改原文件。这当然是sed的主场了:
|
- 在指定日期的访问日志中统计每个 IP地址 的总请求次数,并将结果按访问次数从高到低排序,保存到相应报告子目录下的 summary.txt ,访问次数相同时的排序不做要求,每行格式如下:
IP地址 总访问次数,不要更改原文件。我们可以使用awk来统计 IP 地址的访问情况,结合给出的sort和uniq来实现本题:
|
解释一下最后一条指令:
awk '{print $1}' "${access_src}":提取访问日志中的第一列(IP地址)。sort:对提取的IP地址进行排序,以便后续统计。uniq -c:统计每个唯一IP地址的出现次数,输出格式为“次数 IP地址”。sort -r -n:根据访问次数进行降序排序。awk '{print $2, $1}':调整输出格式为“IP地址 总访问次数”,并将结果保存到 summary.txt 文件中。> "${summary_dst}":将最终结果写入 summary.txt 文件中。
lab 1
lab1课下实验
lab1的主题是内核,启动和输出,即让MOS的最小内核能够被编译,链接,加载到QEMU中运行,并完成最基本的内核输出能力。具体分为以下几个板块:
操作系统的启动
我们的实验代码是在Linux环境中写的,但MOS是运行在MIPS平台上的,所以需要通过交叉编译生成MIPS可执行文件,再交给QEMU模拟的硬件环境运行
ELF的启动
MOS内核最终会被编译成一个ELF格式的文件,然后又QEMU加载到内存中运行。ELF主要由五部分组成:
- ELF头:包括程序的基本信息,例如段头表和节头表的相对文件的偏移量
- 段头表/程序头表:记录各个segment信息,用于运行时加载
- 节头表:记录各个section的信息,用于编译和链接
- 段头表表项:说明某个段应该被加载到内存的什么地方
- 节头表表项:说明代码段,数据段等节的信息,用于链接段。
理解MIPS布局
MIPS的虚拟地址空间分布如图:

kuseg:需要TLB进行虚拟地址和物理地址的转换;对这段地址的存取都会经过cache。kseg0:不需要TLB进行虚拟地址和物理地址的转换;对这段地址的存取都会经过cache。kseg1:不需要TLB进行虚拟地址和物理地址的转换;对这段地址的存取不会经过cache。kseg2:需要TLB进行虚拟地址和物理地址的转换;对这段地址的存取都会经过cache。
内核刚启动时,TLB还没有配置好,所以内核不能放在需要TLB地址转换的区域,所以MOS的内核.text,.bss,.data段会被放到kseg0中。
从_start启动MOS
_start是内核函数的入口,写在汇编文件init/Start.s中,他的作用是做早期的初始化,然后跳转到C语言函数——mips_init()。整体调用流程如下:
|
实现printk
printk就是内核的printf,用于在QEMU控制台里输出调试信息,直接写C语言就好。
总结
通过上述描述,我们可以大概得知lab1的主要内容:
|
lab1课上考试
lab1实验主要是启动和printf的实现。往届的exam一般是要求实现一个简单的printf函数,extra则要求你在对print的机理有更深入的掌握和理解的基础上实现相关功能。2026年春季学期进行了改革,exam考察readelf文件的理解,extra则沿袭往届的考察习惯。
lab1-exam
本题用到的关键变量如下:
|
解题步骤如下:
- 首先我们要设置好文件指针
const ELF64_Ehdr *ehdr = (ELF64_Ehdr*)binary; - 获取节头表的地址
const ELF64_Shdr *sh_table = (ELF64_Shdr*)((char*)binary + ehdr->e_shoff);。这里一定要注意以下指针类型的转换,e_shoff是步数,**(char*)**是步幅,只有在正确的步幅下步数才有意义。 - 获取节名称字符串表的节头,
const ELF64_Shdr *shstrShdr = (const ELF64_Shdr*)(sh_table + ehdr->e_shstrndx);这里步幅是节头表项的大小,步数是节名称字符串表对应的节头下标. - 获取节名称字符串表的内容字符串,
const char* shstrtab = (char*)binary + shstrShdr->sh_offset;这里步幅是字节,步数是节名称字符串表在文件中的偏移。
走到这里工作已经完成一大半了,我们总结以下之前提到的易于混淆的概念:
| 名称 | 类比 | 计算方式 |
|---|---|---|
| 节头表 | 目录索引 | 文件首地址+节头表在文件中的偏移 |
| 节名称字符串表 | 特定页码 | 节头表地址+节名称字符串表对应的节头下标*节头表项的大小 |
| 节名称字符串表的内容字符串 | 某一页的具体文字 | 文件首地址+节名称字符串表在文件中的偏移(从求得的节名称字符串表获取) |
- 获取节名称,遍历节头表,获取每个节名称在节名称字符串表中的偏移
sh_name,并通过偏移获取节名称字符串表中的节名称shstrtab + sh_name,判断是否为.symtab,如果是则将matchSymtab标记为1.这里注意一下节名称字符串表的形式是"\0.text\0.data\0.bss\0.symtab\0.strtab\0.shstrtab\0..."所以在比较的时候可以放心strcmp.
|
至此,本题基本结束。难点在于概念的理解和区分,以及指针类型转换的正确使用。还是挺容易把自己绕进去的。
lab1-extra
题目要求实现 scanf的功能,类比 printf实现。
改动涉及的文件有 include/print.h, include/printk.h, kernel/printk.c ,lib/print.c。我们重点放在lib/print.c上,其他文件的改动主要是函数申明和照抄。
考察形式主要是代码补全,课程组还贴心地为我们提供了思路!
%c
|
这里重点是知道ch和ch_valid分别是什么,只要你不懵逼,做这个题手拿把掐。ch是读取到的字符,ch_valid是标志位。
%s
|
这里我们要对in函数有一定理解,in是回调函数,作为vscanfmt的参数,它的作用是读取下一个字符并更新ch,但并不会改变ch_valid,所以我们在循环内部调用它来继续读取下一个字符。并且不需要画蛇添足地去令ch_valid = 1,因为题目已经明确了当我们遇到空白符或’\0’时停止读取,此时ch里正好握着导致停止的“空白符”,且保持缓存标志位置为有效(值为1),所以我们只需要关注ch的值即可。
%u
|
这里没什么好说的,str转int是程序设计的基本功了。
lab 2
这里当时没来得及写,大家脑补一下吧(bushi😥
lab3
lab3的核心问题是进程创建和进程调度,我们以这两个问题为主线来学习lab3的内容。
进程创建
进程创建发生在内核态,因此我们的主要工作集中在kern/env.c文件中。
env_init():全局环境初始化
env_init()函数的主要任务是搭建整个进程管理的全局数据结构。包括初始化链表(env_free_list, env_sched_list),填充空闲链表。唯一不太好理解的在于构建模板页目录。通过初始化一个全局的模板页目录,并将操作系统中的(物理页管理数组pages和进程控制块数组evns)映射到模板页目录中,后续在创建新进程时可以直接复制这个模板页目录,避免了每次创建新进程都要重新构建页目录的开销。
|
env_alloc():分配并初始化进程块
- 分配PCB空槽:从env_free_list中分配一个空槽,若无空槽则返回错误。
- 初始化地址空间(env_setup_vm):为新进程分配页目录,并将内核空间映射到新进程的地址空间中。
- 分配标识符(mkenvid, asid_alloc()):为新进程分配一个唯一的标识符,同时申请一个唯一的ASID,用于TLB管理。
- 配置初始上下文:设置cp0_status, 初始化栈指针寄存器
load_icode():加载ELF可执行程序
有了进程的躯壳和独立地址空间之后,需要把存放在内存中的ELF格式的应用程序可执行代码加载进去。
- 解析ELF头
- 遍历段并分配物理内存(elf_load_seg):扫描ELF的程序头表,针对每个可加载的段,利用系统提供的内存分配函数为其分配物理内存,并将ELF文件中的代码和数据通过page_insert()映射到新进程的地址空间中。
- 设置入口点:将ELF头中的入口地址设置到新进程的程序计数器寄存器中,以便进程启动时能够正确执行。
env_create():统筹调用env_alloc()和load_icode(),完成一个新进程的创建,并将其加入就绪队列中,等待调度器调度执行。
进程调度
进程调度也发生在内核态,关键在于选择下一个要运行的进程,并切换到该进程的上下文,我们主要关注kern/sched.c和kern/env.c中的相关函数。
首先要明确调度不会凭空发生,必须通过异常或中断陷入内核态才能触发,最常见的有两种:时钟中断(被动),主动让出(主动)。在当前实验中,我们主要通过时钟中断来触发调度。
时钟中断发生,内核保存现场并调用sched_yield()
当时钟中断发生时,内核会自动保存当前进程的上下文(寄存器状态等),然后调用sched_yield()函数来选择下一个要运行的进程。
schedule():选择下一个要运行的进程
将刚刚被中断的进程(如果还活着)放入就绪队列,从就绪队列中选出下一个状态为ENV_RUNNABLE的进程作为新进程。
env_run():切换到新进程
- 将全局指针
curenv指向新进程。 - 更新新进程的状态为
ENV_RUNNING,并增加运行次数。 - 切换页目录:通知CPU使用新进程的页目录,以便新进程能够访问自己的地址空间。
- 切换上下文并退出内核:通过
env_pop_tf()函数将新进程的寄存器状态恢复到CPU寄存器中,最终跳转到新进程的入口地址开始执行。
2025-exam
exam主要考察的是新的调度算法的实现。重点修改kern/sched.c/schedule和kern/env.c/env_create_**文件即可。这里给大家提个小tip:不用看调度算法的说明,直接照着代码提示实现即可
|
exam几乎都是这么个模式,先根据提示实现新的调度算法的核心逻辑,然后在最后调用之前实现的RR算法。
需要注意:
struct Env *e = last_env:这里不用curenv的原因是RR调度器要维护自己的历史状态。EDF进程运行时不能污染RR的last_env。只有真正进入RR分支并调度了RR进程,才更新last_env。(这一点在题目中有说明)。selected_env->env_runtime_left--和count--;都要在env_run()之前执行,只不过是两个调度器各自维护自己的时间片计数器,互不干扰。static struct Env *last_env = NULL;需要初始化为NULL,并且加上static修饰。static int count = 0;需要初始化为0,并且加上static修饰。
static修饰的变量只在函数第一次被调用时初始化一次,count要保存当前正在运行的进程还剩几个时间片,而last_env要保存当前进程的上一个被调度的进程。如果不加static修饰,这两个变量在每次进程被调度时都会被重新初始化,从而无法正确维护状态。
lab4
lab4主要涉及三个模块:系统调用,IPC通信,fork。
系统调用
我们以sys_mem_alloc为例,说明它从用户态进入内核、分发、执行、返回用户态的完整流程。在这过程中大家要持续关注当前所处的是用户态还是内核态,这对理解操作系统的运行机制有很大帮助。
用户态封装
首先我们明确用户程序不会直接调用内核函数sys_mem_alloc,而是调用用户库里(user/lib/syscall_lib.c)的封装函数:
|
执行syscall指令
其中SYS_mem_alloc表示系统调用号,定义在include/syscall.h中。msyscall是一个汇编函数,在user/lib/syscall_wrap.S中实现:
|
按照MIPS的约定,在进入msyscall之前,系统调用号和参数已经被放在了寄存器a0到a3中。具体效果图如下:
|
如果参数超过四个,那么剩余的参数会被放在用户栈上。*((int *)sp + n) 表示第n个参数。syscall指令触发MIPS异常,CPU从用户态切换到内核态,将CP0中的Cause寄存器的ExcCode设置为8(系统调用异常),同时CPU强制将PC(程序计数器)跳转到操作系统的异常统一处理入口0x80000180,而我们在kernel.lds设置了:
|
异常入口保存现场
这意味着此时的内核入口不是sys_mem_alloc,而是通用异常处理入口exc_gen_entry,它位于kern/entry.S中:
|
SAVE_ALL把用户态的寄存器保存到内核栈上,防止内核态的函数调用修改了用户态的寄存器值。接下来我们把CP0_STATUS寄存器的UM、EXL、IE位清零,进入内核态后禁止中断。然后从CP0_CAUSE寄存器中获取异常代码,内核根据异常代码选择处理函数。exception_handlers是一个数组,存储了所有异常类型对应的处理函数地址,位于kern/traps.c中。对于系统调用异常,ExcCode为8,所以我们会跳转到exception_handlers[8],也就是handle_sys函数:
|
handle_sys调用do syscall
handle_sys位于kern/genex.c中:
|
把当前Trapframe指针放入a0寄存器中(刚刚保存好的用户现场),执行do_syscall函数,处理系统调用。再处理完后执行ret_from_exception函数返回用户态:
其中do_syscall位于kern/syscall_all.c中:
|
恢复用户态
在tf->regs[2]写入返回值之后,汇编入口执行以下命令返回用户态:
|
RESTORE_ALL 从 Trapframe 恢复寄存器,eret 根据 cp0_epc 返回用户态。
至此,系统调用全流程已经实现。
IPC通信
IPC是一种同步消息传递机制,发送者和接收者必须同时存在才能完成通信。接收方首先调用ipc_recv进入等待状态,发送方调用ipc_send发送消息,并把接收方变成可运行。接下来我们具体分析。
在user/lib/ipc.c中,用户程序调用ipc_recv函数:
|
在用户态调用syscall_ipc_recv,进入内核态后调用sys_ipc_recv。这正是之前提到的系统调用模块的内容。
依旧在user/lib/ipc.c中,发送方调用ipc_send函数,在进入内核之后调用sys_ipc_try_send。
当sys_ipc_try_send函数返回0之后,接收方被放回调度队列,等待被调度器调度执行。接收方被调度器选中执行时会从阻塞的sys_ipc_recv函数中返回。
这里有一个很有意思的事情:
由于 sys_ipc_recv 阻塞前已经设置 **((struct Trapframe *)KSTACKTOP - 1)->regs[2] = 0;。所以接收方用户看到的是 syscall_ipc_recv(dstva) == 0,于是ipc_recv**函数继续执行,返回发送方的envid、权限和消息值。
fork
fork()要创建一个子进程,使他看起来和父进程一样拥有相同的地址空间,寄存器状态等。我们在user/lib/fork.c中实现了fork()函数:
|
注册TLB Mod 异常处理函数
|
fork()首先调用了syscall_set_tlb_mod_entry,这个函数主要作用是为指定的进程(envid)注册一个用户态的TLB Mod异常处理程序的入口地址。
|
异常处理程序入口cow_entry位于user/lib/fork.c,是用户态下的写时复制异常处理程序。内核的 do_tlb_mod 会把异常现场放到用户异常栈上,然后跳到该函数,
创建子进程
|
用户态下调用syscall_exofork,内核态调用sys_exofork,这一步只做进程控制块和寄存器状态的复制,不复制内存。并且遵循以下原则:
父进程中 fork 返回 child envid;子进程中 fork 返回 0。
由于子进程刚创建时,用户态的 env 变量还是从父进程地址空间继承来的,需要修正为指向自己的 Env 结构。
复制地址空间映射
|
父进程遍历自己的页表,将虚拟页映射到子进程中。
设置子进程异常入口并让他可运行
|
总流程图如下
|
2025-extra
这里提供 2025 年的extra供大家上机参考。extra主要考察的还是新增系统调用这方面的一些知识,只不过extra的难度在于c语言实现。我们的主要战场在kern/syscall_all.c。一些函数的申明在此不多赘述,照着题目提示复制粘贴即可。我们直接来看sys_***函数的实现:
sys_shm_new:申请共享内存
|
做这个题的关键在于知道page_allloc是干啥的,并且理解本题的结构体的概念:
|
拆解题目,我们需要干的主体就是分配内存,如果分配失败清理已经分配的内存,最后返回共享内存的编号。题目还有如下提示:
对于某个共享内存的页面,可能被绑定多个进程,然后被全部解除绑定,此时,你应该保证这些物理页面仍可以进行新的绑定操作。提示:你可以通过在创建共享内存时,增加页面的 pp_ref 记录,并在销毁共享内存时使用 page_decref 函数释放页面。
所以代码也就呼之欲出了:
|
- sys_shm_bind:绑定共享内存
|
这个题目关键在于记得映射虚拟地址和物理页面的函数是什么。
|
- sys_shm_unbind:解除绑定
|
顾名思义,本题的关键在于记得解除映射的函数是什么,代码片段如下:
|
- sys_shm_free:销毁共享内存
|
最后仿照第一个函数释放页面的形式实现即可,别忘了清理已经分配页面的指针shm_pool[key].pages[i] = NULL;。
|
lab5
在开始lab5实验之前,建议大家提前学习了文件管理的相关理论知识。同时在学习过程中可以多和自己现实中的文件管理过程进行类比,有助于理解这个复杂的单元。
lab5主题是搭建一个文件管理系统,那么我们先来了解一下什么是文件管理系统。
在我看来,所谓文件管理系统,就是一个可以让用户方便地管理文件的系统。它可以让用户创建、删除、移动、复制文件和文件夹。那么为了实现这一系列功能,我们就需要设计一套合理的流程,我们可以把这个流程分为三个层次:
- 底层:怎么读写磁盘
- 中层:怎么组织磁盘数据
- 上层:怎么让用户正常使用文件(用户接口)
最终的完整流程如下:
|
这篇博客将从下往上逐级介绍实现细节,对于代码细节不一定会深究,重在框架搭建和流程理解。(在懂了流程之后代码细节都好说)
底层,磁盘读写
用户态读写IDE设备寄存器
这一层解决了一个问题:用户态文件系统服务如何访问磁盘设备。我们现在内核文件kern/syscall_all.c中实现两个系统调用:
|
他们的作用不是直接读写磁盘文件,而是允许用户态程序读写指定的设备物理地址pa,也就是IDE的MMIO寄存器。
这里解释一下,MMIO(Memory-Mapped I/O)就是把硬件设备的控制寄存器伪装成普通的内存地址,这样用户态程序就可以通过普通的内存访问指令来读写设备寄存器了。
设备寄存器读写封装成IDE扇区读写
有了sys_read_dev/sys_write_dev之后,还不能直接读写文件,因为它们只是读写寄存器。接下来要在fs/ide.c中实现:
|
这一层的核心思想是:按照IDE协议配置LBA寄存器,然后通过DATA寄存器传输数据。DATA寄存器只是数据传输的窗口,真正决定读写磁盘的哪里是LBA寄存器。LBA寄存器由四个寄存器组成,分别是:
- LBA[7:0]:存储目标扇区号的低8位
- LBA[15:8]:存储目标扇区号的中间8位
- LBA[23:16]:存储目标扇区号的高8位
- DEVICE寄存器:存储目标磁盘号和LBA模式等信息,以及目标扇区号的[24:27]位
函数整体流程如下:
|
中层,磁盘数据组织
用fsformat构造文件系统镜像
这一部分主要解决磁盘里一开始应该放什么,我们引入tools/fsformat.c。这是一个运行在宿主机上的工具,不是MOS内部运行的程序,它负责把tests/lab5_x/rootfs目录下的文件按照MOS文件系统的格式写入到磁盘镜像fs.img中。
在理清楚fsformat的流程之前,我们介绍一下文件系统的磁盘布局:

代码文件中会涉及几个重要的结构体:Block(磁盘块), Super(文件系统超级块), File(文件控制块)
|
| 字段 | 含义 |
|---|---|
| data[BLOCK_SIZE] | 这个磁盘块真正保存的数据内容。一个块 4096 字节,可能存普通文件内容,也可能存目录项、位图、间接索引块等。 |
| type | 标记这个块的类型。例如这个块是数据块、文件控制块块、索引块、位图块等。主要用于 fsformat 构造镜像时管理磁盘布局。 |
| disk[NBLOCK] | 这是一个全局数组,模拟了磁盘上所有的块。我们在 fsformat 里直接操作这个数组来构造文件系统镜像。disk[i]表示第i个磁盘块。 |
|
| 字段 | 含义 |
|---|---|
| s_magic | 文件系统魔数,用于标识这是一个合法的文件系统。 |
| s_nblocks | 磁盘块总数,告诉文件系统有多少块可用。 |
| s_root | 根目录的文件控制块,包含根目录的元信息和索引关系。 |
|
| 字段 | 含义 |
|---|---|
| f_name | 文件名,存储这个文件的名字。 |
| f_size | 文件大小,单位是字节。对于普通文件,表示文件内容大小;对于目录,表示目录占用的数据块总大小。 |
| f_type | 文件类型。常见值有 FTYPE_REG 和 FTYPE_DIR,分别表示普通文件和目录。 |
| f_direct[NDIRECT] | 直接索引块数组,最多可以直接索引 NDIRECT 个数据块。 |
| f_indirect | 间接索引块号,如果文件需要更多数据块,用 f_indirect 指向一个索引块,这个索引块里面再保存更多数据块号。 |
| f_dir | 指向父目录的指针,方便路径解析时回溯。 |
PS:当f_type是FTYPE_DIR时,f_direct和f_indirect指向的块里存的不是文件内容,而是struct File,也就是目录块。
fsformat主要做几件事:
|
通过以上步骤,我们提前在磁盘里创建好了一个简单文件系统,包括超级块,根目录,位图,文件控制块以及文件数据块。
把磁盘块映射到用户态缓存
文件系统进程是用户态服务。它不能每次访问文件都直接读磁盘,因为磁盘 I/O 很慢。所以引入了块缓存block cache。
在fs/fs.c中实现把磁盘块抽象成用户态虚拟内存中的一页缓存,涉及到的核心函数有
- diskaddr(blockno):计算磁盘块号对应的缓存虚拟地址
- block_is_mapped(blockno):检查磁盘块是否已经映射到缓存
- read_block(blockno):把磁盘块读入缓存
- write_block(blockno):把缓存中的块写回磁盘
- map_block(blockno):把磁盘块映射到缓存,如果没有就分配物理页并从磁盘读入
- unmap_block(blockno):解除磁盘块和缓存的映射
具体流程如下:
|
磁盘空间管理,实现块的分配和释放
文件系统还需要知道哪些磁盘块空闲,哪些已经被占用。这部分依赖 bitmap 位图,涉及的函数有:
- alloc_block():分配一个空闲块,返回块号
- free_block(blockno):释放一个块,把它标记为可用
- block_is_free(blockno):检查一个块是否空闲
|
如此,文件系统就可以像管理物理页一样管理磁盘块了
文件块管理,把文件逻辑块号映射到磁盘块号
这部分逻辑类似lab2内存管理里的pgdir_walk,主要处理文件内部逻辑块和真实磁盘块之间的关系,说白了就是某个文件的第 filebno 个数据块,实际存在哪个磁盘块里的问题,涉及到的函数有:
file_block_walk(struct File *f, u_int filebno, int alloc):根据文件 f 和文件块号 filebno,找到对应的磁盘块号,如果 alloc 为真且不存在则分配新块,返回槽位的地址。file_map_block(struct File *f, u_int filebno, int alloc):根据文件 f 和文件块号 filebno,找到对应的磁盘块号,如果 alloc 为真且不存在则分配新块,返回磁盘块号。file_clear_block(struct File *f, u_int filebno):根据文件 f 和文件块号 filebno,找到对应的磁盘块号并释放它。file_get_block(struct File *f, u_int filebno):根据文件 f 和文件块号 filebno,找到该逻辑块在内存中的指针地址,封装了逻辑块 -> 物理块 -> 内存地址的转换过程。并将其保存在blk中。
|
目录管理
普通文件的数据块中存放的是文件内容,目录的数据块中存放的是多个struct file,也就是子文件的FCB。目录管理的核心函数有:
- dir_lookup(struct File *dir, char *name, struct File **file):在目录 dir 中查找名字为 name 的子文件,如果找到就把它的 struct File 通过 file 参数返回。
- dir_alloc_file(struct File *dir, struct File **file):在目录中找到空闲的struct file(FCB),如果没有空闲则扩展一个,通过 file 参数返回。
|
路径解析,从根目录找到目标文件
这是一个逐级调用dir_lookup的过程,核心函数是walk_path,这个函数至关重要,是后面file_open,file_create,file_remove的基础。
文件级操作
在路径解析和文件块管理的基础上,我们可以实现真正的文件操作,比如:
- file_open() 打开一个文件,返回 struct File 结构体
- file_create() 创建一个新文件,返回 struct File 结构体
- file_remove() 删除一个文件
- file_truncate() 清空一个文件的内容
- ….
上层,用户接口
|
fd.c涉及的数据结构有:
|
| 字段 | 含义 |
|---|---|
| fd_dev_id | 设备ID,标识这个文件描述符对应哪个设备。对于普通文件设备,这个值是0。 |
| fd_offset | 文件偏移量,表示当前读写位置在文件中的字节位置。每次读写操作后,这个值会更新。 |
| fd_omode | 打开模式,表示这个文件描述符是以什么模式打开的,比如只读、只写、读写等。 |
|
file.c涉及的数据结构有:
|
这是是普通文件设备专用的fd结构,它在Fd的基础上额外保存fileid和struct File的副本。
| 字段 | 含义 |
|---|---|
| f_fd | 这是一个通用的文件描述符结构,包含了文件描述符的基本信息,比如类型、权限等。 |
| f_fileid | 这是一个唯一标识符,用于区分不同的文件实例。它可以用来在文件系统服务端和用户态之间进行文件的引用和管理。 |
| f_file | 这是一个 struct File 结构体,是当前文件的struct File副本。包含了文件的元信息和索引关系。它是从文件系统服务端获取的,代表了用户态打开的文件在文件系统中的状态。 |
fsipc.c涉及的数据结构有:
|
| 字段 | 含义 |
|---|---|
| o_file | 指向服务端文件系统中的 struct File。也就是这个打开对象真正对应的文件。 |
| o_fileid | 服务端给这个打开对象分配的编号。用户端 Filefd.f_fileid 保存的就是这个值。 |
| o_mode | 这是一个整数,表示打开文件的模式,比如只读、只写、读写等。 |
| o_ff | 这指向服务端为这个打开对象准备的 struct Filefd 页。serve_open 会把这个页映射给用户进程。 |
lab6
lab6主要内容是实现简单的shell,以及其中涉及管道的部分功能。先来看管道部分。
pipe
管道负责提供跨进程共享内存的环形队列缓冲区,读写机制以及关闭检测来保障shell在用户态下可以实现多个进程之间通过|进行流式管道数据传输。具体实现代码在user/lib/pipe.c中。
int pipe(int pfd[2])
该函数主要功能是分配并映射用于管道通信的资源。具体代码逻辑如下:
- 分配文件描述符
分配两个描述符控制页fd0(读端)和fd1(写端)。并设置权限为PTE_LIBRARY表示共享,以便在fork后父子进程能同时看到状态修改。
|
- 分配共享数据页
为管道申请物理内存页并映射给读端
|
- 实现共享映射
将同一个物理内存页映射给写端
|
- 将分配的文件描述符编号写入pfd[0]和pfd[1]
|
pipe_read
该函数实现了从管道文件描述符中读取最多n字节数据到用户缓冲区vbuf中。核心逻辑为缓冲区读空时的处理:
|
如果i>0说明之前已经成功读取了至少1个字节,应当立即返回已经读取的字节数;如果写端已经全部关闭了,说明以后不会有新的数据写入,可以立即返回。如果以上条件都不满足,主动让出CPU,等待写进程写入数据。
写进程同理,这里就不赘述
_pipe_is_closed()
该函数用于检测通道是否关闭,通过物理页引用计数的比较来实现。
pageref(p):该管道共享数据页的总引用计数
pageref(fd):当前持有的这一端描述符的引用计数。
显然,当fd_ref == page_ref时,说明只剩下当前这一端还映射这这个管道,另一端已无进程打开,即该管道已关闭。
但是在处理过程中我们要考虑并发安全:
|
如果在读取了fd_ref之后,读取pipe_ref之前,发生时钟中断,操作系统将CPU切换给了管道的另一端进程,而另一端进程执行了close()操作,导致pipe_ref的值减1。此时,比较对象一个时关闭前的数据,一个时关闭后的数据,压根不具备可比性。所以我们利用env->env_runs来监控调度,确保fd_ref和pipe_ref是在同一个没有被中断打扰的时间片内读出。
shell
sh.c
sh.c负责接受用户输入的指令,理解其意图。并调度操作系统内核和其他用户程序来执行这些命令。主要用到的是语法分析和词法分析的知识。
目前shell具备识别以下符号的能力:
w:普通单词
<:输入重定向
>:输出重定向
|:管道
spawn.c
在传统的Unix操作系统中,创建并运行新程序需要组合使用fork和exec。然而,MOS采用了类似Windows的spawn机制,把解析和加载程序的任务移到了用户态,简化了微内核的设计。
控制核心为spawn函数,拆解分析如下:
- 打开ELF可执行文件
根据传入的程序路径prog,以只读模式打开对应的ELF格式可执行文件。
|
- 读取并校验ELF头部
从文件中读取sizeof(Elf32_Ehdr)大小的数据到临时缓冲区elfbuf。并调用elf_from函数进行ELF格式校验。最后将程序的入口地址保存在entrypoint中,后续用于初始化PC寄存器。
|
- 创建一个空白子进程
利用syscall_exofork()创建一个新的进程控制块,并复制父进程的寄存器状态,但不会拷贝任何内存页面。此时子进程处于挂起状态,需要注意的是:syscall_exofork()返回值是子进程的envid(环境id),如果执行失败则返回负数。
|
- 初始化子进程的用户栈
调用init_stack申请一个物理页,将其映射到子进程的栈顶处。并向栈中压入argc、argv等参数,最终计算出子进程启动时sp寄存器应指向的虚拟地址,写入sp变量中。
|
- 将ELF程序段加载到子进程内存中
|
- 设置子进程的寄存器上下文
初始化执行状态,子进程被fork出来时寄存器与父进程一致,此时我们需要修正他的两个关键寄存器的值:
cp0_epc:修改为ELF入口地址entrypoint,
regs[29]:修改为刚刚初始化完argv参数的栈顶指针sp
|
- 继承父进程的共享页面
扫描当前父进程页表中所有有效的虚拟页面,如果页面的权限标志中包含PTE_LIBRARY,说明该页面需要跨进程共享,通过syscall_mem_map将这些页面以相同的虚拟地址和权限直接映射到子进程中。这也是子进程能够直接使用父进程打开的管道和重定向文件的根本原因。
|
- 使能并启用子进程
|
将子进程的状态从挂起设为ENV_RUNNABLE即可。
再见OS
至此,本学期的OS实验要向大家说再见了,希望大家在提交lab6之后能带着从容和自信,为这段学习旅程写下一个圆满的句号。
Lyrics Sharing
|