3. Project 2: User Programs#
现在,您已经用Pintos有一段时间了,并逐渐熟悉它的基础架构和线程包,是时候开始研究系统中允许运行用户程序的部分了。基本代码已经支持加载和运行用户程序,但没有I/O,交互也不可能。在此项目中,您将使程序能够通过系统调用与操作系统进行交互。
您将在“userprog”目录下工作,但是您还将与Pintos的几乎所有其他部分进行交互。 我们将在下面描述相关部分。
您可以在提交的项目1的基础上构建项目2,也可以重新开始。本作业不需要项目1的代码。 “闹钟”功能在项目3和4中可能很有用,但并非严格要求。
您可能会发现回头重新阅读如何运行测试很有用(请参阅1.2.1测试)。
3.1 Background#
到目前为止,您在Pintos下运行的所有代码都已成为操作系统内核的一部分。例如,这意味着最新作业中的所有测试代码都作为内核的一部分运行,并且可以完全访问系统的特权部分。一旦我们开始在操作系统之上运行用户程序,那就不再如此。这个项目处理后者。
我们允许一次运行多个进程,但每个进程都只有一个线程(不支持多线程的进程)。用户程序是在他们拥有整个计算机的错觉下编写的。这意味着当您一次加载并运行多个进程时,必须正确管理内存、调度和其他状态以维持这种错觉。
在上一个项目中,我们将测试代码直接编译到内核中,因此我们必须用到内核中某些特定的功能接口。从现在开始,我们将通过运行用户程序来测试您的操作系统。这给您更大的自由。您必须确保用户程序界面符合此处描述的规范,但是在有此约束的情况下,您可以随意重组或重写内核代码。
3.1.1 Source Files#
要了解将要进行的编程的最简单方法是简单地遍历要使用的每个部分。 在“userprog
”中,您会发现少量文件,但是这是您大部分工作要做的地方:
“process.c
”
“process.h
”
加载ELF二进制文件并启动进程.
“pagedir.c
”
“pagedir.h
”
80x86硬件页表的简单管理器。尽管您可能不想为该项目修改此代码,但是您可能希望调用其某些功能。有关更多信息,请参见4.1.2.3页表部分。
“syscall.c
”
“syscall.h
”
每当用户进程想要访问某些内核功能时,它就会调用系统调用。 这是一个系统调用处理程序的骨架。当前,它仅打印一条消息并终止用户进程。 在该项目的第2部分中,您将添加执行其他所有操作系统调用所需要的代码。
“exception.c
”
“exception.h
”
当用户进程执行特权或禁止的操作时,它会作为“例外exception”或“故障fault”进入内核。[3]。这些文件处理异常。 当前,所有异常仅会打印一条消息并终止该过程。 项目2的某些(但不是全部)解决方案需要修改此文件中的page_fault()
。
“gdt.c
”
“gdt.h
”
80x86是分段架构。 全局描述符表(GDT)是描述正在使用的段的表。这些文件设置了GDT。 您无需为任何项目修改这些文件。 如果您对GDT的工作方式感兴趣,可以阅读代码。
“tss.c
”
“tss.h
”
任务状态段(TSS)用于80x86体系结构任务切换。 当用户进程进入中断处理程序时,Pintos仅将TSS用于交换堆栈,Linux也是如此。 您无需为任何项目修改这些文件。 如果您对TSS的工作方式感兴趣,可以阅读代码。
3.1.2 使用文件系统#
您可能需要连接到本项目的文件系统的代码,因为用户程序是从文件系统加载的,许多系统调用必须实现处理文件系统。然而,本项目的重点不是文件系统, 所以我们在“filesys
”目录下提供了一个简单但完整的文件系统. 你可以看一下“filesys.h
” and “file.h
”接口,以理解如何使用文件系统,特别是它的很多限制.
无需修改此项目的文件系统代码,因此我们建议您不要这样做。在文件系统上工作可能会分散您对本项目的关注。
现在,当您改进文件系统实现时,正确使用文件系统例程将使项目4的工作变得更加轻松。 在此之前,您将必须容忍以下限制:
-
没有内部同步。 并发访问会互相干扰。 您应该使用同步来确保一次只有一个进程正在执行文件系统代码.
-
文件大小在创建时固定。 根目录表示为一个文件,因此可以创建的文件数也受到限制.
-
文件数据是按单个扩展区分配的,也就是说,单个文件中的数据必须占用磁盘上连续的扇区范围。因此,随着时间的推移使用文件系统,外部碎片会成为一个严重的问题.
-
没有子目录.
-
文件名限制为14个字符.
-
操作过程中的系统崩溃可能会以无法自动修复的方式损坏磁盘。没有文件系统修复工具.
包括一项重要功能:
- 实现了filesys_remove()的类Unix语义。也就是说,如果在删除文件时打开了该文件,则不会释放其块,并且打开该文件的任何线程仍可以访问该文件,直到最后一个文件关闭为止。有关更多信息,请参见删除打开的文件。
您需要能够创建具有文件系统分区的模拟磁盘。 “pintos-mkdisk”程序提供了此功能。 在“userprog/build”目录中,执行“pintos-mkdisk filesys.dsk --filesys-size=2”。该命令将创建一个名为“filesys.dsk”的模拟磁盘,其中包含一个2MB的Pintos文件系统分区。然后通过在内核命令行上传递“-f -q”来格式化文件系统分区:“pintos -f -q”。 “-f”选项使文件系统格式化,“-q”使Pintos在格式化完成后退出。
您需要一种将文件复制到模拟文件系统中以及从模拟文件系统复制文件的方法。使用“pintos”的“-p”(“put”)和“-g”(“get”)选项即可。要将“file
”复制到Pintos文件系统中,请使用命令“pintos -p file -- -q
”(--是必需的,因为-p用于pintos脚本,而不用于模拟器内核。)要将其复制到名称为newname的Pintos文件系统中,请添加“-a newname”:“`pintos -p file -a newname -- -q ”. 从虚拟机复制文件的命令是相似的,但是用“-g”代替“-p”。
顺便说一下,这些命令通过在内核的命令行上传递特殊的命令“extract”和“append”,并在特殊的模拟“scratch”分区之间进行复制来工作。 如果您很好奇,可以查看pintos
脚本以及“filesys/fsutil.c
”以了解实现细节。
以下是如何创建具有文件系统分区的磁盘,格式化文件系统,将“echo”程序复制到新磁盘中,然后运行“echo”并传递参数“x”的摘要。(参数传递要等到您实现它时才起作用。)假定您已经在“examples”中构建了示例,并且当前目录为“userprog/build”:
pintos-mkdisk filesys.dsk --filesys-size=2
pintos -f -q
pintos -p ../../examples/echo -a echo -- -q
pintos -q run 'echo x'
最后三个步骤实际上可以合并为一个命令:
pintos-mkdisk filesys.dsk --filesys-size=2
pintos -p ../../examples/echo -a echo -- -f -q run 'echo x'
如果您不希望保留文件系统磁盘以备后用或检查,甚至可以将所有四个步骤合并为一个命令。 --filesys-size=n选项创建一个临时文件系统分区,大小大约为n兆字节,仅在`pintos'运行期间。Pintos自动测试套件广泛使用了这种语法:
pintos --filesys-size=2 -p ../../examples/echo -a echo -- -f -q run 'echo x'
您可以使用“rm file”内核操作从Pintos文件系统中删除文件,例如 pintos -q rm
。同样,“ls”列出文件系统中的文件,“cat file”将文件的内容打印到显示器上。
3.1.3 How User Programs Work#
Pintos可以运行普通的C程序,只要它们适合内存并且仅使用您实现的系统调用即可。值得注意的是,无法实现malloc()
,因为该项目所需的系统调用均不支持内存分配。Pintos也不能运行使用浮点运算的程序,因为在切换线程时内核不会保存和恢复处理器的浮点单元。
“src/examples
”目录包含一些示例用户程序。该目录中的“Makefile
”将编译提供的示例,您也可以对其进行编辑以编译自己的程序。一些示例程序仅在实现项目3或4后才起作用。
Pintos可以使用“userprog/process.c”中为您提供的加载器加载ELF可执行文件。ELF是Linux、Solaris和许多其他操作系统使用的文件格式,用于目标文件、共享库和可执行文件。实际上,您可以使用任何输出80x86ELF可执行文件的编译器和链接器为Pintos生成程序。 (我们提供了可以正常运行的编译器和链接器。)
您应该立即意识到,在将测试程序复制到模拟文件系统之前,Pintos将无法做有用的工作。在将各种程序复制到文件系统之前,您将无法做一些有趣的事情。您可能想创建一个干净的参考文件系统磁盘,并在将“filesys.dsk”破坏到超出有用状态时将其复制过来,这可能在调试时偶尔发生。
3.1.4 Virtual Memory Layout#
Pintos中的虚拟内存分为两个区域:用户虚拟内存和内核虚拟内存。用户虚拟内存的范围从虚拟地址0到“PHYS_BASE”(在“threads/vaddr.h”中定义),默认为“ 0xc0000000”(3GB)。内核虚拟内存占用了其余的虚拟地址空间,从“PHYS_BASE”到最大4GB。
用户虚拟内存是按进程管理的。当内核从一个进程切换到另一个进程时,它还会通过更改处理器的页目录基址寄存器来切换用户虚拟地址空间(请参阅“userprog/pagedir.c”中的“pagedir_activate()”)。“struct thread”包含指向进程的页表的指针。
内核虚拟内存是全局的。无论运行什么用户进程或内核线程,它始终以相同的方式映射。在Pintos中,内核虚拟内存从“PHYS_BASE”开始一对一映射到物理内存。也就是说,虚拟地址“PHYS_BASE”访问物理地址0,虚拟地址“PHYS_BASE”+“0x1234”访问物理地址“0x1234”,依此类推,直到机器的物理内存大小为止。
用户程序只能访问其自己的用户虚拟内存。尝试访问内核虚拟内存会导致页面错误,由“userprog/exception.c”中的“page_fault()”处理,该进程将终止。内核线程可以访问内核虚拟内存,也可以访问正在运行的进程的用户虚拟内存(如果用户进程正在运行)。但是,即使在内核中,尝试以未映射的用户虚拟地址访问内存也会导致页面错误。
3.1.4.1 Typical Memory Layout#
从概念上讲,每个进程都可以随意选择自己的用户虚拟内存。 实际上,用户虚拟内存的布局是这样的:
PHYS_BASE +----------------------------------+
| user stack |
| | |
| | |
| V |
| grows downward |
| |
| |
| |
| |
| grows upward |
| ^ |
| | |
| | |
+----------------------------------+
| uninitialized data segment (BSS) |
+----------------------------------+
| initialized data segment |
+----------------------------------+
| code segment |
0x08048000 +----------------------------------+
| |
| |
| |
| |
| |
0 +----------------------------------+
在本项目中,用户堆栈的大小是固定的,但在项目3中,将允许其增长。传统上,未初始化的数据段的大小可以通过系统调用来调整,但是您不必实现这一点。
Pintos中的代码段始于用户虚拟地址“0x08084000”,距离地址空间底部约128MB。 此值在SysV-i386中指定,没有特殊意义。
链接器按照“链接器脚本”的指示设置用户程序在内存中的布局,该脚本会告诉用户各个程序段的名称和位置。您可以通过阅读链接器手册中的“脚本”一章来了解有关链接器脚本的更多信息,可通过“ info ld”进行访问。
要查看特定可执行文件的布局,请运行带有“-p”选项的“objdump”(80x86)或“i386-elf-objdump”(SPARC)。
3.1.5 Accessing User Memory#
作为系统调用的一部分,内核必须经常通过用户程序提供的指针访问内存。内核必须非常小心,因为用户可能传递一个空指针,一个指向未映射虚拟内存的指针或一个指向内核虚拟地址空间的指针(在PHYS_BASE之上)。必须通过终止有问题的进程并释放其资源,拒绝所有这些类型的无效指针,而不会损害内核或其他正在运行的进程。
至少有两种合理的方法可以正确地执行此操作。第一种方法是验证用户提供的指针的有效性,然后解引用它。如果选择此路线,则需要查看“userprog/pagedir.c”和“threads/vaddr.h”中的功能。这是处理用户内存访问的最简单方法。
第二种方法是仅检查用户指针是否指向“PHYS_BASE”下方,然后解引用。无效的用户指针将导致“页面错误”,您可以通过修改“userprog/exception.c”中的“page_fault()”的代码来处理。该技术通常更快,因为它利用了处理器的MMU,因此倾向于在实际内核(包括Linux)中使用。
无论哪种情况,都需要确保不要“泄漏”资源。例如,假设您的系统调用已使用malloc()
获得了锁定或分配的内存。如果之后遇到无效的用户指针,则仍必须确保释放锁定或释放内存页面。如果选择解引用用户指针之前先对其进行验证,则这应该很简单。如果无效指针导致页面错误,则更难处理,因为无法从内存访问中返回错误代码。因此,对于那些想尝试后一种技术的人,我们将提供一些有用的代码:
/* Reads a byte at user virtual address UADDR.
UADDR must be below PHYS_BASE.
Returns the byte value if successful, -1 if a segfault
occurred. */
static int
get_user (const uint8_t *uaddr)
{
int result;
asm ("movl $1f, %0; movzbl %1, %0; 1:"
: "=&a" (result) : "m" (*uaddr));
return result;
}
/* Writes BYTE to user address UDST.
UDST must be below PHYS_BASE.
Returns true if successful, false if a segfault occurred. */
static bool
put_user (uint8_t *udst, uint8_t byte)
{
int error_code;
asm ("movl $1f, %0; movb %b2, %1; 1:"
: "=&a" (error_code), "=m" (*udst) : "q" (byte));
return error_code != -1;
}
这些功能中的每一个均假设用户地址已被验证为低于“PHYS_BASE”。 他们还假设您已经修改了page_fault()
,以便内核中的页面错误仅将eax
设置为0xffffffff
并将其以前的值复制到eip
中。
3.2 建议的实现步骤#
我们建议先实施以下步骤,这些步骤可以并行进行:
-
参数传递(请参阅3.3.3参数传递)。 每个用户程序都将立即发生缺页中断,直到实现参数传递为止。
现在,您可能只希望在
setup_stack()
中将*esp = PHYS_BASE;
改为*esp = PHYS_BASE - 12;
这将适用于任何不检查其参数的测试程序,尽管其名称将被打印为“(null)”。
在实现参数传递之前,应仅运行程序而不传递命令行参数。 尝试将参数传递给程序会将这些参数包含在程序名称中,这可能会失败。
-
用户内存访问(请参阅3.1.5 访问用户内存)。所有系统调用都需要读取用户内存。很少有系统调用需要写入用户内存。
-
系统调用基础结构(请参见3.3.4 系统调用。) 实现足够的代码以从用户堆栈中读取系统调用号码,并根据该号码将其分配给处理程序。
-
exit
系统调用。每个以正常方式完成的用户程序都会调用exit
。甚至从main()返回的程序也会间接调用exit(请参见“lib/user/entry.c”中的_start())。 -
“write”系统调用用于写入系统控制台,文件描述符fd 1。我们所有的测试程序都写入控制台(通过这种方式实现了用户进程版本的printf()),因此它们都会出错,直到可以使用write为止。
-
现在,将process_wait()更改为无限循环(一个永远等待的循环)。提供的实现会立即返回,因此Pintos将在实际运行任何进程之前关闭电源。您最终将需要提供正确的实现.
实施上述步骤后,用户进程应该可以正常运行。至少,他们可以写入控制台并正确退出。然后,您可以优化实现,以使一些测试开始通过。
3.3 Requirements#
3.3.1 Design Document#
在上交项目之前,必须将project 2设计文档模板复制到源代码树中,名称为“pintos/src/userprog/DESIGNDOC
”,并填写我们建议您在开始进行项目之前阅读设计文档模板。 请参阅D. 项目文档),以获得与虚构项目一起提供的示例设计文档.
3.3.2 Process Termination Messages#
每当用户进程终止时,不管是因为它调用exit
或任何其他原因,请打印该进程的名称和退出代码,其格式类似于由printf(“%s:exit(%d)\ n”,... );
。 打印的名称应为传递给process_execute()的全名,并省略命令行参数。当不是用户进程的内核线程终止时,或者在调用“halt”系统调用时,请勿打印这些消息。当进程无法加载时,此消息是可选的。
除此之外,请不要打印提供的Pintos尚未打印的任何其他消息。您可能会发现其他消息在调试过程中很有用,但是它们会使评分脚本感到困惑,从而降低您的得分。
3.3.3 Argument Passing#
当前,process_execute()
不支持将参数传递给新进程。 通过扩展process_execute()
来实现此功能,以使它不仅将程序文件名作为参数,而且还将其分成空格。第一个单词是程序名称,第二个单词是第一个参数,依此类推。也就是说,process_execute(“grep foo bar”)
应该运行grep
,并传递两个参数foo
和bar
。
在命令行中,多个空格等效于一个空格,因此process_execute(“ grep foo bar”)
等同于我们的原始示例。 您可以对命令行参数的长度施加合理的限制。例如,您可以将参数限制为适合单个页面(4kB)的参数。 (不要将限制限制为pintos
实用程序可以传递给内核的最大128字节命令行参数。)
您可以按自己喜欢的任何方式解析参数字符串。如果您不清楚,请查看“strtok_r()”,该原型在“lib/string.h”中,并在“lib/string.c”中带有详尽注释。您可以通过查看手册页(在提示符下运行man strtok_r)找到有关它的更多信息。
关于如何设置堆栈的确切信息,请参见3.5.1 程序启动详解。
3.3.4 系统调用#
在“userprog/syscall.c”中实现系统调用处理程序。我们提供的“处理”系统调用的基本实现是结束进程。它将需要检索系统调用号,然后检索系统调用参数,并执行适当的操作。
实现以下系统调用。列出的原型是由包含“lib/user/syscall.h”的用户程序看到的原型。(此头文件以及“lib/user”中的所有其他头文件仅供用户程序使用。)每个系统调用的系统调用号在“lib/syscall-nr.h”中定义。
System Call: void halt (void)
- 通过调用“shutdown_power_off()”(在“threads/init.h”中声明)终止Pintos。很少使用此方法,因为您会丢失一些有关可能的死锁情况的信息,等等。
System Call: void exit (int status)
终止当前用户程序,将status返回到内核。如果进程的父进程等待它(参见下文),则将返回此状态。按照惯例,状态状态为0表示成功,非零值表示错误。
System Call: pid_t exec (const char *cmd_line)
运行其名称在 cmd_line 中给出的可执行文件,并传递任何给定的参数,返回新进程的进程ID(pid)。 如果程序由于任何原因无法加载或运行,则必须返回pid -1,否则不应为有效pid。 因此,父进程在知道子进程是否成功加载其可执行文件之前不能从exec返回。您必须使用适当的同步来确保这一点。
System Call: int wait (pid_t pid)
-
等待子进程pid并检索子进程的退出状态.
-
如果pid仍然有效,请等待直到终止。然后,返回pid传递给
exit
的状态。如果pid没有调用exit()
,而是被内核终止(例如,由于异常而终止),则wait(pid)
必须返回-1。父进程等待在父进程调用wait之前已经终止的子进程是完全合法的,但是内核仍必须允许父进程检索其子进程的退出状态,或者得知该子进程已被终止。 -
如果满足以下任一条件,则“wait”必须失败并立即返回-1:
-
pid不引用调用过程的直接子级。pid是调用过程的直接子级,当且仅当调用过程从成功调用exec收到pid作为返回值时。
请注意,子级不是继承的:如果A生成子级B和B生成子进程C,则即使B已死,A也无法等待C。进程A对
wait(C)
的调用必须失败。同样,如果孤立进程的父进程先退出,则它们也不会分配给新父进程。 -
调用“wait”的进程已经在pid上调用过wait了。也就是说,一个过程最多可以等待任何给定的孩子一次.
-
进程可以生成任意数量的子代,以任何顺序等待它们,甚至可以退出而无需等待部分或全部子代。您的设计应考虑所有可能发生等待的方式。无论父进程是否等待它,无论子进程是在其父进程之前还是之后退出,都必须释放该进程的所有资源,包括其“结构线程”。
-
您必须确保Pintos在初始进程退出之前不会终止。提供的Pintos代码尝试通过从main()(在“threads/init.c”中)调用“process_wait()”(在“userprog/process.c”中)来实现此目的。 我们建议您根据函数顶部的注释实现
process_wait()
,然后再根据process_wait()
实现wait
系统调用。 -
实现此系统调用所需的工作比其余任何工作都要多得多。
System Call: bool create (const char *file, unsigned initial_size)
创建一个名为file的新文件,其初始大小为 initial_size个字节。如果成功,则返回true,否则返回false。创建新文件不会打开它:打开新文件是一项单独的操作,需要系统调用“open”。
System Call: bool remove (const char *file)
删除名为file的文件。如果成功,则返回true,否则返回false。不论文件是打开还是关闭,都可以将其删除,并且删除打开的文件不会将其关闭。有关详细信息,请参见删除打开的文件。
System Call: int open (const char *file)
-
打开名为 file 的文件。 返回一个称为“文件描述符”(fd)的非负整数句柄;如果无法打开文件,则返回-1.
-
为控制台保留编号为0和1的文件描述符:fd 0(
STDIN_FILENO
)是标准输入,fd 1(STDOUT_FILENO)是标准输出。 open
系统调用将永远不会返回这两个文件描述符中的任何一个,这些文件描述符仅在以下明确描述的情况下才作为系统调用参数有效. -
每个进程都有一组独立的文件描述符。 文件描述符不被子进程继承。
-
当单个文件多次打开(无论是通过单个进程还是通过不同进程)时,每个“open”都将返回一个新的文件描述符。 单个文件的不同文件描述符在对
close
的单独调用中独立关闭,并且它们不共享文件位置。
System Call: int filesize (int fd)
返回以fd打开的文件的大小(以字节为单位).
System Call: int read (int fd, void *buffer, unsigned size)
从打开为fd的文件中读取size个字节到buffer中。返回实际读取的字节数(文件末尾为0),如果无法读取文件(由于文件末尾以外的条件),则返回-1。 fd 0使用input_getc()从键盘读取。
System Call: int write (int fd, const void *buffer, unsigned size)
+ 将size个字节从buffer写入打开的文件fd。返回实际写入的字节数,如果某些字节无法写入,则可能小于size。
-
在文件末尾写入通常会扩展文件,但是基本文件系统无法实现文件增长。预期的行为是在文件末尾写入尽可能多的字节并返回实际写入的数字,如果根本无法写入任何字节,则返回0。
-
fd 1写入控制台。您写入控制台的代码应在一次调用
putbuf()
中写入所有 buffer ,至少只要 size 不超过几百个字节。(打破较大的缓冲区是合理的。)否则,不同进程输出的文本行可能最终会在控制台上交错出现,从而使人类读者和我们的评分脚本感到困惑。
System Call: void seek (int fd, unsigned position)
+ 将打开文件fd中要读取或写入的下一个字节更改为position,以从文件开头开始的字节表示。(因此,position为0是文件的开始。)
- 越过当前文件的末尾进行查找不是错误。后续的读操作将获得0字节,表示文件结束。后续的写操作将扩展文件,并用零填充所有未写入的间隙。(但是,在Pintos中,文件在项目4完成之前都具有固定长度,因此文件末尾的写入将返回错误。)这些语义在文件系统中实现,并且在系统调用实现中不需要任何特殊的工作。
System Call: unsigned tell (int fd)
返回打开文件fd中要读取或写入的下一个字节的位置,以从文件开头开始的字节数表示。
System Call: void close (int fd)
关闭文件描述符fd。退出或终止进程会隐式关闭其所有打开的文件描述符,就像通过为每个进程调用此函数一样。
该文件定义其他系统调用。现在可以忽略它们。您将在项目3中实现其中的某些功能,并在项目4中实现其余的功能,因此请确保在设计系统时考虑到可扩展性。
要实现系统调用,您需要提供在用户虚拟地址空间中读取和写入数据的方法。在获得系统调用号之前,您需要此功能,因为系统调用号位于用户的虚拟地址空间中的用户堆栈上。这可能有点棘手:如果用户提供了无效的指针,指向内核内存的指针或部分位于这些区域之一的块,该怎么办?您应该通过终止用户进程来处理这些情况。我们建议在实现任何其他系统调用功能之前编写和测试此代码。有关更多信息,请参见3.1.5 访问用户内存。
您必须同步系统调用,以便任意数量的用户进程可以进行调用。特别地,同时从多个线程调用“filesys”目录中提供的文件系统代码是不安全的。您的系统调用实现必须将文件系统代码视为临界区。不要忘记process_execute()
也可以访问文件。目前,我们建议不要修改“filesys
”目录中的代码。
我们为“lib/user/syscall.c”中的每个系统调用提供了用户级功能。这些为用户进程提供了一种从C程序调用每个系统调用的方式。每个都使用少量内联汇编代码来调用系统调用,并且(如果适用)返回系统调用的返回值。
当您完成这部分的工作时,并且永远如此,Pintos应该是防弹的。用户程序无法执行的任何操作都不会导致OS崩溃、死机、断言失败或其他故障。强调这一点很重要:我们的测试将尝试以多种方式中断您的系统调用。您需要考虑所有极端情况并加以处理。用户程序应该能够导致OS停止的唯一方法是调用“halt”系统调用。
如果系统调用传递了无效的参数,则可接受的选项包括返回错误值(对于那些返回值的调用),返回未定义的值或终止过程。
有关系统调用如何工作的详细信息,请参见3.5.2 系统调用详解。
3.3.5 Denying Writes to Executables#
添加代码以拒绝写入用作可执行文件的文件。许多操作系统都这么做,因为如果进程尝试运行当时正在磁盘上进行更改的代码,结果将无法预测。特别是在项目3中实现虚拟内存后,这一点很重要,但即使现在,危险也很大。
您可以使用file_deny_write()
防止写入打开的文件。在文件上调用file_allow_write()
将重新启用它们(除非文件被另一个打开程序拒绝写入)。关闭文件也会重新启用写操作。因此,要拒绝写入进程的可执行文件,只要进程仍在运行,就必须保持打开状态。
3.4 FAQ#
我需要写多少代码?
+ 这是由diffstat程序生成的参考解决方案的摘要。 最后一行给出了插入和删除的总行数; 更改的行将同时算作插入和删除。
- 参考解决方案仅代表一种可能的解决方案。 许多其他解决方案也是可能的,其中许多与参考解决方案有很大不同。 某些优秀的解决方案可能不会修改参考解决方案修改的所有文件,而某些解决方案可能会修改参考解决方案未修改的文件。
threads/thread.c | 13
threads/thread.h | 26 +
userprog/exception.c | 8
userprog/process.c | 247 ++++++++++++++--
userprog/syscall.c | 468 ++++++++++++++++++++++++++++++-
userprog/syscall.h | 1
6 files changed, 725 insertions(+), 38 deletions(-)
当我运行pintos -p file -- -q
时,内核总是会panic.
-
您是否格式化了文件系统(使用“pintos -f”)?
-
您的文件名是否太长?文件系统将文件名限制为14个字符。诸如“pintos -p ../../examples/echo -- -q”之类的命令将超出限制。使用“pintos -p ../../examples/echo -a echo -- -q”将文件命名为“
echo
”。 -
文件系统是否已满?
-
文件系统已经包含16个文件了吗? 基本的Pintos文件系统限制为16个文件。
-
文件系统可能过于分散,以至于文件没有足够的连续空间。
当我运行 pintos -p ../file --
, “file
” 没有被复制.
-
默认情况下,文件是以您引用的名称编写的,因此在这种情况下,复制到的文件将被命名为“
../ file
”。 您可能想改为运行pintos -p ../file -a file --
。 -
您可以使用
pintos -q ls
列出文件系统中的文件。
我所有的用户程序都死于页面错误。
如果您尚未实现参数传递(或未正确实现),则会发生这种情况。用户程序的基本C库尝试从堆栈中读取argc和argv。如果堆栈设置不正确,则会导致页面错误。
我所有的用户程序都死于 system call
!
您必须先实现系统调用,然后才能看到其他内容。每个合理的程序都会尝试进行至少一个系统调用(exit()
),而大多数程序都将进行更多的调用。值得注意的是,printf()
调用write()
系统调用。默认的系统调用处理程序仅打印“系统调用”并终止程序。在此之前,您可以使用hex_dump()
来使自己确信参数传递已正确实现(请参阅参考资料3.5.1程序启动详细信息。
如何反汇编用户程序?
objdump(80x86)或i386-elf-objdump(SPARC)实用程序可以反汇编整个用户程序或目标文件。 将其作为“ objdump -d文件”调用。 您可以使用GDB的disassemble
命令来分解各个功能(请参阅E.5 GDB部分。
为什么许多C包含文件在Pintos程序中不起作用?
我可以在Pintos程序中使用libfoo吗?
+ 我们提供的C库非常有限。它不包括实际操作系统的C库所期望的许多功能。C库必须专门针对操作系统(和体系结构)构建,因为它必须对I/O和内存分配进行系统调用。(当然,并非所有功能都可以,但是通常库是作为一个单元编译的。)
- 您想要的库很有可能会使用Pintos未实现的C库的一部分。要使其在Pintos下工作,可能至少需要一些移植工作。值得注意的是,Pintos用户程序C库没有
malloc()
实现。
如何编译新的用户程序?
修改“src/examples/Makefile
”,然后运行“make”。
我可以在调试器下运行用户程序吗?
是的,但有一些限制。见E.5 GDB。
tid_t和pid_t有什么区别? + tid_t标识一个内核线程,该线程可能在其中运行用户进程(如果使用process_execute()创建)或不运行(如果使用thread_create()创建)。 它是仅在内核中使用的数据类型。
-
pid_t标识用户进程。 它由用户进程以及exec和wait系统调用中的内核使用。
-
您可以为“ tid_t”和“ pid_t”选择任何合适的类型。默认情况下,它们都是
int
。您可以使它们成为一对一的映射,以便两者中的相同值标识相同的过程,或者可以使用更复杂的映射。由你决定。
3.4.1 Argument Passing FAQ#
堆栈栈顶不在内核虚拟内存中吗?
堆栈的顶部通常位于“PHYS_BASE”,一般是0xc0000000,这也是内核虚拟内存的起始位置。但是在处理器将数据压入堆栈之前,它会递减堆栈指针。因此,压入堆栈的第一个(4字节)值将位于地址0xbffffffc`。
PHYS_BASE
是固定的吗?
不需要。您只需通过重新编译就可以支持从0x80000000到0xf0000000的0x10000000倍数的PHYS_BASE值。
3.4.2 System Calls FAQ#
我可以只转换(cast)一个struct file *
来获取文件描述符吗?
我可以将struct thread *
转换(cast)为pid_t吗?
您将必须自己做出这些设计决策。大多数操作系统的确在文件描述符(或PID)及其内核数据结构的地址之间进行区分。您需要在提交之前先考虑一下他们为什么这样做。
是否可以为每个进程设置最大打开文件数?
最好不要任意设置限制。如有必要,可以限制每个进程最多打开128个文件。
删除打开的文件会怎样?
您应该为文件实现标准的Unix语义。即,当删除文件时,具有该文件的文件描述符的任何进程都可以继续使用该描述符。这意味着他们可以从文件读取和写入。该文件将没有名称,并且没有其他进程可以打开该文件,但是它将一直存在,直到关闭所有引用该文件的文件描述符或计算机关闭为止。
如何运行需要超过4 kB堆栈空间的用户程序?
您可以修改堆栈设置代码以为每个进程分配多于一页的堆栈空间。在下一个项目中,您将实现更好的解决方案。
如果exec
在加载过程中失败怎么办?
如果子进程由于任何原因无法加载,exec
应该返回-1。 这包括在整个过程中部分加载失败的情况(例如,在“ multi-oom”测试中加载用尽了内存)。 因此,在确定加载是否成功之前,父进程无法从exec系统调用中返回。子进程必须使用适当的同步方式(例如信号量)将此信息传达给其父进程(请参见A.3.2信号量部分),以确保信息的交流没有竞争条件。
3.5 80x86 Calling Convention#
本节总结了在Unix的32位80x86实现上用于常规函数调用的约定的要点。为了简洁起见,省略了一些细节。如果确实需要所有详细信息,请参阅SysV-i386。
调用约定是这样的:
-
调用者通常使用PUSH汇编语言指令将函数的每个参数逐个推入堆栈。参数以从右到左的顺序推入.
堆栈向下增长:每次推入都会减小堆栈指针,然后将其存储到它现在指向的位置,类似C表达式“*--sp = value”.
-
调用方将其下一条指令的地址(返回地址)压入堆栈,并跳转到被调用方的第一条指令。 一条80x86指令“CALL”可同时执行这两项操作。
-
被执行者执行。 当它取得控制权时,堆栈指针指向返回地址,第一个参数位于其上方,第二个参数位于第一个参数上方,依此类推。
-
如果被调用方具有返回值,则将其存储到寄存器“EAX”中。
-
被调用者通过使用80x86“RET指令”从堆栈中弹出返回地址并跳转到其指定的位置来返回。
-
调用者将参数弹出堆栈。
考虑一个带有三个int参数的函数f()。 该图显示了一个示例堆栈框架,如被调用方在上述步骤3的开头所看到的,假定将f()调用为f(1、2、3)。 初始堆栈地址是任意的:
+----------------+
0xbffffe7c | 3 |
0xbffffe78 | 2 |
0xbffffe74 | 1 |
stack pointer --> 0xbffffe70 | return address |
+----------------+
3.5.1 程序启动详情#
用户程序的Pintos C库在“lib/user/entry.c
”中指定“_start()”作为用户程序的入口点。此函数是main()
的包装,如果main()返回以下内容,则调用exit()
:
void
_start (int argc, char *argv[])
{
exit (main (argc, argv));
}
在允许用户程序开始执行之前,内核必须将初始函数的参数放在堆栈上。 参数的传递方式与常规调用约定相同(请参见3.5 80x86调用约定)。
考虑如何处理以下示例命令的参数:“/bin/ls -l foo bar”。首先,将命令分解为单词:“/bin /ls”,“-l”,“foo”,“bar”。 将单词放在堆栈的顶部。 顺序无关紧要,因为它们将通过指针进行引用。
然后,按从右到左的顺序将每个字符串的地址以及一个空指针哨兵压入堆栈。这些是“argv”的元素。空指针sendinel可以确保C标准所要求的argv[argc]是空指针。该命令确保“argv[0]”位于最低虚拟地址。字对齐的访问比未对齐的访问要快,因此为了获得最佳性能,在第一次压入之前将堆栈指针向下舍入为4的倍数。
然后,依次按“argv”(“argv[0]”的地址)和“argc”。最后,推送一个伪造的“返回地址”:尽管入口函数将永远不会返回,但其堆栈框架必须具有与其他任何结构相同的结构。
下表显示了在用户程序开始之前堆栈的状态以及相关的寄存器,假设PHYS_BASE为“0xc0000000”:
Address | Name | Data | Type |
---|---|---|---|
0xbffffffc | argv[3][...] | “bar\0” | char[4] |
0xbffffff8 | argv[2][...] | “foo\0” | char[4] |
0xbffffff5 | argv[1][...] | “-l\0” | char[3] |
0xbfffffed | argv[0][...] | "/bin/ls\0” | char[8] |
0xbfffffec | word-align | 0 | uint8_t |
0xbfffffe8 | argv[4] | 0 | char * |
0xbfffffe4 | argv[3] | 0xbffffffc | char * |
0xbfffffe0 | argv[2] | 0xbffffff8 | char * |
0xbfffffdc | argv[1] | 0xbffffff5 | char * |
0xbfffffd8 | argv[0] | 0xbfffffed | char * |
0xbfffffd4 | argv | 0xbfffffd8 | char ** |
0xbfffffd0 | argc | 4 | int |
0xbfffffcc | return address | 0 | void (*) () |
在这个例子中,堆栈指针将被初始化为0xbfffffcc。
如上所示,您的代码应在用户虚拟地址空间的最顶部,即虚拟地址“PHYS_BASE”(在“threads/vaddr.h”中定义)下方的页面中开始堆栈。
您可能会发现在“hex_dump()
函数对于调试参数传递代码很有用。在上面的示例中将显示以下内容:
bfffffc0 00 00 00 00 | ....|
bfffffd0 04 00 00 00 d8 ff ff bf-ed ff ff bf f5 ff ff bf |................|
bfffffe0 f8 ff ff bf fc ff ff bf-00 00 00 00 00 2f 62 69 |............./bi|
bffffff0 6e 2f 6c 73 00 2d 6c 00-66 6f 6f 00 62 61 72 00 |n/ls.-l.foo.bar.|
3.5.2 系统调用详解#
第一个项目已经处理了操作系统可以从用户程序重新获得控制的一种方式:定时器和I/O设备的中断。这些是“外部”中断,因为它们是CPU外部的实体引起的(请参见A.4.3 外部中断处理部分)。
操作系统还处理软件异常,这些异常是程序代码中发生的事件(请参阅A.4.2 内部中断处理部分)。这些可能是错误,例如页面错误或被零除。 异常也是用户程序可以从操作系统请求服务(“系统调用”)的方式。
在80x86体系结构中,“int”指令是调用系统调用的最常用方法。该指令的处理方式与其他软件异常相同。在Pintos中,用户程序调用“int $0x30”进行系统调用。在调用中断之前,系统调用号和任何其他参数都应按正常方式推送到堆栈上(请参阅3.5 80x86调用约定。
因此,当系统调用处理程序syscall_handler()获得控制权时,系统调用号在调用者的堆栈指针的32位字中,第一个参数在下一个更高地址的32位字中,依此类推。调用syscall_handler()的调用者的堆栈指针可以作为传递给它的“结构intr_frame”的“esp”成员访问。 (struct intr_frame在内核堆栈上。)
函数返回值的80x86约定是将它们放在“EAX”寄存器中。 返回值的系统调用可以通过修改struct intr_frame的“eax”成员来实现。
您应尽量避免编写大量重复的代码来实现系统调用。每个系统调用参数(无论是整数还是指针)在堆栈中占用4个字节。您应该能够利用这一优势,避免编写太多几乎相同的代码来从堆栈中检索每个系统调用的参数。