笔记07 - xv6 启动到执行第一个进程

xv6的boot loader从硬盘加载xv6内核到内存并在entry处开始执行,此时xv6还没开启分页,virtual addresses直接映射到physical addresses。boot loader将内核加载到物理地址0x100000,不加载在0x80100000(内核期望由此地址寻找指令和数据)的原因是机器不一定有这么多内存,不加载在0x0的原因是0xa0000:0x100000的范围内包含了IO设备。为了允许剩余的内核代码可以正常运行,entry设置了页表,映射virtual addresses 0x80000000(KERNBASE)到physical addresses 0x0。entry分别映射virtual addresses 0:0x400000 到 physical addresses 0:0x400000,以及virtual addresses KERNBASE:KERNBASE+0x400000 到 physical addresses 0:0x400000,这也要求内核指令和数据占据的空间在4M以内。以下主要分析x86启动分页、内核初始化、启动多处理器、启动init进程的过程。

xv6的进程结构以及进程调度涉及到进程的有三大数据结构:struct cpu、struct proc、struct context。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// proc.h

// Per-CPU state
struct cpu {
uchar apicid; // Local APIC ID
struct context *scheduler; // swtch() here to enter scheduler
struct taskstate ts; // Used by x86 to find stack for interrupt
struct segdesc gdt[NSEGS]; // x86 global descriptor table
volatile uint started; // Has the CPU started?
int ncli; // Depth of pushcli nesting.
int intena; // Were interrupts enabled before pushcli?

// Cpu-local storage variables; see below
struct cpu *cpu;
struct proc *proc; // The currently-running process.
};

enum procstate { UNUSED, EMBRYO, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };

// Per-process state
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)
};


//PAGEBREAK: 17
// Saved registers for kernel context switches.
// Don't need to save all the segment registers (%cs, etc),
// because they are constant across kernel contexts.
// Don't need to save %eax, %ecx, %edx, because the
// x86 convention is that the caller has saved them.
// Contexts are stored at the bottom of the stack they
// describe; the stack pointer is the address of the context.
// The layout of the context matches the layout of the stack in swtch.S
// at the "Switch stacks" comment. Switch doesn't save eip explicitly,
// but it is on the stack and allocproc() manipulates it.
struct context {
uint edi;
uint esi;
uint ebx;
uint ebp;
uint eip;
};

附上file、inode以及trapframe结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// file.h
struct file {
enum { FD_NONE, FD_PIPE, FD_INODE } type;
int ref; // reference count
char readable;
char writable;
struct pipe *pipe;
struct inode *ip;
uint off;
};

// in-memory copy of an inode
struct inode {
uint dev; // Device number
uint inum; // Inode number
int ref; // Reference count
struct sleeplock lock;
int flags; // I_VALID

short type; // copy of disk inode
short major;
short minor;
short nlink;
uint size;
uint addrs[NDIRECT+1];
};

// x86.h
//PAGEBREAK: 36
// Layout of the trap frame built on the stack by the
// hardware and by trapasm.S, and passed to trap().
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;
};

启动分页

启动分页之前必须创建页表并设置给cr3寄存器,然后给cr0寄存器的PG位置为1。除此之外,x86还允许创建不同粒度的内存页,这涉及到cr4寄存器。在x86中,一个也目录中可以同时存在两种粒度的内存页,4K或者4M。page dir是必须的,这是一个长度最大为1024的整数数组,如果页目录表项的PS位置为1且cr4寄存器的PSE位置为1,那么CPU自动使用4M大小的内存页,即该页目录表项中保存的就是内存页的起始地址,这相当于进行二级分页而不是更常见的三级分页。如果这两个要求不能同时满足就进行三级分页。注意x86运行两种分页同时存在,比如cr4的PSE位设为1,而有些page dir表项设置PS位而有些则不设置,这样就同时存在两种分页机制。为何要使用4MB页呢,考虑这种场景:kernel img大小大约为1M,如果使用4M页映射kernel img,则TLB只需缓存一个页目录项即可,而如果是4K页则需要256个页目录项,这么多的表项是无法都缓存到TLB中的,这会使得地址翻译变慢很多。所以kernel img部分一般用一个4M页进行映射,而其他则使用4K页。对于xv6来说,使用4M页只是临时,不用创建复杂的页表,如此而已,内核启动之后很快就会重新创建页表。

