物理内存探测与管理

我们知道,物理内存通常是一片 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 计算机中的物理内存。

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

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

起始地址终止地址含义
0x00x100QEMU VIRT_DEBUG
0x1000x1000unmapped
0x10000x12000QEMU MROM (包括 hard-coded reset vector; device tree)
0x120000x100000unmapped
0x1000000x101000QEMU VIRT_TEST
0x1010000x2000000unmapped
0x20000000x2010000QEMU VIRT_CLINT
0x20100000x3000000unmapped
0x30000000x3010000QEMU VIRT_PCIE_PIO
0x30100000xc000000unmapped
0xc0000000x10000000QEMU VIRT_PLIC
0x100000000x10000100QEMU VIRT_UART0
0x100001000x10001000unmapped
0x100010000x10002000QEMU VIRT_VIRTIO
0x100020000x20000000unmapped
0x200000000x24000000QEMU VIRT_FLASH
0x240000000x30000000unmapped
0x300000000x40000000QEMU VIRT_PCIE_ECAM
0x400000000x80000000QEMU VIRT_PCIE_MMIO
0x800000000x88000000DRAM 缺省 128MB,大小可配置

不过为了简单起见,我们并不打算自己去解析这个结果。因为我们知道,Qemu 规定的 DRAM 物理内存的起始物理地址为 0x80000000 。而在 Qemu 中,可以使用 -m 指定 RAM 的大小,默认是

物理内存探测与管理 - 图1

。因此,默认的 DRAM 物理内存地址范围就是 [0x80000000,0x88000000) 。我们直接将 DRAM 物理内存结束地址硬编码到内核中:

  1. // src/lib.rs
  2. mod consts;
  3. // src/consts.rs
  4. pub const PHYSICAL_MEMORY_END: usize = 0x88000000;

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

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

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

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

  1. // src/consts.rs
  2. pub const KERNEL_BEGIN_PADDR: usize = 0x80200000;
  3. pub const KERNEL_BEGIN_VADDR: usize = 0x80200000;
  4. // src/init.rs
  5. use crate::consts::*;
  6. #[no_mangle]
  7. pub extern "C" fn rust_main() -> ! {
  8. extern "C" {
  9. fn end();
  10. }
  11. println!(
  12. "free physical memory paddr = [{:#x}, {:#x})",
  13. end as usize - KERNEL_BEGIN_VADDR + KERNEL_BEGIN_PADDR,
  14. PHYSICAL_MEMORY_END
  15. );
  16. crate::interrupt::init();
  17. crate::timer::init();
  18. loop {}
  19. }

可用物理内存地址

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

物理页帧与物理页号

通常,我们在分配物理内存时并不是以字节为单位,而是以一物理页帧(Frame),即连续的

物理内存探测与管理 - 图2

字节为单位分配。我们希望用物理页号(Physical Page Number, PPN) 来代表一物理页,实际上代表物理地址范围在

物理内存探测与管理 - 图3

的一物理页。

不难看出,物理页号与物理页形成一一映射。为了能够使用物理页号这种表达方式,每个物理页的开头地址必须是

物理内存探测与管理 - 图4

的倍数。但这也给了我们一个方便:对于一个物理地址,其除以

物理内存探测与管理 - 图5

(或者说右移

物理内存探测与管理 - 图6

位) 的商即为这个物理地址所在的物理页号。

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

  1. // src/init.rs
  2. println!(
  3. "free physical memory ppn = [{:#x}, {:#x})",
  4. ((end as usize - KERNEL_BEGIN_VADDR + KERNEL_BEGIN_PADDR) >> 12) + 1,
  5. PHYSICAL_MEMORY_END >> 12
  6. );

可用物理页号区间

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

物理内存页式管理

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

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

我们考虑用一颗非递归线段树来维护这些操作。节点上的值存的是

物理内存探测与管理 - 图7

