Final
Console
10
Pipe
10
O
File Descriptor
20
Shell
20
Exec
20
Fork
20
Mmap
10
O
自选 Bonus
20
O
说明
O
:不实现此部分也可以启动 shell。如果时间来不及,请优先保证你能启动 shell,因为启动 shell 所占分值较大。建议优先完成:File Descriptor + Exec + Fork + Shell + Console + Pipe。满分 100 分,超出的部分算 bonus。
1. File Descriptor
在这一部分,我们将完成和文件(file)以及文件描述符有关的最终实现。
你也许有疑问:为什么已经有了完整的硬盘文件/目录抽象(inode),还需要再包装一层?答案正如我们所常说:UNIX 的设计哲学是一切皆文件,除了狭义上的硬盘上的“文件”,我们还有设备文件(device File)和管道文件(pipe file)等等。这些文件的特点是:它们不是硬盘上的文件,而是内核中的一些数据结构,但和硬盘文件一样都可以作为读写操作的对象。
因此,我们需要为它们再设计一套抽象,使得用户态程序可以通过同一套机制来访问它们中的任何一个。这套机制就是文件(file)。
从面向对象的角度理解“一切皆文件”
从面向对象的角度来看,“一切皆文件”可以将“文件”这一概念视为一个抽象类(abstract class)或接口(interface)。这一理念的核心在于通过一套统一的接口操作多种资源。这一原则不仅能够最大限度地复用已有代码,还具备良好的可扩展性,符合软件工程中久经验证的优秀实践之一:开闭原则(OCP)。
在 Linux 系统中,这套接口的实现通过以下结构体定义:
支持该接口的资源通常以数据为中心。例如:
键盘作为字符设备,管理用户输入的字符数据。
管道文件管理一段由某进程输出、供另一进程输入的数据。
/dev/random
设备管理随机数据。
文件除了需要持有底层对象以外,还有一些额外的字段:
文件以偏移量(对于硬盘文件就是从文件开头的字节数,对于设备文件和管道文件就是迄今为止读/写的字节数)来标识当前读写的位置;
文件为了能同时被多个进程持有,需要记录当前持有者的数量;
文件还需要记录当前可行的读写模式(例如,设备文件可能仅能以只读模式打开,管道文件可能一头只写、一头只读,没有权限写的硬盘文件可能也仅能以只读模式打开)。
为了节省不必要的开支,所有的文件对象被组织在一个全局文件表中(一个数组),而每个进程又有自己的进程文件表(同样是一个数组)。进程文件表中,每个文件对象都有一个文件描述符(file descriptor)来标识自己。文件描述符是一个非负整数,即进程文件表中的下标,通过它可以找到对应的文件对象。
一个极简的文件对象如下所示:
值得注意的是,在本实验中我们进行了一些简化和修改:
我们支持的外设很少(只有一个终端字符设备),而且我们目前还没有做出虚拟文件系统(VFS)的接口,因此为简便起见,我们不再为设备(device)设计一个单独的抽象,而是直接复用硬盘的 inode 抽象,将其看作设备文件的下层接口。 也就是说,当我们创建一个设备文件时,我们将在硬盘上创建一个实在的 inode,其类型为 INODE_DEVICE
。因此,你的inode 接口也要考虑到设备文件的情况(例如 inode_read
等函数可能调用设备文件的下层接口,如 console_read
)。当然,这是后话了。
1.1. 任务
任务 1:实现 src/fs/file.c
的下列函数:
任务 2:截止目前我们都未编写路径字符串(例如 /this/is//my/path/so_cool.txt
)的解析逻辑。我们在 src/fs/inode.c
中实现了比较困难的部分,你需要完成其中的namex
函数(可见inode.c
中的注释部分):
任务 4:实现辅助函数:
任务 5(可以以后再做):参考讲解部分,修改 inode.c
中 read
和 write
函数,以支持设备文件。
2. Fork & Exec
fork
和 execve
是 Unix/Linux 系统编程中两个核心的系统调用,用于创建新进程和执行新的程序。fork
用于从当前进程(父进程)中创建一个新进程(子进程),而 execve
用于在当前进程中执行一个新程序。fork
和 execve
的组合使用是实现多进程和加载新程序的核心机制。在本部分中,我们将实现 fork
和 execve
系统调用,为后续启动 shell 做好准备。
2.1. ELF 可执行文件格式
ELF 文件描述了一个程序。它可以看作一个程序的“镜像”,就像 ISO 文件是一个光盘的“镜像”一样。ELF 文件包含程序的入口地址、各个段的大小、各个段的属性(可读、可写、可执行等)等等。我们按照这些信息,将程序加载到内存中,然后跳转到入口地址,就可以运行这个程序了。
在本部分中,我们需要实现 ELF 文件的解析和加载。后续,我们运行用户程序(如 ls
, mkdir
等)前都需要加载其 ELF 到内存中。在这里,我们给出两个会用到的结构体定义(相关头文件:musl/include/elf.h
):
注意:ELF 文件中的 section header 和实验中的 struct section
并不一样,本次实验不用考虑 ELF 中的 section header,但是需要明确 ELF 中的 Elf64_Phdr
与 lab 中 struct section
的对应关系。
提示:可以参考 xv6 加载 ELF 的实现。
2.2. fork()
系统调用
fork()
系统调用计算机系统基础(ICS)复习:用户程序调用 fork()
将创建一个当前进程的完整复制,我们通过 fork()
的返回值区分亲进程和子进程(亲进程的返回值是子进程的 PID ,子进程的返回值是 0)。
在实现 fork()
的内核侧时,我们需要从进程的结构体(Proc
)入手,判断其中的成员是否需要复制,是否需要修改。例如,我们需要深拷贝亲进程的页表给子进程,需要“复制”亲进程的文件描述符给子进程等。
思考:文件描述符的“复制”是什么意思?
注意:为了配合 fork()
,你可能需要在原先的 UserContext
中加入所有寄存器的值。此外,你还需要保存tpidr0
和 q0
,因为musl libc会使用它们。
提示:可以参考 xv6 fork 的实现。
2.3. execve()
系统调用
execve()
系统调用execve()
替换当前进程为 path
所指的 ELF 格式文件,加载该 ELF 文件,并运行该程序。你需要读取 Elf64_Ehdr
和 Elf64_Phdr
结构,注意根据 p_flags
的信息判断 section 类型,把 p_vaddr
, p_memsz
, p_offset
等信息填入到实验框架中的 struct section
对应位置;将文件中的代码和数据加载到内存中,并设置好用户栈、堆等空间;最后跳转到 ELF 文件的入口地址开始执行。
思考:替换 ELF 文件时,当前进程的哪些部分需要释放,哪些部分不需要?
思考:是否需要把 argv
和 envp
中的实际文本信息复制到新进程的地址空间中?
提示:下面提供了一个 execve
的大致流程:
提示:在execve()
中,一般可执行文件中可以加载的只有两段:RX的部分+RW的部分(其它部分会跳过)(因此只设置两种状态,一种是RX,另一种是RW)
RX的部分: 代码部分,主要包括 text 段,属性为 ST_FILE + RO
RW的部分:数据部分,包括了 data + bss 段
2.4. copyout
copyout
copyout
复制内容到给定的页表上,在 execve
中,为了将一个用户地址空间的内容复制到另一个用户地址空间上,可能需要调用这样的函数。如果你使用其他实现,则不需要实现这个函数。
2.5. 任务
任务 1:实现下列内容:
kernel/syscall.c
中的user_readable
和user_writeable
:检查syscall中由用户程序传入的指针所指向的内存空间是否有效且可被用户程序读写。kernel/proc.c
中的fork
。kernel/exec.c
中的execve
。kernel/pt.c
中的copyout
(可选)。kernel/paging.c
中的copy_sections
。kernel/paging.c
中的init_sections
:不需要再单独初始化 heap 段。kernel/paging.c
中的pgfault
:增加有关文件的处理逻辑。
3. Shell
本部分我们将尝试启动 shell 。
3.1. 任务
任务2:内核启动后执行第一个用户态程序 src/user/init.S
。在 src/kernel/core.c
的 kernel_entry()
中手动创建并启动第一个用户态进程。(即将 init.S
的代码映射到进程的 section 中)。
提示:可以在core.c
中使用 extern char icode[]; extern char eicode[];
来获得 init.S
的代码段的内核地址。这样的操作我们在 Lab 0 中清空 BSS 段时也用过。
实现成功后,内核将输出 shell 的 prompt($
)。此时,你仍然无法与内核交互,下面我们将实现 console,支持用户输入命令,运行程序。
4. Console
在开发中,我们常常使用诸如 printf()
, getchar()
等标准库函数实现用户输入与交互。然而,截至目前,我们的内核尚未支持键盘输入功能。因此,基于前一部分已实现的 shell,我们将进一步扩展功能,设计和实现 console,使用户能够通过键盘输入与 shell 交互,从而执行相关命令和运行硬盘上的程序。
当用户通过键盘输入内容时,内核会接收到 UART 触发的中断。通过读取 UART 的相关寄存器(由 uart_get_char()
实现),我们可以获得用户输入的具体字符。以下是 UART 的中断处理函数:
在中断处理流程中,我们首先通过 uart_get_char()
获取用户输入的字符 c
,然后将其交由 console_intr
函数处理。中断处理完成后,通过 device_put_u32
清除 UART 的中断状态。
console
是一个被抽象为文件的设备,主要管理用户输入的数据,即输入/输出缓冲区。在 console_intr
函数中,用户输入的字符会写入 console
的缓冲区,成为 console
所管理的核心数据。
正如之前所提到的,“一切皆文件”的设计理念使我们能够通过统一的接口对各种资源进行操作。在本实验中,用户态的 shell 通过内核提供的 read
和 write
接口,通过读写 console
,实现与用户的交互。
具体来说:
读取用户输入:shell 调用
read
接口,从缓冲区读取用户输入的字符。通过读取这些字符,shell 可以解析并执行用户输入的命令,例如echo hello
。在这种情况下,shell 会理解用户是希望以hello
为参数运行echo
程序。输出到终端:shell 调用
write
接口,将需要显示的内容写入缓冲区。内核会从缓冲区中读取这些内容,并通过 UART 将其输出到终端供用户查看。
回显
用户在终端中输入命令时,需要实时看到自己输入的内容。例如用户输入命令 ls -al
的同时,终端会显示出用户输入的命令,避免用户像输入密码一样“盲打”,给用户带来了良好的交互体验。
原理:当用户在键盘上输入字符时,内核会触发 UART 中断,执行以下流程:用户键盘输入 -> 内核的 UART 中断 -> console_intr(c)
-> 将 c
写入缓冲区 -> uart_put_char(c)
回显给用户。
索引
struct console
定义了三个重要的索引,用于跟踪缓冲区的状态:
read_idx
:标识已被用户态(通过调用 read 的 shell)读取的位置。write_idx
:标识已被用户态(通过调用 write 的 shell)写入的位置。edit_idx
:标识当前用户编辑的光标位置。
4.1. 任务
任务 1:完成 console_intr
,处理下述情况:
backspace
:对应字符c
为\x7f
。删除前一个字符,即input.edit_idx--
。注意,位于input.write_idx
前的已回显字符不能删除。Ctrl+U
:删除一行。Ctrl+D
:更新input.write_idx
到input.edit_idx
。表示EOF
。本身也要写入缓冲区和回显。普通字符写入和回显。
回显会用到 uart_put_char
向console写入内容。
任务 2:完成 console_read
和 console_write
,并在 inode_read
和 inode_write
中调用(前面我们已经提到,我们复用了 inode 作为设备文件的抽象)。
提示:可以参考 xv6 console 的实现。
5. Pipe
相信到目前为止,你已经对“一切皆文件”的哲学有了较深的理解。在本部分,你将在“一切皆文件”哲学的指导下,实现 pipe
。
在 Linux 中,pipe(管道) 是一种进程间通信(IPC, Inter-Process Communication)机制,用于将一个进程的输出直接作为另一个进程的输入。它通过一种“生产者-消费者”模型实现数据流的传递,主要用于命令之间的协作,避免使用临时文件。
管道的创建与使用
在 Shell 中使用管道:管道符号是
|
,将前一个命令的输出传递给后一个命令作为输入。例如:其中,
ls -l
列出了当前目录的详细信息,而grep "txt"
过滤包含 txt 的文件。该命令的作用是筛选出当前目录下的txt
文件。pipe()
是一个低级系统调用,用于创建一个匿名管道。它返回两个文件描述符:读端pipefd[0]
和写端pipefd[1]
。
5.1. 任务
任务1:完成 pipe.c
中的内容。
任务2:完成 sysfile.c
中的 pipe2
,实现相关系统调用。
提示:可以参考 xv6 pipe 的实现。
6. File Mapping
什么是 mmap()
?
mmap
,从函数名就可以看出来这是memory map,即内存映射,是一种内存映射文件的方法,将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。mmap()
系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以向访问普通内存一样对文件进行访问,不必再调用 read()
,write()
等操作。
为什么要用 mmap()
?
Linux通过内存映像机制来提供用户程序对内存直接访问的能力。内存映像的意思是把内核中特定部分的内存空间映射到用户级程序的内存空间去。也就是说,用户空间和内核空间共享一块相同的内存。这样做的直观效果显而易见:内核在这块地址内存储变更的任何数据,用户可以立即发现和使用,根本无须数据拷贝。举个例子理解一下,使用 mmap
方式获取磁盘上的文件信息,只需要将磁盘上的数据拷贝至那块共享内存中去,用户进程可以直接获取到信息,而相对于传统的 write/read IO 系统调用, 必须先把数据从磁盘拷贝至到内核缓冲区中(页缓冲),然后再把数据拷贝至用户进程中。两者相比,mmap
会少一次拷贝数据,这样带来的性能提升是巨大的。
参考资料:XV6学习(15)Lab mmap: Mmap - 星見遥 - 博客园
6.1. 任务
任务 1:实现下列系统调用。
7. 构建、测试与提交
在拉取了最新的框架后,我们需要:
在构建时,我们需要:
当你完成全部任务后,如果一切顺利,将进入 shell,你可以自己运行里面的程序。我们也编写了一个 usertests
程序供测试。
提交日期为 2025 年 1 月 26 日,如有特殊情况请联系助教。
Last updated