为何要将0~4M进行1:1映射呢?在开启分页之前我们都是小心翼翼的使用“低地址”,
而打开分页之后我们将会跳转到“高地址”,低地址还有必要映射吗?有必要。在启动多处理器的时候,还需要从低地址启动,因为这些CPU(non-boot CPU,也叫做AP)要需要从real mode启动,见entryother.S。

内核初始化

进入main函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
int
main(void)
{
//根据entry.S里面对栈的设置,可知栈顶位置在end之前。(存疑:根据objdump -h kernel结果和kernel.asm,entry.S里stack的位置并不在.data节内,为什么?)
//将[end,4M](end先4K对齐)范围free为kmem,此时未开启分页,使用的是硬编码的4m地址映射。
//kmem锁初始化,处于解锁状态,占有kmem锁的CPU数目为0
//end: first address after kernel loaded from ELF file
kinit1(end, P2V(4*1024*1024)); // phys page allocator // see in kalloc.c
//为scheduler进程创建内核页目录,根据kmap设定将所有涉及范围内的内核空间虚拟地址
//(从KERNBASE开始,把IO空间、内核镜像等都建立映射,这些空间全都是用户空间可见的,位于2GB以上)按页大小映射到物理地址上,
//实际上是创建二级页表,并在二级页表项上存储物理地址。将页目录地址存储到cr3中。
//页目录项所存页表的权限是用户可读写
//二级页表项所存物理页的权限按照kmap设定
kvmalloc(); // kernel page table //see in vm.c
//
mpinit(); // detect other processors
lapicinit(); // interrupt controller
//初始化段寄存器,更新当前cpu
seginit(); // segment descriptors // see in vm.c
//
cprintf("\ncpu%d: starting xv6\n\n", cpunum());
//
picinit(); // another interrupt controller
//
ioapicinit(); // another interrupt controller
//
consoleinit(); // console hardware
//
uartinit(); // serial port
//进程表锁初始化,设置进程表处于解锁状态,占有进程表锁的CPU数目为0
pinit(); // process table //see in proc.c
//建立正常的中断/陷阱门描述符,中断处理锁初始化,处于解锁状态,占有中断处理锁的CPU数目为0
tvinit(); // trap vectors //see in trap.c
//
binit(); // buffer cache
//
fileinit(); // file table
//
ideinit(); // disk
//
if(!ismp)
timerinit(); // uniprocessor timer
//
startothers(); // start other processors
//将[4M,PHYSTOP]范围free为kmem,此时已经开启分页。
//设置计数表示kmem锁处于使用状态
kinit2(P2V(4*1024*1024), P2V(PHYSTOP)); // must come after startothers() // see in kalloc.c

//1.
//在进程表中寻找slot,成功的话更改进程状态embryo和pid,并初始化进程的内核栈
//2.
//为进程创建内核页目录,根据kmap设定将所有涉及范围内的内核空间虚拟地址
//(从KERNBASE开始,把IO空间、内核镜像等都建立映射,这些空间全都是用户空间可见的,位于2GB以上)按页大小映射到物理地址上,
//实际上是创建二级页表,并在二级页表项上存储物理地址。
//每个进程都会新建页目录和二级页表
//页目录项所存页表的权限是用户可读写
//二级页表项所存物理页的权限按照kmap设定
//3.
//从kmem上分配一页的物理空间给进程,在新建进程的页目录上映射[0,PGSIZE]的虚拟地址到分配的物理地址上,将指向内容复制到物理内存上
//页目录项所存页表的权限是用户可读写
//二级页表项所存物理页的权限为用户可读写
//4.
//设置进程的trapframe
//设置进程状态runnable
userinit(); // first user process //see in proc.c
//idtinit()加载中断描述符表寄存器
//scheduler()无限循环寻找进程状态为runnable的进程
//对于每次循环:
//开中断运行当前进程
//找到可执行的进程后,更新全局变量proc为当前被选中的进程
//设置cpu环境后,加载选中进程的页目录地址到cr3,切换为进程的页目录
//更改进程状态为running
//CPU调度
//加载内核的页目录地址到cr3,切换为内核的页目录
mpmain(); // finish this processor's setup
}

//vm.c
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
};

启动多处理器

