Skip to content

Latest commit

 

History

History
261 lines (146 loc) · 19.7 KB

day19-learning-lab2.md

File metadata and controls

261 lines (146 loc) · 19.7 KB

物理内存

物理内存是操作系统需要管理的一个重要资源,让运行在一台机器上的多个应用程序不用“争抢”,都能随时得到想要的任意多的内存,是操作系统的想要达到的理想目标。提高系统物理内存的动态使用效率,通过隔离应用的物理内存空间保证应用间的安全性,把“有限”物理内存变成“无限”虚拟内存,是操作系统的一系列重要的目标,本章展现了操作系统为实现“理想”而要扩展的一系列功能:

  • 通过动态内存分配,提高了应用程序对内存的动态使用效率
  • 通过页表的虚实内存映射机制,简化了编译器对应用的地址空间设置
  • 通过页表的虚实内存映射机制,加强了应用之间,应用与内核之间的内存隔离,增强了系统安全
  • 通过页表的虚实内存映射机制,可以实现空分复用(提出,但没有实现)

本章将进一步设计与实现具有上述大部分功能的侏罗纪“头甲龙” 操作系统,让应用开发更加简单,应用程序更加通用,且让应用和操作系统都有强大的地址空间隔离的安全保护。

Rust 中的动态内存分配

到目前为止,如果将当前的操作系统内核也看成一个应用,那么其中所有的变量都是被静态分配在内存中的,这样在对空闲内存的动态使用方面缺少灵活性。我们希望能在操作系统中提供动态申请和释放内存的能力,这样就可以加强操作系统对各种以内存为基础的资源分配与管理。

静态与动态内存分配

静态分配

若在某一时间点观察一个应用的地址空间,可以看到若干个内存块,每一块都对应于一个生命周期尚未结束的变量。这个变量可能是一个局部变量,它来自于正在执行的当前函数调用栈上,即它是被分配在栈上;这个变量也可能是一个全局变量,它一般被分配在数据段中。它们有一个共同点:在编译器编译程序时已经知道这些变量所占的字节大小,于是给它们分配一块固定的内存将它们存储其中,这样变量在栈帧/数据段中的位置就被固定了下来。

这些变量是被 静态分配 (Static Allocation) 的,这一过程来源于我们在程序中对变量的声明,在编译期由编译器完成。如果应用仅使用静态分配,它可以应付一部分的需求,但是对于其它情况,如某些数据结构需求的内存大小取决于程序的实际运行情况,就不够灵活了。比如,需要将一个文件读到内存进行处理,而且必须将文件一次性完整读进来处理。我们可以选择声明一个栈上的数组(局部变量)或者数据段中的数组(全局变量),作为缓冲区来暂存文件的内容。但我们在编程的时候并不知道待处理的文件大小,只能根据经验将缓冲区的大小设置为某一固定常数。在程序真正运行的时候,如果待处理的文件很小,那么缓冲区多出的部分被浪费掉了;如果待处理的文件很大,应用则无法正常运行。

动态分配

如果使用 动态分配 (Dynamic Allocation) 则可以解决上述问题。应用另外放置了一个大小可以随着应用的运行动态增减的内存空间 – 堆(Heap)。同时,应用还要能够将这个堆管理起来,即支持在运行的时候从里面分配一块空间来存放变量,而在变量的生命周期结束之后,这块空间需要被回收以待后面的使用。如果堆的大小固定,那么这其实就是一个连续内存分配问题,同学们可以使用操作系统课上所介绍到的各种连续内存分配算法。一般情况下,应用所依赖的基础系统库(如 Linux 中的 glibc 库等)会直接通过系统调用(如类 Unix 内核提供的 sbrk 系统调用)来向内核请求增加/缩减应用地址空间内堆的大小,之后应用就可以基于基础系统库提供的内存分配/释放函数来获取和释放内存了。应用进行多次不同大小的内存分配和释放操作后,会产生内存空间的浪费,即存在无法被应用使用的空闲内存碎片。

Rust 中的堆数据结构

