跳转至

Lab7 用户程序与系统调用

师旷之聪,不以六律,不能正五音。 ——《孟子·离娄章句上》

在之前的实验中,我们已经为计算系统搭建了比较完成的仿真环境和硬件平台,完成了对计算系统的基本功能的实现。在本次实验中,我们将进一步了解计算系统中操作系统的功能,实现用户程序的运行和系统调用的功能。

本次实验的主要任务如下:

  • 实现用户程序的加载;
  • 实现系统调用的功能;
  • 完善文件系统的功能,实现文件的读写功能。
请获取 Lab7 所需的代码框架

请将工作目录切换到 Lab7,运行以下命令:

shell
$ ./zinit.sh

1 用户程序的加载

1.1 用户程序的编译

在本次实验中,我们提供了一个 os-app 目录,该目录移植自南京大学的 PA 实验,其中有丰富的用户应用程序和测试程序,还有很多库文件。在 os-app/Makefile 中,我们定义了 APPSTESTS 两个变量,这两个变量可以规定将哪些用户程序编译并组装在一起。因此,你只需要修改这两个变量,并在 os 目录下运行如下命令:

shell
$ make update

这个命令会生成一个 ramdisk.img 文件,其中包含了编译生成的 elf 文件和一些资源文件。这个 ramdisk.img 文件会被拷贝到 simulator/device/ramdisk 目录下,供仿真环境后续使用。

为了让操作系统能找到这些用户程序,我们还需要一个结构体数组,来记录每个用户程序的信息。在 os/fs.c 中,我们定义了如下结构体:

C
typedef struct {
  char *  name;
  size_t  size;
  size_t  disk_offset;
  bool    is_open;
  ReadFn  read;
  WriteFn write;
  size_t  open_offset;
} Finfo;
在使用 make update 命令后,os-app 中还会生成一个 file.h 文件,这个文件中就以结构体的形式记录了每个被编译的用户程序信息。这些信息会被直接包含进 os/fs.c 中的 file_table 数组中,供操作系统使用。

第一次的编译时间会较长

由于 OS-APP 需要从我们的实验仓库中拉取 libc 和 compiler-rt 且需要编译所有库文件,因此第一次编译时会比较慢。但是,这些库只需要下载一次,之后的编译就会快很多。

1.2 文件“磁盘”的读写

在真实的文件系统中,文件是保存在磁盘中,而磁盘也属于外设,因此我们需要 MMIO 对磁盘映射的地址进行映射。但是,由于我们后续的实验需要对文件进行大规模的读写操作,而对这一片空间的访问是非可缓存的,因此访问的效率会非常低。为了解决这一问题,我们在仿真环境中构建了一个协处理器,用于加速频繁的文件读写操作。

我们将磁盘控制映射到了以 0xA0000300 为起始地址的 32 字节空间中,这个空间的具体映射如下:

偏移量 说明
0 读写文件的偏移量
4 操作系统存储文件读取/写入内容的缓冲区地址
8 操作系统存储文件读取/写入的长度
12 读写文件操作使能

在进行文件读写的操作时,需要将偏移量、缓冲区地址、读写长度分别写入 0xA0000300, 0xA0000304, 0xA0000308 的地址中。对于读操作,需要向 0xA000030C 处写入 1;对于写操作,则需要向 0xA000030C 处写入 2,就可以完成对文件的读写操作。

基于这样的约定,我们在 base-port/base/src/disk.c 中实现了 ramdisk_readramdisk_write 函数,供操作系统调用。

1.3 用户程序的加载

当我们拥有了一个包含若干用户程序的虚拟文件磁盘和读取磁盘的函数后,我们就可以开始实现用户程序的加载了。所谓“加载用户程序”,就是把用户程序中需要加载的段(segement)都放入其规定的内存位置。

Info

ELF 中采用 Program Header Table 来管理段信息。Program Header Table 的一个表项描述了一个段的所有属性,包括类型、虚拟地址、标志、对齐方式、文件内偏移量和大小。根据这些信息,我们就可以知道需要如何加载一个段了。

此外,我们将会看到,加载一个可执行文件并不是加载它所包含的所有内容,只要加载那些与运行时相关的内容就可以了。我们可以通过判断某个的 Type 属性是否为 PT_LOAD 来判断一个段是否需要被加载。

在 C 的基本库中,也提供了一个 Elf_Ehdr 结构体,我们需要用到的成员如下:

成员 说明
e_ident ELF 的标识,应当为 0x464C457F
e_machine 指定了目标体系结构的架构类型,应当为 EM_RISCV
e_phoff 指定了 Program Header Table 的偏移量
e_phentsize 指定了 Program Header Table 中每个表项的大小
e_phnum 指定了 Program Header Table 中表项的数量
e_entry 指定了程序的入口地址

elf_load 函数的第一部分,我们需要实现以下内容:

  • 声明一个 Elf_Ehdr 型变量 elf_h
  • file_table 数组中找到这个文件对应的 ramdisk.img 内偏移量;
  • 使用 ramdisk_read 函数从这个文件的头部读取一个长度为 sizeof(Elf_Ehdr) 的数据到 elf_h 中;
  • 使用 assert 来判断 elf_h.e_ident 指向的32位地址中的数据是否为 0x464C457F
  • 使用 assert 来判断 elf_h.e_machine 是否为 EM_RISCV

接下来我们要做的,就是把 Program Header Table 中的表项逐个读取出来;根据表项的信息,判断其是否需要加载;如果需要加载,就把它加载到内存中。

在 C 库中,同样定义了 Elf_Phdr 型结构体,我们需要用到的成员如下:

成员 说明
p_type 指定了段的类型,值为 PT_LOAD 时需要加载
p_offset 指定了段在文件中的偏移量
p_vaddr 指定了段应当加载到内存中的虚拟地址
p_filesz 指定了段在文件中的大小
p_memsz 指定了段在内存中的大小

因此,在 elf_load 函数的第二部分,我们需要实现以下内容:

  • 声明一个 Elf_Phdr 型变量 elf_ph
  • 使用循环,从 elf_h.e_phoff 开始,每次读取一个长度为 sizeof(Elf_Phdr) 的数据到 elf_ph 中;
  • 如果 elf_ph.p_typePT_LOAD,就把 elf_ph.p_filesz 个字节从 elf_ph.p_offset 处读取到 elf_ph.p_vaddr 处,并将 elf_ph.p_vaddr + elf_ph.p_filesz 后的 elf_ph.p_memsz - elf_ph.p_filesz 个字节清零;
  • 下次循环时,应当寻找到下一个段,因此对于第 i 次循环,应当从 elf_h.e_phoff + i * elf_h.e_phentsize 开始读取新的 elf_ph
为什么要区分段在文件中和内存中的空间大小?

程序中的数据段包含已初始化和未初始化的程序变量。已初始化变量的值需要存储在程序的可执行文件中,而未初始化的变量不需要存放在可执行文件中。

然而,我们在内存中仍需为未初始化的变量保留相应的空间,这部分空间就是所谓的 BSS 段。在操作系统课程中,我们已经知道在加载时需要将 BSS 段全部清零。

从上面的描述中,我们也可以发现,BSS 段在文件中和在内存中所占的空间是不同的,这也是我们区分某个段在文件和内存中的空间大小的重要原因。

最后一步,你需要返回这个可执行文件的入口地址,这个地址就是 elf_h.e_entry。不过,在返回入口之前,请使用内联汇编插入一条 fence.i 指令,确保写入 DCache 的数据能够正确地被读入 ICache 中。

Task 1.1

请根据以上描述,在 os/src/loader.c 中实现 elf_load 函数。

在正确实现函数后,请在 os-app 目录下的 Makefile 中找到 TESTS 变量,你需要将 dummy 测试也加入这个变量中,然后在 os 目录下运行 make update 命令生成 ramdisk.img。

之后,在 os/src/proc.c 中的 init_proc 函数中,你需要使用 user_naive_load 函数将 /bin/dummy 文件加载到内存中。完成后,请使用 make run 命令运行操作系统。若操作系统触发了一个 6 号未处理事件,则表明你的实现已经完全正确。

