MIT6.828_文件系统

MIT6.828_JOS IPC(进程间通信)

进程间通信是微内核的重要组成部分,是JOS文件系统的前提,因此先来看一下JOS的IPC实现。

两个进程间通信的消息可以只是一个32位的整数,也可以使用一整个页作为消息载体。无论哪一种,都需要我们在 struct Env中额外添加下面这几个属性:

struct Env {
    ...
	bool env_ipc_recving;		// 标识当前用户进程是否在等待接受消息而阻塞
	void *env_ipc_dstva;		// 标识在哪个虚拟地址地址开始映射一个页
	uint32_t env_ipc_value;		// 传递一个32为整数就使用这个属性
	envid_t env_ipc_from;		// 发送端的进程ID
	int env_ipc_perm;		    // 用来通信的页的映射权限(只读?可读写?)
}

进程的虚拟地址空间就是为了保证隔离性而提出的,那么我们怎样实现进程间通信?有两种方式:

  • 一种方法是,直接使用Env结构作为消息传递的载体。JOS的Env数组本身存储与内核代码中,内核态可读写,用户态只读。当一个用户进程通过系统调用修改了Env结构的值时,那么另一个进程就能看到这个改变。上面Env结构的env_ipc_value就是起这个作用的,但是只传递一个值未免也太少了,因此有了下面一种方法。
  • 通过之前的学习,另一种方法的实现很容易理解,即使得两个进程的虚拟地址映射到同一个物理地址。于是尽管进程间使用的虚拟地址可能不同,但是修改的是同一块物理内存,所作的修改同样会被另一个进程看到。

JOS同时实现了两种方式,JOS通过两个系统调用和两个用户包装函数实现。下面是库函数的两个包装函数。

// ipc_recv库函数,包装了sys_ipc_recv系统调用
// envid_t *from_env_store:为传出参数,标识收到了哪个进程的消息
int32_t
ipc_recv(envid_t *from_env_store, void *pg, int *perm_store)
{
        int r;
        if (pg == NULL) /// 如果pg == null,说明只使用env结构中的一个32位整数做消息载体
                r = sys_ipc_recv((void *)UTOP);
        else    // 否则使用一个页传递消息
                r = sys_ipc_recv(pg);
        if (from_env_store != NULL) // 使用env结构中的env_ipc_from属性查看是哪个进程传递信息的
                *from_env_store = r < 0 ? 0 : thisenv->env_ipc_from;
        if (perm_store != NULL)     // 使用env结构中的env_ipc_from属性查看页面映射的权限
                *perm_store = r < 0 ? 0 : thisenv->env_ipc_perm;
        if (r < 0)
                return r;
        else
                return thisenv->env_ipc_value;
}

// 使用while循环调用sys_ipc_try_send系统调用,尝试发送信息
void
ipc_send(envid_t to_env, uint32_t val, void *pg, int perm)
{
        int r;
        void *dstpg;

        dstpg = pg != NULL ? pg : (void *)UTOP;
        // 等待对端接受
        while((r = sys_ipc_try_send(to_env, val, dstpg, perm)) < 0) {
                if (r != -E_IPC_NOT_RECV)
                        panic("ipc_send: send message error %e", r);
                // 调用sys_yield()不浪费CPU
                sys_yield();
        }
}

接下来看两个内核中的系统调用处理函数,分别对应发送和接收进程:

// 接收消息,如果通过页来传送,则映射到dstva这个虚拟地址上
static int
sys_ipc_recv(void *dstva)
{.
	// 如果小于UTOP,那么表示两个进程通过映射同一个页进行通信
	// 此时dstva必须页对齐
	if (dstva < (void *)UTOP && PGOFF(dstva))
			return -E_INVAL;
	curenv->env_ipc_recving = true; // env_ipc_recving标识接收进程是否在等待接收消息,此时置为true,表示接收端在等待
	curenv->env_ipc_dstva = dstva;  // 表示本接收端如果要通过页通信,则通过dstva这个虚拟地址进行通信
	curenv->env_ipc_from = 0;       // env_ipc_from这个标识表示本接收端对应的发送端进程号,但是现在将它初始化位0,表示还没有对应的发送端
	// 将本进程阻塞
	curenv->env_status = ENV_NOT_RUNNABLE;
	sched_yield();
	return 0;
}

sys_ipc_recvd实现比较简单,只是简单地设置了几个属性,尤其注意当进程在调用sched_yield();前将自身的状态改为了ENV_NOT_RUNNABLE,则表示进程将自己挂起,此后不会被操作系统的调度系统选中,除非发送端唤醒了本进程。

而发送端系统调用sys_ipc_try_send,就比较复杂,它还要进行一些列的权限检查。

// envid: 给envid进程发送消息
// value: 传输的32位整数
// srcva: 如果srcva不等于0,表示发送端通过页面传输消息
static int
sys_ipc_try_send(envid_t envid, uint32_t value, void *srcva, unsigned perm)
{
	int r;
	pte_t *pte;
	struct PageInfo *pp;
	struct Env *env;

	if ((r = envid2env(envid, &env, 0)) < 0)
			return -E_BAD_ENV;
	if (env->env_ipc_recving != true || env->env_ipc_from != 0) 
			// 接收端已经在接受别的进程的消息了,告知用户进程。注意这不是错误!
			return -E_IPC_NOT_RECV;
	if (srcva < (void *)UTOP && PGOFF(srcva)) 
			// srcva没有页对齐,错误
			return -E_INVAL;
	if (srcva < (void *)UTOP) {
			if ((perm & PTE_P) == 0 || (perm & PTE_U) == 0)
					return -E_INVAL;
			if ((perm & ~(PTE_P | PTE_U | PTE_W | PTE_AVAIL)) != 0)
					return -E_INVAL;
	}
	// 尝试到发送端进程的srcva对应的物理页面
	if (srcva < (void *)UTOP && (pp = page_lookup(curenv->env_pgdir, srcva, &pte)) == NULL)
			// 没找到,错误
			return -E_INVAL;
	// 找到了srcva的物理页
	if (srcva < (void *)UTOP && (perm & PTE_W) != 0 && (*pte & PTE_W) == 0)
			// 但是perm权限与被发送物理页的pte的权限不一致,即perm可写,但是被发送物理页对应的pte不可写,错误
			return -E_INVAL;
	if (srcva < (void *)UTOP && env->env_ipc_dstva != 0) {
		// 将这个物理页面插入到接收端进程指定的虚拟地址处
			if ((r = page_insert(env->env_pgdir, pp, env->env_ipc_dstva, perm)) < 0)
				// 但是物理空间不足,错误
					return -E_NO_MEM;
			env->env_ipc_perm = perm;
	}

	env->env_ipc_from = curenv->env_id;
	env->env_ipc_recving = false;
	env->env_ipc_value = value;
	env->env_status = ENV_RUNNABLE; // 唤醒接收端!
	env->env_tf.tf_regs.reg_eax = 0;
	return 0;
	// panic("sys_ipc_try_send not implemented");
}

第一种方式比较简单,下面我将分析第二种IPC的实现方法。下面从接收者和发送者两个视角梳理一下第二种方式的进程通信的流程。

接收端的视角

  1. 接受端在本进程Env结构体中记录相应的值、
    • 将本进程的状态设置为正在等待消息,Env.env_ipc_recving = true
    • 记录本进程通过哪个虚拟地址页面进行通信,Env.env_ipc_dstva
    • 将本进程的状态改成,ENV_NOT_RUNNABLE
  2. 调用sched_yield() 将自己阻塞,能够被阻塞是因为在上一步中将自己的状态改成了ENV_NOT_RUNNABLE,不会被CPU调度器选中。
  3. 等待发送端将本进程的状态修改为 ENV_RUNNABLE,这相当于“唤醒”了本进程。本进程会在之后的CPU调度中被执行。
  4. 一旦接收端恢复执行,说明发送端已经已经帮我们建立好了env_ipc_dstva与物理地址的映射,接收端从这个地址进行读操作就可以读出发送端发送的数据

发送端的视角

  1. 发送端循环等待接收端env结构的env_ipc_recving 为true。循环等待是为了等待接收端做好接受准备,如果对方没有做好接收的准备,发送端调用sched_yield(),这与接收端不同的是,并没有将发送端的进程状态改成ENV_NOT_RUNNABLE,因此发送端不会阻塞
  2. 发送端使用page_look_up找到要发送页面虚拟地址(srcva)对应的物理地址。取出接收端env结构中的env_ipc_dstva,使用page_insert在接收端的pgdir中插入一个pte使得env_ipc_dstva映射到同一个物理地址
  3. 修改接收端Env结构的值
    • env_ipc_from,等于本进程的envid,让接收端知道是那个进程对它发送了信息
    • env_ipc_recving = false;作用在 第1. 中已经阐述。
    • env->env_status, 修改为ENV_RUNNABLE,唤醒接收端
    • env->env_tf.tf_regs.reg_eax,修改为0。 让接收端的系统调用返回值为0,表示接收信息成功

Q:修改Env结构的值的这几个操作,为什么不会产生竞争,为什么没有用锁\原子指令实现它?

  • A:只有在内核态中才能修改envs数组中元素,在某一时刻即时有多个进程在执行,但只能有一个进程会执行内核代码(JOS使用大内核锁),且同一个CPU上不会有时钟中断打断这个操作(JOS中,只要从用户态进入内核态,IF一定是关闭的)。因此只要本进程能进入内核态,就说明本进程拿到了大内核锁,且不会被中断打断!因此可以无顾虑地修改它。

关于envs数组:ipc过程中,用户进程需要读取自己或者对方env结构,这些都在envs数组中。怎么做到内核能够读写,但是用户态却只能读呢?

首先明白envs这个变量是一个指针,指向内核代码中struct Env数组的起始地址。但是envs在内核与用户代码中的虚拟地址是不同的。

在内核中,我们早在mem_init的时候就为env数组分配了实际物理空间,且与其他内核代码一起映射到了kernelbase这个虚拟地址之上,它的映射权限设置为:内核有读写权限,但是用户态不能访问。

// 映射内核,权限为可读写
boot_map_region(kern_pgdir, KERNBASE, 0xffffffff - KERNBASE, 0, PTE_W );

而我们在进入用户态后的第一个程序位于lib/entry.S中,它首先改变envs变量的值:

.globl envs
.set envs, UENVS # envs的值等于UENVS,这在kernel base之下

UENVS也是我们在mem_init映射的一个虚拟地址,它指向的确实是内核中的struct Env数组,但是权限设置是只读属性:

boot_map_region(kern_pgdir, UENVS, PTSIZE,PADDR(envs),PTE_U);

