物理内存管理

在管理虚拟内存之前,我们首先需要能够管理物理内存,毕竟所有虚拟内存页都要对应到物理内存页才能使用。

不妨把我们的内存管理模块划分为物理内存管理和虚拟内存管理两个模块。

物理内存管理应当为虚拟内存管理提供这样的接口:

  • 检查当前还有多少空闲的物理页,返回空闲的物理页数目

  • 给出n,尝试分配n个物理页,可以返回一个起始地址和连续的物理页数目,也可能分配一些零散的物理页,返回一个连起来的链表。

  • 给出起始地址和n,释放n个连续的物理页

kern_init()里,我们调用一个新函数:pmm_init()

// kern/init/init.c
int kern_init(void) {
    extern char edata[], end[];
    memset(edata, 0, end - edata);
    cons_init();  // init the console
    const char *message = "(THU.CST) os is loading ...\0";
    cputs(message);
    print_kerninfo();

    idt_init();  // init interrupt descriptor table
    pmm_init();  // 新东西!
    clock_init();   // init clock interrupt
    intr_enable();  // enable irq interrupt
    /* do nothing */
    while (1)
        ;
}

// kern/mm/pmm.c
/* pmm_init - initialize the physical memory management */
void pmm_init(void) {
    // We need to alloc/free the physical memory (granularity is 4KB or other size).
    // So a framework of physical memory manager (struct pmm_manager)is defined in pmm.h
    // First we should init a physical memory manager(pmm) based on the framework.
    // Then pmm can alloc/free the physical memory.
    init_pmm_manager();

    // detect physical memory space, reserve already used memory,
    // then use pmm->init_memmap to create free page list
    page_init();

    // use pmm->check to verify the correctness of the alloc/free function in a pmm
    check_alloc_page();

    extern char boot_page_table_sv39[]; //我们把汇编里定义的页表所在位置的符号声明进来
    satp_virtual = (pte_t*)boot_page_table_sv39;
    satp_physical = PADDR(satp_virtual);//然后输出页表所在的地址
    cprintf("satp virtual address: 0x%016lx\nsatp physical address: 0x%016lx\n", satp_virtual, satp_physical);
}

check_alloc_page()是对物理内存分配功能的一个测试。我们重点关注page_init()

我们在lab2增加了一些功能,方便我们编程:

  • kern/sync/sync.h:为确保内存管理修改相关数据时不被中断打断,提供两个功能,一个是保存 sstatus寄存器中的中断使能位(SIE)信息并屏蔽中断的功能,另一个是根据保存的中断使能位信息来使能中断的功能

  • libs/list.h:定义了通用双向链表结构以及相关的查找、插入等基本操作,这是建立基于链表方法的物理内存管理(以及其他内核功能)的基础。其他有类似双向链表需求的内核功能模块可直接使用 list.h 中定义的函数。

  • libs/atomic.h:定义了对一个二进制位进行读写的原子操作,确保相关操作不被中断打断。包括set_bit()设置某个二进制位的值为1, change_bit()给某个二进制位取反,test_bit()返回某个二进制位的值。

// kern/sync/sync.h
#ifndef __KERN_SYNC_SYNC_H__
#define __KERN_SYNC_SYNC_H__

#include <defs.h>
#include <intr.h>
#include <riscv.h>

static inline bool __intr_save(void) {
    if (read_csr(sstatus) & SSTATUS_SIE) {
        intr_disable();
        return 1;
    }
    return 0;
}

static inline void __intr_restore(bool flag) {
    if (flag) {
        intr_enable();
    }
}
//思考:这里宏定义的 do{}while(0)起什么作用?
#define local_intr_save(x) \
    do {                   \
        x = __intr_save(); \
    } while (0)
#define local_intr_restore(x) __intr_restore(x);

#endif /* !__KERN_SYNC_SYNC_H__ */

list.h里面实现了一个简单的双向链表。虽然接口很多,但是只要对链表熟悉,不难理解。如果理解不了,可以先去学学数据结构这门课。

// libs/list.h
struct list_entry {
    struct list_entry *prev, *next;
};

typedef struct list_entry list_entry_t;

