「BUAA-OS」 葵OS典


初识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 就可以写成:

ver1: $(COMMON_FILE) calc1.c 
gcc calc1.c post_calc.c main.c -o ver1

别忘了变量引用时要加上 $ 符号。接下来比较困难的就是run。执行带参数的程序时要在读懂题意的基础上合理地传参,综上所述,最后的 Makefile 如下:

include makefile.inc
.PHONY: clean run

COMMON_FILE = main.c post_calc.c common.h

run: casegen ver1 ver2
./casegen $(CASE_NUM) $(INPUT_FILE)
./ver1 $(INPUT_FILE)
./ver2 $(INPUT_FILE)

# TIPS: casegen, instead of casegen.c , is one of the dependencies, why?
all: casegen ver1 ver2

ver1: $(COMMON_FILE) calc1.c
gcc calc1.c post_calc.c main.c -o ver1

ver2: calc2.c $(COMMON_FILE)
gcc calc2.c post_calc.c main.c -o ver2

casegen: casegen.c
gcc casegen.c -o casegen

clean:
rm -rf casegen ver1 ver2 *.txt

lab0-extra

题目要求我们从 0 开始写一个 .sh 脚本来实现要求的功能。我们可以逐条分析题目要求。注意:本题一定要善用路径变量,否则你会很痛苦。

  1. 如果日期没有给出,就打印analyze.sh: No date provided到标准输出,并立即退出。如果日期给出则向下分析。此处的难点是参数个数统计,bash中可以通过$#来获取参数个数。于是我们可以写:
