Skip to content

浙江大学实验报告

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

[!ABSTRACT]

课程名称:操作系统 实验类型:综合型/设计型

实验项目名称:实验 4: RV64 用户态程序

学生姓名:俞仲炜 专业:计算机科学与技术 学号:3220104929

队友:李立 专业:计算机科学与技术 学号:3220106038

电子邮件地址:zhongweiy@zju.edu.cn 实验日期:2024.12.8

实验内容或步骤

准备工程

修改 vmlinux.lds ,将用户态程序 uapp 加载至 .data 段:

    .data : ALIGN(0x1000) {
        _sdata = .;

        *(.sdata .sdata*)
        *(.data .data.*)

        _edata = .;

        . = ALIGN(0x1000);
        _sramdisk = .;
        *(.uapp .uapp*)
        _eramdisk = .;
        . = ALIGN(0x1000);
    } >ramv AT>ram

defs.h 添加如下内容:

#define USER_START (0x0000000000000000) // user space start virtual address
#define USER_END (0x0000004000000000) // user space end virtual address

从仓库同步文件,略

修改根目录下的 Makefile,将 user 文件夹下的内容纳入工程管理:

all: clean
    ${MAKE} -C user all     # LAB4 ADD
clean:
    ${MAKE} -C user clean   # LAB4 ADD

创建用户态进程

结构体更新

修改 proc.h 中的 NR_TASKS 如下:

#define NR_TASKS (1 + 4)    // 测试时线程数量

proc.h 中修改 thread_structtask_struct 数据结构如下:

struct thread_struct {
    uint64_t ra;
    uint64_t sp;                     
    uint64_t s[12];
    uint64_t sepc, sstatus, sscratch; 
};

struct task_struct {
    uint64_t state;
    uint64_t counter;
    uint64_t priority;
    uint64_t pid;

    struct thread_struct thread;
    uint64_t *pgd;  // 用户态页表
};

我们为每个进程分配了独立的页表,防止不同用户态进程共享同一块空间,造成错误

我们为每个线程增加了 sepcsstatussscratch 变量用于保存对应的寄存器

修改 task_init()

对于每个进程,初始化我们刚刚在 thread_struct 中添加的三个变量,并创建属于进程自己的页表,再设置用户态栈

sepc 设为用户态程序入口地址 USER_START,如此可以在切换进程时可以通过 sret 开始执行用户态程序,

sscratch 设为 USER_END,即用户栈的起始地址,因为栈是由高往低增长

sstatus 中的 SUM 位置 1,让 S 特权级下的程序能够访问用户页

sstatus 中的 SPP 位置 0,使得 sret 返回至 U-Mode

此外,每个进程都需拷贝一份内核页表,由于本实验中各进程的内核空间映射一致,只有用户空间映射不同,所以只需拷贝第一层页表即可

void task_init(){
    for(int i = 1; i < NR_TASKS; i++){
        ...
        // lab4 4.2.2
        task[i]->thread.sstatus = SSTATUS_SPP_U | SSTATUS_SUM; // in defs.h
        task[i]->thread.sscratch = USER_END;

        // pgtbl
        task[i]->pgd = (uint64_t *)kalloc();
        for(int j = 0; j < 512; j++){
            task[i]->pgd[j] = swapper_pg_dir[j];
        }

        load_bin(task[i]);

        // stack
        uint64_t *user_stack = (uint64_t *)kalloc();
        create_mapping(task[i]->pgd, USER_END - PGSIZE, 
                (uint64_t)user_stack - PA2VA_OFFSET, PGSIZE, 0b10111);

        task[i]->pgd = (uint64_t)task[i]->pgd - PA2VA_OFFSET;
    }
    printk("[DEBUG] task_init done\n");
}

其中 load_bin 负责拷贝 uapp 以及构建映射,并设置 sepc,代码如下:

void* memcpy(void *src, const void *dst, unsigned long len) {
    char *s = (char*)src;
    char *d = (char*)dst;
    while(len--) {
        *d++ = *s++;
    }
    return src;
}