总结:内核与用户态的envs变量的虚拟地址是不同的(它们不同是因为),但是都映射到了相同的物理地址,这个物理地址上存放内核中唯一的env数组。但是pgdir中关于两个envs变量的PTE表项的访问控制内容是不同的,内核中envs数组对内核可读写但对用户不可见,用户程序中的envs数组对内核用户都仅仅可读。

在mem_init()中的映射UENVS数组代码上方有句注释:

// Permissions:
//    - the new image at UENVS  -- kernel R, user R
//    - envs itself -- kernel RW, user NONE

一开始的时候我不能搞清楚这到底是怎么实现的,现在我能够理解老师要传达的信息了。这又是分页机制的一项妙用,即不同虚拟地址指向相同物理地址,但是它们的访问权限却不同,这个特性能够很好地保证内核代码的"统治地位",又不至于让用户程序一无所知.

MIT6.828_JOS文件系统

直到看了JOS文件系统的实现方法,我才确定JOS在分类上不属于“宏内核”,它的组织结构很像微内核结构。

微内核”就是把原来在宏内核中做的工作抽出一部分来,放在用户态工作。比如文件系统,就可以作为单独的一个进程运作,而其他用户进程如果想要使用文件相关功能(如open、read等),就得使用内核提供的IPC功能与文件服务进程通信,由于进程的隔离性,这个过程势必要用到许多内存共享(即两个进程的不同虚拟地址映射到同一物理地址)。示意图如下,图源

image-20221110195903761

但是按照老师在实验指导中的说法,JOS是一个exokernel(外内核?)结具体与微内核有什么区别,我就没有深入了解了。

对应的lab5的任务是比较轻松的,但这是建立在老师们几乎已经完成了整个文件系统框架基础上的。虽然我们的任务比较轻松,也需要一点时间去看一下具体实现。而且虽然JOS已经简化了文件系统(比如没有inode层),但因为它的实现方式与宏内核不太一样,所以就算是看懂它的代码也是不容易的。

由于xv6 linux都是宏内核的实现,所以JOS的文件系统我不打算整理太多,我想就记录一下基本数据结构,文件服务进程的进程空间排布,以及分析如果一个进程open()一个文件,它和文件服务进程的交互过程是怎样的,其中的数据结构会起什么作用。

磁盘上的数据结构

本节介绍JOS文件系统在物理磁盘上的数据结构布局。

sectors和block

  • 磁盘读写的最小单位是一个sector,这是由磁盘制造厂商决定,JOS使用的硬盘的sector大小为512字节
  • 文件系统则按照block的大小来分配和存储磁盘存储空间(在内存中),这是由操作系统编写者决定的,有很大灵活性。JOS中,一个block的大小等于一个页的大小,也就是4096字节

磁盘上的数据结构布局如下:

image-20221110210707431

  • Block0是存放OS引导程序和分区表的地方,文件系统不能用这个块

  • Block1是SuperBlock,它的一个重要作用是存放根目录的信息,解决“鸡生蛋,蛋生鸡”的问题(如果我要找一个文件,那就先找到目录,但是目录只不过是一个特殊的文件,那么为了找到这个目录,要先找到存放这个目录项的目录。。。)

    struct Super {
    	uint32_t s_magic;		// Magic number: FS_MAGIC
    	uint32_t s_nblocks;		// Total number of blocks on disk
    	struct File s_root;		// Root directory node, 根目录的信息在这里
    };
    
  • Bitmap block, 存放一个array,每一个位标志着磁盘上的block是否空闲

  • 其余的块就用来存放文件\目录信息,也即struct File结构

struct File

由于JOS不支持硬连接(多个文件名指向相同的inode),所以不需要有inode这一间接层,将原本inode所记录的信息,全部放在了struct File结构中,且struct File同时存在于硬盘和内存中

struct File {
	char f_name[MAXNAMELEN];	// 文件名
	off_t f_size;			// 文件大小,单位:字节
	uint32_t f_type;		// 用来区别目录文件和普通文件

	// Block pointers.
	// A block is allocated if its value is != 0.
	uint32_t f_direct[NDIRECT];	// direct blocks
	uint32_t f_indirect;		// indirect block

	// Pad out to 256 bytes; must do arithmetic in case we're compiling
	// fsformat on a 64-bit machine.
	uint8_t f_pad[256 - MAXNAMELEN - 8 - 4*NDIRECT - 4];
} __attribute__((packed));	// required only on some 64-bit machines

f_direct数组的每个元素都指向了磁盘上具体的块号,表示这个文件的具体数据在这些block中。f_direct数组存放10个元素,也只能指向10个block,总共有 10 * 4096 = 40KB,因此对于小于40KB大小的小文件只用f_direct数组就可以完全标识它的所有数据块。但是超过40KB后,就需要“间接块指针”f_indirect来协助。f_indirect指向一个block,但是这个block并不存放具体数据,而是存放块指针,这些块指针指项超过40KB的部分。

image-20221110212114847

关于IDE驱动:JOS的实现采用轮询方法, 基于"programmed I/O" (PIO)来进行磁盘存取操作。

  • 为什么采用轮询而不使用磁盘中断异步存取的原因 -- JOS的架构是exokenel而不是monolithic kernel(宏内核),如果采用中断的方式,当内核在收到一个磁盘的中断时,它还要将这些中断“分发”到对应的用户程序中,这是比较困难的。
  • 由于文件服务程序需要直接操作硬盘,因此它应该被赋予IO权限(I/O privilege)。这又用到了EFLAGS寄存器,它的IOPL这个位就标识了当前用户进程是否又资格访问 "IO space"。(对应 lab5的exercise1)但是为了安全,其他进程却不能有IO权限,因此只能由文件服务进程轮询磁盘,而不能让普通进程操作磁盘。
  • 与JOS不同,xv6则是一个宏内核,因此很容易能够实现中断方式异步磁盘存取

IO特权级

x86使用eflgs中的IOPL标志(第12和13位)表示当前任务的IO特权级,正在运行进程的当前特权级(CPL)必须小于或等于I/O(也就是特权级比IPPL高)特权级才能允许访问I/O地址空间,否则进制访问IO地址空间,典型的表现为不能使用 IN、OUT指令进行磁盘读写。

image-20230303170731626

image-20230303170811404

EFLAGS中默认的IOPL为0(表示只有在最高特权下(内核态)才能访问IO空间),用户态的特权级为3,因此在默认情况下用户不能访问IO空间。但是开头已经说过,JOS的文件服务不在内核中实现,而是通过用户进程实现,因此我们必须要改变文件服务进程的EFLAGS的IOPL位,使其为3。

相关的代码在create_env()函数中:

void
env_create(uint8_t *binary, enum EnvType type)
{
	// LAB 3: Your code here.

	struct Env* e;
	int ret;
	if((ret = env_alloc(&e, 0)) < 0)  {
		 panic("env.c : env_create failed: env_alloc failed.\n");
	}
	e->env_type = type;
	// 如果这个进程是文件服务进程,那么就赋予它IO特权级
	if (type == ENV_TYPE_FS) {
		e->env_tf.tf_eflags |= FL_IOPL_MASK; // FL_IOPL_MASK = 0x00003000, pop eflags 时将其第12、13位修改为3.
	}
	load_icode(e, binary);

}

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

在JOS中文件服务程序是一个特殊的用户态程序,它的地址空间布局做了特殊的规划。下面将先看看文件服务程序的地址空间。

文件服务程序的地址空间在USTACKTOP以上,UTEXT以下的布局是和其他用户程序都相同的,不同的仅仅是[UTEXT,USTACKTOP]这段用户空间的布局。

JOS的文件服务程序能够处理磁盘空间的大小为3GB,因此它会将虚拟地址的0x10000000 到 0xD0000000 的空间,用作对磁盘block的映射,以此来达到“缓存的目的”。比如说block0映射到虚拟地址0x10000000,block1映射到虚拟地址0x10001000,可以看到这只是简单的线性映射。

image-20221112220046848

注意虚拟地址空间并不与磁盘直接建立映射,这个映射只是逻辑上的,也是懒惰的---映射的建立是通过缺页处理函数来进行的

比如说,文件服务器启动时,需要获取超级块来读取根目录信息,由之前提到的简单映射关系,服务程序认为block1对应的虚拟地址是0x1000000,但是这个虚拟地址还没有与物理地址建立映射关系呢。此时发生缺页中断,交由缺页处理函数处理。然后发现异常的地址为0x1000000,先在0x1000000处映射一个实际物理页面,然后经过简单计算,它确定用户程序需要读取磁盘block1的数据,因此会通过磁盘驱动程序将block1的内容读入0x1000000。具体的代码就不分析了,详见fs/bc.c/bc_pgfault()。

回到上面的图,看到有三个数据结构 struct FD数组、 opentab数组、与 union Fsipc ,其中第三个与IPC文件服务有关,放在下一节讲。先看看前两个。

  • struct FD数组记录进程已打开文件的动态信息,比如指针偏移、打开模式、设备id,

  • opentab数组连接了struct FD和struct File,将进程的动态信息与磁盘的静态信息连接在了一起

opentab数组的大小在编译时已经确定了,JOS中为1024,因此JOS最多一共可以打卡1024个不同的文件。题外话,由于opentab数组是编译期确定的,因此它的物理映射早在进程运行开始前就已经由操作系统完成了,它属于elf文件的data段(看看env_create()中的load_icode)。

opentab中的元素与struct FD是一一对应的,这在服务启动的时候就已经确定了,但是Fd对应的物理空间却不会立即分配,得等到由进程open时,才会分配实际的物理地址:

void
serve_init(void)
{
	int i;
	uintptr_t va = FILEVA;// 这就是 struct fd数组的首地址
	for (i = 0; i < MAXOPEN; i++) {
		opentab[i].o_fileid = i;
		opentab[i].o_fd = (struct Fd*) va; // 一一对应!!
		va += PGSIZE; // Fd数组的每个元素占用空间大小为PGSIZE
	}
}

另外,从上述代码可以看出,Fd数组的每个元素占用空间大小为PGSIZE

下图表示了opentab数组如何将 struct File结构与struct Fd结构联系起来,Struct Fd存储动态信息,而Struct File存储金泰信息。其中Fd.fd_dev_id表示了文件的类型。

JOS与linux一样,将一切视为文件,包括输入输出和管道,为例了简单,图中并没有体现这一点。

image-20221112222448061

现在了解了如何将动态与静态数据联系在一起,下一步就是了解JOS怎样将动态信息与具体的某一个进程相联系。毕竟struct FD数组是在文件服务进程中的,而且struct FD数组的各元素不可能属于同一个一个用户进程,如何做到将不同进程的资源隔离呢?我将在下一节整理这些内容