2 系统调用的实现

在刚刚的任务中,我们虽然成功把用户程序加载到了内存中,但是我一旦出现了一个系统调用,我们就无法处理了。在之前的实验中,我们已经实现了 ecall 指令的处理,但在软件层面,我们并没有实现系统调用的功能。在本次实验中,我们将实现系统调用的功能,使得用户程序能够正常运行。

2.1 操作系统对系统调用的处理

下面我们来看一下操作系统是如何处理系统调用的:

  • 在操作系统启动之初,通过一系列的初始化操作,操作系统会将 __trap_vector 函数(定义在 trap.S 中)的入口地址写入 mtvec 寄存器中;
  • 一旦发生了系统调用,或者其他异常,处理器就会跳转到 __trap_vector 函数中,执行相应的处理;
  • __trap_vector 函数会将所有的通用寄存器和异常处理相关的部分 CSR 保存到栈上,之后调用 __irq_handle 函数;
  • __irq_handle 这个函数会根据保存在栈中上下文的 mcause 来判断当前触发的是异常还是中断。当确认了是系统调用异常时,会再根据约定,查看栈中的 a7 寄存器用以生成事件号。事件分为很多种,其中就包括 EVENT_SYSCALL,这个事件就是系统调用;
  • 当识别了事件后,__irq_handle 函数会将事件保存到一个事件结构体中,并调用 __event_handle 函数,这个函数会根据事件号,调用相应的处理函数。在我们的系统中,系统调用的处理函数为 syscall_handle 函数。
Task 2.1

请根据上述描述,在 irq.c 文件中的 __irq_handle 中识别 EVENT_SYSCALLEVENT_YIELD 事件(提示:使用保存在栈上的 mcause 识别出系统调用后,再使用保存的 a7 寄存器的值来识别事件)。

系统自陷

并非只有用户程序会使用 ecall 指令,操作系统也可以使用 ecall 指令来触发系统调用,这种操作被称为“系统自陷”。

如果一次 ecall 指令执行时 a7 的值为 -1,那么这次 ecall 指令就会触发一个 EVENT_YIELD 事件。这个事件虽然也是 ecall 指令触发的,但它和系统调用不同,不会触发 syscall_handle 函数。在实验的当前阶段,它会直接返回;而在一般的系统中,这个操作往往是主动引起进程调度。

在处理了事件后,__event_handle 函数还需要将保存在栈上的 mepc 加 4,用以避免重复执行 ecall 指令。这个函数会将响应事件后的上下文指针返回给 __irq_handle 函数,之后 __irq_handle 会将上下文指针返回到 __trap_vector 函数中,最后 __trap_vector 函数会将上下文恢复,使用 mret 返回到用户程序中。

2.2 系统调用处理的细节

在上一节中,我们在“事件”的层面了解了操作系统对系统调用的整体处理,下面我们开始研究 syscall_handle 函数的具体实现。

我们约定,在系统调用中,使用 a0 来传递系统调用号,而使用 a1-a3 来传递参数,使用 a0 来返回结果。

在 syscall.c 文件中,我们已经定义了多种系统调用号:

调用号 说明
SYS_exit 退出当前进程
SYS_yield 主动引起进程调度
SYS_open 打开文件
SYS_close 关闭文件
SYS_read 读取文件
SYS_write 写入文件
SYS_lseek 移动文件指针
SYS_brk 调整堆的大小
SYS_gettimeofday 获取当前时间
SYS_execve 执行一个新的程序
Task 2.2

请你实现 SYS_yieldSYS_exit 的处理。其中,SYS_yield 系统调用会让当前进程主动放弃 CPU,而 SYS_exit 系统调用会让当前进程退出。

为了简单起见,SYS_yield系统调用只需要调用内核中定义的 yield 函数即可。对于 SYS_exit,你只需要在处理中插入 halt(0) 即可。完成后运行 dummy 测试,你会得到 HIT_GOOD_TRAP 的提示,这代表你已经成功处理了 SYS_exit 系统调用。

我触发了两次系统调用?

细心的同学可能发现了这里的一些古怪之处:在用户程序发起 SYS_yield 系统调用后,指令执行了两次 ecall 指令:一次是在用户程序中,一次是 yield 函数中。

这样两次使用 ecall 是为了降低复杂度。SYS_yield 系统调用往往用于进程切换,如果我们在 syscall_handle 中直接调用 schedule,那么势必会让 syscall_handle 函数的返回值变得复杂。因此,我们在 syscall_handle 中只是简单地调用了 yield 函数;而在 yield 函数中,我们再次执行 ecall 指令,这样就可以触发 EVENT_YIELD 事件,让 __event_handle 函数调用 schedule 函数,完成进程切换。

3 简单文件系统的实现

看过前面的系统调用后你会发现:open, read, write, close 等系统调用都是针对文件的,为什么没有针对外设的系统调用呢?如果要读写外设的数据,我们应该怎么做呢?

在这一节中,我们将解答这个问题。

3.1 文件系统概述

在计算系统中,文件系统是操作系统中非常重要的一个组成部分。文件系统的主要功能是将外部存储设备(如硬盘)中的数据组织成文件,并提供对文件的读写功能。在本次实验中,我们将实现一个简单的文件系统,来完成对文件的读写功能。

在我们的实验中,为了简化起见,我们做如下约定:

  • 整个文件系统中的文件顺序保存在 ramdisk.img 中;
  • 文件系统内的文件不会增加或删除,只会进行读写操作;
  • 文件系统内的文件读写不会造成文件大小变化。

3.2 文件的基本操作

3.2.1 文件的打开

在 C 语言中,我们使用 fopen 函数来打开一个文件。下面我们来了解一下在你调用了这个函数后,程序会执行的操作:

  • 程序进入 libc,而 libc 会在底层调用一个 _open 函数(在我们的实验中,这个函数定义在 os-app/libs/libos/syscall.c 中);
  • _open 函数会触发一个类型为 SYS_open 的系统调用,将文件路径名、打开方式、等信息传递给操作系统;
  • 操作系统通过 __event_handle 函数调用 syscall_handle 函数,并使用 fs_open 函数完成对文件的打开;
  • fs_open 会遍历 file_table 数组,找到对应的文件,设置其偏移量为 0,并将 is_open 设置为 true,表示文件已经打开;
  • fs_open 会返回这个文件的文件描述符(也就是在 file_table 数组中的下标)。

在 os/fs.c 中,我们定义了如下的 fs_open 函数:

C
int fs_open(const char *pathname, int flags, int mode);
Task 3.1

请你完成 fs_open 函数的实现。在正确实现后,请你进一步完善 syscall_handle 函数,使其能够响应 SYS_open 系统调用。

注意,当 fs_open 找不到文件时,应当返回一个特殊的值。请你运行 man 2 open 命令,来查看当文件不存在时,open 函数的返回值是什么,并使用这个值作为 fs_open 的返回值。

3.2.2 文件的读写

和 fopen 类似,fread 函数也采用了类似于如上的方式触发系统调用:

  • 程序进入 libc,而 libc 会在底层调用一个 _read 函数;
  • _read 函数会触发一个类型为 SYS_read 的系统调用,将文件描述符、缓冲区地址、读取长度等信息传递给操作系统;
  • 操作系统通过 __event_handle 函数调用 syscall_handle 函数,并使用 fs_read 函数完成对文件的读取;
  • fs_read 首先会检查文件是否打开。如果已经打开,那么 fs_read 会根据文件描述符,在 file_table 中找到对应的文件;再根据文件当前的偏移量,使用 ramdisk_readramdisk.img 中读取数据到缓冲区中,并将偏移量加上读取长度;
  • fs_read 会返回读取的长度。

在 os/fs.c 中,我们定义了如下的 fs_read 函数:

C
ssize_t fs_read(int fd, void *buf, size_t len);
Task 3.2

请你完成 fs_read 函数的实现。完成后,请你进一步完善 syscall_handle 函数,使其能够响应 SYS_read 系统调用。

注意,当文件未打开时,应当返回一个特殊的值。请你使用 man 2 read,来查看当文件未打开时,read 函数的返回值是什么,并使用这个值作为 fs_read 的返回值。

