xv6學習筆記(4) : 程式排程

周小倫發表於2021-08-23

xv6學習筆記(4) : 程式

xv6所有程式都是單程式、單執行緒程式。要明白這個概念才好繼續往下看

1. XV6中程式相關的資料結構

在XV6中,與程式有關的資料結構如下

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

與前述的兩類資訊的對應關係如下

  1. 作業系統管理程式有關的資訊:核心棧kstack,程式的狀態state,程式的pid,程式的父程式parent,程式的中斷幀tf,程式的上下文context,與sleepkill有關的chankilled變數。
  2. 程式本身執行所需要的全部環境:虛擬記憶體資訊szpgdir,開啟的檔案ofile和當前目錄cwd

額外地,proc中還有一條用於除錯的程式名字name

在作業系統中,所有的程式資訊struct proc都儲存在ptable中,ptable的定義如下

下面是proc結構體儲存的一些重要資料結構

  • 首先是儲存了使用者空間執行緒暫存器的trapframe欄位

  • 其次是儲存了核心執行緒暫存器的context欄位

  • 還有儲存了當前程式的核心棧的kstack欄位,這是程式在核心中執行時儲存函式呼叫的位置

  • state欄位儲存了當前程式狀態,要麼是RUNNING,要麼是RUNABLE,要麼是SLEEPING等等

  • lock欄位保護了很多資料,目前來說至少保護了對於state欄位的更新。舉個例子,因為有鎖的保護,兩個CPU的排程器執行緒不會同時拉取同一個RUNABLE程式並執行它

struct {
  struct spinlock lock;
  struct proc proc[NPROC];
} ptable;

除了互斥鎖lock之外,一個值得注意的一點是XV6系統中允許同時存在的程式數量是有上限的。在這裡NPROC為64,所以XV6最多隻允許同時存在64個程式。

要注意作業系統的資源分配的單位是程式,處理機排程的單位是執行緒;

2. 第一個使用者程式

1. userinit函式

main 初始化了一些裝置和子系統後,它通過呼叫 userinit建立了第一個程式。

userinit 首先呼叫 allocprocallocproc的工作是在頁表中分配一個槽(即結構體 struct proc),並初始化程式的狀態,為其核心執行緒的執行做準備。注意一點:userinit 僅僅在建立第一個程式時被呼叫,而 allocproc 建立每個程式時都會被呼叫。allocproc 會在 proc 的表中找到一個標記為 UNUSED的槽位。當它找到這樣一個未被使用的槽位後,allocproc 將其狀態設定為 EMBRYO,使其被標記為被使用的並給這個程式一個獨有的 pid(2201-2219)。接下來,它嘗試為程式的核心執行緒分配核心棧。如果分配失敗了,allocproc 會把這個槽位的狀態恢復為 UNUSED 並返回0以標記失敗。

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

  p = allocproc();
  
  initproc = p;
  if((p->pgdir = setupkvm()) == 0)
    panic("userinit: out of memory?");
  inituvm(p->pgdir, _binary_initcode_start, (int)_binary_initcode_size);
  p->sz = PGSIZE;
  memset(p->tf, 0, sizeof(*p->tf));
  p->tf->cs = (SEG_UCODE << 3) | DPL_USER;
  p->tf->ds = (SEG_UDATA << 3) | DPL_USER;
  p->tf->es = p->tf->ds;
  p->tf->ss = p->tf->ds;
  p->tf->eflags = FL_IF;
  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);
}

2. allocproc函式

  1. 在ptable中找到一個沒有被佔用的槽位
  2. 找到之後分配pid然後把他的狀態設定為EMBRYO
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;
}

這裡進行呼叫完之後得到的狀態如下圖所示

figure1-3

3. mpmain函式

// Common CPU setup code.
static void
mpmain(void)
{
  cprintf("cpu%d: starting %d\n", cpuid(), cpuid());
  idtinit();       // load idt register
  xchg(&(mycpu()->started), 1); // tell startothers() we're up
  scheduler();     // start running processes
}