JOS中的open()

首先看看linux是怎么把进程fd资源与其他进程隔离的。如下图所示,每个进程都有自己的一个文件描述符表,操作系统也又一个文件描述表。而进程中文件描述符表并没有多少有用的信息,它只是指向了操作系统维护的一个状态。这样即时不同进程使用字面上(描述符是个int类型的整数)相同的描述符,它们能够获取的状态信息是不同的。

image-20221112213714700

回到JOS中,它的opentab数组可以类比成linux中的系统级文件表,只不过opentab由文件系统这个特殊用户态进程维护,而linux中由内核维护。

JOS,有没有类似linux的属于进程的文件描述符表,由这个表我们可以联系到opentab数组的元素,这样每个进程也就隔离地获得它们各自的文件内容?

答案是,有的,但只在逻辑上存在。而且在实现细节上,非常不一样。下图是官方lab指导页面的关于read的执行流程,虽然是read,但是与open的流程是类似的。

image-20221112215642910

先看看lib/file.c中的open()函数,它由普通进程调用

// 打开路劲为path的文件
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) // 在0xD0000000开始的虚拟地址之上找到一个还没有被映射的虚拟地址作为Struct Fd
		return r;

	strcpy(fsipcbuf.open.req_path, path); // 将需要打开的文件名复制到fsipcbuf中
	fsipcbuf.open.req_omode = mode; // 设置文件打开模式
	// fd的值现在等于0xD0000000以上的一个还没有建立映射的页面首地址
	if ((r = fsipc(FSREQ_OPEN, fd)) < 0) { // 调用fsipc使用IPC机制请求文件服务进程为我们打开文件
		fd_close(fd, 0);
		return r;
	}

	return fd2num(fd); // 返回文件描述符
}

首先调用fd_alloc分配一个还没有使用的fd,fd_alloc在lib/fd.c中:

#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;
}

这个函数看上去非常怪异,它没有从一个所谓的fd表中分配一个描述符,而是在0xD0000000开始的虚拟地址之上(地址空间为普通进程),找一个还没有建立物理映射的页,把这个页当作是struct Fd返回。

回到上面的open函数中,分配好struct Fd后,调用fsipc(FSREQ_OPEN, fd)函数请求文件服务请求打开名为path(它存储在fsipcbuf.open中)的文件并将Struct Fd结构的物理页面映射到fd这个虚拟地址上。

fsipc函数的实现如下,结合上一章的IPC机制,应该比较容易理解,值得一提的是,文件系统的IPC调用即使用了一个32为整数作为文件服务的类型,也使用了一个页作为文件名字符串的载体共享给文件服务进程。

// type: 标识哪种文件服务,在这个例子中则指打开文件操作
static int
fsipc(unsigned type, void *dstva)
{
	static envid_t fsenv;
    // 首先找到文件服务程序的进程ID
	if (fsenv == 0)
		fsenv = ipc_find_env(ENV_TYPE_FS);
	static_assert(sizeof(fsipcbuf) == PGSIZE);
	// 共享页&fsipcbuf开始的页面
	ipc_send(fsenv, type, &fsipcbuf, PTE_P | PTE_W | PTE_U); // 发送ipc消息给文件服务进程,请求它打开文件
	return ipc_recv(NULL, dstva, NULL); // 调用ipc_recv等待文件服务进程将它对应的物理页共享给本进程
}

然后再看看文件服务进程是怎么响应这个请求的:

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 {
            //....
        }
		ipc_send(whom, r, pg, perm);
		sys_page_unmap(0, fsreq);
	}
}

文件服务进程在OS初始化后便启动了,之后它会运行上面的serve函数,serve函数由一个while循环,一直调用ipc_recv阻塞等待其他进程发送文件服务请求。接下来分析serve_open函数:

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'

	// 调用openfile_alloc在文件服务进程中分配一个struct fd 将其记录在opentab中
	if ((r = openfile_alloc(&o)) < 0) {
		return r;
	}
	fileid = r;

	// ... 接下// 
}

首先serve_open保存文件名,然后调用openfile_alloc函数在opentab中找到一个可以用的OpenFile结构:

// Allocate an open file.
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:// 这个Struct Fd 还没建立物理映射
			// 这里才时即意义上的分配一个 struct fd的物理空间
			// 这里不能用缺页处理函数进行懒分配,因为本进程只对 0x10000000 ~ 0xD0000000范围内的虚拟地址执行缺页处理
			if ((r = sys_page_alloc(0, opentab[i].o_fd, PTE_P|PTE_U|PTE_W)) < 0)
				return r;
			// 注意这里没有break!
		case 1: // 如果物理页的ppref等于1,表示只有文件服务进程在使用这个物理页,这也是能够分配出去的。
			opentab[i].o_fileid += MAXOPEN;
			*o = &opentab[i];
			memset(opentab[i].o_fd, 0, PGSIZE); // 清空这个struct fd
			return (*o)->o_fileid;
		// 如果ppref > 1,表示有一个普通进程在使用这个fd
		}
	}
	return -E_MAX_OPEN;
}

判断opentab各元素是否可用,主要通过判断该Struct Fd有没有被其他进程使用过为标准,即当pageref返回值 > 1时表示有其他进程在使用这个元素,不能选取它。当pageref返回0时,表示这个struct FD还没有建立物理映射,那么先分配一个物理页并映射它。

回到上面的serve_open函数,openfile_alloc

int
serve_open(envid_t envid, struct Fsreq_open *req,
	   void **pg_store, int *perm_store)
{
	//..接上//
	// 打开文件
	if (req->req_omode & O_CREAT) { 
		// 略过
	} else {
try_open:
		if ((r = file_open(path, &f)) < 0) { // 这里调用fileopen将struct File结构读到文件服务程序中去。
			// ...
			return 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 = o->o_fd; // pg_store是serve端返回给发送端的虚拟页面地址,IPC机制会自动为发送端建立物理映射,来共享本进程的struct FD
	*perm_store = PTE_P|PTE_U|PTE_W|PTE_SHARE; // 注意这里的PTE_SHARE标记,表示如果进程要fork或者spawn,需要和它的父进程共享

	return 0;
}

最后的最后,再返回serve函数中,调用ipc_send库函数,将文件服务进程的Struct Fd结构共享给服务请求进程:

void
serve(void)
{
    	// ... 
		ipc_send(whom, r, pg, perm); // 将文件服务进程的Struct Fd结构共享给服务请求进程
		sys_page_unmap(0, fsreq);
	}
}

还是来画一张图吧,描述一下文件描述符表的逻辑指向。

假设普通进程1打开了文件A、B, 普通进程2打开了文件B、C,它们与文件服务进程的逻辑关系如下所示:

image-20230301151756505

所以我在开头说,JOS在逻辑上同样有进程级别和系统级别的文件描述符表,只不过进程的那张表有可能不连续,系统级别的那张表也不再内核中而是在文件服务进程的地址空间中。

JOS其他相关的文件操作就不一一介绍了,实在是精力有限,接下来的内容全部留给xv6的文件系统。

xv6_文件系统

接下来我想大致整理一下xv6的文件系统,有几点原因:

  • 相比JOS的文件系统,xv6的文件系统更接近linux,当然是在说整体架构差不多,linux的文件系统非常复杂

    xv6的文件系统比JOS多了日志的处理,听起来就高大上 :)

  • 文件系统使用缓冲区对磁盘内容进行缓存,这有许多好处,也有一点坏处。理解缓冲区的工作原理,我觉得还是有必要的。

之前看的《操作系统真象》中有类似的文件系统,因此我主要将目光聚焦在xv6的硬盘驱动、缓冲层和日志层,其他关于file和inode的组织是差不多的。

xv6对于硬盘的使用规划如下所示:

image-20221116195948821

MIT官方xv6指导手册对文件做了很好的概述,我把它翻译过来:

块0存放操作系统的boot相关程序,文件系统不应该使用它。块1则是超级快,存放文件系统的元信息(以block为单位的文件系统大小,data数据块的数量,inode的数量,log使用的block数量等)。从2开始的block存放日志。在日志后面存放inode的磁盘数据,每个block有多个inode。bitmao跟踪数据块是否被使用。剩下的block全部作为数据块,它们中的每一个都对应了bitmap中的一位,存放一个文件或目录的数据。

xv6的文件系统一共有7层:图源

image-20221115225823282

  • Disk: 读写硬盘的block(block != sector)
  • buffer cache: 缓存磁盘的block,并同步多线程对缓存块的获取
  • logging:允许上层操作多个block并整合成一个“事务”,保证事务操作的原子性,是崩溃恢复的底层机制
  • inode: 每个文件都表示为一个具有唯一编号的inode节点和一些保存文件数据的数据块
  • directory: 使用特殊的inode表示文件目录,目录的内容是一些列目录项,目录项则包含文件名和inode编号
  • pathname:比入像/usr/local/bin这样的路径名,用来递归地查找文件。有了这一层的抽象,可以方便实现硬链接(即不同的文件名指向相同的inode)
  • file descriptor: 对Unix系统的许多资源的一个抽象(比如文件、管道、控制台等),简化应用开发

官方给的手册,一开始读得很懵。读了《linux内核设计的艺术》后有点感觉了,但是让我再整理一遍xv6的文件系统,也还是无从下手。

想想还是按照手册的顺序来吧。

硬盘驱动程序

显然免不了要学习硬盘的IO指令和相应的寄存器映射,但是我又不想说的过多(也不是很懂),所以就挑代码中出现的指令进行注释,详细的介绍就不再展开了。推荐入门资料:

  • @Rand312
  • 《操作系统真象》相关章节

从总体架构看,因为xv6是宏内核架构,和JOS不同,它很容易得能够实现以中断为核心的硬盘驱动程序:进程向磁盘下达指令后陷入睡眠等待磁盘的完成操作,硬盘处理完成后将发出中断,内核在完成中断处理后唤醒对应的进程。

关于磁盘操作的部分的代码集中于 ide.c这个文件。除了 ideinit()这个函数在CPU初始化时被调用,其他函数都在内核需要与磁盘交互时被调用。

static struct spinlock idelock;
...
ideinit(void)
{
    // 初始化操作
  int i;
  initlock(&idelock, "ide"); // 初始化 idelock
  ioapicenable(IRQ_IDE, ncpu - 1); // 指定最后一个CPU处理硬盘中断
  idewait(0); 
  // 默认0号磁盘是存在的, Check if disk 1 is present
  outb(0x1f6, 0xe0 | (1<<4));
  for(i=0; i<1000; i++){
    if(inb(0x1f7) != 0){
      havedisk1 = 1;
      break;
    }
  }
  // Switch back to disk 0.
  outb(0x1f6, 0xe0 | (0<<4));
}