(此部分待后续理解)
多处理器的启动是通过IPI,即Inter-Processor Instructions进行的,这是CPU间的通讯方式。startothers()来启动其他non-boot CPU,做法是:
复制启动代码到0x7000处,这部分代码相当于boot CPU的启动扇区代码。
为每个AP分配stack(是的,每个CPU都一个自己的stack)。
告诉每个AP,kernel入口在哪里(mpenter函数)。
告诉每个AP,页目录在哪里(entrypgdir)。
然后控制local apic进行CPU间通讯,依次启动其他CPU。启动之后他们会执行mpenter(),进而进入scheduler()开始执行程序。

启动init进程

boot CPU启动其他CPU之后,自己继续执行kinit2()初始化剩余的内存空间,然后开始启动init进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
//proc.c
//在进程表中寻找slot,成功的话更改进程状态embryo和pid,并初始化进程的内核栈
static struct proc*
allocproc(void)
{
struct proc *p;
char *sp;

acquire(&ptable.lock);

for(p = ptable.proc; p < &ptable.proc[NPROC]; p++)
if(p->state == UNUSED)
goto found;

release(&ptable.lock);
return 0;

found:
p->state = EMBRYO;
p->pid = nextpid++;

release(&ptable.lock);

// 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;

return p;
}

//PAGEBREAK: 32
// Set up first user process.
void
userinit(void)
{
struct proc *p;
extern char _binary_initcode_start[], _binary_initcode_size[];

//在进程表中寻找slot,成功的话更改进程状态和pid,并初始化进程的内核栈
p = allocproc();

initproc = p;
//为进程创建内核页目录,根据kmap设定将所有涉及范围内的内核空间虚拟地址
//(从KERNBASE开始,把IO空间、内核镜像等都建立映射,这些空间全都是用户空间可见的,位于2GB以上)按页大小映射到物理地址上,
//实际上是创建二级页表,并在二级页表项上存储物理地址。
//每个进程都会新建页目录和二级页表
//页目录项所存页表的权限是用户可读写
//二级页表项所存物理页的权限按照kmap设定
if((p->pgdir = setupkvm()) == 0) //see in vm.c
panic("userinit: out of memory?");
//从kmem上分配一页的物理空间给进程,在新建进程的页目录上映射[0,PGSIZE]的虚拟地址到分配的物理地址上,将指向内容复制到物理内存上
//页目录项所存页表的权限是用户可读写
//二级页表项所存物理页的权限为用户可读写
inituvm(p->pgdir, _binary_initcode_start, (int)_binary_initcode_size);//see in vm.c
p->sz = PGSIZE;
memset(p->tf, 0, sizeof(*p->tf));
p->tf->cs = (SEG_UCODE << 3) | DPL_USER; // todo ???
p->tf->ds = (SEG_UDATA << 3) | DPL_USER; // todo ???
p->tf->es = p->tf->ds;
p->tf->ss = p->tf->ds;
p->tf->eflags = FL_IF;//allow hardware interrupts
p->tf->esp = PGSIZE;
p->tf->eip = 0; // beginning of initcode.S

safestrcpy(p->name, "initcode", sizeof(p->name));
p->cwd = namei("/");

// this assignment to p->state lets other cores
// run this process. the acquire forces the above
// writes to be visible, and the lock is also needed
// because the assignment might not be atomic.
acquire(&ptable.lock);

p->state = RUNNABLE;

release(&ptable.lock);
}

创建进程数据结构

allocproc()函数在全局变量ptable中寻找UNUSED的进程结构,如果找到就做必要的初始化,然后将其返回,否则返回0,即空指针。其初始化过程包括修改进程状态,设置进程pid,构建进程的kenel stack(每个进程都有一个对应的内核栈)。进程的内核栈是调用p->kstack = kalloc()分配的,而p->tfp->context都在内核栈中。由上可知,进程结构体proc是在内核指令和数据所处的内存空间上,而内核栈是在end[]之后的内存空间上
每个进程的创建都会调用allocproc()函数,不论是init进程还是fork创建的普通进程。调用allocproc()函数之后,进程的kernel stack初始化状态如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
                     +---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| | ss |
