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
89int
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->tf和p->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
16CPU执行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  | //syscall.c  | 
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  | //设置cpu环境后,加载选中进程的页目录地址到cr3,切换为进程的页目录。此时仍然使用的是cpu栈(即在内核的指令和数据内存空间上),且由于内核空间映射到了进程页目录中,所以仍可执行内核代码。  | 
1  | //当前是swtch阶段,栈顶指针指向进程的context,将相关寄存器弹出之后执行ret指令,从栈中弹出数据作为eip,进程context的eip指向forkret函数入口地址。  | 
1  | //当前是initcode阶段,esp指向了进程的用户栈,参数进栈,然后执行int指令调用系统调用。以下是进程的用户栈:  | 
1  | //当前是int阶段,stack转换到tr寄存器指定的tss里面的ss0:esp0,这是该进程对应的内核栈。自动将进程相关寄存器的内容进栈:ss、esp、eflag、cs、eip,这样返回用户态之后才能恢复执行。然后跳转到中断处理程序处,将errcode和trapno压栈,再跳转到alltraps处。以下是进程的内核栈:  | 
1  | //当前是alltraps阶段,将用户进程的寄存器保存到进程内核栈中,构成tramframe,由用户态切换到内核态。push %esp,将当前esp指向的tf结构基地址作为传递的参数,调用trap函数。以下是内核栈:  | 
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、父进程、内核栈、进程状态、当前目录等。  
其他问题
进程的内核栈有没有设置访问权限?
所谓的访问权限指的是进程页目录、二级页表的权限。自始自终进程的内核栈都没有映射进页目录,所以不存在设置访问权限的问题,从进程用户的角度来讲,它是无法访问到进程的内核栈的,这是由内核维护的。
遗留的问题
各种寄存器的作用?