// LAB 4
void load_bin(struct task_struct *task){
    // load uapp (copy)
    uint64_t page_num = ((uint64_t)_eramdisk - (uint64_t)_sramdisk) / PGSIZE + 1;
    uint64_t *uapp_copy = (uint64_t *)alloc_pages(page_num);

    memcpy(_sramdisk, uapp_copy, _eramdisk - _sramdisk);

    create_mapping(task->pgd, USER_START, (uint64_t)uapp_copy - PA2VA_OFFSET, (uint64_t)_eramdisk - (uint64_t)_sramdisk, 0b11111);

    task->thread.sepc = USER_START;
}

修改 __switch_to

在前面新增了 sepcsstatus、ss c ratch 之后,需要将这些变量在切换进程时保存在栈上,因此需要更新 __switch_to 中的逻辑,同时需要增加切换页表的逻辑

__switch_to:
    # save state to prev process
    ...

    # lab4-4.3
    csrr s0, sepc
    sd s0, 144(a0)
    csrr s0, sstatus
    sd s0, 152(a0)
    csrr s0, sscratch
    sd s0, 160(a0)

    # restore state from next process
    ...

    # lab4-4.3
    ld s0, 144(a1)
    csrw sepc, s0
    ld s0, 152(a1)
    csrw sstatus, s0
    ld s0, 160(a1)
    csrw sscratch, s0

    # PPN
    ld t0, 168(a1)      
    srli t0, t0, 12     
    # set mode to sv39
    li t1, 0x8          
    slli t1, t1, 60   

    or t0, t0, t1       
    csrw satp, t0       

    sfence.vma zero, zero

    ret

更新中断处理逻辑

当触发异常时,我们首先要对栈进行切换(从用户栈切换到内核栈),完成了异常处理,从 S-Mode 返回至 U-Mode 时,也需要进行栈切换(从内核栈切换到用户栈)

修改 __dummy

__dummy 进入用户态模式的时候,我们需要切换内核态栈和用户态栈,只需将 spsscratch 的值交换即可

__dummy:
    # LAB4-4.4.1 swap sp and sscratch
    csrr t0, sscratch
    csrw sscratch, sp
    addi sp, t0, 0

    sret

修改 _traps

在进入 trap 的时候需要切换到内核栈,处理完成后需要再切换回来

[!NOTE]

注意如果是内核线程(没有用户栈)触发了异常,则不需要进行切换。(内核线程的 sp 永远指向的内核栈,且 sscratch 为 0)

_traps:
    # LAB4-4.4.2 get sscratch, check if kernel trap or not
    addi sp, sp, -8
    sd t0, 0(sp)
    csrr t0, sscratch
    beq t0, zero, kernel_trap
    ld t0, 0(sp)
    addi sp, sp, 8
    # swap sp and sscratch
    csrr t0, sscratch
    csrw sscratch, sp
    addi sp, t0, 0
    j user_trap

kernel_trap:
    ld t0, 0(sp)
    addi sp, sp, 8
user_trap:
    # 1. save 32 registers and sepc to stack
    addi sp, sp, -280
    ...
    # LAB4
    csrr t0, sstatus
    sd t0, 248(sp)
    csrr t0, scause
    sd t0, 256(sp)
    csrr t0, sepc
    sd t0, 264(sp)
    sd sp, 272(sp)

    # 2. call trap_handler
    csrr a0, scause
    csrr a1, sepc
    addi a2, sp, 0
    call trap_handler

    # 3. restore sepc and 32 registers from stack
    # LAB4
    ld sp, 272(sp)
    ld t0, 264(sp)
    csrw sepc, t0
    ld t0, 256(sp)
    csrw scause, t0
    ld t0, 248(sp)
    csrw sstatus, t0
    ...

    # LAB4-4.4.2 tell if need switch stack
    addi sp, sp, -8
    sd t0, 0(sp)
    csrr t0, sscratch
    beq t0, zero, kernel_trap_ret
    ld t0, 0(sp)
    addi sp, sp, 8
    # swap sp and sscratch
    csrr t0, sscratch
    csrw sscratch, sp
    addi sp, t0, 0
    j user_trap_ret
