目录
一、知识点
1. ucore文件系统设计目标--设计理念和含义
1.1. 通用文件系统访问接口层:
1.2. 文件系统抽象层:
1.3. Simple FS 文件系统实现层:
1.4. 外设接口层:
2. ucore文件系统架构总体设计
2.1. ucore文件系统设计架构
2.1.1. 通用文件系统访问接口层
2.1.2. 文件系统抽象层
2.1.3. Simple FS文件系统实现层
2.1.4. 文件系统I/O设备接口层
2.2. ucore文件系统数据结构设计
2.2.1. 在通用文件系统访问接口层
2.2.2. 文件系统抽象层
2.2.3. Simple文件系统实现层
3. ucore文件系统详细实现
3.1. 通用文件系统访问接口
3.1.1. 文件和目录相关用户库函数
3.1.2. 文件和目录访问相关系统调用
3.2. 文件系统抽象层 - VFS
3.2.1. file & dir接口
3.2.2. inode 接口
3.3. 文件系统实现层 - SFS
3.3.1. 文件系统的布局
3.3.2. 索引节点
内存中的索引节点
Inode的文件操作函数
Inode的目录操作函数
3.4. UCore文件系统--初始化过程
3.5. UCore文件系统--打开文件
3.5.1. 文件系统抽象层的处理流程
3.5.2. SFS文件系统层的处理流程
4. 设备层文件 IO 层
4.1. 关键数据结构
4.2. stdout设备文件
4.3. stdin 设备文件
二、练习解答
2.1. 练习1: 完成读文件操作的实现(需要编码)
2.1.1. 关于sfs_io_nolock函数的解读
2.1.1.1. 具体的目标是什么?
2.1.1.2. 实现这个目标的主要途径是什么?
2.1.1.3. 读取第一块没有对齐的数据到buffer中
2.1.1.4. 读取中间对齐的数据到buffer中
2.1.1.5. 读取最后一块没有对齐的数据到buffer中
2.2. 练习2: 完成基于文件系统的执行程序机制的实现(需要编码)
2.2.1. 关于加载文件系统中文件的解读
2.2.1.1. 对于load_icode()具体目标是什么?
2.2.1.2. 目标1:实验7的elf数据加载到进程的虚拟内存地址空间?
2.2.1.3. 目标2:实验7怎么执行进程虚拟地址空间的代码段(由原先的ELF文件拷贝过来)?
2.2.1.4. 目标3:实验8如何建立进程需要的用户空间栈?
感悟:
本实验涉及的知识面广,需要耐性的阅读文字和代码,最重要的是结合实际情况,进行代码调试。
一、知识点
1. ucore文件系统设计目标--设计理念和含义
操作系统中负责管理和存储长期数据的软件模块叫做文件系统。ucore的文件系统模型源于Havard的OS161的文件系统和Linux文件系统。其实都来源于unix设计,他的设计理念是:文件file、目录项dentry、索引节点inode、安装点mount point。
文件:Unix文件系统中的内容可以理解成一有序字节buffer,文件都有一个方便应用程序识别的文件名称(也称文件路径名)。典型的文件操作有读、写、创建和删除等。
目录项:目录项不是目录,而是目录的组成部分。在Unix中目录被看做是一种特定的文件,而目录项是文件路径的一部分。如一个文件路径名是/test/testfile,它的目录项是:根目录/、目录test/和文件testfile。一般而言目录项包含目录项的名字(文件名或者目录名),目录项的索引节点位置。
索引节点:Unix将文件的相关源数据信息(如访问权限控制、大小、拥有者、创建时间、数据内容等信息)存储在一个单独的数据结构中,该结构称为索引节点。
安装点:在Unix中,文件系统被安装在一个特定的文件路径位置,这个位置就是安装点。所有的已安装文件系统都作为根文件系统树中的叶子节点出现在系统中。
以上的四个重要的抽象概念形成了Unix文件系统的逻辑数据结构,用户看到了文件和目录项层次,而索引节点和安装点是软件实现层次。为了实现以上的目标,需要一个具体的文件系统架构,把上述的信息映射并存储到磁盘的介质上(即数据在磁盘的物理组织)。比如文件元数据信息存储的磁盘块中的索引节点上,当文件被载入内存时,内核需要使用磁盘块中的索引节点构造内存中的索引节点。
ucore模拟Unix的文件系统设计,它的系统架构由四部分组成:
1.1. 通用文件系统访问接口层:
该层提供给用户空间一个文件系统标准访问接口。用户通过该接口能够访问ucore内核的文件系统服务。
1.2. 文件系统抽象层:
向上提供一个一致的接口访问(文件系统相关的系统调用实现模块和其他的内核功能模块)。向下要求提供一个一样的抽象函数指针列表和数据结构,这样屏蔽不同的文件系统实现细节。
1.3. Simple FS 文件系统实现层:
向上实现抽象函数指针。在它的实现层,有一个基于索引方式的简单文件系统实例。向下要求提供一个外设的访问接口。
1.4. 外设接口层:
向上提供device访问接口屏蔽不同的硬件访问细节。向下要求访问各种具体的设备驱动接口,比如disk设备接口、串口设备接口、键盘设备接口等。
2. ucore文件系统架构总体设计
在上一节中提出了文件系统的设计目标,在本届中将阐述为了实现这个目标,整体的流程是什么样子?以及关键的数据结构有哪些?
2.1. ucore文件系统设计架构
假如应用程序操作文件(打开/创建/删除/读写),首先需要通过文件系统的通用文件系统 访问接口层给用户空间提供的访问接口进入文件系统内部,接着由文件系统抽象层把访问请求转发给某一具体文件系统(比如SFS文件系统),具体文件系统(Simple FS文件系统层)把应用程序的访问请求转化为对磁盘上的block的处理请求,并通过外设接口层交给磁盘驱动例程来完成具体的磁盘操作。结合用户态写文件函数write的整个执行过程,我们可以比较清楚地看出ucore文件系统架构的层次和依赖关系。
2.1.1. 通用文件系统访问接口层
图2-1 通用文件系统访问接口与VFS的关系
图2-2 从用户角度看通用文件系统访问接口层
2.1.2. 文件系统抽象层
图2-3 VFS和 Simple FS文件系统实现的关系
图2-4 从CPU中断角度看文件系统抽象层对外的接口
2.1.3. Simple FS文件系统实现层
图2-5 Simple FS文件系统实现和文件系统I/O设备接口的关系
图2-6 Simple FS 基于索引完成抽象数据块的访问
2.1.4. 文件系统I/O设备接口层
图2-7 文件系统I/O设备接口和硬盘驱动的关系
图2-8 基于抽象数据块完成磁盘块的访问
2.2. ucore文件系统数据结构设计
2.2.1. 在通用文件系统访问接口层
主要的数据有两个,一个是函数调用栈传递变参参数,最终放在va_list的ap中。另外一个是系统调用中断传递参数。
static inline int
syscall(int num, ...) {
va_list ap;
va_start(ap, num);
uint32_t a[MAX_ARGS];
int i, ret;
for (i = 0; i < MAX_ARGS; i ++) {
a[i] = va_arg(ap, uint32_t);
}
va_end(ap);
asm volatile (
"int %1;"
: "=a" (ret)
: "i" (T_SYSCALL),
"a" (num),
"d" (a[0]),
"c" (a[1]),
"b" (a[2]),
"D" (a[3]),
"S" (a[4])
: "cc", "memory");
return ret;
}
2.2.2. 文件系统抽象层
关键数据结构是
struct file *file;
struct file {
enum {
FD_NONE, FD_INIT, FD_OPENED, FD_CLOSED,
} status;
bool readable;
bool writable;
int fd;
off_t pos;
struct inode *node;
int open_count;
};
其主要作用是将抽象层的通用属性(fd,pos等)最后落实到具体的文件系统(node)上实现。
2.2.3. Simple文件系统实现层
该层将文件和目录存储到磁盘上,基本的实现思路是通过struct sfs_inode *din数据结构完成的。
图2-9 文件系统的主要结构体解读
3. ucore文件系统详细实现
3.1. 通用文件系统访问接口
3.1.1. 文件和目录相关用户库函数
Lab8中部分用户库函数与文件系统有关,我们先讨论对单个文件进行操作的系统调用,然后讨论对目录和文件系统进行操作的系统调用。
在文件操作方面,最基本的相关函数是open、close、read、write。在读写一个文件之前,首先要用open系统调用将其打开。open的第一个参数指定文件的路径名,可使用绝对路径名;第二个参数指定打开的方式,可设置为O_RDONLY、O_WRONLY、O_RDWR,分别表示只读、只写、可读可写。在打开一个文件后,就可以使用它返回的文件描述符fd对文件进行相关操作。在使用完一个文件后,还要用close系统调用把它关闭,其参数就是文件描述符fd。这样它的文件描述符就可以空出来,给别的文件使用。
读写文件内容的系统调用是read和write。read系统调用有三个参数:一个指定所操作的文件描述符,一个指定读取数据的存放地址,最后一个指定读多少个字节。在C程序中调用该系统调用的方法如下:
count = read(filehandle, buffer, nbytes);
该系统调用会把实际读到的字节数返回给count变量。在正常情形下这个值与nbytes相等,但有时可能会小一些。例如,在读文件时碰上了文件结束符,从而提前结束此次读操作。
如果由于参数无效或磁盘访问错误等原因,使得此次系统调用无法完成,则count被置为-1。而write函数的参数与之完全相同。
对于目录而言,最常用的操作是跳转到某个目录,这里对应的用户库函数是chdir。然后就需要读目录的内容了,即列出目录中的文件或目录名,这在处理上与读文件类似,即需要通过opendir函数打开目录,通过readdir来获取目录中的文件信息,读完后还需通过closedir函数来关闭目录。由于在ucore中把目录看成是一个特殊的文件,所以opendir和closedir实际上就是调用与文件相关的open和close函数。只有readdir需要调用获取目录内容的特殊系统调用sys_getdirentry。而且这里没有写目录这一操作。在目录中增加内容其实就是在此目录中创建文件,需要用到创建文件的函数。
3.1.2. 文件和目录访问相关系统调用
与文件相关的open、close、read、write用户库函数对应的是sys_open、sys_close、sys_read、sys_write四个系统调用接口。与目录相关的readdir用户库函数对应的是sys_getdirentry系统调用。这些系统调用函数接口将通过syscall函数来获得ucore的内核服务。当到了ucore内核后,在调用文件系统抽象层的file接口和dir接口。
3.2. 文件系统抽象层 - VFS
文件系统抽象层是把不同文件系统的对外共性接口提取出来,形成一个函数指针数组,这样,通用文件系统访问接口层只需访问文件系统抽象层,而不需关心具体文件系统的实现细节和接口。
3.2.1. file & dir接口
file&dir接口层定义了进程在内核中直接访问的文件相关信息,这定义在file数据结构中,具体描述如下:
struct file {
enum {
FD_NONE, FD_INIT, FD_OPENED, FD_CLOSED,
} status; //访问文件的执行状态
bool readable; //文件是否可读
bool writable; //文件是否可写
int fd; //文件在filemap中的索引值
off_t pos; //访问文件的当前位置
struct inode *node; //该文件对应的内存inode指针
int open_count; //打开此文件的次数
};
而在kern/process/proc.h中的proc_struct结构中描述了进程访问文件的数据接口files_struct,其数据结构定义如下:
struct files_struct {
struct inode *pwd; //进程当前执行目录的内存inode指针
struct file *fd_array; //进程打开文件的数组
atomic_t files_count; //访问此文件的线程个数
semaphore_t files_sem; //确保对进程控制块中fs_struct的互斥访问
};
当创建一个进程后,该进程的files_struct将会被初始化或复制父进程的files_struct。当用户进程打开一个文件时,将从fd_array数组中取得一个空闲file项,然后会把此file的成员变量node指针指向一个代表此文件的inode的起始地址。
3.2.2. inode 接口
index node是位于内存的索引节点,它是VFS结构中的重要数据结构,因为它实际负责把不同文件系统的特定索引节点信息(甚至不能算是一个索引节点)统一封装起来,避免了进程直接访问具体文件系统。其定义如下:
struct inode {
union { //包含不同文件系统特定inode信息的union成员变量
struct device __device_info; //设备文件系统内存inode信息
struct sfs_inode __sfs_inode_info; //SFS文件系统内存inode信息
} in_info;
enum {
inode_type_device_info = 0x1234,
inode_type_sfs_inode_info,
} in_type; //此inode所属文件系统类型
atomic_t ref_count; //此inode的引用计数
atomic_t open_count; //打开此inode对应文件的个数
struct fs *in_fs; //抽象的文件系统,包含访问文件系统的函数指针
const struct inode_ops *in_ops; //抽象的inode操作,包含访问inode的函数指针
};
在inode中,有一成员变量为in_ops,这是对此inode的操作函数指针列表,其数据结构定义如下:
struct inode_ops {
unsigned long vop_magic;
int (*vop_open)(struct inode *node, uint32_t open_flags);
int (*vop_close)(struct inode *node);
int (*vop_read)(struct inode *node, struct iobuf *iob);
int (*vop_write)(struct inode *node, struct iobuf *iob);
int (*vop_getdirentry)(struct inode *node, struct iobuf *iob);
int (*vop_create)(struct inode *node, const char *name, bool excl, struct inode **node_store);
int (*vop_lookup)(struct inode *node, char *path, struct inode **node_store);
……
};
参照上面对SFS中的索引节点操作函数的说明,可以看出inode_ops是对常规文件、目录、设备文件所有操作的一个抽象函数表示。对于某一具体的文件系统中的文件或目录,只需实现相关的函数,就可以被用户进程访问具体的文件了,且用户进程无需了解具体文件系统的实现细节。
3.3. 文件系统实现层 - SFS
这里我们没有按照从上到下先讲文件系统抽象层,再讲具体的文件系统。这是由于如果能够理解Simple FS(简称SFS)文件系统,就可更好地分析文件系统抽象层的设计。即从具体走向抽象。ucore内核把所有文件都看作是字节流,任何内部逻辑结构都是专用的,由应用程序负责解释。但是ucore区分文件的物理结构。ucore目前支持如下几种类型的文件:
- 常规文件:文件中包括的内容信息是由应用程序输入。SFS文件系统在普通文件上不强 加任何内部结构,把其文件内容信息看作为字节。
- 目录:包含一系列的entry,每个entry包含文件名和指向与之相关联的索引节点(index node)的指针。目录是按层次结构组织的。
- 链接文件:实际上一个链接文件是一个已经存在的文件的另一个可选择的文件名。
- 设备文件:不包含数据,但是提供了一个映射物理设备(如串口、键盘等)到一个文件名的机制。可通过设备文件访问外围设备。
- 管道:管道是进程间通讯的一个基础设施。管道缓存了其输入端所接受的数据,以便在管道输出端读的进程能一个先进先出的方式来接受数据。
在lab8中关注的主要是SFS支持的常规文件、目录和链接中的 hardlink 的设计实现SFS文件系统中目录和常规文件具有共同的属性,而这些属性保存在索引节点中。SFS通过索引节点来管理目录和常规文件,索引节点包含操作系统所需要的关于某个文件的关键信息,比如文件的属性、访问许可权以及其它控制信息都保存在索引节点中。可以有多个文件名可指向一个索引节点。
3.3.1. 文件系统的布局
文件系统通常保存在磁盘上。在本实验中,第三个磁盘(即disk0,前两个磁盘分别是ucore.img 和 swap.img)用于存放一个SFS文件系统(Simple Filesystem)。通常文件系统中,磁盘的使用是以扇区(Sector)为单位的,但是为了实现简便,SFS 中以 block (4K,与内存page 大小相等)为基本单位。
第0个块(4K)是超级块(superblock),它包含了关于文件系统的所有关键参数,当计算机被启动或文件系统被首次接触时,超级块的内容就会被装入内存。其定义如下:
struct sfs_super {
uint32_t magic; /* magic number, should be SFS_MAGIC */
uint32_t blocks; /* # of blocks in fs */
uint32_t unused_blocks; /* # of unused blocks in fs */
char info[SFS_MAX_INFO_LEN + 1]; /* infomation for sfs */
};
可以看到,包含一个成员变量魔数magic,其值为0x2f8dbe2a,内核通过它来检查磁盘镜像是否是合法的 SFS img;成员变量blocks记录了SFS中所有block的数量,即 img 的大小;成员变量unused_block记录了SFS中还没有被使用的block的数量;成员变量info包含了字符串"simple file system"。
从第2个块开始,根据SFS中所有块的数量,用1个bit来表示一个块的占用和未被占用的情况。这个区域称为SFS的freemap区域,这将占用若干个块空间。为了更好地记录和管理freemap区域,专门提供了两个文件kern/fs/sfs/bitmap.[ch]来完成根据一个块号查找或设置对应的bit位的值。
最后在剩余的磁盘空间中,存放了所有其他目录和文件的inode信息和内容数据信息。需要注意的是虽然inode的大小小于一个块的大小(4096B),但为了实现简单,每个 inode 都占用一个完整的 block。
在sfs_fs.c文件中的sfs_do_mount函数中,完成了加载位于硬盘上的SFS文件系统的超级块superblock和freemap的工作。这样,在内存中就有了SFS文件系统的全局信息。
3.3.2. 索引节点
磁盘索引节点
SFS中的磁盘索引节点代表了一个实际位于磁盘上的文件。首先我们看看在硬盘上的索引节点的内容:
struct sfs_disk_inode {
uint32_t size; 如果inode表示常规文件,则size是文件大小
uint16_t type; inode的文件类型
uint16_t nlinks; 此inode的硬链接数
uint32_t blocks; 此inode的数据块数的个数
uint32_t direct[SFS_NDIRECT]; 此inode的直接数据块索引值(有SFS_NDIRECT个)
uint32_t indirect; 此inode的一级间接数据块索引值
};
通过上表可以看出,如果inode表示的是文件,则成员变量direct[]直接指向了保存文件内容数据的数据块索引值。indirect间接指向了保存文件内容数据的数据块,indirect指向的是间接数据块(indirect block),此数据块实际存放的全部是数据块索引,这些数据块索引指向的数据块才被用来存放文件内容数据。
默认的,ucore 里 SFS_NDIRECT 是 12,即直接索引的数据页大小为 12 * 4k = 48k;当使用一级间接数据块索引时,ucore 支持最大的文件大小为 12 * 4k + 1024 * 4k = 48k + 4m。数据索引表内,0 表示一个无效的索引,inode 里 blocks 表示该文件或者目录占用的磁盘的 block的个数。indiret 为 0 时,表示不使用一级索引块。(因为 block 0 用来保存 super block,它不可能被其他任何文件或目录使用,所以这么设计也是合理的)。
图3-1 磁盘中文件存储结构
对于普通文件,索引值指向的 block 中保存的是文件中的数据。而对于目录,索引值指向的数据保存的是目录下所有的文件名以及对应的索引节点所在的索引块(磁盘块)所形成的数组。数据结构如下:
/* file entry (on disk) */
struct sfs_disk_entry {
uint32_t ino; 索引节点所占数据块索引值
char name[SFS_MAX_FNAME_LEN + 1]; 文件名
};
图3-2 磁盘中目录存储结构
操作系统中,每个文件系统下的 inode 都应该分配唯一的 inode 编号。SFS 下,为了实现的简便(偷懒),每个 inode 直接用他所在的磁盘 block 的编号作为 inode 编号。比如,root block 的 inode 编号为 1;每个 sfs_disk_entry 数据结构中,name 表示目录下文件或文件夹的名称,ino 表示磁盘 block 编号,通过读取该 block 的数据,能够得到相应的文件或文件夹的 inode。ino 为0时,表示一个无效的 entry。
此外,和 inode 相似,每个 sfs_dirent_entry 也占用一个 block。
内存中的索引节点
/* inode for sfs */
struct sfs_inode {
struct sfs_disk_inode *din; /* on-disk inode */
uint32_t ino; /* inode number */
uint32_t flags; /* inode flags */
bool dirty; /* true if inode modified */
int reclaim_count; /* kill inode if it hits zero */
semaphore_t sem; /* semaphore for din */
list_entry_t inode_link; /* entry for linked-list in sfs_fs*/
list_entry_t hash_link; /* entry for hash linked-list in sfs_fs */
};
可以看到SFS中的内存inode包含了SFS的硬盘inode信息,而且还增加了其他一些信息,这属于是便于进行是判断否改写、互斥操作、回收和快速地定位等作用。需要注意,一个内存inode是在打开一个文件后才创建的,如果关机则相关信息都会消失。而硬盘inode的内容是保存在硬盘中的,只是在进程需要时才被读入到内存中,用于访问文件或目录的具体内容数据。
为了方便实现上面提到的多级数据的访问以及目录中 entry 的操作,对 inode SFS实现了一些辅助的函数:
- sfs_bmap_load_nolock:将对应 sfs_inode 的第 index 个索引指向的 block 的索引值取出存到相应的指针指向的单元(ino_store)。该函数只接受 index <= inode->blocks 的参数。当 index == inode->blocks 时,该函数理解为需要为 inode 增长一个 block。并标记inode 为 dirty(所有对 inode 数据的修改都要做这样的操作,这样,当 inode 不再使用的时候,sfs 能够保证 inode 数据能够被写回到磁盘)。sfs_bmap_load_nolock 调用的sfs_bmap_get_nolock 来完成相应的操作,阅读 sfs_bmap_get_nolock,了解他是如何工作的。(sfs_bmap_get_nolock 只由 sfs_bmap_load_nolock 调用)。
- sfs_bmap_truncate_nolock:将多级数据索引表的最后一个 entry 释放掉。他可以认为是sfs_bmap_load_nolock 中,index == inode->blocks 的逆操作。当一个文件或目录被删除时,sfs 会循环调用该函数直到 inode->blocks 减为 0,释放所有的数据页。函数通过sfs_bmap_free_nolock 来实现,他应该是 sfs_bmap_get_nolock 的逆操作。和sfs_bmap_get_nolock 一样,调用 sfs_bmap_free_nolock 也要格外小心。
- sfs_dirent_read_nolock:将目录的第 slot 个 entry 读取到指定的内存空间。他通过上面提到的函数来完成。
- sfs_dirent_search_nolock:是常用的查找函数。他在目录下查找 name,并且返回相应的搜索结果(文件或文件夹)的 inode 的编号(也是磁盘编号),和相应的 entry 在该目录的 index 编号以及目录下的数据页是否有空闲的 entry。(SFS 实现里文件的数据页是连续的,不存在任何空洞;而对于目录,数据页不是连续的,当某个 entry 删除的时候,SFS 通过设置 entry->ino 为0将该 entry 所在的 block 标记为 free,在需要添加新 entry的时候,SFS 优先使用这些 free 的 entry,其次才会去在数据页尾追加新的 entry。
注意,这些后缀为 nolock 的函数,只能在已经获得相应 inode 的semaphore才能调用。
Inode的文件操作函数
static const struct inode_ops sfs_node_fileops =
{
.vop_magic = VOP_MAGIC,
.vop_open = sfs_openfile,
.vop_close = sfs_close,
.vop_read = sfs_read,
.vop_write = sfs_write,
……
};
上述sfs_openfile、sfs_close、sfs_read和sfs_write分别对应用户进程发出的open、close、read、write操作。其中sfs_openfile不用做什么事;sfs_close需要把对文件的修改内容写回到硬盘上,这样确保硬盘上的文件内容数据是最新的;sfs_read和sfs_write函数都调用了一个函数sfs_io,并最终通过访问硬盘驱动来完成对文件内容数据的读写。
Inode的目录操作函数
static const struct inode_ops sfs_node_dirops = {
.vop_magic = VOP_MAGIC,
.vop_open = sfs_opendir,
.vop_close = sfs_close,
.vop_getdirentry = sfs_getdirentry,
.vop_lookup = sfs_lookup,
……
};
对于目录操作而言,由于目录也是一种文件,所以sfs_opendir、sys_close对应户进程发出的open、close函数。相对于sfs_open,sfs_opendir只是完成一些open函数传递的参数判断,没做其他更多的事情。目录的close操作与文件的close操作完全一致。由于目录的内容数据与文件的内容数据不同,所以读出目录的内容数据的函数是sfs_getdirentry,其主要工作是获取目录下的文件inode信息。
3.4. UCore文件系统--初始化过程
与实验七相比,实验八增加了文件系统,并因此实现了通过文件系统来加载可执行文件到内存中运行的功能,导致对进程管理相关的实现比较大的调整。我们来简单看看文件系统是如何初始化并能在ucore的管理下正常工作的。
首先看看kern_init函数,可以发现与lab7相比增加了对fs_init函数的调用。fs_init函数就是文件系统初始化的总控函数,它进一步调用了虚拟文件系统初始化函数vfs_init,与文件相关的设备初始化函数dev_init和Simple FS文件系统的初始化函数sfs_init。这三个初始化函数联合在一起,协同完成了整个虚拟文件系统、SFS文件系统和文件系统对应的设备(键盘、串口、磁盘)的初始化工作。其函数调用关系图如下所示:
图3-3 文件系统初始化调用关系
参考上图,并结合源码分析,可大致了解到文件系统的整个初始化流程。
1)vfs_init主要建立了一个device list双向链表vdev_list,为后续具体设备(键盘、串口、磁盘)以文件的形式呈现建立查找访问通道。
2)dev_init函数通过进一步调用disk0/stdin/stdout_device_init完成对具体设备的初始化,把它们抽象成一个设备文件,并建立对应的inode数据结构,最后把它们链入到vdev_list中。这样通过虚拟文件系统就可以方便地以文件的形式访问这些设备了。
3)sfs_init是完成对Simple FS的初始化工作,并把此实例文件系统挂在虚拟文件系统中,从而让ucore的其他部分能够通过访问虚拟文件系统的接口来进一步访问到SFS实例文件系统。
3.5. UCore文件系统--打开文件
有了上述分析后,我们可以看看如果一个用户进程打开文件会做哪些事情?首先假定用户进程需要打开的文件已经存在在硬盘上。以user/sfs_filetest1.c为例,首先用户进程会调用在main函数中的如下语句:
int fd1 = safe_open("/test/testfile", O_RDWR | O_TRUNC);
从字面上可以看出,如果ucore能够正常查找到这个文件,就会返回一个代表文件的文件描述符fd1,这样在接下来的读写文件过程中,就直接用这样fd1来代表就可以了。那这个打开文件的过程是如何一步一步实现的呢?
首先进入通用文件访问接口层的处理流程,即进一步调用如下用户态函数: open->sys_open->syscall,从而引起系统调用进入到内核态。到了内核态后,通过中断处理例程,会调用到sys_open内核函数,并进一步调用sysfile_open内核函数。到了这里,需要把位于用户空间的字符串"/test/testfile"拷贝到内核空间中的字符串path中,并进入到文件系统抽象层的处理流程完成进一步的打开文件操作中。
3.5.1. 文件系统抽象层的处理流程
分配一个空闲的file数据结构变量file在文件系统抽象层的处理中,首先调用的是file_open函数,它要给这个即将打开的文件分配一个file数据结构的变量,这个变量其实是当前进程的打开文件数组current->fs_struct->filemap[]中的一个空闲元素(即还没用于一个打开的文件),而这个元素的索引值就是最终要返回到用户进程并赋值给变量fd1。到了这一步还仅仅是给当前用户进程分配了一个file数据结构的变量,还没有找到对应的文件索引节点。
为此需要进一步调用vfs_open函数来找到path指出的文件所对应的基于inode数据结构的VFS索引节点node。vfs_open函数需要完成两件事情:通过vfs_lookup找到path对应文件的inode;调用vop_open函数打开文件。
1)找到文件设备的根目录“/”的索引节点需要注意,这里的vfs_lookup函数是一个针对目录的操作函数,它会调用vop_lookup函数来找到SFS文件系统中的“/test”目录下的“testfile”文件。为此,vfs_lookup函数首先调用get_device函数,并进一步调用vfs_get_bootfs函数(其实调用了)来找到根目录“/”对应的inode--这个是文件系统目录结构的起始点。这个inode就是位于vfs.c中的inode变量bootfs_node。这个变量在init_main函数(位于kern/process/proc.c)执行时获得了赋值,这种赋值是通过vfs_set_bootfs实现的。
2)找到根目录“/”下的“test”子目录对应的索引节点,在找到根目录对应的inode后,通过调用vop_lookup函数来查找“/”和“test”这两层目录下()的文件“testfile”所对应的索引节点,如果找到就返回此索引节点。
3)把file和node建立联系。完成第3步后,将返回到file_open函数中,通过执行语句“file->node=node;”,就把当前进程的current->fs_struct->filemap[fd](即file所指变量)的成员变量node指针指向了代表“/test/testfile”文件的索引节点node。这时返回fd。经过重重回退,通过系统调用返回,用户态的syscall->sys_open ->open->safe_open等用户函数的层层函数返回,最终把把fd赋值给fd1。自此完成了打开文件操作。但这里我们还没有分析第2和第3步是如何进一步调用SFS文件系统提供的函数找位于SFS文件系统上的“/test/testfile”所对应的sfs磁盘inode的过程。下面需要进一步对此进行分析。
3.5.2. SFS文件系统层的处理流程
这里需要分析文件系统抽象层中没有彻底分析的vop_lookup函数到底做了啥。下面我们来看看。在sfs_inode.c中的sfs_node_dirops变量定义了“.vop_lookup = sfs_lookup”,所以我们重点分析sfs_lookup的实现。
sfs_lookup有三个参数:node,path,node_store。其中node是根目录“/”所对应的inode节点;path是文件“testfile”的绝对路径“/test/testfile”,而node_store是经过查找获得的“testfile”所对应的inode节点。
sfs_lookup函数以“/”为分割符,从左至右逐一分解path获得各个子目录和最终文件对应的inode节点。在本例中是分解出“test”子目录,并调用sfs_lookup_once函数获得“test”子目录对应的inode节点subnode,然后循环进一步调用sfs_lookup_once查找以“test”子目录下的文件“testfile1”所对应的inode节点。当无法分解path后,就意味着找到了testfile1对应的inode节点,就可顺利返回了。
当然这里讲得还比较简单,sfs_lookup_once将调用sfs_dirent_search_nolock函数来查找与路径名匹配的目录项,如果找到目录项,则根据目录项中记录的inode所处的数据块索引值找到路径名对应的SFS磁盘inode,并读入SFS磁盘inode对的内容,创建SFS内存inode。
4. 设备层文件 IO 层
在本实验中,为了统一地访问设备,我们可以把一个设备看成一个文件,通过访问文件的接口来访问设备。目前实现了stdin设备文件文件、stdout设备文件、disk0设备。stdin设备就是键盘,stdout设备就是CONSOLE(串口、并口和文本显示器),而disk0设备是承载SFS文件系统的磁盘设备。下面我们逐一分析ucore是如何让用户把设备看成文件来访问。
4.1. 关键数据结构
为了表示一个设备,需要有对应的数据结构,ucore为此定义了struct device,其描述如下:
struct device {
size_t d_blocks; //设备占用的数据块个数
size_t d_blocksize; //数据块的大小
int (*d_open)(struct device *dev, uint32_t open_flags); //打开设备的函数指针
int (*d_close)(struct device *dev); //关闭设备的函数指针
int (*d_io)(struct device *dev, struct iobuf *iob, bool write); //读写设备的函数指针
int (*d_ioctl)(struct device *dev, int op, void *data); //用ioctl方式控制设备的函数指针
};
这个数据结构能够支持对块设备(比如磁盘)、字符设备(比如键盘、串口)的表示,完成对设备的基本操作。ucore虚拟文件系统为了把这些设备链接在一起,还定义了一个设备链表,即双向链表vdev_list,这样通过访问此链表,可以找到ucore能够访问的所有设备文件。
但这个设备描述没有与文件系统以及表示一个文件的inode数据结构建立关系,为此,还需要另外一个数据结构把device和inode联通起来,这就是vfs_dev_t数据结构:
// device info entry in vdev_list
typedef struct {
const char *devname;
struct inode *devnode;
struct fs *fs;
bool mountable;
list_entry_t vdev_link;
} vfs_dev_t;
利用vfs_dev_t数据结构,就可以让文件系统通过一个链接vfs_dev_t结构的双向链表找到device对应的inode数据结构,一个inode节点的成员变量in_type的值是0x1234,则此 inode的成员变量in_info将成为一个device结构。这样inode就和一个设备建立了联系,这个inode就是一个设备文件。
4.2. stdout设备文件
初始化
既然stdout设备是设备文件系统的文件,自然有自己的inode结构。在系统初始化时,即只需如下处理过程
kern_init-->fs_init-->dev_init-->dev_init_stdout --> dev_create_inode
--> stdout_device_init
--> vfs_add_dev
在dev_init_stdout中完成了对stdout设备文件的初始化。即首先创建了一个inode,然后通过stdout_device_init完成对inode中的成员变量inode->__device_info进行初始:
这里的stdout设备文件实际上就是指的console外设(它其实是串口、并口和CGA的组合型外设)。这个设备文件是一个只写设备,如果读这个设备,就会出错。接下来我们看看stdout设备的相关处理过程。
初始化
stdout设备文件的初始化过程主要由stdout_device_init完成,其具体实现如下:
static void
stdout_device_init(struct device *dev) {
dev->d_blocks = 0;
dev->d_blocksize = 1;
dev->d_open = stdout_open;
dev->d_close = stdout_close;
dev->d_io = stdout_io;
dev->d_ioctl = stdout_ioctl;
}
可以看到,stdout_open函数完成设备文件打开工作,如果发现用户进程调用open函数的参数flags不是只写(O_WRONLY),则会报错。
访问操作实现
stdout_io函数完成设备的写操作工作,具体实现如下:
static int
stdout_io(struct device *dev, struct iobuf *iob, bool write) {
if (write) {
char *data = iob->io_base;
for (; iob->io_resid != 0; iob->io_resid --) {
cputchar(*data ++);
}
return 0;
}
return -E_INVAL;
}
可以看到,要写的数据放在iob->io_base所指的内存区域,一直写到iob->io_resid的值为0为止。每次写操作都是通过cputchar来完成的,此函数最终将通过console外设驱动来完成把数据输出到串口、并口和CGA显示器上过程。另外,也可以注意到,如果用户想执行读操作,则stdout_io函数直接返回错误值-E_INVAL。
4.3. stdin 设备文件
这里的stdin设备文件实际上就是指的键盘。这个设备文件是一个只读设备,如果写这个设备,就会出错。接下来我们看看stdin设备的相关处理过程。
初始化
stdin设备文件的初始化过程主要由stdin_device_init完成了主要的初始化工作,具体实现如下:
static void
stdin_device_init(struct device *dev) {
dev->d_blocks = 0;
dev->d_blocksize = 1;
dev->d_open = stdin_open;
dev->d_close = stdin_close;
dev->d_io = stdin_io;
dev->d_ioctl = stdin_ioctl;
p_rpos = p_wpos = 0;
wait_queue_init(wait_queue);
}
相对于stdout的初始化过程,stdin的初始化相对复杂一些,多了一个stdin_buffer缓冲区,描述缓冲区读写位置的变量p_rpos、p_wpos以及用于等待缓冲区的等待队列wait_queue。在stdin_device_init函数的初始化中,也完成了对p_rpos、p_wpos和wait_queue的初始化。
访问操作实现
stdin_io函数负责完成设备的读操作工作,具体实现如下:
static int
stdin_io(struct device *dev, struct iobuf *iob, bool write) {
if (!write) {
int ret;
if ((ret = dev_stdin_read(iob->io_base, iob->io_resid)) > 0) {
iob->io_resid -= ret;
}
return ret;
}
return -E_INVAL;
}
可以看到,如果是写操作,则stdin_io函数直接报错返回。所以这也进一步说明了此设备文件是只读文件。如果此读操作,则此函数进一步调用dev_stdin_read函数完成对键盘设备的读入操作。dev_stdin_read函数的实现相对复杂一些,主要的流程如下:
static int
dev_stdin_read(char *buf, size_t len) {
int ret = 0;
bool intr_flag;
local_intr_save(intr_flag);
{
for (; ret < len; ret ++, p_rpos ++) {
try_again:
if (p_rpos < p_wpos) {
*buf ++ = stdin_buffer[p_rpos % STDIN_BUFSIZE];
}
else {
wait_t __wait, *wait = &__wait;
wait_current_set(wait_queue, wait, WT_KBD);//将当前进程控制块放在wait,同时将wait放在wait_queue中。
local_intr_restore(intr_flag);
schedule();
local_intr_save(intr_flag);
wait_current_del(wait_queue, wait);
if (wait->wakeup_flags == WT_KBD) {
goto try_again;
}
break;
}
}
}
local_intr_restore(intr_flag);
return ret;
}
在上述函数中可以看出,如果p_rpos < p_wpos,则表示有键盘输入的新字符在stdin_buffer中,于是就从stdin_buffer中取出新字符放到iobuf指向的缓冲区中;如果p_rpos >=p_wpos,则表明没有新字符,这样调用read用户态库函数的用户进程就需要采用等待队列的睡眠操作进入睡眠状态,等待键盘输入字符的产生。
键盘输入字符后,如何唤醒等待键盘输入的用户进程呢?回顾lab1中的外设中断处理,可以了解到,当用户敲击键盘时,会产生键盘中断,在trap_dispatch函数中,当识别出中断是键盘中断(中断号为IRQ_OFFSET + IRQ_KBD)时,会调用dev_stdin_write函数,来把字符写入到stdin_buffer中,且会通过等待队列的唤醒操作唤醒正在等待键盘输入的用户进程。
二、练习解答
2.1. 练习1: 完成读文件操作的实现(需要编码)
首先了解打开文件的处理流程,然后参考本实验后续的文件读写操作的过程分析,编写在sfs_inode.c中sfs_io_nolock读文件中数据的实现代码。
请在实验报告中给出设计实现”UNIX的PIPE机制“的概要设方案,鼓励给出详细设计方案。
2.1.1. 关于sfs_io_nolock函数的解读
这个函数的作用是将磁盘中offset位置-->offset+length位置的数据,传送给内存buffer中。为了搞懂这一过程,对如下问题进行解决:
2.1.1.1. 具体的目标是什么?
将磁盘位置disk[offset,offset+length]的数据与内存位置buffer[0,length]数据读写。
2.1.1.2. 实现这个目标的主要途径是什么?
- 读取第一块没有对齐的数据到buffer中
- 读取中间对齐的数据到buffer中
- 读取最后一块没有对齐的数据到buffer中
2.1.1.3. 读取第一块没有对齐的数据到buffer中
3.1)offset位于文件系统中的哪个逻辑块?
blkno=offset/SFS_BLKSIZE;
3.2)blkno位于磁盘布局中的哪个物理块?
位于物理块ino中,sfs_bmap_load_noblock(sfs,sin,blkno,&ino);
3.3)blkno所处的物理块,需要从哪里开始读取数据到buffer中
需要从blkoffset位置开始,blkoffset=offset%SFS_BLKSIZE
3.4)从blkoffset位置开始读数据,那什么位置结束读取数据呢?
size = SFS_BLKSIZE-blkoffset
3.5)通过什么API读取磁盘数据到buffer中?
sfs_bufer_op(sfs,buf,size,ino,blkoffset);
3.6)当blkoffset是0的时候如何处理?
这个时候他表示第1块是对齐的,那么不执行目标1,继续执行目标2:读取中间对齐的数据到buffer中。
3.7)如何只有第一块,size如何处理?
size=endpos-offset.
最终综合计算的size = (nblk>1)?SFS_BLKSIZE-blkoffset:endpos-offset
3.8)目标1的实现与目标2的实现关系?
nblk = (endpos/SFS_BLKSIZE) - blkno;//total Rd/Wr block number,while excluding the last block
if(blkoffset !=0)
{
//执行目标1的读写
//执行上下文目标(目标1与目标2)控制代码
buf += size;
nblk--;
blkno++;
}
3.9)综合以上的几点考虑,那么最终实现目标1代码是什么?
uint32_t ino;
uint32_t blkno = offset / SFS_BLKSIZE; // The NO. of Rd/Wr begin block
uint32_t nblks = endpos / SFS_BLKSIZE - blkno; // The size of Rd/Wr blocks
//(1)Rd/Wr first block,must be aligned
if((blkoff = offset%SFS_BLKSIZE)!=0)
{
size = (nblks != 0) ? (SFS_BLKSIZE - blkoff) : (endpos - offset);
//@blkno is logic index
//@ino is disk data block
//we need get real disk data block number(ino)
if((ret=sfs_bmap_load_nolock(sfs,sin,blkno,&ino))!=0)
{
goto out;
}
//write from blkoffset to blkoffset+size at blkno datablock
if((ret=sfs_buf_op(sfs,buf,size,ino,blkoff))!=0)
{
goto out;
}
alen += size;//real writing data length
//endpos-offset<one Page
if(nblks==0)
{
goto out;
}
//update state for next step
buf += size;
blkno++;//next time write from blkno+1
nblks--;//left number is nblks-1
}
2.1.1.4. 读取中间对齐的数据到buffer中
4.1)通过什么控制对齐部分的数据读写到buffer中?
通过nblks>0,以及遍历blkno
4.2)直接读写一整块的API是什么?
int (*sfs_block_op)(struct sfs_fs *sfs, void *buf, uint32_t blkno, uint32_t nblks);
4.3)综合以上的几点考虑,那么最终实现目标2代码是什么?
//(2)Rd/Wr middle block in loop,because they are complete data block
size = SFS_BLKSIZE;
while(nblks > 0)
{
if((ret=sfs_bmap_load_nolock(sfs,sin,blkno,&ino))!=0)
{
goto out;
}
//Rd/Wr one block data
if((ret=sfs_block_op(sfs,buf,ino,1))!=0)
{
goto out;
}
alen += size;//real writing data length
//update state for next step
buf += size;
blkno++;//next time write from blkno+1
nblks--;//left number is nblks-1
}
2.1.1.5. 读取最后一块没有对齐的数据到buffer中
5.1)如何计算最后一块需要拷贝的大小
size = endpos%SFS_BLKSIZE;
5.2)对于最后一块开始拷贝的地址是多少?
0
5.3)综合以上的几点考虑,那么最终实现目标3代码是什么?
//(3)Rd/Wr end block,must be aligned,add last data block
if((size = endpos%SFS_BLKSIZE)!=0)
{
blkoff = 0;
if((ret=sfs_bmap_load_nolock(sfs,sin,blkno,&ino))!=0)
{
goto out;
}
//write from blkoffset to blkoffset+size at blkno datablock
if((ret=sfs_buf_op(sfs,buf,size,ino,blkoff))!=0)
{
goto out;
}
alen += size;//real Rd/Wr data length
}
2.2. 练习2: 完成基于文件系统的执行程序机制的实现(需要编码)
改写proc.c中的load_icode函数和其他相关函数,实现基于文件系统的执行程序机制。执行:make qemu。如果能看看到sh用户程序的执行界面,则基本成功了。如果在sh用户界面上可以执行”ls”,”hello”等其他放置在sfs文件系统中的其他执行程序,则可以认为本实验基本成功。
请在实验报告中给出设计实现基于”UNIX的硬链接和软链接机制“的概要设方案,鼓励给出详细设计方案
2.2.1. 关于加载文件系统中文件的解读
代码解读范围是什么?
- do_fork()函数中,主要是从父亲进程中创建子进程结构体。除了LAB1~LAB7需要实现的操作外,对于LAB8需要考虑将父亲进程的文件结构体拷贝到子进程的文件结构体中。这里需要采用copy_files()函数操作。
- load_icode()函数,通过文件句柄fd,将文件的内容载入进程的内存中
2.2.1.1. 对于load_icode()具体目标是什么?
在实验7的基础上,完成ELF文件从磁盘中加载到进程的虚拟内存空间中,建立虚拟地址和物理地址之间的映射关系,以便后续可以执行ELF程序。
为了完成以上的宏大目标,具体由以下的小目标支持。
- 实验7的ELF数据怎么加载到进程的虚拟内存地址空间?
- 实验7怎么执行进程虚拟地址空间的代码段(由原先的ELF文件拷贝过来)?
- 实验8如何建立进程需要的用户空间栈?
2.2.1.2. 目标1:实验7的elf数据加载到进程的虚拟内存地址空间?
1)什么是进程的虚拟内存地址空间?
我们希望每一个正在执行的进程地址空间都是固定的4G,它的布局如下图。
在图中UTEXT-->USERTOP是用户进程地址空间,它最核心的3块内存区是User Program, User stack, User Heap。其中User Program存放的是程序汇编指令,它由CPU的cs:ip寄存器控制,CPU不断的取指令,执行指令。主要的指令有操作内存指令mov,操作栈内存指令push+pop,操作寄存器进行算术和逻辑运算指令例如add。其中User stack是用户的栈地址空间,栈的访问由CPU的esp+ebp寄存器控制。其中User Heap是用户的堆内存地址空间,堆内存与寄存器之间的传送主要通过mov指令完成。那么可以推断出用户空间的所有程序均可以正确的执行,从而用户程序所有想要的功能理论上均可以实现。
在图中KERNBASE-->KERNTOP是内核程序地址空间,他最核心的3个内存区是Kernel Program,Kernel stack,Kernel Heap。其中Kernel Program存储内核的汇编指令,最核心的是存放各种各样的系统调用功能。当用户进程触发系统调用,CPU的cs:ip寄存器就会在Kernel Program中执行系统调用,从而触发Kernel stack,Kernel Heap的使用。
在图中VPT-->0xFB000000是页表地址空间。我们知道CPU在取指令,执行指令的过程中,cs:ip访问的都是逻辑地址单元。如果没有物理空间对应这个逻辑地址,那么获得的结果就是空,从而导致缺页,程序无法正常的执行。因此页表地址空间的作用是建立程序(用户空间程序+内核空间程序)与实际物理内存之间的映射关系,在CPU执行程序的时候,可以获得程序中的内容,从容保证程序正常运行。在多个应用程序的计算机系统中,多个进程在CPU上轮询运行,那么就可以时分共享实际的物理内存,而页表地址空间就是解决多进程访问同一块物理内存的主要方法。
2)elf文件加载到进程地址空间的什么地方去了?
我们知道在操作系统之上运行应用程序,这个应用程序在linux中用elf文件表示,如下图。下图的只读代码段和读写数据段被加载到进程空间的User Program,这种加载指的是从ELF文件中虚拟地址建立进程空间的逻辑地址空间,并放到该进程的逻辑地址页表中。同时建立逻辑地址和物理地址之间的映射关系(PS:通过page=pgdir_alloc_page(pgdir,la)函数分配一个指向la逻辑地址的物理页page,该函数采用default_pmm_manager内存管理器分配一页物理内存。之后通过memcpy(page2kva(page) + off, from, size)将来自elf文件中只读代码段存放到刚才la逻辑地址物理页page中)。
3)目标1实现的关键代码解读
//加载elf文件到内核空间变量elf中
struct Page *page;
//(3.1) get the file header of the elf file program (ELF format)
//instead of struct elfhdr *elf = (struct elfhdr *)binary;LAB8
if((ret=load_icode_read(fd,elf,sizeof(struct elfhdr),0))!=0)
{
ret = -E_NOENT;
goto bad_elf_cleanup_pgdir;
}
//加载elf文件的各个程序段头部到内存中
uint32_t ph_address = elf->e_phoff + ph_index*sizeof(struct proghdr);
if((ret=load_icode_read(fd,ph,sizeof(struct proghdr),ph_address))!=0)
{
ret = -E_NOENT;
goto bad_elf_cleanup_pgdir;
}
//让进程知道该程序段的合法地址空间
//(3.5) call mm_map fun to setup the new vma ( ph->p_va, ph->p_memsz)
vm_flags = 0, perm = PTE_U;
if (ph->p_flags & ELF_PF_X) vm_flags |= VM_EXEC;
if (ph->p_flags & ELF_PF_W) vm_flags |= VM_WRITE;
if (ph->p_flags & ELF_PF_R) vm_flags |= VM_READ;
if (vm_flags & VM_WRITE) perm |= PTE_W;
if ((ret = mm_map(mm, ph->p_va, ph->p_memsz, vm_flags, NULL)) != 0) {
goto bad_cleanup_mmap;
}
//记载elf文件的程序段到进程的虚拟内存空间
//(3.6) alloc memory, and copy the contents of every program section (from, from+end) to process's memory (la, la+end)
end = ph->p_va + ph->p_filesz;
//(3.6.1) copy TEXT/DATA section of bianry program
while (start < end) {
if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL) {//分配物理内存页page
ret = -E_NO_MEM;
goto bad_cleanup_mmap;
}
off = start - la, size = PGSIZE - off, la += PGSIZE;
if (end < la) {
size -= la - end;
}
//memcpy(page2kva(page) + off, from, size);
if((ret=load_icode_read(fd,page2kva(page) + off,size,from))!=0)
{//将程序写入到物理内存页page中
ret = -E_NOENT;
goto bad_elf_cleanup_pgdir;
}
start += size, from += size;
}
2.2.1.3. 目标2:实验7怎么执行进程虚拟地址空间的代码段(由原先的ELF文件拷贝过来)?
3.1)目标2的设计思路解读
在ELF文件头部中,有一个字段是e_entry,它代表的是可执行程序的函数入口,它指向的是_start()函数,这个函数在initcode.S中进行了实现。例如hello可执行程序,他是由initcode.o + hello.o链接而成,其中initcode.S中定义了_start()函数,hello.c中定义了main()函数。
e_entry字段所代表的地址属于进程的虚拟地址,CPU的eip存放这个地址。进程开始执行的时候,首先执行的是e_entry的位置。CPU取指令执行的虚拟地址,通过MMU单元进行虚拟地址到物理地址的映射,获得物理地址所在的指令内容。下图中是x86上执行的一段代码,从中可以看出每次执行的指令长度是不一样的,CPU怎么取指令,翻译指令是CPU的事情了。
3.2)目标2实现的关键代码解读
//设置好进程的入口地址以及栈地址
//(6) setup trapframe for user environment
struct trapframe *tf = current->tf;
memset(tf, 0, sizeof(struct trapframe));
/* LAB5:EXERCISE1 YOUR CODE
* should set tf_cs,tf_ds,tf_es,tf_ss,tf_esp,tf_eip,tf_eflags
* NOTICE: If we set trapframe correctly, then the user level process can return to USER MODE from kernel. So
* tf_cs should be USER_CS segment (see memlayout.h)
* tf_ds=tf_es=tf_ss should be USER_DS segment
* tf_esp should be the top addr of user stack (USTACKTOP)
* tf_eip should be the entry point of this binary program (elf->e_entry)
* tf_eflags should be set to enable computer to produce Interrupt
*/
tf->tf_cs = USER_CS;
tf->tf_eip = elf->e_entry;
tf->tf_ds = tf->tf_es = USER_DS;
tf->tf_ss = USER_DS;
tf->tf_esp = USTACKTOP;
tf->tf_eflags = FL_IF;
2.2.1.4. 目标3:实验8如何建立进程需要的用户空间栈?
4.1)用户栈空间如何生成的?
用户栈的位置是确定的,由USTACKTOP位置,USTACKSIZE大小构成。首先可以通过mm_map(mm, USTACKTOP - USTACKSIZE, USTACKSIZE, vm_flags, NULL)函数在进程中构建合法的栈空间大小,合法性是通过进程的vma结构构成的。这个栈空间大小是256页,一共1M的空间。然后通过pgdir_alloc_page函数分配了4页的栈空间物理内存,该进程的所有物理内存均在mm->pgdir数据结构中表示。每个进程都具有独立的页表,页表的首地址在mm->pgdir中,页表要工作,需要执行CPU的CR3寄存器。
在以上分析的用户栈空间逻辑大小是256页,而实际只是分配了4页的物理页,那么栈的深度达到4页后,程序继续访问栈空间的地址将会缺页,这个时候会触发缺页机制,建立缺页的逻辑逻辑地址和空闲的物理页之间的联系。
建立用户栈逻辑地址空间的核心代码如下:
//(4) build user stack memory
vm_flags = VM_READ | VM_WRITE | VM_STACK;
if ((ret = mm_map(mm, USTACKTOP - USTACKSIZE, USTACKSIZE, vm_flags, NULL)) != 0) {
goto bad_cleanup_mmap;
}
assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-PGSIZE , PTE_USER) != NULL);
assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-2*PGSIZE , PTE_USER) != NULL);
assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-3*PGSIZE , PTE_USER) != NULL);
assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-4*PGSIZE , PTE_USER) != NULL);
//(5) set current process's mm, cr3, and set CR3 reg = physical addr of Page Directory
mm_count_inc(mm);
current->mm = mm;
current->cr3 = PADDR(mm->pgdir);
lcr3(PADDR(mm->pgdir));
4.2)函数调用传参如何进行的?
-->umain(int argc, char *argv[])/umain.c
-->main(int argc, char **argv)/sh.c
-->runcmd(char *cmd);//sh.c
-->__exec(NULL, argv);/ulib.c
-->sys_exec(const char *name, int argc, const char **argv)/syscall.c
-->syscall()/syscall.c
-->sys_exec()/syscall.c-
-->do_execve()/syscall.c
-->load_icode(int fd, int argc, char **kargv)/proc.c
由于load_icode的参数argc,kargv实际上来自应用程序通过系统调用sys_exec传递过来的,因此 argc就是传递参数的个数,而kargv就是argv,表示传递参数的内容。这两个参数必须放在用户栈中,这样当进程调度器调度到该进程的时候,进入e_entry位置执行代码,就可以传递参数(argc,argv)给umain(argc,argv)函数,程序正确执行。如果传递参数错误,将会导致应用程序缺页终止执行。umain(argc,argv)表明argv先压栈,argc后压栈。
通过充分理解一下代码,得出argv的内容首地址在(esp+4)的位置,argc内容的首地址在esp的位置。也就是说,我们必须在_start函数将数据压栈之前,将数据存储在指定的位置,而这个位置就是esp确定后指定的位置。
根据上面_start函数中的汇编语言,我们得到如下的栈中参数分布位置,调用umain函数前的准备。其中最后esp指向的位置必须存放argc,同时esp+4指向的位置必须存放argv。argc是32位的整数,虽然argv实际只要14字节(包括\0),然而由于栈是4字节对齐,所以给定16字节的空间。这些数据存储的空间均是从USTACKTOP开始分配的。
//argv的区域数据拷贝
//6.1 calculate uargv parameters size
int32_t uargv_size = 0;
for(int i=0;i<argc;i++)
{
uargv_size += strnlen(kargv[i],EXEC_MAX_ARG_LEN+1) + 1;//include '\0'
}
uintptr_t stacktop = USTACKTOP - (uargv_size/sizeof(uint32_t)+1)*sizeof(uint32_t);//stack smallest uint is 4Bytes
//6.3 copy kargv[i] to uargv[i]
uargv_size = 0;
for(int i=0;i<argc;i++)
{
uargv[i] = strcpy(stacktop+uargv_size,kargv[i]);
uargv_size += strnlen(kargv[i],EXEC_MAX_ARG_LEN+1) + 1;
}
//argc的区域数据拷贝
//6.4 copy argc to uargc
stacktop = (uintptr_t)uargv - sizeof(uint32_t);
*(uint32_t *)(stacktop) = argc;
//设置程序运行的栈顶首地址
tf->tf_ds = tf->tf_es = tf->tf_ss = USER_DS;
tf->tf_esp = stacktop;///first parameter argc address,keep push stack orders