物理内存探测与管理

我们知道,物理内存通常是一片 RAM ,我们可以把它看成一个以字节为单位的大数组,通过物理地址找到对应的位置进行读写。但是,物理地址并不仅仅只能访问物理内存,也可以用来访问其他的外设,因此你也可以认为物理内存也算是一种外设。

这样设计是因为:如果访问其他外设要使用不同的指令(如 x86 单独提供了in, out 指令来访问不同于内存的IO地址空间),会比较麻烦,于是很多 CPU(如 RISC-V,ARM,MIPS 等)通过 MMIO(Memory Mapped I/O) 技术将外设映射到一段物理地址,这样我们访问其他外设就和访问物理内存一样啦!

我们先不管那些外设,来看物理内存。

物理内存探测

操作系统怎样知道物理内存所在的那段物理地址呢?在 RISC-V 中,这个一般是由 bootloader ,即 OpenSBI 来完成的。它来完成对于包括物理内存在内的各外设的扫描,将扫描结果以 DTB(Device Tree Blob) 的格式保存在物理内存中的某个地方。随后 OpenSBI 会将其地址保存在 a1 寄存器中,给我们使用。

这个扫描结果描述了所有外设的信息,当中也包括 Qemu 模拟的 RISC-V 计算机中的物理内存。

[info] Qemu 模拟的 RISC-V virt 计算机中的物理内存

通过查看virt.cvirt_memmap[]的定义,可以了解到 Qemu 模拟的 RISC-V virt 计算机的详细物理内存布局。可以看到,整个物理内存中有不少内存空洞(即含义为unmapped的地址空间),也有很多外设特定的地址空间,现在我们看不懂没有关系,后面会慢慢涉及到。目前只需关心最后一块含义为DRAM的地址空间,这就是 OS 将要管理的 128MB 的内存空间。

起始地址 终止地址 含义
0x0 0x100 QEMU VIRT_DEBUG
0x100 0x1000 unmapped
0x1000 0x12000 QEMU MROM (包括 hard-coded reset vector; device tree)
0x12000 0x100000 unmapped
0x100000 0x101000 QEMU VIRT_TEST
0x101000 0x2000000 unmapped
0x2000000 0x2010000 QEMU VIRT_CLINT
0x2010000 0x3000000 unmapped
0x3000000 0x3010000 QEMU VIRT_PCIE_PIO
0x3010000 0xc000000 unmapped
0xc000000 0x10000000 QEMU VIRT_PLIC
0x10000000 0x10000100 QEMU VIRT_UART0
0x10000100 0x10001000 unmapped
0x10001000 0x10002000 QEMU VIRT_VIRTIO
0x10002000 0x20000000 unmapped
0x20000000 0x24000000 QEMU VIRT_FLASH
0x24000000 0x30000000 unmapped
0x30000000 0x40000000 QEMU VIRT_PCIE_ECAM
0x40000000 0x80000000 QEMU VIRT_PCIE_MMIO
0x80000000 0x88000000 DRAM 缺省 128MB,大小可配置

不过为了简单起见,我们并不打算自己去解析这个结果。因为我们知道,Qemu 规定的 DRAM 物理内存的起始物理地址为 0x80000000 。而在 Qemu 中,可以使用 -m 指定 RAM 的大小,默认是 128MiB128\text{MiB} 。因此,默认的 DRAM 物理内存地址范围就是 [0x80000000,0x88000000) 。我们直接将 DRAM 物理内存结束地址硬编码到内核中:

// src/lib.rs

mod consts;

// src/consts.rs

pub const PHYSICAL_MEMORY_END: usize = 0x88000000;

但是,有一部分 DRAM 空间已经被占用,不能用来存别的东西了!

  • 物理地址空间 [0x80000000,0x80200000) 被 OpenSBI 占用;
  • 物理地址空间 [0x80200000,KernelEnd) 被内核各代码与数据段占用;
  • 其实设备树扫描结果 DTB 还占用了一部分物理内存,不过由于我们不打算使用它,所以可以将它所占用的空间用来存别的东西。

于是,我们可以用来存别的东西的物理内存的物理地址范围是:[KernelEnd, 0x88000000) 。这里的 KernelEnd​ 为内核代码结尾的物理地址。在 linker64.ld 中定义的 end 符号为内核代码结尾的虚拟地址,我们需要通过偏移量来将其转化为物理地址。

我们来将可用的物理内存地址范围打印出来:

// src/consts.rs

pub const KERNEL_BEGIN_PADDR: usize = 0x80200000;
pub const KERNEL_BEGIN_VADDR: usize = 0x80200000;

// src/init.rs

use crate::consts::*;

#[no_mangle]
pub extern "C" fn rust_main() -> ! {
    extern "C" {
        fn end();
    }
    println!(
        "free physical memory paddr = [{:#x}, {:#x})",
        end as usize - KERNEL_BEGIN_VADDR + KERNEL_BEGIN_PADDR,
        PHYSICAL_MEMORY_END
    );
    crate::interrupt::init();
    crate::timer::init();
    loop {}
}

