type
status
date
slug
summary
tags
category
icon
password
在上一章中,出于任务切换的需要,所有的应用都在初始化阶段被加载到内存中并同时驻留下去直到它们全部运行结束。而且,所有的应用都直接通过物理地址访问物理内存。这会带来以下问题:
  • 开发者需要自己计算内存来为应用程序布局
  • 内核没有对访存行为进行限制
  • 目前应用的内存使用空间在其运行前已经限定死了,内核不能灵活地给应用程序提供的运行时动态可用内存空间
虚拟内存:
站在应用程序运行的角度看,就是存在一个从“0”地址开始的非常大的可读/可写/可执行的地址空间(Address Space),而站在操作系统的角度看,每个应用被局限在分配给它的物理内存空间中运行,无法读写其它应用和操作系统所在的内存空间。
notion image
user目录下的build.py(编译时不再使用)

地址空间

  • CPU 访问数据和指令的内存地址是虚地址,通过硬件机制(比如 MMU +页表查询)进行地址转换,找到对应的物理地址
  • 提出地址空间(Address Space) 抽象,并在内核中建立虚实地址空间的映射机制,给应用程序提供一个基于地址空间的安全虚拟内存环境,让应用程序简单灵活地使用内存
之前的应用都是自己决定把自己放在哪个内存地址上,从内核的角度来看,将直接访问物理内存的权力下放到应用会使得它难以对应用程序的访存行为进行有效管理,已有的特权级机制亦无法阻止很多来自应用程序的恶意行为。

加一层抽象加强内存管理

最终,到目前为止仍被操作系统内核广泛使用的抽象被称为 地址空间 (Address Space)
  • 巨大但不一定真实存在的一片地址空间
  • 在应用程序的视角里,可以独占一片巨大的连续地址空间
  • 应用同样可以使用一个地址作为索引来读写自己地址空间的数据,就像用物理地址作为索引来读写物理内存上的数据一样。这种地址被称为 虚拟地址 (Virtual Address)
  • 应用程序只能直接访问操作系统给它提供的这个虚拟内存空间
MMU和TLB:
  • MMU(Memory Management Unit)
    • MMU 将程序中使用的虚拟地址转换为实际的物理地址
    • MMU 通过设置访问权限位(例如读、写、执行权限)来控制对内存的访问权限,从而实现内存保护功能
    • 虚拟内存管理
  • TLB(Translation Lookaside Buffer)
    • TLB 是 MMU 中的一个高速缓存,用于加速地址转换过程
    • TLB 中存储了虚拟地址到物理地址的映射关系,当 CPU 访问内存时,首先在 TLB 中查找对应的映射关系,如果找到则可以直接进行地址转换,从而节省了访问内存的时间
notion image

分段内存管理

  • 内核以地址空间中的一个逻辑段为单位,来安排应用在物理内存里面的分布
  • 段与段之间存在外碎片,无法被很好地利用

分页内存管理

notion image
  • 内核以页为单位进行物理内存管理
  • 每个应用的地址空间可以被分成若干个(虚拟) 页面 (Page) ,而可用的物理内存也同样可以被分成若干个(物理) 页帧 (Frame) ,虚拟页面和物理页帧的大小相同
  • 这样一个应用就不用存在连续的物理内存里面了,也就不会产生外碎片

SV39 多级页表原理

线性页表的问题:
它保存了所有虚拟页号对应的页表项,但是高达512GiB的地址空间中真正会被应用使用到的只是其中极小的一个子集(本教程中的应用内存使用量约在数十~数百KiB量级),也就导致有意义并能在页表中查到实际的物理页号的虚拟页号在227中也只是很小的一部分。由此线性表的绝大部分空间其实都是被浪费掉的。
解决:按需分配:
  • 有多少合法的虚拟页号,我们就维护一个多大的映射,并为此使用多大的内存用来保存映射
  • 一开始应用的地址空间为空,MMU就判定所有页号不合法,也就不用存储页表内容
  • 而在后面,内核已经决定好了一个应用的各逻辑段存放位置之后,它就需要负责从零开始以虚拟页面为单位来让该应用的地址空间的某些部分变得合法,反映在该应用的页表上也就是一对对映射顺次被插入进来,自然页表所占据的内存大小也就逐渐增加。
