MIT_6.S081_xv6.Information 集合
于2023年2月6日2023年2月6日由Sukuna发布
1:Operating System Overview
1 xv6系统的启动过程:
1.1xv6引导器
当x86系列的PC机启动的时候,首先会执行BIOS程序,BIOS程序一般会存放在固定的ROM中,一般在磁盘固定扇区中.BIOS 的作用是在启动时进行硬件的准备工作,接着BIOS程序会把控制权递交给操作系统.具体来说,BIOS会把控制权递交给从引导扇区中的固定的代码中(BIOS会把引导扇区存储的代码加载到内存0x7c00处),接着引导程序会把操作系统内核载入到内存中,控制权递交给内核,程序是M态的.
在xv6系统,引导程序由汇编引导程序和代码引导程序.
1.2 内核态进入用户态
阅读kernel.asm(内核整体的代码)
代码语言:javascript复制Disassembly of section .text:
0000000080000000 <_entry>:
80000000: 00009117 auipc sp,0x9
80000004: 86013103 ld sp,-1952(sp) # 80008860 <_GLOBAL_OFFSET_TABLE_ 0x8>
80000008: 6505 lui a0,0x1
8000000a: f14025f3 csrr a1,mhartid
8000000e: 0585 addi a1,a1,1
80000010: 02b50533 mul a0,a0,a1
80000014: 912a add sp,sp,a0
80000016: 652050ef jal ra,80005668 <start>
我们看到了_entry这个标签,也就是说内核是从_entry开始运行的,那我们首先查看一下entry.S的代码:
代码语言:javascript复制 # qemu -kernel loads the kernel at 0x80000000
# and causes each CPU to jump there.
# kernel.ld causes the following code to
# be placed at 0x80000000.
.section .text
.global _entry
_entry:
# set up a stack for C.
# stack0 is declared in start.c,
# with a 4096-byte stack per CPU.
# sp = stack0 (hartid * 4096)
la sp, stack0
li a0, 1024*4
csrr a1, mhartid
addi a1, a1, 1
mul a0, a0, a1
add sp, sp, a0
# jump to start() in start.c
call start
引导程序会把内存载入到0x80000000这个地址是因为在0~0x80000000这个地址范围内还有I/O设备等(还有程序的逻辑地址)
entry.S开始设置了一个栈,栈的带下是1024*4=4KB,其中mhartid是运行当前程序的CPU核的ID,那么第i个核的栈地址空间就分配到stack (hartid)*4096~stack (hartid 1)*4096这个范围内.
因为这个操作系统是运行在多核的RISC-V操作系统上,由多个核同时访问一个内存空间,所以说每个核的CPU只在允许的内存空间中执行代码.其中每个核的寄存器又是不一样的,所以说可以修改每个核的sp寄存器来区分不同的核的代码运行空间.
在entry.S执行完操作之后,根据汇编代码,程序会跳转到start的这个函数中
代码语言:javascript复制// entry.S jumps here in machine mode on stack0.
void start()
{
// set M Previous Privilege mode to Supervisor, for mret.
unsigned long x = r_mstatus();
x &= ~MSTATUS_MPP_MASK;
x |= MSTATUS_MPP_S;
w_mstatus(x);
// set M Exception Program Counter to main, for mret.
// requires gcc -mcmodel=medany
w_mepc((uint64)main);
// disable paging for now.
w_satp(0);
// delegate all interrupts and exceptions to supervisor mode.
w_medeleg(0xffff);
w_mideleg(0xffff);
w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);
// configure Physical Memory Protection to give supervisor mode
// access to all of physical memory.
w_pmpaddr0(0x3fffffffffffffull);
w_pmpcfg0(0xf);
// ask for clock interrupts.
timerinit();
// keep each CPU's hartid in its tp register, for cpuid().
int id = r_mhartid();
w_tp(id);
// switch to supervisor mode and jump to main().
asm volatile("mret");
}
在start函数中我们还是会执行在M态执行的操作:
首先会执行mepc寄存器的操作,改变了mepc寄存器就相当于改变了断点值.这个寄存器相当于S态中断进入到M态的时候,触发中断的PC,当M态回到S态的时候,就继续从断点处执行.当然这个操作还制定了M态比S态更加接近于内核.
接着下一步就是取消分页,在这一部分,虚拟地址是和实际的物理地址是一一对应的.
再下一步,将所有的中断委托给S态进行处理.
再下一步,指定程序允许的物理地址,在S态我们允许访问所有的物理地址
在下一步,对时钟芯片编程以产生计时器中断.
再下一步,取CPU的核id
最后一步,返回到main()函数,执行mret指令.
操作系统接着就会进入main函数,main函数主要初始化设备和一些子系统,然后调用userinit()函数来生成第一个进程,第一个进程只会运行很基础的程序,这个程序再initcode.S中已经声明.(这个时候已经进入U态了)
代码语言:javascript复制# Initial process that execs /init.
# This code runs in user space.
#include "syscall.h"
# exec(init, argv)
.globl start
start:
la a0, init
la a1, argv
li a7, SYS_exec
ecall
系统调用的参数是a0~a7,其中a0~a6代表argv等,a7代表具体执行什么系统调用.
这个时候系统会执行init程序,init程序这个时候就会加载sh程序,并且会初始化一个新的文件描述器(你可以认为是标准的I/O输入输出,控制台输出,因为Unix类型的系统会把设备当成文件来看),然后执行Shell程序,系统开始执行
2 操作系统接口与系统调用
这个操作系统没有图形化界面,目前只能执行基本的键盘命令.
操作系统通过接口向用户程序提供服务。设计一个好的接口实际上是很难的。一方面我们希望接口设计得简单和精准,使其易于正确地实现;另一方面,我们可能忍不住想为应用提供一些更加复杂的功能。解决这种矛盾的办法是让接口的设计依赖于少量的机制 (mechanism),而通过这些机制的组合提供强大、通用的功能。
xv6提供 Unix 操作系统中的基本接口(由 Ken Thompson 和 Dennis Ritchie 引入),同时模仿 Unix 的内部设计。Unix 里机制结合良好的窄接口提供了令人吃惊的通用性。这样的接口设计非常成功,使得包括 BSD,Linux,Mac OS X,Solaris (甚至 Microsoft Windows 在某种程度上)都有类似 Unix 的接口。理解 xv6 是理解这些操作系统的一个良好起点。
xv6 使用了传统的内核概念 – 一个向其他运行中程序提供服务的特殊程序。每一个运行中程序(称之为进程)都拥有包含指令、数据、栈的内存空间。指令实现了程序的运算,数据是用于运算过程的变量,栈管理了程序的过程调用。
听上去很绕是吧,我们可以简单地理解,就是操作系统类似于高速公路的服务区,程序就是司机,司机在高速公路上开车相当于程序的正常执行,进入高速公路服务区司机就不会继续开车了,程序暂停执行.所以说程序获得操作系统提供的服务是通过中断的方式获得的.这个中断可以简称访管中断.
程序想获得操作系统的服务,先通过访管中断进入中断处理程序,这个时候就进入到了S态,在中断处理程序中根据某种特殊寄存器的值跳转到特殊的地址执行特殊的程序,这种特殊的程序叫做系统调用.因为用户态的程序权限有限,所以说要向操作系统提获取给更高的权限.只能通过中断的方式获取.
总得来说,进程通过系统调用使用内核服务。系统调用会进入内核,让内核执行服务然后返回。所以进程总是在用户空间和内核空间之间交替运行。
3 更多的基础知识
3.1 内核的组成
RISC-V的CPU主要分成三个态,操作系统会在三个态中穿插进行.
其中M态是机器态,在M态的操作系统有最高的权限,最高的优先级,可以执行所有指令,但是操作系统一般只在刚开始启动的时候是M态,在执行了一段初始化代码后就会降低到S态.
操作系统的内核一般是在S态进行运行,在S态,我们可以执行所有的指令,包括一部分特权指令,特权指令不知道的回去翻一下操作系统书.
在操作系统的空间划分中,我们一般划分内核态和用户态空间,在S态的时候,所有的程序和堆栈都是在内核态空间的,在U态的时候,所有的程序和堆栈都是在用户态空间的.
综上所述,内核主要由内核态空间和一些系统调用组成.这种内核一般称为monolithic kernel.
下面给出了基本的内核态运行的代码,运行这些代码就需要通过系统调用进入内核态,执行syscall.c之后然后再跳转到具体的代码.
对于另外一种microkernel的操作系统,它们会把一部分应该在S态运行的代码下放到U态防止出现问题,这个叫做微内核.
3.2 程序的逻辑地址
程序空间主要由基本的代码和数据,栈,堆,栈帧组成.其中栈帧保存了当前程序执行的时候一些基本的寄存器、断点信息、页表信息和CPU信息.
代码语言:javascript复制struct trapframe {
/* 0 */ uint64 kernel_satp; // kernel page table
/* 8 */ uint64 kernel_sp; // top of process's kernel stack
/* 16 */ uint64 kernel_trap; // usertrap()
/* 24 */ uint64 epc; // saved user program counter
/* 32 */ uint64 kernel_hartid; // saved kernel tp
/* 40 */ uint64 ra;
/* 48 */ uint64 sp;
/* 56 */ uint64 gp;
/* 64 */ uint64 tp;
/* 72 */ uint64 t0;
/* 80 */ uint64 t1;
/* 88 */ uint64 t2;
/* 96 */ uint64 s0;
/* 104 */ uint64 s1;
/* 112 */ uint64 a0;
/* 120 */ uint64 a1;
/* 128 */ uint64 a2;
/* 136 */ uint64 a3;
/* 144 */ uint64 a4;
/* 152 */ uint64 a5;
/* 160 */ uint64 a6;
/* 168 */ uint64 a7;
/* 176 */ uint64 s2;
/* 184 */ uint64 s3;
/* 192 */ uint64 s4;
/* 200 */ uint64 s5;
/* 208 */ uint64 s6;
/* 216 */ uint64 s7;
/* 224 */ uint64 s8;
/* 232 */ uint64 s9;
/* 240 */ uint64 s10;
/* 248 */ uint64 s11;
/* 256 */ uint64 t3;
/* 264 */ uint64 t4;
/* 272 */ uint64 t5;
/* 280 */ uint64 t6;
};
3.3 进程
进程就是运行的代码,进程可以通过调用ecall指令来进入到S态.其中进入到S态的何处就是有S态进行定义的.S态也可以调用sret指令回到断点处继续执行指令.
下面给顶了进程的代码
代码语言:javascript复制struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
int xstate; // Exit status to be returned to parent's wait
int pid; // Process ID
// wait_lock must be held when using this:
struct proc *parent; // Parent process
// these are private to the process, so p->lock need not be held.
uint64 kstack; // Virtual address of kernel stack
uint64 sz; // Size of process memory (bytes)
pagetable_t pagetable; // User page table
struct trapframe *trapframe; // data page for trampoline.S
struct context context; // swtch() here to run process
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
};
其中trapframe就是栈帧,pagetable就是页表.(可以复习一下OS关于内存的论述),state用于描述进程的状态.然后chan我猜测是进程等待队列的下一个元素.file就是打开的文件,cwd就是当前的目录,kstack是内核栈的位置,parent就是父进程.
进程之间是用数组来进行组合的.
在这一节你需要知道的是:
每一个进程都有一个页表来标记va和pa,不同的进程靠不同的页表来物理地相互阻隔,由于页表不同,不同的进程访问相同的虚拟地址,却是访问不同的物理地址,达到了物理的阻塞.
每一个进程再一定的时间会被其他的进程打断,这个时候CPU停止对于这个进程的执行,转而执行其他的进程.
每个进程都有两个栈,在U态处理的时候访问用户栈,在S态处理的时候访问内核栈.当进程被打断的时候信息会存储在用户态栈中.
总之,一个进程有控制流,什么时候控制CPU,还有数据流,对于内存和栈的访问.
2:Trap&Syscall
中断和系统调用
在RISC-V中有三种事件会使得CPU放弃对当前执行的程序的运行转而去处理这些事件.
- 系统调用,当当前程序执行ecall指令的时候
- 异常:指令的执行出现问题,比如说除0等.内部
- 中断:当设备传来需要中断的信号.外部
我们首先先注意到一点就是CPU进入到中断然后从中断中恢复,程序本身是不可查的,也就是说程序并不知道它被中断了,犹如做了一个梦一样,这是非常重要的,就是怎么进入中断,怎么样从中断回来.
有了这么一个基本的要求,我们可以得到大概的处理思路,基本上来说就是中断首先要进入内核的状态进行处理.并且可以分成4步:RISC-V的CPU首先在硬件层面上作出一些反应,接着就是执行一段汇编代码来进入到内核状态.进入到内核状态后就是一段中断例程,这个程序是所有中断共享的,然后再根据中断的类型不同再进入到不同的中断处理的程序.
对于中断我们又可以分成三类,对于这三类有不同的做法,分别是内核态中断,用户态中断和时钟中断.对于处理中断的程序,我们一般称为handler.
RISC-V硬件
首先RISC-V有几个处理中断的硬件结构:
stvec
寄存器:存储中断处理程序(例程)的第一条指令,当中断发生的时候RISC-V的CPU会跳转到stvec
寄存器对应的地址.这个寄存器也叫中断入口寄存器sepc
寄存器:当中断发生的时候RISC-V CPU会保存当前PC寄存器的值在sepc中.scause
寄存器:表示中断的原因和来源,为什么会发生此中断.sscratch
寄存器:内核会放一个值在这里,这一个值对于中断程序的开始很有用.sstatus
寄存器:设置中断屏蔽的寄存器.
上述寄存器在U状态下不可读写.并且上述的寄存器还有一个M开头的版本,用于处理M模式下的中断.对于每一个CPU都有一套寄存器来管理程序运行.
那么硬件具体会做什么呢;
- 如果当前中断是设备中断,并且sstatus寄存器内设置了屏蔽,就不做任何事.
- 设置sstatus寄存器的值,屏蔽中断.
- 把当前PC寄存器的值copy给sepc寄存器.
- 保存当前的模式,在sstatus寄存器.
- 设置scause,保存中断的原因.
- 设置当前状态为S态.
- 把stvec寄存器的值给PC.
- 转而执行PC寄存器对应的指令.
用户态的中断
这里讲述了当执行用户态的代码的时候会发生什么.
当用户段代码出现了中断现象的时候,首先就会执行uservec
->usertrap
->中断处理 ->usertrapret
->userret
.
对于RISC-V的处理中,主要是内核态空间和用户态空间都维持了页表,但是RISC-V的硬件并没有在中断发生的时候在硬件的层面上更换页表,所以说xv6操作系统需要在处理中断的时候把页表替换成内核的页表,并且这个内核的页表可以与stvec寄存器的值对应,不会发生缺页中断.
xv6的解决之道就是添加一个trampoline页,trampoline就是以个特殊的页,这个页包含了uservec和userret两部分,并且这个页存在于所有进程的页表,自然也存在于内核态空间下的页表.并且这个页是分配在虚拟地址空间的最后一个部分,所以说很难与用户进程发生冲突.
这个trampoline页存在于任何一个进程和内核的页表,并且映射的虚拟地址都是一样的,定义在TRAMPOLINE
这个C语言宏中.并且stvec这个寄存器存储的地址,就指向trampoline这个页的uservec这个部分,所以说当用户态发生中断的时候,RISC-V硬件处理完之后就可以立刻转化成内核态然后接着运行.因为U态和S态的页表是部分一样的,起码对于trampoline的记录是一样的
由于stvec寄存器存储了userret的地址,所以中断一开始的时候会进入uservec这个部分执行.
代码语言:javascript复制uservec:
#
# trap.c sets stvec to point here, so
# traps from user space start here,
# in supervisor mode, but with a
# user page table.
#
# sscratch points to where the process's p->trapframe is
# mapped into user space, at TRAPFRAME.
#
# swap a0 and sscratch
# so that a0 is TRAPFRAME
csrrw a0, sscratch, a0
# save the user registers in TRAPFRAME
sd ra, 40(a0)
sd sp, 48(a0)
sd gp, 56(a0)
sd tp, 64(a0)
sd t0, 72(a0)
sd t1, 80(a0)
sd t2, 88(a0)
sd s0, 96(a0)
sd s1, 104(a0)
sd a1, 120(a0)
sd a2, 128(a0)
sd a3, 136(a0)
sd a4, 144(a0)
sd a5, 152(a0)
sd a6, 160(a0)
sd a7, 168(a0)
sd s2, 176(a0)
sd s3, 184(a0)
sd s4, 192(a0)
sd s5, 200(a0)
sd s6, 208(a0)
sd s7, 216(a0)
sd s8, 224(a0)
sd s9, 232(a0)
sd s10, 240(a0)
sd s11, 248(a0)
sd t3, 256(a0)
sd t4, 264(a0)
sd t5, 272(a0)
sd t6, 280(a0)
# save the user a0 in p->trapframe->a0
csrr t0, sscratch
sd t0, 112(a0)
# restore kernel stack pointer from p->trapframe->kernel_sp
ld sp, 8(a0)
# make tp hold the current hartid, from p->trapframe->kernel_hartid
ld tp, 32(a0)
# load the address of usertrap(), p->trapframe->kernel_trap
ld t0, 16(a0)
# restore kernel page table from p->trapframe->kernel_satp
ld t1, 0(a0)
csrw satp, t1
sfence.vma zero, zero
# a0 is no longer valid, since the kernel page
# table does not specially map p->tf.
# jump to usertrap(), which does not return
jr t0
对于中断操作,我们知道我们得把所有的寄存器存放到内存中,但是对于RISC-V的汇编语言,我们还得有一个寄存器来存储应该访问的内存的地址.但是通用寄存器都已经失去了作用了,所以说RISC-V提供一个寄存器叫做sscratch寄存器,这个时候就可以把a0先暂时存储到sscratch
寄存器中,然后再把a0从sscratch
寄存器中取出.在这里这个寄存器主要是存放了栈帧的首地址,新的栈帧就会存放在sscratch
表示的地址中,在原文中提到,在返回到U态时,内核通过设置sscratch
寄存器来制定下一次中断时栈帧的地址.
对于栈帧的处理同样需要页表,在xv6系统中,对于每一个进程我们都会申请一个trapframe页,这个页的虚拟地址永远指定在TRAPFRAME这个地方上.栈帧元素的一系列初始化都是在进程创建的时候都已经保存好了.其实所有进程都会有一个栈帧,并且栈帧的虚拟地址是一样的,但是虚拟地址是一样的由于每个进程的页表又不是一样的,所以说对应的物理地址是不一样的.
那对于内核态的代码,我们不能通过TRAPFRAME这个虚拟地址来访问进程的栈帧结构,那么我们应该怎么办呢?
我们看到p->trapframe的构造过程.首先就是p->trapframe
保存的是kalloc直接分配的物理地址,p->trapframe = (struct trapframe *)kalloc(),直接保存的物理地址.
对于每一个进程,首先要申请一个页面,然后把这个页面的物理地址保存到p->trapframe这个结构中,接着每个进程都要调用proc_pagetable函数,执行mappages(pagetable, TRAPFRAME, PGSIZE, (uint64)(p->trapframe), PTE_R | PTE_W),把这个物理地址映射到TRAPFRAME这个va中.
所以说对于每个进程,在用户态访问trapframe都是访问TRAPFRAME这个va,由于每个进程的页表映射不同导致最后的实际物理地址不同.
综上所述:内核使用p->trapframe保存的物理地址访问栈帧结构,所有的用户态程序使用TRAPFRAME这个同样的虚拟地址访问栈帧结构,但是由于页表不同导致访问的实际物理地址不一样.
最后就是进程进入到内核态,访问p->trapframe就是物理地址,就不会访问TRAPFRAME这个虚拟地址
由于栈帧已经保存好了内核栈的地址,内核页表的地址,以及CPU的核号,所以说接下来的操作就是读取栈帧,读取内核栈地址,内核页表的地址以及下一个trap函数的入口地址.
代码语言:javascript复制void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc();
if(r_scause() == 8){
// system call
if(p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc = 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
syscall();
} else if((which_dev = devintr()) != 0){
// ok
} else {
printf("usertrap(): unexpected scause %p pid=%dn", r_scause(), p->pid);
printf(" sepc=%p stval=%pn", r_sepc(), r_stval());
p->killed = 1;
}
if(p->killed)
exit(-1);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
接着调用trap.c()
中的usertrap
函数,这个时候就已经进入内核态了,首先第一步就是对stvec
寄存器进行修改,因为对于用户态和内核态发生中断,进入的中断程序还是不一样的,然后接着在trapframe
里面保存sepc
寄存器(就是中断的断点),因为有可能调用yield(),所以说保存断点非常有必要.如果trap是syscall
的话,接着就调用syscall函数即可,如果是设备故障的话,就先保存设备的编号,如果不是设备中断的话就是指令的异常这个时候就退出就可以了.如果是时钟中断(which_dev==2)
就处理一下.
这个就是中断处理,对于不同类型的中断有不同的处理,处理完之后就要返回U态了
代码语言:javascript复制void
usertrapret(void)
{
struct proc *p = myproc();
// we're about to switch the destination of traps from
// kerneltrap() to usertrap(), so turn off interrupts until
// we're back in user space, where usertrap() is correct.
intr_off();
// send syscalls, interrupts, and exceptions to trampoline.S
w_stvec(TRAMPOLINE (uservec - trampoline));
// set up trapframe values that uservec will need when
// the process next re-enters the kernel.
p->trapframe->kernel_satp = r_satp(); // kernel page table
p->trapframe->kernel_sp = p->kstack PGSIZE; // process's kernel stack
p->trapframe->kernel_trap = (uint64)usertrap;
p->trapframe->kernel_hartid = r_tp(); // hartid for cpuid()
// set up the registers that trampoline.S's sret will use
// to get to user space.
// set S Previous Privilege mode to User.
unsigned long x = r_sstatus();
x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
x |= SSTATUS_SPIE; // enable interrupts in user mode
w_sstatus(x);
// set S Exception Program Counter to the saved user pc.
w_sepc(p->trapframe->epc);
// tell trampoline.S the user page table to switch to.
uint64 satp = MAKE_SATP(p->pagetable);
// jump to trampoline.S at the top of memory, which
// switches to the user page table, restores user registers,
// and switches to user mode with sret.
uint64 fn = TRAMPOLINE (userret - trampoline);
((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}
首先第一步就是调用usertrapret函数,这个函数首先第一步就是做stvec寄存器的写入,回忆一下:一开始在进入内核的时候为了防止内核出现中断就把stvec寄存器改成kerbelvec
,现在要返回U态了就把中断入口改成uservec即可.然后就是处理栈帧了,把内核页表地址,内核栈和usertrap地址,CPU核号保存进去.接着就是改变status寄存器的数值,改成用户态的寄存器,然后调取断点地址,把断点地址写到sepc寄存器里面(这样子就是进入内核态保存用户态断点,退出内核态的时候把断点进行加载,防止内核态也出现中断),接着切换页表,切换到用户态的页表,然后接着跳转到userret函数中.
userret:
# userret(TRAPFRAME, pagetable)
# switch from kernel to user.
# usertrapret() calls here.
# a0: TRAPFRAME, in user page table.
# a1: user page table, for satp.
# switch to the user page table.
csrw satp, a1
sfence.vma zero, zero
# put the saved user a0 in sscratch, so we
# can swap it with our a0 (TRAPFRAME) in the last step.
ld t0, 112(a0)
csrw sscratch, t0
# restore all but a0 from TRAPFRAME
ld ra, 40(a0)
ld sp, 48(a0)
ld gp, 56(a0)
ld tp, 64(a0)
ld t0, 72(a0)
ld t1, 80(a0)
ld t2, 88(a0)
ld s0, 96(a0)
ld s1, 104(a0)
ld a1, 120(a0)
ld a2, 128(a0)
ld a3, 136(a0)
ld a4, 144(a0)
ld a5, 152(a0)
ld a6, 160(a0)
ld a7, 168(a0)
ld s2, 176(a0)
ld s3, 184(a0)
ld s4, 192(a0)
ld s5, 200(a0)
ld s6, 208(a0)
ld s7, 216(a0)
ld s8, 224(a0)
ld s9, 232(a0)
ld s10, 240(a0)
ld s11, 248(a0)
ld t3, 256(a0)
ld t4, 264(a0)
ld t5, 272(a0)
ld t6, 280(a0)
# restore user a0, and save TRAPFRAME in sscratch
csrrw a0, sscratch, a0
# return to user mode and user pc.
# usertrapret() set up sstatus and sepc.
sret
这个时候会进行函数调用,进入userret这个函数之前,TRAPFRAME作为第一个参数,第二个参数就是用户态页表的地址,首先第一步就是加载用户态页表(处理逻辑:先获得satp,再加载到a1寄存器,接着取出来),接着就是把栈帧中存储的寄存器值全部加载到真实的寄存器中,最后一步就是把栈帧头部的虚拟地址保存到sscratch寄存器,下一次执行中断操作的时候就可以直接读取sscratch寄存器的内容确定栈帧的地址.
最后执行sret,把sepc寄存器的内容给pc,转换为U态,中断结束
总结下来: 导出保存在寄存器的栈帧首虚拟地址->把寄存器保存到trapframe中->加载内核态页表->存储断点->执行中断处理->加载断点->加载用户态页表->把trapframe的内容加载到真实的寄存器->把栈帧首地址放入寄存器中.
其实内核可以修改trapframe中的寄存器值,在中断结束后再把栈帧的值加载到真实的寄存器中.
调用系统函数.
我们接着第二章来说,在执行userinit函数之后,就执行initcode.S
代码语言:javascript复制#include "syscall.h"
# exec(init, argv)
.globl start
start:
la a0, init
la a1, argv
li a7, SYS_exec
ecall
# for(;;) exit();
exit:
li a7, SYS_exit
ecall
jal exit
# char init[] = "/init ";
init:
.string "/init "
# char *argv[] = { init, 0 };
.p2align 2
argv:
.long init
.long 0
这是一个标准的调用系统调用的样本,a0~a6存储系统调用需要的参数,a7传递了系统调用号,表示执行何种系统调用,传递完参数后就执行ecall.ecall是一个硬件指令,会把状态调整为S态然后执行uservec函数,接着就是我们熟知的trap处理函数.
在syscall()函数中,我们可以知道这个函数根据a7寄存器表示的系统调用号来找到函数指针然后进行调用,这里构思很巧妙,就是构建系统调用函数指针来进行跳转.
接着返回的时候就把返回值传递给a0寄存器.
代码语言:javascript复制static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
[SYS_wait] sys_wait,
[SYS_pipe] sys_pipe,
[SYS_read] sys_read,
[SYS_kill] sys_kill,
[SYS_exec] sys_exec,
[SYS_fstat] sys_fstat,
[SYS_chdir] sys_chdir,
[SYS_dup] sys_dup,
[SYS_getpid] sys_getpid,
[SYS_sbrk] sys_sbrk,
[SYS_sleep] sys_sleep,
[SYS_uptime] sys_uptime,
[SYS_open] sys_open,
[SYS_write] sys_write,
[SYS_mknod] sys_mknod,
[SYS_unlink] sys_unlink,
[SYS_link] sys_link,
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
};
//系统调用号,系统调用函数
void
syscall(void)
{
int num;
struct proc *p = myproc();
num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
p->trapframe->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %dn",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
系统调用的参数.
系统调用会传递参数进入,对于RISC-V来说,朴素的思想就是把参数传递到寄存器中,然后系统调用函数读取存储在寄存器中的数据,比如说argint和atgaddr,argfd等.
对于直接传递的参数,我们可以直接读取没有大问题,但是对于传递指针的参数,我们就需要进行额外的处理,第一个问题呢就是我们不知道程序是不是友好的,有可能用户程序通过传递地址来修改内核的内存,这样就导致了不安全的情况的发生.第二个问题就是xv6的内核态和用户态页表是不一样的.
所以说xv6的做法就是对于获得字符串的函数argstr(),去构建一个新的函数fetchstr去安全地获得数据,这个函数就会调用copyinstr()函数.
代码语言:javascript复制int
copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
uint64 n, va0, pa0;
int got_null = 0;
while(got_null == 0 && max > 0){
va0 = PGROUNDDOWN(srcva);
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0)
return -1;
n = PGSIZE - (srcva - va0);
if(n > max)
n = max;
char *p = (char *) (pa0 (srcva - va0));
while(n > 0){
if(*p == '