[success] 可用物理内存地址

free physical memory paddr = [0x8020b000, 0x88000000)

物理页帧与物理页号

通常,我们在分配物理内存时并不是以字节为单位,而是以一物理页帧(Frame),即连续的 212=40962^{12}=4096 字节为单位分配。我们希望用物理页号(Physical Page Number, PPN) 来代表一物理页,实际上代表物理地址范围在 [PPN×212,(PPN+1)×212)[\text{PPN}\times 2^{12},(\text{PPN}+1)\times 2^{12}) 的一物理页。

不难看出,物理页号与物理页形成一一映射。为了能够使用物理页号这种表达方式,每个物理页的开头地址必须是 212=40962^{12}=4096 的倍数。但这也给了我们一个方便:对于一个物理地址,其除以 40964096 (或者说右移 1212 位) 的商即为这个物理地址所在的物理页号。

以这种方式,我们看一下可用物理内存的物理页号表达。将 init.rs 中的输出语句略做改动:

// src/init.rs

println!(
        "free physical memory ppn = [{:#x}, {:#x})",
        ((end as usize - KERNEL_BEGIN_VADDR + KERNEL_BEGIN_PADDR) >> 12) + 1,
        PHYSICAL_MEMORY_END >> 12
);

[success] 可用物理页号区间

free physical memory ppn = [0x8020c, 0x88000)

物理内存页式管理

对于物理内存的页式管理而言,我们所要支持的操作是:

  1. 分配一个物理页,返回其物理页号;
  2. 给定一个物理页号,回收其对应的物理页。
  3. 给定一个页号区间进行初始化。

我们考虑用一颗非递归线段树来维护这些操作。节点上的值存的是 0/10/1 表示这个节点对应的区间内是否还有空闲物理页(0=空闲,1=被占用)。

// src/const.rs

pub const MAX_PHYSICAL_MEMORY: usize = 0x8000000;
pub const MAX_PHYSICAL_PAGES: usize = MAX_PHYSICAL_MEMORY >> 12;

// src/lib.rs

mod memory;

// src/memory/mod.rs

mod frame_allocator;

// src/memory/frame_allocator.rs

use crate::consts::MAX_PHYSICAL_PAGES;

pub struct SegmentTreeAllocator {
    a: [u8; MAX_PHYSICAL_PAGES << 1],
    m: usize,
    n: usize,
    offset: usize
}

impl SegmentTreeAllocator {
    // 使用物理页号区间 [l,r) 进行初始化
    pub fn init(&mut self, l: usize, r: usize) {
        self.offset = l - 1;
        self.n = r - l;
        self.m = 1;
        while self.m < self.n + 2 {
            self.m = self.m << 1;
        }
        for i in (1..(self.m << 1)) { self.a[i] = 1; }
        for i in (1..self.n) { self.a[self.m + i] = 0; }
        for i in (1..self.m).rev() { self.a[i] = self.a[i << 1] & self.a[(i << 1) | 1]; }
    }
    // 分配一个物理页
    // 自上而下寻找可用的最小物理页号
    // 注意,我们假定永远不会出现物理页耗尽的情况
    pub fn alloc(&mut self) -> usize {
        // assume that we never run out of physical memory
        if self.a[1] == 1 {
            panic!("physical memory depleted!");
        }
        let mut p = 1;
        while p < self.m {
            if self.a[p << 1] == 0 { p = p << 1; } else { p = (p << 1) | 1; }
        }
        let result = p + self.offset - self.m;
        self.a[p] = 1;
        p >>= 1;
        while p > 0 {
            self.a[p] = self.a[p << 1] & self.a[(p << 1) | 1];
            p >>= 1;
        }
        result
    }
    // 回收物理页号为 n 的物理页
    // 自下而上进行更新
    pub fn dealloc(&mut self, n: usize) {
        let mut p = n + self.m - self.offset;
        assert!(self.a[p] == 1);
        self.a[p] = 0;
        p >>= 1;
        while p > 0 {
            self.a[p] = self.a[p << 1] & self.a[(p << 1) | 1];
            p >>= 1;
        }
    }
}

事实上每次分配的是可用的物理页号最小的页面,具体实现方面就不赘述了。

我们还需要将这个类实例化并声明为 static ,因为它在整个程序 运行过程当中均有效。

// os/Cargo.toml

[dependencies]
spin = "0.5.2"

// src/memory/frame_allocator.rs

use spin::Mutex;

pub static SEGMENT_TREE_ALLOCATOR: Mutex<SegmentTreeAllocator> = Mutex::new(SegmentTreeAllocator {
    a: [0; MAX_PHYSICAL_PAGES << 1],
    m: 0,
    n: 0,
    offset: 0
});

