6

我正在研究内核设计,我有一些关于分页的问题。

到目前为止,我的基本想法是:每个程序都有自己的(或者它认为的)4G 内存,减去我为程序可以调用的内核函数保留的部分。因此,操作系统需要找出某种方法来加载程序在运行期间需要使用的内存页面。

现在,假设我们有无限量的内存和处理器时间,我可以加载/分配程序写入或读取的任何页面,因为它使用不存在(或被换出)的页面的页面错误,因此操作系统可以快速分配或交换它们。但在现实世界中,我需要优化这个过程,这样我们就不会有一个程序不断消耗它曾经接触过的所有内存。

所以我想我的问题是,操作系统通常是如何解决这个问题的?我最初的想法是创建一个程序调用来设置/释放页面的函数,然后它可以自行管理内存,但是程序通常会这样做,还是编译器假设它有自由支配权?此外,编译器如何处理需要分配相当大的内存段的情况?我是否需要提供一个尝试按顺序给它 X 页的功能?

这显然不是一个特定于语言的问题,但我偏爱标准 C 并且擅长 C++,所以我希望任何代码示例都在那个或汇编中。(汇编应该不是必需的,我完全打算让它与尽可能多的 C 代码一起工作,并作为最后一步进行优化。)

另一件事也应该更容易回答:通常如何处理程序需要调用的内核函数?是否可以只拥有一个包含程序可以调用的大多数基本功能/进程特定内存的内存区域(我正在考虑虚拟空间的末尾)?我的想法是让内核函数做一些非常花哨的事情并在程序需要做任何重大事情时交换页面(这样程序就无法在自己的空间中看到敏感的内核函数),但我不是真的在这一点上专注于安全。

所以我想我更担心一般的设计理念而不是细节。我想让内核(以某种方式)与 GCC 完全兼容,并且我需要确保它提供了普通程序所需的一切。

感谢您的任何建议。

4

2 回答 2

13

所有这些问题的一个很好的起点是看看 Unix 是如何做到的。正如一句名言所说,“那些不了解 UNIX 的人注定要重新发明它,很糟糕。”

首先,关于调用内核函数。仅仅将函数放在程序可以调用的地方是不够的,因为程序很可能在“用户模式”下运行(IA-32 上的 ring 3),而内核必须在“内核模式”下运行(通常是 ring 0在 IA-32 上)执行其特权操作。您必须以某种方式在两种模式之间进行转换,这是非常特定于架构的。

在 IA-32 上,传统方式是在 IDT 中使用门和软件中断(Linux 使用 int 0x80)。较新的处理器还有其他(更快的)方法可以做到这一点,哪些可用取决于 CPU 是来自 AMD 还是 Intel,以及特定的 CPU 型号。为了适应这种变化,最近的 Linux 内核在每个进程的地址空间顶部使用由内核映射的代码页。因此,在最近的 Linux 上,要进行系统调用,您需要调用此页面上的一个函数,该函数将依次执行切换到内核模式所需的任何操作(内核拥有该页面的多个副本,并选择使用哪个副本启动时取决于您的处理器的功能)。

现在,内存管理。这是一个巨大的主题;你可以写一本关于它的大书,而不是对这个主题感到厌烦。

请务必记住,内存至少有两个视图:物理视图(页面的真实顺序,对硬件内存子系统和外部外围设备可见)和逻辑视图(页面的顺序) CPU 上运行的程序可以看到)。很容易混淆两者。您将分配物理页面并将它们分配给程序或内核地址空间上的逻辑地址。一个物理页可以有多个逻辑地址,在不同的进程中可以映射到不同的逻辑地址。

内核内存(为内核保留)通常映射在每个进程的地址空间的顶部。但是,它被设置为只能在内核模式下访问。不需要花哨的技巧来隐藏那部分内存。硬件完成所有阻止访问的工作(在 IA-32 上,它是通过页面标志或段限制完成的)。

程序以几种方式在地址空间的其余部分分配内存:

  • 部分内存由内核的程序加载器分配。这包括程序代码(或“文本”)、程序初始化数据(“数据”)、程序未初始化数据(“bss”,零填充)、堆栈和一些零碎的东西。从要加载的可执行文件的头文件中读取分配多少、在哪里、应该是什么初始内容、使用哪些保护标志以及其他一些内容。
  • 传统上,在 Unix 上,有一个内存区域可以增长和缩小(其上限可以通过brk()系统调用更改)。这传统上由堆使用(C 库上的内存分配器,它malloc()的接口之一,负责堆)。
  • 您通常可以要求内核将文件映射到地址空间区域。对该区域的读取和写入(通过分页魔法)定向到后备文件。这通常称为mmap(). 使用匿名mmap,您可以分配不受任何文件支持的地址空间的新区域,但以相同的方式操作。内核的程序加载器通常会用于mmap分配部分程序代码(例如,程序代码可以由可执行文件本身支持)。

访问未以任何方式分配(或为内核保留)的地址空间区域被视为错误,并且在 Unix 上将导致向程序发送信号。

编译器要么静态分配内存(通过在可执行文件头中指定它;内核的程序加载器将在加载程序时分配内存)或动态(通过调用语言标准库中的函数,然后通常调用C 语言标准库,然后调用内核分配内存并在必要时对其进行细分)。

学习所有这些基础知识的最好方法是阅读几本关于操作系统的书籍中的一本,尤其是那些使用 Unix 变体作为示例的书籍。它将比我在 StackOverflow 上的答案更详细。

于 2008-11-07T04:18:17.813 回答
6

这个问题的答案高度依赖于架构。我假设你在谈论 x86。对于 x86,内核通常提供一组系统调用,它们是内核的预定入口点。用户代码只能在这些特定点进入内核,因此内核可以仔细控制它与用户代码的交互方式。

在 x86 中,有两种方法可以实现系统调用:使用中断和使用 sysenter/sysexit 指令。对于中断,内核建立了一个中断描述符表(IDT),它定义了进入内核的可能入口点。然后,用户代码可以使用该int指令生成软中断以调用内核。中断也可以由硬件产生(所谓的硬中断);这些中断通常应该与软中断不同,但它们不必如此。

sysenter 和 sysexit 指令是执行系统调用的更快方法,因为处理中断很慢;我对使用它们不是很熟悉,所以我无法评论它们是否更适合您的情况。

无论您使用哪个,您都必须定义系统调用接口。您可能希望在寄存器中而不是在堆栈中传递系统调用参数,因为生成中断会导致您将堆栈切换到内核堆栈。这意味着您几乎肯定必须在用户模式端编写一些汇编语言存根来进行系统调用,并再次在内核端编写一些汇编语言存根来收集系统调用参数并保存寄存器。

一旦你准备好了所有这些,你就可以开始考虑处理页面错误了。页面错误实际上只是另一种类型的中断 - 当用户代码尝试访问没有页表条目的虚拟地址时,它将生成中断 14,并且您还将获得错误地址作为错误代码。内核可以获取这些信息,然后决定从磁盘中读取丢失的页面,添加页表映射,然后跳转回用户代码。

我强烈建议您查看MIT 操作系统课程中的一些材料。查看参考部分,它有很多好东西。

于 2008-11-07T03:22:24.820 回答