if [ $# -eq 0 ]
then
echo "analyze.sh: No date provided"
else
# 继续分析
fi
  1. 新建报告根目录 reports ,与日志根目录同级,报告均保存在以对应日期命名的子目录中。新建所有目录的权限为 rwxrwxr-x ,所有普通文件的权限为 rw-rw-r– 。我们根据要求新建目录并设置权限:
REPORTS_DIR="reports"
DST_DIR="${REPORTS_DIR}/${DATE}"
LOGS_DIR="logs"
SRC_DIR="${LOGS_DIR}/${DATE}"

mkdir "${REPORTS_DIR}"
chmod 775 "${REPORTS_DIR}"
mkdir "${DST_DIR}"
chmod 775 "${DST_DIR}"
  1. 在指定日期的错误日志中检查是否存在含有 ERROR 字样的记录,如果有则将这类记录筛选出来,按原顺序保存到相应报告子目录下的 error.log ,否则不要创建此文件。我们查找 ERROR 字样可以使用 grep 命令。那么怎么保留记录呢,我们可以使用字符串变量 errors=$(grep "ERROR" "${error_src}"),并判断字符串是否为空来决定是否创建 error.log 文件:
error_src="${SRC_DIR}/error.log"
error_dst="${DST_DIR}/error.log"

errors=$(grep "ERROR" "${error_src}")
if [ -n "${errors}" ]
then
touch "${error_dst}"
chmod 664 "${error_dst}"
echo "${errors}" > "${error_dst}"
fi
  1. 在指定日期的访问日志中将 /127.0.0.1 字样全部替换为 /localhost ,其它内容保持不变,全文保存到相应报告子目录下的 access.log ,不要更改原文件。这当然是sed的主场了:
access_src="${SRC_DIR}/access.log"
access_dst="${DST_DIR}/access.log"

touch "${access_dst}"
chmod 664 "${access_dst}"
sed 's/\/127.0.0.1/\/localhost/g' "${access_src}" > "${access_dst}"
  1. 在指定日期的访问日志中统计每个 IP地址 的总请求次数,并将结果按访问次数从高到低排序,保存到相应报告子目录下的 summary.txt ,访问次数相同时的排序不做要求,每行格式如下:IP地址 总访问次数,不要更改原文件。我们可以使用 awk 来统计 IP 地址的访问情况,结合给出的sort和uniq来实现本题:
summary_dst="${DST_DIR}/summary.txt"
touch "${summary_dst}"
chmod 664 "${summary_dst}"
awk '{print $1}' "${access_src}" | sort | uniq -c | sort -r -n | awk '{print $2, $1}' > "${summary_dst}"

解释一下最后一条指令:

  • 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的虚拟地址空间分布如图:

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()。整体调用流程如下:

QEMU / Bootloader

加载内核 ELF

跳转到 _start

设置必要的启动环境

调用 mips_init()

MOS 内核开始执行

实现printk

printk就是内核的printf,用于在QEMU控制台里输出调试信息,直接写C语言就好。

总结

通过上述描述,我们可以大概得知lab1的主要内容:

Makefile 负责构建

ELF 描述内核文件结构

Linker Script 决定内核放到哪里、从哪里开始执行

MIPS 内存布局解释为什么要这样放

_start 完成最早期启动

mips_init 表示内核 C 代码开始运行

printk 提供后续调试输出能力

lab1课上考试

lab1实验主要是启动和printf的实现。往届的exam一般是要求实现一个简单的printf函数,extra则要求你在对print的机理有更深入的掌握和理解的基础上实现相关功能。2026年春季学期进行了改革,exam考察readelf文件的理解,extra则沿袭往届的考察习惯。

lab1-exam

本题用到的关键变量如下:

Elf64_Off e_shoff;      /* 节头表在文件中的偏移 */
Elf64_Half e_shnum; /* 节头表表项数量 */
Elf64_Half e_shstrndx; /* 节名称字符串表对应的节头下标 */

Elf64_Word sh_name; /* 节名在节名称字符串表(.shstrtab)中的偏移,切记是在字符串表中的偏移 */
Elf64_Xword sh_size; /* 节总大小 */
Elf64_Off sh_offset; /* 该节在文件中的偏移 */

解题步骤如下:

  1. 首先我们要设置好文件指针const ELF64_Ehdr *ehdr = (ELF64_Ehdr*)binary;
  2. 获取节头表的地址const ELF64_Shdr *sh_table = (ELF64_Shdr*)((char*)binary + ehdr->e_shoff);。这里一定要注意以下指针类型的转换,e_shoff是步数,**(char*)**是步幅,只有在正确的步幅下步数才有意义。
  3. 获取节名称字符串表的节头const ELF64_Shdr *shstrShdr = (const ELF64_Shdr*)(sh_table + ehdr->e_shstrndx);这里步幅是节头表项的大小,步数是节名称字符串表对应的节头下标.
  4. 获取节名称字符串表的内容字符串const char* shstrtab = (char*)binary + shstrShdr->sh_offset;这里步幅是字节,步数是节名称字符串表在文件中的偏移。

走到这里工作已经完成一大半了,我们总结以下之前提到的易于混淆的概念:

名称 类比 计算方式
节头表 目录索引 文件首地址+节头表在文件中的偏移
节名称字符串表 特定页码 节头表地址+节名称字符串表对应的节头下标*节头表项的大小
节名称字符串表的内容字符串 某一页的具体文字 文件首地址+节名称字符串表在文件中的偏移(从求得的节名称字符串表获取)
  1. 获取节名称,遍历节头表,获取每个节名称在节名称字符串表中的偏移sh_name,并通过偏移获取节名称字符串表中的节名称shstrtab + sh_name,判断是否为.symtab,如果是则将matchSymtab标记为1.这里注意一下节名称字符串表的形式是"\0.text\0.data\0.bss\0.symtab\0.strtab\0.shstrtab\0..."所以在比较的时候可以放心strcmp.
for (int i = 0; i < section_count; i++) {
section_name = shstrtab + sh_table[i].sh_name;
int matchSymtab = 0;
if (strcmp(".symtab", section_name) == 0) {
matchSymtab = 1;
}
if (matchSymtab == 1) {
symtabIndex = i;
}
}

至此,本题基本结束。难点在于概念的理解和区分,以及指针类型转换的正确使用。还是挺容易把自己绕进去的。

lab1-extra

题目要求实现 scanf的功能,类比 printf实现。

改动涉及的文件有 include/print.h, include/printk.h, kernel/printk.c ,lib/print.c。我们重点放在lib/print.c上,其他文件的改动主要是函数申明和照抄。

考察形式主要是代码补全,课程组还贴心地为我们提供了思路!

%c

void scan_c(scan_callback_t in, void *data, char *ch, int *ch_valid, char *cp) {
/* ---------------------------------- */
/* Lab 1-extra: Your code here. (2/4) */
/* 提示:
1. 使用辅助函数确保缓冲区里有字符(注意:%c 绝不能跳过空白符!)
2. 将读取到的字符存入 cp 指向的内存。
3. 核心:因为该字符已被 %c 实体吃掉,必须将 缓存标志位 置为无效(值为0)。
*/
/* ---------------------------------- */
ensure_char(in, data, ch, ch_valid);
*cp = *ch;
*ch_valid = 0; // 标志位置为无效
}

这里重点是知道chch_valid分别是什么,只要你不懵逼,做这个题手拿把掐。ch是读取到的字符,ch_valid是标志位。

%s

void scan_s(scan_callback_t in, void *data, char *ch, int *ch_valid, char *cp) {
/* ---------------------------------- */
/* Lab 1-extra: Your code here. (3/4) */
/* 提示:
1. 使用辅助函数跳过所有的前导空白符。
2. 连续读取非空白字符并存入 cp(可以在循环内部使用 *cp++ = *ch 进行赋值)。
3. 遇到空白符或 '\0' 时停止读取。
4. 别忘了在字符串末尾添加 '\0' 封口。
5. 停下时:ch 里必须正好握着导致停止的“空白符”,且保持 缓存标志位 置为有效(值为1)。
*/
/* ---------------------------------- */
skip_whitespace(in, data, ch, ch_valid);
while(*ch != ' ' && *ch != '\t' && *ch != '\n' && *ch != '\r' && *ch != '\0') {
*cp++ = *ch;
in(data, ch, 1); // 继续读取下一个字符
}
*cp = '\0'; // 添加字符串结束符
}

这里我们要对in函数有一定理解,in是回调函数,作为vscanfmt的参数,它的作用是读取下一个字符并更新ch,但并不会改变ch_valid,所以我们在循环内部调用它来继续读取下一个字符。并且不需要画蛇添足地去令ch_valid = 1,因为题目已经明确了当我们遇到空白符或’\0’时停止读取,此时ch里正好握着导致停止的“空白符”,且保持缓存标志位置为有效(值为1),所以我们只需要关注ch的值即可。

%u

void scan_u(scan_callback_t in, void *data, char *ch, int *ch_valid, int *ip) {
/* ---------------------------------- */
/* Lab 1-extra: Your code here. (4/4) */
/* 提示:
1. 定义变量用于累加数字计算结果。
2. 使用辅助函数跳过所有的前导空白符。
3. 兼容可选的前导 '+' 号(至多1个)(如果有,调用 in() 越过它)。
4. 如果遇到的第一个非空白字符就不是数字或 `+`,直接赋值为 `0` 并结束。
5. 连续读取数字字符('0'-'9'),并计算十进制数值。
6. 遇到非数字字符时停止读取,并将结果存入ip。
7. 停下时:ch里必须正好握着导致停止的“非数字字符”,且保持缓存标志位置为有效(值为1)。
*/
/* ---------------------------------- */
int result = 0;
skip_whitespace(in, data, ch, ch_valid);
if (*ch == '+') {
in(data, ch, 1); // 越过 '+'
}
if (*ch < '0' || *ch > '9') {
*ip = 0; // 第一个非空白字符不是数字或 '+'
return;
} else {
while (*ch >= '0' && *ch <= '9') {
result = result * 10 + (*ch - '0');
in(data, ch, 1); // 继续读取下一个字符
}
*ip = result;
}
}

这里没什么好说的,str转int是程序设计的基本功了。

lab 2

这里当时没来得及写,大家脑补一下吧(bushi😥

lab3

lab3的核心问题是进程创建和进程调度,我们以这两个问题为主线来学习lab3的内容。

进程创建

进程创建发生在内核态,因此我们的主要工作集中在kern/env.c文件中。

env_init():全局环境初始化

env_init()函数的主要任务是搭建整个进程管理的全局数据结构。包括初始化链表(env_free_list, env_sched_list),填充空闲链表。唯一不太好理解的在于构建模板页目录。通过初始化一个全局的模板页目录,并将操作系统中的(物理页管理数组pages和进程控制块数组evns)映射到模板页目录中,后续在创建新进程时可以直接复制这个模板页目录,避免了每次创建新进程都要重新构建页目录的开销。

// 分配并初始化页目录本身
struct Page *p;
panic_on(page_alloc(&p));
p->pp_ref++;
base_pgdir = (Pde *)page2kva(p);
// 映射pages数组和envs数组到模板页目录中
map_segment(base_pgdir, 0, PADDR(pages), UPAGES,
ROUND(npage * sizeof(struct Page), PAGE_SIZE), PTE_G);
map_segment(base_pgdir, 0, PADDR(envs), UENVS, ROUND(NENV * sizeof(struct Env), PAGE_SIZE),
PTE_G);

env_alloc():分配并初始化进程块

  1. 分配PCB空槽:从env_free_list中分配一个空槽,若无空槽则返回错误。
  2. 初始化地址空间(env_setup_vm):为新进程分配页目录,并将内核空间映射到新进程的地址空间中。
  3. 分配标识符(mkenvid, asid_alloc()):为新进程分配一个唯一的标识符,同时申请一个唯一的ASID,用于TLB管理。
  4. 配置初始上下文:设置cp0_status, 初始化栈指针寄存器

load_icode():加载ELF可执行程序

有了进程的躯壳和独立地址空间之后,需要把存放在内存中的ELF格式的应用程序可执行代码加载进去。

  1. 解析ELF头
  2. 遍历段并分配物理内存(elf_load_seg):扫描ELF的程序头表,针对每个可加载的段,利用系统提供的内存分配函数为其分配物理内存,并将ELF文件中的代码和数据通过page_insert()映射到新进程的地址空间中。
  3. 设置入口点:将ELF头中的入口地址设置到新进程的程序计数器寄存器中,以便进程启动时能够正确执行。

env_create():统筹调用env_alloc()和load_icode(),完成一个新进程的创建,并将其加入就绪队列中,等待调度器调度执行。

进程调度

进程调度也发生在内核态,关键在于选择下一个要运行的进程,并切换到该进程的上下文,我们主要关注kern/sched.ckern/env.c中的相关函数。

首先要明确调度不会凭空发生,必须通过异常或中断陷入内核态才能触发,最常见的有两种:时钟中断(被动),主动让出(主动)。在当前实验中,我们主要通过时钟中断来触发调度。

时钟中断发生,内核保存现场并调用sched_yield()

当时钟中断发生时,内核会自动保存当前进程的上下文(寄存器状态等),然后调用sched_yield()函数来选择下一个要运行的进程。

schedule():选择下一个要运行的进程

将刚刚被中断的进程(如果还活着)放入就绪队列,从就绪队列中选出下一个状态为ENV_RUNNABLE的进程作为新进程。

env_run():切换到新进程

  1. 将全局指针curenv指向新进程。
  2. 更新新进程的状态为ENV_RUNNING,并增加运行次数。
  3. 切换页目录:通知CPU使用新进程的页目录,以便新进程能够访问自己的地址空间。
  4. 切换上下文并退出内核:通过env_pop_tf()函数将新进程的寄存器状态恢复到CPU寄存器中,最终跳转到新进程的入口地址开始执行。

2025-exam

exam主要考察的是新的调度算法的实现。重点修改kern/sched.c/schedulekern/env.c/env_create_**文件即可。这里给大家提个小tip:不用看调度算法的说明,直接照着代码提示实现即可

// env_create_edf
struct Env *env_create_edf(const void *binary, size_t size, int runtime, int period) {
struct Env *e;

env_alloc(&e, 0);
e->env_edf_runtime = runtime;
e->env_edf_period = period;
e->env_period_deadline = 0; // 初始化为 0,使进程在首次调用 schedule 函数时触发条件判断,进入首个运行周期
e->env_status = ENV_RUNNABLE;
// !!!一定要记得初始化相关字段要全面,并且初值正确!务必务必!

load_icode(e, binary, size);
LIST_INSERT_HEAD(&env_edf_sched_list, e, env_edf_sched_link);
return e;
}
void schedule(int yield) {
static int clock = -1; // 当前时间片,从 0 开始计数
clock++;
static struct Env *last_env = NULL; // 上一个被调度的进程

struct Env *e;
LIST_FOREACH(env, &env_edf_sched_list, env_edf_sched_link) {
if (clock == e->env_period_deadline) {
e->env_period_deadline += e->env_edf_period; // 更新下一个周期的截止时间
e->env_runtime_left = e->env_edf_runtime; // 重置剩余运行时间
}
}

struct Env *selected_env = NULL;
LIST_FOREACH(e, &env_edf_sched_list, env_edf_sched_link) {
if (e->env_runtime_left > 0) { // 只考虑剩余运行时间大于 0 的进程
if (selected_env == NULL ||
e->env_period_deadline < selected_env->env_period_deadline ||
(e->env_period_deadline == selected_env->env_period_deadline && e->env_id < selected_env->env_id)) {
selected_env = e; // 更新选中的进程
}
}
}

if (selected_env != NULL) {
selected_env->env_runtime_left--; // 调度前先减少剩余运行时间
env_run(selected_env); // 调度选中的进程
} else {
static int count = 0; // remaining time slices of current env
struct Env *e = last_env; // 请根据提示修改这行代码

if (yield || count == 0 || e == NULL || e->env_status != ENV_RUNNABLE) {
// 当前进程还活着并且仍然可以运行
if (e && e->env_status == ENV_RUNNABLE) {
TAILQ_REMOVE(&env_sched_list, e, env_sched_link);
TAILQ_INSERT_TAIL(&env_sched_list, e, env_sched_link);
}
e = TAILQ_FIRST(&env_sched_list);
if (e == NULL) {
panic("schedule: no runnable envs\n");
}
count = e->env_pri; // 时间片重置
}
count--;
last_env = e;
env_run(e);
}

}

exam几乎都是这么个模式,先根据提示实现新的调度算法的核心逻辑,然后在最后调用之前实现的RR算法。
需要注意:

  1. struct Env *e = last_env :这里不用curenv的原因是RR调度器要维护自己的历史状态。EDF进程运行时不能污染RR的last_env。只有真正进入RR分支并调度了RR进程,才更新last_env。(这一点在题目中有说明)。
  2. selected_env->env_runtime_left--count--;都要在env_run()之前执行,只不过是两个调度器各自维护自己的时间片计数器,互不干扰。
  3. static struct Env *last_env = NULL;需要初始化为NULL,并且加上static修饰。
  4. 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)的封装函数:

int syscall_mem_alloc(u_int envid, void *va, u_int perm) {
return msyscall(SYS_mem_alloc, envid, va, perm);
}

执行syscall指令

其中SYS_mem_alloc表示系统调用号,定义在include/syscall.h中。msyscall是一个汇编函数,在user/lib/syscall_wrap.S中实现:

syscall
jr ra

按照MIPS的约定,在进入msyscall之前,系统调用号和参数已经被放在了寄存器a0a3中。具体效果图如下:

$a0 = SYS_mem_alloc
$a1 = envid
$a2 = va
$a3 = perm

如果参数超过四个,那么剩余的参数会被放在用户栈上。*((int *)sp + n) 表示第n个参数。
syscall指令触发MIPS异常,CPU从用户态切换到内核态,将CP0中的Cause寄存器的ExcCode设置为8(系统调用异常),同时CPU强制将PC(程序计数器)跳转到操作系统的异常统一处理入口0x80000180,而我们在kernel.lds设置了:

. = 0x80000180;
.exc_gen_entry : {
*(.text.exc_gen_entry)
}

异常入口保存现场

这意味着此时的内核入口不是sys_mem_alloc,而是通用异常处理入口exc_gen_entry,它位于kern/entry.S中:

exc_gen_entry:
SAVE_ALL
mfc0 t0, CP0_STATUS
and t0, t0, ~(STATUS_UM | STATUS_EXL | STATUS_IE)
mtc0 t0, CP0_STATUS
mfc0 t0, CP0_CAUSE
andi t0, 0x7c
lw t0, exception_handlers(t0)
jr t0

SAVE_ALL把用户态的寄存器保存到内核栈上,防止内核态的函数调用修改了用户态的寄存器值。接下来我们把CP0_STATUS寄存器的UMEXLIE位清零,进入内核态后禁止中断。然后从CP0_CAUSE寄存器中获取异常代码,内核根据异常代码选择处理函数。exception_handlers是一个数组,存储了所有异常类型对应的处理函数地址,位于kern/traps.c中。对于系统调用异常,ExcCode为8,所以我们会跳转到exception_handlers[8],也就是handle_sys函数:

void (*exception_handlers[32])(void) = {
[0 ... 31] = handle_reserved,
[0] = handle_int,
[2 ... 3] = handle_tlb,
#if !defined(LAB) || LAB >= 4
[1] = handle_mod,
[8] = handle_sys,
#endif
};

handle_sys调用do syscall

handle_sys位于kern/genex.c中:

.macro BUILD_HANDLER exception handler
NESTED(handle_\exception, TF_SIZE + 8, zero)
move a0, sp
addiu sp, sp, -8
jal \handler
addiu sp, sp, 8
j ret_from_exception
END(handle_\exception)
.endm

BUILD_HANDLER sys do_syscall

把当前Trapframe指针放入a0寄存器中(刚刚保存好的用户现场),执行do_syscall函数,处理系统调用。再处理完后执行ret_from_exception函数返回用户态:

其中do_syscall位于kern/syscall_all.c中:

void do_syscall(struct Trapframe *tf) {
int (*func)(u_int, u_int, u_int, u_int, u_int);
int sysno = tf->regs[4]; // $a0
if (sysno < 0 || sysno >= MAX_SYSNO) {
tf->regs[2] = -E_NO_SYS; // $v0
return;
}
tf -> cp0_epc += 4;
func = syscall_table[sysno];
u_int arg1 = tf->regs[5];
u_int arg2 = tf->regs[6];
u_int arg3 = tf->regs[7];

u_int arg4, arg5;
arg4 = *((u_int *)tf -> regs[29] + 4); // $sp + 16
arg5 = *((u_int *)tf -> regs[29] + 5); // $sp + 20
tf->regs[2] = func(arg1, arg2, arg3, arg4, arg5);
}

恢复用户态

tf->regs[2]写入返回值之后,汇编入口执行以下命令返回用户态:

FEXPORT(ret_from_exception)
RESTORE_ALL
eret

RESTORE_ALLTrapframe 恢复寄存器,eret 根据 cp0_epc 返回用户态。
至此,系统调用全流程已经实现。

IPC通信

IPC是一种同步消息传递机制,发送者和接收者必须同时存在才能完成通信。接收方首先调用ipc_recv进入等待状态,发送方调用ipc_send发送消息,并把接收方变成可运行。接下来我们具体分析。

user/lib/ipc.c中,用户程序调用ipc_recv函数:

u_int ipc_recv(u_int *whom, void *dstva, u_int *perm) {
int r = syscall_ipc_recv(dstva);
if (r != 0) {
user_panic("syscall_ipc_recv err: %d", r);
}

if (whom) {
*whom = env->env_ipc_from;
}

if (perm) {
*perm = env->env_ipc_perm;
}

return env->env_ipc_value;
}

在用户态调用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()函数:

int fork(void) {
u_int child;
u_int i;

if (env->env_user_tlb_mod_entry != (u_int)cow_entry) {
try(syscall_set_tlb_mod_entry(0, cow_entry));
}

child = syscall_exofork();
if (child == 0) {
env = envs + ENVX(syscall_getenvid());
return 0;
}
for (i = 0; i < PDX(UXSTACKTOP); i++) {
if (vpd[i] & PTE_V) {
for (u_int j = 0; j < PAGE_SIZE / sizeof(Pte); j++) {
u_long va = (i * (PAGE_SIZE / sizeof(Pte)) + j) << PGSHIFT;
if (va >= USTACKTOP) {
break;
}
if (vpt[VPN(va)] & PTE_V) {
duppage(child, VPN(va));
}
}
}
}
syscall_set_tlb_mod_entry(child, cow_entry);
syscall_set_env_status(child, ENV_RUNNABLE);

return child;
}

注册TLB Mod 异常处理函数

if (env->env_user_tlb_mod_entry != (u_int)cow_entry) {
try(syscall_set_tlb_mod_entry(0, cow_entry));
}

fork()首先调用了syscall_set_tlb_mod_entry,这个函数主要作用是为指定的进程(envid)注册一个用户态的TLB Mod异常处理程序的入口地址。

int sys_set_tlb_mod_entry(u_int envid, u_int func) {
struct Env *env;
try(envid2env(envid, &env, 1));
env->env_user_tlb_mod_entry = func;
return 0;
}

异常处理程序入口cow_entry位于user/lib/fork.c,是用户态下的写时复制异常处理程序。内核的 do_tlb_mod 会把异常现场放到用户异常栈上,然后跳到该函数,

创建子进程

child = syscall_exofork();
if (child == 0) {
env = envs + ENVX(syscall_getenvid());
return 0;
}

用户态下调用syscall_exofork,内核态调用sys_exofork,这一步只做进程控制块和寄存器状态的复制,不复制内存。并且遵循以下原则:

父进程中 fork 返回 child envid;子进程中 fork 返回 0。

由于子进程刚创建时,用户态的 env 变量还是从父进程地址空间继承来的,需要修正为指向自己的 Env 结构。

复制地址空间映射

for (i = 0; i < PDX(UXSTACKTOP); i++) {
if (vpd[i] & PTE_V) {
for (u_int j = 0; j < PAGE_SIZE / sizeof(Pte); j++) {
u_long va = (i * (PAGE_SIZE / sizeof(Pte)) + j) << PGSHIFT;
if (va >= USTACKTOP) {
break;
}
if (vpt[VPN(va)] & PTE_V) {
duppage(child, VPN(va));
}
}
}
}

父进程遍历自己的页表,将虚拟页映射到子进程中。

设置子进程异常入口并让他可运行

syscall_set_tlb_mod_entry(child, cow_entry);
syscall_set_env_status(child, ENV_RUNNABLE);

总流程图如下

父进程调用 fork()
|
v
检查父进程是否已注册 cow_entry
|
| 未注册
v
syscall_set_tlb_mod_entry(0, cow_entry)
|
v
syscall_exofork()
|
v
内核 sys_exofork
|
| env_alloc 创建子 Env
| 复制父进程 Trapframe 到子进程
| 设置子进程 $v0 = 0
| 设置子进程 ENV_NOT_RUNNABLE
v
父进程从 syscall_exofork 返回 child envid
|
v
父进程遍历 vpd / vpt
|
v
对每个有效用户页调用 duppage(child, vpn)
|
v
判断页面权限
|
| 普通可写页
| 去掉 PTE_D,加 PTE_COW
| 映射给子进程
| 重映射父进程为 COW
|
| 不可写页 / PTE_LIBRARY / 已是 COW
| 按原权限映射给子进程
v
syscall_set_tlb_mod_entry(child, cow_entry)
|
v
syscall_set_env_status(child, ENV_RUNNABLE)
|
v
父进程 fork 返回 child envid
子进程之后被调度
|
v
从复制来的 Trapframe 恢复执行
|
v
因为子进程 $v0 = 0,所以 child == 0
|
v
修正用户态 env 指针
|
v
子进程 fork 返回 0
父子任一方写 COW 页
|
v
页无 PTE_D,触发 TLB Mod 异常
|
v
内核 do_tlb_mod 跳转到用户态 cow_entry
|
v
cow_entry 检查 PTE_COW
|
v
在 UCOW 分配新页
|
v
复制原页内容到 UCOW
|
v
把 UCOW 映射回触发异常的 va,权限改为可写
|
v
解除 UCOW 临时映射
|
v
syscall_set_trapframe 恢复异常现场
|
v
重新执行刚才的写指令,写入成功

2025-extra

这里提供 2025 年的extra供大家上机参考。extra主要考察的还是新增系统调用这方面的一些知识,只不过extra的难度在于c语言实现。我们的主要战场在kern/syscall_all.c。一些函数的申明在此不多赘述,照着题目提示复制粘贴即可。我们直接来看sys_***函数的实现:

  1. sys_shm_new:申请共享内存
// int sys_shm_new(u_int npage)

申请总大小为 npage 个页面的共享内存。在申请时,首先找到一个编号最小的,未被分配的(open = 0)的共享内存,并使用 page_alloc 函数申请 npage 个页面,进行必要的操作后,记录在 pages 数组中。

如果找不到这样可用的共享内存,返回 -E_SHM_INVALID 而无需申请页面。如果在申请页面时,空闲页面不足,返回 -E_NO_MEM。在这个过程中,请避免造成内存泄漏(也即,当发现分配失败时,你需要释放已经分配的页面)。

正确执行后,返回共享内存的编号(数组下标)。

做这个题的关键在于知道page_allloc是干啥的,并且理解本题的结构体的概念:

struct Shm {
u_int npage; // 共享内存的页面数量
struct Page *pages[N_SHM_PAGE]; // 存储共享内存页面的数组
u_int open; // 标志共享内存是否被分配,0表示未分配,1表示已分配
};

struct Shm shm_pool[N_SHM];

拆解题目,我们需要干的主体就是分配内存,如果分配失败清理已经分配的内存,最后返回共享内存的编号。题目还有如下提示:

对于某个共享内存的页面,可能被绑定多个进程,然后被全部解除绑定,此时,你应该保证这些物理页面仍可以进行新的绑定操作。提示:你可以通过在创建共享内存时,增加页面的 pp_ref 记录,并在销毁共享内存时使用 page_decref 函数释放页面。

所以代码也就呼之欲出了:

int sys_shm_new(u_int npage) {
if (npage == 0 || npage > N_SHM_PAGE) {
return -E_SHM_INVALID;
}

// Lab4-Extra: Your code here. (5/8)
// 1. 找到一个编号最小的,未被分配的共享内存
int i;
for (i = 0; i < N_SHM; i++) {
if (shm_pool[i].open == 0) {
break;
}
}
if (i == N_SHM) {
return -E_SHM_INVALID;
}
// 2. 申请 npage 个页面,成功则记录在 pages 数组中,失败则清理已经分配的页面并返回 -E_NO_MEM
for (int j = 0; j < npage; j++) {
struct Page *pp;
int r = page_alloc(&pp); // 记得page_alloc怎么用吗
if (r == 0) {
shm_pool[i].pages[j] = pp;
pp->pp_ref++;
} else {
for (int k = 0; k < j; k++) {
struct Page *p = shm_pool[i].pages[k];
page_decref(p);
shm_pool[i].pages[k] = NULL; // 清理已经分配的页面指针,保持好习惯
}
return -E_NO_MEM;
}
}
// 3. 记录共享内存的页面数量和状态,千万别忘了这一步
shm_pool[i].npage = npage;
shm_pool[i].open = 1;
return i;
}
  1. sys_shm_bind:绑定共享内存
将虚拟地址 va(保证按页对齐) 作为共享内存的起始地址,映射到编号为 key 的共享内存中。你需要将虚拟地址范围 [va, va + npage * PAGE_SIZE) 依次映射到共享内存的 npage 个物理页面上。

如果对应的共享内存未被分配(open = 0),则返回 -E_SHM_NOT_OPEN。否则,返回 0 即可。

这个题目关键在于记得映射虚拟地址和物理页面的函数是什么。

int sys_shm_bind(int key, u_int va, u_int perm) {
if (key < 0 || key >= N_SHM) {
return -E_SHM_INVALID;
}

// Lab4-Extra: Your code here. (6/8)
if (shm_pool[key].open == 0) {
return -E_SHM_NOT_OPEN;
}

for (int i = 0; i < shm_pool[key].npage; i++) {
u_int nowva = va + i * PAGE_SIZE;
page_insert(curenv->env_pgdir, curenv->env_asid, shm_pool[key].pages[i], nowva, perm);
}
return 0;
}
  1. sys_shm_unbind:解除绑定
将虚拟地址范围 [va, va + npage * PAGE_SIZE) 解除映射(va 保证按页对齐)。简单起见,这里的实现不需要关心这些虚拟地址是否真的被映射到共享内存之中,使用 page_remove 函数移除映射即可。

如果 key 对应的共享内存未被分配(open = 0),返回 -E_SHM_NOT_OPEN。否则,返回 0 即可。

顾名思义,本题的关键在于记得解除映射的函数是什么,代码片段如下:

for (int i = 0; i < shm_pool[key].npage; i++) {
u_int nowva = va + i * PAGE_SIZE;
page_remove(curenv->env_pgdir, curenv->env_asid, nowva);
}
  1. sys_shm_free:销毁共享内存
将 key 对应的共享内存注销(将 open 设为 0),并对页面进行必要的操作,将它们释放。

如果该共享内存原本未被分配,返回 -E_SHM_NOT_OPEN。否则,返回 0 即可。

测试数据保证在调用 sys_shm_free 前,全部的映射都已经被 unbind 了。

最后仿照第一个函数释放页面的形式实现即可,别忘了清理已经分配页面的指针shm_pool[key].pages[i] = NULL;

int sys_shm_free(int key) {
if (key < 0 || key >= N_SHM) {
return -E_SHM_INVALID;
}

// Lab4-Extra: Your code here. (8/8)
if (shm_pool[key].open == 0) {
return -E_SHM_NOT_OPEN;
}

shm_pool[key].open = 0;
for (int i = 0; i < shm_pool[key].npage; i++) {
struct Page *p = shm_pool[key].pages[i];
page_decref(p);
shm_pool[key].pages[i] = NULL;
}

return 0;
}

lab5

在开始lab5实验之前,建议大家提前学习了文件管理的相关理论知识。同时在学习过程中可以多和自己现实中的文件管理过程进行类比,有助于理解这个复杂的单元。

lab5主题是搭建一个文件管理系统,那么我们先来了解一下什么是文件管理系统

在我看来,所谓文件管理系统,就是一个可以让用户方便地管理文件的系统。它可以让用户创建、删除、移动、复制文件和文件夹。那么为了实现这一系列功能,我们就需要设计一套合理的流程,我们可以把这个流程分为三个层次:

  1. 底层:怎么读写磁盘
  2. 中层:怎么组织磁盘数据
  3. 上层:怎么让用户正常使用文件(用户接口)

最终的完整流程如下:

用户程序调用 open/read/write/close

用户态 fd 层找到对应设备 devfile

通过 IPC 请求文件系统服务 fs server

fs server 根据路径找到 struct File

根据 f_direct / f_indirect 找到文件数据块

通过块缓存读写磁盘块

必要时通过 ide_read / ide_write 操作 IDE

IDE 通过寄存器访问磁盘扇区

完成真正的数据读写

这篇博客将从下往上逐级介绍实现细节,对于代码细节不一定会深究,重在框架搭建和流程理解。(在懂了流程之后代码细节都好说)

底层,磁盘读写

用户态读写IDE设备寄存器

这一层解决了一个问题:用户态文件系统服务如何访问磁盘设备。我们现在内核文件kern/syscall_all.c中实现两个系统调用:

sys_read_dev(va, pa, len)
sys_write_dev(va, pa, len)

他们的作用不是直接读写磁盘文件,而是允许用户态程序读写指定的设备物理地址pa,也就是IDE的MMIO寄存器。
这里解释一下,MMIO(Memory-Mapped I/O)就是把硬件设备的控制寄存器伪装成普通的内存地址,这样用户态程序就可以通过普通的内存访问指令来读写设备寄存器了。

设备寄存器读写封装成IDE扇区读写

有了sys_read_dev/sys_write_dev之后,还不能直接读写文件,因为它们只是读写寄存器。接下来要在fs/ide.c中实现:

// diskno: 磁盘号
// secno: 起始逻辑扇区号
// nsecs: 要读取的扇区数
// dst: 目标内存地址指针
ide_read(diskno, secno, dst, nsecs)
ide_write(diskno, secno, src, nsecs)

这一层的核心思想是:按照IDE协议配置LBA寄存器,然后通过DATA寄存器传输数据。DATA寄存器只是数据传输的窗口,真正决定读写磁盘的哪里是LBA寄存器。LBA寄存器由四个寄存器组成,分别是:

  • LBA[7:0]:存储目标扇区号的低8位
  • LBA[15:8]:存储目标扇区号的中间8位
  • LBA[23:16]:存储目标扇区号的高8位
  • DEVICE寄存器:存储目标磁盘号和LBA模式等信息,以及目标扇区号的[24:27]位

函数整体流程如下:

等待 IDE 就绪

设置要操作的扇区数量

设置目标扇区号 secno 的 LBA[7:0], LBA[15:8], LBA[23:16]

设置目标磁盘 diskno , LBA 模式和 secno 的 [24:27] ,在 DEVICE 寄存器

写入读/写命令

通过 DATA 寄存器读出或写入一个扇区的数据

中层,磁盘数据组织

用fsformat构造文件系统镜像

这一部分主要解决磁盘里一开始应该放什么,我们引入tools/fsformat.c。这是一个运行在宿主机上的工具,不是MOS内部运行的程序,它负责把tests/lab5_x/rootfs目录下的文件按照MOS文件系统的格式写入到磁盘镜像fs.img中。

在理清楚fsformat的流程之前,我们介绍一下文件系统的磁盘布局:

磁盘布局

代码文件中会涉及几个重要的结构体:Block(磁盘块), Super(文件系统超级块), File(文件控制块)

struct Block {
uint8_t data[BLOCK_SIZE];
uint32_t type;
} disk[NBLOCK];
字段 含义
data[BLOCK_SIZE] 这个磁盘块真正保存的数据内容。一个块 4096 字节,可能存普通文件内容,也可能存目录项、位图、间接索引块等。
type 标记这个块的类型。例如这个块是数据块、文件控制块块、索引块、位图块等。主要用于 fsformat 构造镜像时管理磁盘布局。
disk[NBLOCK] 这是一个全局数组,模拟了磁盘上所有的块。我们在 fsformat 里直接操作这个数组来构造文件系统镜像。disk[i]表示第i个磁盘块。
struct Super {
uint32_t s_magic;
uint32_t s_nblocks;
struct File s_root;
};
字段 含义
s_magic 文件系统魔数,用于标识这是一个合法的文件系统。
s_nblocks 磁盘块总数,告诉文件系统有多少块可用。
s_root 根目录的文件控制块,包含根目录的元信息和索引关系。
struct File {
char f_name[MAXNAMELEN];
uint32_t f_size;
uint32_t f_type;
uint32_t f_direct[NDIRECT];
uint32_t f_indirect;
struct File *f_dir;
char f_pad[FILE_STRUCT_SIZE - MAXNAMELEN - (3 + NDIRECT) * 4 - sizeof(void *)];
} __attribute__((aligned(4), packed));
字段 含义
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主要做几件事:

init_disk()

初始化磁盘块、超级块、位图

遍历传入的文件/目录

如果是普通文件,调用 write_file
如果是目录,调用 write_directory

写入过程中:

为文件/目录分配 struct File
为文件内容分配数据块
维护 f_direct / f_indirect 索引关系

flush_bitmap()

把磁盘块占用情况写入位图

finish_fs()

把内存中的 disk[] 写入 fs.img

通过以上步骤,我们提前在磁盘里创建好了一个简单文件系统,包括超级块,根目录,位图,文件控制块以及文件数据块。

把磁盘块映射到用户态缓存

文件系统进程是用户态服务。它不能每次访问文件都直接读磁盘,因为磁盘 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):解除磁盘块和缓存的映射

