MIT6.828-Lab5-总结

文件系统杂项

JOS的文件系统也是“微内核”模式,其设置文件服务进程用于处理文件操作请求。示意图如下:

IOPL(I/O privilege)赋予进程操作硬盘的权限,其标识当前用户进程想要访问 IO space 需要的最低权限,示意图如下:

文件服务进程的地址空间布局

JOS的文件服务进程设置了3GB的cache用于映射磁盘空间,即0x100000000xD0000000的位置用作磁盘映射,示意图如下:图源

需要注意的是,此处的映射属于懒惰映射,依赖于缺页中断来完成真正的映射。

系统打开文件表与进程文件描述符表

Fd结构与OpenFile结构如下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct FdFile {
int id;
};
struct Fd {
int fd_dev_id;
off_t fd_offset;
int fd_omode;
union {
// File server files
struct FdFile fd_file;
};
};
struct OpenFile {
uint32_t o_fileid; // file id 与文件并不是一一对应的,主要的作用就是找到Fd对应的OpenFile
struct File *o_file; // mapped descriptor for open file
int o_mode; // open mode
struct Fd *o_fd; // Fd page
};

下图展示了opentab如何将struct Filestruct Fd联系起来,其中struct Fd存储打开文件的信息(也可以说是动态信息),struct File存储的是静态信息。

JOS中,Fd其实只是一块物理页而已(分配空间虽过大,但有利于实现权限管理,以及共享fd),在接下来的介绍中会详细解释。

JOS的open流程

下图是官方文档介绍的read流程,open操作与其类似。

open in lib/file.c

普通进程调用此库函数,用于获取文件描述符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int
open(const char *path, int mode)
{
int r;
struct Fd *fd;

if (strlen(path) >= MAXPATHLEN)
return -E_BAD_PATH;

// 在自己进程空间里找到一块未分配的物理页
if ((r = fd_alloc(&fd)) < 0)
return r;

// 指定文件系统请求是open操作,并把path复制到请求体中
strcpy(fsipcbuf.open.req_path, path);
fsipcbuf.open.req_omode = mode;

// fsipc函数会将文件服务进程创建的Fd对应的物理页共享给当前进程
if ((r = fsipc(FSREQ_OPEN, fd)) < 0) {
fd_close(fd, 0);
return r;
}

return fd2num(fd);
}

讲解一下fd_alloc函数:其直接在普通进程的0xD0000000之上找一块没有分配物理页的地址,然后将其作为struct Fd*类型返回。 这也正说明了,JOS中,Fd其实就是个物理页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define FDTABLE		0xD0000000
#define INDEX2FD(i) ((struct Fd*) (FDTABLE + (i)*PGSIZE))
int
fd_alloc(struct Fd **fd_store)
{
int i;
struct Fd *fd;
for (i = 0; i < MAXFD; i++) {
fd = INDEX2FD(i);
if ((uvpd[PDX(fd)] & PTE_P) == 0 || (uvpt[PGNUM(fd)] & PTE_P) == 0) {// 找到一个还没有建立映射的虚拟地址
*fd_store = fd;
return 0;
}
}
*fd_store = 0;
return -E_MAX_OPEN;
}

fsipc与FS server

说明:主要解释都写在注释里
我们先看一下serve_init函数,其主要是初始化opentabfileidfd

1
2
3
4
5
6
7
8
9
10
11
12
void
serve_init(void)
{
int i;
uintptr_t va = FILEVA;
for (i = 0; i < MAXOPEN; i++) {
opentab[i].o_fileid = i;
// 需要注意的是此处并没有映射物理页,而是在后续的openfile_alloc中实现的
opentab[i].o_fd = (struct Fd*) va;
va += PGSIZE;
}
}

fsipc函数用于将普通进程的请求发送给文件服务进程,然后使用ipc_recv等待回复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static int
fsipc(unsigned type, void *dstva)
{
static envid_t fsenv;
if (fsenv == 0)
fsenv = ipc_find_env(ENV_TYPE_FS);

static_assert(sizeof(fsipcbuf) == PGSIZE);

if (debug)
cprintf("[%08x] fsipc %d %08x\n", thisenv->env_id, type, *(uint32_t *)&fsipcbuf);

// 请求保存在fsipcbuf,现将请求发送给file server
ipc_send(fsenv, type, &fsipcbuf, PTE_P | PTE_W | PTE_U);
// FS Server 已经在处理请求了,等待其的回复即可
return ipc_recv(NULL, dstva, NULL);
}