事实上 SV39 分页机制等价于一颗字典树:
  • 字典树:树上的节点是根据插入的字符串来动态生成的,如果下一个字符串为abc,a节点就会生成b指针
    • notion image
  • 虚拟页号有27位,每9位一个字符,字符集{0…512},那么一个虚拟页号就可以用一个长度为3的字符串来存储
  • 每个叶子节点都需要保存512个页表项,一个页表项长度为8字节(地址索引),一共正好4KiB,可以直接放在一个物理页帧内
  • 非叶子节点也最多只需要保存512个向下的指针
    • 不过我们就像叶节点那样也保存512个页表项,这样每个节点都可以被放在一个物理页帧内,节点的位置可以用它所在物理页帧的物理页号来代替。当想从一个非叶节点向下走时,只需找到当前字符对应的页表项的物理页号字段,它就指向了下一级节点的位置,这样非叶节点中转的功能也就实现了
非叶节点(页目录表,非末级页表)的表项标志位含义和叶节点(页表,末级页表)相比有一些不同:
  • 当 V 为 0 的时候,代表当前指针是一个空指针,无法走向下一级节点,即该页表项对应的虚拟地址范围是无效的;
  • 只有当 V 为1 且 R/W/X 均为 0 时,表示是一个合法的页目录表项,其包含的指针会指向下一级的页表;
  • 注意: 当 V 为1 且 R/W/X 不全为 0 时,表示是一个合法的页表项,其包含了虚地址对应的物理页号。
    • notion image

SV39 地址转换过程

默认情况下 MMU 未被使能,此时无论 CPU 处于哪个特权级,访存的地址都将直接被视作物理地址。 可以通过修改 S 特权级的 satp CSR 来启用分页模式,此后 S 和 U 特权级的访存地址会被视为虚拟地址,经过 MMU 的地址转换获得对应物理地址,再通过它来访问物理内存
具体来说,假设我们有虚拟地址 (VPN0,VPN1,VPN2,offset) :
  • 我们首先会记录装载「当前所用的一级页表的物理页」的页号到 satp 寄存器中;
  • 把 VPN0 作为偏移在一级页表的物理页中找到二级页表的物理页号;
  • 把 VPN1 作为偏移在二级页表的物理页中找到三级页表的物理页号;
  • 把 VPN2 作为偏移在三级页表的物理页中找到要访问位置的物理页号;
  • 物理页号对应的物理页基址(即物理页号左移12位)加上 offset 就是虚拟地址对应的物理地址。
notion image
实践表明绝大部分应用程序的虚拟地址访问过程具有时间局部性和空间局部性的特点。因此,在 CPU 内部,我们使用MMU中的 快表(TLB, Translation Lookaside Buffer) 来作为虚拟页号到物理页号的映射的页表缓存。这部分知识在计算机组成原理课程中有所体现,当我们要进行一个地址转换时,会有很大可能对应的地址映射在近期已被完成过,所以我们可以先到 TLB 缓存里面去查一下,如果有的话我们就可以直接完成映射,而不用访问那么多次内存了。

实现 SV39 多级页表机制

虚拟地址和物理地址

notion image
💡
RV64 架构中虚拟地址为何只有 39 位?
虚拟地址长度确实应该和位宽一致为 64 位,但是在启用 SV39 分页模式下,只有低 39 位是真正有意义的。 SV39 分页模式规定 64 位虚拟地址的 [63:39] 这 25 位必须和第 38 位相同,否则 MMU 会直接认定它是一个 不合法的虚拟地址。。
也就是说,所有 264 个虚拟地址中,只有最低的 256GiB (当第 38 位为 0 时) 以及最高的 256GiB (当第 38 位为 1 时)是可能通过 MMU 检查的。
地址转换是以页为单位进行的,转换前后地址页内偏移部分不变。MMU 只是从虚拟地址中取出 27 位虚拟页号, 在页表中查到其对应的物理页号,如果找到,就将得到的 44 位的物理页号12 位页内偏移拼接到一起,形成 56 位物理地址。
offest就是页内地址,offset共有12位,表示Page大小为
页号和地址可以相互转换:
对于不对齐的情况,物理地址不能通过 From/Into 转换为物理页号,而是需要通过它自己的 floor 或 ceil 方法来 进行下取整或上取整的转换。

