OS Lab5流程
本文主要介绍 MOS Lab5 中文件系统的实现流程
如何从用户态磁盘读写出发,逐步实现一个支持文件创建、打开、读写、关闭和删除的文件系统。
全文主要包括以下内容:
- 实现
sys_read_dev和sys_write_dev,支持用户态访问 IDE 设备寄存器。 - 封装
ide_read和ide_write,完成基于 LBA 模式的磁盘扇区读写。 - 使用
fsformat工具,将宿主机文件和目录写入磁盘镜像fs.img。 - 解释
struct File、直接索引和间接索引如何组织普通文件与目录。 - 实现块缓存机制,将磁盘块映射到用户态虚拟地址空间中。
- 通过 bitmap 管理磁盘块的分配与释放。
- 实现文件块映射、目录查找和路径解析。
- 实现文件的创建、打开、关闭、截断、删除和刷新。
- 说明文件系统服务进程如何通过 IPC 为普通用户进程提供文件操作接口。
整体来看,本文的主线是:
1 | 设备寄存器读写 |
此外本文中对于具体函数实现等部分说的有点多,在看的时候可以先忽略这个具体的函数的细节,重点先把握主体的脉络,理解总体的目的和流程,等到最后再具体深入到函数的具体实现,可能这样会舒服很多。 。 。 然后文章中对于 Makefile 和 fsformat 的描述有点过用,但是其实这个东西和操作系统本身关系不大,主要用于对于创建这个 mos 时候将测试用的文件写进去以及对于有关进程的创建(本文其实并没有过多涉及),方便评测使用,稍微了解即可QAQ。
实现在用户态对磁盘进行读写
首先,在用户态实现两个系统调用: kern/syscall_all.c 中的 sys_write_dev , sys_read_dev 。
int sys_read_dev(u_int va, u_int pa, u_int len) 将位于物理地址位 pa 的 IDE 中的长度为 len 的数据读入虚拟地址 va 处, int sys_write_dev(u_int va, u_int pa, u_int len) 将位于虚拟地址 va 处的数据物理写入地址位 pa 的 IDE 中的长度为 len 的数据。
pa 是设备寄存器的物理地址,也就是 MMIO/I/O 寄存器地址。sys_read_dev/sys_write_dev 不是直接读写磁盘扇区,而是读写设备寄存器。IDE 扇区读写是通过多次访问这些寄存器间接完成的。
1 | int sys_write_dev(u_int va, u_int pa, u_int len) { |
这里的 iowrite 和 ioread 位于文件 include/io.h ,通过直接对 pa | KSEG1 位置进行读取数据来实现。
在实现了这个基本的从外界硬件读取数据的函数之后,就可以进一步封装成对 IDE 进行读取的函数:
很显然,我们刚才实现的对外界硬件数据的读写是很笼统的,如果我们想要实现对于磁盘的读取,必须知道:从哪个磁盘读取/写入、从这个磁盘的哪个扇区读取/写入、读取/写入到什么位置、每次读取/写入多少个字节,这些信息。这就需要我们对 sys_read_dev 和 sys_write_dev 进行进一步的封装:

如图,在 LBA 模式下,IDE 设备不再使用传统的“柱面 / 磁头 / 扇区”方式寻址,而是把整个磁盘抽象成一个连续的扇区数组:
1 | sector 0, sector 1, sector 2, sector 3, ... |
每个扇区都有一个唯一的逻辑编号,也就是 secno。如果要读写某个扇区,CPU 需要先把这个扇区编号按照 IDE 协议拆分后写入几个 LBA 相关寄存器中:
1 | LBA[7:0] -> LBA LowLBA[15:8] -> LBA MidLBA[23:16] -> LBA HighLBA[27:24] -> Device 寄存器低 4 位 |
同时,CPU 还需要写入扇区数量寄存器,告诉 IDE 这次要操作几个扇区;写入 Device 寄存器,告诉 IDE 使用 LBA 模式并选择目标磁盘;最后向 Command 寄存器写入读或写命令。
因此,IDE 一次读写操作可以理解为:
1 | 设置目标磁盘和扇区号→ 设置读写扇区数量→ 发送读/写命令→ 通过 DATA 寄存器传输数据 |
需要注意的是,0x180001F0 对应的是 IDE 的 DATA 数据寄存器。CPU 最终确实是通过这个固定地址读出或写入数据的,但这个地址本身并不决定访问磁盘的哪个位置。真正决定磁盘位置的是前面写入的 LBA 寄存器、扇区数量寄存器和命令寄存器。
也就是说,DATA 寄存器更像是一个统一的数据窗口:
1 | LBA 寄存器决定“读写磁盘哪里” Command 寄存器决定“执行读还是写” DATA 寄存器负责“数据从这里进出” |
所以,当 CPU 从 0x180001F0 读取数据时,IDE 控制器会根据之前设置好的 LBA 地址和读命令,把对应扇区的数据送到这个数据寄存器中;当 CPU 向 0x180001F0 写入数据时,IDE 控制器也会根据之前设置好的 LBA 地址和写命令,把这些数据写入指定的磁盘扇区。
所以我们这里就要对刚才实现的 sys_write_dev , sys_read_dev 这两个函数进行进一步的封装:需要在读写之前对磁盘的 LBA 寄存器按照标准写入,很显然这里就可以通过我们刚刚实现的 sys_write_dev 来实现,因为我们是知道这个 LBA寄存器中每一个的物理地址(如图),在写完了这些数据之后通过我们刚才实现的函数来对 DATA 寄存器进行读写数据,就可以通过之前说的那样,经过 IDE 的协助帮你成功的获得你需要的磁盘中你需要的磁盘块的数据。
1 | void ide_read(u_int diskno, u_int secno, void *dst, u_int nsecs) |
ide_write 同理。
现在我们就实现了在用户态对磁盘设备的读写操作。
离线构造阶段:fsformat
如何实现将我们现在的文件写入磁盘镜像
tools/fsformat.c 是用于创建符合我们定义的文件系统镜像的工具,可以将多个文件按照内核所定义的文件系统写入到磁盘镜像中。
后面这一段是对整体如何通过Makefile实现将这个文件写入的说明,你会花费一段时间看完,然后分数将会增长0分!
这里的意思是,当我们要运行代码的时候,比如我们运行 make test lab=5_3 && make run 的时候,在 tests/lab5_2 这个文件夹下会有以下文件:
rootfs这个文件夹,这个文件夹中存储着提前准备好的文件,也就是我们需要将他编译进入磁盘镜像中的文件;kernel.mk中设置了fs-files和init-env
1 | # kernel.mk |
先说这个 fs-files 的用处,在定义了这个 Makefile 变量之后,会在顶层的 Makefile中有 :
1 | fs-image: $(target_dir) user |
这里面,image 是 make 的目标产物,依赖是这个tests目录下的rootfs这个目录文件,然后运行这个makefile的环境是在fs这个文件夹之下。在fs文件夹中的Makefile中 :
1 | image: $(tools_dir)/fsformat |
先生成两个磁盘镜像:fs.img 和 empty.img ,之后,再通过我们写的 tools 下的 fsformat 这个工具将我们的rootfs这个文件夹下面的文件写入磁盘镜像中。
这个 init-envs 的作用在后面再细说。
fs_check.c和Makefile:这两个文件,前面是用于测试的代码,后面的Makefile则主要是实现对这个测试代码编译进入MOS,方便测试。
fsformat 是如何实现将文件写入磁盘镜像中的功能
先说磁盘块 Block :对于一个磁盘的空间,我们将他分成了许多个大小相同的磁盘块(block),在这个实验中,每一个block的大小和一个页一样大,为4096个字节。控制这个磁盘块的结构体如下所示,disk[i] 表示第 i 个磁盘块,一共有 NBLOCK个磁盘块,每一个磁盘块都有自己的 type(类型)和 data 。
1 | struct Block |
在所有的这些磁盘块中,一般来说,第0个磁盘块是引导扇区和分区表 ,第1个磁盘块是超级块,后面还有位图表示磁盘的占用情况,再往后就是存放数据的数据块。
再介绍一个基本的结构体: struct File :
1 | struct File { |
所有的文件/目录都用这样的 struct File * 这样的一个文件控制块进行具体描述。在这个结构体中,f_name 是文件/目录的名字,f_size 是文件/目录的大小,f_type 是文件的类型:用来区分文件 (Regular file) 和 目录(Directory) 。f_direct 和 f_indirect 是文件/目录的具体内容在磁盘的存储位置。其中,f_direct 用来直接存储,f_indirect 用来间接存储,这两个存储的内容会根据文件的类型不一样而发生变化:
- 如果文件的类型是文件(Regular_file) :则这里面每一个
f_direct[i]里面存储的内容是这个文件中的部分内容存储到的磁盘中的磁盘块的编号。超过直接索引的上限的则通过间接索引找到:f_indirect这个指针指向一个磁盘块,这个磁盘块中存储了磁盘中用来存储文件内容的磁盘块的编号。 - 如果文件的类型为目录(Direction): 则这里面的每一个
f_direct[i]里面存储的内容是存储了这个子文件的文件控制块的磁盘块的编号,这个磁盘块里面连续存放多个子文件/子目录的 struct File,而间接指针存储的就是一个存储了这个子文件的文件控制块的磁盘块的编号的磁盘块的编号;
这里的文字表述可能有点抽象,可以看下面的uml图来理解一下 :

说完这两个结构体就可以具体说说这个 fsformat 是如何实现的了z,我们可以直接从他的 main 函数开始看 :
1 | int main(int argc, char **argv) |
首先进行一步 init_disk ,将磁盘镜像中的将所有的块都标为空闲块。这里的磁盘就是要对我们上面的 disk[] 这个数组中的内容进行处理。在具体实现的过程中,根据一共有的磁盘块的数量,为每一个磁盘块分配一个bit用来存储这个磁盘的状态,1表示空闲,0表示被占用,这里分配的这些bit也需要占用空间,这里占用的磁盘块就被标记为位图。这里要注意一点 :
如果位图还有剩余,不能将最后一块位图块中靠后的一部分内容标记为空闲,因为这些位所对应的磁盘块并不存在,是不可使用的。因此,将所有的位图块的每一位都置为 1 之后,还需要根据实际情况,将位图不存在的部分设为 0。
这里要求 argc 至少大于等于3,也就是至少需要传入两个参数,argv[1] 是磁盘的镜像文件,argv[2 ……] 是需要写入磁盘的文件/目录名。对于每一个要写入磁盘镜像的文件,会根据这个文件的类型选择 write_directory 或者 write_file 两个函数进行处理。
通过 flush_bitmap 函数对于写入文件之后的磁盘中的位图进行重新标记,在进行文件写入的过程中会维护 nextbno 这个变量的值为已使用的磁盘块的数量,通过将位图的对应 bit 置为0来表示这个磁盘块已经被占用;
finish_fs 函数把内存里的 disk[] 写到镜像文件。所谓的磁盘镜像本质上就是一个文件,所以给定的 argv[1] 就是这个磁盘镜像的文件名,这里可以直接使用 C 标准库中 open 函数打开这个文件,然后通过 write 将我们的 disk[i].data 写入这个文件,这一步就完成了。
下面再具体说说这个 write_directory 和 write_file 是如何实现的,这两个函数分别实现将目录 、文件中的具体内容写入磁盘块中。想要实现将当前文件写入磁盘镜像中,首先,我们得为这个文件先分配一个 struct File 结构体用来管理这个文件,
从最开始的函数看起,next_block 函数实现了取出下一个空闲的磁盘块,并将磁盘块的类型设置为 type ,前面提到,在 flush_bitmap 这个函数中,我们需要使用到全局变量 nextbno 来设置这些磁盘块被使用,这里之所以可以使用这个全局变量就是因为,在分配磁盘块的过程中我们统一使用 next_block 这个函数统一分配磁盘块,同时会将 nextbno++ ,这样在刷新 disk 的存储的时候,我们只需要遍历将 [0,nextbno) 中的磁盘编号对应的bit置为0即可。
1 | int next_block(int type) |
对于一个文件来说,为他分配了磁盘块之后,他的文件控制块(FCB)也应该相对应的发生改变,比如需要修改这个 FCB 中的 file_size ,在这个文件控制块中记录磁盘块的变量中增加这个新分配的磁盘块。这一步是通过 make_link_block 这个函数做的 :
1 | int make_link_block(struct File *dirf, int nblk) |
他会调用这个 save_block_link 函数 :
1 | void save_block_link(struct File *f, int nblk, int bno) |
其实很简单,save_block_link 会在文件控制块之中添加这个新分配的磁盘块的索引,具体如果文件当前使用的磁盘块没有超过直接索引的范围,则直接在直接索引的数组中添加上这个新分配的磁盘块编号即可;否则,则需要将间接索引对应的磁盘块中加入这个新分配的磁盘块编号。
通过这一步,后续便可以通过这个FCB找到这个磁盘块,make_link_block 相当于在分配一个新的磁盘块之后,同时添加了新分配的磁盘块与这个文件之间的连接,同时对于这个文件的部分内容进行了修改。
接下来就可以具体看看 create_file 函数了 :
1 | struct File *create_file(struct File *dirf) |
先解释这个 FILE2BLK 对于一个目录来说,他的磁盘块中存储的是文件控制块FCB,在 fs.h 中有 :
1 |
那这个FILE2BLK其实就是一个磁盘块中存储的FCB的数量。
再看看这个 create_file 函数,其实就是先找到这个目录的所有磁盘块,取出来每一个磁盘块中的文件控制块,如果存在FCB中的文件名称为空,则说明这个FCB没有被占用,直接返回这个FCB ;如果不存在FCB没有被占用的情况,则就通过 make_link_block 函数为他分配一个新的磁盘块,再将这个新的磁盘块的第一个FCB的指针返回。相当于如果当前目录下有空闲的FCB,就用空闲的,如果没有空闲的那就重新分配一个磁盘块,从中取出一个FCB使用。
现在我们就可以理解这个 write_file 函数了:
1 | void write_file(struct File *dirf, const char *path) |
实现了把宿主机中的一个普通文件 path 写入到自制文件系统镜像 disk[] 中,并在目录 dirf 下生成对应的文件控制块。其中,使用 create_file 在 dirf 下创建了一个FCB,再将目标目录中的文件内容通过 read 函数写入 disk 中的同时,使用 save_block_link 将这个新的FCB与对应的磁盘块建立联系。再结合 main 函数看一眼,这里的 dirf 其实就是super块里的根目录 File 结构 。
这里需要说明,这个super块,是文件系统的总控制块,保存整个文件系统的基本信息和根目录入口,也就是 super.root 。
同样的 write_directory 和这个几乎一摸一样,只是对于一个目录来说,下面可能既有目录也有文件,如果是文件,直接调用 write_file ,否则就递归调用 write_directory 。
这样就实现了将宿主机中的文件/目录写入 disk 中,同时维护好了这个super块和每一个FCB与磁盘块的映射关系,最后再通过 flush_bitmap 和 finish_fs 这样就实现了将文件写入磁盘镜像中。通过这些步骤就成功实现了在mos内核中创建一个简单的文件系统结构了。
文件系统的具体实现
通过上面的步骤,我们在mos中创建了一个磁盘镜像,再结合我们最开始实现的对于磁盘的读写的函数:ide_read 和 ide_write ,我们应该可以实现对于磁盘中内容的读写了。我们现在能实现给你一个磁盘块的编号就可以读取磁盘上对应位置的数据,但是,如果我们想要实现对于一个文件进行读写,这些函数肯定还是不行的,因为给你一个文件名,你想要获得他在磁盘中的位置,他占用了多少个磁盘块,这对于用户来说是很困难的事情。所以我们需要对现在的这两个函数进行进一步的封装,实现对于文件的操作。
首先我们要实现的是对于磁盘块数据的读写
在操作这个磁盘中的数据的过程中,由于对于硬盘 I/O 操作一次只能读取少量数据,同时频繁读写磁盘会拖慢系统,故常用块缓存将数据暂存于内存,减少磁盘访问次数,从而加快数据读取。意思是每次读取磁盘数据会读取一个磁盘块的数据,将他放入缓存中。这里在指导书中提到:将 DISKMAP 到 DISKMAP+DISKMAX 这一段虚存地址空间(0x10000000-0x4FFFFFFF) 作为缓冲区,这一段的大小为1G ,但是我们再看看我们的磁盘,经过简单的计算:1024 * 4096 得到我们的磁盘大小是4M 。 。 。这也就意味着,我们可以随便把磁盘中的内容放进缓冲区。实验中采取线性映射的方法,使用 disk_addr 函数,实现了对于磁盘编号为 blockno 的磁盘块映射到对应的缓冲区的地址,这个地方得到的就是这个磁盘块映射到的虚拟地址:
1 | void *disk_addr(u_int blockno) { |
当然,除了为他分配需要的虚拟地址,还需要为这个磁盘块分配物理页来保存这个磁盘块的内容。这里又有一系列函数用来实现这个过程。一方面要看这个磁盘块是否已经映射到了物理页上,另一方面要实现对于磁盘块的内容与物理页的映射的map与unmap。
首先,实现判断磁盘块是否已经映射到物理页:调用位于 fs/fs.c 中的函数 block_is_mapped :
1 | void *block_is_mapped(u_int blockno) { |
其中:
1 | int va_is_mapped(void *va) { |
就是看这个va对应的页表中的一级页表项和二级页表项是否有效,如果有效,则这个磁盘块对应的va映射到了一个物理页,也就是说,这个磁盘块已经映射到了一个物理页了。
再看是如何给这个磁盘块映射到一个物理页上的:
1 | int map_block(u_int blockno) { |
如果已经映射了,则直接返回,否则,通过系统调用 syscall_mem_alloc 来为这个磁盘块对应的虚拟地址分配一个物理页。
再看看是如何给这个磁盘块解除映射的:
1 | void unmap_block(u_int blockno) { |
这里与增加映射不一样的地方在于,如果我的缓存中的磁盘内容已经被更改,那么我需要再接触这个缓存和磁盘块的映射之前将缓存中的内容写入这个磁盘块中,从而保证前后内容的一致。
这里要判断这个磁盘块是否被更改,使用两个函数:
1 | int block_is_free(u_int blockno) { |
这个函数通过在位图中的bit,来判断这个磁盘块是否被使用,如果空闲返回1,否则返回0 。
1 | int block_is_dirty(u_int blockno) { |
这个函数通过查看磁盘块对应的虚拟地址的vpt中是否有脏位(这个脏位具体在write_block函数中),如果有,则认为缓存中的内容已经被更改。
如果这个磁盘块被使用了,而且缓存中的内容也被修改了,则需要调用 write_block 函数:
1 | void write_block(u_int blockno) { |
注意: 这个地方中 blockno 是磁盘块号,而在ide读写的要求是要得到这个扇区号,通过代码发现一个扇区是512B,而一个磁盘块是4096B,很显然两个不一样大,所以在读的过程中需要宏 SECT2BLK 等宏处理一下。
这里还有 read_block 函数,实现了将 blockno 的磁盘块的内容读出来,读入的虚拟地址为 *blk ,通过 *isnew 说明读入的磁盘块之前是否已经被读入,后面两个参数如果传入为 NULL ,则不会返回。
1 | int read_block(u_int blockno, void **blk, u_int *isnew) { |
除了对于磁盘块的读入读出之外,还需要实现对于一个磁盘块的磁盘空间管理,也就是我们还要能够实现能够像物理页面一样实现对于空闲磁盘块的申请,已使用的磁盘块的释放。这里我们实现了 alloc_block 和 free_block 两个函数。这里的重点在于对于位图的读取和对于位图的修改。
先说 alloc_block :
1 | int alloc_block(void) { |
这里的 alloc_block_num 函数用于寻找空闲的磁盘块,如果找到了空闲的磁盘块,就把他与一个物理页建立映射关系,最后返回这个磁盘块的编号。下面是 alloc_block_num ,关键点在于对于从super块读取位图,和对位图的更新。通过super块得到了整个文件系统的大小,进一步得到位图的大小;遍历位图上的每一个位,如果位图上这一个bit为1(free)就把这个位置为0,同时把缓存中的内容通过 write_block 回写到磁盘中。
1 | int alloc_block_num(void) { |
再说 free_block 函数,这个函数其实非常简单,只需要更新位图,解除映射,回写到磁盘就结束了。
1 | void free_block(u_int blockno) { |
在实现了对磁盘块的处理之后,就要实现对于文件中块的操作了
这里在前面的基础上,实现了对于文件操作的几个函数:file_block_walk , file_map_block , file_get_block , file_clear_block 。
先说这个 file_block_walk 函数,这个函数和 pgdir_walk 函数非常相像,实现了根据文件内的块号 filebno,
找到它在 File 结构中应该存放磁盘块号的位置,这里其实就是得到的 **ppdiskbno 就是这个文件块所存放的磁盘块的编号 。(这里其实可以具体看 file_map_block 函数 )
如果 filebno 小于直接块数量,那么位置就在 f->f_direct[filebno]。 如果 filebno 超过直接块范围,那么位置就在 f->f_indirect 指向的间接块中。 如果需要间接块,但是目前还没有, 并且 alloc 参数允许分配, 那么函数会自动分配一个间接块。
1 | int file_block_walk(struct File *f, u_int filebno, uint32_t **ppdiskbno, u_int alloc) { |
再看 file_map_block :在文件 f 下找第 filebno 块对应的文件块在磁盘所存放的磁盘块的编号,本质就是对上一个 file_block_walk 的一次封装。
1 | int file_map_block(struct File *f, u_int filebno, u_int *diskbno, u_int alloc) { |
这里有一个函数 dirty_fcb :在 f 的父目录 f->f_dir 的目录数据块中,找到哪个目录数据块包含这个 struct File f,
然后只把那个目录数据块标脏。
1 | void dirty_fcb(struct File *f) { |
这个 dirty_block 就是给这个 blockno 对应的 va 对应的 vpt 的权限位加一个 PTE_DIRTY 。
1 | int dirty_block(u_int blockno) { |
还有函数 file_clear_block ,从文件 f 中移除一个文件块 ,函数 file_get_block 得到文件第 filebno 个数据块在块缓存中的虚拟地址。这里都代码都比较容易理解,不多做赘述。
1 | int file_clear_block(struct File *f, u_int filebno) { |
目录也是一种文件,这里还实现了 dir_lookup 和 dir_alloc_file 两个函数,用来实现对目录的操作。
dir_lookup 这个函数寻找目录下的文件名为name的文件,通过一个指针返回。
1 | int dir_lookup(struct File *dir, char *name, struct File **file) { |
dir_alloc_file 则实现了为 dir 分配一个文件控制块的大小,如果目录中有空闲的直接返回,否则分配一个磁盘块,从中取出一个空闲的文件控制块通过 file 指针返回。
1 | int dir_alloc_file(struct File *dir, struct File **file) { |
在实现了这些基本功能之后,还需要进一步实现对于一个文件的打开关闭读写等操作
首先需要看看一个基本的函数 walk_path :从根目录开始寻找路径为 path 的文件,并返回:目标文件 pfile、它所在的父目录 pdir,以及最后一级名字 lastelem 。本质就是对 dir_lookup 的进一步封装。最开始的目录是根目录,每一次从 path 中从前往后取出来一个文件名称,在当前目录下进行寻找,再循环如此,最终找到这个路径对应的文件。这个函数只有在能找到 path 的文件,或者在这个文件没有被找到,但是目前已经到达这个文件的目录的时候 ,才会设置对应的 pdir ,lastelem。
1 | int walk_path(char *path, struct File **pdir, struct File **pfile, char *lastelem) { |
则现在就可以实现 file_open :本质就是通过路径找到这个文件,返回这个文件控制块。
1 | int file_open(char *path, struct File **file) { |
对应的 file_close 会先 file_flush(f),把文件相关脏块写回磁盘;如果 f 是普通文件,则遍历它的数据块并 unmap_block。目录文件不在这里按普通文件数据块方式全部 unmap。
1 | void file_close(struct File *f) { |
除了打开文件之外,还可以实现对于一个文件的创建: file_create 。先使用 walk_path 查看文件是否存在,如果不存在,则查看他的最近一级目录是否存在。如果不存在,则创建文件失败;如果存在,那么就在这个目录下创建需要的文件的FCB :为这个 dir 使用函数 dir_alloc_file 创建一个FCB ,并设置这个FCB的一些基本信息,最后再刷新这个新创建的文件的目录到磁盘。
1 | int file_create(char *path, struct File **file) { |
通过上述的方式实现了创建路径为 path 的文件。
在实现了创建一个文件之后,就是对一个文件进行删除和释放了。这里我们要实现函数 file_truncate , file_set_size ,file_remove
先看核心函数 file_truncate ,他的作用是f把文件的逻辑大小f_size 截断为 newsize,并释放 newsize 之后不再需要的文件数据块。
如果新的文件大小需要的文件块可以直接映射得到,则只需要释放多的直接映射块和间接映射块(如果存在);
否则就只需要删除这个间接映射中多余的文件块即可。
这个函数主要用来缩小文件的大小。
1 | void file_truncate(struct File *f, u_int newsize) { |
对应的 file_set_size 就是实现了对与上述函数的一层封装,使用上面的函数减小文件大小,同时实现了对于文件大小增加的功能。
1 | int file_set_size(struct File *f, u_int newsize) { |
而 file_remove 则就是通过 path 找到文件之后,将文件大小变为0,再刷新磁盘即可。
1 | int file_remove(char *path) { |
至此,我们实现了对于文件系统中对 磁盘块, 文件块, 文件/目录的一系列操作。
文件系统的用户接口
MOS 操作系统内核符合一个典型的微内核的设计,文件系统属于用户态进程,以服务的形式供其他进程调用。也就是说,如果我们想要实现对于文件的操作,本质上是通过文件系统服务进程来实现的。
这里的意思是:普通用户进程自己不直接操作磁盘,而是把“我要打开文件 / 映射文件块 / 关闭文件 / 同步文件”等请求,通过 IPC 发给 文件服务进程 fs server。文件服务进程收到请求后,替用户进程操作磁盘和文件系统元数据,再把结果返回给用户进程。
这一部分主要说明:这个文件系统服务进程如何实现对于文件的开关读写。
对于文件系统服务进程来说,对文件进行操作的时候需要使用文件描述符来存储文件的基本信息和用户进程中关于文件的状态;同时,文件描述符也起到描述用户对于文件操作的作用。这里先介绍两个在用户态使用的结构体 :Fd ,FileFd 。
1 | struct Fd { |
Fd 是文件标识符,包含三个参数,分别为:fd_dev_id:这个 fd 属于哪个设备,比如普通文件、控制台、管道。fd_offset:当前读写偏移。read() / write() 后会更新它。fd_omode:打开模式,比如 O_RDONLY、O_WRONLY、O_RDWR。
FCB / struct File 描述的是文件本身,是磁盘文件系统中的元数据。Fd 描述的是某个进程打开文件后的状态,例如当前偏移量、打开模式、设备类型。Filefd 则是普通文件设备专用的 fd 结构,它在 Fd 的基础上额外保存 fileid 和 struct File 的副本。
很显然,这个 Fd 其实和 FCB 类似,在 user/lib/fd.c 中实现了用户态中对于 Fd 操作的一系列函数。
在 user/include/fd.h 中给出了 Fd 的定义和相关的宏 :每一个进程最多32个 Fd ,还有 Fd 存储的虚拟地址以及范围,还有通过 Fd 编号得到对应的虚拟地址的宏。INDEX2FD(i) 得到第 i 个 fd 的“文件描述符结构体页”的虚拟地址INDEX2DATA(i) 得到第 i 个 fd 对应的“文件数据映射区”的虚拟地址
1 |
总体的布局大概是这样:
1 | 低地址 |
下面按 user/lib/fd.c 中的函数顺序,总结它们对 Fd 的操作作用。
fd.c 的整体定位是:用户态文件描述符管理层。它负责 fd 的分配、查找、关闭、复制,以及把 read/write/close/stat 分发给具体设备,比如 file、console、pipe。
dev_lookup(int dev_id, struct Dev **dev) :根据 fd->fd_dev_id 查找对应设备结构 struct Dev。
1 | int dev_lookup(int dev_id, struct Dev **dev) { |
例如普通文件的 dev_id 对应 devfile,控制台对应 devcons,管道对应 devpipe。
它的作用是让通用的 read/write/close 能够根据 fd 类型调用不同设备的处理函数:
1 | dev->dev_read |
fd_alloc(struct Fd **fd) :寻找一个空闲的 fd 位置。
1 | int fd_alloc(struct Fd **fd) { |
它会从 0 到 MAXFD - 1 扫描,检查每个 fd 对应的虚拟地址是否已经映射:如果该 fd 页没有映射,就认为这个 fd 编号空闲,并把地址返回到 *fd。注意:fd_alloc() 只找空位,不分配物理页。真正映射 fd 页通常由后续操作完成,比如普通文件打开时由文件服务进程把 Filefd 页映射回来。
fd_close(struct Fd *fd) :关闭一个 fd 页本身。
1 | void fd_close(struct Fd *fd) { |
它只是取消当前进程中这个 fd 页的映射。页一旦取消映射,这个 fd 编号就重新变为空闲。
注意:它不负责通知具体设备关闭文件。真正的完整关闭流程在 close() 中完成。
fd_lookup(int fdnum, struct Fd **fd) :根据 fd 编号找到对应的 struct Fd *。检查这个 Fd 对应的虚拟地址是否已经映射。如果映射存在,说明 fd 有效。
1 | int fd_lookup(int fdnum, struct Fd **fd) { |
fd2data(struct Fd *fd) :根据一个 Fd * 找到该 fd 对应的数据映射区。
1 | void *fd2data(struct Fd *fd) { |
fd2num(struct Fd *fd) :根据 Fd * 地址反推出 fd 编号。
1 | int fd2num(struct Fd *fd) { |
num2fd(int fd) :根据 fd 编号计算对应的 fd 虚拟地址。
1 | int num2fd(int fd) { |
它和 fd2num() 是相反方向的转换。
close(int fdnum) :完整关闭一个 fd。
流程是:
- 用
fd_lookup()找到Fd。 - 用
dev_lookup()找到对应设备。 - 调用设备自己的关闭函数:
1 | int close(int fdnum) { |
对于lab5来说,通过 dev_lookup 找到的 dev 就是 devfile ,定义在 user/lib/file.c 中 :
1 | struct Dev devfile = { |
也就是说,这个close 函数在关闭一个 Fd 的同时还通过 file.c 中的 file_close 函数同时关闭了这个文件。
close_all(void) :关闭当前进程中所有 fd。
1 | void close_all(void) { |
dup(int oldfdnum, int newfdnum):复制文件描述符。
它会把旧 fd 的 fd 页映射到新 fd 的位置,并且把旧 fd 对应的数据页也映射到新 fd 的数据区域。这样 newfdnum 和 oldfdnum 指向同一个打开对象。它不是重新打开文件,也不是复制文件内容,而是复制/共享页映射。
1 | int dup(int oldfdnum, int newfdnum) { |
read(int fdnum, void *buf, u_int n):通用读接口。从文件中读取最多n个字节写入缓冲区buf里面。
流程是:
fd_lookup()找到Fd。dev_lookup()找到设备。- 检查打开模式,如果是
O_WRONLY,返回错误。 - 调用设备自己的读函数:
- 如果读取成功,更新:
所以read()本身不关心具体怎么读。普通文件、控制台、管道的读法由各自设备函数决定。对于文件的read还是通过file.c中的file_read实现的。
1 | int read(int fdnum, void *buf, u_int n) { |
write(int fdnum, const void *buf, u_int n) :流程和 read() 类似:主要还是通过 file.c 中的 file_write 进行实现,这里不多赘述。
这个地方还有一些其他的函数不再多说明,可以参照下面的表格:
1 | fd_alloc 找空闲 fd 地址 |
在用户态进程和文件服务进程交互实现文件管理服务
在有了上面的对于文件标识符进行控制的函数之后,我们就可以具体查看在用户态实现文件系统服务的实现。MOS 操作系统中的文件系统服务通过 IPC 的形式供其他进程调用,进行文件读写操作。具体来说,在内核开始运行时,就使用 ENV_CREATE(fs_serv) 启动了文件系统服务进程 fs_serv,用户进程需要进行文件操作时,使用 user/lib/ipc.c 中的 ipc_send、 ipc_recv 与 fs_serv 进行交互,完成操作。
在 user/lib/file.c 中我们实现了对于一个用户进程对于文件的打开、关闭、读、写、修改大小等功能,在实现这些功能的过程中,有些功能是不需要ipc进程通信可以直接实现的,有些则需要和文件管理服务进程进行进程的通信,使用我们上面说到的在 fs/fs.c 中的实现的功能来完成。但其实,在 fs/serv.c 中我们实现了对于上述的 fs.c 中的函数的进一步封装,通过调用 ipc 的相关函数实现了文件系统调用函数之后将结果传递给用户进程。
那么到底什么是文件服务进程?
在本实验中,fs_serv 是由内核启动阶段通过 ENV_CREATE(fs_serv) 创建出来的第二个用户环境。它启动后执行 fs/serv.c 的 main(),初始化文件系统,然后进入 serve() 死循环,通过 IPC 接收普通用户进程的文件请求并处理它们。
1 | ENV_CREATE(user_fstest); |
而文件服务进程其实就是一直运行 fs/serv.c 中的那个main函数,这是一个死循环,不断的等待请求进行处理 :
1 | int main() { |
这里直接看 serve 函数:
1 | void serve(void) { |
为了实现这个serve函数,在 fs/serve.c 中还做了很多准备:
服务向量表,与根据传进来的参数来选择合适的处理函数,同时这里的 serve_open 都是对上面我们说的 file_open 的又一次封装,主要实现对于进程之间的信息传递。这里只说明 serve_open 函数。
1 | void *serve_table[MAX_FSREQNO] = { |
先说说进程之间的通信的具体细节。举一个例子具体说明,当用户进程调用 open 函数的时候 :
1 | // user/lib/file.c |
这个函数的核心在于通过 fsipc_open 获得 path 对应的文件标识符 fd 。在这之后便是通过 fsipc_map 把打开的文件内容按块映射到当前进程的用户地址空间中。
在 user/lib/fsipc.c 中定义了 fsipc_* 函数,大多数都是调用 fsipc 函数来与文件服务进程通信完成服务。对于 fsipc_open 函数 :
1 | int fsipc_open(const char *path, u_int omode, struct Fd *fd) { |
这里就需要对 fsipc 函数进行一个进一步说明 :
首先在 user/include/fsreq.h 中有相关的宏定义 ,包括这个 fsipc 第一个 type 参数,还有每一个 type 对应的结构体的定义(这里只说明open相关的,其他的都差不多,不再赘述)
1 | enum { |
这里面的在调用fsipc时会被用到:type用来表示请求类型,fsreq则是请求类型相对应的一个结构体,这是传递的主要内容,包括请求服务的文件的一些基本信息,在 open 中这里的信息是文件的路径以及请求打开文件的模式(只读,只写……)。
1 | static int fsipc(u_int type, void *fsreq, void *dstva, u_int *perm) { |
在 fsipc 的内部会调用 ipc_send 和 ipc_recv 两个函数去和文件服务进程进行通信。这里 envs[1].env_id 就是文件服务进程的 env id。这里的 ipc_send 和 ipc_recv 会通过系统调用 syscall_ipc_try_send 和 syscall_ipc_recv 完成。之所以第2个进程是文件服务进程,这是在init的时候约定好的。
现在跳转到了serve 函数中(见上面),他会先通过 ipc_recv 接住来自刚才用户进程的 ipc_send 的信息,在确定没有错误之后,就会根据传进来的 type 也就是 serve 中的 req 来从选择服务向量表中对应的服务函数。再调用这个服务函数,将请求的进程的envid和请求的文件的有关信息传入函数中,交给函数进行具体处理。
1 | void serve_open(u_int envid, struct Fsreq_open *rq) |
他会先通过 open_alloc 分配服务端打开文件项,服务端有一个打开文件表:struct Open opentab[MAXOPEN];每个 Open 记录:struct Open { struct File *o_file; u_int o_fileid; int o_mode; struct Filefd *o_ff; }; open_alloc() 会找一个空闲项。成功后,o 指向该项,返回的编号就是 fileid。如果用户打开模式包含 O_CREAT,服务端会调用:file_create(path, &f) 来创建文件。之后会通过函数 file_open 打开这个文件,得到这个文件的FCB,如果带 O_TRUNC,则清空文件。最后会根据得到的文件信息构造 Filefd,最后通过 IPC 把 Filefd 页返回给用户,被存储在一个 Fd 指针中。
这里需要注意的是:
1 | open() |
也就是只有open , remove 要通过文件服务进程来实现,对于文件的写,读直接在用户进程就可以实现,因为在 fd 中保存了文件的内容。
再次总结一下这个流程:
用户进程调用文件服务函数 open -> 在用户态中通过函数 fsipc 具体实现 -> fsipc 通过 ipc 进程通讯向文件服务进程发送一个请求 -> 文件服务系统中的 serve 函数接收到请求,通过服务表分发给具体 serve_open 函数处理 -> serve_open 函数中会调用 fs/fs.c 中的函数处理这个请求,再调用 ipc_send 将处理的结果返回给用户进程 -> 用户进程接收到文件服务进程处理的结果,实现再用户进程对文件的管理。
这样我们就是实现了对于一个在用户态使用进程通信实现对于文件的处理了。
- Title: OS Lab5流程
- Author: Connor
- Created at : 2026-05-26 16:35:22
- Updated at : 2026-05-26 16:44:12
- Link: https://redefine.ohevan.com/2026/05/26/os-lab5/
- License: This work is licensed under CC BY-NC-SA 4.0.