注意文件边界的处理!

当读取长度超过文件剩余长度时,那么读取的长度应当为文件剩余长度。

Task 3.3

当你实现了文件的读取后,文件的写入相信也不难了。请你完成 fs_write 函数的实现。然后,请你进一步完善 syscall_handle 函数,使其能够响应 SYS_write 系统调用。

3.2.3 文件的指针移动和关闭

fseek 函数也使用了类似的方法进行处理,对于在操作系统之上的部分,我们已经介绍了很多,在此就不再赘述了。

我们需要关注的是 fs_lseek 函数。大家可能还记得 fseek 有三种移动方式:

  • SEEK_SET:从文件开头开始移动
  • SEEK_CUR:从当前位置开始移动
  • SEEK_END:从文件末尾开始移动

在 os/fs.c 中,我们定义了如下的 fs_lseek 函数:

C
off_t fs_lseek(int fd, off_t offset, int whence);

在这个函数中,你需要根据 whence 的值,完成这三种形式的移动。

Task 3.4

请你完成 fs_lseek 函数的实现。完成后,请你进一步完善 syscall_handle 函数,使其能够响应 SYS_lseek 系统调用。

Task 3.5

相对于上面的任务,fs_close 函数就显得非常简单了。请你完成 fs_close 函数的实现。完成后,请你进一步完善 syscall_handle 函数,使其能够响应 SYS_close 系统调用。

Task 3.6

在完成了上述所有任务后,请将 file-test 加入到 os-app/Makefile 的 TESTS 变量中,然后运行 make update 命令,生成 ramdisk.img。然后,你需要在 os/src/proc.c 中的 init_proc 函数中,使用 user_naive_load 函数将 /bin/file-test 文件加载到内存中。

完成后,请使用 make run 命令运行操作系统。由于file-test测试的最后需要一些其他的系统调用支持,因此如果运行失败,你可以根据反汇编文件和trace来基本验证你的实现是否正确。

Advance Task

请你修改 elf_load 函数的实现,使其使用 fs_open, fs_read, fs_lseek, fs_close 等函数,而不是直接使用 ramdisk_read 函数。

3.3 抽象的“文件”

现在我们来解答本节开头的问题:如何在没有特定的系统调用的情况下实现向外设写入数据的功能?

由于对外设只会有读写两种操作,我们可以将所有的外设都抽象为文件——但这些文件的大小和偏移量都是 0。这样,我们就可以使用上面定义的文件操作函数来对外设进行读写操作了。

仔细阅读 Finfo 的结构体定义,你会发现其中有两个函数指针成员:

C
ReadFn  read;
WriteFn write;

对于外设,这两个指针分别指向了对应的外设的读写函数,而对于普通的文件,这两个函数指针为空。因此,我们可以使用这两个函数指针来区分普通文件和外设。

这些“外设文件”包括:

  • stdin:标准输入(不实现);
  • stdout:标准输出(对应串口);
  • stderr:标准错误(对应串口);
  • dev/events:键盘事件输入(对应键盘);
  • dev/fb:帧缓冲(对应VGA);
  • proc/dispinfo:显示信息(对应VGA配置信息)。
Task 3.5

在 fs.c 中,我们让外设的读和写函数指针指向 invalid_readinvalid_write 函数。请你根据上述的对应外设,找到 device.c 中对应的外设控制函数,并将其填入对应的函数指针处。

对于不需要实现或不应该发起的请求(比如对键盘进行写操作),请保持函数指针为 invalid_readinvalid_write

Task 3.6

请你修改 fs_read, fs_write 函数的实现,使其能够对外设进行读写操作。完成后,请你进一步完善 syscall_handle 函数,使其能够响应涉及到外设的 SYS_open, SYS_read, SYS_write, SYS_lseek, SYS_close 系统调用。

我们已经帮大家实现了基本的 SYS_execve 函数,在完成了上述的所有函数调用后,你就可以编译运行 os-app 中的所有应用了。其中,menu 程序是菜单程序,可以在其中选择运行其他的应用,快来体验一下吧!

4 实验报告

本次实验不需要提交实验报告。