MIT 6.S081 2020 操作系统
本文为MIT 6.S081课程第四章教材内容翻译加整理。
本课程前置知识主要涉及:
- C语言(建议阅读C程序语言设计—第二版)
- RISC-V汇编
- 推荐阅读: 程序员的自我修养-装载,链接与库
有三种事件会导致CPU搁置普通指令的执行,并强制将控制权转移到处理该事件的特殊代码上:
ecall
指令要求内核为其做些什么时;本书使用陷阱(trap)作为这些情况的通用术语。
通常,陷阱发生时正在执行的任何代码都需要稍后恢复,并且不需要意识到发生了任何特殊的事情。
xv6内核处理所有陷阱。这对于系统调用来说是顺理成章的。由于隔离性要求用户进程不直接使用设备,而且只有内核具有设备处理所需的状态,因而对中断也是有意义的。因为xv6通过杀死违规程序来响应用户空间中的所有异常,它也对异常有意义。
Xv6陷阱处理分为四个阶段:
虽然三种陷阱类型之间的共性表明内核可以用一个代码路径处理所有陷阱,但对于三种不同的情况:来自用户空间的陷阱、来自内核空间的陷阱和定时器中断,分别使用单独的程序集向量和C陷阱处理程序更加方便。
每个RISC-V CPU都有一组控制寄存器,内核通过向这些寄存器写入内容来告诉CPU如何处理陷阱,内核可以读取这些寄存器来明确已经发生的陷阱。RISC-V文档包含了完整的内容。riscv.h(kernel/riscv.h:1)包含在xv6中使用到的内容的定义。以下是最重要的一些寄存器概述:
stvec
:内核在这里写入其陷阱处理程序的地址;RISC-V跳转到这里处理陷阱。sepc
:当发生陷阱时,RISC-V会在这里保存程序计数器pc
(因为pc
会被stvec
覆盖)。sret
(从陷阱返回)指令会将sepc
复制到pc
。内核可以写入sepc
来控制sret
的去向。scause
: RISC-V在这里放置一个描述陷阱原因的数字。sscratch
:内核在这里放置了一个值,这个值在陷阱处理程序一开始就会派上用场。sstatus
:其中的SIE位控制设备中断是否启用。如果内核清空SIE,RISC-V将推迟设备中断,直到内核重新设置SIE。SPP位指示陷阱是来自用户模式还是管理模式,并控制sret
返回的模式。上述寄存器都用于在管理模式下处理陷阱,在用户模式下不能读取或写入。在机器模式下处理陷阱有一组等效的控制寄存器,xv6仅在计时器中断的特殊情况下使用它们。
多核芯片上的每个CPU都有自己的这些寄存器集,并且在任何给定时间都可能有多个CPU在处理陷阱。
当需要强制执行陷阱时,RISC-V硬件对所有陷阱类型(计时器中断除外)执行以下操作:
pc
复制到sepc
。scause
以反映产生陷阱的原因。stvec
复制到pc
。pc
上开始执行。请注意,CPU不会切换到内核页表,不会切换到内核栈,也不会保存除pc
之外的任何寄存器。内核软件必须执行这些任务。CPU在陷阱期间执行尽可能少量工作的一个原因是为软件提供灵活性;
你可能想知道CPU硬件的陷阱处理顺序是否可以进一步简化:
satp
寄存器来指向允许访问所有物理内存的页表。stvec
,是很重要的。第2章以initcode.S调用exec
系统调用(user/initcode.S:11)结束。让我们看看用户调用是如何在内核中实现exec
系统调用的。
首选,我们来回滚一下initcode被调用的流程:
前面章节讲过的代码,后续章节都不会再进行讲解,只会讲解未讲过的。
//kernel/vm.c
// Load the user initcode into address 0 of pagetable,
// for the very first process.
// sz must be less than a page.
// 将initcode程序的代码加载到0号进程用户态页表的0地址处
void
uvminit(pagetable_t pagetable, uchar *src, uint sz)
{
char *mem;
if(sz >= PGSIZE)
panic("inituvm: more than a page");
//分配物理页,并初始化该物理页
mem = kalloc();
memset(mem, 0, PGSIZE);
// 虚拟地址0~PGSIZE 与 上面分配的物理页地址建立映射关系
mappages(pagetable, 0, PGSIZE, (uint64)mem, PTE_W|PTE_R|PTE_X|PTE_U);
// 将initcode代码数据从内核空间拷贝到用户空间 ---> 因为此时这个物理页是映射到了0号用户态进程的,因此可以这样理解
memmove(mem, src, sz);
}
// Per-CPU process scheduler.
// Each CPU calls scheduler() after setting itself up.
// 每个CPU都会在初始化工作完成后,调用scheduler开始任务调度执行
// Scheduler never returns. It loops, doing:
// - choose a process to run.
// - swtch to start running that process.
// - eventually that process transfers control
// via swtch back to the scheduler.
void scheduler(void)
{
struct proc *p;
// 获取当前hart
struct cpu *c = mycpu();
// 清空当前hart正在运行的进程信息
c->proc = 0;
for (;;)
{
// Avoid deadlock by ensuring that devices can interrupt.
// 设置sstatus寄存器的SIE为1,即打开S态下的全局中断
intr_on();
// 寻找下一个需要被调度的进程
int found = 0;
for (p = proc; p < &proc[NPROC]; p++)
{
acquire(&p->lock);
// 简单遍历proc数组,找到第一个处于RUNNABLE状态的进程
// RUNNABLE在xv6中表示可调度运行状态
if (p->state == RUNNABLE)
{
// Switch to chosen process. It is the process's job
// to release its lock and then reacquire it
// before jumping back to us.
// 设置当前进程的状态为RUNNABLE ——> 运行态
p->state = RUNNING;
// 将该进程与当前hart绑定
c->proc = p;
// 页表实验中我们新增的代码: 更新SATP指向当前进程的内核页表
// Store the kernal page table into the SATP
proc_inithart(p->kernelpt);
// 具体切换执行的函数---cpu中的context用于保存当前寄存器上下文环境
// proc中的context用于恢复其保存的寄存器上下文环境
swtch(&c->context, &p->context);
// Come back to the global kernel page table
// 当CPU空闲时,恢复stap指向全局的内核页表---也是我们新增的代码
kvminithart();
// Process is done running for now.
// It should have changed its p->state before coming back.
// 解决绑定关系
c->proc = 0;
found = 1;
}
release(&p->lock);
}
#if !defined(LAB_FS)
if (found == 0)
{
intr_on();
asm volatile("wfi");
}
#else
;
#endif
}
}
hart0会在系统各个模块初始化任务完成后,唤醒其他hart , 然后scheduler调度0号init程序执行,而后续其他hart也会调度scheduler,然后不断轮询,等待可被调度的任务出现。
如何获取当前CPU的信息:
struct cpu cpus[NCPU];
// Saved registers for kernel context switches.
// 下面是一些需要在函数调用时,进行保存的寄存器
struct context {
uint64 ra;
uint64 sp;
// callee-saved
uint64 s0;
uint64 s1;
uint64 s2;
uint64 s3;
uint64 s4;
uint64 s5;
uint64 s6;
uint64 s7;
uint64 s8;
uint64 s9;
uint64 s10;
uint64 s11;
};
// Per-CPU state.
struct cpu {
// 当前hart上正在运行的进程
struct proc *proc; // The process running on this cpu, or null.
// context用于存储需要在swtch函数进行任务切换过程中保存的寄存器
struct context context; // swtch() here to enter scheduler().
int noff; // Depth of push_off() nesting.
int intena; // Were interrupts enabled before push_off()?
};
// Must be called with interrupts disabled,
// to prevent race with process being moved
// to a different CPU.
// 获取tp寄存器中保存的当前硬件线程的hart id
int cpuid()
{
int id = r_tp();
return id;
}
// Return this CPU's cpu struct.
// Interrupts must be disabled.
struct cpu *
mycpu(void)
{
int id = cpuid();
// xv6中使用cpu[]数组保存所有cpu核--hart id作为数组索引
struct cpu *c = &cpus[id];
return c;
}
swtch函数最终会跳回ra寄存器指向的地址继续执行,那么0号init进程的proc->context上下文中的ra寄存器的值是在何时设置的呢?毕竟如果不进行设置,这里无法正确跳转到initcode代码段执行。
// Look in the process table for an UNUSED proc.
// If found, initialize state required to run in the kernel,
// and return with p->lock held.
// If there are no free procs, or a memory allocation fails, return 0.
static struct proc *
allocproc(void)
{
struct proc *p;
//遍历找到一个空位---如果找到了,说明当前创建的进程数量没有超过最大进程数的限制
for (p = proc; p < &proc[NPROC]; p++)
{
acquire(&p->lock);
if (p->state == UNUSED)
{
//跳到found标记处继续执行
goto found;
}
else
{
release(&p->lock);
}
}
return 0;
found:
// 分配pid
p->pid = allocpid();
// Allocate a trapframe page.
// 为trapframe帧分配页面--->该帧是用于上下文切换时来保持当前寄存器环境上下文快照的
if ((p->trapframe = (struct trapframe *)kalloc()) == 0)
{
release(&p->lock);
return 0;
}
// An empty user page table.
// 为进程的用户态页表分配一个新的空闲物理页--同时做好TRAMPOLINE和TRAMPOLINE的映射
// TRAMPOLINE和TRAMPOLINE这两部分代码是上下文切换通用代码,所以会被映射到每个用户虚拟地址空间最高地址处
p->pagetable = proc_pagetable(p);
if (p->pagetable == 0)
{
freeproc(p);
release(&p->lock);
return 0;
}
//----------上一节页表实验中补充的代码-----------------
// 为当前进程分配一个空的内核态根页表
p->kernelpt = proc_kpt_init(p);
if (p->pagetable == 0)
{
freeproc(p);
release(&p->lock);
return 0;
}
// Allocate a page for the process's kernel stack.
// Map it high in memory, followed by an invalid
// guard page.
char *pa = kalloc();
if (pa == 0)
panic("kalloc");
uint64 va = KSTACK((int)(p - proc));
// 当前进程的内核栈映射到当前进程的自己内核页表中
uvmmap(p->kernelpt, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
p->kstack = va;
//-----------补充结束----------------------
// Set up new context to start executing at forkret,
// which returns to user space.
// 清空当前进程的context
memset(&p->context, 0, sizeof(p->context));
//设置context的ra指向forkret函数地址
p->context.ra = (uint64)forkret;
//设置sp指向内核栈栈顶地址---内核栈只占据一个页面大小
p->context.sp = p->kstack + PGSIZE;
return p;
}
这里有一个概念大家需要注意区分一下:
allocproc函数在初始化进程结构体时,会设置context中的ra寄存器指向forkret函数,该函数负责完成内核态返回到用户态的工作。
scheduler函数中调用swtch完成内核态上下文的保存和恢复,将当前hart上正在运行的进程的内核态上下文保存到context中,然后将即将被调度执行的进程的内核态上下文进行恢复:
swtch(&c->context, &p->context);
此时ra寄存器会被恢复为指向forkret函数的地址,然后swtch函数最后执行ret函数,跳转到forkret函数入口地址处执行,完成内核态到用户态的切换,这个切换的具体过程是下一部分我们要重点分析的过程。
forkret函数负责完成内核态到用户态的切换,通过trapframe恢复用户态上下文,此时sepc被赋值为userinit 0号进程初始化函数中被设置的trapframe->epc的值,然后执行sret完成S态到U态的切换,pc寄存器被赋值为spec,我们跳转到了initcode代码段入口地址处执行:
用户代码将exec
需要的参数放在寄存器a0
和a1
中,并将系统调用号放在a7
中。
系统调用号与syscalls
数组中的条目相匹配,syscalls
数组是一个函数指针表(kernel/syscall.c:108)。
ecall
指令陷入(trap)到内核中,执行uservec
、usertrap
和syscall
,这个过程我们将会在下面详细分析。
syscall
(kernel/syscall.c:133)从陷阱帧(trapframe)中保存的a7
中检索系统调用号(p->trapframe->a7
),并用它索引到syscalls
中,对于第一次系统调用,a7
中的内容是SYS_exec
(kernel/syscall. h:8),导致了对系统调用接口函数sys_exec
的调用。
当系统调用接口函数返回时,syscall
将其返回值记录在p->trapframe->a0
中。这将导致原始用户空间对exec()
的调用返回该值,因为RISC-V上的C调用约定将返回值放在a0
中。系统调用通常返回负数表示错误,返回零或正数表示成功。如果系统调用号无效,syscall
打印错误并返回-1。
我们紧接上一part的流程继续往下分析,当我们在swtch函数中通过ret指令跳转到forkret函数时,forkret内核会干什么呢?
//proc.c
// A fork child's very first scheduling by scheduler()
// will swtch to forkret.
void forkret(void)
{
//静态的局部变量first
//第一次执行该函数时进行初始化,在随后的forkret()调用中,'first'的值将跨越函数调用保持不变,不会重新初始化
static int first = 1;
// Still holding p->lock from scheduler.
release(&myproc()->lock);
if (first)
{
// File system initialization must be run in the context of a
// regular process (e.g., because it calls sleep), and thus cannot
// be run from main().
first = 0;
fsinit(ROOTDEV);
}
//执行从S态返回用户态的操作
usertrapret();
}
注意:
在xv6中,文件系统初始化必须在一个正常的进程上下文中进行,因为它需要执行一些可能会导致进程阻塞或休眠的操作。这些操作包括从磁盘上读取文件系统数据以及建立与硬件设备的连接。
如果文件系统初始化在main()之前进行,那么它将在内核启动时进行,此时还没有正常的进程上下文可用。如果在这种情况下尝试执行与进程相关的操作,例如调用sleep()函数等待I/O完成,那么就会导致死锁,因为当前没有任何进程可以运行。
为了避免这个问题,在xv6中,文件系统初始化被推迟到第一个进程被创建并开始执行之后。这样,文件系统初始化就在正常的进程上下文中进行,并且可以安全地进行可能会导致进程阻塞或休眠的操作。
usertrapret函数中会执行S态返回用户态的操作:
//proc.c
//这三个外部全局遍历定义在trampoline.s中
extern char trampoline[], uservec[], userret[];
//
// return to user space
//
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.
//设置sstatus的值为0,以此来关闭S态下全局中断
intr_off();
// send syscalls, interrupts, and exceptions to trampoline.S
// stvec更改为指向uservec
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
// 用户态发生trap时会调用usertrap进行处理
p->trapframe->kernel_trap = (uint64)usertrap;
//保存hart id
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();
//设置status的SPP位为0,这样sret指令执行后,会恢复到user特权下
x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
//设置status的SPIE位为1,这样sret指令执行后, 会重新打开全局中断
x |= SSTATUS_SPIE; // enable interrupts in user mode
//重新设置sstatus寄存器的值
w_sstatus(x);
// set S Exception Program Counter to the saved user pc.
//设置sepc指向trapframe中保存的epc,也就是我们先前设置好的程序的入口地址
w_sepc(p->trapframe->epc);
// tell trampoline.S the user page table to switch to.
// 设置stap在sret执行后,指向进程的用户态根页表
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.
// fn为userret函数的入口地址
uint64 fn = TRAMPOLINE + (userret - trampoline);
// TRAPFRAME帧用于在用户态和内核态切换时进行上下文的保存和恢复
// 该帧在proc_pagetable初始化进程的用户态页表时分配物理页并在用户态页表建立映射
// 其位置就在TRAMPOLINE帧下面
// userret函数传入当前用户态上下文trapframe地址,和用户态的根页表
((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}
trampoline,uservec,userret是定义在trampoline.S中的三个全局符号,其中trampoline 符号是一个占位符标记,并不包含任何指令地址,它的存在只是为了让代码可以被正确地映射到用户空间和内核空间相同的虚拟地址:
- uservec用于用户态进入内核态时,进行用户态寄存器上下文环境的保存。
- userret用于内核态返回用户态时,进行用户态寄存器上下文环境的恢复
同时,通过.section trampsec 语句用于创建一个名为 trampsec 的新节,而trampoline.S汇编文件后面做的事情,则是定义该节中包含哪些符号,在kernel.ld链接器脚本中会搜集名为trampsec的节,并将其放置在代码段后面:
kvminit内核页表初始化函数中,最后会将trampoline映射到内核虚拟地址空间最高处,大小为一个物理页。
同时,通过链接器脚本可知,.text节中包含了内核原本的所有代码节和trampsec节,所以可知,最终得到的代码段不仅包含了原有的内核代码,还包含了trampsec节相关指令。
又因为kvminit函数中已经对代码段进行了等价映射,又在最后单独将trampsec节的内容映射到内核虚拟地址空间最高处,实际上是将一块物理内存映射到了内核虚拟地址空间两处地方,但是我们只会通过访问内核虚拟地址最高地址处,来访问trampsec节的内容:
kvmmap第二个参数中只需要传入trampoline全局符号的物理地址即可,因为trampoline全局符号的地址就是trampsec节在物理内存上的起始地址,映射大小为一页,刚好包含了整个trampsec节的内容。
所以usertrapret函数中涉及到的trampoline,uservec,userret三个符号的运算实际目的如下图所示:
//memlayout.h
// map the trampoline page to the highest address,
// in both user and kernel space.
// TRAMPOLINE被映射到的虚拟地址空间的地址--占据虚拟地址空间最高地址处的一个页面
#define TRAMPOLINE (MAXVA - PGSIZE)
返回用户空间的是通过调用usertrapret
(kernel/trap.c:90)完成的。
stvec
更改为指向uservec
,准备uservec
所依赖的陷阱帧字段,并将sepc
设置为之前保存的用户程序计数器。usertrapret
在用户和内核页表中都映射的蹦床页面上调用userret
;userret
中的汇编代码会切换页表。usertrapret函数在关闭S态全局中断,更改stvec指向uservec,设置好trapframe相关待恢复上下文,sstatus寄存器相关Previous值,sepc寄存器和satp寄存器待恢复的值后,调用userret函数,并传入trapframe地址和satp寄存器的应该恢复值。
下面我们来看看userret这段汇编过程调用都干了啥:
usertrapret
对userret
的调用将指针传递到a0
中的进程用户页表和a1
中的TRAPFRAME
(kernel/trampoline.S:88)。
userret
将satp
切换到进程的用户页表。TRAPFRAME
,但没有从内核映射其他内容。satp
后继续执行。userret
复制陷阱帧保存的用户a0
到sscratch
,为以后与TRAPFRAME
的交换做准备。userret
可以使用的唯一数据是寄存器内容和陷阱帧的内容。userret
从陷阱帧中恢复保存的用户寄存器,做a0
与sscratch
的最后一次交换来恢复用户a0
并为下一个陷阱保存TRAPFRAME
,并使用sret
返回用户空间。userret函数进行trap返回,跳转到设置好的sepc地址处执行,这里就是initcode代码的入口地址处,并且页表完成了切换,所以使用的是当前进程用户态页表进行虚地址翻译。
initcode代码执行的实际是ecall系统调用,调用的syscall_exec函数:
ecall指令干了什么呢 ?
如果用户程序发出系统调用(ecall
指令),或者做了一些非法的事情,或者设备中断,那么在用户空间中执行时就可能会产生陷阱。
uservec
(kernel/trampoline.S:16)usertrap
(kernel/trap.c:37);usertrapret
(kernel/trap.c:90)userret
(kernel/trampoline.S:16)ecall指令本质是产生异常,从而打断当前程序正常执行,进入异常指令流,也就是stvec寄存器指向的异常处理程序入口地址,stvec寄存器初始是在trapinithart中被设置:
// set up to take exceptions and traps while in the kernel.
void
trapinithart(void)
{
//stvec指向kernelvec
w_stvec((uint64)kernelvec);
}
kernelvec用于处理发生在S态下的所有中断和异常,但是如果是处于用户态下的程序发生了中断或者异常,则会跳转到uservec地址处执行,因为我们在usertrapret函数中将stvec更改为指向uservec,这样从S态返回到U态后,如果发生了异常或者中断,就都会跳转到uservec地址处执行了。
uservec负责完成用户态切换到内核态时,用户态下寄存器上下文环境的保存:
trap发生时,硬件会自动保存当前特权级到SPP,SIE到SPIE,pc到spec , 同时关闭全局中断。
.globl uservec
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
# 交换a0和sscratch寄存器的值,此时a0指向TRAPFRAME的地址,sscratch保存a0的值
csrrw a0, sscratch, a0
# save the user registers in TRAPFRAME
# 保存通用寄存器到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
# 将sscratch保存的a0寄存器的值读取到t0中,然后把原本a0的值也保存进trapframe中
csrr t0, sscratch
sd t0, 112(a0)
# restore kernel stack pointer from p->trapframe->kernel_sp
# 有关当前进程在内核态中寄存器的值是在usertrapret中被设置的
# 将kernel_satp的值设置到sp寄存器中,也就是sp指向当前进程内核栈的地址
ld sp, 8(a0)
# make tp hold the current hartid, from p->trapframe->kernel_hartid
# 恢复tp寄存器的值,保存当前hart id
ld tp, 32(a0)
# load the address of usertrap(), p->trapframe->kernel_trap
# 使用t0寄存器暂存usertrap函数地址
ld t0, 16(a0)
# restore kernel page table from p->trapframe->kernel_satp
# satp指向内核页表,并刷新TLB
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
// 跳转到Usertrap地址处继续执行
jr t0
来自用户代码的陷阱比来自内核的陷阱更具挑战性,因为satp
指向不映射内核的用户页表,栈指针可能包含无效甚至恶意的值。
由于RISC-V硬件在陷阱期间不会切换页表,所以用户页表必须包括uservec
(stvec指向的陷阱向量指令)的映射。uservec
必须切换satp
以指向内核页表;为了在切换后继续执行指令,uservec
必须在内核页表中与用户页表中映射相同的地址。
xv6使用包含uservec
的蹦床页面(trampoline page)来满足这些约束。xv6将蹦床页面映射到内核页表和每个用户页表中相同的虚拟地址。这个虚拟地址是TRAMPOLINE
。蹦床内容在trampoline.S中设置,并且(当执行用户代码时)stvec
设置为uservec
(kernel/trampoline.S:16)。
当uservec
启动时,所有32个寄存器都包含被中断代码所拥有的值。但是uservec
需要能够修改一些寄存器,以便设置satp
并生成保存寄存器的地址。RISC-V以sscratch
寄存器的形式提供了帮助。uservec
开始时的csrrw
指令交换了a0
和sscratch
的内容。现在用户代码的a0
被保存了;uservec
有一个寄存器(a0
)可以使用;a0
包含内核以前放在sscratch
中的值。
uservec
的下一个任务是保存用户寄存器。在进入用户空间之前,内核先前将sscratch
设置为指向一个每个进程的陷阱帧,该帧(除此之外)具有保存所有用户寄存器的空间(kernel/proc.h:44)。因为satp
仍然指向用户页表,所以uservec
需要将陷阱帧映射到用户地址空间中。每当创建一个进程时,xv6就为该进程的陷阱帧分配一个页面,并安排它始终映射在用户虚拟地址TRAPFRAME
,该地址就在TRAMPOLINE
下面。尽管使用物理地址,该进程的p->trapframe
仍指向陷阱帧,这样内核就可以通过内核页表使用它。
因此在交换a0
和sscratch
之后,a0
持有指向当前进程陷阱帧的指针。uservec
现在保存那里的所有用户寄存器,包括从sscratch
读取的用户的a0
。
陷阱帧包含指向当前进程内核栈的指针、当前CPU的hartid
、usertrap
的地址和内核页表的地址。uservec
取得这些值,将satp
切换到内核页表,并调用usertrap
。
uservec函数再完成用户态寄存器环境上下文保存后,跳转到usertrap继续执行:
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
// 处理用户态发生的trap
void
usertrap(void)
{
int which_dev = 0;
// 确保是用户态发生的trap
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.
// 由于我们现在处于内核态,所以后续trap发生都交给kernelvec处理
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
// 将spec保存到trapframe中
p->trapframe->epc = r_sepc();
// 错误码为8,表示产生的是系统调用异常
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.
// 产生异常时,spec保存的是发生异常的那条指令地址,这里为了防止产生无限循环,将trapframe中保存的epc值+4
p->trapframe->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
// 发生trap时,硬件会自动关闭全局中断,我们这里确保保存好了上面这些寄存器内容后,重新打开中断
intr_on();
// 系统调用号保存在a7寄存器中,调用syscall函数,根据系统调用号进行系统调用的派发
syscall();
}
// 处理设备中断--判断是什么设备中断
else if((which_dev = devintr()) != 0){
// ok
} else {
//无法识别的cause错误码
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
if(p->killed)
exit(-1);
// give up the CPU if this is a timer interrupt.
// 如果是时钟中断,则主动让出当前cpu
if(which_dev == 2)
yield();
// 如果不是时钟中断,说明是系统调用执行完毕后,执行到这里,则进行trap返回流程
//该函数已经分析过了
usertrapret();
}
usertrap
的任务是确定陷阱的原因,处理并返回(kernel/trap.c:37)。
stvec
,这样内核中的陷阱将由kernelvec
处理。sepc
(保存的用户程序计数器),再次保存是因为usertrap
中可能有一个进程切换,可能导致sepc
被覆盖。syscall
会处理它;devintr
会处理;pc
上加4,因为在系统调用的情况下,RISC-V会留下指向ecall
指令的程序指针(返回后需要执行ecall
之后的下一条指令)。usertrap
检查进程是已经被杀死还是应该让出CPU(如果这个陷阱是计时器中断)。内核中的系统调用接口需要找到用户代码传递的参数。因为用户代码调用了系统调用封装函数,所以参数最初被放置在RISC-V C调用所约定的地方:寄存器。
内核陷阱代码将用户寄存器保存到当前进程的陷阱框架中,内核代码可以在那里找到它们。函数artint
、artaddr
和artfd
从陷阱框架中检索第n个系统调用参数并以整数、指针或文件描述符的形式保存。他们都调用argraw
来检索相应的保存的用户寄存器(kernel/syscall.c:35)。
有些系统调用传递指针作为参数,内核必须使用这些指针来读取或写入用户内存。
exec
系统调用传递给内核一个指向用户空间中字符串参数的指针数组。内核实现了安全地将数据传输到用户提供的地址和从用户提供的地址传输数据的功能。
fetchstr
是一个例子(kernel/syscall.c:25)。exec
,使用fetchstr
从用户空间检索字符串文件名参数。fetchstr
调用copyinstr
来完成这项困难的工作。// Fetch the nul-terminated string at addr from the current process.
// Returns length of string, not including nul, or -1 for error.
//
int
fetchstr(uint64 addr, char *buf, int max)
{
struct proc *p = myproc();
// 负责用户态虚地址转换,并将数据从用户态缓冲区拷贝到内核态中来
int err = copyinstr(p->pagetable, buf, addr, max);
if(err < 0)
return err;
return strlen(buf);
}
copyinstr
(kernel/vm.c:406)从用户页表中的虚拟地址srcva
复制max
字节到dst
。
walkaddr
(它又调用walk
)在软件中遍历页表,以确定srcva
的物理地址pa0
。copyinstr
可以直接将字符串字节从pa0
复制到dst
。walkaddr
(kernel/vm.c:95)检查用户提供的虚拟地址是否为进程用户地址空间的一部分,因此程序不能欺骗内核读取其他内存。copyout
,将数据从内核复制到用户提供的地址。// Copy a null-terminated string from user to kernel.
// Copy bytes to dst from virtual address srcva in a given page table,
// until a '\0', or max.
// Return 0 on success, -1 on error.
int
// 从用户空间传过来的虚拟地址srcva处拷贝字符串,直到遇到'\0'结束符号,或者拷贝字符超过max限制
// copy带有'\0'结束符号的字符串
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;
// 整体实现思路和 copyin 一致
//不同之处在于,由于事先不清楚copy数据的长度,只能一个个字节的copy,边copy边判断是否到达字符串末尾
char *p = (char *) (pa0 + (srcva - va0));
while(n > 0){
if(*p == '\0'){
*dst = '\0';
got_null = 1;
break;
} else {
// 由于内核态下采用的是等价映射,所以才可以直接这样玩
//毕竟dst代表内核态的虚拟地址,而p代表物理地址
*dst = *p;
}
--n;
--max;
p++;
dst++;
}
srcva = va0 + PGSIZE;
}
if(got_null){
return 0;
} else {
return -1;
}
}
如果做过页表节实验小伙伴可能知道,由于我们为每个进程在内核中增加了自己的内核页表,并将用户态下的内存通样映射到了每个进程自己的内核页表中,所以传入的用户态指针可以直接在内核态下进行解引用,下面是上一节实验改造后的copystr实现:
// Copy a null-terminated string from user to kernel.
// Copy bytes to dst from virtual address srcva in a given page table,
// until a '\0', or max.
// Return 0 on success, -1 on error.
int
copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
return copyinstr_new(pagetable, dst, srcva, max);
}
// Copy a null-terminated string from user to kernel.
// Copy bytes to dst from virtual address srcva in a given page table,
// until a '\0', or max.
// Return 0 on success, -1 on error.
int
copyinstr_new(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
struct proc *p = myproc();
char *s = (char *) srcva;
stats.ncopyinstr++; // XXX lock
//无需经历虚地址到物理地址翻译这一步,可以直接解引用,因为每个进程的内核页表包含了用户态内核映射
//所以无需考虑转换
for(int i = 0; i < max && srcva + i < p->sz; i++){
dst[i] = s[i];
if(s[i] == '\0')
return 0;
}
return -1;
}
下面补充视频课程中提到的一些点,虽然上面都提到了,但是可能有些地方教材描述的不是特别清楚,我附加的说明也不够到位,所以还是给出视频中大佬通俗易懂的讲解:
该标志位由硬件负责维护,代码不可见,我们只能通过XPP来间接控制当前特权级切换。
首先,我们需要保存32个用户寄存器。因为很显然我们需要恢复用户应用程序的执行,尤其是当用户程序随机的被设备中断所打断时。我们希望内核能够响应中断,之后在用户程序完全无感知的情况下再恢复用户代码的执行。所以这意味着32个用户寄存器不能被内核弄乱。但是这些寄存器又要被内核代码所使用,所以在trap之前,你必须先在某处保存这32个用户寄存器。
程序计数器也需要在某个地方保存,它几乎跟一个用户寄存器的地位是一样的,我们需要能够在用户程序运行中断的位置继续执行用户程序。
我们需要将mode改成supervisor mode,因为我们想要使用内核中的各种各样的特权指令。
SATP寄存器现在正指向user page table,而user page table只包含了用户程序所需要的内存映射和一两个其他的映射,它并没有包含整个内核数据的内存映射。所以在运行内核代码之前,我们需要将SATP指向kernel page table。
我们需要将堆栈寄存器指向位于内核的一个地址,因为我们需要一个堆栈来调用内核的C函数。
一旦我们设置好了,并且所有的硬件状态都适合在内核中使用, 我们需要跳入内核的C代码。
一旦我们运行在内核的C代码中,那就跟平常的C代码是一样的。
- 操作系统的一些high-level的目标能帮我们过滤一些实现选项。其中一个目标是安全和隔离,我们不想让用户代码介入到这里的user/kernel切换,否则有可能会破坏安全性。所以这意味着,trap中涉及到的硬件和内核机制不能依赖任何来自用户空间东西。比如说我们不能依赖32个用户寄存器,它们可能保存的是恶意的数据,所以,XV6的trap机制不会查看这些寄存器,而只是将它们保存起来。
- 在操作系统的trap机制中,我们仍然想保留隔离性并防御来自用户代码的可能的恶意攻击。同样也很重要的是,另一方面,我们想要让trap机制对用户代码是透明的,也就是说我们想要执行trap,然后在内核中执行代码,同时用户代码并不用察觉到任何有意思的事情。这样也更容易写用户代码。
本节内容已经足够多了,后续的内容将会在下一小节进行补充说明。