页表项的数据结构抽象与类型定义

上图为 SV39 分页模式下的页表项,其中 [53:10] 这 44 位是物理页号,最低的 8 位 [7:0] 则是标志位,它们的含义如下:
  • 仅当 V(Valid) 位为 1 时,页表项才是合法的;
  • R/W/X 分别控制索引到这个页表项的对应虚拟页面是否允许读/写/取指
  • U 控制索引到这个页表项的对应虚拟页面是否在 CPU 处于 U 特权级的情况下是否被允许访问;
  • G 我们不理会;
  • A(Accessed) 记录自从页表项上的这一位被清零之后,页表项的对应虚拟页面是否被访问过
  • D(Dirty) 则记录自从页表项上的这一位被清零之后,页表项的对应虚拟页表是否被修改过
notion image
标志位:
页表项 PageTableEntry:
物理页帧号+标志位
  • 根据ppn和标志位flag生成新的页表项
  • ppn从第10位开始,flag在低8位,因此通过移位可以生成页表项的bits
  • 取出物理页号,注意物理页号占43位
  • into() 方法被调用,将这个物理页号转换为 PhysPageNum 类型
  • 判断页表项的V标志位是否有效

物理页帧管理

用一个左闭右开的物理页号区间来表示可用的物理内存,则:
  • 区间的左端点应该是 ekernel 的物理地址以上取整方式转化成的物理页号;
  • 区间的右端点应该是 MEMORY_END 以下取整方式转化成的物理页号。
  • 创建一个物理页帧管理器的实例
  • 以物理页号为单位进行物理页帧的分配和回收
  • 一种最简单的 栈式物理页帧 管理策略
  • [current, end)表示还没有被分配出去的物理页号
  • recycled:已经被回收了的物理页号
  • 创建实例和初始化的方法
  • 创建实例是把整个可用内存空间都设为0
  • 而在它真正被使用起来之前,需要调用 init 方法将自身的[current,end)初始化为可用物理页号区间
    • l和r表示可用的物理页号
核心内容:alloc和dealloc函数
  • alloc可能不能成功分配——内存已经耗尽的情况,所以返回值是一个Option类型,在内存耗尽时返回None
  • 如果recycled里面有物理页号,那么就返回这个物理页号
  • 如果recycled里面没有物理页号了,就只能新分配一个从未分配过的物理页号
    • 将current分配出去
    • current+=1
  • 通过into 方法将 usize 转换成了物理页号 PhysPageNum
  • 回收物理页表号,首先要检查物理页表号的合法性
    • 如果页表号≥current,说明还没被分配,不合法
    • 如果页表号已经在recycled里面了,说明已经被回收,不合法
  • 对于合法的页表,将它push到cycled进行回收
之后创建 StackFrameAllocator 的全局实例 FRAME_ALLOCATOR,并在正式分配物理页帧之前将 FRAME_ALLOCATOR 初始化
分配/回收物理页帧的接口:
公开给其他子模块调用的分配/回收物理页帧的接口:
  • frame_alloc函数返回的不是物理页号,而是将其 进一步包装为一个 FrameTracker
  • FrameTracker被创建的时候,需要被分配一个物理页号,然后把这个物理页号指向的物理页清零
  • 当FrameTracker类型的变量离开作用域的时候,需要回收它所指代的物理页号
  • 当一个 FrameTracker 实例被回收的时候,它的 drop 方法会自动被编译器调用,通过之前实现的 frame_dealloc 我们就将它控制的物理页帧回收以供后续使用了

多级页表实现