kernel_trap_ret:
    ld t0, 0(sp)
    addi sp, sp, 8
user_trap_ret:
    sret

修改 trap_handler

处理系统调用的时候需要寄存器的值,因此我们需要在 trap_handler() 里面进行捕获

根据 _trap 里的入栈顺序,定义 struct pt_regs 如下:

struct pt_regs {
    uint64_t reg[31];
    uint64_t sstatus;
    uint64_t scause;
    uint64_t sepc;
};

然后修改 trap_handler 的输入,在 _trap 里也应额外传入 sp 作为参数

添加系统调用

trap_handler() 里对新增的两个系统调用进行解析:

void trap_handler(uint64_t scause, uint64_t sepc, struct pt_regs *regs) {
    uint64_t i = 1ULL << 63;
    uint64_t itrpt = scause & i;
    i = ~i;
    uint64_t exc = scause & i;

    if(itrpt && (exc == 5)) {
        ...
    } else if(exc == 8){
        if(regs->reg[16] == SYS_WRITE) {
            regs->reg[9] = sys_write(regs->reg[9], (const char*)regs->reg[10], regs->reg[11]);
            regs->sepc += 4;
        } else if(regs->reg[16] == SYS_GETPID) {
            regs->reg[9] = sys_getpid();
            regs->sepc += 4;
        }
    }
}

针对系统调用这一类异常,我们需要手动完成 sepc + 4,以便异常处理结束后继续执行原程序

syscall.c 中实现 getpid() 以及 write() 逻辑,前者直接调用提供的 printk 进行打印,后者无需多言:

size_t sys_write(unsigned int fd, const char* buf, size_t count)
{
    long res = 0;
    for (int i = 0; i < count; i++) {
        if(fd==1){
            printk("%c", buf[i]);
            res++;
        }
    }
    return res;
}

extern struct task_struct *current;

uint64_t sys_getpid()
{
    return current->pid;
}

调整时钟中断

start_kernel() 中,test() 之前调用 schedule(),以实现在 OS boot 完成之后立即调度 uapp 运行:

extern void test();
extern void schedule();

int start_kernel() {
    printk("2024");
    printk(" ZJU Operating System\n");
    schedule();
    test();
    return 0;
}

head.S 中设置 sstatus.SIE 的逻辑注释掉,确保 schedule 过程不受中断影响:

# modified at LAB4
# set sstatus[SIE] = 1
# li t0, 1<<1  # the 1_bit
# csrsi sstatus, 1<<1 

测试纯二进制文件

image-20241211105810576

添加 ELF 解析与加载

我们以 segment 为粒度将程序加载进内存中,且只关注 Type 为 LOAD 的 segment,LOAD 表示它们需要在开始运行前被加载进内存中

uapp.S 中的 payload 给换成我们的 ELF 文件:

.section .uapp

# .incbin "uapp.bin"
.incbin "uapp"

这时候从 _sramdisk 开始的数据就变成了名为 uapp 的 ELF 文件,我们需要对 task_init 中的初始化步骤进行修改,将 ELF 文件中进行拷贝:

void task_init() {
    ...  
    for(int i = 1; i < NR_TASKS; i++){
        ...

        if (*((uint32_t *)((Elf64_Ehdr *)(void *)_sramdisk)->e_ident) ==  0x464c457f) 
        {
            printk("[task_init] loading elf for task %d...\n", i);
            load_program(task[i]);
        } else {
            printk("[task_init] loading binary file for task %d...\n", i);
            load_bin(task[i]);
        }
        ...
    }
}

其中 load_program 负责在 ELF 文件中定位需要拷贝的内容,并获取对应的权限,初始化内存中各段内容,并构建映射:

#include "elf.h"