值得注意的是ioapicenable(IRQ_IDE, ncpu - 1);这一行,指定了一个CPU处理硬盘中断。

当上层需要磁盘数据时,调用iderw()函数:

// idequeue指向当前要执行的buf
// idequeue->next指向下一个要执行的buf
// struct buf的next指针形成了一个等待队列
static struct buf *idequeue;
void
iderw(struct buf *b)
{
  struct buf **pp;
  if(!holdingsleep(&b->lock)) // 调用iderw前,要求进程已经获得了 buf的锁
    panic("iderw: buf not locked");
  if((b->flags & (B_VALID|B_DIRTY)) == B_VALID)
    panic("iderw: nothing to do");
  if(b->dev != 0 && !havedisk1)
    panic("iderw: ide disk 1 not present");
  acquire(&idelock);  //获取ide锁,本文件定义的一个static自旋锁,见上面那段代码
  // Append b to idequeue. 将buf添加到等待队列的末尾
  b->qnext = 0;
  for(pp=&idequeue; *pp; pp=&(*pp)->qnext)  // idequeue也是本文件开头定义的一个数据结构,
    ;
  *pp = b;
  // 如果等待队列中只有b,那么现在就开始执行
  if(idequeue == b)
    idestart(b);
  // 本进程阻塞并等待硬盘操作完成
  while((b->flags & (B_VALID|B_DIRTY)) != B_VALID){
    sleep(b, &idelock);
  }
  release(&idelock);
}

这就是为什么文件系统相关代码比较难读原因,就这一个函数涉及到了好多东西

  • 自旋锁、sleep
  • struct buf结构,充当与磁盘交互的缓冲块,一方面保护硬盘,另一方面加快CPU读硬盘的过程(无论什么层次的缓存,最大作用就是这个)
  • 等待队列

自旋锁比较简单,xv6使用关中断 + xchg原子硬件指令实现自旋锁。

sleep与JOS的把本进程状态改成SLEEPING后调用yield()的情况差不多,会把本进程投入睡眠,如果没有其他进程“唤醒”,本进程不会继续执行。

当然在xv6中,sleep的行为更类似条件变量,它会传入一个已经获取了的自旋锁,在睡眠前它会释放自旋锁,然后在本进程的proc结构中标识自己当前在哪个东西上睡眠(proc->chan),修改进程状态为sleeping,然后调用shed()陷入睡眠。在被唤醒开始执行后,它要做的第一件事就是重新获取这把自旋锁。上述代码中idelock就对应了这把自旋锁。

然后是struct buf结构:

struct buf {
  int flags; 
  uint dev;
  uint blockno;     // 对应磁盘上的块号
  struct sleeplock lock;
  uint refcnt;      // 进程读写完缓冲区后,refcnt--。当refcnt为0时,表示没有进程与缓冲块关联。但这个缓冲块依然和磁盘的对应块关联
  struct buf *prev; // LRU cache list
  struct buf *next;
  struct buf *qnext; // disk queue, ide.c
  uchar data[BSIZE]; // 位于内存的对磁盘一个扇区的内容的拷贝。BSIZE 与磁盘的一个扇区的大小相同即512字节
};
#define B_VALID 0x2  // buffer has been read from disk
#define B_DIRTY 0x4  // buffer needs to be written to disk

主要关注struct buf *qnext这个成员,在硬盘中断服务程序中,由qnext指针串联起一个等待队列。硬盘读入的内容读取到buf的data数组中,至于其他的的字段在之后的分析中会反复提到。

回到iderw()函数,它将buf结构加入到等待队列后,判断刚刚加入的那个buf是不是等待队列的唯一请求,如果是的话就调用idestart,然后sleep等待硬盘完成操作。

iderw其实就做了两件事:把请求加入队列,然后去睡觉等着磁盘叫醒,当调用iderw的进程被唤醒,则表示硬盘已经将数据写入到了对应的buf结构中。

idestart函数则是对硬盘进行操作,在进行了一系列硬盘控制字操作后(要注意 outb(0x3f6, 0),没有这一行不会使每次磁盘操作完成后都产生中断),根据buf中的dirty标识判断要执行什么操作。

如果是写操作,那么立刻就用out命令写磁盘,

但如果是读操作却没有立刻将数据读入buf,而是先告知磁盘把数据准备到磁盘自己的缓冲区中,准备好后磁盘发出中断让CPU正式读取读取数据。

// Start the request for b.  Caller must hold idelock.
static void
idestart(struct buf *b)
{
  if(b == 0)
    panic("idestart");
  if(b->blockno >= FSSIZE)
    panic("incorrect blockno");
  int sector_per_block =  BSIZE/SECTOR_SIZE;
  int sector = b->blockno * sector_per_block; // 计算要硬盘被操作的对应扇区
  int read_cmd = (sector_per_block == 1) ? IDE_CMD_READ :  IDE_CMD_RDMUL; //一个块包含多个扇区的话就用读多个块的命令
  int write_cmd = (sector_per_block == 1) ? IDE_CMD_WRITE : IDE_CMD_WRMUL;//一个块包含多个扇区的话就用写多个块的命令
  if (sector_per_block > 7) panic("idestart");

  idewait(0);
  outb(0x3f6, 0);  // generate interrupt,告知磁盘每次命令完成之后要产生中断。
  // ....一系列硬盘寄存器操作
  // ...
  // 
  if(b->flags & B_DIRTY){  // 如果要执行写操作
    outb(0x1f7, write_cmd);
    outsl(0x1f0, b->data, BSIZE/4);
  } else { // 如果要执行读操作
    outb(0x1f7, read_cmd);// 需要让磁盘把所需数据准备到它自己的缓冲区中
    // 真正的读操作在ideintr这个中断处理函数中,它从磁盘的缓冲区读到内存中
  }
}

到目前为止,都是CPU向磁盘发出命令,无论是写磁盘还是读磁盘,上层调用者都会被阻塞。当磁盘控制器将数据写入磁盘完成,或者磁盘将数据准备好放在了它的缓存区,磁盘将发出中断信号,CPU则响应硬盘中断,经过一些列的现场保存以及中断服务程序定位后,最终会执行磁盘中断处理函数ideintr():

ideintr(void)
{
  struct buf *b;
  // 处理队列中的第一个buf请求
  acquire(&idelock);
...
    // 准备下一个请求
  idequeue = b->qnext;
  // 如果请求是读请求,读取数据到buf的data字段
  if(!(b->flags & B_DIRTY) && idewait(1) >= 0)
    insl(0x1f0, b->data, BSIZE/4);

  b->flags |= B_VALID; // 刚从磁盘同步,因此valid为1,dirty 为0
  b->flags &= ~B_DIRTY;
  wakeup(b); // 唤醒等待的进程

  // 调用idestart处理等待队列上的下一个请求
  if(idequeue != 0)
    idestart(idequeue);

  release(&idelock);
}

首先根据buf的flalg判断这是不是读请求,如果是的话就使用in指令将数据读入buf的data字段,这对应在idestart中我们没有真正读磁盘,而是让磁盘准备数据到它的缓冲区。而如果是写请求,这是时候就没有额外操作了,因为在idestart中已经执行了写磁盘操作。

其次修改buf的flag,因为无论刚刚进行的是读磁盘,还是写磁盘,此时这个缓冲块一定是有效且不脏的。

然后,唤醒等待此buf的进程。

最后调用idestart处理等待队列的下一个请求。

缓冲层

缓冲层相关的函数在bio.c文件中,主要关注bread bwrite brelse函数

缓冲层有两个作用:

  • 同步执行流对磁盘block的获取,保证只有一个对应的拷贝存在于内存中,并且同一时刻保证只有一个内核线程在使用这个缓冲块
  • 缓存热门的磁盘block,从而减少于磁盘的交互。使用lru剔除策略,尽量增加“cache hit”。

大多数内核的文件操作,明面上只跟缓冲块打交道,如果要读某个磁盘块,则算出文件block块号后调用bread;如果要写磁盘块则调用bwrite,不需要再直接调用硬盘的驱动程序。

struct buf {
  int flags; 
  uint dev;
  uint blockno;     // 对应磁盘上的块号
  struct sleeplock lock;
  uint refcnt;      // 进程读写完缓冲区后,refcnt--。当refcnt为0时,表示没有进程与缓冲块关联。但这个缓冲块依然和磁盘的对应块关联
  struct buf *prev; // LRU cache list
  struct buf *next;
  struct buf *qnext; // disk queue, ide.c
  uchar data[BSIZE]; // 位于内存的对磁盘一个扇区的内容的拷贝。BSIZE 与磁盘的一个扇区的大小相同即512字节
};
#define B_VALID 0x2  // buffer has been read from disk
#define B_DIRTY 0x4  // buffer needs to be written to disk

struct buf的sleeplock这个睡眠锁,保证了依次只有一个内核线程操作这个缓冲块。blockno则标识了这个缓冲块的内容是哪一个磁盘块的内容,为了保证内存内只有一份对应磁盘block的缓存,blockno存放磁盘的某个block在整个磁盘上的编号,是一对一的关系。

整个操作系统的缓存在操作系统被载入时就已经创建了:

struct {
  struct spinlock lock;
  struct buf buf[NBUF]; // buf队列,总共可以存储NBUF个buf结构,NBUF = 30
  // Linked list of all buffers, through prev/next.
  // head.next is most recently used.
  struct buf head;
} bcache;
void
binit(void)
{
  struct buf *b;
  initlock(&bcache.lock, "bcache");
  bcache.head.prev = &bcache.head;
  bcache.head.next = &bcache.head;
  // 初始化buf链表
  for(b = bcache.buf; b < bcache.buf+NBUF; b++){
    b->next = bcache.head.next;
    b->prev = &bcache.head;
    initsleeplock(&b->lock, "buffer");
    bcache.head.next->prev = b;
    bcache.head.next = b;
  }
}

一开始,buf全部被加入队列中,一共有30个buf结构在队列中。

image-20221116171154118

bread函数如下,返回上层调用者需要的对应磁盘block的缓冲块

// Return a locked buf with the contents of the indicated block.
// 一旦bread成功返回,那么上层调用者就获得了这个buf结构的独享使用权
struct buf*
bread(uint dev, uint blockno)
{
  struct buf *b;
  b = bget(dev, blockno);
  // 如果要读取的内容没有在内存中缓存,那么调用iderw从磁盘中读取
  if((b->flags & B_VALID) == 0) {

    iderw(b);
  }
  return b;
}

二话不说先调用bget函数:

