Skip to content

浙江大学实验报告

:material-circle-edit-outline: 约 2719 个字 :fontawesome-solid-code: 386 行代码 :material-clock-time-two-outline: 预计阅读时间 14 分钟

[!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 的设置,以及跳转到对应的虚拟地址

我们在 relocatesatp 进行配置,将页表设置为 early_pgtbl,将模式设置为 Sv39,由此开启虚拟地址的使用

跳转到虚拟地址的方式是将 ra 修改为虚拟地址,最后借助 ret 指令我们就能开始使用虚拟地址了,此外 sp 也应设置为虚拟地址

rasp 设置的实现如下:

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 还指向物理地址,为了避免从设置完 satpretpc 出问题,我们之前在 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;
    }
}

编译及测试

image-20241201123843232

思考题

[!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;
}

得到下图结果,可见都是可读的

image-20241201135109516

可写性验证

如果无法进行写操作,会出 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

image-20241201135758885

修改 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

image-20241201140108772

可执行性验证

.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

image-20241201140334679

[!QUOTE]

本次实验中如果不做等值映射,会出现什么问题,原因是什么;

虚拟地址启用前后,各 reg 的值不会变化,这意味着会存储地址的相关的 reg (诸如 pc, sp, ra)的值还是物理地址,需要改成虚拟地址才能用,所以需要我们手动进行修改,该修改在 relocate 代码段中实现

对于 spra 寄存器,我们在修改 satp 前(即,启用虚拟地址前)修改成了虚拟地址,因为这两者在离开 relocate 前不会影响程序的运行

但是,pc 是时时刻刻指向实时执行指令的地址,在修改 satp 前修改的话可能会出错,最后的 ret 没正常执行就完蛋了,所以我们通过额外进行一次等值映射,让使用物理地址的 pc 能正常映射回对应的物理地址,就能保证 pcret 之前能继续正常工作

经过 retpc 就改成了直接映射的虚拟地址,等值映射就没用了

[!QUOTE]

简要分析 Linux v5.2.21 或之后的版本中的内核启动部分(直至 init/main.cstart_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

首先设置 spinit_thread_union + THREAD_SIZE,然后先后调用了 setup_vmrelocate 函数

init_thread_union 为内核 idle 进程栈顶的地址,THREAD_SIZE 为栈的大小(在 thread_info.h 中被定义为 8KB)

随后我们进入 setup_vmsetup_vm 定义在 mm/init.c 中,主要负责初始化 trampoline_pg_dirswapper_pg_dir 这两个页表,这两个页表给内核启动时使用

随后我们进入 relocate

与我们自己实验里写的类似,relocate 首先对 ra 进行了设置

PAGE_OFFSET 物理地址的起始地址,即 _start 函数的起始地址

链接脚本为所有符号指定了虚拟地址, _start 符号实际上代表起始虚拟地址。

两者相减即可计算出物理地址与虚拟地址间的偏移。然后,将 ra 的地址修改为对应的虚拟地址

/* Relocate return address */
li a1, PAGE_OFFSET
la a0, _start
sub a1, a1, a0
add ra, ra, a1

之后,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_dirswapper_pg_dir 两个页表的 VPN 分别存入 a0a2,并将 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

然后再次初始化 spinit_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_dirswapper_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;
}

能跑(喜):

image-20241201161429198

心得体会

本次实验代码量少,重在理解(悲)

在一开始是按照文档以及注释直接翻译成代码的,直到代码写完程序能跑了都还没完全搞懂内存管理的逻辑(当然有一部分是队友写的,所以可能正好跳过坑了),比如为什么要搞一个看起来好像没啥用的等值映射,为什么 vm.c 里获取代码段地址时,使用 extern char _ekernel[] 这样的数组形式可以获取地址,而使用 extern void *_ekernel 获取的地址却是错的,等等

后面就搞明白了等值映射是为了 pc 从物理地址过渡为虚拟地址,不能用指针是因为获取的是那个地址的内容而不是地址,而数组是直接获取的地址