我们注意到在内核中开了一块比较大的静态内存,a 数组。那么 a 数组究竟有多大呢?实际上 a 数组的大小为最大可能物理页数的二倍,因此 a 数组大小仅为物理内存大小的 1212×20.05%\frac{1}{2^{12}}\times 2\simeq 0.05\%,可说是微乎其微。

我们本来想把 SEGMENT_TREE_ALLOCATOR 声明为 static mut 类型,这是因为首先它需要是 static 类型的;其次,它的三个方法 init, alloc, dealloc 都需要修改自身。

但是,对于 static mut 类型的修改操作是 unsafe 的。我们之后会提到线程的概念,对于 static 类型的静态数据,所有的线程都能访问。当一个线程正在访问这段数据的时候,如果另一个线程也来访问,就可能会产生冲突,并带来难以预测的结果。

所以我们的方法是使用 spin::Mutex<T> 给这段数据加一把锁,一个线程试图通过 .lock() 打开锁来获取内部数据的可变引用,如果钥匙被别的线程所占用,那么这个线程就会一直卡在这里;直到那个占用了钥匙的线程对内部数据的访问结束,锁被释放,将钥匙交还出来,被卡住的那个线程拿到了钥匙,就可打开锁获取内部引用,访问内部数据。

这里使用的是 spin::Mutex<T> , 我们在 Cargo.toml 中添加依赖。幸运的是,它也无需任何操作系统支持(即支持 no_std),我们可以放心使用。

我们在 src/memory/mod.rs 里面再对这个类包装一下:

// src/memory/mod.rs

use frame_allocator::SEGMENT_TREE_ALLOCATOR as FRAME_ALLOCATOR;
use riscv::addr::{
    // 分别为虚拟地址、物理地址、虚拟页、物理页帧
    // 非常方便,之后会经常用到
    // 用法可参见 https://github.com/rcore-os/riscv/blob/master/src/addr.rs
    VirtAddr,
    PhysAddr,
    Page,
    Frame
};

pub fn init(l: usize, r: usize) {
    FRAME_ALLOCATOR.lock().init(l, r);
    println!("++++ setup memory!    ++++");
}
pub fn alloc_frame() -> Option<Frame> {
    //将物理页号转为物理页帧
    Some(Frame::of_ppn(FRAME_ALLOCATOR.lock().alloc()))
}
pub fn dealloc_frame(f: Frame) {
    FRAME_ALLOCATOR.lock().dealloc(f.number())
}

现在我们来测试一下它是否能够很好的完成物理页分配与回收:

// src/init.rs

use crate::memory::{
    alloc_frame,
    dealloc_frame
};

#[no_mangle]
pub extern "C" fn rust_main() -> ! {
    extern "C" {
        fn end();
    }
    println!("kernel end vaddr = {:#x}", end as usize);
    println!(
        "free physical memory ppn = [{:#x}, {:#x})",
        ((end as usize - KERNEL_BEGIN_VADDR + KERNEL_BEGIN_PADDR) >> 12) + 1,
        PHYSICAL_MEMORY_END >> 12
    );
    crate::interrupt::init();

    crate::memory::init(
        ((end as usize - KERNEL_BEGIN_VADDR + KERNEL_BEGIN_PADDR) >> 12) + 1,
        PHYSICAL_MEMORY_END >> 12
    );
    frame_allocating_test();
    crate::timer::init();

    unsafe {
        asm!("ebreak"::::"volatile");
    }
    panic!("end of rust_main");
    loop {}
}

fn frame_allocating_test() {
    println!("alloc {:x?}", alloc_frame());
    let f = alloc_frame();
    println!("alloc {:x?}", f);
    println!("alloc {:x?}", alloc_frame());
    println!("dealloc {:x?}", f);
    dealloc_frame(f.unwrap());
    println!("alloc {:x?}", alloc_frame());
    println!("alloc {:x?}", alloc_frame());
}

我们尝试在分配的过程中回收,之后再进行分配,结果如何呢?

[success] 物理页分配与回收测试

free physical memory paddr = [0x8021f020, 0x88000000)
free physical memory ppn = [0x80220, 0x88000)
++++ setup interrupt! ++++
++++ setup timer!     ++++
++++ setup memory!    ++++
alloc Some(Frame(PhysAddr(80220000)))
alloc Some(Frame(PhysAddr(80221000)))
alloc Some(Frame(PhysAddr(80222000)))
dealloc Some(Frame(PhysAddr(80221000)))
alloc Some(Frame(PhysAddr(80221000)))
alloc Some(Frame(PhysAddr(80223000)))
* 100 ticks *
* 100 ticks *
...

我们回收的页面接下来马上就又被分配出去了。

如果结果有问题的话,在这里能找到现有的代码。

不过,这种物理内存分配给人一种过家家的感觉。无论表面上分配、回收做得怎样井井有条,实际上都并没有对物理内存产生任何影响!不要着急,我们之后会使用它们的。

results matching ""

    No results matching ""