1. 程式人生 > >XV6操作系統代碼閱讀心得(一):啟動加載、中斷與系統調用

XV6操作系統代碼閱讀心得(一):啟動加載、中斷與系統調用

tel 包括 sched main函數 結構 依次 bss comm 啟動

XV6操作系統是MIT 6.828課程中使用的教學操作系統,是在現代硬件上對Unix V6系統的重寫。XV6總共只有一萬多行,非常適合初學者用於學習和實踐操作系統相關知識。

MIT 6.828的課程網站是https://pdos.csail.mit.edu/6.828/。XV6操作系統有官方文檔,英文版在前面的網站可以下載,中文版翻譯參見https://th0ar.gitbooks.io/xv6-chinese/content/。

此部分內容另有PPT

前置知識

在閱讀XV6操作系統代碼前,需要熟練掌握C語言,了解有關X86體系結構的基本知識,操作系統相關的基本概念,以及關於編譯、鏈接相關的基本知識。關於相關理論知識,個人推薦的教材是文末的參考文獻[1]、[2]。此外,閱讀過程中可能遇到很多新概念,熟練掌握Google和Stack Overflow也是必須的。其中,尤其有用的資料是OS Dev Wiki和x86指令手冊。最後,推薦能熟練使用某種代碼編輯器,提升自己閱讀代碼的效率。

相關知識總結

1. 內核態與用戶態

在操作系統中,內核態指的是操作系統內核在運行時系統的狀態,在這個狀態下,內核程序具有訪問任何已有硬件和執行任何已有指令的權限;用戶態指的是用戶進程在執行時系統的狀態,在這個狀態下,用戶進程只能執行一部分指令,按照操作系統提供的系統調用來訪問硬件和與其他進程交互。將內核態與用戶態隔離是為了提升系統整體的安全性和健壯性,避免惡意進程和出錯進程破壞系統。

2. 中斷與系統調用

中斷是一種能讓操作系統響應外部硬件的機制,比如說,在一個用戶進程執行時,另一個用戶進程請求的磁盤文件加載完畢,那麽需要設計一個中斷信號來通知操作系統,暫停當前用戶進程,讓操作系統處理這個中斷事件;而系統調用則是使得用戶進程能夠陷入內核態,請求某種系統服務的機制,比如利用系統提供的syscall指令陷入內核,為進程完成需要內核權限的輸入輸出任務,然後返回用戶態,進程繼續執行。

計算機在運行時,通過CPU內某些寄存器的權限位來得知當前是處於內核態還是用戶態。比如,在x86系統中,CPU通過檢查%cs寄存器內的CPL位,來檢查當前指令的執行權限級別。在XV6系統中,CPL0代表內核態,CPL3代表用戶態。如果指令的執行權限不符合CPL位的值,那麽就會產生一個通用保護異常(General Protection Fault)。

3. ELF文件

ELF是Unix系統中主要被使用的可執行文件格式,詳細信息可以參考https://en.wikipedia.org/wiki/Executable_and_Linkable_Format。在bootmain()函數中,涉及到了ELF中兩個重要的概念,ELF Header和Program Header。ELF Header記錄了ELF文件相關的基本信息,其中包含一組Program Header,每個Program Header記錄ELF文件中的一段代碼或者數據的具體位置和大小等基本信息。Program Header所指向的ELF段包括.text .data等。bootmain()函數就是先從加載到內存0x10000地址處的ELF Header中獲得所有Program Header的信息,然後將這些Program段依次從磁盤加載到內存中。通過readelf命令,可以查看內核究竟有哪些Program Header,得到結果如下:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        80100000 001000 008111 00  AX  0   0  4
  [ 2] .rodata           PROGBITS        80108114 009114 000672 00   A  0   0  4
  [ 3] .stab             PROGBITS        80108786 009786 000001 0c  WA  4   0  1
  [ 4] .stabstr          STRTAB          80108787 009787 000001 00  WA  0   0  1
  [ 5] .data             PROGBITS        80109000 00a000 002596 00  WA  0   0 4096
  [ 6] .bss              NOBITS          8010b5a0 00c596 00715c 00  WA  0   0 32
  [ 7] .debug_line       PROGBITS        00000000 00c596 001f8c 00      0   0  1
  [ 8] .debug_info       PROGBITS        00000000 00e522 00a965 00      0   0  1
  [ 9] .debug_abbrev     PROGBITS        00000000 018e87 0026ed 00      0   0  1
  [10] .debug_aranges    PROGBITS        00000000 01b578 0003a0 00      0   0  8
  [11] .debug_loc        PROGBITS        00000000 01b918 002f30 00      0   0  1
  [12] .debug_str        PROGBITS        00000000 01e848 000cdc 01  MS  0   0  1
  [13] .comment          PROGBITS        00000000 01f524 00001c 01  MS  0   0  1
  [14] .debug_ranges     PROGBITS        00000000 01f540 000018 00      0   0  1
  [15] .shstrtab         STRTAB          00000000 01f558 0000a5 00      0   0  1
  [16] .symtab           SYMTAB          00000000 01f8d0 0023d0 10     17 138  4
  [17] .strtab           STRTAB          00000000 021ca0 0012d0 00      0   0  1

XV6系統的啟動過程

技術分享圖片

在源代碼中,XV6系統的啟動運行軌跡如圖。系統的啟動分為以下幾個步驟:

  1. 首先,在bootasm.S中,系統必須初始化CPU的運行狀態。具體地說,需要將x86 CPU從啟動時默認的Intel 8088 16位實模式切換到80386之後的32位保護模式;然後設置初始的GDT(詳細解釋參見https://wiki.osdev.org/Global_Descriptor_Table),將虛擬地址直接按值映射到物理地址;最後,調用bootmain.c中的bootmain()函數。

  2. bootmain()函數的主要任務是將內核的ELF文件從硬盤中加載進內存,並將控制權轉交給內核程序。具體地說,此函數首先將ELF文件的前4096個字節(也就是第一個內存頁)從磁盤裏加載進來,然後根據ELF文件頭裏記錄的文件大小和不同的程序頭信息,將完整的ELF文件加載到內存中。然後根據ELF文件裏記錄的入口點,將控制權轉交給XV6系統。

  3. entry.S的主要任務是設置頁表,讓分頁硬件能夠正常運行,然後跳轉到main.cmain()函數處,開始整個操作系統的運行。

  4. main()函數首先初始化了與內存管理、進程管理、中斷控制、文件管理相關的各種模塊,然後啟動第一個叫做initcode的用戶進程。至此,整個XV6系統啟動完畢。

XV6的操作系統的加載與真實情況有一些區別。首先,XV6操作系統作為教學操作系統,它的啟動過程是相對比較簡單的。XV6並不會在啟動時對主板上的硬件做全面的檢查,而真實的Bootloader會對所有連接到計算機的所有硬件的狀態進行檢查。此外,XV6的Boot loader足夠精簡,以至於能夠被壓縮到小於512字節,從而能夠直接將Bootloader加載進0x7c00的內存位置。真實的操作系統中,通常會有一個兩步加載的過程。首先將一個加載Bootloader的程序加載在0x7c00處,然後加載進完整的功能復雜的Bootloader,再使用Bootloader加載內核。

bootmain()函數詳解

void bootmain(void)
{
  struct elfhdr *elf;
  struct proghdr *ph, *eph;
  void (*entry)(void);
  uchar* pa;

  elf = (struct elfhdr*)0x10000;  // scratch space

  // Read 1st page off disk
  readseg((uchar*)elf, 4096, 0);

  // Is this an ELF executable?
  if(elf->magic != ELF_MAGIC)
    return;  // let bootasm.S handle error

  // Load each program segment (ignores ph flags).
  ph = (struct proghdr*)((uchar*)elf + elf->phoff);
  eph = ph + elf->phnum;
  for(; ph < eph; ph++){
    pa = (uchar*)ph->paddr;
    readseg(pa, ph->filesz, ph->off);
    if(ph->memsz > ph->filesz)
      stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz);
  }

  // Call the entry point from the ELF header.
  // Does not return!
  entry = (void(*)(void))(elf->entry);
  entry();
}

bootmain.c中的bootmain()函數是XV6系統啟動的核心代碼。bootmain()函數首先從磁盤中讀取第一個內存頁(11行);然後判斷讀取到的內存頁是否是ELF文件的開頭(14-15行);如果是的話,根據ELF文件頭內保存的每個程序頭和其長度信息,依次將程序讀入內存(18-25行);最後,從ELF文件頭內找到程序的入口點,跳轉到那裏執行(29-30行)。通過readelf命令可以得到ELF文件中程序頭的詳細信息。總而言之,boot loader在XV6系統的啟動中主要用來將內核的ELF文件從硬盤中加載進內存,並將控制權轉交給內核程序。