智能指针 (Smart Pointer) 。智能指针和 Rust 中的其他两类指针:裸指针 $*const T/*mut T 和引用 $&T/&mut T 一样, 都指向地址空间中的另一个区域并包含它的位置信息。但它们携带的信息数量不等,需要经过编译器不同等级的安全检查,所以它们在可靠性和灵活程度也有所不同。

基于上述智能指针,可形成更强大的 集合 (Collection) 或称 容器 (Container) 类型,它们负责管理一组数目可变的元素,这些元素的类型相同或是有着一些同样的特征。在 C++/Python/Java 等高级语言中我们已经对它们的使用方法非常熟悉了,对于 Rust 而言,我们可以直接使用以下容器:

image

在内核中支持动态内存分配

如果要在操作系统内核中支持动态内存分配,则需要实现在本节开始介绍的一系列功能:初始化堆、分配/释放内存块的函数接口、连续内存分配算法。相对于 C 语言而言,Rust语言在 alloc crate 中设定了一套简洁规范的接口,只要实现了这套接口,内核就可以很方便地支持动态内存分配了。上述与堆相关的智能指针或容器都可以在 Rust 自带的 alloc crate 中找到。当我们使用 Rust 标准库 std 的时候可以不用关心这个 crate ,因为标准库内已经已经实现了一套堆管理算法,并将 alloc 的内容包含在 std 名字空间之下让开发者可以直接使用。然而操作系统内核运行在禁用标准库(即 no_std )的裸机平台上,核心库 core 也并没有动态内存分配的功能,这个时候就要考虑利用 alloc 库定义的接口来实现基本的动态内存分配器。 alloc 库需要我们提供给它一个 全局的动态内存分配器 ,它会利用该分配器来管理堆空间,从而使得与堆相关的智能指针或容器数据结构可以正常工作。具体而言,我们的动态内存分配器需要实现它提供的 GlobalAlloc Trait,这个 Trait 有两个必须实现的抽象接口:

pub unsafe fn alloc(&self, layout: Layout) -> *mut u8;
pub unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);

可以看到,它们类似 C 语言中的 malloc/free ,分别代表堆空间的分配和回收,也同样使用一个裸指针(也就是地址)作为分配的返回值和回收的参数。两个接口中都有一个 alloc::alloc::Layout 类型的参数, 它指出了分配的需求,分为两部分,分别是所需空间的大小 size ,以及返回地址的对齐要求 align 。这个对齐要求必须是一个 2 的幂次,单位为字节数,限制返回的地址必须是 align 的倍数。

然后只需将我们的动态内存分配器类型实例化为一个全局变量,并使用 #[global_allocator] 语义项标记即可。由于该分配器的实现比较复杂,我们这里直接使用一个已有的伙伴分配器实现。首先添加 crate 依赖:

buddy_system_allocator = "0.6"

接着,需要引入 alloc 库的依赖,由于它算是 Rust 内置的 crate ,我们并不是在 Cargo.toml 中进行引入,而是在 main.rs 中声明即可:

extern crate alloc;

然后,根据 alloc 留好的接口提供全局动态内存分配器:

use buddy_system_allocator::LockedHeap;
use crate::config::KERNEL_HEAP_SIZE;

#[global_allocator]
static HEAP_ALLOCATOR: LockedHeap = LockedHeap::empty();

static mut HEAP_SPACE: [u8; KERNEL_HEAP_SIZE] = [0; KERNEL_HEAP_SIZE];

pub fn init_heap() {
    unsafe {
        HEAP_ALLOCATOR
            .lock()
            .init(HEAP_SPACE.as_ptr() as usize, KERNEL_HEAP_SIZE);
    }
}

第 7 行,我们直接将 buddy_system_allocator 中提供的 LockedHeap 实例化成一个全局变量,并使用 alloc 要求的 #[global_allocator] 语义项进行标记。注意 LockedHeap 已经实现了 GlobalAlloc 要求的抽象接口了。

第 11 行,在使用任何 alloc 中提供的堆数据结构之前,我们需要先调用 init_heap 函数来给我们的全局分配器一块内存用于分配。在第 9 行可以看到,这块内存是一个 static mut 且被零初始化的字节数组,位于内核的 .bss 段中。 LockedHeap 也是一个被互斥锁 Mutex 保护的类型,在对它任何进行任何操作之前都要先获取锁以避免其他线程同时对它进行操作导致数据竞争。然后,调用 init 方法告知它能够用来分配的空间的起始地址和大小即可。

我们还需要处理动态内存分配失败的情形,在这种情况下我们直接 panic :

// os/src/main.rs

#![feature(alloc_error_handler)]

// os/src/mm/heap_allocator.rs

#[alloc_error_handler]
pub fn handle_alloc_error(layout: core::alloc::Layout) -> ! {
    panic!("Heap allocation error, layout = {:?}", layout);
}

最后,让我们尝试一下动态内存分配吧!感兴趣的同学可以在 rust_main 中尝试调用下面的 heap_test 函数(调用 heap_test() 前要记得先调用 init_heap() )。

 // os/src/mm/heap_allocator.rs

#[allow(unused)]
pub fn heap_test() {
   use alloc::boxed::Box;
   use alloc::vec::Vec;
   extern "C" {
       fn sbss();
       fn ebss();
   }
   let bss_range = sbss as usize..ebss as usize;
   let a = Box::new(5);
   assert_eq!(*a, 5);
   assert!(bss_range.contains(&(a.as_ref() as *const _ as usize)));
   drop(a);
   let mut v: Vec<usize> = Vec::new();
   for i in 0..500 {
       v.push(i);
   }
   for i in 0..500 {
       assert_eq!(v[i], i);
   }
   assert!(bss_range.contains(&(v.as_ptr() as usize)));
   drop(v);
   println!("heap_test passed!");
}

地址空间

虚拟地址与地址空间

image

增加硬件加速虚实地址转换

我们回顾一下 计算机组成原理 课,如上图所示,当 CPU 取指令或者执行一条访存指令的时候,它都是基于虚拟地址访问属于当前正在运行的应用的地址空间。此时,CPU 中的 内存管理单元 (MMU, Memory Management Unit) 自动将这个虚拟地址进行 地址转换 (Address Translation) 变为一个物理地址,即这个应用的数据/指令的物理内存位置。也就是说,在 MMU 的帮助下,应用对自己虚拟地址空间的读写才能被实际转化为对于物理内存的访问。

事实上,每个应用的地址空间都存在一个从虚拟地址到物理地址的映射关系。可以想象对于不同的应用来说,该映射可能是不同的,即 MMU 可能会将来自不同两个应用地址空间的相同虚拟地址转换成不同的物理地址。要做到这一点,就需要硬件提供一些寄存器,软件可以对它进行设置来控制 MMU 按照哪个应用的地址映射关系进行地址转换。于是,将应用的代码/数据放到物理内存并进行管理,建立好应用的地址映射关系,在任务切换时控制 MMU 选用应用的地址映射关系,则是作为软件部分的内核需要完成的重要工作。

回过头来,在介绍内核对于 CPU 资源的抽象——时分复用的时候,我们曾经提到它为应用制造了一种每个应用独占整个 CPU 的幻象,而隐藏了多个应用分时共享 CPU 的实质。而地址空间也是如此,应用只需、也只能看到它独占整个地址空间的幻象,而藏在背后的实质仍然是多个应用共享物理内存,它们的数据分别存放在内存的不同位置。

地址空间只是一层抽象接口,它有很多种具体的实现策略。对于不同的实现策略来说,操作系统内核如何规划应用数据放在物理内存的位置,而 MMU 又如何进行地址转换也都是不同的。下面我们简要介绍几种曾经被使用的策略,并探讨它们的优劣。

分段内存管理

image

image

分页内存管理

image

SV39 多级页表的硬件机制

在上一小节中我们已经简单介绍了分页的内存管理策略,现在我们尝试在 RISC-V 64 架构提供的 SV39 分页硬件机制的基础上完成内核中的软件对应实现。由于内容过多,我们将分成两个小节进行讲解。本节主要讲解在 RISC-V 64 架构下的虚拟地址与物理地址的访问属性(可读,可写,可执行等),组成结构(页号,帧号,偏移量等),访问的空间范围,硬件实现地址转换的多级页表访问过程等;以及如何用Rust语言来设计有类型的页表项

虚拟地址和物理地址

内存控制相关的CSR寄存器

默认情况下 MMU 未被使能(启用),此时无论 CPU 位于哪个特权级,访存的地址都会作为一个物理地址交给对应的内存控制单元来直接访问物理内存。我们可以通过修改 S 特权级的一个名为 satp 的 CSR 来启用分页模式,在这之后 S 和 U 特权级的访存地址会被视为一个虚拟地址,它需要经过 MMU 的地址转换变为一个物理地址,再通过它来访问物理内存;而 M 特权级的访存地址,我们可设定是内存的物理地址。

image

地址格式与组成

image

地址相关的数据结构抽象与类型定义

正如本章第一小节所说,在分页内存管理中,地址转换的核心任务在于如何维护虚拟页号到物理页号的映射——也就是页表。不过在具体实现它之前,我们先将地址和页号的概念抽象为 Rust 中的类型,借助 Rust 的类型安全特性来确保它们被正确实现。

// os/src/mm/address.rs

#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct PhysAddr(pub usize);

#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct VirtAddr(pub usize);

#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct PhysPageNum(pub usize);

#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct VirtPageNum(pub usize);

管理 SV39 多级页表

该项目涉及到代码实现,具体请看rcore-turtorial

内核与应用的地址空间

页表 PageTable 只能以页为单位帮助我们维护一个虚拟内存到物理内存的地址转换关系,它本身对于计算机系统的整个虚拟/物理内存空间并没有一个全局的描述和掌控。操作系统通过对不同页表的管理,来完成对不同应用和操作系统自身所在的虚拟内存,以及虚拟内存与物理内存映射关系的全面管理。这种管理是建立在 地址空间 的抽象上,用来表明正在运行的应用或内核自身所在执行环境中的可访问的内存空间。本节 我们就在内核中通过基于页表的各种数据结构实现地址空间的抽象,并介绍内核和应用的虚拟和物理地址空间中各需要包含哪些内容。

实现地址空间抽象

地址空间:一系列有关联的逻辑段

内核地址空间

内核和应用代码的访存地址都被视为一个物理地址,并直接访问物理内存,而在分页模式开启之后,CPU先拿到虚存地址,需要通过 MMU 的地址转换变成物理地址,再交给 CPU 的访存单元去访问物理内存。地址空间抽象的重要意义在于 隔离 (Isolation) ,当内核让应用执行前,内核需要控制 MMU 使用这个应用的多级页表进行地址转换。由于每个应用地址空间在创建的时候也顺带设置好了多级页表,使得只有那些存放了它的代码和数据的物理页帧能够通过该多级页表被映射到,这样它就只能访问自己的代码和数据而无法触及其他应用或内核的内容。

image

可以看到,跳板放在最高的一个虚拟页面中。接下来则是从高到低放置每个应用的内核栈,内核栈的大小由 config 子模块的 KERNEL_STACK_SIZE 给出。它们的映射方式为 MapPermission 中的 rw 两个标志位,意味着这个逻辑段仅允许 CPU 处于内核态访问,且只能读或写。

注意相邻两个内核栈之间会预留一个 保护页面 (Guard Page) ,它是内核地址空间中的空洞,多级页表中并不存在与它相关的映射。它的意义在于当内核栈空间不足(如调用层数过多或死递归)的时候,代码会尝试访问空洞区域内的虚拟地址,然而它无法在多级页表中找到映射,便会触发异常,此时控制权会交给内核 trap handler 函数进行异常处理。由于编译器会对访存顺序和局部变量在栈帧中的位置进行优化,我们难以确定一个已经溢出的栈帧中的哪些位置会先被访问,但总的来说,空洞区域被设置的越大,我们就能越早捕获到这一可能覆盖其他重要数据的错误异常。由于我们的内核非常简单且内核栈的大小设置比较宽裕,在当前的设计中我们仅将空洞区域的大小设置为单个页面。

下面则给出了内核地址空间的低 (256\text{GiB}) 的布局:

image

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

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

超越物理内存的地址空间

超越物理内存的方法

分时复用内存

动态内存分配

动态分配内存的目标是处理快速和浪费的空闲空间碎片少。这里我们会进一步分析动态分配内存的策略。对于不同的分配需求的前提情况,会有不同的分配策略。比如,每次分配的空间大小是固定的,那么就可以把空闲空间按固定大小的单元组织为一个列表。当需要分配时,从列表中的第一个单元取出即可;但需要回收时,把回收单元放到列表的末尾即可。

  • 最先匹配(first fit)策略
  • 最优匹配(best fit)策略
  • 最差匹配(worst fit)策略

减少碎片

其实,采用上面的三种策略,都会由于对空闲块进行分割而产生碎片。所以减少碎片是一个需要考虑的重要问题。减少碎片有两种方法,第一种方法是合并操作,即在内存释放操作中,把在地址连续的多个空闲块合并为一个大的空闲块,这样可以满足更多的内存分配请求。

第二种方法是比较激进的紧致(compaction,也称紧凑)操作,即把地址不连续的多个空闲块移动在一个连续的地址空间中,形成一个大的空闲块。在移动空闲块的过程中,也需要移动已经分配的内存块。这除了带来性能上的影响外,还会带来数据寻址问题,应即用程序中数据所在的内存地址会发生改变,应用程序要能够感知这种变化,才能正确访问更新后的数据地址。这需要应用程序具有在紧致操作后的数据地址更新功能,这对应用程序开发者提出了很高的要求,不具有通用性。