操作系统在开始运行用户进程的时候,内核便开始处于被动状态,只有在出现以下几种情况的时候才会触发硬件机制陷入内核:(1)用户代码由于某种原因引发异常(例如除以0);(2)硬件产生中断并且没有屏蔽触发中断;(3)用户代码调用相关指令(例如x86体系下的int系统调用指令)主动陷入内核。以上三种情况便是异常、中断、系统调用机制。这三种机制由于需要陷入内核所以在进入内核之前必须先保存现场,然后回到用户环境的时候恢复现场,xv6对着三种机制都采用相同的处理方式陷入内核。
在x86体系下,这三种机制都会触发相同的硬件操作,完成部分保护现场的任务,上图中通过将部分寄存器值压入堆栈来实现保护现场,如果触发中断(由于系统调用、中断、异常具有相同的处理机制,所以一下全以中断代称)前处于内核态,则直接在当前栈中保护现场,如果处于用户态,则根据任务栈描述符得到新的内核栈并压入用户态的ss和esp。在硬件完成操作后,栈中会得到以上数据。
x86规定了中断、异常、系统调用的规范,在发生以上情况时,硬件能够区分上述规定的256种触发中断的原因并通过寻找在内存中保存的中断向量表来得到中断处理程序的地址,然后将控制权交给中断处理程序,这是x86架构下发生中断时硬件完成的操作。
xv6通过一个存放函数指针的数组来作为中断向量表,在main函数初始化过程中,调用tvinit函数完成中断向量表的初始化。tvinit将每一个中断处理程序的地址写入idt数组中,idtinit将数组地址载入中断向量表寄存器,硬件能够根据中断向量表寄存器准确找出中断处理程序,xv6使用vector.pl脚本生成vector数组,vector数组存放着每个中断处理程序的入口地址,xv6简单地将所有的中断处理程序指向alltraps,由alltraps来负责具体的处理。在调用alltraps之前,xv6统一压入errnum和trapnum来区分是256情况中的哪种。
void
tvinit(void)
{
int i;
for(i = 0; i < 256; i++)
SETGATE(idt[i], 0, SEG_KCODE<<3, vectors[i], 0);
SETGATE(idt[T_SYSCALL], 1, SEG_KCODE<<3, vectors[T_SYSCALL], DPL_USER);
initlock(&tickslock, "time");
}
void
idtinit(void)
{
lidt(idt, sizeof(idt));
}
vector255:
pushl $0
pushl $255
jmp alltraps
alltraps继续压入寄存器保存现场,得到trapframe结构体,trapframe结构体如图所示,其中oesp没有用处,这是pushal指令统一压栈的。
.globl alltraps
alltraps:
# Build trap frame.
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal
在这之后重新设置段寄存器,进入内核态,压入当前栈esp,然后调用C函数trap处理中断,在trap返回时,弹出esp然后trapret弹出寄存器恢复现场。
# Set up data and per-cpu segments.
movw $(SEG_KDATA<<3), %ax
movw %ax, %ds
movw %ax, %es
movw $(SEG_KCPU<<3), %ax
movw %ax, %fs
movw %ax, %gs
# Call trap(tf), where tf=%esp
pushl %esp
call trap
addl $4, %esp
# Return falls through to trapret...
.globl trapret
trapret:
popal
popl %gs
popl %fs
popl %es
popl %ds
addl $0x8, %esp # trapno and errcode
iret
在这里有必要详细说明的是在调用trap时由于trap是c函数所以会在栈上压入返回地址eip和部分寄存值构成context结构,如图:
struct context {
uint edi;
uint esi;
uint ebx;
uint ebp;
uint eip;
};
之所以会重复压入部分寄存器的值是应为Intel规定esi,ebx,esi是被调用者保存寄存器,需要由被调用者保存,而ebp则是c函数中维护每个函数栈帧用的,eip是call指令压栈的,也就是说,当call指令执行后会执行以上动作才跳入trap函数体,具体有关被调用者寄存器可以参考下面的博文,如果侵权,请提示我删除
寄存器使用惯例
在trap执行ret指令返回之前会弹出context恢复栈的调用前状态。
明确context结构能够使我们很容易理解进程调用时,通过切换上下文context,很容易便能实现切换进程的目的,因为任何C函数的调用都会形成context结构,在进程调度时通过切换context结构,很容易使得函数返回时返回到另一个进程中去
实际调度的情况是这样的,当把当前进程上下文切换至另一个context上下文时,调度器会手动弹出context结构来模拟函数返回的情形,当弹出eip的时候就意味着回到了就绪进程的下一条指令中来。这样便能实现进程的切换。
进程调度器通过恢复进程现场来返回进程,此时并没有所谓的进程“现场”,那么进程又是如何第一次运行的呢?
xv6通过自建一个“现场”来模拟返回第一个进程,xv6手动写入了trapframe和context结构的上下文来让调度器调度并返回用户进程,这些操作由allocproc负责,allocproc手动构建内核栈并写入内核寄存器来构成进程第一次运行的“现场”,如下面代码所示:
// Allocate kernel stack.
if((p->kstack = kalloc()) == 0){
p->state = UNUSED;
return 0;
}
sp = p->kstack + KSTACKSIZE;
// Leave room for trap frame.
sp -= sizeof *p->tf;
p->tf = (struct trapframe*)sp;
// Set up new context to start executing at forkret,
// which returns to trapret.
sp -= 4;
*(uint*)sp = (uint)trapret;
sp -= sizeof *p->context;
p->context = (struct context*)sp;
memset(p->context, 0, sizeof *p->context);
p->context->eip = (uint)forkret;
allocproc在“正常现场”中本应该压入esp的地方压入trapret,trapret处的代码负责弹出寄存器恢复现场,然后在本应该是trap返回地址的地方压入forkret的地址并将context的内容置零,通过这种方式,调度器在调度的时候手动弹出context,但是此时弹出的eip并不是trap的返回地址而是forkret,forkret返回时弹出压入的trapret恢复现场,这样进程第一次便开始运行了,至于为什么要压入forkret我还没搞清楚,后面的博客会再次介绍。
在 main 初始化了一些设备和子系统后,它通过调用 userinit建立了第一个进程。userinit首先调用上面讲的allocproc来创建进程的内核栈,来完成进程运行的必要环境。注意一点:userinit 仅仅在创建第一个进程时被调用,而 allocproc 创建每个进程时都会被调用。然后userinit设置全局静态变量initproc的值,设置页表中的内核态映射,然后调用inituvm将initcode.S移动到虚拟地址0处,并设置页表,然后为trapframe赋 初始值,最后使p->state设置为RUNNABLE状态,以便调度器能够调度运行。
initcode.S刚开始会将 argv,init,0 三个值推入栈中,接下来把 %eax 设置为 SYS_exec 然后执行 int T_SYSCALL:这样做是告诉内核运行 exec 这个系统调用。如果运行正常的话,exec 不会返回:它会运行名为 init 的程序,init 是一个以空字符结尾的字符串,即 /init。如果 exec 失败并且返回了,initcode 会不断调用一个不会返回的系统调用 exit 。
系统调用 exec 的参数是 init、argv。最后的0让这个手动构建的系统调用看起来就像普通的系统调用一样,我们会在第3章详细讨论这个问题。和之前的代码一样,xv6 努力避免为第一个进程的运行单独写一段代码,而是尽量使用通用于普通操作的代码。
现在 initcode 已经执行完了,进程将要运行 /init。 init会在需要的情况下创建一个新的控制台设备文件,然后把它作为描述符0,1,2打开。接下来它将不断循环,开启控制台 shell,处理没有父进程的僵尸进程,直到 shell 退出,然后再反复。系统就这样建立起来了。
trap函数主要根据trapframe中的trapno来确定到底是哪种原因导致中断的发生,如果是系统调用,则通过调用syscall函数负责具体的系统调用处理
if(tf->trapno == T_SYSCALL){
if(proc->killed)
exit();
proc->tf = tf;
syscall();
if(proc->killed)
exit();
return;
}
在这里处理proc->killed的原因是kill系统调用的需要,kill系统调用通过将killed置1,来杀死一个进程,由于迟早进程会由于系统调用或者时钟中断进入trap,此时trap检查到killed置1便能够将进程杀死。
注意到这里重新更新了tf的地址,这样做的原因是trapframe的大小由于可能硬件未压入ss和esp导致不一致,而任务栈ts总是指向内核栈所在页的最高地址处:
cpu->ts.esp0 = (uint)proc->kstack + KSTACKSIZE;
proc->tf最开始的赋值是按照大小包括esp和ss来的,在allocproc中:
p->tf = (struct trapframe*)sp;
所以在这里重新设置了tf的地址。
如果中断产生的原因是硬件中断或者异常,trap则调用相应的函数来进行处理:
switch(tf->trapno){
case T_IRQ0 + IRQ_TIMER:
if(cpunum() == 0){
acquire(&tickslock);
ticks++;
wakeup(&ticks);
release(&tickslock);
}
lapiceoi();
break;
case T_IRQ0 + IRQ_IDE:
ideintr();
lapiceoi();
break;
case T_IRQ0 + IRQ_IDE+1:
// Bochs generates spurious IDE1 interrupts.
break;
case T_IRQ0 + IRQ_KBD:
kbdintr();
lapiceoi();
break;
case T_IRQ0 + IRQ_COM1:
uartintr();
lapiceoi();
break;
case T_IRQ0 + 7:
case T_IRQ0 + IRQ_SPURIOUS:
cprintf("cpu%d: spurious interrupt at %x:%x\n",
cpunum(), tf->cs, tf->eip);
lapiceoi();
break;
//PAGEBREAK: 13
default:
if(proc == 0 || (tf->cs&3) == 0){
// In kernel, it must be our mistake.
cprintf("unexpected trap %d from cpu %d eip %x (cr2=0x%x)\n",
tf->trapno, cpunum(), tf->eip, rcr2());
panic("trap");
}
// In user space, assume process misbehaved.
cprintf("pid %d %s: trap %d err %d on cpu %d "
"eip 0x%x addr 0x%x--kill proc\n",
proc->pid, proc->name, tf->trapno, tf->err, cpunum(), tf->eip,
rcr2());
proc->killed = 1;
那么如果是系统调用,syscall都干了些什么事情,这里只是讲讲系统调用的实现机制,具体的每个系统调用后面博客会更新。
syscall通过trapframe中的eax来确定系统调用号以决定调用那个系统函数,当然eax的值或许是库函数在调用int指令的时候设置的,只是保护现场使得存放在了trapframe中,然后通过系统调用号调用具体的系统调用处理函数并返回到trapframe中的eax位置,这样恢复现场时库函数便能根据eax得到系统调用的返回值
void
syscall(void)
{
int num;
num = proc->tf->eax;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
proc->tf->eax = syscalls[num]();
} else {
cprintf("%d %s: unknown sys call %d\n",
proc->pid, proc->name, num);
proc->tf->eax = -1;
}
}
xv6中具体的系统调用如下:
static int (*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,
};
每个系统调用都有不同的参数,那么在内核中的系统调用函数又是如何找到这些参数?参数或许是库函数在陷入内核前压栈的,所以可以根据trapframe中的用户栈esp来找到各种参数,xv6使用了工具函数 argint、argptr 和 argstr来获得第 n 个系统调用参数,这里直接COPY自xv6中文文档,如果侵权,请提示我删除。
他们分别用于获取整数,指针和字符串起始地址。argint 利用用户空间的 %esp 寄存器定位第 n 个参数:%esp 指向系统调用结束后的返回地址。参数就恰好在 %esp 之上(%esp+4)。因此第 n 个参数就在 %esp+4+4*n。
argint 调用 fetchint 从用户内存地址读取值到 *ip。fetchint 可以简单地将这个地址直接转换成一个指针,因为用户和内核共享同一个页表,但是内核必须检验这个指针的确指向的是用户内存空间的一部分。内核已经设置好了页表来保证本进程无法访问它的私有地址以外的内存:如果一个用户尝试读或者写高于(包含)p->sz的地址,处理器会产生一个段中断,这个中断会杀死此进程,正如我们之前所见。但是现在,我们在内核态中执行,用户提供的任何地址都是有权访问的,因此必须要检查这个地址是在 p->sz 之下的。
argptr 和 argint 的目标是相似的:它解析第 n 个系统调用参数。argptr 调用 argint 来把第 n 个参数当做是整数来获取,然后把这个整数看做指针,检查它的确指向的是用户地址空间。注意 argptr 的源码中有两次检查。首先,用户的栈指针在获取参数的时候被检查。然后这个获取到得参数作为用户指针又经过了一次检查。
argstr 是最后一个用于获取系统调用参数的函数。它将第 n 个系统调用参数解析为指针。它确保这个指针是一个 NUL 结尾的字符串并且整个完整的字符串都在用户地址空间中。
系统调用的实现(例如,sysproc.c 和 sysfile.c)仅仅是封装而已:他们用 argint,argptr 和 argstr 来解析参数,然后调用真正的实现。在第二章,sys_exec 利用这些函数来获取参数。
// User code makes a system call with INT T_SYSCALL.
// System call number in %eax.
// Arguments on the stack, from the user call to the C
// library system call function. The saved user %esp points
// to a saved program counter, and then the first argument.
// Fetch the int at addr from the current process.
int
fetchint(uint addr, int *ip)
{
if(addr >= proc->sz || addr+4 > proc->sz)
return -1;
*ip = *(int*)(addr);
return 0;
}
// Fetch the nul-terminated string at addr from the current process.
// Doesn't actually copy the string - just sets *pp to point at it.
// Returns length of string, not including nul.
int
fetchstr(uint addr, char **pp)
{
char *s, *ep;
if(addr >= proc->sz)
return -1;
*pp = (char*)addr;
ep = (char*)proc->sz;
for(s = *pp; s < ep; s++)
if(*s == 0)
return s - *pp;
return -1;
}
// Fetch the nth 32-bit system call argument.
int
argint(int n, int *ip)
{
return fetchint(proc->tf->esp + 4 + 4*n, ip);
}
// Fetch the nth word-sized system call argument as a pointer
// to a block of memory of size bytes. Check that the pointer
// lies within the process address space.
int
argptr(int n, char **pp, int size)
{
int i;
if(argint(n, &i) < 0)
return -1;
if(size < 0 || (uint)i >= proc->sz || (uint)i+size > proc->sz)
return -1;
*pp = (char*)i;
return 0;
}
// Fetch the nth word-sized system call argument as a string pointer.
// Check that the pointer is valid and the string is nul-terminated.
// (There is no shared writable memory, so the string can't change
// between this check and being used by the kernel.)
int
argstr(int n, char **pp)
{
int addr;
if(argint(n, &addr) < 0)
return -1;
return fetchstr(addr, pp);
}