具体流程如下:

磁盘块号 blockno

计算对应的缓存虚拟地址

如果没映射,就分配物理页并从磁盘读入

如果缓存被修改,标记 dirty

必要时 write_block 写回磁盘

磁盘空间管理,实现块的分配和释放

文件系统还需要知道哪些磁盘块空闲,哪些已经被占用。这部分依赖 bitmap 位图,涉及的函数有:

  • alloc_block():分配一个空闲块,返回块号
  • free_block(blockno):释放一个块,把它标记为可用
  • block_is_free(blockno):检查一个块是否空闲
alloc_block:
扫描 bitmap

找到值为 1 的空闲块

将其置为 0,表示已占用

把 bitmap 所在块写回磁盘

为这个 block 建立缓存映射

返回 blockno

free_block:
将 bitmap 对应 bit 置为 1

回写 bitmap

如果该块在缓存中,解除映射

如此,文件系统就可以像管理物理页一样管理磁盘块了

文件块管理,把文件逻辑块号映射到磁盘块号

这部分逻辑类似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中。
给定 File + 文件内部块号 filebno

如果 filebno 在直接索引范围内

找 f_direct[filebno]

否则

找 f_indirect 指向的间接索引块

在间接索引块中找到对应磁盘块号

目录管理

普通文件的数据块中存放的是文件内容,目录的数据块中存放的是多个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:
遍历目录的所有数据块

每个数据块里有多个 struct File

比较 f_name

找到对应文件

dir_alloc_file:
遍历目录中的 struct File

找到空闲 FCB

如果没有空闲 FCB,就给目录扩展一个新数据块

路径解析,从根目录找到目标文件

这是一个逐级调用dir_lookup的过程,核心函数是walk_path,这个函数至关重要,是后面file_open,file_create,file_remove的基础。

文件级操作

在路径解析和文件块管理的基础上,我们可以实现真正的文件操作,比如:

  • file_open() 打开一个文件,返回 struct File 结构体
  • file_create() 创建一个新文件,返回 struct File 结构体
  • file_remove() 删除一个文件
  • file_truncate() 清空一个文件的内容
  • ….

上层,用户接口

用户程序
|
| open/read/write/close/stat/remove
v
+-------------------------+
| fd.c |
| 通用 fd 管理与设备分发 |
+-------------------------+
|
| 如果 fd 是普通文件
v
+-------------------------+
| file.c |
| 普通文件设备接口 |
| open/file_read/write... |
+-------------------------+
|
| fsipc_open/map/dirty...
v
+-------------------------+
| fsipc.c |
| IPC 请求封装层 |
+-------------------------+
|
| IPC
v
+-------------------------+
| serv.c |
| 文件系统服务端 |
| serve_open/map/dirty... |
+-------------------------+
|
| 调用真正文件系统函数
v
+-------------------------+
| fs.c / ide.c / disk.c |
| 文件、目录、块缓存、磁盘 |
+-------------------------+

