xv6-riscv中使用的物理内存分配单位是页(Page),也就是4KB(4096B)。 也就是说,干啥都要按页来处理。
内存的初始化过程,就是下面三个步骤:
main()
{
// ...
kinit(); // physical page allocator
kvminit(); // create kernel page table
kvminithart(); // turn on paging
// ...
}
其中,kinit将内核未分配的所有内存,都写为01;kvminit映射内核需要的所有页表; kvminithart需要在所有hart上执行,开启分页。
分页是一个物理实现,打开分页后,不管是用户还是内核,都将使用虚拟内存地址进行 内存的访问,无法直接读取物理内存地址。在satp寄存器中写入根页表的地址就开启了分页, 非常简单。
开启分页(初始化到kvminithart)后通过QEMU控制台中可以查看分页情况:
(qemu) help info mem
info mem -- show the active virtual memory mappings
(qemu) info mem
vaddr paddr size attr
---------------- ---------------- ---------------- -------
000000000c000000 000000000c000000 0000000000400000 rw-----
0000000010000000 0000000010000000 0000000000002000 rw-----
0000000080000000 0000000080000000 0000000000002000 r-x--a- // 内核代码
0000000080002000 0000000080002000 0000000000006000 r-x----
0000000080008000 0000000080008000 0000000000001000 rw-----
0000000080009000 0000000080009000 0000000000001000 rw---a-
000000008000a000 000000008000a000 0000000007ff6000 rw-----
0000003ffff7f000 0000000087f78000 0000000000040000 rw-----
0000003ffffff000 0000000080007000 0000000000001000 r-x---- // trampoline
但是这个内存布局有几个巧妙之处:
-
前面几行的vaddr和paddr是完全一致的,也就是说是直接映射(ditect mapping),简化了 内核内存的管理。比如consoleinit时调用uartinit,实际上操作了UART0寄存器的,因为是直接 映射,所以后面再使用UART0也不需要调整映射关系;
-
后面两行不是直接映射,一行是内核栈空间,一行是trampoline
-
无效的页不会在这里显示出来
kinit()
kinit初始化内核的内存页面,大概流程如下图:
kmem内包含了一个spinlock,就是为了保护freelist(空闲页链表),防止并发问题。 操作freelist链表的时候使用锁包裹。
end是kerenl.ld中定义的,表示.bss段末尾位置,可以说是内核内存的heap初始位置。
kfree把页内的内存值都设为0x01,不设为0。
struct run 定义的很巧妙,本身只包含一个指针;所以pa既可以是uint64类型的物理地址, 也可以是页首地址,也可以是链表成员!!!
struct run {
struct run *next;
};
kvminit()
kvminit 设置内核页表,内核页表和用户页表采用的是相同的数据结构pagetable_t,而且这个类型也很普通, 就是一个指针。其实开始也不太理解,为啥就是个指针。其实页表类型,它所指向的就是一个内存页面,而 这个指针就是页的首地址,或者说它是页内第一个PTE的地址。
// vm.c
pagetable_t kernel_pagetable;
// riscv.h
typedef uint64 pte_t;
typedef uint64 *pagetable_t; // 512 PTEs
页表就是用来索引物理页地址到虚拟页地址的,但是页表本身也需要页来存储,所以页表本身也存放在分配 的页中,一个页4096B,一个页表项(PTE=44+10)占用64bit(8B),所以一个页可以存放512个PTE。这也是 xv6采用的Sv39内存结构的重要特性。
内核页表的创建由kvmmake()完成,就是按照内核布局进行映射
// Make a direct-map page table for the kernel.
pagetable_t
kvmmake(void)
{
pagetable_t kpgtbl;
kpgtbl = (pagetable_t) kalloc();
memset(kpgtbl, 0, PGSIZE);
// uart registers
kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);
// virtio mmio disk interface
kvmmap(kpgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
// PLIC
kvmmap(kpgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
// map kernel text executable and read-only.
kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
// map kernel data and the physical RAM we'll make use of.
kvmmap(kpgtbl, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
// map the trampoline for trap entry/exit to
// the highest virtual address in the kernel.
kvmmap(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
// allocate and map a kernel stack for each process.
proc_mapstacks(kpgtbl);
return kpgtbl;
}
完成后就出现了(qemu)info mem所显示的内存布局了。kvmmap是主要实现,它又封装了mappages函数…大概过程如下:
里面的walk()是比较难理解的,它主要是模拟了riscv分页情况下虚拟地址如何查找物理地址的过程:
- 根页表(L2)中查找PTE,确认PTE指向的L1页表是否存在,不存在就创建一个;
- L1页表中查找PTE,指向L0页表是否存在,不存在就创建;
- L0页表中查找PTE,把这个PTE的地址返回
每个PTE有效的是44位PPN是指向页表位置或者物理位置的指针(因为都是页对齐的,所以省略12位),10位flags是页权限; 比如L2的PTE.ppn«12 –> L1 页表的初始位置; L0 PTE.ppn«12 –> 物理页面地址。
这两个宏定义了页表项pte如何与物理地址pa转换:
// shift a physical address to the right place for a PTE.
#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)
#define PTE2PA(pte) (((pte) >> 10) << 12)
kvminithart()
这个函数反而最简单,清空TLB然后切换页表。
void
kvminithart()
{
// wait for any previous writes to the page table memory to finish.
sfence_vma();
w_satp(MAKE_SATP(kernel_pagetable));
// flush stale entries from the TLB.
sfence_vma();
}
将跟页表的地址,写入到satp中,但是不是简单的写入,还有一个MODE需要一块写入,那就是证明xv6使用Sv39类型的虚拟地址SATP_SV39。
// use riscv's sv39 page table scheme.
#define SATP_SV39 (8L << 60)
#define MAKE_SATP(pagetable) (SATP_SV39 | (((uint64)pagetable) >> 12))
可以参考riscv手册查看satp寄存器的格式: