iT邦幫忙

2022 iThome 鐵人賽

DAY 25
0

前言

前面我們提到了 Process 的概念,我們知道一個位於 user space 執行中的程式為 Process,而 Process 有虛擬記憶體的概念,也就是每一個 Process 有自己的記憶體分頁,從0到某一個最大值,而這個記憶體分頁會通過某些方式映射到物理記憶體分頁上,這部分會在後面的記憶體分頁內容中提及。並且我們知道如何使用與 Process 有關的 System call,諸如fork()exec()等等,而以下將針對 Process 的概念做進一步討論。

在一台電腦中時常一次會有多個 Process 中正在執行,而電腦一個核心 (hart) 只能執行一個 Process,核心數量有限,而要如何執行多個 Process 就成了一個重要的議題,我們必須讓 Process 有無限多 CPU 可以使用的錯覺,也就是如同記憶體虛擬化的概念 (使用page table實現),讓 Process 以為有全部的記憶體可以使用,也有無限多 cpu 資源可以使用,這一部分就會牽扯到 cpu 的虛擬化議題。

通過 CPU 的虛擬化,讓 Process 一次執行在一個 timeslice (這裡使用到 Round-Robin 進行排程) 上面,接著 hart 便會切換到其他的 Process,造成了多個 cpu 的錯覺,這是 time sharing 的技術,將資源由一個 Process 占用一段時間,接著由另外一個 Process 占用一段時間,如此下去讓資源被多個 Process 共享。而這其中會設計到一些優先度與調度策略的部分,哪一些 Process 需要優先取的資源等等。

Process

一個 Process 最簡單的概念就是一個正在執行中的程式,而 Process 有兩個重要的部分,一個是他的記憶體地址空間 (address space),另外一個為他所使用的暫存器以及暫存器狀態,像是 program counter,讓我們知道程式目前正在執行哪一個指令,stack pointer 或是 fram pointer 讓我們知道 function 的記憶體地址,執行順序,參數,以及回傳值等等。而一個 Process 也會需要周圍的 I/O 設備進行一些存取,前面有提及在 linux 中 I/O 設備會被看作是一個檔案,因此每一個 Process 可能會開啟一些檔案,有一區域紀錄這一些開啟的檔案描述子 (file descriptor),關於 Process 的記憶體分布,可以回顧之前提到的 memory layout in UNIX 的部分有提及。

前面我們也使用了一個 Process 所提供的 API,System call,包含fork()exec(),而我們下面將介紹一些 Process 的狀態以及一些細節,包含用於描述一個 Process 的結構 PCB (Process control block),process 的建立 (create),執行 (run),終止 (terminate)。

Create Process

在 xv6 的啟動與架構中,我們知道在 xv6 完成了一些分頁等等初始化配置之後,會執行第一個 Process,而我們可以思考 OS 是如何執行一隻程式的

作業系統需要從硬碟將程式以及一些數據載入到記憶體中,而在早期的作業系統中,會在一隻程式執行之前,先完成所有的載入操作,而在現代作業系統中,常常可以看見到作業系統在需要該數據的時候才會進行載入,而這部分的實作會涉及到一些記憶體分頁以及交換的相關細節。

在將一些資料以及程式載入到記憶體之後,作業系統還需要為 Process 分配一個 run-time stack (在 Memory layout in UNIX 中會提及 ),像是將 main()放入到 stack,argc, argv 等等參數放入參數區等等。而 Process 可能會需要更多個記憶體,這一些記憶體與heap所關聯,通過一些malloc()的機制 (在 xv6 中會介紹 kalloc() 分配記憶體空間),或是通過free()釋放這一些記憶體給作業系統使用。

process 結構

為了得知一個 Process 的狀態,我們使用了一種資料結構來管理每一個 Process,而這個帶有 Process 狀態的結構我們就稱為 Process Control Block, PCB,一個 PCB 需要有以下幾項資訊

  • 作業系統執行 process 所需要的資訊,包含執行的優先度,PID等等
  • 虛擬記憶體,打開的 I/O 裝置等等

在 linux 中 Process 的結構名稱為 task_struct,而在 xv6 中名稱為 struct proc。

而以下為 xv6 中 Process 相關的資料結構

struct proc {
  struct spinlock lock;