| +---------------+
| | esp |
| +---------------+
| | eflags |
| +---------------+
| | cs |
| +---------------+
| | eip | <-- 这里往上在iret时自动弹出到相关寄存器中
| +---------------+
| | err |
| +---------------+
| | trapno |
| +---------------+
| | ds |
| +---------------+
| | es |
| +---------------+
| | fs |
struct trapframe | +---------------+
| | gs |
| +---------------+
| | eax |
| +---------------+
| | ecx |
| +---------------+
| | edx |
| +---------------+
| | ebx |
| +---------------+
| | oesp |
| +---------------+
| | ebp |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +---------------+ <-- p->tf
| trapret | <-- 弹出进程tf,执行用户代码
/ +---------------+ <-- forkret will return to
| | eip(=forkret) | <-- return addr,启动log
| +---------------+
| | ebp |
| +---------------+
struct context | | ebx |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +-------+-------+ <-- p->context
| | |
| v |
| empty |
+---------------+ <-- p->kstack

注意trapframe里的eip正是进入用户态之后执行的程序的入口,init进程跟普通进程
的差别就在这里了,init进程设置的eip=0(见userinit()函数,可知进程将分配一页空间来存放initcode的指令和数据,其虚拟地址范围为0x0:4K,因此eip=0则会跳转执行initcode的第一条指令),在此之前0这里已经放置了initcode.S的内容,而普通进程这里设置的是elf->entry,即程序的main()函数。

构建进程地址空间

userinit函数调用setupkvm函数为进程创建内核页目录,根据kmap设定将所有涉及范围内的内核空间虚拟地址(从KERNBASE开始,把IO空间、内核镜像等都建立映射,这些空间全都是用户空间可见的,位于2GB以上)按页大小映射到物理地址上,实际上是创建二级页表,并在二级页表项上存储物理地址。每个进程都会新建页目录和二级页表,页目录项所存页表的权限是用户可读写,二级页表项所存物理页的权限按照kmap设定。根据kmap把kernel也映射到进程空间的原因是:执行initcode代码前将切换为进程页目录,为了能继续执行内核代码,需要把kernel也映射到进程空间。
之后userinit函数调用inituvm函数从kmem(end[]之后的内存空间)上分配一页的物理空间给进程,在新建进程的页目录上映射[0,PGSIZE]的虚拟地址到分配的物理地址上,将_binary_initcode_start指向内容复制到物理内存上。页目录项所存页表的权限是用户可读写,二级页表项所存物理页的权限为用户可读写。然后设置进程的tf值,这部分值将在执行trapret时弹出到相关寄存器中,将进程的状态更改为RUNNABLE。

进入调度器开始执行

boot CPU继续执行进入scheduler()函数,它线性遍历ptable寻找RUNNABLE的进程,找到之后就执行,无穷循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//proc.c
void
scheduler(void)
{
struct proc *p;

for(;;){
// Enable interrupts on this processor.
//进程开中断
sti();

// Loop over process table looking for process to run.
acquire(&ptable.lock);
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
if(p->state != RUNNABLE)
continue;

// Switch to chosen process. It is the process's job
// to release ptable.lock and then reacquire it
// before jumping back to us.
//更新全局变量proc为当前被选中的进程
proc = p;
//设置cpu环境后,加载选中进程的页目录地址到cr3,切换为进程的页目录
switchuvm(p); //see in vm.c
//更改进程状态为running
p->state = RUNNING;
swtch(&cpu->scheduler, p->context); //see in swtch.S
//调用swtch之后将不会返回内核,但会将下一条指令的eip进栈,swtch会保存context寄存器,并将cpu->scheduler指向保存位置,程序执行完毕后应该会返回执行内核代码,重新调度下一个进程。(中断时会执行相关中断处理函数,不会返回这里。)
//加载内核的页目录地址到cr3,切换为内核的页目录
switchkvm(); //see in vm.c

// Process is done running for now.
// It should have changed its p->state before coming back.
proc = 0;
}
release(&ptable.lock);
}
}

//vm.c
// 设置cpu环境后,加载进程的页目录地址到cr3
void
switchuvm(struct proc *p)
{
pushcli();
cpu->gdt[SEG_TSS] = SEG16(STS_T32A, &cpu->ts, sizeof(cpu->ts)-1, 0);
cpu->gdt[SEG_TSS].s = 0;
cpu->ts.ss0 = SEG_KDATA << 3;
cpu->ts.esp0 = (uint)proc->kstack + KSTACKSIZE;
// setting IOPL=0 in eflags *and* iomb beyond the tss segment limit
// forbids I/O instructions (e.g., inb and outb) from user space
cpu->ts.iomb = (ushort) 0xFFFF;
ltr(SEG_TSS << 3);
if(p->pgdir == 0)
panic("switchuvm: no pgdir");
lcr3(V2P(p->pgdir)); // switch to process's address space
popcli();
}

