Lab7 用户程序与系统调用
师旷之聪,不以六律,不能正五音。 ——《孟子·离娄章句上》
在之前的实验中,我们已经为计算系统搭建了比较完成的仿真环境和硬件平台,完成了对计算系统的基本功能的实现。在本次实验中,我们将进一步了解计算系统中操作系统的功能,实现用户程序的运行和系统调用的功能。
本次实验的主要任务如下:
- 实现用户程序的加载;
- 实现系统调用的功能;
- 完善文件系统的功能,实现文件的读写功能。
1 用户程序的加载
1.1 用户程序的编译
在本次实验中,我们提供了一个 os-app 目录,该目录移植自南京大学的 PA 实验,其中有丰富的用户应用程序和测试程序,还有很多库文件。在 os-app/Makefile 中,我们定义了 APPS
和 TESTS
两个变量,这两个变量可以规定将哪些用户程序编译并组装在一起。因此,你只需要修改这两个变量,并在 os 目录下运行如下命令:
这个命令会生成一个 ramdisk.img 文件,其中包含了编译生成的 elf 文件和一些资源文件。这个 ramdisk.img 文件会被拷贝到 simulator/device/ramdisk 目录下,供仿真环境后续使用。
为了让操作系统能找到这些用户程序,我们还需要一个结构体数组,来记录每个用户程序的信息。在 os/fs.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_read
和 ramdisk_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_type
为PT_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_SYSCALL
和 EVENT_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_yield
和 SYS_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
函数:
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_read
从ramdisk.img
中读取数据到缓冲区中,并将偏移量加上读取长度;fs_read
会返回读取的长度。
在 os/fs.c 中,我们定义了如下的 fs_read
函数:
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
函数:
在这个函数中,你需要根据 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
的结构体定义,你会发现其中有两个函数指针成员:
对于外设,这两个指针分别指向了对应的外设的读写函数,而对于普通的文件,这两个函数指针为空。因此,我们可以使用这两个函数指针来区分普通文件和外设。
这些“外设文件”包括:
- stdin:标准输入(不实现);
- stdout:标准输出(对应串口);
- stderr:标准错误(对应串口);
- dev/events:键盘事件输入(对应键盘);
- dev/fb:帧缓冲(对应VGA);
- proc/dispinfo:显示信息(对应VGA配置信息)。
Task 3.5
在 fs.c 中,我们让外设的读和写函数指针指向 invalid_read
或 invalid_write
函数。请你根据上述的对应外设,找到 device.c 中对应的外设控制函数,并将其填入对应的函数指针处。
对于不需要实现或不应该发起的请求(比如对键盘进行写操作),请保持函数指针为 invalid_read
或 invalid_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 实验报告
本次实验不需要提交实验报告。