通過獲取struct elfhdrstruct proghdr的位置和大小信息(18-19行,elf->phoff elf->phnum),就能得知XV6內核程序段(Program Header)的位置和數量,在加載硬盤扇區的過程中,逐步向前移動ph指針,一個個加載對應的程序段。對於一個程序段,通過ph->fileszph->off獲得程序段的大小和位置,使用readseg()函數來加載程序段,逐步向前移動pa指針,直到加載進的磁盤扇區使得加載進的扇區大小超過程序文件的結尾epa,從而完成單個程序段的加載。對於單個內核程序段,代碼確保它會填滿最後一個內存頁。

XV6系統的中斷管理

1. 中斷描述符與中斷描述符表

中斷描述符表是X86體系結構中保護模式下用來存放中斷服務程序信息的數據結構,其中的條目被稱為中斷描述符。在XV6數據結構中,涉及的數據結構如下

// Gate descriptors for interrupts and traps
struct gatedesc {
  uint off_15_0 : 16;   // low 16 bits of offset in segment
  uint cs : 16;         // code segment selector
  uint args : 5;        // # args, 0 for interrupt/trap gates
  uint rsv1 : 3;        // reserved(should be zero I guess)
  uint type : 4;        // type(STS_{IG32,TG32})
  uint s : 1;           // must be 0 (system)
  uint dpl : 2;         // descriptor(meaning new) privilege level
  uint p : 1;           // Present
  uint off_31_16 : 16;  // high bits of offset in segment
};
struct gatedesc idt[256];
extern uint vectors[]; 

其中,struct gatedesc的格式與X86體系結構所要求的完全相同https://wiki.osdev.org/Interrupt_Descriptor_Table。對於第\(i\)條中斷描述符,CS寄存器存儲的是內核代碼段的段編號SEG_KCODE,offset部分存儲的是vector[i]的地址。在XV6系統中,所有的vector[i]地址均指向trapasm.S中的alltraps函數。

2. XV6中斷管理的初始化

由於中斷機制是由CPU硬件支持的,所以計算機在運行階段一開始時,BIOS就開啟並支持中斷。但是,在XV6系統的啟動過程中,第一條指令就使用cli指令來屏蔽中斷,直到第一個進程調度時才會在scheduler()裏使用STI指令允許硬件中斷。在允許硬件中斷之前,必須先配置好中斷描述符表,具體的實現在tvinit()和idtinit()函數中

void tvinit(void) {
  int i;

  for(i = 0; i < 256; i++)
    SETGATE(idt[i], 0, SEG_KCODE<<3, vectors[i], 0);
  SETGATE(idt[T_SYSCALL], 1, SEG_KCODE<<3, vectors[T_SYSCALL], DPL_USER);

  initlock(&tickslock, "time");
}
void idtinit(void) {
  lidt(idt, sizeof(idt));
}

在XV6系統中,只有中斷和系統調用機制可以實現用戶態到內核態的轉變。因此,即使是第一個用戶進程啟動時,XV6系統也會在內核態手動構建Trap Frame,設置Trap Frame中的CS寄存器上的相關權限位,然後調用中斷返回函數進入用戶態。XV6中的硬件中斷都是使用CTI和STI指令來進行開關。在實際的計算機中,中斷分為外部中斷和內部中斷。外部中斷包括來自外部IO設備的中斷、來自時鐘的中斷、斷電信號等,外部中斷又分為可屏蔽中斷和不可屏蔽中斷。對於內部中斷,包括由軟件調用INT指令觸發的中斷和由CPU內部錯誤(指令除零等)觸發的中斷。

3. XV6中斷處理過程舉例

以除零錯誤為例。當XV6的指令執行中遇到除零錯誤時,首先CPU硬件會發現這個錯誤,觸發中斷處理機制。在中斷處理機制中,硬件會執行如下步驟:

  1. 從IDT 中獲得第 n 個描述符,n 就是 int 的參數。
  2. 檢查CS的域 CPL <= DPL,DPL 是描述符中記錄的特權級。
  3. 如果目標段選擇符的 PL < CPL,就在 CPU 內部的寄存器中保存ESP和SS的值。
  4. 從一個任務段描述符中加載SS和ESP。
  5. 將SS壓棧。
  6. 將ESP壓棧。
  7. 將EFLAGS壓棧。
  8. 將CS壓棧。
  9. 將EIP壓棧。
  10. 清除EFLAGS的一些位。
  11. 設置CS和EIP為描述符中的值。

