英文原文 - 中文出处
Rust是一门系统编程语言,专注于安全,尤其是并发安全,支持函数式和命令式以及泛型等编程范式的多范式语言。Rust在语法上和C++类似,但是设计者想要在保证性能的同时提供更好的内存安全。 Rust最初是由Mozilla研究院的Graydon Hoare设计创造,然后在Dave Herman, Brendan Eich以及很多其他人的贡献下逐步完善的。 Rust的设计者们通过在研发Servo网站浏览器布局引擎过程中积累的经验优化了Rust语言和Rust编译器。
Rust编译器是在MIT License 和 Apache License 2.0双重协议声明下的免费开源软件。 Rust已经连续三年(2016,2017,2018)在Stack Overflow开发者调查的“最受喜爱编程语言”评选项目中折取桂冠。
本文介绍了内存分页技术,这是一种非常常见的内存管理方案,我们也将该技术用于通过 Rust 编写操作系统中。内存分页技术解释了为什么需要内存隔离、分段如何工作、虚拟内存是什么以及分页如何解决内存碎片(memory fragmentation)问题。另外,本文还探讨了x86_64体系结构上的多级页表布局情况
内存保护
操作系统的一个主要任务是将程序彼此隔离。例如,web浏览器不应该干扰文本编辑器。为了实现这个目标,操作系统利用硬件功能来确保一个进程的内存区域不被其他进程访问。根据硬件和操作系统实现的不同,有不同的方法。
例如,一些ARM Cortex-M处理器(用于嵌入式系统)有一个内存保护单元(MPU),它允许你定义少量具有不同访问权限(例如无访问权限、只读限、写入权限)的内存区域。在每次内存访问时,MPU都会确保该地址位于具有正确访问权限的区域,否则发出异常警报。通过更改每个进程切换(process switch)上的区域和访问权限,操作系统可以确保每个进程只访问自己的内存,从而将进程彼此隔离。
在x86上,硬件支持两种不同的内存保护方法:分段和分页。
分段
分段技术早在1978年就已经被开发出来了,最初是为了增加可寻址内存的数量。当时的情况是cpu只使用16位地址,这将可寻址内存的数量限制为64KiB。为了使更多的64KiB可访问,就需要引入额外的段寄存器,每个段寄存器包含一个偏移地址。CPU会在每次内存访问时自动添加这个偏移量,以便可访问高达1MiB的内存。
段寄存器由CPU根据内存访问的类型自动选择,比如获取指令使用代码段CS,而堆栈操作(push/pop)则使用堆栈段SS。其他指令使用数据段DS或额外段ES。后来,又增加了两个可以自由使用的段寄存器FS和GS。
在第一版的分段技术中,段寄存器直接包含偏移量,不执行访问控制。后来,随着保护模式的引入,这种情况发生了改变。当CPU以此模式运行时,段描述符包含本地或全局描述符表的索引,该表除了包含偏移地址外,还包含段大小和访问权限。通过为每个进程加载单独的全局或本地描述符表,将内存访问限制在进程自己的内存区域,操作系统可以将进程彼此隔离。
通过在实际访问之前修改内存地址,分段技术已经使用了一种几乎无处不在的技术——虚拟内存。
虚拟内存
虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。目前,大多数操作系统都使用了虚拟内存,如Windows家族的“虚拟内存”;Linux的“交换空间”等。
Windows 8/8.1 操作系统如出现开机时卡在Windows徽标页面,无法进入系统,必须强制关机再重启才能打开时,可适当调整虚拟内存设置解决。
归根结底,虚拟内存背后的思想是从底层物理存储设备抽象出内存地址。首先执行转换步骤,而不是直接访问存储设备。对于分段来说,转换步骤是添加活动段的偏移地址。假设一个程序在偏移量0x1111000的段中访问内存地址0x1234000,则经过转换,实际访问的地址是0x2345000。
为了区分这两种地址类型,转换前的地址称为虚拟地址,转换后的地址称为物理地址。这两种地址的一个重要区别是,物理地址是惟一的,并且总是指向相同的、不同的内存位置。另一方面,虚拟地址依赖于转换功能。所以很有可能,两个不同的虚拟地址完全有可能指向同一个物理地址。同样,相同的虚拟地址在使用不同的转换功能时可以引用不同的物理地址。
如下所示,使用这个属性的一个示例是,并行运行相同的程序两次:
![474d8b0d6d8d9fa002be0657d399df90.png](https://i-blog.csdnimg.cn/blog_migrate/37d4deabd504e72da81038b340a42af6.jpeg)
在这个示例中,相同的程序运行两次,但却使用了不同的转换功能。第一个示例的段偏移量为100,因此它的虚拟地址0-150被转换为物理地址100 – 250。第二个示例的段偏移量为300,因此它的虚拟地址0-150被转换为物理地址300 – 450。这允许两个程序运行相同的代码并使用相同的虚拟地址,但不会相互干扰。
另一个优点是,程序现在可以放在任意物理内存位置,即使它们使用完全不同的虚拟地址。因此,操作系统可以利用全部可用内存而无需重新编译程序。
内存碎片
内存分配有静态分配和动态分配两种, 静态分配在程序编译链接时分配的大小和使用寿命就已经确定,而应用上要求操作系统可以提供给进程运行时申请和释放任意大小内存的功能,这就是内存的动态分配。
因此动态分配将不可避免会产生内存碎片的问题,那么什么是内存碎片?内存碎片即“碎片的内存”描述一个系统中所有不可用的空闲内存,这些碎片之所以不能被使用,是因为负责动态分配内存的分配算法使得这些空闲的内存无法使用,这一问题的发生,原因在于这些空闲内存以小且不连续方式出现在不同的位置。因此这个问题的或大或小取决于内存管理算法的实现上。
虚拟地址和物理地址的区别使得分段功能显得异常强大,然而,分段却存在着碎片化的问题。例如,假设我们想运行上面看到的程序的第三个副本:
![41d38c63cf05d858cca9c0aed66747fb.png](https://i-blog.csdnimg.cn/blog_migrate/e0ae15dfd25bc32ccc4565d8bf95f59a.jpeg)
即使有足够多的空闲内存可用,也不可能不重叠地将程序的第三个示例映射到虚拟内存。问题是我们需要连续的内存,而不能使用小的空闲块。
解决这种碎片问题的一种方法是暂停执行,将内存中已使用部分整合到起,更新转换后,然后重新执行
![ebab3b748938e4f9bf7397ade61f4cb2.png](https://i-blog.csdnimg.cn/blog_migrate/bddcb95f666374e9dd7f2cc93c9fc7e3.jpeg)
现在,就有足够的连续空间来启动程序的第三个示例了。
不过,这种碎片整理过程的缺点是需要复制大量内存,这会降低性能。在内存变得过于碎片化之前,还需要定期执行此操作。这使得性能变得不可预测,因为程序会在随机时间暂停,并且可能变得无响应。
碎片问题是大多数系统不再使用分段的原因之一,实际上,x86上的64位模式甚至不再支持分段。而是使用分页,这完全避免了碎片问题。
分页
分页的思想是将虚拟内存空间和物理内存空间都划分为固定大小的小块,此时虚拟内存空间的块称为页,物理地址空间的块称为帧。每个页面都可以单独映射到一个帧,这使得在非连续物理帧之间,分割出更大的内存区域成为可能。
如果我们回顾一下碎片内存空间的示例,就会发现使用分页的优势。
![b3a66031c235639c9cb6c3a257009e72.png](https://i-blog.csdnimg.cn/blog_migrate/b94f082c5ee227ab315899dad70eb199.jpeg)
在本文的示例中,我们有一个50字节的页面大小,这意味着我们的每个内存区域都被分为三个页面。每个页面都单独映射到一个帧,因此连续的虚拟内存区域可以映射到非连续的物理帧,这允许我们在不执行任何碎片整理之前启动程序的第三个示例。
隐藏的碎片
与分段相比,分页会造成大量小的、固定大小的内存区域,而不像分段那样,是一些大的、可变大小的区域。因为每个帧都有相同的大小,所以没有太小而不能使用的帧,因此不会发生碎片。
表面上起来,是不会发生什么碎片问题的。但其实还是有一些隐藏的碎片,也就是所谓的内部碎片。发生内部碎片是因为并非每个内存区域都是页面大小的精确倍数。
内部碎片的产生:因为所有的内存分配必须起始于可被 4、8 或 16 整除(视处理器体系结构而定)的地址或者因为MMU的分页机制的限制,决定内存分配算法仅能把预定大小的内存块分配给客户。假设当某个客户请求一个 43 字节的内存块时,因为没有适合大小的内存,所以它可能会获得 44字节、48字节等稍大一点的字节,因此由所需大小四舍五入而产生的多余空间就叫内部碎片。
内部碎片虽然无法避免,且仍然会浪费内存,但不需要进行碎片整理,并且可以预测碎片数量(每个内存区域平均有半页)。
页表
我们看到,每个潜在的数百万个页面都被单独映射到一个帧,而这个映射信息需要存储在某个地方。分段为每个活动的内存区域使用单独的段选择器寄存器。而分页则是不可能实现这个的,因为页面比寄存器多得多,分页使用名为page table的表结构来存储映射信息。
对于上面的示例,页表应该是这样的:
![0609c952c63049b0215798eb739725cd.png](https://i-blog.csdnimg.cn/blog_migrate/8001b0972cefe96f649a23913321959b.jpeg)
我们看到每个程序示例都有自己的页表,指向当前活动表的指针存储在一个特殊的CPU寄存器中。在x86上,这个寄存器称为CR3。操作系统的任务是在运行每个程序示例之前,用指向正确页表的指针加载该寄存器。
在每次内存访问时,CPU从寄存器中读取表指针,并在表中查找被访问页面的映射帧。这完全是在硬件中完成的,并且对运行中的程序完全透明。为了加快转换过程,许多CPU架构都有一个特殊的缓存,它可以记住最后一次转换的结果。
根据体系结构的不同,页表条目还可以在标志字段中存储访问权限等属性。在上面的示例中,“r/w”标志使页面可读又可写。
多级页表
我们刚才看到的简单页表在较大的地址空间中存在一个问题:浪费内存。例如,假设一个程序使用四个虚拟页面0、1_000_000、1_000_050和1_000_100(我们使用_作为千位分隔符)。
![28e0c7778e05b34e09b80a35f6bfe474.png](https://i-blog.csdnimg.cn/blog_migrate/498f2e78e055acdfbaee5969672b368a.jpeg)
它只需要4个物理帧,但是页表有超过100万个条目。我们不能省略空条目,因为这样CPU就不能再直接跳转到转换过程中的正确条目,例如,不能再保证第四个页面使用第四个条目。
为了减少内存的浪费,我们可以使用一个两级页表( two-level page table),为不同的地址区域使用不同的页表。另一个名为level 2 页表的附加表则包含地址区域和(level 1)页表之间的映射。
下面举一个示例来解释,让我们定义每个1级页表负责一个大小为10_000的区域。然后,上面的示例映射将存在以下表:
![e2e2ffd111733c6fe333c48b69feeceb.png](https://i-blog.csdnimg.cn/blog_migrate/813fb223b8f7fd5c79f86e45f84501aa.jpeg)
第0页属于第一个10_000字节区域,因此它使用2级页表的第一个条目。此条目指向1级页表T1,指定页0指向第0帧。
页1_000_000、1_000_050和1_000_100都属于第100个10_000字节区域,因此它们使用2级页表的第100个条目。该条目指向不同的1级页表T2,该表将这三个页面映射到帧100、150和200。注意,1级表中的页地址不包括区域偏移量,因此,例如第1_000_050页的条目仅为50。
现在,我们在2级表中仍然有100个空条目,但是比之前的100万个空条目要少得多。其原因是,我们不需要为10_000到1_000_000之间的未映射内存区域创建1级页表。
两级页表的原理可以扩展到三级,四级或更多级,然后页表寄存器指向最高级别的表,该表指向下一个较低级别的表,依此类推。然后,1级页表指向映射的帧,该原则一般称为多级或分层页表。
既然我们已经知道分页和多级页表是如何工作的,看一下如何在x86_64架构中实现分页(我们假设CPU在64位模式下运行)。
本文我们对分段和分页的优缺点进行了介绍后,还是决定用分页技术对编写操作系统。下篇文章,我会接着介绍分页的具体使用过程和其中所遇到的问题。