  // p->lock must be held when using these:
  enum procstate state;        // Process state
  void *chan;                  // If non-zero, sleeping on chan
  int killed;                  // If non-zero, have been killed
  int xstate;                  // Exit status to be returned to parent's wait
  int pid;                     // Process ID

  // wait_lock must be held when using this:
  struct proc *parent;         // Parent process

  // these are private to the process, so p->lock need not be held.
  uint64 kstack;               // Virtual address of kernel stack
  uint64 sz;                   // Size of process memory (bytes)
  pagetable_t pagetable;       // User page table
  struct trapframe *trapframe; // data page for trampoline.S
  struct context context;      // swtch() here to run process
  struct file *ofile[NOFILE];  // Open files
  struct inode *cwd;           // Current directory
  char name[16];               // Process name (debugging)
};

而套用到上面對於一個 PCB 所需要的資訊,概念上我們可以將以上資訊做以下分類

  • 作業系統相關 :
    • 作業系統管理與 Process 相關的訊息 kstackkstack 用來儲存 Process 在 kernel 中執行時,函式呼叫的狀態。
    • Process 的狀態state
    • 識別每一個 Process 的 id pid
    • Process 的親代 Process parent
    • 用於處理 trap 的區域,儲存 user space 底下 Process 的暫存器內容於 trapframe
    • 用於處理 trap 的區域,儲存 kernel space 底下 Process 的暫存器內容於 context
    • 與System call,sleep 和 kill 有關的 chan, killed
  • Process 本身相關 :
    • 關於虛擬記憶體,記憶體分頁的訊息szpgdir
    • 開啟的檔案ofile和目前所在得目錄cwd

Process 狀態

一個 Process 被建立時,作業系統會為其設定一個 Process 狀態。而通常狀態的設定操作如下。存取 proc 這個結構的某一個成員

p->state = RUNNING

如果一個 Process A 正在執行,那麼該 Process A 的狀態就是 RUNNING (RUNNING 和 NOTRUNNING 的差別在於是否佔有目前 CPU 的資源)。

但是如果 Process A 需要向硬碟存取資料,則目前的 Process A 需要釋放目前持有的 CPU 資源,讓其他 Process B 得以獲得 CPU 資源並執行,而這時候 Process B 就處於 WAITING 狀態,等待硬碟的資料準備完成。

同一時間,一個 CPU (hart, thread) 只能持有一個 Process (在 CPU 的結構描述中可以看到 CPU 結構指向到一個 proc 結構)。當一個 Process 正在使用某一個 CPU 的資源,其他 Process 需要處於 WAITING 狀態或是其他狀態,詳細 Process 之間的狀態轉換將於排程中詳細敘述。

一個 Process 會有許多種不同的狀態,在 xv6 中也有類似的實作,可以在kernel/proc.h中看見

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

下圖大致上的描述了 Process 之間狀態的轉換。

< XV6操作系统代码阅读心得(二):进程 >
可以看到總共有6種 process 的狀態 (詳細會在 thread 以及 schedule 中介紹)

  • UNUSED : 為初始化狀態,一個 process 處於建立時的狀態 (尚未分配記憶體分頁)。
  • USED : 當成功為 process 分配了記憶體空間 (通過 allocproc()進行分配,就會進入到 USED 狀態)。
  • SLEEPING : 表示 process 需要立即放棄對 CPU 的存取。
  • RUNNABLE : 表示一個 process 所需要的所有資源都已經處於可使用狀態,而 CPU 還不能夠使用 (可能被其他 process 所佔有)。
  • RUNNING : 表示一個 process 獲得 CPU 的資源開始執行。
  • ZOMBIE : 一個 process 處於已經退出了,但尚未清除與歸還資源給 OS 的狀態。

以下為其他狀態 (沒有在 xv6 中特別提及)

  • BLOCK : process 等待某一些事件發生,諸如等待 I/O 完成回應。
  • TERMINATE (EXIT) : process 意外或正常的結束工作。

xv6 啟動後第一個 Process

我們在一開始 xv6 的啟動與架構中,看到了記憶體分頁的設置以及一些中斷的機制,在一切準備就許之後,我們會開始我們第一個 Process,進入的函式為userinit(),我們試著追蹤了解整個 Process 是如何建立,以及過程中 Process 的狀態切換。

