浙江大学实验报告
[!ABSTRACT]
课程名称:操作系统 实验类型:综合型/设计型
实验项目名称:实验 3 RV64 虚拟内存管理
学生姓名:俞仲炜 专业:计算机科学与技术 学号:3220104929
队友:李立 专业:计算机科学与技术 学号:3220106038
电子邮件地址:zhongweiy@zju.edu.cn 实验日期:2024.11.25
实验内容或步骤
准备工程
[!QUOTE]
在
defs.h
添加 如下内容#define OPENSBI_SIZE (0x200000) #define VM_START (0xffffffe000000000) #define VM_END (0xffffffff00000000) #define VM_SIZE (VM_END - VM_START) #define PA2VA_OFFSET (VM_START - PHY_START)
并更新
vmlinux.lds
此外,在 head.S
对应地方调用 setup_vm
, relocate
, setup_vm_final
# (previous) initialize stack
la sp,boot_stack_top # initial the sp
# lab 3
call setup_vm
call relocate
# lab 2
call mm_init
call task_init
# lab 3
call setup_vm_final
# lab 1
# set stvec = _traps
la t0, _traps
csrw stvec, t0
关于 PIE
[!QUOTE]
在 Makefile 的
CF
中加一个-fno-pie
,强制不编译出 PIE 的代码
CF = -march=$(ISA) -mabi=$(ABI) -mcmodel=medany -fno-builtin -fno-pie -ffunction-sections -fdata-sections -nostartfiles -nostdlib -nostdinc -static -lgcc -Wl,--nmagic -Wl,--gc-sections -g
开启虚拟内存映射
setup_vm
的实现
[!QUOTE]
将 0x80000000 开始的 1GB 区域进行两次映射,一次等值映射(PA == VA),另一次映射到
direct mapping area
(使得PA + PV2VA_OFFSET == VA
)
启用 Sv39 后,内存访问方式从通过物理地址直接访问对应的内存空间变为得先通过页表查询虚拟地址对应的物理地址,再通过得到的物理地址访问对应的内存空间
为了顺序过渡虚拟内存的启用,我们需要先进行两次映射,等值映射是为了 pc
的过渡,直接映射初始化了虚拟地址,这两次映射的实现在 setup_vm
函数中实现:
void setup_vm() {
memset(early_pgtbl, 0x0, PGSIZE);
// equal value map
uint64_t idx = (PHY_START >> 30) & 0x1FF;
early_pgtbl[idx] = (((PHY_START >> 30) & 0x3ffffff) << 28) | PGTBL_ENTRY_V | PGTBL_ENTRY_R | PGTBL_ENTRY_W | PGTBL_ENTRY_X;
// direct map
idx = (VM_START >> 30) & 0x1FF;
early_pgtbl[idx] = (((PHY_START >> 30) & 0x3ffffff) << 28) | PGTBL_ENTRY_V | PGTBL_ENTRY_R | PGTBL_ENTRY_W | PGTBL_ENTRY_X;
}
[!QUOTE]
完成上述映射之后,通过
relocate
函数,完成对satp
的设置,以及跳转到对应的虚拟地址
我们在 relocate
对 satp
进行配置,将页表设置为 early_pgtbl
,将模式设置为 Sv39
,由此开启虚拟地址的使用
跳转到虚拟地址的方式是将 ra
修改为虚拟地址,最后借助 ret
指令我们就能开始使用虚拟地址了,此外 sp
也应设置为虚拟地址
ra
与 sp
设置的实现如下:
relocate:
addi t0, x0, 1
slli t0, t0, 31 # 0x80000000
lui t1, 0xfffff
li t2, 0xfdf
add t1, t1, t2
slli t1, t1, 32 # 0xffffffdf00000000
add t0, t0, t1 # PA2VA_OFFSET
add ra, ra, t0 # Add PA2VA_OFFSET to ra
add sp, sp, t0 # Add PA2VA_OFFSET to sp
# need a fence to ensure the new translations are in use
sfence.vma zero, zero
由于 satp
设置后 pc
还指向物理地址,为了避免从设置完 satp
至 ret
的 pc
出问题,我们之前在 setup_vm
里进行了等值映射,保证了这段指令能够正常执行
satp
设置的实现如下:
# set satp with early_pgtbl
la t1, early_pgtbl
srli t1, t1, 12 # PA >> 12 == PPN
li t2, 8 # set mode = Sv39
slli t2, t2, 60
or t1, t1, t2
csrw satp, t1
ret
[!QUOTE]
至此我们已经完成了虚拟地址的开启,之后我们运行的代码也都将在虚拟地址上运行
不过在
mm_init
中我们释放的内存是_ekernel ~ PHY_END
,在进入虚拟地址后PHY_END
还是物理地址,会导致实际上没有内存被释放,kalloc
可能没有可用的空间,因此需要修改mm_init
函数,将结束地址调整为虚拟地址,才能正常运行
void mm_init(void) {
kfreerange(_ekernel, (char *)(PHY_END + PA2VA_OFFSET));
printk("...mm_init done!\n");
}
setup_vm_final
的实现
[!QUOTE]
接下来
setup_vm_final
需要完成对所有物理内存 (128M) 的映射,采用三级页表映射,并设置正确的权限不再需要为 OpenSBI 创建映射,因为 OpenSBI 运行在 M 态,直接使用物理地址
我们通过 vmlinux.ldx
里提供的变量获取各代码段的起始地址与结束地址,用于获取代码段的大小
extern char _ekernel[];
extern char _stext[];
extern char _srodata[];
extern char _sdata[];
extern char _sbss[];
extern char _etext[];
extern char _erodata[];
extern char _edata[];
extern char _ebss[];
随后在 setup_vm_final
函数中通过调用 create_mapping
函数对各代码段进行映射的构建
由于目前虚拟地址还是物理地址直接映射得到的,pa
的设置就直接通过 va
减去 PA2VA_OFFSET
得到,其余参数的设置不再赘述
页表构建后,将新的第一级页表的地址传入 satp
,启用新的页表
void setup_vm_final()
{
memset(swapper_pg_dir, 0x0, PGSIZE);
// mapping kernel text X|-|R|V
create_mapping(swapper_pg_dir, _stext, \
_stext-PA2VA_OFFSET, _etext - _stext, 0b1011);
// mapping kernel rodata -|-|R|V
create_mapping(swapper_pg_dir, _srodata, \
_srodata-PA2VA_OFFSET, _erodata - _srodata, 0b0011);
// mapping other memory -|W|R|V
create_mapping(swapper_pg_dir, _sdata, \
_sdata-PA2VA_OFFSET, _ekernel - _sdata, 0b0111);
// set satp with swapper_pg_dir
__asm__ volatile(
"csrw satp, %0"
:: "r"((uint64_t)swapper_pg_dir)
);
asm volatile("sfence.vma zero, zero");
return;
}
create_mapping
负责构建三级页表里的映射,按部就班即可:
void create_mapping(uint64_t *pgtbl, uint64_t va, uint64_t pa, uint64_t sz, uint64_t perm)
{
while(sz>0){
uint64_t* pgtbl2;
uint64_t* pgtbl3;
uint64_t VPN1 = (va >> 30) & 0x1FF;
uint64_t VPN2 = (va >> 21) & 0x1FF;
uint64_t VPN3 = (va >> 12) & 0x1FF;
uint64_t entry;
entry = pgtbl[VPN1];
if(!(entry & PGTBL_ENTRY_V))
{
pgtbl2 = (uint64_t)kalloc();
pgtbl[VPN1] = (((uint64_t)pgtbl2 >> 12) << 10) | PGTBL_ENTRY_V;
}
else
{
pgtbl2 = (uint64_t*)((entry >> 10) << 12);
}
entry = pgtbl2[VPN2];
if(!(entry&PGTBL_ENTRY_V))
{
pgtbl3 = (uint64_t*)kalloc();
pgtbl2[VPN2] = (((uint64_t)pgtbl3 >> 12) << 10) | PGTBL_ENTRY_V;
}
else
{
pgtbl3 = (uint64_t*)((entry >> 10) << 12);
}
pgtbl3[VPN3] = ((pa >> 12) << 10) | PGTBL_ENTRY_V | perm;
sz--;
va += PGSIZE;
pa += PGSIZE;
}
}
编译及测试
思考题
[!QUOTE]
验证
.text
,.rodata
段的属性是否成功设置,给出截图。
我们将 .text
段的权限改为了 X|-|R|V
, .rodata
段权限改为了 -|-|R|V
,下面分别进行验证
可读性验证
然后我们验证两者的可读性,我们在 start_kernel
函数中添加代码如下:
extern char *_stext;
extern char *_srodata;
int start_kernel() {
printk("2024 ZJU Operating System\n");
printk(" _stext: %x\n", _stext);
printk(" _srodata: %x\n", _srodata);
// task_init();
test(); // DO NOT DELETE !!!
return 0;
}
得到下图结果,可见都是可读的
可写性验证
如果无法进行写操作,会出 exception,进而进入 trap_handler
,我们修改 trap_handler
以捕抓 exception 信息,以此确定确实无法进行写操作 :
void trap_handler(uint64_t scause, uint64_t sepc) {
// 通过 `scause` 判断 trap 类型
// 如果是 interrupt 判断是否是 timer interrupt
// 如果是 timer interrupt 则打印输出相关信息,并通过 `clock_set_next_event()` 设置下一次时钟中断
// `clock_set_next_event()` 见 4.3.4 节
// 其他 interrupt / exception 可以直接忽略,推荐打印出来供以后调试
uint64_t i = 1ULL << 63;
uint64_t itrpt = scause & i;
i = ~i;
uint64_t exc = scause & i;
printk("[%s] cause = %d\n", (itrpt ? "Interrupt" : "Exception"), exc);
if(itrpt && (exc == 5)){
// printk("Supervisor Mode Timer Interrupt\n");
clock_set_next_event();
do_timer();
}
}
修改 start_kernel
如下,验证 .stext
可写性
extern char *_stext;
int start_kernel() {
printk("2024 ZJU Operating System\n");
printk("Writing _stext\n");
_stext[0] = 'a';
// task_init();
test(); // DO NOT DELETE !!!
return 0;
}
结果如下图,可见出现 exception
修改 start_kernel
如下,验证 .srodata
可写性
extern char *_srodata;
int start_kernel() {
printk("2024 ZJU Operating System\n");
printk("Writing _srodata\n");
_srodata[0] = 'a';
// task_init();
test(); // DO NOT DELETE !!!
return 0;
}
结果如下图,可见出现 exception
可执行性验证
.text
段的执行权限是显而易见的,不然根本跑不起来
修改 start_kernel
如下,验证 .srodata
可执行性
extern void _srodata();
int start_kernel() {
printk("2024 ZJU Operating System\n");
printk("Running _srodata\n");
_srodata();
// task_init();
test(); // DO NOT DELETE !!!
return 0;
}
结果如下图,可见出现 exception
[!QUOTE]
本次实验中如果不做等值映射,会出现什么问题,原因是什么;
虚拟地址启用前后,各 reg 的值不会变化,这意味着会存储地址的相关的 reg (诸如 pc
, sp
, ra
)的值还是物理地址,需要改成虚拟地址才能用,所以需要我们手动进行修改,该修改在 relocate
代码段中实现
对于 sp
与 ra
寄存器,我们在修改 satp
前(即,启用虚拟地址前)修改成了虚拟地址,因为这两者在离开 relocate
前不会影响程序的运行
但是,pc
是时时刻刻指向实时执行指令的地址,在修改 satp
前修改的话可能会出错,最后的 ret
没正常执行就完蛋了,所以我们通过额外进行一次等值映射,让使用物理地址的 pc
能正常映射回对应的物理地址,就能保证 pc
在 ret
之前能继续正常工作
经过 ret
后 pc
就改成了直接映射的虚拟地址,等值映射就没用了
[!QUOTE]
简要分析 Linux v5.2.21 或之后的版本中的内核启动部分(直至
init/main.c
中start_kernel
开始之前),特别是设置 satp 切换页表附近的逻辑;
在 vmlinux.lds.S
中,我们能找到 ENTRY(_start)
,由此确程序入口为 _start
该代码段在 head.S
中
_start
首先执行跳过禁用浮点寄存器、选择处理器核、清空 .bss 段等初始化代码,最其最后部分,我们能在里面找到代码如下,这段是我们将要追踪的核心代码:
/* Initialize page tables and relocate to virtual addresses */
la sp, init_thread_union + THREAD_SIZE
call setup_vm
call relocate
/* Restore C environment */
la tp, init_task
sw zero, TASK_TI_CPU(tp)
la sp, init_thread_union + THREAD_SIZE
/* Start the kernel */
mv a0, s1
call parse_dtb
tail start_kernel
首先设置 sp
为 init_thread_union + THREAD_SIZE
,然后先后调用了 setup_vm
和 relocate
函数
init_thread_union
为内核 idle 进程栈顶的地址,THREAD_SIZE
为栈的大小(在 thread_info.h
中被定义为 8KB)
随后我们进入 setup_vm
,setup_vm
定义在 mm/init.c
中,主要负责初始化 trampoline_pg_dir
和 swapper_pg_dir
这两个页表,这两个页表给内核启动时使用
随后我们进入 relocate
与我们自己实验里写的类似,relocate
首先对 ra
进行了设置
PAGE_OFFSET
物理地址的起始地址,即 _start
函数的起始地址
链接脚本为所有符号指定了虚拟地址, _start
符号实际上代表起始虚拟地址。
两者相减即可计算出物理地址与虚拟地址间的偏移。然后,将 ra 的地址修改为对应的虚拟地址
之后,stvec
被设置为符号 1
的地址,其位于 relocate
中刚修改完 satp
处,可以猜到是为了修改 pc
/* Point stvec to virtual address of intruction after satp write */
la a0, 1f
add a0, a0, a1
csrw CSR_STVEC, a0
stvec
设置好之后,trampoline_pg_dir
和 swapper_pg_dir
两个页表的 VPN 分别存入 a0
与 a2
,并将 a0
被写入 satp
寄存器,以此启用虚拟内存
vmlinux.lds.S
已为所有符号指定了虚拟地址,所以启用 trampoline_pg_dir
页表后 stvec
的值为有效的虚拟地址,同时 pc
的值依旧为物理地址,所以执行下一条指令是会出现 exception,然后 pc
被修改为 stvec
储存的地址,由此完成了物理地址到虚拟地址的切换,即符号 1
处,也就是回到 exception
出现处继续执行,就当啥也没有发生:
/* Compute satp for kernel page tables, but don't load it yet */
la a2, swapper_pg_dir
srl a2, a2, PAGE_SHIFT
li a1, SATP_MODE
or a2, a2, a1
/*
* Load trampoline page directory, which will cause us to trap to
* stvec if VA != PA, or simply fall through if VA == PA. We need a
* full fence here because setup_vm() just wrote these PTEs and we need
* to ensure the new translations are in use.
*/
la a0, trampoline_pg_dir
srl a0, a0, PAGE_SHIFT
or a0, a0, a1
sfence.vma
csrw CSR_SATP, a0
接下来是符号 1
之后的代码
首先,stvec
的值被修改为 ⼀ 个死循环的地址(help debug)
然后,将 __global_pointer$
值写入 gp
然后,将 satp
页表改成 swapper_pg_dir
,这是内核真正使用的页表
最后 ret
,返回 call relocate
处
.align 2
1:
/* Set trap vector to spin forever to help debug */
la a0, .Lsecondary_park
csrw CSR_STVEC, a0
/* Reload the global pointer */
.option push
.option norelax
la gp, __global_pointer$
.option pop
/*
* Switch to kernel page tables. A full fence is necessary in order to
* avoid using the trampoline translations, which are only correct for
* the first superpage. Fetching the fence is guarnteed to work
* because that first superpage is translated the same way.
*/
csrw CSR_SATP, a2
sfence.vma
ret
返回 call relocate
处,开始执行以下代码:
/* Restore C environment */
la tp, init_task
sw zero, TASK_TI_CPU(tp)
la sp, init_thread_union + THREAD_SIZE
/* Start the kernel */
mv a0, s1
call parse_dtb
tail start_kernel
随后将 init_task
的地址加载到 tp
中,后者是线程指针(Thread Pointer),用于存储当前任务的控制块地址,这里是进行线程初始化
然后将 tp + TASK_TI_CPU
处置 0
,如此初始化当前任务的 CPU ID 为 0
然后再次初始化 sp
为 init_thread_union + THREAD_SIZE
最后调用解析设备树(Device Tree Blob, DTB)初始化内核硬件抽象相关的信息,并跳转到 start_kernel
[!QUOTE]
回答 Linux 为什么可以不进行等值映射,它是如何在无等值映射的情况下让 pc 从物理地址跳到虚拟地址;
vmlinux.lds.S
已为所有符号指定了虚拟地址,所以启用 trampoline_pg_dir
页表后 stvec
的值为有效的虚拟地址,同时 pc
的值依旧为物理地址,所以执行下一条指令是会出现 exception,然后 pc
被修改为 stvec
储存的地址,由此完成了物理地址到虚拟地址的切换,即符号 1
处,也就是回到 exception
出现处继续执行
[!QUOTE]
Linux v5.2.21 中的
trampoline_pg_dir
和swapper_pg_dir
有什么区别,它们分别是在哪里通过 satp 设为所使用的页表的;
这两个页表在 setup_vm
中进行映射的构建
asmlinkage void __init setup_vm(void)
{
......
trampoline_pg_dir[(PAGE_OFFSET >> PGDIR_SHIFT) % PTRS_PER_PGD] =
pfn_pgd(PFN_DOWN((uintptr_t)trampoline_pmd),
__pgprot(_PAGE_TABLE));
trampoline_pmd[0] = pfn_pmd(PFN_DOWN(pa), prot);
for (i = 0; i < (-PAGE_OFFSET)/PGDIR_SIZE; ++i) {
size_t o = (PAGE_OFFSET >> PGDIR_SHIFT) % PTRS_PER_PGD + i;
swapper_pg_dir[o] =
pfn_pgd(PFN_DOWN((uintptr_t)swapper_pmd) + i,
__pgprot(_PAGE_TABLE));
}
for (i = 0; i < ARRAY_SIZE(swapper_pmd); i++)
swapper_pmd[i] = pfn_pmd(PFN_DOWN(pa + i * PMD_SIZE), prot);
swapper_pg_dir[(FIXADDR_START >> PGDIR_SHIFT) % PTRS_PER_PGD] =
pfn_pgd(PFN_DOWN((uintptr_t)fixmap_pmd),
__pgprot(_PAGE_TABLE));
fixmap_pmd[(FIXADDR_START >> PMD_SHIFT) % PTRS_PER_PMD] =
pfn_pmd(PFN_DOWN((uintptr_t)fixmap_pte),
__pgprot(_PAGE_TABLE));
......
}
trampoline_pg_dir
包含 _start
汇编函数所在页的映射,帮助从物理地址向虚拟地址切换
swapper_pg_dir
为内核实际使用的页表(kernel page tables),其包含了虚拟地址内核空间段映射与内核固定映射
两者都是在 relocate
中被赋予 satp
,注释已经明确说明了:
/*
* Load trampoline page directory, which will cause us to trap to
* stvec if VA != PA, or simply fall through if VA == PA. We need a
* full fence here because setup_vm() just wrote these PTEs and we need
* to ensure the new translations are in use.
*/
la a0, trampoline_pg_dir
srl a0, a0, PAGE_SHIFT
or a0, a0, a1
sfence.vma
csrw CSR_SATP, a0
......
/*
* Switch to kernel page tables. A full fence is necessary in order to
* avoid using the trampoline translations, which are only correct for
* the first superpage. Fetching the fence is guarnteed to work
* because that first superpage is translated the same way.
*/
csrw CSR_SATP, a2
sfence.vma
......
[!QUOTE]
尝试修改你的 kernel,使得其可以像 Linux 一样不需要等值映射。
参考这个 relocate
的思路,借助 stvec
来设置 pc
修改 relocate
如下:
relocate:
# set ra = ra + PA2VA_OFFSET
# set sp = sp + PA2VA_OFFSET (If you have set the sp before)
# PA2VA_OFFSET = 0xffffffdf80000000
addi t0, x0, 1
slli t0, t0, 31 # 0x80000000
lui t1, 0xfffff
li t2, 0xfdf
add t1, t1, t2
slli t1, t1, 32 # 0xffffffdf00000000
add t0, t0, t1 # PA2VA_OFFSET
add ra, ra, t0 # Add PA2VA_OFFSET to ra
add sp, sp, t0 # Add PA2VA_OFFSET to sp
# SET STVEC
la t3 1f
add t3, t3, t0
csrw stvec, t1
sfence.vma zero, zero
# set satp with early_pgtbl
la t1, early_pgtbl
# sub t1, t1, t0 # t0 = PA2VA_OFFSET
srli t1, t1, 12 # PA >> 12 == PPN
li t2, 8 # set mode = Sv39
slli t2, t2, 60
or t1, t1, t2
csrw satp, t1
1:
ret
并删除 setup_vm
中的等值映射:
void setup_vm() {
memset(early_pgtbl, 0x0, PGSIZE);
// direct map
uint64_t idx = (VM_START >> 30) & 0x1FF;
early_pgtbl[idx] = (((PHY_START >> 30) & 0x3ffffff) << 28) | PGTBL_ENTRY_V | PGTBL_ENTRY_R | PGTBL_ENTRY_W | PGTBL_ENTRY_X;
}
能跑(喜):
心得体会
本次实验代码量少,重在理解(悲)
在一开始是按照文档以及注释直接翻译成代码的,直到代码写完程序能跑了都还没完全搞懂内存管理的逻辑(当然有一部分是队友写的,所以可能正好跳过坑了),比如为什么要搞一个看起来好像没啥用的等值映射,为什么 vm.c
里获取代码段地址时,使用 extern char _ekernel[]
这样的数组形式可以获取地址,而使用 extern void *_ekernel
获取的地址却是错的,等等
后面就搞明白了等值映射是为了 pc
从物理地址过渡为虚拟地址,不能用指针是因为获取的是那个地址的内容而不是地址,而数组是直接获取的地址