switchuvm(p),它会设置TSS段并且将其中的ss0设置为SEG_KDATA,把esp0设置成p->kstack+KSTACKSIZE,也就是该进程内核栈的栈底,然后ltr(SEG_TSS<<3),这会让CPU在执行iret时使用该TSS的内容。设置ss0,esp0的意思是:该进程以后被中断或者trap时,只要进入kernel,就使用这个stack,上述代码表明trap时将使用进程的内核栈。然后切换到进程页目录,注意,因为kernel空间也映射进用户也目录了,所以kernel在切换页目录之后依然正常执行,此时仍然使用的是CPU栈。标记进程状态为RUNNING之后开始执行swtch,看swtch(&cpu->scheduler, p->context);。注意到swtch会将当前context相关寄存器进栈,然后将cpu的context(即scheduler)指针指向当前esp(仍然是cpu栈,即在内核的指令和数据内存空间上),再将esp指向到进程的context,由于会改变cpu的scheduler指针指向,因此需要传递&cpu->scheduler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//swtch.S
.globl swtch
swtch:
movl 4(%esp), %eax #load *(esp + 4) into eax, that is &cpu->scheduler, pointer's addr
movl 8(%esp), %edx #load *(esp + 8) into edx, that is p->context, pointer's value

# Save old callee-save registers #进入swtch函数之前,eip已经进栈
pushl %ebp
pushl %ebx
pushl %esi
pushl %edi

# Switch stacks
movl %esp, (%eax) #cpu->scheduler现在指向了当前cpu栈顶,保存了相关context寄存器
movl %edx, %esp #esp指向进程的context

# Load new callee-save registers
popl %edi
popl %esi
popl %ebx
popl %ebp
ret

swtch的作用是保存相关context寄存器到cpu栈,将当前cpu的scheduler指向保存位置,然后将栈顶指针指向进程的context(进程的内核栈),将相关寄存器弹出之后执行ret指令,由于此时esp指向进程context的eip,因此将从栈中弹出该数据作为eip。该eip实际指向forkret函数入口地址,不同于call指令,调用该函数不会将下一条指令的地址进栈。因此后面forkret启动了一个log之后返回时将继续弹栈作为新的eip,即trapret地址,见tramasm.S。

1
2
3
4
5
6
7
8
9
10
//trapasm.S
.globl trapret
trapret:
popal #弹出通用寄存器中的上下文环境
popl %gs
popl %fs
popl %es
popl %ds
addl $0x8, %esp # trapno and errcode
iret

popal弹出一堆寄存器值,其中部分寄存器值在userinit函数中被指定了,如cs。popl又弹出一堆,然后addl $0x8, %esp越过trapno和errcode,此时esp指向进程tf的eip。执行iret时会把int指令自动压栈的内容再自动恢复回去,这里是eip、cs、eflags、esp、ss。eip在userinit函数中被设置为0,此处已经被inituvm()放置上initcode.S的内容了;esp在userinit函数中被设置为PGSIZE,即进程分配到的物理地址空间的顶部,此处作为栈顶(由此可以看出esp的地址也是虚拟地址,将根据进程页目录和页表被转化为物理地址)。之后回到用户空间的起始地址处执行initcode.S的内容。这个文件通过系统调用exec执行/init程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//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

//exec指令用新程序替代当前进程的内存和寄存器,但保留文件描述符、进程id、父进程不变。如果一切运行正常,exec将不会返回到这里,它将执行一个新的名为$init的程序。init进程根据需要创建终端设备文件,以文件描述符0、1、2打开它,然后循环开启一个控制台shell、处理孤立僵尸进程直到shell退出,重复循环。整个系统启动。
# for(;;) exit();
exit:
movl $SYS_exit, %eax
int $T_SYSCALL
jmp exit

# char init[] = "/init\0";
init:
.string "/init\0"

# char *argv[] = { init, 0 };
.p2align 2
argv:
.long init
.long 0