void load_program(struct task_struct *task) {
    Elf64_Ehdr *ehdr = (Elf64_Ehdr *)_sramdisk;
    Elf64_Phdr *phdrs = (Elf64_Phdr *)(_sramdisk + ehdr->e_phoff);
    for (int i = 0; i < ehdr->e_phnum; ++i) {
        Elf64_Phdr *phdr = phdrs + i;
        if (phdr->p_type == PT_LOAD) {
            // alloc & copy 
            void *bin = alloc_pages(phdr->p_memsz / PGSIZE + 1);
            char *bin_va = bin + (phdr->p_vaddr - PGROUNDDOWN(phdr->p_vaddr));

            memcpy(_sramdisk + phdr->p_offset,bin_va , phdr->p_filesz);
            memset(bin_va + phdr->p_filesz, 0, phdr->p_memsz-phdr->p_filesz);

            // create mapping for program segment
            uint32_t p_flags = phdr->p_flags;
            uint64_t perm = (p_flags & PF_X ? PGTBL_ENTRY_X : 0) |
                            (p_flags & PF_W ? PGTBL_ENTRY_W : 0) |
                            (p_flags & PF_R ? PGTBL_ENTRY_R : 0) | 
                            PGTBL_ENTRY_U | PGTBL_ENTRY_V;

            create_mapping(task->pgd, phdr->p_vaddr, (bin_va-PA2VA_OFFSET),
                phdr->p_memsz, perm);
        }
    }
    task->thread.sepc = ehdr->e_entry;
}

ELF 程序运行结果如下:

image-20241211110023271

思考题

[!QUOTE] 我们在实验中使用的用户态线程和内核态线程的对应关系是怎样的?(一对一,一对多,多对一还是多对多)

一对一,我们为每个用户态线程都分配了一个不同的内核态栈,所以是每个用户态分别对应一个不同的内核态线程负责管理调度

[!QUOTE] 系统调用返回为什么不能直接修改寄存器?

系统调用返回的值应该返回到户态下的寄存器,而异常处理时会从用户态切换到内核态,内核态下的寄存器专门用于内核态,不能储存用户态的数据,所以系统调用返回的值应该直接修改栈上面的用户态寄存器对应的值,从内核态切回用户态时顺便就获取到了

[!QUOTE] 针对系统调用,为什么要手动将 sepc + 4?

发送异常时 sepc 寄存器指向产 ⽣ 异常的指令,对于系统调用就是指向 ecall 指令,在异常处理结束后 pc 跳转到 sepc 指向的位置以继续执行程序

系统调用本身不会自动将 sepc + 4,如果不手动设置,就会陷入无限发起系统调用的死循环

[!QUOTE] 为什么 Phdr 中,p_fileszp_memsz 是不一样大的,它们分别表示什么?

通过 man elf 命令我们可以查询到这两个字段的解释:

image-20241211113541511

p_filesz 代表 segment 在文件中的大小, p_memsz 代表 segment 在内存中的大小

程序中可能包含 未初始化数据 .bss 段,在 ELF 文件中其内容将被省略以节省空间,而加载 ELF 时操作系统选哟将该段初始化,使其占用内存空间,导致 segment 在运行时占用的内存空间比其在文件中的大小要大

[!QUOTE] 为什么多个进程的栈虚拟地址可以是相同的?用户有没有常规的方法知道自己栈所在的物理地址?

不同进程单独有一张页表,页表内构建的映射指向了不同的物理地址,即,不同进程使用的相同的虚拟地址会映射为不同的物理地址,所以多个进程的栈互不干扰

页表所在的内存页仅允许高特权级访问,所以用户没有常规方法获取物理地址

心得体会

trap_handler 里处理新的系统调用时,有个地方很容易犯错

_traps 里我们将用户态寄存器值压入栈,通过 pt_regs 数据结构在内核态中访问用户态寄存器,但是我们在 _traps 中习惯性地没有保存 x0 的值,而是从 x1 开始保存,却在 pt_regs 中设置了 regs[32],导致最后三个寄存器访问的位置发生了偏移,此外,还可能因为忘记 没有放入 x0,导致数组索引序号也偏了一位,使得 trap_handler 里访问的用户态寄存器值全部木大

此外,在 load_program 时,需要将未初始化的 .bss 段进行初始化,我们通过 memset 将该段置 0 来初始化:

void load_program(struct task_struct *task) {
    ...
    memset(uapp_copy_va + phdr->p_filesz, 0, phdr->p_memsz-phdr->p_filesz);
    ...
}