引言
本章导读
本章展现了操作系统一系列功能:
通过动态内存分配,提高了应用程序对内存的动态使用效率
通过页表的虚实内存映射机制,简化了编译器对应用的地址空间设置
通过页表的虚实内存映射机制,加强了应用之间,应用与内核之间的内存隔离,增强了系统安全
通过页表的虚实内存映射机制,可以实现空分复用(提出,但没有实现)
上一章,我们分别实现了多道程序和分时多任务系统,它们的核心机制都是任务切换。由于多道程序和分时多任务系统的设计初衷不同,它们在任务切换的时机和策略也不同。有趣的一点是,任务切换机制对于应用是完全 透明 (Transparent) 的,应用可以不对内核实现该机制的策略做任何假定(除非要进行某些针对性优化),甚至可以完全不知道这机制的存在。
在大多数应用(也就是应用开发者)的视角中,它们会独占一整个 CPU 和特定(连续或不连续)的内存空间。当然,通过上一章的学习,我们知道在现代操作系统中,出于公平性的考虑,我们极少会让独占CPU这种情况发生。所以应用自认为的独占CPU只是内核想让应用看到的一种 幻象 (Illusion) ,而 CPU 计算资源被 时分复用 (TDM, Time-Division Multiplexing) 的实质被内核通过恰当的抽象隐藏了起来,对应用不可见。
与之相对,我们目前还没有对内存管理功能进行有效的管理,仅仅是把程序放到某处的物理内存中。在内存访问方面,所有的应用都直接通过物理地址访问物理内存,这使得应用开发者需要了解繁琐的物理地址空间布局,访问内存也很不方便。在上一章中,出于任务切换的需要,所有的应用都在初始化阶段被加载到内存中并同时驻留下去直到它们全部运行结束。而且,所有的应用都直接通过物理地址访问物理内存。这会带来以下问题:
首先,内核提供给应用的内存访问接口不够透明,也不好用。由于应用直接访问物理内存,这需要它在构建的时候就需要规划自己需要被加载到哪个地址运行。为了避免冲突可能还需要应用的开发者们对此进行协商,这显然是一件在今天看来不可理喻且极端麻烦的事情。
其次,内核并没有对应用的访存行为进行任何保护措施,每个应用都有整块物理内存的读写权力。即使应用被限制在 U 特权级下运行,它还是能够造成很多麻烦:比如它可以读写其他应用的数据来窃取信息或者破坏它的正常运行;甚至它还可以修改内核的代码段来替换掉原本的
trap_handler
来挟持内核执行恶意代码。总之,这造成系统既不安全、也不稳定。再次,目前应用的内存使用空间在其运行前已经限定死了,内核不能灵活地给应用程序提供的运行时动态可用内存空间。比如一个应用结束后,这个应用所占的空间就被释放了,但这块空间无法动态地给其它还在运行的应用使用。
因此,为了防止应用胡作非为,本章将更好的管理物理内存,并提供给应用一个抽象出来的更加透明易用、也更加安全的访存接口,这就是基于分页机制的虚拟内存。站在应用程序运行的角度看,就是存在一个从“0”地址开始的非常大的可读/可写/可执行的地址空间(Address Space)。
实现地址空间的第一步就是实现分页机制,建立好虚拟内存和物理内存的页映射关系。此过程涉及硬件细节,不同的地址映射关系组合,相对比较复杂。总体而言,我们需要思考如下问题:
硬件中物理内存的范围是什么?
哪些物理内存空间需要建立页映射关系?
如何建立页表使能分页机制?
如何确保OS能够在分页机制使能前后的不同时间段中都能正常寻址和执行代码?
页目录表(一级)的起始地址设置在哪里?
二级/三级等页表的起始地址设置在哪里,需要多大空间?
如何设置页目录表项的内容?
如何设置其它页表项的内容?
如果要让每个任务有自己的地址空间,那每个任务是否要有自己的页表?
代表应用程序的任务和操作系统需要有各自的页表吗?
在有了页表之后,任务和操作系统之间应该如何传递数据?
如果能解决上述问题,我们就能设计实现具有超强防护能力的侏罗纪“头甲龙”操作系统。并可更好地理解地址空间,虚拟地址等操作系统的抽象概念与操作系统的虚存具体实现之间的联系。
实践体验
本章的应用和上一章相同,只不过由于内核提供给应用的访存接口被替换,应用的构建方式发生了变化,这方面在下面会深入介绍。 因此应用运行起来的效果与上一章是一致的。
获取本章代码:
$ git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
$ cd rCore-Tutorial-v3
$ git checkout ch4
在 qemu 模拟器上运行本章代码:
$ cd os
$ make run
将 Maix 系列开发板连接到 PC,并在上面运行本章代码:
$ cd os
$ make run BOARD=k210
如果顺利的话,我们将看到和上一章相同的运行结果(以 K210 平台为例):
[rustsbi] RustSBI version 0.1.1
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: K210 (Version 0.1.0)
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x22
[rustsbi] medeleg: 0x1ab
[rustsbi] Kernel entry: 0x80020000
[kernel] Hello, world!
.text [0x80020000, 0x8002b000)
.rodata [0x8002b000, 0x8002e000)
.data [0x8002e000, 0x8004c000)
.bss [0x8004c000, 0x8035d000)
mapping .text section
mapping .rodata section
mapping .data section
mapping .bss section
mapping physical memory
[kernel] back to world!
remap_test passed!
init TASK_MANAGER
num_app = 4
power_3 [10000/300000power_5 [10000/210000]
power_5 [20000/210000]
power_5 [30000/210000]
...
(mod 998244353)
Test power_7 OK!
[kernel] Application exited with code 0
power_3 [290000/300000]
power_3 [300000/300000]
3^300000 = 612461288(mod 998244353)
Test power_3 OK!
[kernel] Application exited with code 0
Test sleep OK!
[kernel] Application exited with code 0
[kernel] Panicked at src/task/mod.rs:112 All applications completed!
[rustsbi] reset triggered! todo: shutdown all harts on k210; program halt. Type: 0, reason: 0
本章代码树
|
|
本章代码导读
本章涉及的代码量相对多了起来,也许同学们不知如何从哪里看起或从哪里开始尝试实验。这里简要介绍一下“头甲龙”操作系统的大致开发过程。
我们先从简单的地方入手,那当然就是先改进应用程序了。具体而言,主要就是把 linker.ld
中应用程序的起始地址都改为 0x0
,这是假定我们操作系统能够通过分页机制把不同应用的相同虚地址映射到不同的物理地址中。这样我们写应用就不用考虑物理地址布局的问题,能够以一种更加统一的方式编写应用程序,可以忽略掉一些不必要的细节。
为了能够在内核中动态分配内存,我们的第二步需要在内核增加连续内存分配的功能,具体实现主要集中在 os/src/mm/heap_allocator.rs
中。完成这一步后,我们就可以在内核中用到Rust的堆数据结构了,如 Vec
、 Box
等,这样内核编程就更加灵活了。
操作系统如果要建立页表,首先要能管理整个系统的物理内存,这就需要知道物理内存哪些区域放置内核的代码、数据,哪些区域则是空闲的等信息。所以需要了解整个系统的物理内存空间的范围,并以物理页帧为单位分配和回收物理内存,具体实现主要集中在 os/src/mm/frame_allocator.rs
中。
页表中的页表项的索引其实是虚拟地址中的虚拟页号,页表项的重要内容是物理地址的物理页帧号。为了能够灵活地在虚拟地址、物理地址、虚拟页号、物理页号之间进行各种转换,在 os/src/mm/address.rs
中实现了各种转换函数。
完成上述工作后,基本上就做好了建立页表的前期准备。我们就可以开始建立页表,这主要涉及到页表项的数据结构表示,以及多级页表的起始物理页帧位置和整个所占用的物理页帧的记录。具体实现主要集中在 os/src/mm/page_table.rs
中。
一旦使能分页机制,那么内核中也将基于虚地址进行虚存访问,所以在给应用添加虚拟地址空间前,内核自己也会建立一个页表,把整个物理地址空间通过简单的恒等映射对应到一个虚拟地址空间中。后续的应用在执行前,也需要建立一个虚拟地址空间,这意味着第三章的 task
将进化到第五章的拥有独立页表的进程 。虚拟地址空间需要有一个数据结构管理起来,这就是 MemorySet
,即地址空间这个抽象概念所对应的具象体现。在一个虚拟地址空间中,有代码段,数据段等不同属性且不一定连续的子空间,它们通过一个重要的数据结构 MapArea
来表示和管理。围绕 MemorySet
等一系列的数据结构和相关操作的实现,主要集中在 os/src/mm/memory_set.rs
中。比如内核的页表和虚拟空间的建立在如下代码中:
|
|
完成到这里,我们就可以使能分页机制了。且我们应该有更加方便的机制来给支持应用运行。在本章之前,都是把应用程序的所有元数据丢弃从而转换成二进制格式来执行,这其实把编译器生成的 ELF 执行文件中大量有用的信息给去掉了,比如代码段、数据段的各种属性,程序的入口地址等。既然有了给应用运行提供虚拟地址空间的能力,我们就可以利用 ELF 执行文件中的各种信息来灵活构建应用运行所需要的虚拟地址空间。在 os/src/loader.rs
中可以看到如何获取一个应用的 ELF 执行文件数据,而在 os/src/mm/memory_set
中的 MemorySet::from_elf
可以看到如何通过解析 ELF 来创建一个应用地址空间。
对于有了虚拟地址空间的 任务 ,我们可以把它叫做 进程 了。操作系统为此需要扩展任务控制块 TaskControlBlock
的管理范围,使得操作系统能管理拥有独立页表和虚拟地址空间的应用程序的运行。相关主要的改动集中在 os/src/task/task.rs
中。
由于代表应用程序运行的进程和管理应用的操作系统各自有独立的页表和虚拟地址空间,所以这就出现了两个比较挑战的事情。一个是由于系统调用、中断或异常导致的应用程序和操作系统之间的 Trap 上下文切换不像以前那么简单了,因为需要切换页表,这需要看看 os/src/trap/trap.S
;还有就是需要对来自用户态和内核态的 Trap 分别进行处理,这需要看看 os/src/trap/mod.rs
和 跳板的实现 中的讲解。
另外一个挑战是,在内核地址空间中执行的内核代码常常需要读写应用地址空间的数据,这无法简单的通过一次访存交给 MMU 来解决,而是需要手动查应用地址空间的页表。在访问应用地址空间中的一块跨多个页数据的时候还需要注意处理边界条件。可以参考 os/src/syscall/fs.rs
、 os/src/mm/page_table.rs
中的 translated_byte_buffer
函数的实现。
实现到这,应该就可以给应用程序运行提供一个方便且安全的虚拟地址空间了。