type
status
slug
date
summary
tags
category
password
icon
前置知识
地址空间
为了解决这种困境,抽象仍然是最重要的指导思想。在这里,抽象意味着内核要负责将物理内存管理起来,并为上面的应用提供一层抽象接口,从之前的失败经验学习,这层抽象需要达成下面的设计目标:
- 透明 :应用开发者可以不必了解底层真实物理内存的硬件细节,且在非必要时也不必关心内核的实现策略, 最小化他们的心智负担;
- 高效 :这层抽象至少在大多数情况下不应带来过大的额外开销;
- 安全 :这层抽象应该有效检测并阻止应用读写其他应用或内核的代码、数据等一系列恶意行为。
在每个应用程序的视角里,操作系统分配给应用程序一个地址范围受限(容量很大),独占的连续地址空间(其中有些地方被操作系统限制不能访问,如内核本身占用的虚地址空间等),因此应用程序可以在划分给它的地址空间中随意规划内存布局,它的各个段也就可以分别放置在地址空间中它希望的位置(当然是操作系统允许应用访问的地址)。应用同样可以使用一个地址作为索引来读写自己地址空间的数据,就像用物理地址作为索引来读写物理内存上的数据一样。这种地址被称为 虚拟地址 (Virtual Address) 。当然,操作系统要达到地址空间抽象的设计目标,需要有计算机硬件的支持,这就是计算机组成原理课上讲到的
MMU
和 TLB
等硬件机制。我们知道应用的数据终归还是存在物理内存中的,那么虚拟地址如何形成地址空间,虚拟地址空间如何转换为物理内存呢?操作系统可以设计巧妙的数据结构来表示地址空间。但如果完全由操作系统来完成转换每次处理器地址访问所需的虚实地址转换,那开销就太大了。这就需要扩展硬件功能来加速地址转换过程(回忆 计算机组成原理 课上讲的
MMU
和 TLB
)。
将来自不同两个应用地址空间的相同虚拟地址转换成不同的物理地址。要做到这一点,就需要硬件提供一些寄存器,软件可以对它进行设置来控制 MMU 按照哪个应用的地址映射关系进行地址转换。于是,将应用的代码/数据放到物理内存并进行管理,建立好应用的地址映射关系,在任务切换时控制 MMU 选用应用的地址映射关系,则是作为软件部分的内核需要完成的重要工作。
回过头来,在介绍内核对于 CPU 资源的抽象——时分复用的时候,我们曾经提到它为应用制造了一种每个应用独占整个 CPU 的幻象,而隐藏了多个应用分时共享 CPU 的实质。而地址空间也是如此,应用只需、也只能看到它独占整个地址空间的幻象,而藏在背后的实质仍然是多个应用共享物理内存,它们的数据分别存放在内存的不同位置。如上图所示,内核以页为单位进行物理内存管理。每个应用的地址空间可以被分成若干个(虚拟) 页面 (Page) ,而可用的物理内存也同样可以被分成若干个(物理) 页帧 (Frame) ,虚拟页面和物理页帧的大小相同。
为了方便实现虚拟页面到物理页帧的地址转换,我们给每个虚拟页面和物理页帧一个编号,分别称为 虚拟页号 (VPN, Virtual Page Number) 和 物理页号 (PPN, Physical Page Number) 。每个应用都有一个表示地址映射关系的 页表 (Page Table) ,里面记录了该应用地址空间中的每个虚拟页面映射到物理内存中的哪个物理页帧,即数据实际被内核放在哪里。
在页表中,还针对虚拟页号设置了一组保护位,它限制了应用对转换得到的物理地址对应的内存的使用方式。最典型的如
rwx
, r
表示当前应用可以读该内存; w
表示当前应用可以写该内存; x
则表示当前应用可以从该内存取指令用来执行。一旦违反了这种限制则会触发异常,并被内核捕获到。通过适当的设置,可以检查一些应用在运行时的明显错误:比如应用修改只读的代码段,或者从数据段取指令来执行。SV39 地址转换过程
RV64 架构中虚拟地址为何只有 39 位?
虚拟地址长度确实应该和位宽一致为 64 位,但是在启用 SV39 分页模式下,只有低 39 位是真正有意义的。 SV39 分页模式规定 64 位虚拟地址的 [63:39] 这 25 位必须和第 38 位相同,否则 MMU 会直接认定它是一个 不合法的虚拟地址。。
也就是说,所有个虚拟地址中,只有最低的 256GiB (当第 38 位为 0 时) 以及最高的 256GiB (当第 38 位为 1 时)是可能通过 MMU 检查的。
上面分别给出了物理地址、虚拟地址、物理页号、虚拟页号的 Rust 类型声明,它们都是 usize 的一种简单包装。 将它们各自抽象出来而不是直接使用 usize,是为了在 Rust 编译器的帮助下进行多种方便且安全的 类型转换 (Type Convertion) 。
页表项的数据结构抽象与类型定义
物理页号和全部的标志位以某种固定的格式保存在一个结构体中,它被称为 页表项 (PTE, Page Table Entry) ,是利用虚拟页号在页表中查到的结果。
上图为 SV39 分页模式下的页表项,其中 [53:10] 这 44 位是物理页号,最低的 8 位 [7:0] 则是标志位,它们的含义如下:
- 仅当 V(Valid) 位为 1 时,页表项才是合法的;
- R/W/X 分别控制索引到这个页表项的对应虚拟页面是否允许读/写/取指;
- U 控制索引到这个页表项的对应虚拟页面是否在 CPU 处于 U 特权级的情况下是否被允许访问;
- G 我们不理会;
- A(Accessed) 记录自从页表项上的这一位被清零之后,页表项的对应虚拟页面是否被访问过;
- D(Dirty) 则记录自从页表项上的这一位被清零之后,页表项的对应虚拟页表是否被修改过。
让我们先来实现页表项中的标志位
PTEFlags
:首先,我们需要知道物理内存的哪一部分是可用的。在
os/src/linker.ld
中,我们用符号 ekernel
指明了内核数据的终止物理地址,在它之后的物理内存都是可用的。而在 config
子模块中:我们硬编码整块物理内存的终止物理地址为
0x88000000
。 而 之前 提到过物理内存的起始物理地址为 0x80000000
,这意味着我们将可用内存大小设置为 126MiB 。实际上在 Qemu 模拟器上可以通过设置使用更大的物理内存用一个左闭右开的物理页号区间来表示可用的物理内存,则:
- 区间的左端点应该是
ekernel
的物理地址以上取整方式转化成的物理页号;
- 区间的右端点应该是
MEMORY_END
以下取整方式转化成的物理页号。
在回收
dealloc
的时候,我们需要检查回收页面的合法性,然后将其压入 recycled
栈中。回收页面合法有两个 条件:- 该页面之前一定被分配出去过,因此它的物理页号一定 <current ;
- 该页面没有正处在回收状态,即它的物理页号不能在栈
recycled
中找到。
操作系统如果要建立页表(构建虚实地址映射关系),首先要能管理整个系统的物理内存,这就需要知道整个计算机系统的物理内存空间的范围,物理内存中哪些区域是空闲可用的,哪些区域放置内核/应用的代码和数据。操作系统内核能够以物理页帧为单位分配和回收物理内存,具体实现主要集中在
os/src/mm/frame_allocator.rs
中;也能在虚拟内存中以各种粒度大小来动态分配内存资源,具体实现主要集中在 os/src/mm/heap_allocator.rs
中。内存控制相关的CSR寄存器
默认情况下 MMU 未被使能,此时无论 CPU 处于哪个特权级,访存的地址都将直接被视作物理地址。 可以通过修改 S 特权级的
satp
CSR 来启用分页模式,此后 S 和 U 特权级的访存地址会被视为虚拟地址,经过 MMU 的地址转换获得对应物理地址,再通过它来访问物理内存。上图是 RV64 架构下
satp
的字段分布。当 MODE
设置为 0 的时候,所有访存都被视为物理地址;而设置为 8 时,SV39 分页机制被启用,所有 S/U 特权级的访存被视为一个 39 位的虚拟地址,MMU 会将其转换成 56 位的物理地址;如果转换失败,则会触发异常。MODE
控制 CPU 使用哪种页表实现;
ASID
表示地址空间标识符,这里还没有涉及到进程的概念,我们不需要管这个地方;
PPN
存的是根页表所在的物理页号。这样,给定一个虚拟页号,CPU 就可以从三级页表的根页表开始一步步的将其映射到一个物理页号。
地址格式与组成
我们采用分页管理,单个页面的大小设置为4KiB,每个虚拟页面和物理页帧都对齐到这个页面大小,
就是说虚拟/物理地址区间[0,4KiB)为第0 个虚拟页面/物理页帧,而[4KiB,8KiB)为第1个
以此类推。4KiB需要用 12 位字节地址来表示,因此虚拟地址和物理地址都被分成两部分:它们的低 12 位,即[11:0]被称为页内偏移 (Page Offset) ,它描述一个地址指向的字节在它所在页面中的相对位置。而虚拟地址的高 27 位,即[38:12]为它的虚拟页号 VPN,同理物理地址的高 44 位,即[55:12] 为它的物理页号 PPN,页号可以用来定位一个虚拟/物理地址属于哪一个虚拟页面/物理页帧。
非叶节点(页目录表,非末级页表)的表项标志位含义和叶节点(页表,末级页表)相比有一些不同:
- 当
V
为 0 的时候,代表当前指针是一个空指针,无法走向下一级节点,即该页表项对应的虚拟地址范围是无效的;
- 只有当
V
为1 且R/W/X
均为 0 时,表示是一个合法的页目录表项,其包含的指针会指向下一级的页表;
- 注意: 当
V
为1 且R/W/X
不全为 0 时,表示是一个合法的页表项,其包含了虚地址对应的物理页号。
在这里我们给出 SV39 中的
R/W/X
组合的含义:啃代码
物理页帧管理
栈式物理页帧管理策略 StackFrameAllocator
这里我们使用
UPSafeCell<T>
来包裹栈式物理页帧分配器。每次对该分配器进行操作之前,我们都需要先通过 FRAME_ALLOCATOR.exclusive_access()
拿到分配器的可变借用。在正式分配物理页帧之前,我们需要将物理页帧全局管理器
FRAME_ALLOCATOR
初始化:分配/回收物理页帧的接口,这个是公开给其它模块调用的
Rust Tips:Drop Trait
Rust 中的
Drop
Trait 是它的 RAII 内存管理风格可以被有效实践的关键。之前介绍的多种在堆上分配的 Rust 数据结构便都是通过实现 Drop
Trait 来进行被绑定资源的自动回收的。例如:Box<T>
的drop
方法会回收它控制的分配在堆上的那个变量;
Rc<T>
的drop
方法会减少分配在堆上的那个引用计数,一旦变为零则分配在堆上的那个被计数的变量自身也会被回收;
UPSafeCell<T>
的exclusive_access
方法会获取内部数据结构的独占借用权并返回一个RefMut<'a, T>
(实际上来自RefCell<T>
),它可以被当做一个&mut T
来使用;而RefMut<'a, T>
的drop
方法会将独占借用权交出,从而允许内核内的其他控制流后续对数据结构进行访问。
FrameTracker
的设计也是基于同样的思想,有了它之后我们就不必手动回收物理页帧了,这在编译期就解决了很多潜在的问题。多级页表管理
页表基本数据结构与访问接口
我们知道,SV39 多级页表是以节点为单位进行管理的。每个节点恰好存储在一个物理页帧中,它的位置可以用一个物理页号来表示。
我们通过
new
方法新建一个 PageTable
的时候,它只需有一个根节点。为此我们需要分配一个物理页帧 FrameTracker
并挂在向量 frames
下,然后更新根节点的物理页号 root_ppn
。
为了 MMU 能够通过地址转换正确找到应用地址空间中的数据实际被内核放在内存中位置,操作系统需要动态维护一个虚拟页号到页表项的映射,支持插入/删除键值对:Identity Mapping
在尚未启用分页模式之前,内核和应用的代码都可以通过物理地址直接访问内存。而在打开分页模式之后,运行在 S 特权级的内核与运行在 U 特权级的应用在访存上都会受到影响,它们的访存地址会被视为一个当前地址空间(
satp
CSR 给出当前多级页表根节点的物理页号)中的一个虚拟地址,需要 MMU 查相应的多级页表完成地址转换变为物理地址,即地址空间中虚拟地址指向的数据真正被内核放在的物理内存中的位置,然后才能访问相应的数据。此时,如果想要访问一个特定的物理地址 pa
所指向的内存上的数据,就需要 构造 对应的一个虚拟地址 va
,使得当前地址空间的页表存在映射 va→pa ,且页表项中的保护位允许这种访问方式。于是,在代码中我们只需访问地址 va
,它便会被 MMU 通过地址转换变成 pa
,这样我们就做到了在启用分页模式的情况下也能正常访问内存。这就需要提前扩充多级页表维护的映射,让每个物理页帧的物理页号
ppn
,均存在一个对应的虚拟页号 vpn
,这需要建立一种映射关系。这里我们采用一种最简单的 恒等映射 (Identical Mapping) ,即对于物理内存上的每个物理页帧,我们都在多级页表中用一个与其物理页号相等的虚拟页号来映射。这里需要说明的是,在下一节中我们可以看到,应用和内核的地址空间是隔离的。而直接访问物理页帧的操作只会在内核中进行,应用无法看到物理页帧管理器和多级页表等内核数据结构。因此,上述的恒等映射只需被附加到内核地址空间即可。
建立和拆除虚实地址映射关系
在多级页表中找到一个虚拟地址对应的页表项
找到之后,只要修改页表项的内容即可完成键值对的插入和删除。
在寻找页表项的时候,可能出现页表的中间级节点还未被创建的情况,这个时候我们需要手动分配一个物理页帧来存放这个节点, 并将这个节点接入到当前的多级页表的某级中。
解释一下,这里取reverse,idx就是我们的虚拟页表号(由于identity mapping,也是物理帧号),这里之所以要逆序,是因为vpn的低位9位是三级页表项,最高9位是root页表,也就是一级页表,这样返回的usize数组就是[三级页表号,二级页表号,root(一级)页表号]
接下来就能继续解释前面的map和unamp方法,插入页表项的操作了:
我们去看PageTable实现的两个方法:
内核与应用的地址空间
页表
PageTable
只能以页为单位帮助我们维护一个虚拟内存到物理内存的地址转换关系,它本身对于计算机系统的整个虚拟/物理内存空间并没有一个全局的描述和掌控。操作系统通过对不同页表的管理,来完成对不同应用和操作系统自身所在的虚拟内存,以及虚拟内存与物理内存映射关系的全面管理。这种管理是建立在 地址空间 的抽象上,用来表明正在运行的应用或内核自身所在执行环境中的可访问的内存空间。逻辑段:一段连续地址的虚拟内存
所谓逻辑段,就是指地址区间中的一段实际可用(即 MMU 通过查多级页表可以正确完成地址转换)的地址连续的虚拟地址区间,该区间内包含的所有虚拟页面都以一种相同的方式映射到物理页帧,具有可读/可写/可执行等属性。
MapType
描述该逻辑段内的所有虚拟页面映射到物理页帧的同一种方式,它是一个枚举类型,在内核当前的实现中支持两种方式:其中
Identical
表示上一节提到的恒等映射方式;而 Framed
则表示对于每个虚拟页面都有一个新分配的物理页帧与之对应,虚地址与物理地址的映射关系是相对随机的。恒等映射方式主要是用在启用多级页表之后,内核仍能够在虚存地址空间中访问一个特定的物理地址指向的物理内存。地址空间:一系列有关联的逻辑段
地址空间 是一系列有关联的不一定连续的逻辑段,这种关联一般是指这些逻辑段组成的虚拟内存空间与一个运行的程序(目前把一个运行的程序称为任务,后续会称为进程)绑定,即这个运行的程序对代码和数据的直接访问范围限制在它关联的虚拟地址空间之内。这样我们就有任务的地址空间,内核的地址空间等说法了。地址空间使用
MemorySet
类型来表示:它包含了该地址空间的多级页表
page_table
和一个逻辑段 MapArea
的向量 areas
new_bare
方法可以新建一个空的地址空间;
push
方法可以在当前地址空间插入一个新的逻辑段 map_area
,如果它是以 Framed
方式映射到物理内存,还可以可选地在那些被映射到的物理页帧上写入一些初始化数据 data
insert_framed_area
方法调用 push
,可以在当前地址空间插入一个 Framed
方式映射到物理内存的逻辑段。注意该方法的调用者要保证同一地址空间内的任意两个逻辑段不能存在交集,从后面即将分别介绍的内核和应用的地址空间布局可以看出这一要求得到了保证;
maparea的方法
new
方法可以新建一个逻辑段结构体,注意传入的起始/终止虚拟地址会分别被下取整/上取整(因为要让页的范围覆盖完起始地址到终止地址)为虚拟页号并传入迭代器 vpn_range
中;map
和unmap
可以将当前逻辑段到物理内存的映射从传入的该逻辑段所属的地址空间的多级页表中加入或删除。可以看到它们的实现是遍历逻辑段中的所有虚拟页面,并以每个虚拟页面为单位依次在多级页表中进行键值对的插入或删除,分别对应 MapArea
的 map_one
和 unmap_one
方法map_one
来说,在虚拟页号 vpn
已经确定的情况下,它需要知道要将一个怎么样的页表项插入多级页表。页表项的标志位来源于当前逻辑段的类型为 MapPermission
的统一配置,只需将其转换为 PTEFlags
;而页表项的物理页号则取决于当前逻辑段映射到物理内存的方式:- 当以恒等映射
Identical
方式映射的时候,物理页号就等于虚拟页号;
- 当以
Framed
方式映射时,需要分配一个物理页帧让当前的虚拟页面可以映射过去,此时页表项中的物理页号自然就是 这个被分配的物理页帧的物理页号。此时还需要将这个物理页帧挂在逻辑段的data_frames
字段下。
当确定了页表项的标志位和物理页号之后,即可调用多级页表
PageTable
的 map
接口来插入键值对。
对于第 19 行的 unmap_one
来说,基本上就是调用 PageTable
的 unmap
接口删除以传入的虚拟页号为键的键值对即可。然而,当以 Framed
映射的时候,不要忘记同时将虚拟页面被映射到的物理页帧 FrameTracker
从 data_frames
中移除,这样这个物理页帧才能立即被回收以备后续分配。
copy_data
方法将切片 data
中的数据拷贝到当前逻辑段实际被内核放置在的各物理页帧上,从而在地址空间中通过该逻辑段就能访问这些数据。调用它的时候需要满足:切片 data
中的数据大小不超过当前逻辑段的总大小,且切片中的数据会被对齐到逻辑段的开头,然后逐页拷贝到实际的物理页帧。内核地址空间
CPU先拿到虚存地址,需要通过 MMU 的地址转换变成物理地址,再交给 CPU 的访存单元去访问物理内存。地址空间抽象的重要意义在于 隔离 (Isolation) ,当内核让应用执行前,内核需要控制 MMU 让它使用这个应用的多级页表进行地址转换。由于每个应用地址空间在创建的时候也顺带设置好了多级页表,使得只有那些存放了它的代码和数据的物理页帧能够通过该多级页表被映射到,这样它就只能访问自己的代码和数据而无法触及其他应用或内核的内容。
启用分页模式下,内核代码的访存地址也会被视为一个虚拟地址并需要经过 MMU 的地址转换,因此我们也需要为内核对应构造一个地址空间,它除了仍然需要允许内核的各数据段能够被正常访问之后,还需要包含所有应用的内核栈以及一个 跳板 (Trampoline)
下图是软件看到的 64 位地址空间在 SV39 分页模式下实际可能通过 MMU 检查的最高256GiB
可以看到,跳板放在最高的一个虚拟页面中。接下来则是从高到低放置每个应用的内核栈,内核栈的大小由
config
子模块的 KERNEL_STACK_SIZE
给出。它们的映射方式为 MapPermission
中的 rw 两个标志位,意味着这个逻辑段仅允许 CPU 处于内核态访问,且只能读或写
注意相邻两个内核栈之间会预留一个 保护页面 (Guard Page) ,它是内核地址空间中的空洞,多级页表中并不存在与它相关的映射。它的意义在于当内核栈空间不足(如调用层数过多或死递归)的时候,代码会尝试访问空洞区域内的虚拟地址,然而它无法在多级页表中找到映射,便会触发异常,此时控制权会交给内核 trap handler 函数进行异常处理。由于编译器会对访存顺序和局部变量在栈帧中的位置进行优化,我们难以确定一个已经溢出的栈帧中的哪些位置会先被访问,但总的来说,空洞区域被设置的越大,我们就能越早捕获到这一可能覆盖其他重要数据的错误异常。由于我们的内核非常简单且内核栈的大小设置比较宽裕,在当前的设计中我们仅将空洞区域的大小设置为单个页面。
下面则给出了内核地址空间的低256GiB 的布局:
内核的四个逻辑段
.text/.rodata/.data/.bss
被恒等映射到物理内存,这使得我们在无需调整内核内存布局 os/src/linker.ld
的情况下就仍能象启用页表机制之前那样访问内核的各个段。注意我们借用页表机制对这些逻辑段的访问方式做出了限制,这都是为了在硬件的帮助下能够尽可能发现内核中的 bug ,在这里:- 四个逻辑段的 U 标志位均未被设置,使得 CPU 只能在处于 S 特权级(或以上)时访问它们;
- 代码段
.text
不允许被修改;
- 只读数据段
.rodata
不允许被修改,也不允许从它上面取指执行;
.data/.bss
均允许被读写,但是不允许从它上面取指执行。
此外, 之前 提到过内核地址空间中需要存在一个恒等映射到内核数据段之外的可用物理页帧的逻辑段,这样才能在启用页表机制之后,内核仍能以纯软件的方式读写这些物理页帧。它们的标志位仅包含 rw ,意味着该逻辑段只能在 S 特权级以上访问,并且只能读写。
应用地址空间
在前面的章节中,我们直接将丢弃了所有符号信息的应用二进制镜像链接到内核,在初始化的时候内核仅需将他们加载到正确的初始物理地址就能使它们正确执行。但本章中,我们希望效仿内核地址空间的设计,同样借助页表机制使得应用地址空间的各个逻辑段也可以有不同的访问方式限制,这样可以提早检测出应用的错误并及时将其终止以最小化它对系统带来的恶劣影响。
在
.text
和 .rodata
中间以及 .rodata
和 .data
中间我们进行了页面对齐,因为前后两个逻辑段的访问方式限制是不同的,由于我们只能以页为单位对这个限制进行设置,因此就只能将下一个逻辑段对齐到下一个页面开始放置。而 .data
和 .bss
两个逻辑段由于访问限制相同(可读写),它们中间则无需进行页面对齐。
应用地址空间的布局:左侧给出了应用地址空间最低256GiB的布局:从0x10000
开始向高地址放置应用内存布局中的各个逻辑段,最后放置带有一个保护页面的用户栈。这些逻辑段都是以
Framed
方式映射到物理内存的,从访问方式上来说都加上了 U 标志位代表 CPU 可以在 U 特权级也就是执行应用代码的时候访问它们。右侧则给出了最高的256GiB,可以看出它只是和内核地址空间一样将跳板放置在最高页,还将 Trap 上下文放置在次高页中。这两个虚拟页面虽然位于应用地址空间,但是它们并不包含 U 标志位,事实上它们在地址空间切换的时候才会发挥作用,请同样参考本章的最后一节在
os/src/build.rs
中,我们不再将丢弃了所有符号的应用二进制镜像链接进内核,因为在应用二进制镜像中,内存布局中各个逻辑段的位置和访问限制等信息都被裁剪掉了。我们直接使用保存了逻辑段信息的 ELF 格式的应用可执行文件。这样 loader
子模块的设计实现也变得精简:它仅需要提供两个函数:
get_num_app
获取链接到内核内的应用的数目,而 get_app_data
则根据传入的应用编号取出对应应用的 ELF 格式可执行文件数据。它们和之前一样仍是基于 build.rs
生成的 link_app.S
给出的符号来确定其位置,并实际放在内核的数据段中。 loader
模块中原有的内核和用户栈则分别作为逻辑段放在内核和用户地址空间中,我们无需再去专门为其定义一种类型。
在创建应用地址空间的时候,我们需要对 get_app_data
得到的 ELF 格式数据进行解析,找到各个逻辑段所在位置和访问限制并插入进来,最终得到一个完整的应用地址空间:
基于地址空间的分时多任务
现有的操作系统进行如下的功能扩展:
- 创建内核页表,使能分页机制,建立内核的虚拟地址空间;
- 扩展Trap上下文,在保存与恢复Trap上下文的过程中切换页表(即切换虚拟地址空间);
- 建立用于内核地址空间与应用地址空间相互切换所需的跳板空间;
- 扩展任务控制块包括虚拟内存相关信息,并在加载执行创建基于某应用的任务时,建立应用的虚拟地址空间;
- 改进Trap处理过程和sys_write等系统调用的实现以支持分离的应用地址空间和内核地址空间。
在扩展了上述功能后,应用与应用之间,应用与操作系统内核之间通过硬件分页机制实现了内存空间隔离,且应用和内核之间还是能有效地进行相互访问,而且应用程序的编写也会更加简单通用。
建立并开启基于分页模式的虚拟地址空间
当 SBI 实现(本项目中基于 RustSBI)初始化完成后, CPU 将跳转到内核入口点并在 S 特权级上执行,此时还并没有开启分页模式,内核的每次访存是直接的物理内存访问。而在开启分页模式之后,内核代码在访存时只能看到内核地址空间,此时每次访存需要通过 MMU 的地址转换。这两种模式之间的过渡在内核初始化期间完成。
我们创建内核地址空间的全局实例:
PageTable::token
会按照 satp CSR 格式要求 构造一个无符号 64 位无符号整数,使得其分页模式为 SV39 且将当前多级页表的根节点所在的物理页号填充进去。在
activate
中,我们将这个值写入当前 CPU 的 satp CSR ,从这一刻开始 SV39 分页模式就被启用了,而且 MMU 会使用内核地址空间的多级页表进行地址转换。我们必须注意切换 satp CSR 是否是一个 平滑 的过渡:其含义是指,切换 satp 的指令及其下一条指令这两条相邻的指令的虚拟地址是相邻的(由于切换 satp 的指令并不是一条跳转指令, pc 只是简单的自增当前指令的字长),而它们所在的物理地址一般情况下也是相邻的,但是它们所经过的地址转换流程却是不同的——切换 satp 导致 MMU 查的多级页表是不同的。这就要求前后两个地址空间在切换 satp 的指令 附近 的映射满足某种意义上的连续性。
幸运的是,我们做到了这一点。这条写入 satp 的指令及其下一条指令都在内核内存布局的代码段中,在切换之后是一个恒等映射,而在切换之前是视为物理地址直接取指,也可以将其看成一个恒等映射。这完全符合我们的期待:即使切换了地址空间,指令仍应该能够被连续的执行。
sfence.vma 是一个屏障(Barrier)
对于一种含有快表的 RISC-V CPU 实现来说,我们可以认为
sfence.vma
的作用就是清空快表。事实上它在特权级规范中被定义为一种含义更加丰富的内存屏障,具体来说: sfence.vma
可以使得所有发生在它后面的地址转换都能够看到所有排在它前面的写入操作。在不同的硬件配置上这条指令要做的具体事务是有差异的。这条指令还可以被精细配置来减少同步开销,详情请参考 RISC-V 特权级规范。跳板机制的实现
上一小节我们看到无论是内核还是应用的地址空间,最高的虚拟页面都是一个跳板。同时应用地址空间的次高虚拟页面还被设置为用来存放应用的 Trap 上下文。那么跳板究竟起什么作用呢?为何不直接把 Trap 上下文仍放到应用的内核栈中呢?
回忆曾在第二章介绍过的 Trap 上下文保存与恢复 。当一个应用 Trap 到内核时,
sscratch
已指向该应用的内核栈栈顶,我们用一条指令即可从用户栈切换到内核栈,然后直接将 Trap 上下文压入内核栈栈顶。当 Trap 处理完毕返回用户态的时候,将 Trap 上下文中的内容恢复到寄存器上,最后将保存着应用用户栈顶的 sscratch
与 sp 进行交换,也就从内核栈切换回了用户栈。在这个过程中, sscratch
起到了非常关键的作用,它使得我们可以在不破坏任何通用寄存器的情况下,完成用户栈与内核栈的切换,以及位于内核栈顶的 Trap 上下文的保存与恢复。
然而,一旦使能了分页机制,一切就并没有这么简单了,我们必须在这个过程中同时完成地址空间的切换。具体来说,当
__alltraps
保存 Trap 上下文的时候,我们必须通过修改 satp 从应用地址空间切换到内核地址空间,因为 trap handler 只有在内核地址空间中才能访问;同理,在 __restore
恢复 Trap 上下文的时候,我们也必须从内核地址空间切换回应用地址空间,因为应用的代码和数据只能在它自己的地址空间中才能访问,应用是看不到内核地址空间的。这样就要求地址空间的切换不能影响指令的连续执行,即要求应用和内核地址空间在切换地址空间指令附近是平滑的。内核与应用地址空间的隔离
目前我们的设计思路 A 是:对内核建立唯一的内核地址空间存放内核的代码、数据,同时对于每个应用维护一个它们自己的用户地址空间,因此在 Trap 的时候就需要进行地址空间切换,而在任务切换的时候无需进行(因为这个过程全程在内核内完成)。
我们为何将应用的 Trap 上下文放到应用地址空间的次高页面而不是内核地址空间中的内核栈中呢?原因在于,在保存 Trap 上下文到内核栈中之前,我们必须完成两项工作:1)必须先切换到内核地址空间,这就需要将内核地址空间的 token 写入 satp 寄存器;2)之后还需要保存应用的内核栈栈顶的位置,这样才能以它为基址保存 Trap 上下文。这两步需要用寄存器作为临时周转,然而我们无法在不破坏任何一个通用寄存器的情况下做到这一点。因为事实上我们需要用到内核的两条信息:内核地址空间的 token ,以及应用的内核栈栈顶的位置,RISC-V却只提供一个
sscratch
寄存器可用来进行周转。所以,我们不得不将 Trap 上下文保存在应用地址空间的一个虚拟页面中,而不是切换到内核地址空间去保存。扩展Trap 上下文
为了方便实现,我们在 Trap 上下文中包含更多内容(和我们关于上下文的定义有些不同,它们在初始化之后便只会被读取而不会被写入,并不是每次都需要保存/恢复):
在多出的三个字段中:
kernel_satp
表示内核地址空间的 token ,即内核页表的起始物理地址;
kernel_sp
表示当前应用在内核地址空间中的内核栈栈顶的虚拟地址;
trap_handler
表示内核中 trap handler 入口点的虚拟地址。
切换地址空间
当应用 Trap 进入内核的时候,硬件会设置一些 CSR 并在 S 特权级下跳转到
__alltraps
保存 Trap 上下文。此时 sp 寄存器仍指向用户栈,但 sscratch
则被设置为指向应用地址空间中存放 Trap 上下文的位置(实际在次高页面)。随后,就像之前一样,我们 csrrw
交换 sp 和 sscratch
,并基于指向 Trap 上下文位置的 sp 开始保存通用寄存器和一些 CSR ,这个过程在第 28 行结束。到这里,我们就全程在应用地址空间中完成了保存 Trap 上下文的工作。这样,这段汇编代码放在一个物理页帧中,且
__alltraps
恰好位于这个物理页帧的开头,其物理地址被外部符号 strampoline
标记。在开启分页模式之后,内核和应用代码都只能看到各自的虚拟地址空间,而在它们的视角中,这段汇编代码 被放在它们地址空间的最高虚拟页面上,由于这段汇编代码在执行的时候涉及到地址空间切换,故而被称为跳板页面。
那么在产生trap前后的一小段时间内会有一个比较 极端 的情况,即刚产生trap时,CPU已经进入了内核态(即Supervisor Mode), 但此时执行代码和访问数据还是在应用程序所处的用户态虚拟地址空间中,而不是我们通常理解的内核虚拟地址空间。在这段特殊的时间内,CPU指令 为什么能够被连续执行呢?这里需要注意:无论是内核还是应用的地址空间,跳板的虚拟页均位于同样位置,且它们也将会映射到同一个实际存放这段 汇编代码的物理页帧。也就是说,在执行 __alltraps
或 __restore
函数进行地址空间切换的时候, 应用的用户态虚拟地址空间和操作系统内核的内核态虚拟地址空间对切换地址空间的指令所在页的映射方式均是相同的, 这就说明了这段切换地址空间的指令控制流仍是可以连续执行的
现在可以说明我们在创建用户/内核地址空间中用到的 map_trampoline
是如何实现的了:最后可以解释为何我们在
__alltraps
中需要借助寄存器 jr
而不能直接 call trap_handler
了。因为在 内存布局中,这条 .text.trampoline
段中的跳转指令和 trap_handler
都在代码段之内,汇编器(Assembler) 和链接器(Linker)会根据 linker.ld
的地址布局描述,设定电子指令的地址,并计算二者地址偏移量 并让跳转指令的实际效果为当前 pc 自增这个偏移量。但实际上我们知道由于我们设计的缘故,这条跳转指令在被执行的时候, 它的虚拟地址被操作系统内核设置在地址空间中的最高页面之内,加上这个偏移量并不能正确的得到 trap_handler
的入口地址。
问题的本质可以概括为:跳转指令实际被执行时的虚拟地址和在编译器/汇编器/链接器进行后端代码生成和链接形成最终机器码时设置此指令的地址是不同的。加载和执行应用程序
除了应用的地址空间
memory_set
之外,还有位于应用地址空间次高页的 Trap 上下文被实际存放在物理页帧的物理页号trap_cx_ppn
,它能够方便我们对于 Trap 上下文进行访问。此外,base_size
统计了应用数据的大小,也就是在应用地址空间中从0x0开始到用户栈结束一共包含多少字节。它后续还应该包含用于应用动态内存分配的堆空间的大小,但目前暂不支持。更新对任务控制块的管理
和之前实现相比,
TrapContext::app_init_context
需要补充上让应用在 __alltraps
能够顺利进入到内核地址空间并跳转到 trap handler 入口点的相关信息。
在内核初始化的时候,需要将所有的应用加载到全局应用管理器中:可以看到,在
TaskManagerInner
中我们使用向量 Vec
来保存任务控制块。在全局任务管理器 TASK_MANAGER
初始化的时候,只需使用 loader
子模块提供的 get_num_app
和 get_app_data
分别获取链接到内核的应用数量和每个应用的 ELF 文件格式的数据,然后依次给每个应用创建任务控制块并加入到向量中即可。将 current_task
设置为 0 ,表示内核将从第 0 个应用开始执行。
回过头来介绍一下应用构建器 os/build.rs
的改动:
• 首先,我们在 .incbin
中不再插入清除全部符号的应用二进制镜像 *.bin
,而是将应用的 ELF 执行文件直接链接进来;
• 其次,在链接每个 ELF 执行文件之前我们都加入一行 .align 3
来确保它们对齐到 8 字节,这是由于如果不这样做, xmas-elf
crate 可能会在解析 ELF 的时候进行不对齐的内存读写,例如使用 ld
指令从内存的一个没有对齐到 8 字节的地址加载一个 64 位的值到一个通用寄存器。而在 k210 平台上,由于其硬件限制,这种情况会触发一个内存读写不对齐的异常,导致解析无法正常完成。
为了方便后续的实现,全局任务管理器还需要提供关于当前应用与地址空间有关的一些信息:
通过
current_user_token
可以获得当前正在执行的应用的地址空间的 token 。同时,该应用地址空间中的 Trap 上下文很关键,内核需要访问它来拿到应用进行系统调用的参数并将系统调用返回值写回,通过 current_trap_cx
内核可以拿到它访问这个 Trap 上下文的可变引用并进行读写。改进 Trap 处理的实现
由于应用的 Trap 上下文不在内核地址空间,因此我们调用
current_trap_cx
来获取当前应用的 Trap 上下文的可变引用而不是像之前那样作为参数传入 trap_handler
。至于 Trap 处理的过程则没有发生什么变化。
注意到,在 trap_handler
的开头还调用 set_kernel_trap_entry
将 stvec
修改为同模块下另一个函数 trap_from_kernel
的地址。CSR 名 | 该 CSR 与 Trap 相关的功能 |
sstatus | SPP 等字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息 |
sepc | 当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址 |
scause | 描述 Trap 的原因 |
stval | 给出 Trap 附加信息 |
stvec | 控制 Trap 处理代码的入口地址 |
这就是说,一旦进入内核后再次触发到 S态 Trap,则硬件在设置一些 CSR 寄存器之后,会跳过对通用寄存器的保存过程,直接跳转到
trap_from_kernel
函数,在这里直接 panic
退出。这是因为内核和应用的地址空间分离之后,U态 –> S态 与 S态 –> S态 的 Trap 上下文保存与恢复实现方式/Trap 处理逻辑有很大差别。这里为了简单起见,弱化了 S态 –> S态的 Trap 处理过程:直接 panic
。
在 trap_handler
完成 Trap 处理之后,我们需要调用 trap_return
返回用户态:
改进 sys_write 的实现
类似Trap处理的改进,由于内核和应用地址空间的隔离,
sys_write
不再能够直接访问位于应用空间中的数据,而需要手动查页表才能知道那些数据被放置在哪些物理页帧上并进行访问。
为此,页表模块 page_table
提供了将应用地址空间中一个缓冲区转化为在内核空间中能够直接访问的形式的辅助函数
参数中的
token
是某个应用地址空间的 token
ptr
和 len
则分别表示该地址空间中的一段缓冲区的起始地址和长度(注:这个缓冲区的应用虚拟地址范围是连续的)
上述函数尝试将按应用的虚地址指向的缓冲区转换为一组按内核虚地址指向的字节数组切片构成的向量,然后把每个字节数组切片转化为字符串
&str
然后输出即可。小结
这里面的核心内容是如何建立基于页表机制的虚拟地址空间。为此,操作系统需要知道并管理整个系统中的物理内存;需要建立虚拟地址到物理地址映射关系的页表;并基于页表给操作系统自身和每个应用提供一个虚拟地址空间;并需要对管理应用的任务控制块进行扩展,确保能对应用的地址空间进行管理;由于应用和内核的地址空间是隔离的,需要有一个跳板来帮助完成应用与内核之间的切换执行;并导致了对异常、中断、系统调用的相应更改。这一系列的改进,最终的效果是编写应用更加简单了,且应用的执行或错误不会影响到内核和其他应用的正常工作。为了得到这些好处,我们需要比较费劲地进化我们的操作系统。如果同学结合阅读代码,编译并运行应用+内核,读懂了上面的文档,那完成本章的实验就有了一个坚实的基础。
- 作者:liamY
- 链接:https://liamy.clovy.top/article/OS_Tutorial/lab4Pre
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。