static struct buf*
bget(uint dev, uint blockno)
{
  struct buf *b;
  acquire(&bcache.lock);
  // 一次遍历,从头到尾遍历,查找是否有缓存块已经缓存了对应的磁盘block
  // 体现缓冲层的第二个作用
  for(b = bcache.head.next; b != &bcache.head; b = b->next){
    if(b->dev == dev && b->blockno == blockno){
      b->refcnt++;
      release(&bcache.lock);
      acquiresleep(&b->lock);
      return b;
    }
  }
   // 二次遍历,从未到头,找到一个空闲的缓存块
  // Even if refcnt==0, B_DIRTY indicates a buffer is in use
  // because log.c has modified it but not yet committed it.
  for(b = bcache.head.prev; b != &bcache.head; b = b->prev){
    if(b->refcnt == 0 && (b->flags & B_DIRTY) == 0) {
      b->dev = dev;
      b->blockno = blockno;
      b->flags = 0; // 刚刚分配的缓存块,其内容不保证就是对应磁盘块的内容
      b->refcnt = 1;
      release(&bcache.lock);
      acquiresleep(&b->lock);
      return b;
    }
  }
  // 如果找不到能释放的缓冲块就panic
  // 为什么不挂起进程呢?--xv6手册:防止死锁
  panic("bget: no buffers");
}

begt循环了两次buf队列

  • 首先看一看是否磁盘块内容的缓存已经在内存中了。之前讲过,buf的内容是磁盘的某个block的拷贝,使用buf.blockno进行标记。如果找到了对应的blockno的buf,直接返回这个buf,这个buf大概率是VALID。因此调用者bread将不再使用iderw进行磁盘操作,体现了缓冲层的第二个作用。

    image-20221116172045588

  • 如果没有任何一个buf的内容缓存了对应磁盘块,那么就再一次循环buf队列,找到一个空闲的buf(引用计数为0),将这个buf的valid位清除然后返回bread。

    bread检测到valid位是0,则调用iderw()进行磁盘操作,调用bread的进程可能因此陷入睡眠。当该进程醒过来时,就代表buf的data字段存储了磁盘对应的内容。

    下面用一个矩形简单标识buf结构

    image-20221116191600293

如果有进程修改了buf的内容,那么在他释放这个buf前,应该将buf的内容同步到磁盘中,进程只需要调用bwrite即可。bwrite比较简单,它将buf的flag设置为脏后,就调用磁盘驱动程序的iderw就可以了。调用进程可能在此睡眠,待磁盘写入完成后,会由中断程序唤醒本进程。

void
bwrite(struct buf *b)
{
  if(!holdingsleep(&b->lock)) // 调用者一定是获得了这个buf的独占使用权后,才能写回这个buf
    panic("bwrite");
  b->flags |= B_DIRTY;
  iderw(b);
}

最后再看看brelse,先将refcnt递减,如果refcnt减至0,说明没有进程在使用这个缓存块了,那么就可以将这个buf移动到队列的头部

这样做有什么好处呢?请再看一下bget中两个循环的方向,第一个循环从头至尾寻找被缓存的buf,第二个循环则是从尾至头寻找空闲的buf。

这体现了lru思想,brelse将刚刚操作过的buf放入队列的头部,这表明

  • 队列头部的buf是最近最多(most recently used)使用过的缓存块,这里保存的是热点数据,所以bget的第一个循环从头开始找,由于“局部性”的影响,这可能加快寻找的效率
  • 队列尾部的buf是最近最少(least recetly used)使用的缓存块,保存的是非热点数据,所以bget的第二个循环从末尾开始找,剔除这个最不常使用buf的硬盘数据缓存。如此便将那些磁盘热点数据尽可能地在内存中多保留一会,万一缓冲块不够用时,避免剔除热点数据,让缓存层尽可能地 "cache hit"
void
brelse(struct buf *b)
{
  if(!holdingsleep(&b->lock))
    panic("brelse");

  releasesleep(&b->lock);

  acquire(&bcache.lock);
  b->refcnt--;
  if (b->refcnt == 0) {
    // 如果refcnt为0,那就真正地释放这个缓存块,将它加入空闲链表中
    // lru思想,提升bget中两个循环的效率
    b->next->prev = b->prev;
    b->prev->next = b->next;
    b->next = bcache.head.next; // 移动到空闲链表的头部
    b->prev = &bcache.head;
    bcache.head.next->prev = b;
    bcache.head.next = b;
  }
  
  release(&bcache.lock);
}

日志层

按照我的理解,日志在硬盘上的结构和逻辑指针如下所示:

image-20221117152241300

xv6的日志占磁盘的30个block,其中第一个block作为logheader存储块,其余的block存储文件的数据,这些数据块是磁盘data部分的block的一份最新拷贝,其对应关系由logheader的一个整型数组记录,数组元素的数值对应磁盘的blockno。

struct logheader {
  int n;
  int block[LOGSIZE];
};

比如说 logheader.block[1] = 50,则代表logheader块后的第一个block存放的数据应该刷新至50号block中。

内存中也有一个结构struct log来管理日志系统的动态执行,它除了有磁盘logheader的一份缓存外,它还有许多字段来控制整个文件系统对日志的使用

struct log {
  struct spinlock lock;
  int start; // 磁盘上存放日志内容的起始block号
  int size;  // 磁盘上总共有多少block存放日志
  int outstanding; // 记录有多少个文件系统相关的系统调用正在执行
  int committing;  // 表示正在提交的标志位
  int dev;
  struct logheader lh;
};
struct log log;

log结构的构建在初始化阶段完成,读入超级块,超级快包含有log的部分信息,然后对内存中的struct log结构的各个字段赋值:

void
initlog(int dev)
{
  if (sizeof(struct logheader) >= BSIZE)
    panic("initlog: too big logheader");

  struct superblock sb;
  initlock(&log.lock, "log");
  readsb(dev, &sb);
  log.start = sb.logstart;
  log.size = sb.nlog;
  log.dev = dev;
  recover_from_log(); // 日志恢复,后面再讲
}

日志系统向上层提供的函数有三个

  • begin_op() : 开启一个事务
  • end_op() : 事务结束,提交
  • log_write() :是bwrite的一个代替,实际上将bwrite操作延迟到了commit阶段,文件系统的上层操作在写入磁盘时应该调用log_write()而不是bwrite。(上层的读操作还是直接使用bread来读硬盘文件。)

在一个系统调用中,对日志系统的典型使用代码片段长这样:

begin_op() ;
...
bp = bread();
bp->data[...] = ...
log_write(bp);
...
end_op();

begin_op

begin_op(void)
{
  acquire(&log.lock);
  while(1){
    if(log.committing){ // 正在执行提交操作
      sleep(&log, &log.lock);
    } else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE){
      // this op might exhaust log space; wait for commit.
      sleep(&log, &log.lock);
    } else {
      log.outstanding += 1;
      release(&log.lock);
      break;
    }
  }
}

取得log.lock锁后,首先得等其他的日志的提交操作执行完成,即等待log.committing字段为1,否则将本线程投入睡眠。

在这里先简单讲一下sleep(),调用它会将本线程投入睡眠,如何实现sleep呢?其实在JOS的IPC通信那里,我们已经知道了,将进程状态设置为NOT_RUNNABLE,然后调用sched()运行别的进程,而本进程则因为进程状态不为RUNNABLE,之后的CPU调度器再也不会选择本线程运行----这就是sleep的原理。xv6的sleep实现其实在原理上是差不多的,只不过xv6的内核锁很细致,所以多了很多锁操作。那么唤醒进程(wakeup)如何实现? 很简单由其他进程设置本进程的进程状态为RUNNABLE即可,调度器自然而然地会选中本进程然后运行。

sleep()被调后,本进程马上投入睡眠;其他进程wakeup()本进程后,则需要等待调度器调度本线程。所以在效果上,wakeup的调用效果可能要稍微等一会才能被看见。

然后要限制日志块的使用量在30(LOGSIZE)以内。logheader中的n字段记录当前日志已经被使用了多少,log.outstanding表示当前系统中有多少个文件系统调用操作正在执行, MAXOPBLOCKS表示一次文件系统调用最多能够使用这么多的日志块数量。那么(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS)> LOGSIZE 表示就是如果当前操作如果不加以限制,就可能超过日志磁盘块的使用数量,因此睡眠等待。

最后,正真要做的操作只是log.outstanding加上1,表示系统又多了一个文件事务操作。

log_write

当调用者修改了某个缓冲块,此时应调用log_write而不是直接调用bwrite,logwrite只会修改内存中的数据块,不会与磁盘交互

void
log_write(struct buf *b)
{
  int i;
 ...
  acquire(&log.lock);
   // log absorbtion 操作
  for (i = 0; i < log.lh.n; i++) {
    //a block is written multiple times in a single transaction
    if (log.lh.block[i] == b->blockno)  
      break;
  }
    
  log.lh.block[i] = b->blockno;
  if (i == log.lh.n)
    log.lh.n++;  // 没有absorbion掉,那么就消耗掉一个日志记录块
  b->flags |= B_DIRTY; // 防止被LRU剔除策略剔除
  release(&log.lock);
}

可以看到,log_write并不是直接调用磁盘操作写缓冲块,而是在内存中的日志头(logheader中)记录信息,实际上就是将一个逻辑指针指向了对应的磁盘block。

image-20221117163325886

如上图所示,假设我们选择了logeheader后的第三个日志块,且这个日志快对应的真实数据块编号为50,表示当事务结束时这个日志快的内容将被复制到编号为50的数据块中。

注意这里有一个所谓absorbion操作,如果一个磁盘块在一次操作中被多次写入,那么就复用它在logheader中的记录,而不用另外新分配一个日志记录块,因为反正它对应的是磁盘上的同一个块,之后commit操作把日志记录快的内容拷贝到磁盘记录块时,并没有任何信息丢失,反而节省了磁盘操作。

end_op

void
end_op(void)
{
  int do_commit = 0;

  acquire(&log.lock);
  log.outstanding -= 1; // 表示系统中的日志事务操作减少1
  if(log.committing)
    panic("log.committing");
  if(log.outstanding == 0){// 等待当前没有文件系统调用时才开始commit
    do_commit = 1;
    log.committing = 1; // 进入提交状态
  } else {
      // 对应begin_op()可能有操作在等待日志空间,刚刚把outstanding减了1,现在可能有空间了
    wakeup(&log);
  }
  release(&log.lock);
    
 // 重点是下面的提交操作
  if(do_commit){
    commit(); // 主角
    acquire(&log.lock);
    log.committing = 0;
    wakeup(&log);
    release(&log.lock);
  }
}

end_op首先将log.outstanding 递减,表示有一个文件操作已经完成了,但是正真的提交操作得等到当前系统没有文件操作调用时(log.outstanding = 0时)才一起进行。