fd.c涉及的数据结构有:

struct Fd {
u_int fd_dev_id;
u_int fd_offset;
u_int fd_omode;
};
字段 含义
fd_dev_id 设备ID,标识这个文件描述符对应哪个设备。对于普通文件设备,这个值是0。
fd_offset 文件偏移量,表示当前读写位置在文件中的字节位置。每次读写操作后,这个值会更新。
fd_omode 打开模式,表示这个文件描述符是以什么模式打开的,比如只读、只写、读写等。
struct Dev {
int dev_id;
char *dev_name;
int (*dev_read)(struct Fd *, void *, u_int, u_int);
int (*dev_write)(struct Fd *, const void *, u_int, u_int);
int (*dev_close)(struct Fd *);
int (*dev_stat)(struct Fd *, struct Stat *);
int (*dev_seek)(struct Fd *, u_int);
};
// 如果fd是普通文件,就可以变形
struct Dev devfile = {
.dev_id = 'f',
.dev_name = "file",
.dev_read = file_read,
.dev_write = file_write,
.dev_close = file_close,
.dev_stat = file_stat,
};

file.c涉及的数据结构有:

struct Filefd {
struct Fd f_fd;
u_int f_fileid;
struct File f_file;
};

这是是普通文件设备专用的fd结构,它在Fd的基础上额外保存fileidstruct File的副本。