$argv、$init、$0将被存储在进程物理空间顶部。movl $SYS_exec, %eax把trapno放到%eax里面,之后int的时候会跳转到alltraps,其会执行pushal将包括eax在内的通用寄存器进栈(其实是保存到proc->tf),之后在类似syscall函数就可以通过proc->tf->eax获取trapno了。此时的esp指向0x00000ff4,根据initcode.asm可知int指令的下一条指令地址(即执行int时的eip为)0x00000013。
CPU在执行int指令时会执行以下动作:
1、栈转换,stack转换到tr寄存器指定的tss里面的ss0:esp0,这是该进程对应的内核栈,esp指向内核栈栈顶。
2、自动将相关寄存器的内容进栈:ss、esp、eflag、cs、eip。
3、跳转到中断处理程序处(trap.c),也就是vectorXXX处,这里为每个中断设置了一个中断处理程序,xv6的中断处理表是用vector.pl生成的(make之后见vectors.S),基本每个表项都是一样的。
(上述动作还应包含触发中断时的权限检查。)

1
2
3
4
5
.globl vector64
vector64:
pushl $0
pushl $64
jmp alltraps

内核栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CPU执行int指令时自动压栈的,加上中断处理程序压栈
+---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| ss |
+---------------+
| esp | <-- 0x00000ff4(压进三个参数)
+---------------+
| eflags |
+---------------+
| cs |
+---------------+
| eip | <-- 0x00000013(int指令的下一条指令地址)
+---------------+
| err |
+---------------+
| trapno |
+---------------+ <-- %esp

跳转到alltrap处执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
  # vectors.S sends all traps here.
.globl alltraps
alltraps:
# Build trap frame. //将用户进程的寄存器保存
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal

# Set up data and per-cpu segments.
#此时已经由p->tf->esp指向的进程用户栈切换到ssX:espX指定的栈中,
#这里是p->kstack指向的进程内核栈,当前进程相关的寄存器保存到了进程内核栈中。
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 #push %esp的原因是将当前esp指向的tf结构基地址作为传递的参数
call trap
addl $4, %esp

# Return falls through to trapret...
.globl trapret
trapret:
#主要是弹出保存在ssX:espX指定的栈中的进程相关的寄存器,返回执行进程代码。
#第一次切换到用户态执行代码时,ssX:espX指定的栈中的进程相关的寄存器是在userinit函数中指定的。
popal #弹出通用寄存器中的上下文环境
popl %gs
popl %fs
popl %es
popl %ds
addl $0x8, %esp # trapno and errcode
iret

alltraps将进程相关的寄存器压入到ssX:espX指定的栈中,这里是p->kstack指向的进程内核栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
                 /   +---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| | ss |
| +---------------+
| | esp |
| +---------------+
| | eflags |
| +---------------+
| | cs |
| +---------------+
| | eip | <-- 这里往上在iret时自动弹出到相关寄存器中
| +---------------+
| | err |
| +---------------+
| | trapno |
| +---------------+
| | ds |
| +---------------+
| | es |
| +---------------+
| | fs |
struct trapframe | +---------------+
| | gs |
| +---------------+
| | eax |
| +---------------+
| | ecx |
| +---------------+
| | edx |
| +---------------+
| | ebx |
| +---------------+
| | oesp |
| +---------------+
| | ebp |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +---------------+ <-- %esp

然后执行pushl %esp为trap()函数准备参数,调用trap()函数,处理所有的中断、trap,参数是一个trapframe。完成后弹出之前压栈的参数,%esp又指向trapframe结构了。到此为止,系统调用的实质工作已经完成了,接下来就要返回用户态了,也就是trapret,把进程状态相关的寄存器恢复回去。iret指令把int自动压栈的内容再自动恢复回去,这样程序就回到eip处了,即中断发生的指令处。

系统调用的执行结果和参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//syscall.c
void
syscall(void)
{
int num;
num = proc->tf->eax;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
proc->tf->eax = syscalls[num]();
//cprintf("\n%s -> %d\n", syscall_name[num], proc->tf->eax);
} else {
cprintf("%d %s: unknown sys call %d\n",
proc->pid, proc->name, num);
proc->tf->eax = -1;
}
}

inidcode.S执行movl $SYS_exec, %eax把trapno放到%eax里面,之后int的时候会跳转到alltraps,其会执行pushal将包括eax在内的通用寄存器进栈(其实是保存到proc->tf),之后在syscall函数就可以通过proc->tf->eax获取trapno了。而系统调用的执行结果也放在了eax中。