接下来重点看提交操作,其中的commit()才是主角:

static void
commit()
{
  if (log.lh.n > 0) {
    write_log();     // Write modified blocks from cache to log
    write_head();    // Write header to disk -- the real commit
    install_trans(); // Now install writes to home locations
    log.lh.n = 0;
    write_head();    // Erase the transaction from the log
  }
}

有4个阶段

  1. write_log()把内存中的日志记录块(不包括日志头块)写入磁盘对应的block

    static void
    write_log(void)
    {
      int tail;
    
      for (tail = 0; tail < log.lh.n; tail++) {
        struct buf *to = bread(log.dev, log.start+tail+1); //  log.start是磁盘上日志区域的开始block编号,+1 是为了跳过磁盘上的logheader 块
        struct buf *from = bread(log.dev, log.lh.block[tail]); // 对应于磁盘中的数据块的缓存,我们可能已经修改过它了
        memmove(to->data, from->data, BSIZE); // 修改过的磁盘块buf拷贝到日志快的buf中
        bwrite(to);  // 将日志块的内容同步到磁盘。
        brelse(from);
        brelse(to);
      }
    }
    

    image-20230301193616957

    如上图所示,首先将日志块读进内存,然后将被修改的buffer的内容拷贝到这个日志块中,最后将这个日志块同步到磁盘上。

  2. write_head();将内存的日志头记录同步到磁盘,这才是正真的提交(详见末尾的分析)

    static void
    write_head(void)
    {
      struct buf *buf = bread(log.dev, log.start);
      struct logheader *hb = (struct logheader *) (buf->data);
      int i;
      hb->n = log.lh.n;
      for (i = 0; i < log.lh.n; i++) {
        hb->block[i] = log.lh.block[i]; // 日志记录块的数组元素一一拷贝
      }
      bwrite(buf); // 同步到磁盘
      brelse(buf);
    }
    

    image-20230301194306251

  3. install_trans,将磁盘日志记录块根据日志头数组的逻辑指针,将它们的内容拷贝到对应的磁盘block中

    // Copy committed blocks from log to their home location
    static void
    install_trans(void)
    {
      int tail;
    
      for (tail = 0; tail < log.lh.n; tail++) {
        struct buf *lbuf = bread(log.dev, log.start+tail+1); // read log block
        struct buf *dbuf = bread(log.dev, log.lh.block[tail]); // read dst
        memmove(dbuf->data, lbuf->data, BSIZE);  // copy block to dst
        bwrite(dbuf);  // write dst to disk
        brelse(lbuf);
        brelse(dbuf);
      }
    }
    

    image-20221117172541048

    这一步看上去需要不少耗时的磁盘操作,但实际上由缓冲层的作用,bread不一定非得从磁盘上重新读数据,可能内存已经缓存了磁盘数据的拷贝,这是缓冲层的作用之一。

    此外注意当log.lh.n = 0时,根本不进行拷贝操作。

  4. 最后将日志头的n设为0,同步到磁盘,表示日志文件与磁盘文件是一致的。

    commit()
    {
    	// ....
        log.lh.n = 0;
        write_head();    // Erase the transaction from the log
       	// ...
    }
    

    日志头的整数n有着重要作用。如果n >0,表示日志文件的部分块还没有和磁盘的对应block进行同步,当操作系统下次启动时,就会执行日志恢复操作。

日志恢复

日志恢复操作早在日志初始化时就已经被调用了:

void
initlog(int dev)
{
  // ...
  recover_from_log();
}

操作系统每次初始化时都会尝试日志恢复操作

static void
recover_from_log(void)
{
  read_head();
  install_trans(); // if committed, copy from log to disk
  log.lh.n = 0;
  write_head(); // clear the log
}

recover_from_log只是执行commit()的后两步骤,即根据日志的记录将日志文件的数据拷贝到对应的磁盘block中。

假设loghead里的n为0,那么install_trans什么都不会做,如果n不为0,则会将日志块的内容根据日志头的指示一一拷贝到磁盘数据块中。

为什么xv6这样的日志能够进行错误恢复,使得磁盘数据一致性得到保证呢?

再来看commit操作:

static void
commit()
{
  if (log.lh.n > 0) {
    write_log();     // Write modified blocks from cache to log
    write_head();    // Write header to disk -- the real commit
    install_trans(); // Now install writes to home locations
    log.lh.n = 0;
    write_head();    // Erase the transaction from the log
  }
}

如果崩溃发生在第一次write_head()后,第二次write_head()前,就表示日志文件还没有将全部的数据拷贝到对应的磁盘块中,但是没关系,日志文件已经记录了这样的操作,当系统重启执行recover_from_log时,日志头n不为0,会按照日志头的信息,再次执行奔溃前的拷贝操作,从而完成了恢复。从这里可以看出,xv6的日志是一个redolog

如果系统崩溃发生在第一次write_head之前,日志头的n为0。当系统重启执行recover_from_log时发现,日志头的n为0,于是根本不进行任何操作,就好像啥都没有发生一样(即时崩溃前确实在内存中执行了许多操作,也可能将某些缓存写入了日志文件,但没有写入对应的数据block,因此不会造成不一致)。

如果系统崩溃发生在第二次write_head之后,日志头的n也为0,但此时这意味着在崩溃前,系统已经将日志文件数据同步到了对应磁盘块中,不需要进行额外恢复

因此可以说,第一次write_head()的成功执行,才算是真正的提交,它在磁盘上记录了系统在内存中执行的修改,有必要时操作系统根据日志头"redo"系统指令。

梳理总结

  • 当上层调用者要对文件数据进行修改时,先将文件数据块复制到磁盘的日志块中。等到commit时,再将日志块的数据拷贝到文件对应的磁盘数据块中。
  • 如何知道哪个日志块拷贝到哪个数据块中?根据日志头的指示进行拷贝。日志头记录了需要拷贝多少数据块,记作n,也记录了这些块要拷贝到哪里的信息。当拷贝完成时,将n置为0。
  • 如何处理崩溃恢复?开机时检查日志头的信息,看看n是否为0。
    • 如果是0,这要么表示没有发生崩溃恢复,要么表示在提交前发生了崩溃恢复,提交前的对文件数据块的一切修改都只在内存中完成,为了数据一致性,不需要进行任何操作。
    • 如果不是0,表示系统在提交后发生了崩溃,此次需要将日志块数据拷贝到文件磁盘块中。

扩展 : 关于redo和undo

如果将xv6中的日志与Mysql中的日志做个比较,不难发现xv6系统只是用了redo日志就能保证持久性与事务原子性,但是Mysql却要使用redo日志 + undo日志才能保证事务的原子性。为什么会有这样的差别?

这与系统如何管理内存缓存池有关。

  • Steal 还是 Not Steal
    • 系统是否允许未提交的事务覆盖内存中最近提交过的事务的值?如果允许,则称为Steal,如果不允许,则称为Not Steal
  • Force 还是 Not Force?
    • 系统是否强制在提交事务这个时间点上,将内存的修改刷新至磁盘上?如果强制,则为Force,如果不强制,则为Not Force

对于使用Not Steal+Force策略的系统来说,崩溃恢复不需要日志,对于Steal+Not Steal的系统来说需要同时使用redo和undo日志。

image-20230322203044479

几乎所有数据库都使用No force + Steall策略,因此同时需要redo和undo日志。Mysql也不例外,Mysql的日志可能在任意时间点被刷新至磁盘(有个后台线程不停地将内存的日志内容刷新至磁盘中)。

然而xv6在事务提交这个时间点上,必须将内存中的缓存刷新至磁盘,使用的是 Force + Streal策略, 因此xv6的文件系统只需要Redo日志即可保证事务的持久性和原子性。

更多详细内容见CMU15-445的数据库原理课程CMU 15-445/645 Database Systems (Fall 2020) :: Logging Schemes

inode、directory、file descriptor

这几层就不使用代码上细讲了,首先从一张总体逻辑图俯瞰整个xv6的文件系统,然后稍稍介绍下其中涉及的数据结构,最后从几个常见系统调用(open、read、write等)出发,结合图示分析它们底层数据结构的变化。

逻辑图以及相关数据结构

首先从一张图看一下xv6的文件系统总体的逻辑,注意下面的所有数据结构都在内核中

image-20221118225154849

从用户进程的视角看,它能看到的只有文件描述符,描述符是一个整数,是文件描述符表的index,用户进程甚至意识不到文件描述表的存在,这样就让进程有了一种独占文件的错觉。但实际上fd整数离磁盘文件有十万八千里远。

xv6内核对每一个进程都使用一个struct proc来描述,proc结构所拥有的资源就是改进程能够操作的资源。其中就包括它的文件描述符表,每个元素都是一个file指针指向系统即级别的文件表中的一项。

// 表示一个进程,相当于linux中的task_struct
struct proc {
    ...
  // 进程的文件描述符表,只是一个指针数组
  struct file *ofile[NOFILE];  // 每个元素都指向ftable.file的一项
    ...
};

// 系统级的文件表,使用自旋锁保证线程安全
struct {
  struct spinlock lock;
  struct file file[NFILE];
} ftable;

struct file {
  enum { FD_NONE, FD_PIPE, FD_INODE } type; // 设备文件的file.type也是 FD_INODE
  int ref; // reference count。fork后,有多出一个指指向本file结构; dup调用之后也会多出一个指向本file结构的指针
  char readable;
  char writable;
  struct pipe *pipe; // 可以指向 pipe 
  struct inode *ip; // 也可以指向 inode
  uint off; // 文件读写偏移量
};

内存中的inode结构是磁盘上的inode结构的复制,但是多了几个控制字段。

// file.h
// in-memory copy of an inode
struct inode {
  uint dev;           // Device number,
  uint inum;          // Inode number
  int ref;            // Reference count,内存有多少指针指向本inode
  struct sleeplock lock; // protects everything below here
  int valid;          // inode has been read from disk?

  // 对比下面的dinode,下面几个字段完全是dinode的复制
  short type;         // 目录文件 / 普通文件 / 设备文件
  short major;		 // 主设备号
  short minor;		 // 副设备号
  short nlink;        // 磁盘上有多少个目录项指向本inode
  uint size;		 // 这个文件的大小,单位为字节 
  uint addrs[NDIRECT+1]; // 12个直接块,以及1个间接块
};

// fs.h
// On-disk inode structure
struct dinode {
  short type;           // File type,files directories or devices。 如果是0则表示,这个inode是free的
  short major;          // Major device number (T_DEV only)
  short minor;          // Minor device number (T_DEV only)
  short nlink;          // Number of links to inode in file system。counts the number of directory entries that refer to this inode
                         //indicate when the on-disk inode and its data blocks should be freed
    					// 当nlink = 0时,就说明改inode需要被释放了,只需在bitmap中标记释放即可。
  uint size;            // Size of file (bytes)
  uint addrs[NDIRECT+1];   // Data block addresses
};

