File system
来到指导书最高点!太美丽了xv6。哎呀那不文件系统吗(
这里是自底向上讲起的。之后可以看看hit网课的自顶向下。
Overview
The disk layer reads and writes blocks on an virtio hard drive.
The buffer cache layer caches disk blocks and synchronizes access to them, making sure that only one kernel process at a time can modify the data stored in any particular block.
The logging layer allows higher layers to wrap updates to several blocks in a transaction, and ensures that the blocks are updated atomically in the face of crashes (i.e., all of them are updated or none). 【日志记录层允许更高层将更新包装到一个事务中的多个块,并确保在崩溃时以原子方式更新块(即,所有块都更新或不更新)。可以类比一下数据库的那个概念。】
The inode layer provides individual files, each represented as an inode with a unique i-number and some blocks holding the file’s data.
The directory layer implements each directory as a special kind of inode whose content is a sequence of directory entries, each of which contains a file’s name and i-number.
The pathname layer provides hierarchical path names like /usr/rtm/xv6/fs.c, and resolves them with recursive lookup.
The file descriptor layer abstracts many Unix resources (e.g., pipes, devices, fifiles, etc.) using the file system interface, simplifying the lives of application programmers.
The file system must have a plan for where it stores inodes and content blocks on the disk. To do so, xv6 divides the disk into several sections, as Figure 8.2 shows.
The file system does not use block 0 (it holds the boot sector).
Block 1 is called the superblock; it contains metadata about the file system (the file system size in blocks, the number of data blocks, the number of inodes, and the number of blocks in the log). The superblock is filled in by a separate program, called
mkfs
, which builds an initial file system.Blocks starting at 2 hold the log.
After the log are the inodes, with multiple inodes per block.
After those come bitmap blocks tracking which data blocks are in use. 【应该是用来标识每个块是否空闲的吧】
The remaining blocks are data blocks; each is either marked free in the bitmap block, or holds content for a file or directory【要么空闲要么是文件或目录】.
Buffer cache
The buffer cache has two jobs:
- synchronize access to disk blocks to ensure that only one copy of a block is in memory and that only one kernel thread at a time uses that copy;
- cache popular blocks so that they don’t need to be re-read from the slow disk.
The code is in
bio.c
.Buffer cache中保存磁盘块的缓冲区数量固定,这意味着如果文件系统请求还未存放在缓存中的块,Buffer cache必须回收当前保存其他块内容的缓冲区。Buffer cache为新块回收最近使用最少的缓冲区。这样做的原因是认为最近使用最少的缓冲区是最不可能近期再次使用的缓冲区。
数据结构定义
1 | struct buf { |
这应该代表着一个磁盘块。
1 | struct { |
大概buf数组里存储着所有buf的内容。buf本身通过最近使用排序的双向链表连接,head是链表的头。
初始化
1 | // called by main.c |
上层接口
The main interface exported by the buffer cache consists of
bread
andbwrite
.The buffer cache uses a per-buffer sleep-lock to ensure concurrent security.
bread
bread
obtains a buf containing a copy of a block which can be read or modified in memory.依据给定设备号和给定扇区号寻找cache的buf。返回的buf是locked的。
1 | // Return a locked buf with the contents of the indicated block. |
bwrite
writes a modified buffer to the appropriate block on the disk
1 | // Write b's contents to disk. Must be locked. |
brelse
A kernel thread must release a buffer by calling brelse when it is done with it.
1 | // Release a locked buffer. |
具体细节
bget
用于获取cache中是否存在block。如果不存在,则新申请一个buf,并把该buf以上锁状态返回
1 | // Look through buffer cache for block on device dev. |
Logging layer
简介
Xv6通过简单的日志记录形式解决了文件系统操作期间的崩溃问题。
xv6系统调用不会直接写入磁盘上的文件系统数据结构。相反,它会在磁盘上的log(日志)中放置它希望进行的所有磁盘写入的描述。一旦系统调用记录了它的所有写入操作,它就会向磁盘写入一条特殊的commit(提交)记录,表明日志包含一个完整的操作。此时,系统调用将写操作复制到磁盘上的文件系统数据结构。完成这些写入后,系统调用将擦除磁盘上的日志。
如果系统崩溃并重新启动,则在运行任何进程之前,文件系统代码将按如下方式从崩溃中恢复:
如果日志标记为包含完整操作,则恢复代码会将写操作复制到磁盘文件系统中它们所属的位置,然后擦除日志。如果日志没有标记为包含完整操作,则恢复代码将忽略该日志,然后擦除日志。
这就保证了原子性。
Log design
superblock记录了log的存储位置。
它由一个头块(header block)和一系列更新块的副本(logged block)组成。
头块包含一个扇区号(sector)数组(每个logged block对应一个扇区号)以及日志块的计数。
磁盘上的头块中的计数为零表示日志中没有事务,为非零表示日志包含一个完整的已提交事务,并具有指定数量的logged block。
在事务提交(commit)时Xv6才向头块写入数据,在此之前不会写入。在将logged blocks复制到文件系统后,头块的计数将被设置为零。
因此,事务中途崩溃将导致日志头块中的计数为零;提交后的崩溃将导致非零计数。
为了允许不同进程并发执行文件系统操作,日志系统可以将多个系统调用的写入累积到一个事务中。因此,单个提交可能涉及多个完整系统调用的写入。为了避免在事务之间拆分系统调用,日志系统仅在没有文件系统调用进行时提交。
同时提交多个事务的想法称为组提交(group commit)。组提交减少了磁盘操作的数量,因为成本固定的一次提交分摊了多个操作。组提交还同时为磁盘系统提供更多并发写操作,可能允许磁盘在一个磁盘旋转时间内写入所有这些操作。Xv6的virtio驱动程序不支持这种批处理,但是Xv6的文件系统设计允许这样做。
【这感觉实现得也还挺简略的】
Xv6在磁盘上留出固定的空间来保存日志。事务中系统调用写入的块总数必须可容纳于该空间。这导致两个后果:
任何单个系统调用都不允许写入超过日志空间的不同块。
【这段话我一个字没看懂】
这对于大多数系统调用来说都不是问题,但其中两个可能会写入许多块:
write
和unlink
。一个大文件的write
可以写入多个数据块和多个位图块以及一个inode块;unlink
大文件可能会写入许多位图块和inode。Xv6的write
系统调用将大的写入分解为适合日志的多个较小的写入,unlink
不会导致此问题,因为实际上Xv6文件系统只使用一个位图块。日志空间有限的另一个后果是,除非确定系统调用的写入将可容纳于日志中剩余的空间,否则日志系统无法允许启动系统调用。
Code: logging
log的原理是这样的:
在每个系统调用的开始调用
begin_op
表示事务开始,然后之后新申请一块block,也即把该block的内容读入内存,并且把该block的blockno记录到log的header中。此后程序正常修改在内存中的block,磁盘中的block保持不变。最后commit的时候遍历log header中的blockno,一块块地把内存中的block写入日志和磁盘中。如果程序在commit前崩溃,则内存消失,同时磁盘也不会写入;如果在commit后崩溃,那也无事发生。
在每次启动的时候,都会执行log的初始化,届时可以顺便恢复数据。
完美实现了日志的功能。
数据结构
1 | // Contents of the header block, used for both the on-disk header block |
关键函数
begin_op()
begin_op
等待直到日志系统当前未处于提交中,并且直到有足够的未被占用的日志空间来保存此调用的写入。
log.outstanding
统计预定了日志空间的系统调用数;为此保留的总空间为log.outstanding
乘以MAXOPBLOCKS
(10)。递增log.outstanding
会预定空间并防止在此系统调用期间发生提交(if的第二个分支)。代码保守地假设每个系统调用最多可以写入MAXOPBLOCKS
(10)个不同的块。
1 | // called at the start of each FS system call. |
log_write
log_write
充当bwrite
的代理。它将块的扇区号记录在内存中,在磁盘上的日志中预定一个槽位,并调用bpin
将缓存固定在block cache中,以防止block cache将其逐出【具体原理就是让refcnt++,这样就不会被当成空闲block用掉了】。为啥要防止换出呢?换出不是就正好自动写入磁盘了吗?这里一是为了保障前面提到的原子性,防止换入换出导致的单一写入磁盘;二是换出自动写入的是磁盘对应位而不一定是日志所在的blocks。
1 | void |
end_op
1 | // called at the end of each FS system call. |
commit
1 | static void |
write_log
1 | // Copy modified blocks from cache to log. |
write_head
1 | // Write in-memory log header to disk. |
install_trans
1 | // Copy committed blocks from log to their home location |
恢复与初始化
上面介绍了log的一次事务提交的流程。接下来介绍它是怎么恢复的。
recover_from_log
是由initlog
调用的,而它又是在第一个用户进程运行之前的引导期间由fsinit
调用的。
第一个进程运行之前
由前面scheduler一章的知识可知,每个进程被初次调度的时候会先来执行forkret
。这时候就做了log的恢复工作。
注释解释了为什么不选择在main.c
中初始化,而选择在此处初始化。确实,它需要调用sleep,如果在main.c中调用sleep感觉会乱套()毕竟那时候scheduler线程尚未被初始化。
1 | // A fork child's very first scheduling by scheduler() |
fsinit
1 | // Init fs |
initlog
1 | void |
recover_from_log
1 | static void |
Code: Block allocator
个人理解
说实话没怎么懂,也不大清楚它有什么用,先大概推测一下:
之前的bread和bwrite这些,就是你给一个设备号和扇区号,它就帮你加载进内存cache。你如果要用的话,肯定还是使用地址方便。所以block allocator的作用之一就是给bread和bwrite加一层封装,将获取的block封装为地址返回,你可以直接操纵这个地址,而无需知道下层的细节。
这个过程要注意的有两点:
封装返回的地址具体是什么,怎么工作的
封装返回的地址实质上是buffer cache中的buf的data字段的地址【差不多】。之后的上层应用在该地址上写入,也即写入了buf,最后会通过log层真正写入磁盘。
结合bcache的LRU,详细谈谈工作机制
我们可以看到,在balloc中有这么一段逻辑:
1
2
3
4
5bp = bread(dev, BBLOCK(b, sb));
// ...
log_write(bp);
brelse(bp);
return b + bi;看到的第一反应就是,我们需求的那块buf是bp,但是这里先是bread了一次,又是brelse了一次,这样bp的refcnt不就为0,很容易被替换掉了吗?
会有这个反应,一定程度上是因为没有很好地理解LRU。事实上,正是它可能被替换掉,才满足了LRU的条件。因为它可能被替掉才能说明它可能是最近最少使用的。
bitmap
文件和目录内容存储在磁盘块中,磁盘块必须从空闲池中分配。xv6的块分配器在磁盘上维护一个空闲位图,每一位代表一个块。0表示对应的块是空闲的;1表示它正在使用中。
引导扇区、超级块、日志块、inode块和位图块的比特位是由程序
mkfs
初始化设置的:
allocator
类似于memory allocator,块分配器也提供了两个函数:bfree
和balloc
。
balloc
Balloc
从块0到sb.size
(文件系统中的块数)遍历每个块。它查找位图中位为零的空闲块。如果balloc
找到这样一个块,它将更新位图并返回该块。为了提高效率,循环被分成两部分。外部循环读取位图中的每个块。内部循环检查单个位图块中的所有BPB位。由于任何一个位图块在buffer cache中一次只允许一个进程使用【
bread(dev, BBLOCK(b, sb))
会返回一个上锁的block,bread
和brelse
隐含的独占使用避免了显式锁定的需要】,因此,如果两个进程同时尝试分配一个块也是并发安全的。
1 | // Allocate a zeroed disk block. |
bfree
1 | // Free a disk block. |
Inode layer
inode
术语inode(即索引结点)可以具有两种相关含义之一。它可能是指包含文件大小和数据块编号列表的磁盘上的数据结构【on-disk inode】。或者“inode”可能指内存中的inode【in-memory inode】,它包含磁盘上inode的副本以及内核中所需的额外信息。
on-disk inode
The on-disk inodes are packed into a contiguous area of disk called the inode blocks.
Every inode is the same size, so it is easy, given a number n, to find the nth inode on the disk. In fact, this number n, called the inode number or i-number, is how inodes are identifified in the implementation.
1 | // in fs.h |
in-memory inode
The kernel keeps the set of active inodes in memory.
The kernel stores an inode in memory only if there are C pointers referring to that inode.当且仅当ref==0才会从内核中释放。
如果nlinks==0就会从物理block中释放。
The
iget
andiput
functions acquire and release pointers to an inode, modifying the reference count.【相当于buffer cache的balloc
和bfree
】Pointers to an inode can come from file descriptors, current working directories, and transient kernel code such as exec.
iget
返回的struct inode
可能没有任何有用的内容。为了确保它保存磁盘inode的副本,代码必须调用ilock
。这将锁定inode(以便没有其他进程可以对其进行ilock
),并从磁盘读取尚未读取的inode。iunlock
释放inode上的锁。将inode指针的获取与锁定分离有助于在某些情况下避免死锁,例如在目录查找期间。多个进程可以持有指向iget
返回的inode的C指针,但一次只能有一个进程锁定inode。
1 | //in file.h |
Code: inode
主要是在讲inode layer这一层的方法,以及给上层提供的接口。
Overview
底层接口
iget
iput
iget
逻辑还是跟buffer cache非常相似的,不过可以看出这个的数据结构简单许多,也不用实现LRU。
A struct inode pointer returned by iget() is guaranteed to be valid until the corresponding call to iput(): the inode won’t be deleted, and the memory referred to by the pointer won’t be re-used for a different inode. 【通过ref++实现。】
不同于buffer cache的
bget
,iget()
提供对inode的非独占访问,因此可以有许多指向同一inode的指针。文件系统代码的许多部分都依赖于iget()
的这种行为,既可以保存对inode的长期引用(如打开的文件和当前目录),也可以防止争用,同时避免操纵多个inode(如路径名查找)的代码产生死锁。
1 | // Find the inode with number inum on device dev |
iput
iput()
可以写入磁盘。这意味着任何使用文件系统的系统调用都可能写入磁盘,因为系统调用可能是最后一个引用该文件的系统调用。即使像read()
这样看起来是只读的调用,也可能最终调用iput()
。这反过来意味着,即使是只读系统调用,如果它们使用文件系统,也必须在事务中进行包装。
iput()
和崩溃之间存在一种具有挑战性的交互。iput()
不会在文件的链接计数降至零时立即截断文件,因为某些进程可能仍在内存中保留对inode的引用:进程可能仍在读取和写入该文件,因为它已成功打开该文件。但是,如果在最后一个进程关闭该文件的文件描述符之前发生崩溃,则该文件将被标记为已在磁盘上分配,但没有目录项指向它。如果不做任何处理措施的话,这块磁盘就再也用不了了。文件系统以两种方式之一处理这种情况。简单的解决方案用于恢复时:重新启动后,文件系统会扫描整个文件系统,以查找标记为已分配但没有指向它们的目录项的文件。如果存在任何此类文件,接下来可以将其释放。
第二种解决方案不需要扫描文件系统。在此解决方案中,文件系统在磁盘(例如在超级块中)上记录链接计数降至零但引用计数不为零的文件的i-number。如果文件系统在其引用计数达到0时删除该文件,则会通过从列表中删除该inode来更新磁盘列表。重新启动时,文件系统将释放列表中的所有文件。
Xv6没有实现这两种解决方案,这意味着inode可能被标记为已在磁盘上分配,即使它们不再使用。这意味着随着时间的推移,xv6可能会面临磁盘空间不足的风险。
1 | // Drop a reference to an in-memory inode. |
上层接口
获取和释放inode
ialloc
1 | // Allocate an inode on device dev. |
inode的锁保护
前面说到,inode的设计使得有多个指针同时指向一个inode成为了可能。因而,修改使用inode的时候就要对其进行独占访问。使用ialloc
获取和用ifree
释放的inode必须被保护在ilock
和iunlock
区域中。
ilock
ilock
既可以实现对inode的独占访问,同时也可以给未初始化的inode进行初始化工作。
iget
返回的struct inode
可能没有任何有用的内容。为了确保它保存磁盘inode的副本,代码必须调用ilock
。这将锁定inode(以便没有其他进程可以对其进行ilock
),并从磁盘读取尚未读取的inode。
1 | // Lock the given inode and reads the inode from disk if necessary. |
iunlock
iunlock
释放inode上的锁。将inode指针的获取与锁定分离有助于在某些情况下避免死锁,例如在目录查找期间。多个进程可以持有指向
iget
返回的inode的C指针,但一次只能有一个进程锁定inode。
1 | // Unlock the given inode. |
Code: inode content
Overview
主要讲的是inode本身存储数据的结构
磁盘上的inode结构体
struct dinode
包含一个size
和一个块号数组(见图8.3),数组内罗列着存储着该inode数据的块号。前面的
NDIRECT
个数据块被列在数组中的前NDIRECT
个元素中;这些块称为直接块(direct blocks)。接下来的NINDIRECT
个数据块不在inode中列出,而是在称为间接块(indirect block)的数据块中列出。addrs
数组中的最后一个元素给出了间接块的地址。因此,可以从inode中列出的块加载文件的前12 kB(
NDIRECT x BSIZE
)字节,而只有在查阅间接块后才能加载下一个256 kB(NINDIRECT x BSIZE
)字节。
1 | // On-disk inode structure |
bmap
函数
bmap
负责封装这个寻找数据块的过程,以便实现我们将很快看到的如readi
和writei
这样的更高级例程。
bmap(struct inode *ip, uint bn)
返回inodeip
的第bn
个数据块的磁盘块号。如果ip
还没有这样的块,bmap
会分配一个。
Bmap
使readi
和writei
很容易获取inode的数据。
1 | // Inode content |
itrunc
itrunc
释放文件的块,将inode的size
重置为零。
Itrunc
首先释放直接块,然后释放间接块中列出的块,最后释放间接块本身。
readi
readi
和writei
都是从检查ip->type == T_DEV
开始的。这种情况处理的是数据不在文件系统中的特殊设备;我们将在文件描述符层返回到这种情况。
1 | // Read data from inode.数据大小为n,从off开始,读到dst处 |
writei
1 | // Write data to inode. |
stati
函数
stati
将inode元数据复制到stat
结构体中,该结构通过stat
系统调用向用户程序公开。
在defs.h
中可看到inode结构体是private的,而stat是public的。
Directory layer
数据结构
目录的内部实现很像文件。其inode的
type
为T_DIR
,其数据是directory entries的集合。每个entry都是一个
struct dirent
。
也就是说这一层其实本质上是一个大小一定的map,该map自身也存放在inode中,大小为inode的大小,每个表项entry映射了目录名和文件inode。所以接下来介绍的函数我们完全可以从hashmap增删改查的角度去理解。
1 | // Directory is a file containing a sequence of dirent structures. |
相关函数
dirlookup
函数
dirlookup
在directory中搜索具有给定名称的entry。它返回的指向enrty.inum相应的inode是非独占的【通过iget获取】,也即无锁状态。它还会把
*poff
设置为所需的entry的字节偏移量。为什么要返回未锁定的inode?是因为调用者已锁定
dp
,因此,如果对.
进行查找,则在返回之前尝试锁定inode将导致重新锁定dp
并产生死锁【确实】(还有更复杂的死锁场景,涉及多个进程和..
,父目录的别名。.
不是唯一的问题。)所以锁定交给caller来做。caller可以解锁
dp
,然后锁定该函数返回的ip
,确保它一次只持有一个锁。
1 | // Look for a directory entry in a directory. |
dirlink
1 | // Write a new directory entry (name, inum) into the directory dp. |
Pathname layer
Path name lookup involves a succession of calls to dirlookup, one for each path component.
namei和nameiparent
Namei (kernel/fs.c:661) evaluates path and returns the corresponding inode.
函数
nameiparent
是一个变体:它在最后一个元素之前停止,返回父目录的inode并将最后一个元素复制到name
中。两者都调用通用函数namex
来完成实际工作。
1 | struct inode* |
1 | struct inode* |
namex
Namex
首先决定路径解析的开始位置。如果路径以“ / ”开始,则从根目录开始解析;否则,从当前目录开始。
然后,它使用
skipelem
依次考察路径的每个元素。循环的每次迭代都必须在当前索引结点ip
中查找name
。迭代首先给
ip
上锁并检查它是否是一个目录。如果不是,则查找失败。如果caller是
nameiparent
,并且这是最后一个路径元素,则根据nameiparent
的定义,循环会提前停止;最后一个路径元素已经复制到name
中【在上一轮循坏中做了这件事】,因此namex
只需返回解锁的ip
。最后,循环将使用
dirlookup
查找路径元素,并通过设置ip = next
为下一次迭代做准备。当循环用完路径元素时,它返回ip
。注:
- 在每次迭代中锁定
ip
是必要的,不是因为ip->type
可以被更改,而是因为在ilock
运行之前,ip->type
不能保证已从磁盘加载,所以得用到ilock保证一定会被加载的这个性质。
1 | // Look up and return the inode for a path name. |
namex
过程可能需要很长时间才能完成:它可能涉及多个磁盘操作来读取路径名中所遍历目录的索引节点和目录块(如果它们不在buffer cache中)。Xv6 is carefully designed,如果一个内核线程对
namex
的调用在磁盘I/O上阻塞,另一个查找不同路径名的内核线程可以同时进行。Namex
locks each directory in the path separately so that lookups in different directories can proceed in parallel.锁细粒度化This concurrency introduces some challenges. For example, while one kernel thread is looking up a pathname another kernel thread may be changing the directory tree by unlinking a directory. A potential risk is that a lookup may be searching a directory that has been deleted by another kernel thread and its blocks have been re-used for another directory or file.一个潜在的风险是,查找可能正在搜索已被另一个内核线程删除且其块已被重新用于另一个目录或文件的目录。
Xv6避免了这种竞争,也就是说,你查到的inode保证暂时不会被释放,里面的内容还是真的,而不会被重新利用从而导致里面的内容变样。
例如,在
namex
中执行dirlookup
时,lookup线程持有目录上的锁,dirlookup
返回使用iget
获得的inode。Iget
增加索引节点的引用计数。只有在从dirlookup
接收inode之后,namex
才会释放目录上的锁。现在,另一个线程可以从目录中取消inode的链接,但是xv6还不会删除inode,因为inode的引用计数仍然大于零。另一个风险是死锁。例如,查找“
.
”时,next
指向与ip
相同的inode【确实】。在释放ip
上的锁之前锁定next
将导致死锁【为什么???难道不是会由于在acquire时已经持有锁,从而爆panic("acquire")
吗?】。为了避免这种死锁,namex
在获得下一个目录的锁之前解锁该目录。这里我们再次看到为什么iget
和ilock
之间的分离很重要。
File descriptor layer
Unix的一个很酷的方面是,Unix中的大多数资源都表示为文件,包括控制台、管道等设备,当然还有真实文件。文件描述符层是实现这种一致性的层。
数据结构
Xv6为每个进程提供了自己的打开文件表或文件描述符。每个打开的文件都由一个
struct file
表示,它是inode或管道的封装,加上一个I/O偏移量。每次调用
open
都会创建一个新的打开文件(一个新的struct file
):如果多个进程独立地打开同一个文件,那么不同的实例将具有不同的I/O偏移量。另一方面,单个打开的文件(同一个
struct file
)可以多次出现在一个进程的文件表中,也可以出现在多个进程的文件表中。如果一个进程使用open
打开文件,然后使用dup
创建别名,或使用fork
与子进程共享,就会发生这种情况。
1 | struct file { |
ftable
所有在系统中打开的文件都会被放入global file table
ftable
中。
ftable
具有分配文件(filealloc
)、创建重复引用(filedup
)、释放引用(fileclose
)以及读取和写入数据(fileread
和filewrite
)的函数。前三个都很常规,跟之前的xxalloc、xxfree的思路是一样的。
函数
filestat
、fileread
和filewrite
实现对文件的stat
、read
和write
操作。
filealloc
1 | // Allocate a file structure. |
filedup
1 | // Increment ref count for file f. |
fileclose
1 | // Close file f. (Decrement ref count, close when reaches 0.) |
filestat
Filestat只允许在inode上操作并且调用了
stati
1 | // Get metadata about file f. |
fileread
1 | // Read from file f. |
Code: System calls
通过使用底层提供的函数,大多数系统调用的实现都很简单(请参阅***kernel/sysfile.c***)。有几个调用值得仔细看看。
以下介绍的函数都在
kernel/sysfile.c
中。
sys_link
这个函数的功能是给文件old加上一个链接,这个链接存在于文件new的父目录。感觉也就相当于把文件从old复制到new处了。具体实现逻辑就是要给该文件所在目录添加一个entry,name=新名字,inode=该文件的inode。
1 | // Create the path new as a link to the same inode as old. |
create
它是三个文件创建系统调用的泛化:带有
O_CREATE
标志的open
生成一个新的普通文件,mkdir
生成一个新目录,mkdev
生成一个新的设备文件。
创建一个新的inode结点,结点名包含在path
内。返回一个锁定的inode。
由于使用了iupdate
等,所以该函数只能在事务中被调用。
1 | static struct inode* |
sys_mkdir
1 | uint64 |
sys_open
Sys_open
是最复杂的,因为创建一个新文件只是它能做的一小部分。
1 | uint64 |
sys_pipe
1 | uint64 |
Real world
实际操作系统中的buffer cache比xv6复杂得多,但它有两个相同的用途:缓存和同步对磁盘的访问。
与UNIX V6一样,Xv6的buffer cache使用简单的最近最少使用(LRU)替换策略;有许多更复杂的策略可以实现,每种策略都适用于某些工作场景,而不适用于其他工作场景。更高效的LRU缓存将消除链表,而改为使用哈希表进行查找,并使用堆进行LRU替换【跟我们在lock中实现的一样,再多个堆优化】。现代buffer cache通常与虚拟内存系统集成,以支持内存映射文件。
Xv6的日志系统效率低下。提交不能与文件系统调用同时发生。系统记录整个块,即使一个块中只有几个字节被更改。它执行同步日志写入,每次写入一个块,每个块可能需要整个磁盘旋转时间。真正的日志系统解决了所有这些问题。
文件系统布局中最低效的部分是目录,它要求在每次查找期间对所有磁盘块进行线性扫描【确实】。当目录只有几个磁盘块时,这是合理的,但对于包含许多文件的目录来说,开销巨大。Microsoft Windows的NTFS、Mac OS X的HFS和Solaris的ZFS(仅举几例)将目录实现为磁盘上块的平衡树。这很复杂,但可以保证目录查找在对数时间内完成(即时间复杂度为O(logn))。
Xv6对于磁盘故障的解决很初级:如果磁盘操作失败,Xv6就会调用
panic
。这是否合理取决于硬件:如果操作系统位于使用冗余屏蔽磁盘故障的特殊硬件之上,那么操作系统可能很少看到故障,因此panic
是可以的。另一方面,使用普通磁盘的操作系统应该预料到会出现故障,并能更优雅地处理它们,这样一个文件中的块丢失不会影响文件系统其余部分的使用。Xv6要求文件系统安装在单个磁盘设备上,且大小不变。随着大型数据库和多媒体文件对存储的要求越来越高,操作系统正在开发各种方法来消除“每个文件系统一个磁盘”的瓶颈。基本方法是将多个物理磁盘组合成一个逻辑磁盘。RAID等硬件解决方案仍然是最流行的,但当前的趋势是在软件中尽可能多地实现这种逻辑。这些软件实现通常允许通过动态添加或删除磁盘来扩展或缩小逻辑设备等丰富功能。当然,一个能够动态增长或收缩的存储层需要一个能够做到这一点的文件系统:xv6使用的固定大小的inode块阵列在这样的环境中无法正常工作。将磁盘管理与文件系统分离可能是最干净的设计,但两者之间复杂的接口导致了一些系统(如Sun的ZFS)将它们结合起来。
Xv6的文件系统缺少现代文件系统的许多其他功能;例如,它缺乏对快照和增量备份的支持。
现代Unix系统允许使用与磁盘存储相同的系统调用访问多种资源:命名管道、网络连接、远程访问的网络文件系统以及监视和控制接口,如
/proc
。不同于xv6中fileread
和filewrite
的if
语句,这些系统通常为每个打开的文件提供一个函数指针表【确实有印象】,每个操作一个,并通过函数指针来援引inode的调用实现。网络文件系统和用户级文件系统提供了将这些调用转换为网络RPC并在返回之前等待响应的函数。(注:Linux 内核提供了一种通过
/proc
文件系统,在运行时访问内核内部数据结构、改变内核设置的机制。proc文件系统是一个伪文件系统,它只存在内存当中,而不占用外存空间。它以文件系统的方式为访问系统内核数据的操作提供接口。)
Lab: file system
In this lab you will add large files【大文件支持】 and symbolic links【软链接】 to the xv6 file system.
不过做完这个实验,给我的一种感觉就是磁盘管理和内存管理真的有很多相似之处,不过也许它们所代表的思想也很普遍。
Large files
实验内容
Overview
In this assignment you’ll increase the maximum size of an xv6 file.
Currently xv6 files are limited to 268 blocks, or 268*BSIZE bytes (BSIZE is 1024 in xv6). This limit comes from the fact that an xv6 inode contains 12 “direct” block numbers and one “singly-indirect” block number, which refers to a block that holds up to 256 more block numbers, for a total of 12+256=268 blocks.
You’ll change the xv6 file system code to support a “doubly-indirect” block in each inode, containing 256 addresses of singly-indirect blocks, each of which can contain up to 256 addresses of data blocks. The result will be that a file will be able to consist of up to 65803 blocks, or 256*256+256+11 blocks (11 instead of 12, because we will sacrifice one of the direct block numbers for the double-indirect block).
Preliminaries
If at any point during the lab you find yourself having to rebuild the file system from scratch, you can run
make clean
which forces make to rebuild fs.img.
What to Look At
意思就是要我们去看一眼fs.h,bmap,以及了解一下逻辑地址bn如何转化为blockno。这个我是知道的。
Your Job
Modify
bmap()
so that it implements a doubly-indirect block, in addition to direct blocks and a singly-indirect block.You’ll have to have only 11 direct blocks, rather than 12, to make room for your new doubly-indirect block; you’re not allowed to change the size of an on-disk inode.
The first 11 elements of
ip->addrs[]
should be direct blocks; the 12th should be a singly-indirect block (just like the current one); the 13th should be your new doubly-indirect block. You are done with this exercise whenbigfile
writes 65803 blocks andusertests
runs successfully.
感想
意外地很简单()在此不多做赘述,直接上代码。
唯一要注意的一点就是记得在itrunc
中free掉
代码
修改定义
1 | // in fs.h |
1 | // in file.h |
修改bmap()
1 | // in fs.c |
修改itrunc
1 | // Truncate inode (discard contents). |
Symbolic links
In this exercise you will add symbolic links to xv6.
Symbolic links (or soft links) refer to a linked file by pathname; when a symbolic link is opened, the kernel follows the link to the referred file.
Symbolic links resembles hard links, but hard links are restricted to pointing to file on the same disk, while symbolic links can cross disk devices.
Although xv6 doesn’t support multiple devices, implementing this system call is a good exercise to understand how pathname lookup works.
You will implement the
symlink(char *target, char *path)
system call, which creates a new symbolic link at path that refers to file named by target. For further information, see the man page symlink. To test, add symlinktest to the Makefile and run it.
硬链接不会创建新的物理文件,但是会使得当前物理文件的引用数加1。当硬链接产生的文件存在时,删除源文件,不会清除实际的物理文件,即对于硬链接“生成的新文件”不会产生任何影响。
软链接就更像一个指针,只是指向实际物理文件位置,当源文件移动或者删除时,软链接就会失效。
【所以说,意思就是软链接不会让inode->ulinks++的意思?】
感想
这个实验比上个实验稍难一些,但也确实只是moderate的水平,其复杂程度主要来源于对文件系统的理解,还有如何判断环,以及对锁的获取和释放的应用。我做这个实验居然是没看提示的【非常骄傲<-】,让我有一种自己水平上升了的感觉hhh
正确思路
本实验要求实现软链接。首先需要实现创建软链接:写一个系统调用 symlink(char *target, char *path)
用于创建一个指向target的在path的软链接;然后需要实现打开软链接进行自动的跳转:在sys_open
中添加对文件类型为软链接的特殊处理。
初见思路
我的初见思路是觉得可以完全参照sys_link
来写。但其实还是很不一样的。
sys_link
的逻辑:
- 获取old的inode
- 获取new所在目录的inode,称为dp
- 在dp中添加一项entry指向old
sys_symlink
的逻辑:
通过path创建一个新的inode,作为软链接的文件
这里选择新建inode,而不是像link那样做,主要还是为了能遵从
symlinktest
给的接口使用方法(朴实无华的理由)。而且这么做也很方便,符合“一切皆文件”的思想,也能简单化对其在open
中的处理。在inode中填入target的地址
我们可以把软链接视为文件,文件内容是其target的path。
可以说是毫不相干,所以还是直接自起炉灶比较好。
一些错误
其实没什么好说的,虽然debug过程挺久,但是靠常规的printf追踪就都可以看出来是哪里错了。下面我说说一个我印象比较深刻的吧。
symlinktest
中有一个检测点是,软链接不能成环,也即b->a->b是非法的。于是,我就选择了用快慢指针来检测环形链表这个思想,用来看是否出现环。
在symlinktest
的另一个检测点中:
我出现了如下错误:
此时的结构是1[27]->2[28]->3[29]->4,[]内为inode的inum。
快慢指针的实现方式是当cnt为奇数的时候,慢指针才会移动。而上图中,cnt==0时,两个指针的值都发生了变化,这就非常诡异。
这其实是因为slow指针所指向的那个inode被释放了,然后又被fast指针的下一个inode捡过来用了,从而导致值覆盖。
为什么会被释放呢?
1 | // 快指针移动 |
在这里,我错误地调用了ilockput
,从而使inode的ref–,使得它在下一次fast指针调用namei
,namei
调用iget
时,该inode被当做free inode使用,于是就这么寄了。
所以我们需要把ilockput
的调用换成ilock
,这样一来就能防止inode被free。至于什么时候再iput?我想还是交给操作系统启动时的清理工作来做吧23333【开摆】
代码
添加定义
fcntl.c
open参数
1 | // 意为只用获取软链接文件本身,而不用顺着软链接去找它的target文件 |
stat.h
文件类型
1 |
添加sys_symlink系统调用
1 | // in sysfile.c |
修改open
1 | uint64 |
Lab mmap
The
mmap
andmunmap
system calls allow UNIX programs to exert detailed control over their address spaces.They can be used to:
- share memory among processes
- map files into process address spaces
- as part of user-level page fault schemes such as the garbage-collection algorithms discussed in lecture.
In this lab you’ll add
mmap
andmunmap
to xv6, focusing on memory-mapped files.mmap是系统调用,在用户态被使用。我们这次实验仅实现mmap功能的子集,也即memory-mapped files。
declaration for
mmap
:
1
2 void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
参数
addr
is always zero.You can assume that
addr
will always be zero, meaning that the kernel should decide the virtual address at which to map the file.【addr
由kernel决定,因而用户态只需传入0即可】
length
is the number of bytes to mapMight not be the same as the file’s length.
prot
indicates whether the memory should be mapped readable, writeable, and/or executable.you can assume that
prot
isPROT_READ
orPROT_WRITE
or both.
flags
has two values.
MAP_SHARED
meaning that modifications to the mapped memory should be written back to the file,
如果标记为此,则当且仅当file本身权限为RW或者WRITABLE的时候,prot才可以标记为PROT_WRITE
MAP_PRIVATE
meaning that they should not.
如果标记为此,则无论file本身权限如何,prot都可以标记为PROT_WRITE
You can assume
offset
is zero (it’s the starting point in the file at which to map)return
mmap
returns that kernel-decided address, or 0xffffffffffffffff if it fails.如果两个进程同时对某个文件进行memory map,那么这两个进程可以不共享物理页面。
munmap(addr, length)
should remove mmap mappings in the indicated address range.If the process has modified the memory and has it mapped
MAP_SHARED
, the modifications should first be written to the file. 【如果两个进程的修改发生冲突了怎么办?】An
munmap
call might cover only a portion of an mmap-ed region, but you can assume that it will either unmap at the start, or at the end, or the whole region (but not punch a hole in the middle of a region).
感想
这个实验做得我……怎么说,感觉非常地难受吧。虽然我认为我这次做得挺不错的,因为我没有怎么看hints,我的代码差不多全都是我自己想出来的,没有依赖保姆级教学,我认为是一个很好的进步。不过,正因为我没有看hints,导致我的想法比起答案来思路非常地奇诡,导致我第一次错误想法写了一天,看了hints后决心痛改前非,结果第二次错误想法又写了一天emmm
下面的第一个代码版本虽然可以过掉mmaptest,但确实还是有一个很致命的bug,并且lazy也没有lazy到位,最后的版本离正确思路还有偏差,也就是下面放的第一个代码版本是错误的,但我认为它也不是完全没有亮点。第二个版本才是经过改正的正确版本,但写得着实有点潦草。
笔记整理得也有点匆忙,毕竟我真的话比较多而且心里很烦。总之,先记录我的全部思路过程,至于价值如何,先不管了2333
初见思路
所以说,我们要做的,就是实现一个系统调用mmap,在mmap中,应该首先申请几页用来放file的内容,并且在页表中填入该项,然后再返回该项的虚拟地址。然后在munmap中,再将该file页内容写入file。
也就是说,直接在mmap把文件的全部内容写入内存,然后各进程读写自己的那块内容块,最后在munmap的时候把修改内容写入文件然后释放该内存块就行了
问题:在哪里放置file的内容
题目要求the kernel should decide the **virtual address** at which to map the file.
也就是说,在我们的mmap
中,需要决定我们要讲文件内容放在哪里。那要放在哪呢……
我第一反应很奇葩:扫描页表,找到空闲页。但我自己也知道这样不可行,文件内容不止一页,这种零零散散存储需要的数据结构实现起来太麻烦了。
那怎么办?可以在heap内分配。那么到底怎么样才能在heap里分配?你该怎么知道heap哪里开始是空闲的,哪里是用过的,不还是得扫描页表吗?【思维大僵化】
其实……道理很简单。我们之间把proc->sz
作为mapped-file的起始地址就好了。相信看到这里,你也明白这是什么原理了。能想到这个,我感觉确实很不容易。
正确思路
初见思路虽然简单,但是很粗暴,如果文件很大,宝贵的内存空间就会被我们浪费。所以我们借用lazy allocation的思想,先建立memory-file的映射,再在缺页中断中通过文件读写申请内存空间,把文件内容读入内存。
问题就在于如何“先建立memory-file的映射”。在lazy allocation中,我们是先填好所有的对应页表项,仅是不申请对应的物理内存,也即占着XX不XX。在这次实验中,我们也是这么做,只不过新增了一个难点,那就是如何管理这些页。因为lazy allocation页与页之间没有比较紧密的关系,但是在mmap中页却可以被所属文件这个关键字划分。因而,我们需要一个数据结构,来给页分门别类地组织在一起,并且记录它们的meta data比如说所属文件之类的,这也就是hints里的VMA结构,也即我的filemap结构。
我们可以将这样的数据结构池化,并且存储在proc域中,以避免对象的重复创建。
我的lazy法与别人不大一样……我没有想得像他们那么完美。我的做法是,在需要读某个地址的文件内容时,直接确保这个地址前面的所有文件内容都读了进来。也即在filemap中维护一个okva,表明va
okva这段内存已经读入,之后就仅需再读入okvaneed_va这段地址就行。这样虽然lazy了,但没完全lazy。我认为这不能体现lazy的思想……因为一读读一坨,还是很占空间啊。
因而,我们需要做的就是:
在mmap中将信息填入该数据结构
- 依据传入的长度扩容proc,原sz作为mapped-file起始地址va
- 从对象池中寻找到一个空闲的filemap,对其填写信息
- 返回1所得的va
在我的代码中,还针对proc->sz不满足page-align做出了对策:先把文件的
PGROUNDUP(sz)-sz
这部分的信息读入,并且更新okva,这样一来,之后在usertrap中,就可以从okva开始一页页地分配地址,做到自然地page-align了。为什么要对不满足page-align的情况进行处理?
这是因为,growproc的时候一次性扩充一页,但proc->sz却可以不满足page-align,也就是说,proc->sz所处的这一页已经被分配了。
在我们的lazy思路中,我们如果不预先读入文件页,是只能等待用户陷入缺页中断的情况下才能读入文件内容。
但是,proc->sz这一页已经被分配了。因而,在用户态读取这一页地址的时候,并不会发生缺页中断。因而,就会发生文件内容未读入,用户读到脏数据的情况。
其实还有一种更简单的办法可以强制page-align,那就是,直接让起始地址为
PGROUNDUP(proc->sz)
……至于为什么我不用这个,而要写这么多麻烦的东西呢?答案是我没想到。()在usertrap增加对缺页中断的处理
- 依据va找到对应filemap
- 根据对应filemap的信息,使用
readi
(正确)fileread
(错误)读取文件内容并存入物理内存
在munmap中进行释放
- 根据标记写入文件页,并且释放对应物理内存
- 修改filemap结构的参数,并且在其失效的时候放回对象池
修改fork和exit
exit
手动释放map-file域
为什么不能把这些合并到
wait
中调用的freepagetable
进行释放呢?因为
freepagetable
只会释放对应的物理页,没有达到munmap
减少文件引用等功能。fork
手动复制filemap池
我的错误思路们
第一次错误思路
上面说到:
问题就在于如何“先建立memory-file的映射”。在lazy allocation中,我们是先填好所有的对应页表项,仅是不申请对应的物理内存,也即占着XX不XX。在这次实验中,我们也是这么做,只不过新增了一个难点,那就是如何管理这些页。因为lazy allocation页与页之间没有比较紧密的关系,但是在mmap中页却可以被所属文件这个关键字划分。因而,我们需要一个数据结构,来给页分门别类地组织在一起,并且记录它们的meta data比如说所属文件之类的,这也就是hints里的VMA结构,也即我的filemap结构。
官方给出的答案是在proc域里的pool。我……额……是把这些信息,存入在页中(真是自找麻烦呀)
具体来说,就是,我在mmap
的时候给每个文件申请一页,然后在页的开头填上和filemap结构相差无几的那些参数,再加上一个next指针,表示下一个文件页的地址。页的剩下部分就用来存储数据。总的就是一个链表结构。
这个思路其实很不错,比起上面的直接在proc内存的尾巴扩容,这个空间利用率应该更大,并且不仅能节省物理内存,还能节省虚拟地址空间,实现了lazy上加lazy。
但问题是……我为什么非要傻瓜式操纵内存,在页的开头填入参数数据,而不是把这种页抽象为一个个node,最终形成一个十字链表的形式(差不多的意思,鱼骨状),组织进proc域,这样不挺好的吗……唔,有时候我头脑昏迷程度让我自己都感到十分震惊。归根结底,还是想得太少就动手了,失策。
总之放上代码。我没有实现next指针,仅假设文件内容不超过一页。也就是这一页开头在mmap中填meta data,其余部分在usertrap中填入文件内容。【这个分开的点也让我迷惑至极……】
1 |
|
1 | } else if(r_scause() == 13 || r_scause() == 15){ |
为什么下面的代码是错的
正如开头所说的那样,我并没有完美做好这次实验,下面代码有一个致命的bug。
先说说致命bug是什么。
我的filemap结构体其实隐藏了两个具有“offset”这一含义的状态。一个是filemap里面的成员变量offset,另一个是filemap里面的成员变量file的成员变量off:
1 | // in proc.h |
在我的代码里,它们被赋予了不同的含义。
filemap->file->off
被用于trap.c
中,表示的是当前未读入文件内容的起始位置(实际上也就是okva-va
的值),用于自然地使用fileread
进行文件读入。
比如说,这次读入PGSIZE,那么off就会在
fileread
中自增PGSIZE。下次调用fileread
就可以直接从下一个位置读入了,这样使代码更加简洁
filemap->offset
被用于munmap
中。filewrite
同fileread
一样,都是从file->off
处开始取数据。munmap
所需要取数据的起始位置和trap.c
中需要取数据的起始位置肯定不一样,
想想它们的功能。
trap.c
的off需要始终指向有效内存段的末尾,但munmap
由于要对特定内存段进行写入文件操作,因而off要求可以随机指向。
因而,我们可以将当前va对应的文件位置记录在offset中。届时,我们只需要从p->filemaps[i].offset+va-p->filemaps[i].va
取数据就行。
上述两个变量相辅相成,看上去似乎能够完美无缺地实现我们的功能。但是,实际上,不行。为什么呢?因为它们的file指针,filemap->file
,如果被两个mmap区域同时使用的话,就会出问题。
可以来看看mmaptest.c
中的这一段代码:
1 | makefile(f); |
1 | // in trap.c |
这段代码因为共用fd,导致file指针被两个mmap区域同时使用。
共用fd,为什么file指针也一起共用了?
可以追踪一下它们的生命周期:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 // in sys_open()
// 获取file结构体和文件描述符。
if((f = filealloc()) == 0 || (fd = fdalloc(f)) < 0){
// in sysfile.c
// Allocate a file descriptor for the given file.
// Takes over file reference from caller on success.
static int
fdalloc(struct file *f)
{
int fd;
struct proc *p = myproc();
for(fd = 0; fd < NOFILE; fd++){
if(p->ofile[fd] == 0){
p->ofile[fd] = f;
return fd;
}
}
return -1;
}可以看到,它实际上是有一个文件描述符表,key为fd,value为file指针。因而,同一张表,fd相同,file指针相同。
注:父子进程,同样的fd,file指针也是相同的
fork出来的父子进程同一个句柄对同一个文件的偏移量是相同的,这个原理应该是因为,父子进程共享的是文件句柄这个结构体对象本身,也就是拷贝的时候是浅拷贝而不是深拷贝。
1
2
3
4
5 // in fork()
// increment reference counts on open file descriptors.
for(i = 0; i < NOFILE; i++)
if(p->ofile[i])
np->ofile[i] = filedup(p->ofile[i]);
最后的check that the parent's mappings are still there.
环节中,_v1(p1)
执行时并没有陷入trap,这是正常的。不正常的是_v1(p2)
的执行结果。它陷入了trap,但是却因file->off == file size
,导致被判定为已全部读入文件,事实上却是并没有读入文件。
为什么会这样呢?
这是因为p1和p2共用同一个fd,也就共用了同一个file指针。共用了一个file指针,那么p1和p2面对的file->off
相同。上面说到,file->off
用于控制文件映射。那么,当p1完成了对文件的映射,p1的off指针如果不加重置,就会永远停留在file size处。这样一来,当p2想要使用同样的file指针进行文件映射时,就会出问题。
这个问题的一个解决方法是每次mmap
都深拷贝一个船新file结构体。但是这样的话,file
域里的ref
变量就失去了它的意义,并且file对象池应该也很快就会爆满,非常不符合设计方案。
这个问题的完美解,是不要赋予file->off
这个意义,而是使用readi
替代fileread
。
1 | fileread(struct file *f, uint64 addr, int n) |
这样做的好处是,我们可以实时计算offset(前面提到,其恰恰等于okva-va),而不用把这个东西用file的off来表示。
也确实,我之所以弯弯绕绕那么曲折,是因为只想到了
fileread
这个函数,压根没注意到还有一个readi
……
我在下面的代码仅做了一个能够通过测试,但是上面的bug依然存在的功利性折中代码。我是这么实现的:
1 | // 在`mmap`的时候初始化`file->off` |
因而,结论是,一步错步步错,一个错误需要更多的错误来弥补,最后还是错的(悲)
如何把下面的错误思路改成正确思路
可以做以下几点:
正确地lazy
每次trap仅分配一页。
改用readi函数,修改
file->off
的语义
这样一来,大概就可以完美地正确了。
其他的一些小细节
file指针的生命周期
在数据结构中存储file指针至关重要。但仔细想一想,file指针的生命周期似乎长到过分:从sys_mmap被调用,一直到usertrap处理缺页中断,最后到munmap释放,我们要求file指针的值需要保持稳定不变。
这么长的生命周期,它真的可以做到吗?毕竟file指针归根到底只是一个局部变量,在syscall mmap结束之后,它还有效吗?答案是有效的,这个有效性由mmap
实现中对ref的增加来实现保障。
在用户态中关闭一个文件,需要使用syscallclose(int fd)
。不妨来看看close
的代码。
1 | // in kernel/sysfile.c |
可以看到,当ref数>1时,file指针就不会失效。
这就是为什么我们还需要在mmap中让file的ref数++。
缺页中断蕴含的设计思想
如果只存入file指针,用户态要如何对对应的文件进行读写呢?
我们可以自然想到也许需要设计一个函数,让用户在想要对这块内存读写的时候调用这个函数即可。但是,这样的方法使得用户对内存不能自然地读写,还需要使用我们新设计的这个函数,这显然十分地不美观。所以,我们需要找到一个方法,让上层的用户可以统一地读取任何的内存块,包括memory-mapped file内存块,而隐藏memory-mapped file与其他内存块读写方式不同的这些复杂细节。经历过前面几次实验的你看到这里一定能想到,有一个更加优美更加符合设计规范的方法,那就是:缺页中断!
没做这个实验之前就知道mmap需要借助缺页中断来实现了,但实际自己的第一印象是觉得并不需要缺页中断,直到分析到这里才恍然大悟。
“让上层的用户可以统一地读取任何的内存块,而隐藏不同类型的内存块读写方式不同的这些复杂细节”
仔细想想,前面几个关于缺页中断的实验,比如说cow fork,lazy allocation,事实上都是基于这个思想。它们并不是不能与缺页中断分离,只是有了缺页中断,它们的实现更加简洁,更加优美。
再次感慨os的博大精深。小小一个缺页中断,原理那么简单,居然集中了这么多设计思想,不禁叹服。
正确答案的munmap中如果遇到未映射的页怎么办
在正确答案的munmap中:
1 | //释放已经申请的页表项、内存,并且看看是不是需要写回 |
如果map类型为MAP_SHARED
,并且该页尚未映射,会怎么样呢?
追踪filewrite的路径
1 | // in file.c |
copyin
最终会在 if(pa0 == 0) return -1;
这里终结,但writei
并不会在接收到-1的时候爆出panic或者是引发缺页中断,而只会把它当做文件结尾,默默地返回。
并且,在munmap
中是一页一页地释放,而不是直接传参length全部释放,这一点也很重要。因为我们的lazy allocation很可能导致va~va+length
这一区间内只是部分页被映射,部分页没有。如果直接传参length释放,那么在遇到第一页未被映射的时候,filewrite
就会终止,该页之后的页就没有被写回文件的机会了。
所以结论是,在正确实现的munmap
中遇到未映射的页会自动跳过,什么也不会发生。
代码
数据结构
1 | // in param.h |
mmap
具体系统调用注册过程略。
1 | // in sysproc.c |
1 |
|
usertrap
错的
1 | } else if(r_scause() == 13 || r_scause() == 15){ |
对的
1 | } else if(r_scause() == 13 || r_scause() == 15){ |
munmap
错的
1 | uint64 min(uint64 a,uint64 b){return a>b?b:a;} |
对的
1 | uint64 min(uint64 a,uint64 b){return a>b?b:a;} |
exit和fork
exit
1 | // 关闭map-file |
fork
1 | for(int i=0;i<NFILEMAP;i++){ |
修改uvmcopy和uvmunmap
1 | // in uvmunmap() |