文件服务进程主要就是一个循环,等待其他进程的请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void
serve(void)
{
uint32_t req, whom;
int perm, r;
void *pg;

while (1) {
req = ipc_recv((int32_t *) &whom, fsreq, &perm); // 阻塞以接收其他普通进程的请求
// 一些检查

pg = NULL;
if (req == FSREQ_OPEN) {// 如果是文件打开的请求,则执行serve_open
r = serve_open(whom, (struct Fsreq_open*)fsreq, &pg, &perm);
} else {
//....
}
// 将文件服务进程的`Struct Fd`结构共享给服务请求进程
ipc_send(whom, r, pg, perm);
sys_page_unmap(0, fsreq);
}
}

接下来重点看一看serve_open函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
int
serve_open(envid_t envid, struct Fsreq_open *req,
void **pg_store, int *perm_store)
{
char path[MAXPATHLEN];
struct File *f;
int fileid;
int r;
struct OpenFile *o;

// 拷贝文件名
memmove(path, req->req_path, MAXPATHLEN);
path[MAXPATHLEN-1] = 0; // 添加 '\0'

// 在系统打开文件表中找一个空位,在后面的操作会利用这个o变量,将一些信息填入此文件对应的fd的物理页中
if ((r = openfile_alloc(&o)) < 0) {
return r;
}
fileid = r;

// 一些其他操作,这里就略过了 //

// 最后将opentab的元素与Struct File联系起来
// 将file指针保存到opentab的元素中
o->o_file = f;
// Fill out the Fd structure
o->o_fd->fd_file.id = o->o_fileid;
o->o_fd->fd_omode = req->req_omode & O_ACCMODE;
o->o_fd->fd_dev_id = devfile.dev_id;
o->o_mode = req->req_omode;

// pg_store是serve端返回给发送端的虚拟页面地址,IPC机制会自动为发送端建立物理映射,来共享本进程的struct FD
*pg_store = o->o_fd;
// 注意这里的PTE_SHARE标记,表示如果进程要fork或者spawn,需要和它的父进程共享
*perm_store = PTE_P|PTE_U|PTE_W|PTE_SHARE;
return 0;
}

随后是openfile_alloc函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int
openfile_alloc(struct OpenFile **o)
{
int i, r;

// Find an available open-file table entry
for (i = 0; i < MAXOPEN; i++) {
switch (pageref(opentab[i].o_fd)) {
case 0:
// 如果这个fd都没进程用,则需要为其分配一个物理页
// 至于为什么不能像前面一样靠缺页处理函数呢?因为缺页处理函数只关注文件服务进程缓存的那3G空间
if ((r = sys_page_alloc(0, opentab[i].o_fd, PTE_P|PTE_U|PTE_W)) < 0)
return r;
/* fall through */
// 这样子,文件服务进程创建了一个fd
case 1: // 如果物理页的ref等于1,表明只有文件服务进程使用这个物理页
// 需要注意的是此时别的用户进程也是可以用这个物理页的
// 也就是说,文件服务进程和普通进程可以同时拥有同一个fd
opentab[i].o_fileid += MAXOPEN;
*o = &opentab[i];
memset(opentab[i].o_fd, 0, PGSIZE);
return (*o)->o_fileid;
}
}
return -E_MAX_OPEN;
}

小总结

所以JOS中也是有像其他系统的进程文件描述符表与系统打开文件描述符表的,画一张图:

我们可以注意到对于一个文件,不同进程打开其后,在OpenFileTab中也会有两个不同的fd,这样做的目的是使得每个进程对自己打开的文件的读写位置等状态信息不会被其他进程影响。

JOS的read流程

先再看一下官方文档介绍的read流程图,有了上面我对open流程的介绍后,下面部分的理解将会变得很简单。

read