SV39 多级页表是以节点为单位进行管理的。每个节点恰好存储在一个物理页帧中,它的位置可以用一个物理页号来表示
页表结构:
  • 每一个页表具有一个根节点
    • 一个应用可以被分成许多页,每一页都对应一个物理页帧;因此每个应用的页表是不一样的,需要一个页表起始地址来划分,因此 PageTable 要保存它根节点的物理页号 root_ppn 作为页表唯一的区分标志
  • 每个页表里面有许多表项,存储了页面和物理页帧的映射关系
    • 向量 frames 以 FrameTracker 的形式保存了页表所有的节点(包括根节点)所在的物理页帧
  • 当 PageTable 生命周期结束后,向量 frames 里面的那些 FrameTracker 也会被回收,也就意味着存放多级页表节点的那些物理页帧 被回收了
新建页表:
  • 新建一个页表
  • 需要至少为这个页表建立一个表项,而一个表项就对应一个物理页帧,因此需要为它alloc一个物理页帧(帧号为frame.ppn)
  • 将页表根节点设为frame的帧号
  • frames此时只有一个物理页帧号,即刚刚新分配的那个
建立映射关系:
为了 MMU 能够通过地址转换正确找到应用地址空间中的数据实际被内核放在内存中 位置,操作系统需要动态维护一个虚拟页号到页表项的映射,支持插入/删除键值对
虚拟页号→页表项→物理页帧号→物理地址
  • 我们通过 map 方法来在多级页表中插入一个键值对,注意这里我们将物理页号 ppn 和页表项标志位 flags 作为不同的参数传入而不是整合为一个页表项;
  • 相对的,我们通过 unmap 方法来删除一个键值对,在调用时仅需给出作为索引的虚拟页号即可。
  • 每一个页表都是一个节点,每一个节点都被保存在一个物理页帧中,我们以一个节点被存放在的物理页帧的物理页号作为指针指向该节点,这意味着,对于每个节点来说,一旦我们知道了指向它的物理页号,我们就能够修改这个节点的内容
    • 这里我们采用一种最简单的 恒等映射 (Identical Mapping) ,也就是说对于物理内存上的每个物理页帧,我们都在多级页表中用一个与其物理页号相等的虚拟页号映射到它
内核中访问物理页帧的方法:
  • get_pte_array
    • 获取一个页表项的可变引用,可以用来修改多级页表中的一个节点
      'static 生命周期是指整个程序的执行周期,意味着这个引用可以存在于程序的整个运行过程中。具体来说,'static 生命周期意味着引用所指向的值在整个程序执行过程中都是有效的
  • get_bytes_array
    • 可以获取某个物理页帧上的数据的可变引用,可以按字节为单位修改内容
  • get_mut
    • 是个泛型函数,可以获取一个恰好放在一个物理页帧开头的类型为 T 的数据的可变引用
  • let pa: PhysAddr = self.clone().into();
    • 把传入的物理帧号转为物理地址,接着将它作为裸指针访问这个地址指向的物理内存
    • 虽然现在这个裸指针可能是一个虚拟地址,但是由于虚拟页号和物理页帧号一一对应,所以还是可以映射到一个正确的物理地址
建立和拆除虚实地址映射关系:
接下来介绍建立和拆除虚实地址映射关系的 map 和 unmap 方法是如何实现的。它们都依赖于一个很重要的过程, 也即在多级页表中找到一个虚拟地址对应的页表项(指的是最后一个)
  • 取出虚拟页号的三级页索引
  • 每一个索引长9位,最低位是最后一个索引
  • .rev() 方法是用于反转一个迭代器的元素顺序的。在你提供的示例中,(0..3) 是一个 Range 迭代器,它生成了一个范围从 0 到 2 的序列。.rev() 方法被调用在这个范围迭代器上,用于反转元素的顺序,即从 2 到 0。
  • 在多级页表中,通过传入的vpn,找到对应的页表项
  • 遍历idxs里面的索引,找到在当前节点(页表)中它对应的页表项
  • 如果在页表中没找到对应的页表项,就创建页表项
  • 如果在页表中有索引,就使ppn为该页表项的ppn,然后跳转到下一个节点
  • 如果这已经是最后一个索引,说明该页表项指向的就是物理帧,那么返回这个页表项
