ucore操做系統實驗筆記 - Lab1

最近一直都在跟清華大學的操做系統課程,這個課程最大的特色是有一系列能夠實戰的操做系統實驗。這些實驗總共有8個,我在這裏記錄實驗中的一些心得和總結。html

Task1

這個Task主要是爲了熟悉Makfile以及如何生成操做系統的鏡像文件。Makefile會用就好了,並不用太深刻的理解。git

Task2

這個Task主要是爲了熟悉GDB以及熟悉操做系統的啓動過程,下面是調試BIOS的一些過程。segmentfault

首先修改gdbinit爲:架構

set architecture i8086
target remote :1234
define hook-stop
x/i $pc
end

而後輸入函數

make debug

經過輸入ui

x/i $cs
x/i $eip

咱們能夠獲取當前 $cs$eip 的值。其中spa

$cs = 0xf000
$eip = 0xfff0

在實模式下,這個地址就是操作系統

$cs << 4 | $eip = 0xffff0

咱們也能夠看看這個地址的指令是什麼debug

x/2i 0xffff0

獲得的結果是調試

0xffff0:     ljmp   $0xf000,$0xe05b

也就是說,BIOS開始的地址應該是

$cs << 4 | 0xe05b = 0xfe05b

此時, 咱們設置一個斷點到0x7c00:

b *0x7c00 /* 注意,對於絕對地址來講,須要添加*將其做爲地址 */

而後當程序運行起來後, 最後會中止在 0x7c00 這個地址。這裏存放的即是bootloader了。

Task3

這個Taks是這5個Taks中最重要的一個。經過這個Task咱們能夠了解:如何開啓A20;CPU是如何從實模式轉換到保護模式;如何初始化和使用GDT表。

如何開啓/關閉 A20

實模式下內存的訪問

在開啓A20前,咱們先來講說i8086時CPU是如何訪問內存空間的。

在i8086時代,CPU的數據總線是16bit,地址總線是20bit,寄存器是16bit,所以CPU只能訪問1MB之內的空間。由於數據總線和寄存器只有16bit,若是須要獲取20bit的數據, 咱們須要作一些額外的操做,好比移位。實際上,CPU是經過對segment(每一個segment大小恆定爲64K) 進行移位後和offset一塊兒組成了一個20bit的地址,這個地址就是實模式下訪問內存的地址:

address = segment << 4 | offset

理論上,20bit的地址能夠訪問1MB的內存空間(0x00000 - (2^20 - 1 = 0xFFFFF))。但在實模式下, 這20bit的地址理論上能訪問從0x00000 - (0xFFFF0 + 0xFFFF = 0x10FFEF)的內存空間。也就是說,理論上咱們能夠訪問超過1MB的內存空間,但越過0xFFFFF後,地址又會回到0x00000。

上面這個特徵在i8086中是沒有任何問題的(由於它最多隻能訪問1MB的內存空間),但到了i80286/i80386後,CPU有了更寬的地址總線,數據總線和寄存器後,這就會出現一個問題: 在實模式下, 咱們能夠訪問超過1MB的空間,但咱們只但願訪問1MB之內的內存空間。爲了解決這個問題, CPU中添加了一個可控制A20地址線的模塊,經過這個模塊,咱們在實模式下將第20bit的地址線限制爲0,這樣CPU就不能訪問超過1MB的空間了。進入保護模式後,咱們再經過這個模塊解除對A20地址線的限制,這樣咱們就能訪問超過1MB的內存空間了。

A20開啓/關閉的過程

如今使用的CPU都是經過鍵盤控制器8042來控制A20地址線。默認狀況下,A20地址線是關閉的(第20bit的地址線限制爲0),所以在進入保護模式(須要訪問超過1MB的內存空間)前,咱們須要開啓A20地址線(第20bit的地址線可爲0或者1)。A20的開啓過程請參考bootasm.S文件。

CPU是如何從實模式轉換到保護模式

這個特別簡單,咱們須要在開啓A20地址線後,將$CR0(control register 0)的PE(bit0)置爲1就好了。具體代碼請參考bootasm.S文件。

如何初始化和使用GDT表

GDT詳解

在使用GDT前,咱們須要先來了解什麼是GDT。GDT全稱是Global Descriptor Table,也就是全局描述符表。在保護模式下,咱們經過設置GDT將內存空間被分割爲了一個又一個的segment(這些segment是能夠重疊的),這樣咱們就能實現不一樣的程序訪問不一樣的內存空間。
這和實模式下的尋址方式是不一樣的, 在實模式下咱們只能使用

address = segment << 4 | offset

的方式進行尋址(雖然也是segment + offset的,但在實模式下咱們並不會真正的進行分段)。在這種狀況下,任何程序都能訪問整個1MB的空間。而在保護模式下,經過分段的方式,程序並不能訪問整個內存空間。下面引用一段ucore實驗報告書上的說明:

【補充】保護模式下,有兩個段表:GDT(Global Descriptor Table)和LDT(Local Descriptor Table),每一張段表能夠包含8192 (2^13)個描述符[1],於是最多能夠同時存在2 * 2^13 = 2^14個段。雖然保護模式下能夠有這麼多段,邏輯地址空間看起來很大,但實際上段並不能擴展物理地址空間,很大程度上各個段的地址空間是相互重疊的。目前所謂的64TB(2^(14+32)=2^46)邏輯地址空間是一個理論值,沒有實際意義。在32位保護模式下,真正的物理空間仍然只有2^32字節那麼大。注:在ucore lab中只用到了GDT,沒有用LDT。

Reference: [1] 3.5.1 Segment Descriptor Tables, Intel® 64 and IA-32 Architectures Software Developer’s Manual

除了GDT, 咱們還須要瞭解另外幾個名詞:段描述符(segment descriptor)和段選擇子(segment selector)。段描述符就是GDT中的元素,段選擇子就是訪問GDT的索引。

段選擇子

在實模式下, 邏輯地址由段選擇子和段選擇子偏移量組成. 其中, 段選擇子16bit, 段選擇子偏移量是32bit. 下面是段選擇子的示意圖:

段選擇子示意圖

  1. 在段選擇子中,其中的INDEX[15:3]是GDT的索引。

  2. TI[2:2]用於選擇表格的類型,1是LDT,0是GDT。

  3. RPL[1:0]用於選擇請求者的特權級,00最高,11最低。

段描述符

段描述符的形式比較複雜(爲了兼容各類不一樣版本的CPU),這裏我只給一個示意圖,具體的內容請查找手冊。這裏用到的最重要的是segment base和segment limit:

段描述符示意圖

GDT的訪問

有了上面這些知識,咱們能夠來看看到底應該怎樣經過GDT來獲取須要訪問的地址了。咱們經過這個示意圖來說解:

GDT的訪問

  1. 咱們根據CPU給的邏輯地址分離出段選擇子。

  2. 利用這個段選擇子選擇一個段描述符。

  3. 將段描述符裏的Base Address和段選擇子的偏移量相加而獲得線性地址。這個地址就是咱們須要的地址。

GDT的初始化和使用

由於在保護模式下咱們須要使用分段的內存空間,所以在進入保護模式前,咱們就須要初始化GDT。 下面就經過一些代碼來講明如何初始化和使用GDT。

下面是GDT初始化的代碼:

#define SEG_NULLASM                                             \
    .word 0, 0;                                                 \
    .byte 0, 0, 0, 0

#define SEG_ASM(type,base,lim)                                  \
    .word (((lim) >> 12) & 0xffff), ((base) & 0xffff);          \
    .byte (((base) >> 16) & 0xff), (0x90 | (type)),             \
        (0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)

gdt:
    /* 有一個特殊的選擇子稱爲空(Null)選擇子,它的Index=0,TI=0,而RP
    L字段能夠爲任意值。空選擇子有特定的用途,當用空選擇子進行存儲訪
    問時會引發異常。空選擇子是特別定義的,它不對應於全局描述符表GDT
    中的第0個描述符,所以處理器中的第0個描述符總不被處理器訪問,一
    般把它置成全0。*/
    SEG_NULLASM                                     # null seg
    
    /* 在Lab1中, code segment和data segment均可以訪問整個內存空間 */
    SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)           # code seg for bootloader and kernel
    SEG_ASM(STA_W, 0x0, 0xffffffff)                 # data seg for bootloader and kernel

gdtdesc:
    /* lgdt 要先載入GDT的大小, 而後纔是gdt的地址 */
    .word 0x17                                      # sizeof(gdt) - 1
    .long gdt                                       # address gdt

理論上GDT能夠存在內存中任何位置,但這裏咱們是在實模式下初始化GDT的,所以GDT應該是存在最低的這1MB內存空間中。CPU經過lgdt指令讀入GDT的地址,以後咱們就可使用GDT了。

.set PROT_MODE_CSEG,        0x8   
.set PROT_MODE_DSEG,        0x10

/* 載入GDT */
lgdt gdtdesc

/* 從實模式切換到保護模式*/
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0

# ljmp <imm1>, <imm2>
# %cs ← imm1
# %ip ← imm2
/* 將%cs(code segment)的值設置爲0x8 */
ljmp $PROT_MODE_CSEG, $protcseg

...

protcseg:
    # Set up the protected-mode data segment registers
    /* 設置data segment 的值 */
    movw $PROT_MODE_DSEG, %ax                       # Our data segment selector
    movw %ax, %ds                                   # -> DS: Data Segment
    movw %ax, %es                                   # -> ES: Extra Segment
    movw %ax, %fs                                   # -> FS
    movw %ax, %gs                                   # -> GS
    movw %ax, %ss                                   # -> SS: Stack Segment

Task4

經過這個Task,咱們能夠了解OS是如何加載ELF鏡像文件的。這裏我並無仔細研究ELF文件格式以及如何使用。

Task5

這個task是爲了讓咱們瞭解函數的調用和堆棧的關係。對於函數調用的細節,我在以前的文章中已經寫過了,具體請參見C函數調用過程原理及函數棧幀分析。這裏主要分析下代碼,源代碼在 kern/debug/kdebug.c文件中。

/*
棧底方向      高位地址
...          
...          
參數3        
參數2        
參數1        
返回地址     
上一層[ebp]   <-------- [esp/當前ebp]
局部變量      低位地址
*/
void
print_stackframe(void) {
    uint32_t cur_ebp, cur_eip; 
    uint32_t args[4]; 
    cur_ebp = read_ebp();
    cur_eip = read_eip();
    
    /* 假設最多有20層的函數調用 */
    for (int stack_level = 0; stack_level < STACKFRAME_DEPTH + 1; stack_level++) {
        cprintf("ebp: 0x%08x eip: 0x%08x ", cur_ebp, cur_eip);
        
        /* 假設函數最多有4個參數 */
        for (int arg_num = 0; arg_num < 4; arg_num++)
            args[arg_num] = *((uint32_t *)cur_ebp + (2 + arg_num));
        cprintf("args:0x%08x 0x%08x 0x%08x 0x%08x\n", args[0], args[1], args[2], args[3]);
        print_debuginfo(cur_eip);
        
        /* 獲取上一層函數的返回地址和$ebp的值 */
        cur_eip = *((uint32_t *)cur_ebp + 1); 
        cur_ebp = *((uint32_t *)cur_ebp);  
    }
}

Task6

這個Task主要是爲了讓咱們熟悉保護模式下的中斷。在X86架構中,中斷能夠分爲3種:

  1. 和CPU無關的,好比外設的請求等,這些屬於Interrupt。

  2. 和CPU有關的,好比除0,page fault等,這些屬於Exception。

  3. 系統調用,這些屬於Trap

中斷機制

當CPU收到中斷(經過8259A完成)或者異常的事件時,它會暫停執行當前的程序或任務,經過必定的機制跳轉到負責處理這個信號的相關處理例程中,在完成對這個事件的處理後再跳回到剛纔被打斷的程序或任務中.

中斷向量和中斷服務例程

在X86架構中, 系統最多支持256種不一樣的中斷, 這些中斷都有一個相應的中斷向量與其對應. 每一箇中斷向量又有一個對應的中斷服務例程, 這個中斷服務例程用於處理中斷向量.

IDT

將中斷向量和中斷服務例程聯繫在一塊兒的是IDT(Interrupt Descriptor Table),輸入一箇中斷向量,咱們能夠找到並運行該中斷向量對應的中斷服務例程。IDT和GDT相似,每一個描述符都是8K,但IDT的第一項能夠包含一個描述符。IDT中的中斷描述符能夠分爲3種:

  1. Task Gate

  2. Interrupt Gate

  3. Trap Gate

在這個Lab中咱們使用了後兩種中斷描述符.

中斷描述符