首先在自己的进程文件描述符表找到fd,随后执行对应设备的dev_read函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ssize_t
read(int fdnum, void *buf, size_t n)
{
int r;
struct Dev *dev;
struct Fd *fd;

// 在自己的文件描述符表找到fd
if ((r = fd_lookup(fdnum, &fd)) < 0
|| (r = dev_lookup(fd->fd_dev_id, &dev)) < 0)
return r;
if ((fd->fd_omode & O_ACCMODE) == O_WRONLY) {
cprintf("[%08x] read %d -- bad mode\n", thisenv->env_id, fdnum);
return -E_INVAL;
}
if (!dev->dev_read)
return -E_NOT_SUPP;
// 调用设备类型的read函数
return (*dev->dev_read)(fd, buf, n);
}

devfile_read

建立好ipc请求后,fs server会把成功读取的数据存放在fsipcbuf.readRet.ret_buf中,随后此函数将数据拷贝到read提供的buf处即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static ssize_t
devfile_read(struct Fd *fd, void *buf, size_t n)
{
int r;

// 建立好ipc读请求
fsipcbuf.read.req_fileid = fd->fd_file.id;
fsipcbuf.read.req_n = n;
if ((r = fsipc(FSREQ_READ, NULL)) < 0)
return r;
assert(r <= n);
assert(r <= PGSIZE);
// 把读到的内容复制一下
memmove(buf, fsipcbuf.readRet.ret_buf, r);
return r;
}

serve_read

文件服务进程接收到读请求后,会去自己的文件打开描述符表中根据fileid找到要读的文件,随后调用之前完成的file_read函数读取内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// read不应该管count大于buf大小的问题
int
serve_read(envid_t envid, union Fsipc *ipc)
{
struct Fsreq_read *req = &ipc->read;
struct Fsret_read *ret = &ipc->readRet;
struct OpenFile *o;
int r;

if (debug)
cprintf("serve_read %08x %08x %08x\n", envid, req->req_fileid, req->req_n);

// Lab 5: Your code here:
if ((r = openfile_lookup(envid, req->req_fileid, &o)) < 0)
return r;
if ((r = file_read(o->o_file, ret->ret_buf, req->req_n, o->o_fd->fd_offset)) < 0)
return r;

o->o_fd->fd_offset += r;
return r;
}

Spawn(fork + exec)

其调用树如下:

  • 使用open函数打开要执行的elf文件
  • 使用read函数从打开的文件读取elf
  • 使用sys_exofork系统调用创建一个新的进程
  • 使用init_stack把子进程的栈在父进程的UTEMP地址处建立好(重点是把spawn的函数参数建立好用于传递给要执行的程序),然后直接映射到子进程的USTACK即可,并设置好子进程的栈指针

借用一位大佬的图来解释init_stack后用户栈里长啥样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// strings is the topmost thing on the stack.
string_store = (char*) UTEMP + PGSIZE - string_size;
// argv is below that. There's one argument pointer per argument, plus
// a null pointer.
// 通过argv_store可以得知每个字符串存在哪里
argv_store = (uintptr_t*) (ROUNDDOWN(string_store, 4) - 4 * (argc + 1));
// 省略一些代码
for (i = 0; i < argc; i++) {
argv_store[i] = UTEMP2USTACK(string_store);
strcpy(string_store, argv[i]);
string_store += strlen(argv[i]) + 1;
}
argv_store[argc] = 0;
// 实际传递给子进程的两个参数如下:
argv_store[-1] = UTEMP2USTACK(argv_store);
argv_store[-2] = argc;
// 设置子进程的esp
*init_esp = UTEMP2USTACK(&argv_store[-2]);
// 省略一些代码

结合代码注释以及我在图中标注的位置可以很好地理解用户栈的布局

  • 使用map_segmentelf指定的相关段以父进程的UTEMP处作缓冲,然后映射到子进程对应的虚拟地址处,然后设置好子进程的eip
  • 上面说的设置子进程的某某寄存器都只是在child_tf这个局部变量中修改,由于我们的spawn是在用户态实现的,所以我们无法修改envs(它只允许被内核修改,子进程只能读),所以我们使用系统调用sys_env_set_trapframe来修改其。最后使用系统调用sys_env_set_status将子进程设置为ENV_RUNNABLE等待CPU进行运行