表示这个节点对应的区间内是否还有空闲物理页(0=空闲,1=被占用)。

  1. // src/const.rs
  2. pub const MAX_PHYSICAL_MEMORY: usize = 0x8000000;
  3. pub const MAX_PHYSICAL_PAGES: usize = MAX_PHYSICAL_MEMORY >> 12;
  4. // src/lib.rs
  5. mod memory;
  6. // src/memory/mod.rs
  7. mod frame_allocator;
  8. // src/memory/frame_allocator.rs
  9. use crate::consts::MAX_PHYSICAL_PAGES;
  10. pub struct SegmentTreeAllocator {
  11. a: [u8; MAX_PHYSICAL_PAGES << 1],
  12. m: usize,
  13. n: usize,
  14. offset: usize
  15. }
  16. impl SegmentTreeAllocator {
  17. // 使用物理页号区间 [l,r) 进行初始化
  18. pub fn init(&mut self, l: usize, r: usize) {
  19. self.offset = l - 1;
  20. self.n = r - l;
  21. self.m = 1;
  22. while self.m < self.n + 2 {
  23. self.m = self.m << 1;
  24. }
  25. for i in (1..(self.m << 1)) { self.a[i] = 1; }
  26. for i in (1..self.n) { self.a[self.m + i] = 0; }
  27. for i in (1..self.m).rev() { self.a[i] = self.a[i << 1] & self.a[(i << 1) | 1]; }
  28. }
  29. // 分配一个物理页
  30. // 自上而下寻找可用的最小物理页号
  31. // 注意,我们假定永远不会出现物理页耗尽的情况
  32. pub fn alloc(&mut self) -> usize {
  33. // assume that we never run out of physical memory
  34. if self.a[1] == 1 {
  35. panic!("physical memory depleted!");
  36. }
  37. let mut p = 1;
  38. while p < self.m {
  39. if self.a[p << 1] == 0 { p = p << 1; } else { p = (p << 1) | 1; }
  40. }
  41. let result = p + self.offset - self.m;
  42. self.a[p] = 1;
  43. p >>= 1;
  44. while p > 0 {
  45. self.a[p] = self.a[p << 1] & self.a[(p << 1) | 1];
  46. p >>= 1;
  47. }
  48. result
  49. }
  50. // 回收物理页号为 n 的物理页
  51. // 自下而上进行更新
  52. pub fn dealloc(&mut self, n: usize) {
  53. let mut p = n + self.m - self.offset;
  54. assert!(self.a[p] == 1);
  55. self.a[p] = 0;
  56. p >>= 1;
  57. while p > 0 {
  58. self.a[p] = self.a[p << 1] & self.a[(p << 1) | 1];
  59. p >>= 1;
  60. }
  61. }
  62. }

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

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

  1. // os/Cargo.toml
  2. [dependencies]
  3. spin = "0.5.2"
  4. // src/memory/frame_allocator.rs
  5. use spin::Mutex;
  6. pub static SEGMENT_TREE_ALLOCATOR: Mutex<SegmentTreeAllocator> = Mutex::new(SegmentTreeAllocator {
  7. a: [0; MAX_PHYSICAL_PAGES << 1],
  8. m: 0,
  9. n: 0,
  10. offset: 0
  11. });

我们注意到在内核中开了一块比较大的静态内存,a 数组。那么 a 数组究竟有多大呢?实际上 a 数组的大小为最大可能物理页数的二倍,因此 a 数组大小仅为物理内存大小的

物理内存探测与管理 - 图8

,可说是微乎其微。

我们本来想把 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 里面再对这个类包装一下:

  1. // src/memory/mod.rs
  2. use frame_allocator::SEGMENT_TREE_ALLOCATOR as FRAME_ALLOCATOR;
  3. use riscv::addr::{
  4. // 分别为虚拟地址、物理地址、虚拟页、物理页帧
  5. // 非常方便,之后会经常用到
  6. // 用法可参见 https://github.com/rcore-os/riscv/blob/master/src/addr.rs
  7. VirtAddr,
  8. PhysAddr,
  9. Page,
  10. Frame
  11. };
  12. pub fn init(l: usize, r: usize) {
  13. FRAME_ALLOCATOR.lock().init(l, r);
  14. println!("++++ setup memory! ++++");
  15. }
  16. pub fn alloc_frame() -> Option<Frame> {
  17. //将物理页号转为物理页帧
  18. Some(Frame::of_ppn(FRAME_ALLOCATOR.lock().alloc()))
  19. }
  20. pub fn dealloc_frame(f: Frame) {
  21. FRAME_ALLOCATOR.lock().dealloc(f.number())
  22. }

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

  1. // src/init.rs
  2. use crate::memory::{
  3. alloc_frame,
  4. dealloc_frame
  5. };
  6. #[no_mangle]
  7. pub extern "C" fn rust_main() -> ! {
  8. extern "C" {
  9. fn end();
  10. }
  11. println!("kernel end vaddr = {:#x}", end as usize);
  12. println!(
  13. "free physical memory ppn = [{:#x}, {:#x})",
  14. ((end as usize - KERNEL_BEGIN_VADDR + KERNEL_BEGIN_PADDR) >> 12) + 1,
  15. PHYSICAL_MEMORY_END >> 12
  16. );
  17. crate::interrupt::init();
  18. crate::memory::init(
  19. ((end as usize - KERNEL_BEGIN_VADDR + KERNEL_BEGIN_PADDR) >> 12) + 1,
  20. PHYSICAL_MEMORY_END >> 12
  21. );
  22. frame_allocating_test();
  23. crate::timer::init();
  24. unsafe {
  25. asm!("ebreak"::::"volatile");
  26. }
  27. panic!("end of rust_main");
  28. loop {}
  29. }
  30. fn frame_allocating_test() {
  31. println!("alloc {:x?}", alloc_frame());
  32. let f = alloc_frame();
  33. println!("alloc {:x?}", f);
  34. println!("alloc {:x?}", alloc_frame());
  35. println!("dealloc {:x?}", f);
  36. dealloc_frame(f.unwrap());
  37. println!("alloc {:x?}", alloc_frame());
  38. println!("alloc {:x?}", alloc_frame());
  39. }

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

物理页分配与回收测试

  1. free physical memory paddr = [0x8021f020, 0x88000000)
  2. free physical memory ppn = [0x80220, 0x88000)
  3. ++++ setup interrupt! ++++
  4. ++++ setup timer! ++++
  5. ++++ setup memory! ++++
  6. alloc Some(Frame(PhysAddr(80220000)))
  7. alloc Some(Frame(PhysAddr(80221000)))
  8. alloc Some(Frame(PhysAddr(80222000)))
  9. dealloc Some(Frame(PhysAddr(80221000)))
  10. alloc Some(Frame(PhysAddr(80221000)))
  11. alloc Some(Frame(PhysAddr(80223000)))
  12. * 100 ticks *
  13. * 100 ticks *
  14. ...

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

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

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