inode缓存队列之于dinode,相当于block缓存队列之于磁盘block数据块,都是为了缓存热点磁盘数据,但是inode缓存队列没有用链表管理,也没有lru剔除策略,当需要分配内存inode时,上层调用者就使用iget获得inode队列中的一个坑位。如下代码所示,先循环inode队列看看,是否对应的磁盘inode已经被缓存了,同时记录一个inode队列中一个空闲的inode坑位。如果内存没有缓存dinode,且没有空位的话,xv6的做法是直接panic。

static struct inode*
iget(uint dev, uint inum)
{
  struct inode *ip, *empty;
  acquire(&icache.lock);
  empty = 0;
  for(ip = &icache.inode[0]; ip < &icache.inode[NINODE]; ip++){
    if(ip->ref > 0 && ip->dev == dev && ip->inum == inum){
      ip->ref++;
      release(&icache.lock);
      return ip;
    }
    if(empty == 0 && ip->ref == 0)    // Remember empty slot.
      empty = ip;
  }
  // Recycle an inode cache entry.
  if(empty == 0)
      // 如果没有空位直接panic了...
    panic("iget: no inodes");
  ip = empty;
  ip->dev = dev;
  ip->inum = inum;
  ip->ref = 1;
  ip->valid = 0;
  release(&icache.lock);
  return ip;
}

如果inode的type是目录文件,那么inode所指向的磁盘block中存储的内容将被解释为“目录项”:

struct dirent { // 目录项指向一个文件
  ushort inum;  // 这个目录项指向的文件的inode编号
  char name[DIRSIZ]; // 目录项指向的文件的文件名
};

不同目录项可以指向相同的inode,但是具有不同的文件名,这就是硬链接的实质

常见文件系统调用

open

用户发起open后最终会调用sys_open()函数,大致分四步:

  1. 使用inode层提供的函数将对应文件的dinode结构从磁盘读入内存的inode缓存队列中,并返回它的指针。
  2. 在tftable中找一个空位,得到file指针;然后在本进程的文件描述符表中找到一个空位;最后将这两项联系起来
  3. 在fttable的对应file中将它与inode结构对应起来,并设置初始的读写状态。
  4. 返回fd给用户进程,此后进程就用这个整型数值操作打开的文件
int
sys_open(void)
{
  char *path;
  int fd, omode;
  struct file *f;
  struct inode *ip;
  // 1.将inode读入内存,并将地址赋值给ip
    ...
  ip = namei(path) == 0){
    .....
  // 2.分配系统级file表项,再分配进程级的文件描述符
  if((f = filealloc()) == 0 || (fd = fdalloc(f)) < 0){
	...//一些错误处理
  }
  iunlock(ip);
  end_op();
  // 3.将file结构与inode联系起来,并设置初始的读写状态
  f->type = FD_INODE;
  f->ip = ip; // 设置inode指针
  f->off = 0; // 新打开的文件的偏移为0
  f->readable = !(omode & O_WRONLY);
  f->writable = (omode & O_WRONLY) || (omode & O_RDWR);
  return fd; // 返回文件描述符表的index
}

image-20221119194239210

read

open之后我们就可以读文件内容了

read函数的第一个参数就是文件描述符,文件描述符就是文件描述表的index值,索引对应的file指针。

read调用最终会定向到sys_read函数中:

int
sys_read(void)
{
  struct file *f; 
  int n;  // 读多少字节
  char *p; // 读到哪个地址
  if(argfd(0, 0, &f) < 0 || argint(2, &n) < 0 || argptr(1, &p, n) < 0) // 从用户栈中取参数
    return -1;
  return fileread(f, p, n);
}

// Read from file f.
int
fileread(struct file *f, char *addr, int n)
{
  int r;

  if(f->readable == 0)
    return -1;
  // 判断file的类型
  if(f->type == FD_PIPE) // 管道
    return piperead(f->pipe, addr, n);
  if(f->type == FD_INODE){ // 文件
    ilock(f->ip);
    // 根据文件表的偏移和inode的直接/间接指针读取磁盘内容
    if((r = readi(f->ip, addr, f->off, n)) > 0) // 参数依次为 :本文件对应的inode 编号, 读取到哪个地址处,文件偏移指针,读取几个字节
      f->off += r; // 更新文件偏移指针
    iunlock(f->ip);
    return r;
  }
  panic("fileread");
}

argfd argint argptr这三个函数是用来从用户栈中读取系统调用的参数,是的xv6的系统调用参数使用用户栈传递而不是寄存器,因此需要额外的特权级和安全性检查,全部都使用agrXX函数封装起来了。

主要工作都在fileread函数中,它会首先判断这个文件是管道还是文件(关于管道,我会另外用一章来整理)。

如果是文件那就使用inode层提供的函数读取对应的文件内容。inode由file的ip指针指定(这在open中已经绑定),从文件的哪里开始读还是由file的读写指针(逻辑上的)off字段决定,读到哪里以及读多少字节则有上层调用者决定。主要逻辑交给readi函数完成:

// 从inode中读数据
int
readi(struct inode *ip, char *dst, uint off, uint n)
{
  uint tot, m;
  struct buf *bp;

  if(ip->type == T_DEV){ // 如果inode的type是一个设备文件(xv6中只有一个设备文件--控制台)
    if(ip->major < 0 || ip->major >= NDEV || !devsw[ip->major].read)
      return -1;
    return devsw[ip->major].read(ip, dst, n); // 则调用设备文件的read函数
  }

  if(off > ip->size || off + n < off)  // 如果要读取的偏移量大于真个文件的大小的话,返回是被
    return -1;
  if(off + n > ip->size) // 如果文件甚于的可读取字节数小于上层调用者要读取的字节数,那么调整需要读取的字节数为文件的剩余字节数。
    n = ip->size - off;

  for(tot=0; tot<n; tot+=m, off+=m, dst+=m){ // tot:目前读取了多少字节,n:一共需要读取多少字节,off:从文件的哪里开始读,dst:读到哪里
    bp = bread(ip->dev, bmap(ip, off/BSIZE)); // 给定inode和偏移,由bmap计算要读的磁盘编号,然后调用bread读取到缓存块中,缓冲块缓存磁盘block的具体数据
    m = min(n - tot, BSIZE - off%BSIZE);
    memmove(dst, bp->data + off%BSIZE, m); // 将缓存块的内容再拷贝到用户地址中
    brelse(bp);
  }
  return n;
}

这里的难点是将偏移地址转化为磁盘上的绝对block编号,这个逻辑有bmap函数实现,它会将一个block在inode的相对编号转化为其在磁盘上的绝对编号

// 将inode中的相对block号转化成磁盘上的绝对block号
static uint
bmap(struct inode *ip, uint bn) // ip : inode指针, bn : 在ip这个inode中的相对块号, 返回值: 该block在磁盘上的绝对块号
{
  uint addr, *a;
  struct buf *bp;

  if(bn < NDIRECT){ // 在直接块中,直接索引数组获取即可。
    if((addr = ip->addrs[bn]) == 0) // 如果直接这个直接块还没有在磁盘分配
      ip->addrs[bn] = addr = balloc(ip->dev); // 分配一个block
    return addr;
  }
  // 在间接块中,先减去12
  bn -= NDIRECT;

  if(bn < NINDIRECT){ // 一个block只能存放 BLOCKSIZE / sizeoof(uint) = 128 个简介块指针,因此bn不能大于这个数
    // 加在间接块block,如有必要则先创建它
    if((addr = ip->addrs[NDIRECT]) == 0)
      ip->addrs[NDIRECT] = addr = balloc(ip->dev);
    bp = bread(ip->dev, addr); // 将间接块的数据读入buffer
    a = (uint*)bp->data;
    if((addr = a[bn]) == 0){  // 获得块号,如有必要则先创建block
      a[bn] = addr = balloc(ip->dev);
      log_write(bp);
    }
    brelse(bp);
    return addr; // 返回磁盘的绝对block号
  }

  panic("bmap: out of range");
}

在读取完成后,还要更新file的读写指针(逻辑上的)off。

从上面的readi函数可以看出,xv6调用read读取磁盘文件时,需要先将磁盘文件拷贝到内核态的bffer中,然后再将buffer的内容拷贝到指定的用户态地址去。平日里所说的,linux read系统调用比map系统调用慢,就慢在了这里。

image-20221119201323471

write

与read调用的过程是类似的,只不过write将用户指定的内容写到磁盘文件中

dup

dup(int fd)函数将在进程的文件描述符表占一个坑,并将其file指针指向传入参数fd所指定的哪个file结构。

当dup返回时,进程就可使用两个不同的fd读取/修改相同的文件了。

dup函数的使用频率再shell中非常高,重定向、管道命令都需要用到dup函数。

下面请看具体代码和图示:

int
sys_dup(void)
{
  struct file *f;
  int fd;

  if(argfd(0, 0, &f) < 0)
    return -1;
  if((fd=fdalloc(f)) < 0)
    return -1;
  filedup(f); // 递增file结构的ref字段
  return fd;
}

image-20221119204911879

close

关闭一个文件,主要就是切断由open函数建立起来的联系(fd -> file -> inode)

其中 fd和file的联系无条件切断,而file和inode的联系则要判断file.ref是否等于0

如果ref不等于0,说明有本进程的其他fd在引用相同的文件,或者有其他进程在引用相同的文件,这时不能切断file和inode的联系。

int
sys_close(void)
{
  int fd;
  struct file *f;
  if(argfd(0, &fd, &f) < 0)
    return -1;
  myproc()->ofile[fd] = 0; // 切除进程级文件描述符表与系统级文件表的联系
  fileclose(f); 
  return 0;
}

void
fileclose(struct file *f)
{
  struct file ff;

  acquire(&ftable.lock);
  if(f->ref < 1)
    panic("fileclose");
  if(--f->ref > 0){ 
    // 有其他人引用了这个file结构,因此还不能切断file和inode的联系
    release(&ftable.lock);
    return;
  }
  // 如果内存中指向本file机构的指针数量为0,则切断file和inode的联系
  ff = *f;
  f->ref = 0;
  f->type = FD_NONE; // 将file的type置为NODE即可且切断联系
  release(&ftable.lock);

  if(ff.type == FD_PIPE)
    pipeclose(ff.pipe, ff.writable);
  else if(ff.type == FD_INODE){
    begin_op();
    iput(ff.ip); // 减少inode的ref指针
    end_op();
  }
}

image-20221119204150400

image-20221119204721600