趁热讲一讲用户进程的第一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// lib/entry.S
// Entrypoint - this is where the kernel (or our parent environment)
// starts us running when we are initially loaded into a new environment.
.text
.globl _start
_start:
// See if we were started with arguments on the stack
cmpl $USTACKTOP, %esp
jne args_exist

// If not, push dummy argc/argv arguments.
// This happens when we are loaded by the kernel,
// because the kernel does not know about passing arguments.
pushl $0
pushl $0

args_exist:
call libmain
1: jmp 1b

// lib/libmain.c
void
libmain(int argc, char **argv)
{
// set thisenv to point at our Env structure in envs[].
// LAB 3: Your code here.
thisenv = &envs[ENVX(sys_getenvid())];

// save the name of the program so that panic() can use it
if (argc > 0)
binaryname = argv[0];

// call user main routine
umain(argc, argv);

// exit gracefully
exit();
}

Pipe

现在让我们探讨一下JOS中的pipe实现机制
JOS中,pipeconsolefile都是被抽象成fd,如果fddev_id表明是file,则会根据struct File结构去到正确的block;如果表明是pipe,则会去到FDTABLE + MAXFD * PGSIZE的地方,此处映射一页用于管道通信的缓存,如下图所示:fd0是读管道,fd1是写管道,当调用pipe后,会将fd0fd1的数据页共享。

管道的读/写操作时,终止条件均为对应的操作管道端已经没有进程在写/读(通过_pipeisclosed判断):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// devpipe_read
for (i = 0; i < n; i++) {
while (p->p_rpos == p->p_wpos) {
// pipe is empty
// if we got any data, return it
if (i > 0)
return i;
// if all the writers are gone, note eof
if (_pipeisclosed(fd, p))
return 0;
// yield and see what happens
if (debug)
cprintf("devpipe_read yield\n");
sys_yield();
}
// there's a byte. take it.
// wait to increment rpos until the byte is taken!
buf[i] = p->p_buf[p->p_rpos % PIPEBUFSIZ];
p->p_rpos++;
}
// devpipe_write
for (i = 0; i < n; i++) {
while (p->p_wpos >= p->p_rpos + sizeof(p->p_buf)) {
// pipe is full
// if all the readers are gone
// (it's only writers like us now),
// note eof
if (_pipeisclosed(fd, p))
return 0;
// yield and see what happens
if (debug)
cprintf("devpipe_write yield\n");
sys_yield();
}
// there's room for a byte. store it.
// wait to increment wpos until the byte is stored!
p->p_buf[p->p_wpos % PIPEBUFSIZ] = buf[i];
p->p_wpos++;
}

下面介绍_pipeisclosed,其通过pageref(fd) == pageref(p)来判断,我们知道fd0fd1的数据页是共享的,且此物理页的虚拟地址可以用p表示,所以如果在读操作中pageref(fd) == pageref(p),则说明写端已经关闭,则不可能还有数据需要读了,写操作同理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static int
_pipeisclosed(struct Fd *fd, struct Pipe *p)
{
int n, nn, ret;

while (1) {
n = thisenv->env_runs;
ret = pageref(fd) == pageref(p);
nn = thisenv->env_runs;
if (n == nn)
return ret;
if (n != nn && ret == 1)
cprintf("pipe race avoided\n", n, thisenv->env_runs, ret);
}
}

所以pipe的一个使用模板如下:

1
2
3
4
5
6
7
8
9
10
11
12
if(pid == 0){
//子进程程序
close(p[1]);
i = readn(p[0], buf, sizeof buf-1);
exit();
}
else{
//父进程程序
close(p[0]);
i = write(p[1], msg, strlen(msg));
close(p[1]);
}
  • 读之前,切记关闭写端,否则如果子进程由于某种原因用了那块虚拟地址,就会导致read由于认为还有写数据要读而陷入死循环
  • 写之后,切记关闭写端,理由同上
  • 写之前,关闭读端,否则如果写入数据大于管道缓冲区,就会认为还有进程想读(实则是其自己),从而陷入死循环

MIT6.828-Lab5-总结
http://bugeater.space/2024/04/09/MIT6-828-Lab5-总结/
Author
BugEater
Posted on
April 9, 2024
Licensed under