Interrupt Gate和Trap Gate差很少,但有些微小的區別,我直接引用老師的說明:

【補充】所謂「自動禁止」,指的是CPU跳轉到interrupt gate裏的地址時,在將EFLAGS保存到棧上以後,清除EFLAGS裏的IF位,以免重複觸發中斷。在中斷處理例程裏,操做系統能夠將EFLAGS裏的IF設上,從而容許嵌套中斷。可是必須在此以前作好處理嵌套中斷的必要準備,如保存必要的寄存器等。二在ucore中訪問Trap Gate的目的是爲了實現系統調用。用戶進程在正常執行中是不能禁止中斷的,而當它發出系統調用後,將經過Trap Gate完成了從用戶態(ring 3)的用戶進程進了核心態(ring 0)的OS kernel。若是在到達OS kernel後禁止EFLAGS裏的IF位,第一沒意義(由於不會出現嵌套系統調用的狀況),第二還會致使某些中斷得不到及時響應,因此調用Trap Gate時,CPU則不會去禁止中斷。總之,interrupt gate和trap gate之間沒有優先級之分,僅僅是CPU在處理中斷時有不一樣的方法,供操做系統在實現時根據須要進行選擇。

根據實際需求,咱們創建相應的IDT,在創建好IDT後,咱們就須要告訴CPU咱們創建的IDT在哪裏。要實現這個目的,咱們須要使用一個專門的指令lidt將IDT的地址加載到IDTR寄存器中。這樣 CPU就經過這個寄存器即可以訪問IDT了。在IDTR寄存器中,咱們須要存入IDT的起始地址和大小。下面是IDTR寄存器的示意圖:

IDTR寄存器

中斷實例

我這裏經過該Task的代碼來講明如何創建IDT以及如何經過中斷向量來訪問相應的中斷服務例程。

創建中斷向量表

在這個lab中,中斷向量表是__vectors,該表的每一項存儲一箇中斷向量的地址。中斷服務例程在__alltraps中被調用。 __alltraps除了調用中斷服務例程外,還會作現場保護等工做。

# kern/trap/vectors.S
.globl vector0
vector0:
  pushl $0
  pushl $0
  jmp __alltraps
  ...
.globl vector255
vector255:
  pushl $0
  pushl $255
  jmp __alltraps

# vector table
.data
.globl __vectors
__vectors:
  .long vector0
  .long vector1
  .long vector2
  .long vector3
  ...
  .long vector255
  
# kern/trap/trapentry.S
.globl __alltraps
__alltraps:
    ...
    # push %esp to pass a pointer to the trapframe as an argument to trap()
    # 我這裏補充一下, 在call __alltraps 以前, $esp指向最後壓入的一個參數, 也就是interrupt number(好比pushl $255). 因此說這裏 pushl %esp 就是把 $255 在stack中的地址壓入stack做爲 trap() 的參數
    pushl %esp

    # call trap(tf), where tf=%esp
    call trap

創建IDT

在這個Lab中,前32箇中斷向量和T_SYSCALL使用的是Trap Gate;其他的中斷向量都是使用Interrupt Gate。

void
idt_init(void) {
    extern uintptr_t __vectors[]; 
    
    for (int i = 0; i < 256; i++) {
        if (i < IRQ_OFFSET) { 
            SETGATE(idt[i], 1, GD_KTEXT, __vectors[i], DPL_KERNEL); 
        } else if (i == T_SYSCALL) { 
            SETGATE(idt[i], 1, GD_KTEXT, __vectors[i], DPL_USER);
        } else { 
            SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
        }
    }

    lidt(&idt_pd);
}

中斷處理流程

下圖是一個簡化版的中斷處理流程:

  1. 當系統接收到中斷後, 會根據中斷類型產生一箇中斷向量。

  2. 用這個中斷向量做爲索引在IDT中找到相應的中斷描述符。

  3. 利用中斷描述符中的Segment Selector在GDT中找到相應的Segment。

  4. 將3中找到的Segment和中斷描述符中的Offset(也就是中斷向量表中存儲的中斷向量的地址)相加獲得中斷服務例程的地址。

  5. 調用這個中斷服務例程。

詳細的中斷處理過程請參考中斷與異常lab1中對中斷的處理實現.

中斷處理過程請

相關文章
相關標籤/搜索