1. scheduler()函式

這個函式是非常重要的,進行程式之間的排程,在上面我們建立了第一個使用者程式但是還沒有進行執行。

void
scheduler(void)
{
  struct proc *p;
  struct cpu *c = mycpu();
  c->proc = 0;
  
  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.
      c->proc = p;
      switchuvm(p);
      p->state = RUNNING;

      swtch(&(c->scheduler), p->context);
      switchkvm();

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

  }
}

2. switchuvm函式

  1. 這裡要設定當前cpu的taskstate。關於taskstate的知識補充

taskstate的知識補充

  1. 對於cpu而言是沒有程式或者執行緒的概念,對於cpu只有任務的概念
  2. 對於ss0是儲存的0環的棧段選擇子
  3. 對於esp是儲存的0環的棧指標
  4. 而對於ring的概念也就是環的概念這裡可以簡單理解成特權集參考部落格
image-20210822224911938
// Switch TSS and h/w page table to correspond to process p.
void
switchuvm(struct proc *p)
{
  if(p == 0)
    panic("switchuvm: no process");
  if(p->kstack == 0)
    panic("switchuvm: no kstack");
  if(p->pgdir == 0)
    panic("switchuvm: no pgdir");

  pushcli();
  mycpu()->gdt[SEG_TSS] = SEG16(STS_T32A, &mycpu()->ts,
                                sizeof(mycpu()->ts)-1, 0);
  mycpu()->gdt[SEG_TSS].s = 0;
  mycpu()->ts.ss0 = SEG_KDATA << 3;
  mycpu()->ts.esp0 = (uint)p->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
  mycpu()->ts.iomb = (ushort) 0xFFFF;
  ltr(SEG_TSS << 3);
  lcr3(V2P(p->pgdir));  // switch to process's address space
  popcli();
}

3. 第一個程式Initcode.S

第一個程式會在虛擬地址[0-pagesize]這一段

# exec(init, argv)
.globl start
start:
  pushl $argv
  pushl $init
  pushl $0  // where caller pc would be
  movl $SYS_exec, %eax
  int $T_SYSCALL

# 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

這裡是呼叫了exec執行init函式

這個其實更像什麼,更像shell終端的啟動

int
main(void)
{
  int pid, wpid;

  if(open("console", O_RDWR) < 0){
    mknod("console", 1, 1);
    open("console", O_RDWR);
  }
  dup(0);  // stdout
  dup(0);  // stderr

  for(;;){
    printf(1, "init: starting sh\n");
    pid = fork();
    if(pid < 0){
      printf(1, "init: fork failed\n");
      exit();
    }
    if(pid == 0){
      exec("sh", argv);
      printf(1, "init: exec sh failed\n");
      exit();
    }
    while((wpid=wait()) >= 0 && wpid != pid)
      printf(1, "zombie!\n");
  }
}

4. 程式切換

程式切換解決之後,對於xv6的程式排程就會有一個比較清晰的分析了

figure5-1

這裡有幾個重要的概念就是

  • 每一個程式都有一個對應的核心執行緒(也就是scheduler thread)執行緒。

  • 在xv6中想要從一個程式(當然這裡叫執行緒也是無所謂的)切換到另一個執行緒中,必須要先從當前程式-->當前程式的核心執行緒-->目的執行緒的核心執行緒-->目的執行緒的使用者程式。這樣一個過程才能完成排程

1. 先從yied和sched開始

其實yield函式並沒有幹很多事情,關於?的操作後面會單獨來講一下,這裡就先跳過去

這個函式就是當前程式要讓出cpu。所以把當前proc()的狀態設定成RUNNABLE

最後呼叫sched()

// Give up the CPU for one scheduling round.
void
yield(void)
{
  acquire(&ptable.lock);  //DOC: yieldlock
  myproc()->state = RUNNABLE;
  sched();
  release(&ptable.lock);
}

這裡先進行一些狀態判斷,如果出問題就會panic。

2. 隨後呼叫swtch函式

其實這個函式就是switch這裡為了不與c語言中的庫函式同名

