part1:预备知识
1、进程管理的数据结构——进程控制块(Process Control Block, PCB)
在XV6中,与进程有关的数据结构定义如下:
struct proc {
uint sz; // Size of process memory (bytes)
pde_t* pgdir; // Page table
char *kstack; // Bottom of kernel stack for this process
enum procstate state; // Process state
int pid; // Process ID
struct proc *parent; // Parent process
struct trapframe *tf; // Trap frame for current syscall
struct context *context; // swtch() here to run process
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
};
这些定义可分成两类:
- 与操作系统管理进程有关的信息:内核栈
kstack
,进程的状态state
,进程的pid
,父进程parent
,进程的中断帧tf
,进程的上下文context
,与sleep
和kill
有关的chan
和killed
变量。 - 进程本身运行所需要的全部环境:虚拟内存信息
sz
和pgdir
,打开的文件ofile
和当前目录cwd
。
某些信息详细介绍:
pgdir
:用户程序使用的是虚拟地址,虚拟地址被映射到物理地址,并且这样的映射是以页(4K)为单位的,xv6让每个进程都有独立的页表结构来记录页面在内存中对应的物理块号。 当进程切换时,页表也会随之发生切换,因此对于每个进程来说,似乎是独占整个大小为4G的虚拟地址空间的。kstack
:内核栈的栈底。每个进程都有一个与用户栈分开的内核栈,该栈就是运行时的使用的栈;而对于用户进程,该栈是发生特权级改变进入内核时保存环境用的栈。 之所以内核栈要和用户栈分开也是防止用户程序通过修改栈内容来突破内核安全保护。tf
:中断帧的指针,总是指向内核栈的某个位置。当进程从用户空间跳到内核空间时,中断帧记录了进程在用户态时的状态。当内核需要回到用户空间时,可从中断帧中弹出各寄存器值来恢复进程。
2. 进程表ptable
在操作系统中,所有的进程信息struct proc
都存储在ptable
中,ptable
的定义如下
struct {
struct spinlock lock;
struct proc proc[NPROC];
} ptable;
在param.h
中#define NPROC 64
限制了 XV6最多只允许同时存在64个进程 。
3、用户态和内核态:
-
特权级:inter x86架构的cpu对执行权限分成四个级别,0-3级,0级特权级最高,3级特权级最低。当一个进程在执行用户自己的代码时处于用户态,此时特权级最低,为3级,大部分用户直接面对的程序都是运行在用户态;当一个进程因为系统调用陷入内核代码中执行时处于内核态,此时特权级最高,为0级。3级状态不能访问0级的地址空间,包括代码和数据。
-
状态切换:当一个程序需要操作系统帮助完成一些用户态自己没有特权和能力完成的操作时就会切换到内核态。
用户态切换到内核态的3种方式:
(1)系统调用
这是用户态进程主动要求切换到内核态的一种方式。用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。例如
fork()
就是执行了一个创建新进程的系统调用。系统调用的机制是使用了操作系统为用户特别开放的一个中断来实现,如Linux的int 80h、xv6的int 64。(2)异常
当cpu在执行运行在用户态下的程序时,发生了一些没有预知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常。
(3)外围设备的中断
当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令而转到与中断信号对应的处理程序去执行,如果前面执行的指令是用户态下的程序,那么转换的过程自然就会是由用户态到内核态的切换。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等。
这三种方式是系统在运行时由用户态切换到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。从触发方式上看,切换方式都不一样,但从最终实际完成由用户态到内核态的切换操作来看,都相当于执行了一个中断响应的过程。后面会详细解释系统调用如何通过中断机制实现。
4、进程的状态
XV6操作系统中的进程状态是六模型:
enum procstate { UNUSED, EMBRYO, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };
状态之间的转换关系如下:
5、xv6的独特内核切换
一般操作系统中进程的切换是:A的用户态->A的内核态->B的内核态->B的用户态。但是xv6模拟出了一个内核线程——per-cpu调度器线程,当需要切换进程时,在这个线程上会调用scheduler()
在切换中起到调度的作用。所以实际上在xv6中,进程切换的中间两步要换成三步:旧内核线程->per-cpu调度器线程->新的内核线程。如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Dp7TXwNo-1572157762066)(C:\Users\zcr\AppData\Roaming\Typora\typora-user-images\1571239670419.png)]
6、进程的虚拟地址布局:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y3Vt2FW8-1572157762067)(C:\Users\zcr\AppData\Roaming\Typora\typora-user-images\1571109484080.png)]
-
用户内存位于
0 ~ 0x7FFFFFFF
-
内核区域
- BIOS被映射到
0x80000000 ~ 0x800FFFFF
处 - 内核指令和数据映射到
0x80100000 ~ 0xFFFFFFFF
。之所以进程的页表要包含内核代码的映射,是因为当进程从用户态进入内核态时,内核代码在同一个进程的内核区域运行,不需要切换页表。
- BIOS被映射到
7、内核页表kpgdir
内核页表是per-cpu调度器线程的页表。
如上一条,对于每一个用户进程映射的线性地址包括两部分:用户态与内核态。其中,内核态地址对应的相关页表项,对于所有进程来说都是相同的(因为内核空间对所有进程来说都是共享的),而这部分页表内容其实就来源于kpgdir
,即每个进程的“进程页表”中内核态地址相关的页表项都是kpgdir
的一个拷贝。进程使用的是自己的页表,当要退出当前进程或者切换到其他进程时,当前页表会被切换为per-cpu调度器线程的页表——kpgdir
,所以只有当进程切换时kpgdir
会被使用。
8、x86的分页映射
x86使用了两级表。32位地址的高20位的转换被分成两步来进行,每步使用其中的10bit。
第一级表称为页目录(page directory)。它被存放在1页4K大小的页面中,每个表项32bit,指向对应的二级表。线性地址的最高10位(位31~22)用作页目录中的索引值,取表项高20位为对应二级页表基址。
第二级表称为页表(page table),它的大小也是4k。每个表项含有相关页面的20位物理基址。二级页表使用线性地址中间10位(位21~12)作为表项索引值,获取表项的高20位作为最终物理页表的基址,与线性地址中的低12位(页内偏移)组合在一起就得到了最终的最终物理地址。
上述过程如下图所示,其中CR3寄存器指定页目录表的基地址:
Part2:第一个进程的创建
1、main函数
在jos中entry
结束后会跳到i386_init()
进入c code,而xv6是跳到main()
由此开始运行c code:
#在entry.S最后
mov $main, %eax
jmp *%eax
main
函数定义在main.c
中,调用了很多初始化函数。下面只介绍和进程创建相关的重要的函数:
1)kvmalloc
作用:为kernel建立内核页表(setupkvm
),然后再采用该页表(switchkvm
)。
void
kvmalloc(void)
{
kpgdir = setupkvm();
switchkvm();
}
- setupkvm:
首先,它会分配一页内存作为页目录表(PDE)来放置页目录:
if((pgdir = (pde_t*)kalloc()) == 0)
return 0;
然后调用 mappages
来建立内核需要的映射:
for(k = kmap; k < &kmap[NELEM(kmap)]; k++)
if(mappages(pgdir, k−>virt, k−>phys_end − k−>phys_start,(uint)k−>phys_start, k−>perm) < 0)
return 0;
具体映射项可以在 kmap
数组中找到:
static struct kmap {
void *virt;
uint phys_start;
uint phys_end;
int perm;
} kmap[] = {
{ (void*)KERNBASE, 0, EXTMEM, PTE_W}, // I/O space
{ (void*)KERNLINK, V2P(KERNLINK), V2P(data), 0}, // kern text+rodata
{ (void*)data, V2P(data), PHYSTOP, PTE_W}, // kern data+memory
{ (void*)DEVSPACE, DEVSPACE, 0, PTE_W}, // more devices
};
这里的映射包括内核的指令和数据,PHYSTOP
以下的物理内存,以及 I/O 设备所占的内存。注:setupkvm
不会建立任何用户内存的映射。
mappages
做的工作是在页表中建立一段虚拟内存到一段物理内存的映射。它是在页的级别,即一页一页地建立映射的。对于每一个待映射虚拟地址,mappages
调用 walkpgdir
来找到该地址对应的二级页表中的PTE 地址。然后更新该 PTE 以保存对应物理地址、许可级别(PTE_W
和/或 PTE_U
)以及用标记该 PTE 是否是有效的 PTE_P
位。 代码如下:
mappages(pde_t *pgdir, void *va, uint size, uint pa, int perm)
{
char *a, *last;
pte_t *pte;
a = (char*)PGROUNDDOWN((uint)va);
last = (char*)PGROUNDDOWN(((uint)va) + size − 1);
for(;;){
if((pte = walkpgdir(pgdir, a, 1)) == 0)
return −1;
if(*pte & PTE_P)
panic("remap");
*pte = pa | perm | PTE_P;
if(a == last)
break;
a += PGSIZE;
pa += PGSIZE;
}
return 0;
}
mappages
中关键的就是walkpgdir
函数,该函数模仿前面介绍的 x86 的分页硬件为一个虚拟地址寻找 PTE 的过程。walkpgdir
通过虚拟地址的前 10 位来找到在页目录中的对应条目地址:
pde = &pgdir[PDX(va)];
如果该条目不存在,说明要找的二级页表页尚未分配,如果 alloc
参数被设置了,walkpgdir
会分配页表给这个条目,将这个被分配页用0填充,并把其物理基址放到页目录中:
if(!alloc || (pgtab = (pte_t*)kalloc()) == 0)
return 0;
// Make sure all those PTE_P bits are zero.
memset(pgtab, 0, PGSIZE);
*pde = V2P(pgtab) | PTE_P | PTE_W | PTE_U;
然后用虚拟地址的接下来 10 位来找到其在这个二级页表中的偏移并返回:
return &pgtab[PTX(va)];
至此,内核页表已经建好,此页表项对于今后所有的用户进程都是通用的。
- 调用switchkvm来更新cr3寄存器( CR3是页目录基址寄存器,保存页目录表的物理地址 ),开始采用此页表。
void switchkvm(void){
lcr3(V2P(kpgdir)); // switch to the kenel page table}
}
2)xxinit
调用各种init建立内存页管理系统
3)userinit:初始化第一个用户进程:
allocproc
初始化用于进程内核线程执行的 PCB中部分数据结构 :
p = allocproc();
【alloc_proc】
1、遍历进程表proc
,找到一个标记为 UNUSED
的槽位。若找到,将其状态设置为 EMBRYO
,并设置进程pid;否则返回0:
在全局变量ptable
中寻找UNUSED的进程结构,未找到返回0:
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++)
if(p->state == UNUSED)
goto found;
return 0;
found:
p->state = EMBRYO;
p->pid = nextpid++;
2、给进程p分配内核栈,如果分配失败了,把这个位置的结构的状态恢复为 UNUSED
,并返回0以标记失败;若成功,p->kstack
则代表内核栈栈底,KSTACKSIZE
是分配给内核栈的大小,为4k,sp代表内核栈栈顶:
if((p->kstack = kalloc()) == 0){
p->state = UNUSED;
return 0;
}
sp = p->kstack + KSTACKSIZE;
3、设置trapframe
(中断发生时保存信息的地方)的栈顶:
sp -= sizeof *p->tf;
p->tf = (struct trapframe*)sp;
【trapframe】
trapframe
的定义:
struct trapframe {
// registers as pushed by pusha
uint edi;
uint esi;
uint ebp;
uint oesp; // useless & ignored
uint ebx;
uint edx;
uint ecx;
uint eax;
// rest of trap frame
ushort gs;
ushort padding1;
ushort fs;
ushort padding2;
ushort es;
ushort padding3;
ushort ds;
ushort padding4;
uint trapno;
// below here defined by x86 hardware
uint err;
uint eip;
ushort cs;
ushort padding5;
uint eflags;
// below here only when crossing rings, such as from user to kernel
uint esp;
ushort ss;
ushort padding6;
};
这个结构体中,依次储存了——
- 目标寄存器
- gs, fs, es, ds 段寄存器
- trap_no, err :中断信息
- eip, cs :trap返回后的目的地址
- esp, ss :如果是从用户态进入内核态时,因为要使用内核栈,所以会保存这两个指针表示用户栈
4、保存trapret:
sp -= 4;
*(uint*)sp = (uint)trapret;
【为什么trapret】
.globl trapret
trapret:
popal
popl %gs
popl %fs
popl %es
popl %ds
addl $0x8, %esp # trapno and errcode
iret
popal
根据注释是从栈中恢复普通寄存器,然后恢复段寄存器,最后iret
指令恢复eip、cs
和EFLAGS
(可能还有esp、ss
)。所以trapret
是把trapframe
里的寄存器依次弹出。
从用户态到内核态有三种方式:外部硬件中断、异常、系统调用。但在xv6中,从用户态到内核态只能通过中断机制实现。每当进程运行中要将控制权交到内核时,硬件就会在进程的内核栈的trapframe
上保存用户态下的寄存器。当中断返回时,trapframe
会自动弹栈恢复寄存器。现在是出于内核态,但是并不是因为中断进入的,所以这里手动放入trapret
以弹栈,模拟成是通过中断进入内核的一样。
5、设置context
( 进程切换需要保存的上下文 )的顶部指针, 设置 context->eip
为 forkret
:
sp -= sizeof *p->context;
p->context = (struct context*)sp;
memset(p->context, 0, sizeof *p->context);
p->context->eip = (uint)forkret;
proc.h
中context
的定义:
struct context {
uint edi;
uint esi;
uint ebx;
uint ebp;
uint eip;
};
储存了部分用户寄存器的值。 为什么要保存这些寄存器?
首先要明确,要用到trapframe
的只有两种情况:中断;进程切换。
- 中断(后面的
alltraps
部分进行了介绍)会形成trapframe
,然后会call trap
处理中断,在call trap
的时候会自动把这些寄存器压栈形成context
(因为这些几个寄存器是被调用者保护寄存器:被调用者会改变这几个寄存器的值,所以先把调用者的这几个寄存器的值保存起来,在返回时恢复)。 - 进程切换:步骤是A用户线程->A内核线程->per-cpu调度器线程->B内核线程->B用户线程。在用户线程陷入内核时会生成
trapframe
,在内核线程切换到调度器线程时会保存context
,为了下次调度器调用swtch
的时候能够恢复该线程上下文。也就是说一个被阻塞的内核线程(除了调度器线程)都得有trapframe
和context
。
当前进程之后要被scheduler
调度,scheduler
会调用swtch
切换context
。由于swtch
函数(后面mpmain
对swtch
进行了介绍)结束后将返回到context.eip
位置。alloc_proc
中p->context->eip = (uint)forkret
,所以会进入forkret
函数(定义在proc.c
中)。forkret
返回后进入trapret()
继续执行 。(在后面关于wait()的介绍中解释了为什么是forkret)
根据上面的分析,可以画出此时内核栈结构:
/ +---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| | ss |
| +---------------+
| | esp |
| +---------------+
| | eflags |
| +---------------+
| | cs |
| +---------------+
| | eip |
| +---------------+
| | err |
| +---------------+
| | trapno |
| +---------------+
| | ds |
| +---------------+
| | es |
| +---------------+
| | fs |
struct trapframe | +---------------+
| | gs |
| +---------------+
| | eax |
| +---------------+
| | ecx |
| +---------------+
| | edx |
| +---------------+
| | ebx |
| +---------------+
| | oesp |
| +---------------+
| | ebp |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +---------------+ <-- p->tf
| trapret |
/ +---------------+ <-- forkret will return to
| | eip(=forkret) | <-- return addr
| +---------------+
| | ebp |
| +---------------+
struct context | | ebx |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +-------+-------+ <-- p->context
| | |
| v |
| empty |
+---------------+ <-- p->kstack
- 然后初始化这个进程p自己的页表(
struct proc
中的pgdir
)。首先,userinit()
会使用setupkvm()
生成与前面建立的内核页表一模一样的内核页表,然后使用inituvm()
生成第一个用户内存页并映射到虚拟地址0x0:
if((p->pgdir = setupkvm()) == 0)
panic("userinit: out of memory?");
inituvm(p->pgdir, _binary_initcode_start, (int)_binary_initcode_size);
关于inituvm
:
1)调用`kalloc`分配一个物理页*mem*:
mem = kalloc();
2)调用`mappages`将虚拟地址*0*-PGSIZE映射到该物理页:
mappages(pgdir, 0, PGSIZE, V2P(mem), PTE_W|PTE_U);
3)将`initcode`(用户进程初始化代码,后面会用到)拷贝至该物理页 :
memmove(mem, init, sz);
-
设置PCB:
其中
p->tf->eip = 0;
表示从虚拟0地址(即initcode.S)开始执行;p->tf->esp = PGSIZE;
表示用户堆栈的空间最大只有4K ;p->tf->cs = (SEG_UCODE << 3) | DPL_USER;
会使段选择器指向段SEG_UCODE并处于特权级DPL_USER(即在用户模式而非内核模式);对于数据段的处理p->tf->ds = (SEG_UDATA << 3) | DPL_USER
同理;将状态设为RUNNABLE:p->state = RUNNABLE;
4)mpmain
关键:调用scheduler()
开始调度准备切换
scheduler(); // start running processes
关于scheduler()
:
scheduler
有一个外循环是不断进行的。外循环开始的时候会开起中断 sti()
, 防止所有进程都在等待IO时,由于关闭中断而产生的死锁。
外循环中有一个内循环:
1.每一次内循环前都要获取ptable.lock
,然后遍历进程表寻找runnable
状态的进程
acquire(&ptable.lock);
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
if(p->state != RUNNABLE)
continue;
scheduler函数是个死循环,不停地通过内循环遍历所有进程状态,由于遍历的过程中可能会改变进程状态(xv6支持多cpu,共享ptable),所以,一次遍历的内循环外需要加锁保护。并且内循环执行了一遍后需要释放ptable.lock,这是为了防止当前没有进程待执行的时候,由于当前CPU一直占有锁,其他CPU上的进程可能会执行wakeup改变进程状态却没办法获取锁导致的死锁。
2.调用switchuvm
,该函数负责切换相关进程独有的数据结构,其中包括TSS相关的操作,然后将该进程的页表载入cr3寄存器,完成设置进程相关的虚拟地址空间。
【关于TSS】:
-
不管是用户程序还是内核调用了系统调用,CPU 都会离开原来的指令,跳转到系统调用的代码执行。当系统调用完成后,会回到系统调用处继续原来的指令。所以需要保存原来程序的上下文,当系统调用结束后,能恢复原来的运行环境,继续执行。
-
x86对于每一个 task, 都会有一个 TSS 来保存该 task 运行环境,当 task 发生切换时,TSS 负责保存/恢复上下文,通过TR寄存器指向的TSS改变而切换进程。
-
xv6可以运行多cpu的计算机上,使用
struct cpu
结构体来记录每个CPU状态 ,并将所有cpu保存在struct cpu cpus[NCPU]
中,通过mycpu()
获取当前cpu。proc.h:struct cpu
中有两个字段,一个是 ts,一个是 gdt。
struct cpu {
...
struct taskstate ts; // Used by x86 to find stack for interrupt
struct segdesc gdt[NSEGS]; // x86 global descriptor table
...
}
这个ts字段就是TSS,但是xv6 并没有采用每个进程一个TSS,更换TSS来切换进程。xv6是一个cpu一个TSS,并且不完全依靠TSS保存每个进程切换时的寄存器副本,而是将这些寄存器副本保存在各个进程自己的内核栈中,因此TSS中的绝大部分内容就失去了原来的意义。那么,当进行任务切换时,怎样自动更换堆栈?只更换TSS中的ss0和esp0(指向内核栈),而不更换TSS本身。这是因为,改变TSS中ss0和esp0的开销比通过装入TR以更换一个TSS要小得多。所以每个进程运行之前会通过switchuvm
更新ts字段(实际上只是更新ts的ss0和esp0)。对应的具体代码:
1)安装 ts描述符到 gdt中:
mycpu()->gdt[SEG_TSS] = SEG16(STS_T32A, &mycpu()->ts, sizeof(mycpu()->ts)-1, 0);
2)设置 ts 的内核栈:
mycpu()->ts.esp0 = (uint)p->kstack + KSTACKSIZE;
【/tss】
当TSS设置完之后,通知硬件开始使用目标进程的页表:
lcr3(V2P(p->pgdir));
3.将目标进程状态改为running:
p->state = RUNNING;
4.调用swtch,作用是切换上下文到目标进程的内核线程中。如前所述,xv6中线程的切换步骤是是旧内核线程->一个特殊的per-cpu调度器线程->新的内核线程。此时调用scheduler
的就是这个per-cpu调度器线程。所以此处调用swtch
是实现从per-cpu调度器线程上下文切换到新进程的内核线程的上下文。
swtch(struct context **old, struct context *new)
旧进程调用swich时首先保存自己的context,然后将栈指针指向新的context,弹出恢复寄存器,当switch返回时返回的则是新的进程,也就是说,旧进程总是被阻塞在swtch处,只有当进程再次被调度时才能恢复到返回swich的“状态”。
swtch
具体:
1)把struct context **old
赋给%eax
,把struct context *new
赋给%edx
。
movl 4(%esp), %eax
movl 8(%esp), %edx
2)保存old process
的寄存器
#Save old callee-saved registers
pushl %ebp
pushl %ebx
pushl %esi
pushl %edi
3)栈的切换:
#Switch stacks
movl %esp, (%eax)
movl %edx, %esp
第一句时把当前%esp
的值赋给(%eax)
,即(**old)
,即*old
也就是old process对应的proc
结构体的context
字段,指向的是内核栈中context
的起始地址,布局如下所示:
/ +---------------+
| | eip(return addr)|
| +---------------+
| | ebp |
old process's | +---------------+ struct context| | ebx |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +-------+-------+ <-- p->context
第二句是把%edx
赋给%esp
,又因为现在%edx
是struct context *new
,所以现在%esp
就是new process对应的proc
结构体的context
字段。
4)切换到新线程的栈后,就可以恢复新线程的寄存器。
#Load new callee-saved registers
popl %edi
popl %esi
popl %ebx
popl %ebp
ret
最后的ret
指令从栈中弹出目标进程的%eip
,结束context
切换工作。现在处理器就运行在新进程的内核栈上了。
因为在前面alloc_proc
中p->context->eip = (uint)forkret
,所以ret使得执行proc.c:forkret
的代码。forkret
启动了一个log就返回了,当forkret
返回弹栈时,根据allocproc
的栈的分配可知接下来是trapret()
的地址,于是返回到trapret()
继续执行。trapret
用弹出指令从trapframe
中恢复寄存器,就像swtch
对context
的操作一样:
.globl trapret
trapret:
popal
popl %gs
popl %fs
popl %es
popl %ds
addl $0x8, %esp # trapno and errcode
iret
popal
根据注释是从栈中恢复普通寄存器,然后恢复段寄存器,最后iret
指令恢复eip
、cs
和EFLAGS
(可能还有esp
、ss
)。其中%eip
即进入用户态之后执行的程序的入口。而在userinit()
中 p->tf->eip = 0;
并且inituvm()
把initcode.S
放到了虚拟地址0处,所以接下来回进入initcode.S
。
【如何理解forkret和trapret的设置?】
cpu最初是内核态,要创建第一个用户进程,需要进入用户态。由于XV6系统只允许中断返回这一种从内核态进入用户态的方式,因此需要通过allocproc()
模拟中断调用的栈结构,仿佛是从一次真正的中断里返回进程一样。
【什么时候会需要保存esp和ss?】
当从用户态进入内核态时,cpu会做下面一些事:如果cpu在用户模式下运行,它会从tss(mycpu()->ts)
中加载 %esp(ts->ts_esp0)
和 %ss(ts->ts_ss0)
并压入栈中;如果cpu在内核模式下运行,上面的事件就不会发生。cpu接下来会把 %eflags,%cs,%eip
压栈~~(此处可查看栈)~~。这样做的目的是为了中断返回时可以恢复进程状态。
2、SYSCALL
接下来会通过中断从用户态进入内核态,然后执行exec系统调用,通过这个系统调用来加载进一个真正的用户进程。
1)initcode.S
# exec(init, argv)
.globl start
start:
pushl $argv
pushl $init
pushl $0 // where caller pc would be
movl $SYS_exec, %eax
int $T_SYSCALL
# char init[] = "/init\0";
init:
.string "/init\0"
# char *argv[] = { init, 0 };
.p2align 2
argv:
.long init
.long 0
SYS_exec
在syscall.h
中被定义为7,syscall.c
中有一个syscalls
函数指针数组:static int (*syscalls[])(void)
,每一项指向一个系统调用函数,这些函数在syscall.h
中被define成不同的序号。当系统调用发生时,需要有个参数来通知内核要调用的是哪一个函数,这里通过movl $SYS_exec, %eax
把sys_exec
这个系统调用函数的序号存到了%eax
中。
系统调用是内核模式,所以要从用户态“陷入”内核态“,在xv6中是通过触发中断。在 x86 中,中断处理程序的入口在中断描述符表(IDT)中被定义,这个表有256个表项,每一个都提供了相应的 %cs 和 %eip指向中断处理程序,并且通过int n指令来选用哪个中断处理程序,n 就是 IDT 的索引。n是由操作系统的开发者指定的,比如Linux用的是int 0x80,而XV6选用的是int 64:在traps.h
中T_SYSCALL
被定义成64。
所以上面的代码其实可以翻译成:
char init[] = "/init\0";
char *argv[] = { init, 0 };
start:
Push argv #exec的参数
Push init
Push 0 #应该是argv的结束符
%eax = 7 #exec的系统调用号
int 64 #触发硬件中断,进入内核执行exec系统调用执行init程序
前面说过,当从用户模式向内核模式转换后,要使用区别于用户栈的内核栈。xv6 会使得在“内陷”(用户态->内核态)发生的时候进行一个栈切换:让硬件从mycpu()->ts
中读出内核的 %esp
和%ss
的值。这两个值是在进程开始前switchuvm存入ts中的:
mycpu()->ts.ss0 = SEG_KDATA << 3;
mycpu()->ts.esp0 = (uint)p->kstack + KSTACKSIZE;
然后把进程的用户栈的 %ss
和 %esp
压入内核栈中,继续把 %eflags,%cs,%eip
压栈(此处可查看栈)。
【判断是否需要保存 %esp
和%ss
的方式:特权级保护】
预备知识说到,x86共有4个特权级。但是一般只会用到2个特权级,内核特权级为0,用户进程特权级为3。
中断描述符有3个相关的特权级
- 当前代码的特权级,就是当前
cs
的CPL
- 中断处理过程所在的目标代码段的特权级,中断门中存放目标代码段描述符的选择子,通过选择子,可以从
GDT/LDT
中找到目标代码段的DPL
- 中断门描述符的
DPL
判断规则如下:
-
当前的
CPL
< 目标代码段描述符的特权级:不允许将控制转移到中断处理程序中 -
目标代码段描述符的特权级 < 当前的
CPL
:栈切换并保存%ss
和%esp
【特权级保护/】
2) vector64
int 64
使得cpu保存了上述寄存器后,会进入vector64
。
vectors.pl
脚本生成 vectors.S
,定义了每一个 interrupt handler
,并在最后定义了数组 uint vectors[]
存放interrupt handler
,共256项。之所以叫interrupt handler
是因为tvinit:SETGATE(idt[i], 0, SEG_KCODE<<3, vectors[i], 0);
将这个vectors[]
与中断描述符表struct gatedesc idt[256]
一 一对应,而中断描述符表存储着中断程序入口。
每个 interrupt handler
主要做两件事情:
vector64:
pushl $0
pushl $64
jmp alltraps
-
push 0
(errnum)和中断号(trapnum) -
跳转到
alltraps
执行
所以int64将cpu导向vector64
,压入参数后,跳到alltraps
。
3)alltraps
定义在trapasm.s
。
alltraps继续压入寄存器保存现场,得到trapframe
结构体(如alloc_proc
的trapframe
图所示):
.globl alltraps
alltraps:
# Build trap frame.
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal
重设段寄存器从而进入内核态:
movw $(SEG_KDATA<<3), %ax
movw %ax, %ds
movw %ax, %es
压入%esp
作为trap
的参数,然后调用C函数trap
处理中断:
pushl %esp
call trap
addl $4, %esp
注:此时的trapframe
的下一条是%esp
作为参数,与alloc_proc
的trapret
目的不同,后者是因为不是真正的要从中断返回,所以需要手动加入trapret
弹栈。
trap
结束后trapret
自动弹出寄存器恢复中断前现场:
.globl trapret
trapret:
popal
popl %gs
popl %fs
popl %es
popl %ds
addl $0x8, %esp # trapno and errcode
iret
4)trap
由于trap
是c函数,所以调用它时会自动在栈上压入返回地址eip
和部分寄存值构成context
结构(这些寄存器是calleesave register
),在trap
执行ret
指令返回之前会自动弹出context
恢复栈的调用前状态。
注:这里的context
和alloc_proc
的context
结构一样,但是弹出方法不一样。前者的形成是因为调用C函数,当C函数返回时会自动弹出,后者的形成是为了完成scheduler
的进程调度,弹出则是靠scheduler
调用swtch
,在swtch
中手动弹出context
结构来模拟函数返回的情形:mpmain
->scheduler
->swtch.S
。这样的区别和上一条关于trapframe
如何弹栈的注释类似。
trap
函数主要根据trapframe
中的trapno
来确定到底是哪种原因导致中断的发生,如果是系统调用,则通过调用syscall
函数负责具体的系统调用处理:
if(tf->trapno == T_SYSCALL){
if(proc->killed)
exit();
proc->tf = tf;
syscall();
if(proc->killed)
exit();
return;
}
注意到这里更新了进程的trapframe
的地址,这样做的原因是trapframe
的大小由于可能硬件未压入ss
和esp
导致不一致,而任务栈ts
需要指向内核栈所在页的最高地址处:
cpu->ts.esp0 = (uint)proc->kstack + KSTACKSIZE;
但是,proc->tf
在alloc_proc
中的赋值是按照大小包括ss
和esp
来的:
p->tf = (struct trapframe*)sp;
所以在这里更新了tf
的地址。
【系统调用实现机制】
syscall
通过trapframe
中的eax
来确定系统调用号:
num = proc->tf->eax;
eax
的值之所以存储着系统调用号是因为在initcode.S
中movl $SYS_exec, %eax
并且eax
在alltraps中被压入trapframe
。
然后通过系统调用号调用具体的系统调用处理函数,并把返回给trapframe
中的eax
,这样trapret
时便能根据eax
得到系统调用的返回值:
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
proc->tf->eax = syscalls[num]();
在syscalls[]
这个函数指针数组中,7对应的函数是sys_exec
。sys_exec
根据trapframe中的esp来找到陷入内核前的用户栈,从而获得当时压入的参数(这个例子中是“init”和char *argv),然后调用exec
。
5)exec
exec
的实现在exec.c
中。exec
会从磁盘里加载一个ELF文件(init程序,第一个用户进程)取代调用进程的内容。ELF文件中包含了所有代码段和数据段的信息,并且描述了这些段应该被加载到的虚拟地址。
然后,exec
会分配两个虚拟内存页,第一个页设置为不可访问,第二个页用作用户栈。最后会重设进程的页表,中断帧的eip
和esp
,意味着trapframe
弹栈后不会恢复到中断前,而是开始使用新的代码段,数据段和堆栈。
oldpgdir = curproc->pgdir;
curproc->pgdir = pgdir;
curproc->sz = sz;
curproc->tf->eip = elf.entry; // main
curproc->tf->esp = sp;
switchuvm(curproc);
所以scheduler
通过swtch
进入initcode
,然后initcode
陷入内核,然后系统调用exec
,本来scheduler
后面还有调用switchkvm()
但是不会再返回到此处了。
经过exec, 第一个进程init即将正式运行。为什么要从内核态返回用户态执行initcode.S,然后在initcode.S中又陷入内核通过系统调用创建第一个用户进程?
当内核初始化结束后,只有init唯一 一个进程,让它执行initcode.S其实只是起到过渡的作用,主要是真正的init程序的代码和数据太庞大,无法塞到内核中。所以就用initcode.S这个过渡代码发起了exec系统调用,在磁盘里加载真正的init程序。
Part3:几个函数的理解
1、fork()
功能: 除了第一个进程,其他进程都是由父进程调用fork()
创建的。
-
调用
myproc
函数获取当前的进程:struct proc *curproc = myproc();
myproc()
通过mycpu()
获取当前cpu,进而获得当前cpu->proc:struct proc* myproc(void) { struct cpu *c; struct proc *p; pushcli(); c = mycpu(); p = c->proc; popcli(); return p; }
-
调用
allocproc()
函数获得并初始化一个进程控制块struct proc
:if((np = allocproc()) == 0){ return -1; }
根据前面的分析可知,
allocproc()
会对np的内核栈进行初始化,在内核栈里设置trapframe和context,并用0填充。 -
调用
copyuvm()
函数复制父进程的虚拟内存结构:np->pgdir = copyuvm(curproc->pgdir, curproc->sz)
【copyuvm】:
先调用
setupkvm
建立内核代码段与数据段在虚拟内存高地址处的映射:d = setupkvm();
开始对父进程的用户内存一页一页的进行拷贝:
for(i = 0; i < sz; i += PGSIZE){ if((pte = walkpgdir(pgdir, (void *) i, 0)) == 0) #pte是二级页表的表项 panic("copyuvm: pte should exist"); if(!(*pte & PTE_P)) panic("copyuvm: page not present"); pa = PTE_ADDR(*pte); #pa是以pte为基础寻址得到的物理地址 flags = PTE_FLAGS(*pte); if((mem = kalloc()) == 0) #在物理地址空间中分配一页 goto bad; memmove(mem, (char*)P2V(pa), PGSIZE); #把pa为基址的一页拷贝到mem中 #建立拷贝好的内存mem与子进程页表的映射 if(mappages(d, (void*)i, PGSIZE, V2P(mem), flags) < 0) { kfree(mem); goto bad; } }
由此可见,xv6在创建子进程时就实打实地分配了新的物理地址,而**不是采取“ 写时复制“**的方式。
-
子进程继承父进程的大小,设置父进程为当前进程,继承
trapframe
使得子进程返回时处在和父进程一模一样的状态:np->sz = curproc->sz; np->parent = curproc; *np->tf = *curproc->tf;
-
因为子进程完全拷贝父进程的
trapframe
,所以被调度到时会返回父进程调用fork()
处,为了区分子进程和父进程,设置trapframe
的eax
为0,这样子进程创建完通过中断返回的时候,会返回0,而父进程会返回子进程的pid:np->tf->eax = 0;
-
把父进程打开的文件描述符、父进程所处于的目录和父进程的名字全部拷贝到子进程:
for(i = 0; i < NOFILE; i++) if(curproc->ofile[i]) np->ofile[i] = filedup(curproc->ofile[i]); np->cwd = idup(curproc->cwd); safestrcpy(np->name, curproc->name, sizeof(curproc->name));
-
设置子进程当前状态为就绪(RUNABLE):
np->state = RUNNABLE;
-
最后返回子进程当前的pid给父进程:
pid = np->pid; return pid;
2、wait()
功能:等待一个子进程exit并返回其pid,如果没有子进程返回-1
-
wait()
函数首先必须要获得ptable
的锁,因为它有可能会对ptable
做出修改。acquire(&ptable.lock);
-
遍历
ptable
,从中寻找自己的子进程。如果发现僵尸子进程,就把僵尸子进程回收,具体地说要回收它的内核栈、虚拟内存,并设置状态为UNUSED
。for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){ if(p->parent != curproc) continue; havekids = 1; if(p->state == ZOMBIE){ // 找到一个僵尸子进程 pid = p->pid; kfree(p->kstack); // 回收内核栈 p->kstack = 0; freevm(p->pgdir); // 回收虚拟内存 p->pid = 0; p->parent = 0; p->name[0] = 0; p->killed = 0; p->state = UNUSED; // 设置状态为UNUSED release(&ptable.lock); return pid; } }
注:
wait()
函数没有回收这个子进程打开的文件描述符,因为只有exit()
之后的进程才可能是ZOMBIE
状态,而在exit()
函数内这个进程打开的文件描述符已经全部被关闭了。 -
对于没有子进程的情况,
wait()
会直接返回,否则他会调用sleep()
阻塞父进程,并传入ptable
的锁作为参数://没有子进程 if(!havekids || curproc->killed){ release(&ptable.lock); return -1; } // 暂时阻塞父进程,等待僵尸子进程 sleep(curproc, &ptable.lock);
为了进一步理解
wait
,继续看sleep
和wakeup
的代码。sleep:
if(lk != &ptable.lock){ //判断sleep的调用者获取的锁是否为ptable.lock acquire(&ptable.lock); //如果不是,就要loop直至获取ptable.lock release(lk); //可以释放sleep调用者获取的锁 } p->chan = chan; //将该进程sleep on chan p->state = SLEEPING;//设置进程状态 sched(); //切换上下文到scheduler线程 //被唤醒后 p->chan = 0; // 重新获取原本的锁,并释放ptable.lock if(lk != &ptable.lock){ release(&ptable.lock); acquire(lk); }
wakeup:在获取
ptable.lock
的前提下唤醒所有sleep on chan
的进程static void wakeup1(void *chan) { struct proc *p; for(p = ptable.proc; p < &ptable.proc[NPROC]; p++) if(p->state == SLEEPING && p->chan == chan) p->state = RUNNABLE; }
1)为什么
sleep
要获取ptable.lock
?因为在真正把进程设置为
SLEEP
状态之前,子进程可能就已经成为僵死进程并在exit()
函数中调用了wakeup1()
来唤醒父进程,但此时父进程还未SLEEP
,这会使得父进程接收不到wakeup
信号从而进入死锁状态。这种现象的原因是睡眠操作与检测睡眠条件不是一个原子操作,xv6让进程在SLEEP
之前获取了ptable.lock
,而exit()
调用wakeup
又必须要获取ptable.lock
,所以不会发生lose wakeup
。2)
sleep
一直占着ptable.lock
,哪里有释放才能使其他进程获取锁?在xv6中,
ptable.lock
总是由旧进程获得,并将锁的控制权转移给切换代码,由新进程释放。锁的这种使用方式很少见,通常来说,持有锁的线程应该负责释放该锁,这样更容易让我们理解其正确性。但对于上下文切换来说,我们必须使用这种方式,因为ptable.lock
会保证进程的state
和context
在运行swtch
时保持不变。如果在swtch
中没有持有ptable.lock
,可能引发这样的问题:在 yield 将某个进程状态设置为RUNNABLE
之后,但又是在swtch
让它停止在其内核栈上运行之前,有另一个 CPU 的schduler检测到该进程位RUNNABLE
,然后调用swtch
,结果将是两个 CPU 都运行在同一个栈上,这显然是不该发生的。这里也可以解释为什么在第一个进程中要把context
的eip
设为forkret,就是为了按照惯例释放ptable.lock
,否则这个新进程是可以直接从从trapret
就开始执行的。 所以在进程被置为SLEEPING之后要give up CPU
,并不由当前进程释放锁,而是调用sched()
,sched()
会通过下面这句切换到scheduler
:swtch(&p->context, mycpu()->scheduler);
而前面说过所有内核线程被切换掉时都会被卡在
swtch
,scheduler
也不例外,所以scheduler
会从swtch开始继续往下执行。而下面正有release(&ptable.lock);
3)为什么有
lk
锁,为什么可以释放?因为调用
sleep
的不一定是wait
,比如发送者和接收者也需要调用sleep和wakeup(详细的例子参考xv6调度[睡眠与唤醒])。sleep的调用者必须持有锁,这个锁不是ptable.lock,是保证二者比如发送者和接收者操作原子性的锁,作用是防止在调用者在test和sleep之间,对手进程在另一个cpu上运行,更新了共享量,调用了wakeup却发现没有需要唤醒的进程,导致对手进程被阻塞在test上,而调用者进程也依然sleep,这就造成死锁。接着 sleep 要求持有 ptable.lock。于是该进程就会同时持有锁 ptable.lock 和 lk 了。而如今 sleep 已经持有了 ptable.lock,因为wakeup一定要持有ptable.lock,所以即便对手进程调用了wakeup,wakeup 也不可能在没有持有 ptable.lock 的情况下运行直至sleep 让进程睡眠后,所以此时sleep完全可以释放lk,让对手进程能够修改共享量防止另一种死锁,同时这样一来,wakeup也 不会错过 sleep 。在这里是由wait调用sleep,lk 就是 ptable.lock 的时候,这样sleep 就会直接跳过lk和ptable.lock这两个步骤。
4)事实上xv6实现了wakeup和wakeup1,前者获取ptable.lock之后调用后者。为什么要这样做?
因为有时调度器既可能在持有 ptable.lock 的情况下唤醒进程,也可能在不持有的情况下唤醒。
3、exit()
-
首先关闭这个进程打开的所有文件描述符,然后除去自己对所处的文件目录的引用:
for(fd = 0; fd < NOFILE; fd++){ if(curproc->ofile[fd]){ fileclose(curproc->ofile[fd]); curproc->ofile[fd] = 0; } } begin_op(); iput(curproc->cwd); end_op(); curproc->cwd = 0;
-
如果这个进程的父进程正在等待子进程结束,那么这个进程必须唤醒父进程,只有这样父进程才能够在某个时刻回收僵尸子进程:
acquire(&ptable.lock); wakeup1(curproc->parent);
-
如果这个进程有子进程的话,就把这些子进程的父进程都设为
init
进程,如果是僵尸子进程,则唤醒init
进程回收僵尸子进程(总之就是一定要有父进程来帮助回收僵尸子进程):for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){ if(p->parent == curproc){ p->parent = initproc; if(p->state == ZOMBIE) wakeup1(initproc); } }
-
把该进程状态设置为
ZOMBIE
,调度器调度其他进程运行,并且因为不会再被唤醒所以不会再返回继续执行 :curproc->state = ZOMBIE; sched();
为什么子进程不能直接exit(),而是应该变成僵尸子进程,然后交给父进程来回收?
这样可以保证为退出的进程做好子进程的清理工作。 因为当子进程运行 exit 时,它正在利用 p->kstack 分配到的栈以及 p->pgdir 对应的页表,所以这两者只能在子进程结束运行后由父进程情理。这也是为什么要给scheduler单独分配一个内核栈和一个内核页表(kpgdir),而不能让其运行在调用 sched 的线程的栈上,否则像这种exit()的情况,调用sched(),父进程处理子进程栈和虚拟内存时会把scheduler也清理掉。
Part4:增加syscall获取父进程ID
系统调用SYSCALL实现机制在Part2中已介绍过。
1、实现
1、前面讨论的系统调用函数都是内核态下的,xv6为用户提供的这些函数的API定义在**user.h
**中:
// system calls
int fork(void);
int exit(void) __attribute__((noreturn));
int wait(void);
……
在最后添加:
int getfpid(void);
2、这些函数是通过调用汇编指令实现的,在**usys.S
**这个文件:
#define SYSCALL(name)
.globl name;
name:
movl $SYS_ ## name, %eax;
int $T_SYSCALL;
ret
在后面是所有系统调用函数的宏定义,以SYSCALL(wait)
为例,展开后就是:
.globl wait;
wait:
movl $SYS_wait, %eax;
int $T_SYSCALL;
ret
在最后添加:
SYSCALL(getpid)
在这里我产生了一个问题:对于有参数的系统调用怎么办?以前面的initcode.S系统调用exec为例,在用户态下调用exec(xx,xx)时参数都已经被压栈了,既然在栈中了在内核态中”顺藤摸瓜“获取参数不是难事。
3、syscall.h
,这个文件就定义了所有的$SYS_ ## name的系统调用号。
在最后添加:
#define SYS_getfpid 22
4、traps.h,这个文件定义了所有的中断处理程序的编号。比如#defineT_SYSCALL 64 // system call。前面说过,所有的系统调用都是通过int 64陷入内核,所以这里不需要改动。
5、按照前面分析,接下来硬件会把系统调用号以及其他寄存器压栈->vector64->alltraps建立trapframe->trap调用syscall()->syscall()通过trapframe中的eax来确定系统调用号并调用具体的系统调用处理函数。
这就涉及到syscalls[]这个函数指针数组,定义在**syscall.c
**中。这个文件的作用是将系统调用标识SYS_xx和系统调用函数sys_xx通过系统调用关联起来,并注册系统调用函数。
注册:
extern int sys_getfpid(void);
并在static int (*syscalls[])(void)
中添加:
[SYS_getfpid] sys_getfpid,
6、然后就要实现sys_xx,这些函数都定义在**sysproc.c
**中,并且在sys_xx中调用真正的处理函数。
//return parent process's pid
int
sys_getfpid(void)
{
return getfpid();
}
7、真正的处理函数在**defs.h
中注册,在proc.c
**中定义。
注册:
int getfpid(void);
实现:
int getfpid(void)
{
struct proc *curproc = myproc();
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
if(curproc->parent ==p)
return p->pid;
}
return -1;
}
2、检验
在xv6系统中,可以自己写一段C语言程序,并通过修改Makefile的方式,使得自己的应用程序成为系统的一部分,并在xv6上通过shell执行。
1、新建一个源代码,命名getfpid.c:
#include "types.h"
#include "stat.h"
#include "user.h"
int main(int argc, char *argv[])
{
if(argc!=1)
printf(1, "Usage: ps\n"); //注意printf的用法
else
printf(2,"parent process's pid : %d\n", getfpid());
exit();
}
2、修改Makefile,一共有两处:
1)168行左右的UPROGS=\需要在最后加入这个源代码名字:
UPROGS=\
_cat\
_echo\
_forktest\
_grep\
_init\
_kill\
_ln\
_ls\
_mkdir\
_rm\
_sh\
_stressfs\
_usertests\
_wc\
_zombie\
_getfpid\
2)251行的EXTRA=\处,添加入自己的.c源文件:
EXTRA=\
mkfs.c ulib.c cat.c echo.c forktest.c grep.c kill.c\
ln.c ls.c mkdir.c rm.c stressfs.c usertests.c wc.c zombie.c getfpid.c\
……
3)开启xv6:
cd xv6-public;
make clean;
make;
make qemu-nox;
4)在shell中输入getfpid
,得到当前shell进程的父进程pid:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2awI18Dl-1572157762070)(C:\Users\zcr\AppData\Roaming\Typora\typora-user-images\1571649901721.png)]
references: