浙江大学实验报告
[!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
文件夹下的内容纳入工程管理:
创建用户态进程
结构体更新
修改 proc.h
中的 NR_TASKS
如下:
在 proc.h
中修改 thread_struct
和 task_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; // 用户态页表
};
我们为每个进程分配了独立的页表,防止不同用户态进程共享同一块空间,造成错误
我们为每个线程增加了 sepc
、 sstatus
与 sscratch
变量用于保存对应的寄存器
修改 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
在前面新增了 sepc
、sstatus
、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
进入用户态模式的时候,我们需要切换内核态栈和用户态栈,只需将 sp
与 sscratch
的值交换即可
修改 _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
如下:
然后修改 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 过程不受中断影响:
测试纯二进制文件
添加 ELF 解析与加载
我们以 segment 为粒度将程序加载进内存中,且只关注 Type 为 LOAD 的 segment,LOAD 表示它们需要在开始运行前被加载进内存中
将 uapp.S
中的 payload 给换成我们的 ELF 文件:
这时候从 _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 程序运行结果如下:
思考题
[!QUOTE] 我们在实验中使用的用户态线程和内核态线程的对应关系是怎样的?(一对一,一对多,多对一还是多对多)
一对一,我们为每个用户态线程都分配了一个不同的内核态栈,所以是每个用户态分别对应一个不同的内核态线程负责管理调度
[!QUOTE] 系统调用返回为什么不能直接修改寄存器?
系统调用返回的值应该返回到户态下的寄存器,而异常处理时会从用户态切换到内核态,内核态下的寄存器专门用于内核态,不能储存用户态的数据,所以系统调用返回的值应该直接修改栈上面的用户态寄存器对应的值,从内核态切回用户态时顺便就获取到了
[!QUOTE] 针对系统调用,为什么要手动将 sepc + 4?
发送异常时 sepc
寄存器指向产 ⽣ 异常的指令,对于系统调用就是指向 ecall
指令,在异常处理结束后 pc
跳转到 sepc
指向的位置以继续执行程序
系统调用本身不会自动将 sepc + 4,如果不手动设置,就会陷入无限发起系统调用的死循环
[!QUOTE] 为什么 Phdr 中,
p_filesz
和p_memsz
是不一样大的,它们分别表示什么?
通过 man elf
命令我们可以查询到这两个字段的解释:
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 来初始化: