这一章代码变化比较大,实现了虚拟内存功能
.
├── os
│ └── src
│ ├── config.rs
│ ├── linker.ld
│ ├── loader.rs
│ ├── main.rs
│ ├── mm
│ │ ├── address.rs
│ │ ├── frame_allocator.rs
│ │ ├── heap_allocator.rs
│ │ ├── memory_set.rs
│ │ ├── mod.rs
│ │ └── page_table.rs
│ ├── syscall
│ │ ├── fs.rs
│ │ ├── mod.rs
│ │ └── process.rs
│ ├── task
│ │ ├── context.rs
│ │ ├── mod.rs
│ │ ├── switch.rs
│ │ ├── switch.S
│ │ └── task.rs
│ └── trap
│ ├── context.rs
│ ├── mod.rs
│ └── trap.S
└── user
└── build.py
在前面的章节中由于没有堆所以数据都是在固定长度的数组中存放的,这限制了内核的灵活性和扩展性。在Rust中依赖于堆的数据结构和智能指针都单独放在了 alloc crate
中,只要为 alloc crate
实现 GlobalAlloc trait
的两个函数接口,crate中的所有内容就都可以使用了。但是在这里我们没有自己实现这两个接口而是使用了 buddy_system_allocator crate
。buddy_system_allocator
提供了一个实现了 GlobalAlloc triat
的数据结构 LockedHeap
,我们使用时要做的就是传入一段或多段连续内存。
这样做一方面是减少浪费,另一方面也是更重要的是能够使用alloc中的各种抽象。
os/src/mm/heap_allocator.rs
实现了上面所说的内容。
MMU是一个硬件模块,页表是一个MMU约定的内存中的数据结构。浅显地说,当MMU使能,CPU访问内存时会经过MMU的地址转换而不是直接访问(当然也就存在着一边使用页表一边修改页表的可能)。页表是一个类似于字典树的数据结构,SV39所约定的页表则可以看作是一个三层字典树,字典树的“字”是9位二进制,“词”为27位二进制。
SV39也指虚拟地址有39位,其中高27位是上面所说的“词”即页号,低12位是页内偏移也就是每页4KB的大小。总共512GB的寻址空间。页表除了映射关系还保存着一个选项字节用于权限控制,比如 R/W/X 三个位用于控制是否允许读/写/取指。
但物理地址并非也是39位而是56位即高44位的页号和低12位的页内偏移。
os/src/mm/address.rs
os/src/mm/page_table.rs
两个文件中的结构体是对各种地址、页号和页表结构的抽象,通过封装将原本都是 usize
的内容纳入到了Rust的所有权体系,也实现了各种辅助函数方便使用。
satp
寄存器用于控制MMU,存储了运行模式、一个与TLB有关的ASID和页表根节点地址,三个信息。
不仅是应用,我们的内核也使用虚拟地址,一方面是为了安全一方面是让地址这个原本只能硬编码的访问方式更灵活。
在机器上电后第一次进入内核前sbi会关闭MMU,这时内核是直接访问物理地址的,在构造好内核的页表之后就可以通过写入satp
寄存器使能MMU。
这里一个比较关键的点是无论对于satp
写入什么信息,pc指针都会像往常一样后移,但取指也是需要经过MMU的,这就需要保证写入satp
寄存器的命令所在的那段代码或者说那个函数在MMU使能或关闭前后处于同样的地址。或许通过精细地控制映射关系可以使程序跳转到一个可控的位置,但是如果不是特殊需要的话这种处理方式更容易出错也难以理解
为了对内核自己也进行一定程度的保护,我们修改 os/src/linker.ld
使内核各个段与页对齐并在页表中给各个段最小限度的权限,比如只有 .text
段才能取指。
在启用了MMU之后就有了一个抽象的内存空间的概念,它相比物理内存可用的地址更少了,但增加了各段地址的权限信息。os/src/mm/memory_set.rs
具象了内存空间,也是用来管理内存空间的。其中 MapArea
结构体表示一段连续的拥有相同权限的虚拟地址,通过该结构体的方法可以方便的维护 PageTable
并保持一致性。而内核的其他部分也就可以基于 MemorySet
来管理内核和各个应用的内存空间了。
我在学习本章时一个比较困惑的点是各种数据结构的实例到底都放在物理内存的哪个部分。由于虚拟地址空间只是一个抽象概念,归根结底在机器上数据要么在寄存器里要么在8MB大的物理内存里。所以我就简单分析一下:
- 首先这一章并没有涉及用户堆,也就是说所有与堆有关的数据都在内核堆中,而内核堆被定义在了
.bss
段中也就相当于是内核中一个比较大的全局变量。 - 内核中的数据结构大量的使用了
alloc crate
中的工具也就是说大部分数据在堆中小部分数据在栈中(比较特殊的是页表项并没有在二者之中),而内核栈也在.bss
段中 - 这就表示内核除了
.text/.rodata/.data/.bss
四个段外没有占用其他的物理内存,那应用呢?由于此时还没有文件的概念应用依旧是包含在内核的.text
段中的 - 综上所述,从内核的结尾到内存的结尾这段地址基本是空闲状态,我们将这段内存交给
os/src/mm/frame_allocator.rs
页帧管理器,页帧管理器以页为单位提供了物理内存的申请和回收。 - 页帧目前有两个用途,一个是用于存放页表,另一个是用于存放应用数据
有了内存空间后应用开始有了一些进程的样子了,现在应用不需要在编译时确定自己的运行时地址,各个应用也不用专门错开了,user/build.py
也直接删掉了,在连接时也会使用没有删除符号信息的elf文件了。
在应用启动前内核会先构造内存空间,通过解析elf确定应用的入口地址,将代码复制到对应的虚拟地址,增加用户栈等内容,再启动应用(而这些数据实际上存储在页帧管理器分配的物理内存中)。所以原本用于加载应用的 os/src/loader.rs
也被删得只剩两个函数。原本每个应用对应的内核栈则提前分配在内核的内存空间中。
os/src/syscall
和 os/src/task
都因为内存空间的出现做了一些修改,但变化不是很大,变化比较大的是 os/src/trap
。
在之前的特权级切换中比较关键的是用户栈和内核栈的切换,但有了内存空间后切换起来要更复杂,我们除了换栈还要换内存空间。但如前面提到的,写 satp
寄存器时pc指针会简单地后移,所以一个比较简单的实现方法是将负责切换内存空间的函数放在所有内存空间中相同的地址上。实验指导书中将这个称为“跳板”,而这个跳板实际上就是 os/src/trap/trap.S
中的两个函数。这一对函数就像是将各个内存空间钉在一起的钉子,让pc指针能够安全地在各个内存空间之间移动。