(匿名)管道

从shell的角度看匿名管道的实现

先从shell是怎么处理命令行开始说起,这比较直观

命令行:

a | c

便能创建一个管道。首先shell进程将解释这个命令,进行一些列词法和语法分析后,得出这个命令是管道命令:

// xv6/sh.c/runcmd()函数片段:
int p[2]; // pipe调用返回两个fd存放在这里
case PIPE:
    pcmd = (struct pipecmd*)cmd;
    if(pipe(p) < 0)
      panic("pipe");
    if(fork1() == 0){	// 先fork一个进程1, 子进程1进入if代码块
      close(1);		 // 子进程1关闭标准输出,即fd = 1这个文件描述符不再指向控制台
      dup(p[1]);	// 子进程1将fd = 1这个文件描述符指向改成 fd = p[1] 这个文件描述符的指向,也即管道的输入端
      close(p[0]);
      close(p[1]);
      runcmd(pcmd->left); // runcmd执行 | 左边的程序,会调用exec不再返回
    }
    if(fork1() == 0){ // 再fork一个进程2,下面代码块对子进程2做类似处理,但是重定向标准输入的文件描述符
      close(0);
      dup(p[0]);
      close(p[0]);
      close(p[1]);
      runcmd(pcmd->right); // runcmd执行 | 右边的程序,会调用exec不再返回
    }
    close(p[0]);
    close(p[1]);
    wait();   // shell进程等待两个子进程执行完毕
    wait();
    break;

感到神秘的就只有pipe这个系统调用了,但我之后再讲这个玩意,先从shell和它的子进程门的角度粗浅地解释一下匿名管道的工作原理。

现在你只需要知道,pipe命令在内核中创建了一块内存,内核返回了两个文件描述符来让用户操作它,存放于int p[2],其中p[1]为管道的输入端描述符,p[0]为管道的输出端描述符。

image-20230301221202653

记住fork后的子进程的文件描述符表与shell进程相同:

image-20230301221812627

子进程1将自己的fd = 1这个描述符重定向到管道的输出端,子进程2将自己的fd = 0这个描述符重定向到管道的输入端,并删除多余的文件描述符表:

image-20230301223121656

然后子进程1的输出就顺利地通过管道作为子进程2的输入了:

image-20230301223743265

底层实现

数据结构

现在来梳理下pipe的底层实现,以及两个进程是如何通过read、write两个函数从管道读\写数据的;既然读写管道使用的函数与读写普通文件相同,那么又是如何做出区分的?

首先介绍一下管道的数据结构,在xv6中struct pipe表示一个管道

struct pipe {
  struct spinlock lock; // 锁
  char data[PIPESIZE]; // 存放数据的内核内存区域
  uint nread;     // 读了几个字节
  uint nwrite;    // 写了几个字节
  int readopen;   // 读端是否打开
  int writeopen;  // 写端是否打开
};

pipe.data[PIPESIZE]就是之前所说的内核的一片内存,它是进程通信的消息载体,PIPESIZE = 521。稍后会看到,读写管道其实就是一个有界的消费者生产者模型,因此除了内核数据区,还会有锁、标识位等来保证线程安全。

sys_pipe

sys_pipe的实现:

// 用户空间发起系统调用
int fd[2];
pipe(fd);
...
// 对应的内核处理函数
int
sys_pipe(void)
{
  int *fd;
  struct file *rf, *wf;
  int fd0, fd1;
	// 1.取用户传来的整形数组的地址
  if(argptr(0, (void*)&fd, 2*sizeof(fd[0])) < 0)
    return -1;
    // 2.分配pipe结构体和file结构体
  if(pipealloc(&rf, &wf) < 0)
    return -1;
  fd0 = -1;
    // 3. 在进程的文件描述符表中找两个空位,将他们与系统级的文件表联系起来,返回它们的整型索引地址值作为文件描述符
  if((fd0 = fdalloc(rf)) < 0 || (fd1 = fdalloc(wf)) < 0){
    if(fd0 >= 0)
      myproc()->ofile[fd0] = 0;
    fileclose(rf);
    fileclose(wf);
    return -1;
  }
    // 4. 设置好用户空间的文件描述符
  fd[0] = fd0; 
  fd[1] = fd1;
  return 0;
}

其中的pipealloc函数实现如下:

int
pipealloc(struct file **f0, struct file **f1)
{
  struct pipe *p;

  p = 0;
  *f0 = *f1 = 0;
   // 1. 分配file结构
  if((*f0 = filealloc()) == 0 || (*f1 = filealloc()) == 0)
    goto bad;
   // 2. 分配struct pipe所占用的内存页 
  if((p = (struct pipe*)kalloc()) == 0) 
    goto bad;
   // 3. 初始化pipe结构
  p->readopen = 1;
  p->writeopen = 1;
  p->nwrite = 0;
  p->nread = 0;
  initlock(&p->lock, "pipe");
   // 4. 初始化两个file结构
  (*f0)->type = FD_PIPE; // 注意file的类型为FD_PIPE !
  (*f0)->readable = 1;
  (*f0)->writable = 0;
  (*f0)->pipe = p;
  (*f1)->type = FD_PIPE;// 注意file的类型为FD_PIPE !
  (*f1)->readable = 0;
  (*f1)->writable = 1;
  (*f1)->pipe = p;
  return 0;
// bad: ....
}

要注意其中struct file的type字段为FD_PIPE,表示它指向一个管道而不是inode,因此其inode指针为null。

这使得UnixLike内核的文件描述符的抽象能力大大提高,上层只管调用read、write,inode与管道的具体区分交给底层代码。

多态?

系统根据file的类型不同,调用不同底层实现函数,感觉非常像C++的多态的概念。

比如说read系统调用:

// fd : 文件描述符
// addr:读入的内容复制到哪个地址上
// n : 总共要读多少个字节
int read(int fd, char* addr, int n)

无论fd代表的是普通文件还是管道,都是一样的接口。

区别就在于read的底层实现fileread函数:

// sysfile.c
// read系统调用路由至内核处理函数sys_read
int
sys_read(void)
{
  struct file *f;
  int n;
  char *p;
 	// argfd:取得用户传入的fd并将其转换为file结构存储在f变量中
  if(argfd(0, 0, &f) < 0 || argint(2, &n) < 0 || argptr(1, &p, n) < 0)
    return -1;
  return fileread(f, p, n);
}
// file.c
// struct file *f 要读的文件结构
// char* addr 要读到哪里,通常是个用户态地址
// int n : 要读多少个字节数
int
fileread(struct file *f, char *addr, int n)
{
  int r;
  // 判断file结构的type
  if(f->type == FD_PIPE)// 如果是管道,则调用管道的读操作
    return piperead(f->pipe, addr, n);
  if(f->type == FD_INODE){// 如果是inode,则调用inode读操作
    ilock(f->ip);
    if((r = readi(f->ip, addr, f->off, n)) > 0)
      f->off += r;
    iunlock(f->ip);
    return r;
  }
  panic("fileread");
}

fileread函数首先判断file结构中记录的文件类型,如果是管道类型则调用piperead,如果是inode类型则使用inode处理的相关方法。

就这样似乎根据了file的“实际类型”调用了其对应的处理函数,这样的行为非常类似于C++的多态行为。

类似的filewrite、fileclose函数也做了同样的转发工作:

int
filewrite(struct file *f, char *addr, int n)
{
	// ...
  if(f->type == FD_PIPE)
    return pipewrite(f->pipe, addr, n);
	// ...
}

void
fileclose(struct file *f)
{

	//....
  if(ff.type == FD_PIPE)
    pipeclose(ff.pipe, ff.writable);
	// ...
}

piperead、pipewrite、pipeclose

本质上,进程对管道的读写是一种有界缓冲区的消费者生产者模型,只有当管道缓冲区有数据时,读端才能读取数据否则进入阻塞状态;只有当管道缓冲区有空位时,写端才能存放数据否则进入阻塞状态。

再看一眼pipe的数据结构,是不是和操作系统课本上讨论的有界缓冲区问题的数据结构非常相似?

struct pipe {
  struct spinlock lock; // 锁
  char data[PIPESIZE]; // 这其实就是有界缓冲区
  uint nread;     // 读了几个字节
  uint nwrite;    // 写了几个字节
  int readopen;   // 读端是否打开
  int writeopen;  // 写端是否打开
};

piperead和pipewrite的实现如下:

int
piperead(struct pipe *p, char *addr, int n)
{
  int i;

  acquire(&p->lock);
  while(p->nread == p->nwrite && p->writeopen){  //DOC: pipe-empty
    if(myproc()->killed){ // 如果本进程醒来后发现自己收到kill信号,则返回-1
      release(&p->lock);
      return -1;
    }
    sleep(&p->nread, &p->lock); //写进程睡眠,等待while循环条件为假
  }
  for(i = 0; i < n; i++){  
    if(p->nread == p->nwrite) // 
      break;
    addr[i] = p->data[p->nread++ % PIPESIZE]; // 将内核缓冲区的数据拷贝到用户指定的地址
  }
  wakeup(&p->nwrite);  // 唤醒写端
  release(&p->lock);
  return i;
}

int
pipewrite(struct pipe *p, char *addr, int n)
{
  int i;

  acquire(&p->lock);
  for(i = 0; i < n; i++){
    while(p->nwrite == p->nread + PIPESIZE){  //DOC: pipewrite-full
      if(p->readopen == 0 || myproc()->killed){ // 如果本进程被设置了 killed信号,则退出
        release(&p->lock);
        return -1;
      }
      wakeup(&p->nread); // 唤醒写端
      sleep(&p->nwrite, &p->lock);  //DOC: pipewrite-sleep
    }
    p->data[p->nwrite++ % PIPESIZE] = addr[i];// 将用户指定的内容拷贝到内核缓冲区
  }
  wakeup(&p->nread);  // 唤醒读进程
  release(&p->lock);
  return n;
}

可以对比[《OSTEP》第30章](single.dvi (wisc.edu)),管道是一种生产者消费者模型,如果你知道xv6的sleep和wakeup实现,那么也能想到xv6的管道消费者适合多生产者和多消费者的场景。

image-20230302193752054

上图的互斥锁就是struct pipe结构的自旋锁(虽然种类不同,但在此场景下能够达成相似效果), use_ptr就是pipe.nread, fill_ptr就是pipe.nwrite。那么两个条件变量呢?虽然xv6的pipe实现表面上没有使用条件变量,但是条件变量能做的,xv6的sleep和wake一样都能做,而且更灵活。我将在锁相关的博文中分析xv6的wake和sleep实现原理。

posted @ 2023-03-02 20:51  别杀那头猪  阅读(223)  评论(3编辑  收藏  举报