于是, map/unmap 就非常容易实现了:
  • map
    • 刚才通过find_pte_create(vpn)已经找到了最后一个页表项
    • 现在这个页表项必须要有效
    • 在这个页表项这里,建立一个与物理帧号的映射,这个页表项的ppn就是传入的ppn
    • 此时这个物理帧号可以转变为一个物理地址,也就是这个虚拟地址对应的物理地址
  • unmap
    • 刚才通过find_pte_create已经找到了最后一个页表项
    • 现在这个页表项 必须要有效
    • 清空这个页表项

内核与应用的地址空间

实现地址空间抽象

逻辑段:一段连续地址的虚拟内存:
我们以逻辑段 MapArea 为单位描述一段连续地址的虚拟内存。所谓逻辑段,就是指地址区间中的一段实际可用(即 MMU 通过查多级页表可以正确完成地址转换)的地址连续的虚拟地址区间,该区间内包含的所有虚拟页面都以一种相同的方式映射到物理页帧,具有可读/可写/可执行等属性
  • VPNRange:SimpleRange定义了一个类型别名 VPNRange,用于表示虚拟页号(VirtPageNum)的简单范围
    • 迭代器 SimpleRangeIterator<T> 的定义如下:
    • current 表示当前迭代到的值。
    • end 表示范围的结束值。
    • new() 方法用于创建一个新的迭代器实例。
    • next() 方法用于获取迭代器的下一个元素。
  • data_frames:BTreeMap<VirtPageNum, FrameTracker> 表示一个映射,其中键是 VirtPageNum 类型的对象,值是 FrameTracker 类型的对象。这个映射能够将 VirtPageNum 类型的键映射到对应的 FrameTracker 类型的值。
    • 这些物理页帧被用来存放实际内存数据而不是作为多级页表中的中间节点
  • MapType:逻辑段中的虚拟地址到物理地址的映射方式
    • MapPermission:表示控制该逻辑段的访问方式,它是页表项标志位 PTEFlags 的一个子集,仅保留 U/R/W/X 四个标志位,因为其他的标志位仅与硬件的地址转换机制细节相关,这样的设计能避免引入错误的标志位
    MapArea的函数:
    • pub fn map_one(&mut self, page_table: &mut PageTable, vpn: VirtPageNum)
      • 在逻辑段内新建一个从vpn到ppn的映射,并把这个映射添加到该逻辑段的页表中
    • pub fn map(&mut self, page_table: &mut PageTable)
      • 新建该逻辑段到ppn的映射,vpn范围为该逻辑段的vpn范围,并把这一系列映射添加到页表中
    地址空间:一系列有关联的逻辑段:
    地址空间是一系列有关联的逻辑段,这种关联一般是指这些逻辑段属于一个运行的程序(目前把一个运行的程序称为任务,后续会称为进程)。 用来表明正在运行的应用所在执行环境中的可访问内存空间,在这个内存空间中,包含了一系列的不一定连续的逻辑段。 这样我们就有任务的地址空间、内核的地址空间等说法了
    地址空间的表示:
    它包含了该地址空间的多级页表 page_table 和一个逻辑段 MapArea 的向量 areas 。
    • 注意 PageTable 下挂着所有多级页表的节点所在的物理页帧
    • 每个 MapArea 下则挂着对应逻辑段中的数据所在的物理页帧
    这两部分 合在一起构成了一个地址空间所需的所有物理页帧
    MemorySet的函数:
    • fn push(&mut self, mut map_area: MapArea, data: Option<&[u8]>)
      • 在该地址空间的页表中添加该逻辑段的映射关系
      • 如果传入了data,还可以可选地在那些被映射到的物理页帧上写入一些初始化数据 data
    • pub fn insert_framed_area( &mut self, start_va: VirtAddr, end_va: VirtAddr, permission: MapPermission, )
      • 新建一个逻辑段,范围是start_va~end_va
      • 并在这个地址空间插入这个新的逻辑段

    小结

    • 逻辑段指的是连续的好几个虚拟页,地址空间由许多逻辑段组成
    • 逻辑段的data_frames存的是vpn到物理帧号的直接映射,这个物理帧号转成的物理地址就是虚拟地址对应的那个物理地址
    • 地址空间的page_table存着所有地址空间的逻辑段的vpn到ppn的映射关系,这个page_table可以查到该地址空间内的逻辑段的vpn对应实际物理地址
      • 所有的page_table创建实际上都是采用了多级页表的find_pte_create,这样就可以保证只有存放了数据的物理页帧能够被多级页表查到

    内核地址空间

    之前介绍了地址空间和逻辑段的概念
    notion image
    当我们在执行每个应用的代码的时候,内核需要控制 MMU 使用这个应用地址空间的多级页表进行地址转换
    每个应用地址空间在创建的时候也顺带设置好了多级页表使得只有那些存放了它的数据的物理页帧能够通过该多级页表被映射到,这样它就只能访问自己的数据而无法触及其他应用或是内核的数据。
    notion image
    • 跳板被放在最高的虚拟页面中(跳板还没讲)
    • 接下来则是从高到低放置每个应用的内核栈,内核栈的大小由 config 子模块的 KERNEL_STACK_SIZE 给出
    • 相邻两个内核栈之间会预留一个 保护页面 (Guard Page) ,它是内核地址空间中的空洞,多级页表中并不存在与它相关的映射
      • 它的意义在于当内核栈空间不足(如调用层数过多或死递归)的时候,代码会尝试访问空洞区域内的虚拟地址,然而它无法在多级页表中找到映射,便会触发异常,此时控制权会交给内核 trap handler 函数进行异常处理
    notion image

    应用地址空间

    在第三章中,每个应用链接脚本中的起始地址被要求是不同的,这样它们的代码和数据存放的位置才不会产生冲突。但是这是一种对于应用开发者 极其不友好的设计。现在,借助地址空间的抽象,我们终于可以让所有应用程序都使用同样的起始地址,这也意味着所有应用可以使用同一个链接脚本了
    • BASE_ADDRESS = 0x0;
      • 显然它只能是一个地址空间中的虚拟地址而非物理地址
    • 应用地址空间的布局
      • notion image
      • 从0x0开始向高地址放置应用内存布局中的 各个逻辑段,最后放置带有一个保护页面的用户栈
      • 这些逻辑段都是以 Framed 方式映射到物理内存的,从访问方式上来说都加上 了 U 标志位代表 CPU 可以在 U 特权级也就是执行应用代码的时候访问它们
      • 高位:它只是和内核地址空间一样将跳板放置在最高页,还将Trap 上下文放置在次高页中
        • 这两个虚拟页面虽然位于应用地址空间, 但是它们并不包含 U 标志位,事实上它们在地址空间切换的时候才会发挥作用
    loader.rs:
    它仅需要提供两个函数: get_num_app 获取链接到内核内的应用的数目,而 get_app_data 则根据传入的应用编号 取出对应应用的 ELF 格式可执行文件数据。它们和之前一样仍是基于 build.rs 生成的 link_app.S 给出的符号来 确定其位置,并实际放在内核的数据段中。 loader 模块中原有的内核和用户栈则分别作为逻辑段放在内核和用户地址空间中,我们无需再去专门为其定义一种类型。
    在创建应用地址空间的时候,我们需要对 get_app_data 得到的 ELF 格式数据进行解析,找到各个逻辑段所在位置和访问 限制并插入进来,最终得到一个完整的应用地址空间

    基于地址空间的分时多任务

    建立并开启基于分页模式的虚拟地址空间

    此时还并没有开启分页模式 ,内核的每一次访存仍被视为一个物理地址直接访问物理内存。而在开启分页模式之后,内核的代码在访存的时候只能看到内核地址空间, 此时每次访存将被视为一个虚拟地址且需要通过 MMU 基于内核地址空间的多级页表的地址转换。这两种模式之间的过渡在内核初始化期间完成。
    创建内核地址空间:
    rust_main函数:
    • mm::init();
      • 进行内存管理子系统的初始化
      • 先进行全局动态内存分配器的初始化
      • 接下来初始化页帧分配器
      • 最后我们创建内核地址空间并让 CPU 开启分页模式, MMU 在地址转换的时候使用内核的多级页表,这一切均在一行之内做到
        • 首先,我们引用 KERNEL_SPACE ,这是它第一次被使用,就在此时它会被初始化,调用 MemorySet::new_kernel 创建一个内核地址空间并使用 Arc<UPSafeCell<T>> 包裹起来
        • 最然后,我们调用 MemorySet::activate
          • PageTable::token 会按照 satp CSR 格式要求 构造一个无符号 64 位无符号整数,使得其分页模式为 SV39 ,且将当前多级页表的根节点所在的物理页号填充进去。在 activate 中,我们将这个值写入当前 CPU 的 satp CSR ,从这一刻开始 SV39 分页模式就被启用了,而且 MMU 会使用内核地址空间的多级页表进行地址转换

    跳板的实现

    第二章:使用sscratch和sp交换,可以实现换栈。然而,一旦使能了分页机制,一切就并没有这么简单了,我们必须在这个过程中同时完成地址空间的切换
    当 __alltraps 保存 Trap 上下文的时候,我们必须通过修改 satp 从应用地址空间切换到内核地址空间, 因为 trap handler 只有在内核地址空间中才能访问; 同理,在 __restore 恢复 Trap 上下文的时候,我们也必须从内核地址空间切换回应用地址空间,因为应用的代码和数据只能在它自己的地址空间中才能访问,内核地址空间是看不到的
    我们为何将应用的 Trap 上下文放到应用地址空间的次高页面而不是内核地址空间中的内核栈中呢?
    原因在于,在保存 Trap 上下文到内核栈中之前,我们必须完成两项工作:1)必须先切换到内核地址空间,这就需要将内核地址空间的 token 写入 satp 寄存器;2)之后还需要保存应用的内核栈栈顶的位置,这样才能以它为基址保存 Trap 上下文。这两步需要用寄存器作为临时周转,然而我们无法在不破坏任何一个通用寄存器的情况下做到这一点。因为事实上我们需要用到内核的两条信息:内核地址空间的 token ,以及应用的内核栈栈顶的位置,RISC-V却只提供一个 sscratch 寄存器可用来进行周转。所以,我们不得不将 Trap 上下文保存在应用地址空间的一个虚拟页面中,而不是切换到内核地址空间去保存。 在trap上下文中包含更多内容:
    notion image
    在多出的三个字段中:
    • kernel_satp 表示内核地址空间的 token ;
    • kernel_sp 表示当前应用在内核地址空间中的内核栈栈顶的虚拟地址;
    • trap_handler 表示内核中 trap handler 入口点的虚拟地址。
    它们在应用初始化的时候由内核写入应用地址空间中的 TrapContext 的相应位置,此后就不再被修改
    💡
    token 为了允许用户程序或其他特权级别较低的代码与内核进行通信或访问内核功能,操作系统会提供一些特殊的接口或机制。这些接口可能需要传递一个特殊的 token,作为访问内核地址空间的凭证或权限证明。当用户程序或其他代码想要执行内核功能时,它们需要提供有效的 token,以便操作系统可以验证其合法性,并允许其访问内核地址空间
    • 此时 sp 寄存器仍指向用户栈,但 sscratch 则被设置为指向应用地址空间中存放 Trap 上下文的位置,实际在次高页面,换栈后sp指向Trap上下文
    • 在应用地址空间保存Trap上下文
    • 其中,先保存了32个通用寄存器,然后保存了两个CSR(一个sstatus,一个sepc)
    • 保存用户栈指针
      • csrr t2, sscratch 将用户栈指针读到t2里面,sd t2, 2*8(sp) 将通用寄存器 t2 中的值存储到栈指针 sp 指示的栈中的一个偏移位置
    接下来该考虑切换到内核地址空间并跳转到 trap handler 了:
    • 将内核地址空间的token载入t0,将trap_handler的入口载入t1,直接将sp修改为内核栈顶的地址。这就切换到了内核地址空间
    • jr 指令跳转到 t1 寄存器所保存的 trap handler 入口点的地址。注意这里我们不能像之前的章节那样直接 call trap_handler ,原因稍后解释
    • sfence.vma 刷新快表
    最终既把地址空间切换了也把栈给换了
    当内核将 Trap 处理完毕准备返回用户态的时候会 调用 __restore ,它有两个参数:第一个是 Trap 上下文在应用地址空间中的位置,这个对于所有的应用来说都是相同的,由调用规范在 a0 寄存器中传递;第二个则是即将回到的应用的地址空间的 token ,在 a1 寄存器中传递
    • 将a1(参数2)的token拿到satp,切换回用户地址空间
    • 将传入的 Trap 上下文位置保存在 sscratch 寄存器中
    • 将栈指针指向a0,指向Trap上下文
    • 开始根据保存的上下文恢复寄存器
    • 通过 sret 指令返回用户态
    这样,这段汇编代码放在一个物理页帧中,且 __alltraps 恰好位于这个物理页帧的开头,其物理地址被外部符号 strampoline 标记。在开启分页模式之后,内核和应用代码都只能看到各自的虚拟地址空间,而在它们的视角中,这段汇编代码 被放在它们地址空间的最高虚拟页面上,由于这段汇编代码在执行的时候涉及到地址空间切换,故而被称为跳板页面。

    加载和执行应用程序

    任务控制块新增内容:
    notion image
    • memory_set:存放应用程序的地址空间
    • trap_cx_ppn:存放位于应用程序的地址空间次高位的Trap上下文对应的物理页帧号
    • base_size:统计了应用数据的大小,也就是 在应用地址空间中从 0x0 开始到用户栈结束一共包含多少字节
    更新对任务控制块的管理:
    • 相当于找到这一段的开头地址和结尾地址
      • notion image
    • 获取应用的Trap上下文
    在内核初始化的时候,需要将所有的应用加载到全局应用管理器中:
    在全局任务管理器 TASK_MANAGER 初始化的时候,只需使用 loader 子模块提供的  get_num_app  和 get_app_data 分别获取链接到内核的应用数量和每个应用的 ELF 文件格式的数据,然后依次给每个应用创建任务控制块并加入到向量中即可。将 current_task 设置为 0 ,表示内核将从第 0 个应用开始执行
    pub fn new(elf_data: &[u8], app_id: usize) -> Self :只需要应用程序的可执行代码和id就可以创建一个新的任务管理块
    为了方便后续的实现,全局任务管理器还需要提供关于当前应用与地址空间有关的一些信息:

    Lab4

    pub fn sys_get_time(_ts: *mut TimeVal, _tz: usize) -> isize
    • 按照之前的方法计算出正确的时间
    • 但是由于_ts是一个指向应用地址空间的指针,而现在syscall是系统调用,它只能修改内核地址空间的内容,因此需要用到translated_byte_buffer :尝试将按应用的虚地址指向的缓冲区转换为一组按内核虚地址指向的字节数组切片构成的向量,然后修改_ts对应的物理地址的内容
    • 这个缓冲区的应用虚拟地址肯定是连续的,但是内核虚拟地址可能是不连续的
    pub fn sys_task_info(_ti: *mut TaskInfo) -> isize
    • 由于_ti也是一个指向应用地址空间的指针,因此也是一样的思路,将其转换成一片可修改的物理内存的区域
    • 也要注意分页的问题,即内核虚拟地址可能不是连续的,按字节填充buffer
    fn sys_mmap(start: usize, len: usize, port: usize) -> isize
    • 这个主要是要检查[start, start + len) 中是否存在已经被映射的页
    • 你增加 PTE_U 了吗?是的,要手动添加这个标志位,表示U模式有效
    • 然后直接使用task control block的mem_set的接口
    pub fn sys_munmap(_start: usize, _len: usize) -> isize
    • 主要是要检查[start, start + len) 中是否存在未被映射的虚存
      • 略麻烦,需要手动查找任务地址空间的页表,看看页表里面vpn对应的那一个页表项的标志位是否有效
    • unmap操作也简单,直接调用页表的接口unmap,但一定要修改task control block的地址空间里面的areas,去掉已经unmap的逻辑段
    数组Computer Networking Notes
    • Giscus