void
sched(void)
{
  int intena;
  struct proc *p = myproc();

  if(!holding(&ptable.lock))
    panic("sched ptable.lock");
  if(mycpu()->ncli != 1)
    panic("sched locks");
  if(p->state == RUNNING)
    panic("sched running");
  if(readeflags()&FL_IF)
    panic("sched interruptible");
  intena = mycpu()->intena;
  swtch(&p->context, mycpu()->scheduler);
  mycpu()->intena = intena;
}

swtch函式就是傳說中的上下文切換。只不過和之前說的使用者狀態的上下文切換不一樣

這裡是把當前cpu的核心執行緒的暫存器儲存到p->context

這裡的(esp + 4)儲存的就是edi暫存器的值。而(esp + 8)儲存的就是esi暫存器的值,也就是第一個引數和第二個引數

.globl swtch
swtch:
  movl 4(%esp), %eax
  movl 8(%esp), %edx

  # Save old callee-saved registers
  pushl %ebp
  pushl %ebx
  pushl %esi
  pushl %edi

  # Switch stacks
  movl %esp, (%eax)
  movl %edx, %esp

  # Load new callee-saved registers
  popl %edi
  popl %esi
  popl %ebx
  popl %ebp
  ret
image-20210823173722313

所以這裡最後就會把mycpu()->scheduler中儲存的context資訊彈出到暫存器中。同時把esp暫存器更換成mycpu()->scheduler那裡。所以這裡的ret的返回地址就是mycpu()->scheduler儲存的eip的值。也就會返回到

image-20210823183713981

紅色箭頭所指向的一行。

3. 回到scheduler函式

現在我們在scheduler函式的迴圈中,程式碼會檢查所有的程式並找到一個來執行。隨後再來呼叫swtch函式

又呼叫了swtch函式來儲存排程器執行緒的暫存器,並恢復目標程式的暫存器(注,實際上恢復的是目標程式的核心執行緒)

這裡有件事情需要注意,排程器執行緒呼叫了swtch函式,但是我們從swtch函式返回時,實際上是返回到了對於switch的另一個呼叫,而不是排程器執行緒中的呼叫。我們返回到的是pid為目的程式的程式在很久之前對於switch的呼叫。這裡可能會有點讓人困惑,但是這就是執行緒切換的核心。

4. 回到使用者空間

最後的返回是利用了trapret

# Return falls through to trapret...
.globl trapret
trapret:
  popal
  popl %gs
  popl %fs
  popl %es
  popl %ds
  addl $0x8, %esp  # trapno and errcode
  iret

這個函式把儲存的trapframe恢復。最後通過iret恢復到使用者空間

5. 看一下fork、wait、exit函式

1. fork函式

  1. 建立一個程式
  2. 把父程式的頁表copy過來(這裡還不是cow方式的)
  3. 這裡比較重要的點是先加鎖。然後把子程式的狀態設定成runnable。如果在解鎖之前子程式就被排程的話。那返回值就是利用tf->eax來獲取
  4. 否則的話解鎖return父程式的pid,表示從父程式返回
// Create a new process copying p as the parent.
// Sets up stack to return as if from system call.
// Caller must set state of returned proc to RUNNABLE.
int
fork(void)
{
  int i, pid;
  struct proc *np;
  struct proc *curproc = myproc();

  // Allocate process.
  if((np = allocproc()) == 0){
    return -1;
  }

  // Copy process state from proc.
  if((np->pgdir = copyuvm(curproc->pgdir, curproc->sz)) == 0){
    kfree(np->kstack);
    np->kstack = 0;
    np->state = UNUSED;
    return -1;
  }
  np->sz = curproc->sz;
  np->parent = curproc;
  *np->tf = *curproc->tf;

  // Clear %eax so that fork returns 0 in the child.
  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));

  pid = np->pid;

  acquire(&ptable.lock);

  np->state = RUNNABLE;

  release(&ptable.lock);

  return pid;
}