字段 含义
f_fd 这是一个通用的文件描述符结构,包含了文件描述符的基本信息,比如类型、权限等。
f_fileid 这是一个唯一标识符,用于区分不同的文件实例。它可以用来在文件系统服务端和用户态之间进行文件的引用和管理。
f_file 这是一个 struct File 结构体,是当前文件的struct File副本。包含了文件的元信息和索引关系。它是从文件系统服务端获取的,代表了用户态打开的文件在文件系统中的状态。

fsipc.c涉及的数据结构有:

struct Fsreq_* {
// 用户进程发给文件服务进程的 IPC 请求参数,位于 user/lib/fsreq.h
}
struct Open {
struct File *o_file;
u_int o_fileid;
int o_mode;
struct Filefd *o_ff;
};
字段 含义
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])

该函数主要功能是分配并映射用于管道通信的资源。具体代码逻辑如下:

  1. 分配文件描述符

分配两个描述符控制页fd0(读端)和fd1(写端)。并设置权限为PTE_LIBRARY表示共享,以便在fork后父子进程能同时看到状态修改。

    if ((r = fd_alloc(&fd0)) < 0 || (r = syscall_mem_alloc(0, fd0, PTE_D | PTE_LIBRARY)) < 0) {
        goto err;
    }
    if ((r = fd_alloc(&fd1)) < 0 || (r = syscall_mem_alloc(0, fd1, PTE_D | PTE_LIBRARY)) < 0) {
        goto err1;
    }
  1. 分配共享数据页