void
userinit(void)
{
  struct proc *p;

  p = allocproc();
  initproc = p;
  
  // allocate one user page and copy initcode's instructions
  // and data into it.
  uvmfirst(p->pagetable, initcode, sizeof(initcode));
  p->sz = PGSIZE;

  // prepare for the very first "return" from kernel to user.
  p->trapframe->epc = 0;      // user program counter
  p->trapframe->sp = PGSIZE;  // user stack pointer

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

  p->state = RUNNABLE;

  release(&p->lock);
}

首先在最一開始的時候會呼叫 allocproc()

allocproc() 分配記憶體分頁,但不包含 code, text, data

static struct proc*
allocproc(void)
{
  struct proc *p;

  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock);
    if(p->state == UNUSED) {
      goto found;
    } else {
      release(&p->lock);
    }
  }
  return 0;

found:
  p->pid = allocpid();
  p->state = USED;

  // Allocate a trapframe page.
  if((p->trapframe = (struct trapframe *)kalloc()) == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }

  // An empty user page table.
  p->pagetable = proc_pagetable(p);
  if(p->pagetable == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }

  // Set up new context to start executing at forkret,
  // which returns to user space.
  memset(&p->context, 0, sizeof(p->context));
  p->context.ra = (uint64)forkret;
  p->context.sp = p->kstack + PGSIZE;

  return p;
}

allocproc() 的作用為查看一個元素皆為 Process 的陣列,如果陣列中有 Process 處於 UNUSED,也就是尚未分配記憶體分頁時的狀態,則會進入到 found 對 Process 進行一些初始化的設定。

  • 使用 kalloc() 分配該 Process 的 trapframe 記憶體分頁。
  • 通過 proc_pagetable() 建立一個空的記憶體分頁 (處理映射,實體頁面在上方 kalloc() 處理),並建立 trampoline 和 trapframe 記憶體分頁的映射。
  • p->context 加入一些初始資訊,以及 return address 設定 (fork() 會呼叫 allocproc())。
  • 將該 Process 從 UNUSED 狀態設定成 USED 狀態,表示該 Process 已經完成初始設置,並且記憶體分頁已經分配完成。

接著回到 userinit(),接著會呼叫 uvmfirst()

uvmfirst() 設置一個 user page 以及將 initcode 移動到其中

void
uvmfirst(pagetable_t pagetable, uchar *src, uint sz)
{
  char *mem;

  if(sz >= PGSIZE)
    panic("uvmfirst: more than a page");
  mem = kalloc();
  memset(mem, 0, PGSIZE);
  mappages(pagetable, 0, PGSIZE, (uint64)mem, PTE_W|PTE_R|PTE_X|PTE_U);
  memmove(mem, src, sz);
}

uvmfirst 會根據使用者輸入的大小,建立一個實體物理記憶體分頁,並且映射到 Process 的 page table。initcode 會移動到 page table 中。

最後 userinit() 會設置 Process 的名稱,目前所在目錄等等,而上面的程式碼已經完成了 Process 的基本設置,包含記憶體分頁的建立,return address 設置等等,已經準備好被 CPU 執行了 (但是還沒有),這時候 Process 就會處於 RUNNABLE 的狀態了。

Overview 與 Process 相關的 System call

  • fork(): 建立一個新的 Process,為親代 Process 的子 Process,內容和親代 Process 相同。
  • exec(): 執行程式 (Program file),會替換掉當前 Process 的記憶體內容。
  • exit(): 終止 Process。
  • wait(): 等待子 Process 呼叫 exit()
  • kill(): 將 Process 設定成 killed 狀態。
  • sbrk(): 調整 Process 可用的記憶體空間 (在 lazy page allocation 有詳細敘述)

exit(),kill(),wait() 將會在後續排程中詳細提及。

reference

How does wait(), exit(), kill() work?
xv6-riscv
Operating System Concepts, 9/e
RISC-V xv6 Book
Introduction of Process Management


上一篇
Day-23 Spinlock 在 UART 使用與實作
下一篇
Day-25 xv6 Thread, Switch Thread
系列文
與作業系統的第一類接觸 : 探索 xv631
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言