從零開始寫 OS 內核 - 第一個 kernel 線程

系列目錄

準備

這個項目系列到這裏差很少到一半了,前半部分的 segment,虛擬內存,中斷等,其實能夠看做只是準備工做。是的,咱們花了大量時間去準備這些基石工做,以致於到如今,整個所謂的 kernel 好像仍然處於一種「靜止「狀態,可能已經讓你以爲睏倦了。從本篇開始,這個 kernel 將會真正地」動「起來,開始搭建一個操做系統應有的核心能力,那就是任務管理。java

從用戶的角度來說,操做系統的本質功能就是爲他們運行任務,不然就難以稱之爲操做系統了。這是一個複雜的工程,萬事開頭難,因此做爲開始階段的本篇,會盡量地簡單,從運行一個單線程開始。在後面的篇章中,會逐漸進入多線程切換與管理,同步與競爭等問題,以及最終來到更上層的進程管理。git

線程

threadprocess 的相關概念應該不須要多解釋了,都是老生常談。在接下來的行文和代碼中,我會將 task 等同於 thread,二者混用,都表示線程;而 process 則是進程。shell

操做系統調度的對象是 thread,也是接下來須要討論和實現的核心概念。 也許 thread 聽上去很抽象,從本質上來講,它能夠歸結爲如下兩個核心要素:segmentfault

代碼 + stack

代碼控制它時間維度上的流轉,stack 則是它空間維度的依託,這二者構成了 thread 運行的核心。多線程

因此每一個 thread 都有它本身的 stack,例如運行在 kernel 態的一堆 threads,大概是這樣的格局:app

每一個 thread 運行在它本身的 stack 上,而操做系統則負責調度這些 threads 的啓停。從本質上說,自從咱們進入 kernel 的 main 函數運行到如今,也能夠歸爲一個 thread,它是一個引導。再日後,操做系統將建立更多的 threads,而且 CPU 將會在操做系統的控制下,在這些 threads 之間來回跳轉切換,其實質就是在這些 threads 各自所屬代碼(指令)和 stack 上進行跳轉切換。函數

建立 thread

本篇代碼主要在 src/task/thread.c,僅供參考。gitlab

首先創建 thread 結構 task_struct,或者叫 tcb_t,即 task control block佈局

struct task_struct {
  uint32 kernel_esp;
  uint32 kernel_stack;
  
  uint32 id;
  char name[32];

  // ...
};
typedef struct task_struct tcb_t;

這裏有兩個關鍵字段,是關於這個 thread 的 stack 信息:測試

  • kernel_esp
  • kernel_stack

每一個 thread 都以 page 爲單位分配 kernel stack 空間,Linux 好像是 2 pages,因此咱們也分配 2 pages,用 kernel_stack 字段指向它,這個字段後面再也不變化:

#define KERNEL_STACK_SIZE  8192

tcb_t* init_thread(char* name,
                   thread_func function,
                   uint32 priority,
                   uint8 is_user_thread) {
  // ...
  thread = (tcb_t*)kmalloc(sizeof(struct task_struct));
  // ...
  uint32 kernel_stack = (uint32)kmalloc_aligned(KERNEL_STACK_SIZE);
  for (int32 i = 0; i < KERNEL_STACK_SIZE / PAGE_SIZE; i++) {
    map_page(kernel_stack + i * PAGE_SIZE);
  }
  memset((void*)kernel_stack, 0, KERNEL_STACK_SIZE);
  thread->kernel_stack = kernel_stack;
  
  // ...
}

注意這裏分配了 2 pages 給 kernel_stack 後,馬上爲它創建了 physical 內存的映射。這是由於 page fault 做爲一箇中斷,它的處理是要在 kernel stack 上完成的,所以對 kernel stack 自己的訪問不能夠再觸發 page fault,因此這裏提早解決了這個問題。

另外一個字段 kernel_esp,標識的是當前這個 thread 在 kernel stack 上運行的 esp 指針。目前是 thread 建立階段,咱們須要初始化這個 esp,因此首先須要對整個 stack 的版圖作一個初始化。咱們爲 stack 定義以下結構:

struct switch_stack {
  // Switch context.
  uint32 edi;
  uint32 esi;
  uint32 ebp;
  uint32 ebx;
  uint32 edx;
  uint32 ecx;
  uint32 eax;

  // For thread first run.
  uint32 start_eip;
  void (*unused_retaddr);
  thread_func* function;
};

這個 stack 結構第一眼看上去可能比較奇怪,後面會慢慢展開解釋。它既是 thread 第一次啓動運行時的初始 stack,也是後面 multi-threads 上下文切換時的 stack,因此也能夠叫 context switch stack,或者 switch stack。咱們將它鋪設到剛纔分配的 2 pages 的 stack 空間上去:

switch stack 上方的虛線空間是預留的,這是之後做爲返回用戶空間用的 interrupt stack,暫時能夠無視。目前你只須要知道它的結構定義爲 interrupt_stack_t,也就是以前的 src/interrupt/interrupt.h 裏定義的 isr_params 這個結構,你能夠回顧一下中斷處理這一篇,它是中斷髮生時的 CPU 和操做系統壓棧,用於保存中斷 context 的。

因此整個 stack 的初始化,就是在最上方分配了一個 interrupt stack + switch stack 結構:

thread->kernel_esp = kernel_stack + KERNEL_STACK_SIZE -
    (sizeof(interrupt_stack_t) + sizeof(switch_stack_t));

因而 kernel_esp 就被初始化爲上圖標出的位置,實際上指向了 switch stack 這個結構。

接下來就是初始化這個 switch stack

switch_stack_t* switch_stack = (switch_stack_t*)thread->kernel_esp;

switch_stack->edi = 0;
switch_stack->esi = 0;
switch_stack->ebp = 0;
switch_stack->ebx = 0;
switch_stack->edx = 0;
switch_stack->ecx = 0;
switch_stack->eax = 0;

switch_stack->start_eip = (uint32)kernel_thread;
switch_stack->function = function;
  • 全部的通用寄存器初始化爲 0,由於這是 thread 第一次運行;
  • start_eip 是 thread 入口地址,設置爲 kernel_thread 這個函數;
  • function 是 thread 真正要運行的工做函數,它由 kernel_thread 函數來啓動運行;
static void kernel_thread(thread_func* function) {
  function();
  schedule_thread_exit(0);
}

這裏若是不明白的話能夠先接着往下看 thread 的運行,而後再來回顧。

運行 thread

建立 thread,而且運行 thread:

void test_thread() {
  monitor_printf("first thread running ...\n");
  while (1) {}
}

int main() {
  tcb_t* thread = init_thread(
      "test", test_thread, THREAD_DEFAULT_PRIORITY, false);

  asm volatile (
    "movl %0, %%esp; \
    jmp resume_thread": : "g" (thread->kernel_esp) : "memory");
}

測試代碼很簡單,建立了一個 thread,它要運行的函數是 test_thread,僅僅是打印一下。

這裏在 C 語言裏用內聯 asm 代碼,觸發了 thread 開始運行,來看一下它的原理。首先將 esp 寄存器賦值爲該 thread 的 kernel_esp,而後跳轉到 resume_thread 這個函數:

resume_thread:
  pop edi
  pop esi
  pop ebp
  pop ebx
  pop edx
  pop ecx
  pop eax
  
  sti
  ret

它實際上是 context_switch 函數的下半部分,這個是用於 multi-threads 切換的,這個下一篇再講。

來看 resume_thread 作的事情,對照圖中的 kernel_esp 位置開始,運行代碼:

  • 首先 pop 了全部通用寄存器,在 multi-threads 切換裏它是用來恢復 thread 的 context 數據的,可是如今 thread 是第一次運行,因此它們這裏全被 pop 成了 0;
  • 而後 ret 指令,使程序跳轉到了 start_eip 處,它被初始化爲爲函數 kernel_thread,從這裏 thread 正式開始運行,它的運行 stack 爲右圖中淺藍色部分;

注意到 kernel_thread 函數,傳入並運行了參數 function,這是 thread 真正的工做函數:

static void kernel_thread(thread_func* function) {
  function();
  schedule_thread_exit(0);
}

這裏可能有幾個問題須要解釋一下:

[問題 1] 爲何不直接運行 function,而要在外面嵌套一層 kernel_thread 函數做爲 wrapper?

由於 thread 運行結束後須要一個退出機制,函數 schedule_thread_exit 會完成 thread 的收尾和清理工做;schedule_thread_exit 函數不會返回,而是直接引導該 thread 消亡,而後切換到下一個待運行的 thread。關於 schedule_thread_exit 也會在下一篇中再細講。

[問題 2] 圖中灰色的 unused 部分是什麼?

它是 kernel_thread 函數的返回值,實際上 kernel_thread 不會返回,由於函數 schedule_thread_exit 不會返回。這裏 unused 僅僅是一個佔位。


OK,至此咱們的第一個 thread 就運行起來了,能夠看到它的打印:

總結

本篇運行了第一個 thread,它作的事情其實比較簡單,就是找了一塊內存作 stack,而後在上面創造出了一個函數運行的環境,而後跳轉指令到 thread 的入口處開始運行。可能有不少細節處仍是霧裏看花,不知其因此然,本篇都沒有詳細展開,例如:

  • 爲何要構建這樣的一個 stack 佈局?
  • thread 運行結束後的退出機制是什麼樣的?
  • 以及最重要的,thread 之間怎麼切換運行?

這些都留待下一篇詳解,待到下一篇完成後,結合這兩篇的內容,應該會對 threaad 的運行機制有一個全面的認識。

相關文章
相關標籤/搜索