为管道申请物理内存页并映射给读端

    va = fd2data(fd0);
    if ((r = syscall_mem_alloc(0, (void *)va, PTE_D | PTE_LIBRARY)) < 0) {
        goto err2;
    }
  1. 实现共享映射

将同一个物理内存页映射给写端

if ((r = syscall_mem_map(0, (void *)va, 0, (void *)fd2data(fd1), PTE_D | PTE_LIBRARY)) < 0) {
        goto err3;
    }
  1. 将分配的文件描述符编号写入pfd[0]和pfd[1]
pfd[0] = fd2num(fd0);
pfd[1] = fd2num(fd1);

pipe_read

该函数实现了从管道文件描述符中读取最多n字节数据到用户缓冲区vbuf中。核心逻辑为缓冲区读空时的处理:

while (p->p_rpos >= p->p_wpos) {
    if (i > 0 || _pipe_is_closed(fd, p)) {
        return i;
    }
    syscall_yield();
}

如果i>0说明之前已经成功读取了至少1个字节,应当立即返回已经读取的字节数;如果写端已经全部关闭了,说明以后不会有新的数据写入,可以立即返回。如果以上条件都不满足,主动让出CPU,等待写进程写入数据。

写进程同理,这里就不赘述

_pipe_is_closed()

该函数用于检测通道是否关闭,通过物理页引用计数的比较来实现。

  • pageref(p):该管道共享数据页的总引用计数

  • pageref(fd):当前持有的这一端描述符的引用计数。