2. wait函式

  1. 如果找到了處於ZOMBIE狀態子程式會把他釋放掉。(分別釋放對於的pid、核心棧、頁表)
  2. 否則如果沒有子程式則return -1
  3. 否則呼叫slepp函式等待
// Wait for a child process to exit and return its pid.
// Return -1 if this process has no children.
int
wait(void)
{
  struct proc *p;
  int havekids, pid;
  struct proc *curproc = myproc();
  
  acquire(&ptable.lock);
  for(;;){
    // Scan through table looking for exited children.
    havekids = 0;
    for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
      if(p->parent != curproc)
        continue;
      havekids = 1;
      if(p->state == ZOMBIE){
        // Found one.
        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;
        release(&ptable.lock);
        return pid;
      }
    }

    // No point waiting if we don't have any children.
    if(!havekids || curproc->killed){
      release(&ptable.lock);
      return -1;
    }

    // Wait for children to exit.  (See wakeup1 call in proc_exit.)
    sleep(curproc, &ptable.lock);  //DOC: wait-sleep
  }
}

sleep函式會在後面講鎖的時候去看

3. exit函式

  1. 首先exit函式關閉了所有已開啟的檔案。這裡可能會很複雜,因為關閉檔案系統中的檔案涉及到引用計數,雖然我們還沒學到但是這裡需要大量的工作。不管怎樣,一個程式呼叫exit系統呼叫時,會關閉所有自己擁有的檔案。
  2. 程式有一個對於當前目錄的記錄,這個記錄會隨著你執行cd指令而改變。在exit過程中也需要將對這個目錄的引用釋放給檔案系統。
  3. 如果這個想要退出的程式,它又有自己的子程式,接下來需要設定這些子程式的父程式為init程式。我們接下來會看到,每一個正在exit的程式,都有一個父程式中的對應的wait系統呼叫。父程式中的wait系統呼叫會完成程式退出最後的幾個步驟。所以如果父程式退出了,那麼子程式就不再有父程式,當它們要退出時就沒有對應的父程式的wait。所以在exit函式中,會為即將exit程式的子程式重新指定父程式為init程式,也就是PID為1的程式。
  4. 最後把要exit的程式狀態設定成ZOMBIE
  5. 執行sched函式重新回到核心執行緒。。。找新的執行緒去執行
void
exit(void)
{
  struct proc *curproc = myproc();
  struct proc *p;
  int fd;

  if(curproc == initproc)
    panic("init exiting");

  // Close all open files.
  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);

  // Parent might be sleeping in wait().
  wakeup1(curproc->parent);

  // Pass abandoned children to init.
  for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
    if(p->parent == curproc){
      p->parent = initproc;
      if(p->state == ZOMBIE)
        wakeup1(initproc);
    }
  }

  // Jump into the scheduler, never to return.
  curproc->state = ZOMBIE;
  sched();
  panic("zombie exit");
}

4. kill函式

最後我想看的是kill系統呼叫。Unix中的一個程式可以將另一個程式的ID傳遞給kill系統呼叫,並讓另一個程式停止執行。如果我們不夠小心的話,kill一個還在核心執行程式碼的程式,會有風險,比如我們想要殺掉的程式的核心執行緒還在更新一些資料,比如說更新檔案系統,建立一個檔案。如果這樣的話,我們不能就這樣殺掉程式,因為這樣會使得一些需要多步完成的操作只執行了一部分。所以kill系統呼叫不能就直接停止目標程式的執行。實際上,在XV6和其他的Unix系統中,kill系統呼叫基本上不做任何事情。

// Kill the process with the given pid.
// Process won't exit until it returns
// to user space (see trap in trap.c).
int
kill(int pid)
{
  struct proc *p;

  acquire(&ptable.lock);
  for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
    if(p->pid == pid){
      p->killed = 1;
      // Wake process from sleep if necessary.
      if(p->state == SLEEPING)
        p->state = RUNNABLE;
      release(&ptable.lock);
      return 0;
    }
  }
  release(&ptable.lock);
  return -1;
}

相關文章