1
2
3
4
5
6
7
//syscall.c
// 获取第n个32-bit参数
int
argint(int n, int *ip)
{
return fetchint(proc->tf->esp + 4 + 4*n, ip);
}

参考argint等函数,可知道系统调用的参数保存在tf->esp指向的进程用户栈中,userinit函数中将其指向进程数据和指令物理内存空间的顶端PAGESIZE,initcode传递的参数保存在这里。

以下是scheduler调度器调用swtch函数到initcode调用的第一个系统调用结束的栈变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//设置cpu环境后,加载选中进程的页目录地址到cr3,切换为进程的页目录。此时仍然使用的是cpu栈(即在内核的指令和数据内存空间上),且由于内核空间映射到了进程页目录中,所以仍可执行内核代码。
当前是swtch阶段,保存当前cpu的context,并将栈顶指针指向进程的context,以下是cpu栈:
+---------------+ <-- $(stack + KSTACKSIZE)(see in entry.S)
| ... |
+---------------+
| p->context |
+---------------+
|&cpu->scheduler|
+---------------+
| eip | <-- 调用swtch函数的下一条指令
+---------------+
| ebp | <-- see in swtch.S
+---------------+
| ebx |
+---------------+
| esi |
+---------------+
| edi |
+---------------+ <-- %esp
esp此时指向上面四个寄存器信息,现在改变cpu->scheduler指针的指向(指向位置仍然是cpu栈的某个单元),使其指向esp的位置。即让cpu的context变量保存了当前的cpu寄存器状态。
然后esp指向进程的context,其位于进程内核栈内。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//当前是swtch阶段,栈顶指针指向进程的context,将相关寄存器弹出之后执行ret指令,从栈中弹出数据作为eip,进程context的eip指向forkret函数入口地址。
//forkret启动了一个log之后返回时将继续弹栈作为新的eip(不同于call指令,调用该函数不会将下一条指令的地址进栈。),即trapret地址。转入trapret阶段,弹出已设定的进程状态相关寄存器,由内核态切换到用户态,执行iret指令后开始执行用户代码。这时候esp指针将指进程的用户栈,其位于所分配的进程指令和数据物理地址空间(一页)的顶端。以下是进程内核栈:

+---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| | ss | <-- (SEG_UDATA << 3) | DPL_USER;
| +---------------+
| | esp | <-- PGSIZE
| +---------------+
| | eflags | <-- FL_IF(allow hardware interrupts)
| +---------------+
| | cs | <-- (SEG_UCODE << 3) | DPL_USER;
| +---------------+
| | eip | <-- 0(init进程)/main函数入口(其他进程)
| +---------------+ <-- 这里往上在iret时自动弹出到相关寄存器中
| | err |
| +---------------+
| | trapno |
| +---------------+
| | ds | <-- (SEG_UDATA << 3) | DPL_USER;
| +---------------+
| | es | <-- (SEG_UDATA << 3) | DPL_USER;
| +---------------+
| | fs |
struct trapframe | +---------------+
| | gs |
| +---------------+ 第一次切换到用户态执行代码前,trapframe
| | eax |
| +---------------+ 的值是在userinit函数中指定的
| | ecx |
| +---------------+
| | edx |
| +---------------+
| | ebx |
| +---------------+
| | oesp |
| +---------------+
| | ebp |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +---------------+ <-- p->tf
| trapret | <-- 跳转到trapret,由allocproc()设置
/ +---------------+ <-- forkret返回
| | eip(=forkret) | <-- 由allocproc()设置
| +---------------+ <-- 执行ret指令,forkret启动log
| | ebp |
| +---------------+
struct context | | ebx |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +-------+-------+ <-- p->context, %esp
| | |
| | |
| empty |
+---------------+ <-- p->kstack
1
2
3
4
5
6
7
8
9
10
11
12
13
//当前是initcode阶段,esp指向了进程的用户栈,参数进栈,然后执行int指令调用系统调用。以下是进程的用户栈:

+---------------+ <-- stack base(PGSIZE)
| $argv |
+---------------+
| $init |
+---------------+
| $0 |
+-------+-------+ <-- %esp
| | |
| | |
| | |
+---------------+ <-- 0,initcode的代码放置在此
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//当前是int阶段,stack转换到tr寄存器指定的tss里面的ss0:esp0,这是该进程对应的内核栈。自动将进程相关寄存器的内容进栈:ss、esp、eflag、cs、eip,这样返回用户态之后才能恢复执行。然后跳转到中断处理程序处,将errcode和trapno压栈,再跳转到alltraps处。以下是进程的内核栈:

+---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| ss |
+---------------+
| esp | <-- 0x00000ff4(压进三个参数)
+---------------+
| eflags |
+---------------+
| cs |
+---------------+
| eip | <-- 0x00000013(int指令的下一条指令地址)
+---------------+
| err |
+---------------+
| trapno |
+---------------+ <-- %esp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//当前是alltraps阶段,将用户进程的寄存器保存到进程内核栈中,构成tramframe,由用户态切换到内核态。push %esp,将当前esp指向的tf结构基地址作为传递的参数,调用trap函数。以下是内核栈:

/ +---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| | ss |
| +---------------+
| | esp | <-- 0x00000ff4(压进三个参数)
| +---------------+
| | eflags |
| +---------------+
| | cs |
| +---------------+
| | eip | <-- 0x00000013(int指令的下一条指令地址)
| +---------------+
| | err |
| +---------------+
| | trapno |
| +---------------+
| | ds |
| +---------------+
| | es |
| +---------------+
| | fs |
struct trapframe | +---------------+
| | gs |
| +---------------+
| | eax |
| +---------------+
| | ecx |
| +---------------+
| | edx |
| +---------------+
| | ebx |
| +---------------+
| | oesp |
| +---------------+
| | ebp |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +---------------+
| esp |
+---------------+ <-- %esp

trap阶段将会使用内核栈,而系统调用所传递的参数将由p->tf->esp处取得,这个位置一开始指向了虚拟地址0xPAGE处,即进程的用户栈。系统调用的工作完成后,跳过之前压栈的参数esp,由内核态切换到用户态,弹出保存在ssX:espX指定的栈中的进程相关的寄存器,返回执行进程代码。
上述所有阶段关于栈的变化是:
切换到进程页目录/使用cpu栈–swtch切换到进程的内核栈–trapret根据设定切换内核态为用户态/使用进程的用户栈–initcode.S调用系统调用切换到进程的内核栈–alltraps保存进程寄存器并切换用户态为内核态–执行系统调用后trapret出栈进程相关的寄存器切换内核态为用户态/使用进程的用户栈

exec执行“/init”程序。

进入系统调用时,stack转换到tr寄存器指定的tss里面的ss0:esp0,即该进程对应的内核栈。exec将会执行新程序,替换掉当前执行的用户程序,执行“/init”程序具体如下:1、根据“/init”路径查找对应的inode;2、根据inode读取程序的elf信息;3、调用setupkvm()创建页目录,并映射内核空间(页目录项所存页表的权限是用户可读写,二级页表项所存物理页的权限按照kmap设定);4、根据inode和elf程序头表偏移量,按段读取程序。每次循环时:4.1、先读取段的程序头表项,程序头表项包含段的虚拟地址及段占据的内存大小,4.2、根据信息调用allocuvm()分配出物理空间,分配过程会将程序的虚拟地址映射到分配到的物理页地址上,二级页表项所存物理页的权限是用户可读写,4.3、根据程序头表项信息(段偏移及段的文件大小)读取段到内存中;5、继续为程序分配两页物理空间,二级页表项所存物理页的权限是用户可读写,取消第一页用户可读写权限,第二页将作为用户栈。6、构建用户栈数据(参数等,见下);7、更新进程的页目录、用户空间size、进程的tf->eip为elf.entry(main)、tf->esp为用户栈指针当前位置,然后调用switchuvm()函数设置cpu环境,加载进程的页目录地址到cr3,释放旧页目录空间;8、返回trapret处,切换为用户态,返回执行init程序代码,不返回initcode.S。

由此可以注意到,exec改变的有进程的页目录、用户空间内容、用户空间size、进程的tf->eip、tf->esp、用户栈、进程名,没有改变的有进程文件描述符、进程pid、父进程、内核栈、进程状态、当前目录等。

其他问题

进程的内核栈有没有设置访问权限?
所谓的访问权限指的是进程页目录、二级页表的权限。自始自终进程的内核栈都没有映射进页目录,所以不存在设置访问权限的问题,从进程用户的角度来讲,它是无法访问到进程的内核栈的,这是由内核维护的。

遗留的问题

各种寄存器的作用?

显示 Gitment 评论