显然,当fd_ref == page_ref时,说明只剩下当前这一端还映射这这个管道,另一端已无进程打开,即该管道已关闭。

但是在处理过程中我们要考虑并发安全:

do {
        runs = env->env_runs;
        fd_ref = pageref(fd);
        pipe_ref = pageref(p);
    } while (runs != env->env_runs);

    return fd_ref == pipe_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操作系统中,创建并运行新程序需要组合使用forkexec。然而,MOS采用了类似Windows的spawn机制,把解析和加载程序的任务移到了用户态,简化了微内核的设计。

控制核心为spawn函数,拆解分析如下:

  1. 打开ELF可执行文件

根据传入的程序路径prog,以只读模式打开对应的ELF格式可执行文件。

int fd;
    if ((fd = open(prog, O_RDONLY)) < 0) {
        return fd;
    }
  1. 读取并校验ELF头部

从文件中读取sizeof(Elf32_Ehdr)大小的数据到临时缓冲区elfbuf。并调用elf_from函数进行ELF格式校验。最后将程序的入口地址保存在entrypoint中,后续用于初始化PC寄存器。

int r;
u_char elfbuf[512];
if ((r = readn(fd, elfbuf, sizeof(Elf32_Ehdr))) != sizeof(Elf32_Ehdr)) {
    goto err;
}
const Elf32_Ehdr *ehdr = elf_from(elfbuf, sizeof(Elf32_Ehdr));
if (!ehdr) {
    r = -E_NOT_EXEC;
    goto err;
}
u_long entrypoint = ehdr->e_entry;
  1. 创建一个空白子进程

利用syscall_exofork()创建一个新的进程控制块,并复制父进程的寄存器状态,但不会拷贝任何内存页面。此时子进程处于挂起状态,需要注意的是syscall_exofork()返回值是子进程的envid(环境id),如果执行失败则返回负数。

u_int child;
child = syscall_exofork();
if (child < 0) {
    r = child;
    goto err;
}
  1. 初始化子进程的用户栈

调用init_stack申请一个物理页,将其映射到子进程的栈顶处。并向栈中压入argcargv等参数,最终计算出子进程启动时sp寄存器应指向的虚拟地址,写入sp变量中。

u_int sp;
if ((r = init_stack(child, argv, &sp)) < 0) {
    goto err1;
}
  1. 将ELF程序段加载到子进程内存中
size_t ph_off;
ELF_FOREACH_PHDR_OFF (ph_off, ehdr) {
    if ((r = seek(fd, ph_off)) < 0) { goto err1; }
    if ((r = readn(fd, elfbuf, ehdr->e_phentsize)) != ehdr->e_phentsize) { goto err1; }
    Elf32_Phdr *ph = (Elf32_Phdr *)elfbuf;
    if (ph->p_type == PT_LOAD) {
        void *bin;
        // 1. 将文件中的段数据读入父进程的临时缓冲区
        r = read_map(fd, ph->p_offset, &bin);
        if (r != 0) { goto err1; }
        // 2. 将段数据映射并加载到子进程的虚拟内存空间中
        r = elf_load_seg(ph, bin, spawn_mapper, &child);
        if (r != 0) { goto err1; }
    }
}

close(fd);
  1. 设置子进程的寄存器上下文

初始化执行状态,子进程被fork出来时寄存器与父进程一致,此时我们需要修正他的两个关键寄存器的值:

  • cp0_epc:修改为ELF入口地址entrypoint,

  • regs[29]:修改为刚刚初始化完argv参数的栈顶指针sp

struct Trapframe tf = envs[ENVX(child)].env_tf;
tf.cp0_epc = entrypoint; // 设置 PC 寄存器为程序入口地址
tf.regs[29] = sp;        // 设置 SP 寄存器为第四步计算出的栈指针
if ((r = syscall_set_trapframe(child, &tf)) != 0) {
    goto err2;
}
  1. 继承父进程的共享页面

扫描当前父进程页表中所有有效的虚拟页面,如果页面的权限标志中包含PTE_LIBRARY,说明该页面需要跨进程共享,通过syscall_mem_map将这些页面以相同的虚拟地址和权限直接映射到子进程中。这也是子进程能够直接使用父进程打开的管道和重定向文件的根本原因。

// 扫描整个用户空间页表直到 USTACKTOP
for (u_int pdeno = 0; pdeno <= PDX(USTACKTOP); pdeno++) {
    if (!(vpd[pdeno] & PTE_V)) { continue; }
    for (u_int pteno = 0; pteno <= PTX(~0); pteno++) {
        u_int pn = (pdeno << 10) + pteno;
        u_int perm = vpt[pn] & ((1 << PGSHIFT) - 1);
        if ((perm & PTE_V) && (perm & PTE_LIBRARY)) {
            void *va = (void *)(pn << PGSHIFT);
            // 共享映射到子进程对应虚拟地址
            if ((r = syscall_mem_map(0, va, child, va, perm)) < 0) {
                goto err2;
            }
        }
    }
}
  1. 使能并启用子进程
if ((r = syscall_set_env_status(child, ENV_RUNNABLE)) < 0) {
    goto err2;
}
return child;

将子进程的状态从挂起设为ENV_RUNNABLE即可。

再见OS

至此,本学期的OS实验要向大家说再见了,希望大家在提交lab6之后能带着从容和自信,为这段学习旅程写下一个圆满的句号。

Lyrics Sharing

阳光总在风雨后
乌云上有晴空
珍惜所有的感动
每一份希望在你手中
阳光总在风雨后
请相信有彩虹
风风雨雨都接受
我一直会在你的左右

文章作者: Cordial-Kid
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Cordial-Kid !
  目录