此時,由於CS已經被設置為描述符中的值(SEG_KCODE),所以此時已經進入了內核態,並且EIP指向了trapasm.S中alltraps函數的開頭。在alltrap函數中,系統將用戶寄存器壓棧,構建Trap Frame,並且設置數據寄存器段為內核數據段,然後跳轉到trap.c中的trap函數。在trap函數中,首先通過檢查中斷調用號,發現這不是一個系統調用,也不是一個外部硬件中斷,因此進入如下代碼段:

    if(myproc() == 0 || (tf->cs&3) == 0){
      // In kernel, it must be our mistake.
      cprintf("unexpected trap %d from cpu %d eip %x (cr2=0x%x)\n",
              tf->trapno, cpuid(), tf->eip, rcr2());
      panic("trap");
    }
    // In user space, assume process misbehaved.
    cprintf("pid %d %s: trap %d err %d on cpu %d "
            "eip 0x%x addr 0x%x--kill proc\n",
            myproc()->pid, myproc()->name, tf->trapno,
            tf->err, cpuid(), tf->eip, rcr2());
    myproc()->killed = 1;

根據觸發中斷的是內核態還是用戶進程,執行不同的處理。如果是用戶進程出錯了,那麽系統會殺死這個用戶進程;如果是內核進程出錯了,那麽在輸出一段錯誤信息後,整個系統進入死循環。

如果是一個可以修復的錯誤,比如頁錯誤,那麽系統會在處理完後返回trap()函數進入trapret()函數,在這個函數中恢復進程的執行上下文,讓整個系統返回到觸發中斷的位置和狀態。

4. 如何在XV6中添加新的系統調用(以setrlimit為例)

在Linux系統中,setrlimit系統調用的作用是設置資源使用限制。我們以setrlimit為例,要在XV6系統中添加一個新的系統調用,首先在syscall.h中添加一個新的系統調用的定義

#define SYS_setrlimit  22

然後,在syscall.c中增加新的系統調用的函數指針

static int (*syscalls[])(void) = {
        ...
    [SYS_setrlimit] sys_setrlimit,
};

當然現在sys_setrlimit這個符號還不存在,因此在sysproc.c中聲明並實現這個函數

int sys_setrlimit(int resource, const struct rlimit *rlim) {
    // set max memory for this process, etc
}

最後,在user.h中聲明setrlimit()這個函數系統調用函數的接口,並在usys.S中添加有關的用戶系統調用接口。

SYSCALL(setrlimit)

int setrlimit(int resource, const struct rlimit *rlim);

一些問題

1. 在中斷描述符表裏存放了一個CS寄存器的值,為什麽要有這個CS寄存器?

這個問題事實上涉及到了很多關於x86的底層實現的細節。在80386中,硬件對內存訪問支持保護模式,在32位保護模式中,CPU使用Global Descriptor Table來存儲有關內存段的信息,使用CS寄存器來存儲GDT的索引,通過這個方式來索引內存段的過程中,可以通過GDT中的相應位來設置這塊內存的權限。註意,這與操作系統的虛擬內存是相互獨立的兩個機制。對於XV6系統而言,GDT中只有5個描述符,分別是內核代碼段、內核數據段、用戶代碼段、用戶數據段和TSS,對應的定義如下

   // various segment selectors.
   #define SEG_KCODE 1  // kernel code
   #define SEG_KDATA 2  // kernel data+stack
   #define SEG_UCODE 3  // user code
   #define SEG_UDATA 4  // user data+stack
   #define SEG_TSS   5  // this process's task state

在中斷切換的時候,需要從用戶代碼段切換到內核代碼段,因此需要保存CS的值,在中斷返回的時候再彈出。此外,中斷描述符表中的CS寄存器的值指明了中斷處理程序應該使用的CS值,也就是對應的內存段。

2. 在從用戶態和內核態之間切換的時候,代碼的執行權限是如何被設置的?

代碼的執行權限由CS寄存器中的權限位標記。在中斷調用時,INT指令會保存原來的CS寄存器,讀入新的CS寄存器,從而維持中斷前後的代碼執行權限不變。對於第一個用戶進程的而言,需要在啟動前手動設置CS寄存器的相關權限位才行,具體的代碼片段如下

    p->tf->cs = (SEG_UCODE << 3) | DPL_USER;
    p->tf->ds = (SEG_UDATA << 3) | DPL_USER;

參考文獻

  1. Bryant, Randal E., O‘Hallaron David Richard, and O‘Hallaron David Richard. Computer systems: a programmer‘s perspective. Vol. 281. Upper Saddle River: Prentice Hall, 2003.
  2. Silberschatz, Abraham, Greg Gagne, and Peter B. Galvin. Operating system concepts. Wiley, 2018.

XV6操作系統代碼閱讀心得(一):啟動加載、中斷與系統調用