static inline void list_init(list_entry_t *elm) __attribute__((always_inline));
static inline void list_add(list_entry_t *listelm, list_entry_t *elm) __attribute__((always_inline));
static inline void list_add_before(list_entry_t *listelm, list_entry_t *elm) __attribute__((always_inline));
static inline void list_add_after(list_entry_t *listelm, list_entry_t *elm) __attribute__((always_inline));
static inline void list_del(list_entry_t *listelm) __attribute__((always_inline));
static inline void list_del_init(list_entry_t *listelm) __attribute__((always_inline));
static inline bool list_empty(list_entry_t *list) __attribute__((always_inline));
static inline list_entry_t *list_next(list_entry_t *listelm) __attribute__((always_inline));
static inline list_entry_t *list_prev(list_entry_t *listelm) __attribute__((always_inline));
//下面两个函数仅在内部使用,不对外开放作为接口。
static inline void __list_add(list_entry_t *elm, list_entry_t *prev, list_entry_t *next) __attribute__((always_inline));
static inline void __list_del(list_entry_t *prev, list_entry_t *next) __attribute__((always_inline));

看起来list.h里面定义的list_entry并没有数据域,但是,如果我们把list_entry作为其他结构体的成员,就可以利用C语言结构体内存连续布局的特点,从`list_entry的地址获得它所在的上一级结构体。

于是我们定义了可以连成链表的Page结构体和一系列对它做操作的宏。这个结构体用来管理物理内存。

// libs/defs.h

/* Return the offset of 'member' relative to the beginning of a struct type */
#define offsetof(type, member)                                      \
    ((size_t)(&((type *)0)->member))

/* *
 * to_struct - get the struct from a ptr
 * @ptr:    a struct pointer of member
 * @type:   the type of the struct this is embedded in
 * @member: the name of the member within the struct
 * */
#define to_struct(ptr, type, member)                               \
    ((type *)((char *)(ptr) - offsetof(type, member)))

// kern/mm/memlayout.h
/* *
 * struct Page - Page descriptor structures. Each Page describes one
 * physical page. In kern/mm/pmm.h, you can find lots of useful functions
 * that convert Page to other data types, such as physical address.
 * */
struct Page {
    int ref;                 // page frame's reference counter
    uint64_t flags;          // array of flags that describe the status of the page frame
    unsigned int property;   // the num of free block, used in first fit pm manager
    list_entry_t page_link;  // free list link
};

/* Flags describing the status of a page frame */
#define PG_reserved                 0       // if this bit=1: the Page is reserved for kernel, cannot be used in alloc/free_pages; otherwise, this bit=0
#define PG_property                 1       // if this bit=1: the Page is the head page of a free memory block(contains some continuous_addrress pages), and can be used in alloc_pages; if this bit=0: if the Page is the the head page of a free memory block, then this Page and the memory block is alloced. Or this Page isn't the head page.
//这几个对page操作的宏用到了atomic.h的原子操作
#define SetPageReserved(page)       set_bit(PG_reserved, &((page)->flags))
#define ClearPageReserved(page)     clear_bit(PG_reserved, &((page)->flags))
#define PageReserved(page)          test_bit(PG_reserved, &((page)->flags))
#define SetPageProperty(page)       set_bit(PG_property, &((page)->flags))
#define ClearPageProperty(page)     clear_bit(PG_property, &((page)->flags))
#define PageProperty(page)          test_bit(PG_property, &((page)->flags))

// convert list entry to page
#define le2page(le, member)                 \
    to_struct((le), struct Page, member)

/* free_area_t - maintains a doubly linked list to record free (unused) pages */
typedef struct {
    list_entry_t free_list;         // the list header
    unsigned int nr_free;           // # of free pages in this free list
} free_area_t;

(抄自rcore tutorial)

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

起始地址

终止地址

含义

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 物理内存结束地址硬编码到内核中:

// kern/mm/memlayout.h

#define KERNBASE            0xFFFFFFFFC0200000 
#define KMEMSIZE            0x7E00000          
#define KERNTOP             (KERNBASE + KMEMSIZE) 

#define PHYSICAL_MEMORY_END         0x88000000
#define PHYSICAL_MEMORY_OFFSET      0xFFFFFFFF40000000 //物理地址和虚拟地址的偏移量
#define KERNEL_BEGIN_PADDR          0x80200000
#define KERNEL_BEGIN_VADDR          0xFFFFFFFFC0200000

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

  • 物理地址空间 [0x80000000,0x80200000) 被 OpenSBI 占用;

  • 物理地址空间 [0x80200000,KernelEnd) 被内核各代码与数据段占用;

  • 其实设备树扫描结果 DTB 还占用了一部分物理内存,不过由于我们不打算使用它,所以可以将它所占用的空间用来存别的东西。

于是,我们可以用来存别的东西的物理内存的物理地址范围是:[KernelEnd, 0x88000000) 。这里的 KernelEnd 为内核代码结尾的物理地址。在 kernel.ld 中定义的 end 符号为内核代码结尾的虚拟地址。

为了管理物理内存,我们需要在内核里定义一些数据结构,来存储”当前使用了哪些物理页面,哪些物理页面没被使用“这样的信息,使用的是Page结构体。我们将一些Page结构体在内存里排列在内核后面,这要占用一些内存。而摆放这些Page结构体的物理页面,以及内核占用的物理页面,之后都无法再使用了。我们用page_init()函数给这些管理物理内存的结构体做初始化。下面是代码

// kern/mm/pmm.h

/* *
 * PADDR - takes a kernel virtual address (an address that points above
 * KERNBASE),
 * where the machine's maximum 256MB of physical memory is mapped and returns
 * the
 * corresponding physical address.  It panics if you pass it a non-kernel
 * virtual address.
 * */
#define PADDR(kva)                                                 \
    ({                                                             \
        uintptr_t __m_kva = (uintptr_t)(kva);                      \
        if (__m_kva < KERNBASE) {                                  \
            panic("PADDR called with invalid kva %08lx", __m_kva); \
        }                                                          \
        __m_kva - va_pa_offset;                                    \
    })

/* *
 * KADDR - takes a physical address and returns the corresponding kernel virtual
 * address. It panics if you pass an invalid physical address.
 * */
/*
#define KADDR(pa)                                                \
    ({                                                           \
        uintptr_t __m_pa = (pa);                                 \
        size_t __m_ppn = PPN(__m_pa);                            \
        if (__m_ppn >= npage) {                                  \
            panic("KADDR called with invalid pa %08lx", __m_pa); \
        }                                                        \
        (void *)(__m_pa + va_pa_offset);                         \
    })
*/
extern struct Page *pages;
extern size_t npage;

// kern/mm/pmm.c

// pages指针保存的是第一个Page结构体所在的位置,也可以认为是Page结构体组成的数组的开头
// 由于C语言的特性,可以把pages作为数组名使用,pages[i]表示顺序排列的第i个结构体
struct Page *pages;
size_t npage = 0;
uint64_t va_pa_offset;
// memory starts at 0x80000000 in RISC-V
const size_t nbase = DRAM_BASE / PGSIZE;
//(npage - nbase)表示物理内存的页数

static void page_init(void) {
    va_pa_offset = PHYSICAL_MEMORY_OFFSET; //硬编码 0xFFFFFFFF40000000

    uint64_t mem_begin = KERNEL_BEGIN_PADDR;//硬编码 0x80200000
    uint64_t mem_size = PHYSICAL_MEMORY_END - KERNEL_BEGIN_PADDR;
    uint64_t mem_end = PHYSICAL_MEMORY_END; //硬编码 0x88000000

    cprintf("physcial memory map:\n");
    cprintf("  memory: 0x%016lx, [0x%016lx, 0x%016lx].\n", mem_size, mem_begin,
            mem_end - 1);

    uint64_t maxpa = mem_end;

    if (maxpa > KERNTOP) {
        maxpa = KERNTOP;
    }

    npage = maxpa / PGSIZE;

    extern char end[];
    pages = (struct Page *)ROUNDUP((void *)end, PGSIZE);
    //把pages指针指向内核所占内存空间结束后的第一页

    //一开始把所有页面都设置为保留给内核使用的,之后再设置哪些页面可以分配给其他程序
    for (size_t i = 0; i < npage - nbase; i++) {
        SetPageReserved(pages + i);//记得吗?在kern/mm/memlayout.h定义的
    }
    //从这个地方开始才是我们可以自由使用的物理内存
    uintptr_t freemem = PADDR((uintptr_t)pages + sizeof(struct Page) * (npage - nbase));
    //按照页面大小PGSIZE进行对齐, ROUNDUP, ROUNDDOWN是在libs/defs.h定义的
    mem_begin = ROUNDUP(freemem, PGSIZE);
    mem_end = ROUNDDOWN(mem_end, PGSIZE);
    if (freemem < mem_end) {
        //初始化我们可以自由使用的物理内存
        init_memmap(pa2page(mem_begin), (mem_end - mem_begin) / PGSIZE);
    }
}

page_init()的代码里,我们调用了一个函数init_memmap(), 这和我们的另一个结构体pmm_manager有关。虽然C语言基本上不支持面向对象,但我们可以用类似面向对象的思路,把”物理内存管理“的功能集中给一个结构体。我们甚至可以让函数指针作为结构体的成员,强行在C语言里支持了”成员函数“。可以看到,我们调用的init_memmap()实际上又调用了pmm_manager的一个”成员函数“。如果你不熟悉函数指针的用法,可以读一读《The C Programming Language》的相关章节(待补充:第几章第几节?)。

// kern/mm/pmm.c

// physical memory management
const struct pmm_manager *pmm_manager;


// init_memmap - call pmm->init_memmap to build Page struct for free memory
static void init_memmap(struct Page *base, size_t n) {
    pmm_manager->init_memmap(base, n);
}

// kern/mm/pmm.h
#ifndef __KERN_MM_PMM_H__
#define __KERN_MM_PMM_H__

#include <assert.h>
#include <atomic.h>
#include <defs.h>
#include <memlayout.h>
#include <mmu.h>
#include <riscv.h>

// pmm_manager is a physical memory management class. A special pmm manager -
// XXX_pmm_manager
// only needs to implement the methods in pmm_manager class, then
// XXX_pmm_manager can be used
// by ucore to manage the total physical memory space.
struct pmm_manager {
    const char *name;  // XXX_pmm_manager's name
    void (*init)(
        void);  // 初始化XXX_pmm_manager内部的数据结构(如空闲页面的链表)
    void (*init_memmap)(
        struct Page *base,
        size_t n);  //知道了可用的物理页面数目之后,进行更详细的初始化
    struct Page *(*alloc_pages)(
        size_t n);  // 分配至少n个物理页面, 根据分配算法可能返回不同的结果
    void (*free_pages)(struct Page *base, size_t n);  // free >=n pages with
                                                      // "base" addr of Page
                                                      // descriptor
                                                      // structures(memlayout.h)
    size_t (*nr_free_pages)(void);  // 返回空闲物理页面的数目
    void (*check)(void);            // 测试正确性
};

extern const struct pmm_manager *pmm_manager;

void pmm_init(void);

struct Page *alloc_pages(size_t n);
void free_pages(struct Page *base, size_t n);
size_t nr_free_pages(void); // number of free pages

#define alloc_page() alloc_pages(1)
#define free_page(page) free_pages(page, 1)

pmm_manager提供了各种接口:分配页面,释放页面,查看当前空闲页面数。但是我们好像始终没看见pmm_manager内部对这些接口的实现,那些接口只是作为函数指针,作为pmm_manager的一部分,我们需要把那些函数指针变量赋值为真正的函数名称。

还记得最早我们在pmm_init()里首先调用了init_pmm_manager(), 在这里面我们把pmm_manager的指针赋值成&default_pmm_manager, 看起来我们在这里实现了那些接口。

// init_pmm_manager - initialize a pmm_manager instance
static void init_pmm_manager(void) {
    pmm_manager = &default_pmm_manager;
    cprintf("memory management: %s\n", pmm_manager->name);
    pmm_manager->init();
}
// alloc_pages - call pmm->alloc_pages to allocate a continuous n*PAGESIZE
// memory
struct Page *alloc_pages(size_t n) {
    struct Page *page = NULL;
    bool intr_flag;
    //禁用中断,确保操作的原子性
    local_intr_save(intr_flag);
    {
        page = pmm_manager->alloc_pages(n);
    }
    local_intr_restore(intr_flag);
    return page;
}

// free_pages - call pmm->free_pages to free a continuous n*PAGESIZE memory
void free_pages(struct Page *base, size_t n) {
    bool intr_flag;
    local_intr_save(intr_flag);
    {
        pmm_manager->free_pages(base, n);
    }
    local_intr_restore(intr_flag);
}

// nr_free_pages - call pmm->nr_free_pages to get the size (nr*PAGESIZE)
// of current free memory
size_t nr_free_pages(void) {
    size_t ret;
    bool intr_flag;
    local_intr_save(intr_flag);
    {
        ret = pmm_manager->nr_free_pages();
    }
    local_intr_restore(intr_flag);
    return ret;
}

到现在,我们距离完整的内存管理, 就只差default_pmm_manager结构体的实现了。我们主要在里面实现了页面分配算法。

最后更新于