ucoreOS_lab1 實驗報告

ucoreOS_lab1 實驗報告

由於我個人不太懂 AT&T 語法,在完成實驗的過程中遇到了相當大的阻礙,甚至有點懷疑人生,我是否心太大了,妄想在短時間內學懂大清的課程。ucoreOS_lab1 這個實驗前前後後做到了現在才勉強完成,後來又花了兩天時間,寫完了這份9000餘字的報告。網上的資料參差不齊,很難有一份適合我這種新手(菜雞)的詳細的實驗過程,無奈只有自己狠下心來,完成了這篇實驗報告,雖然只是一篇小小的實驗報告,卻涵蓋了我是如何一步步摸索這一艱辛的實驗過程,如果文中有不合理之處,歡迎指出,共同學習,共同進步。若圖片失效,請移步至Github:https://github.com/AngelKitty/review_the_national_post-graduate_entrance_examination/blob/master/books_and_notes/professional_courses/operating_system/sources/ucore_os_lab/docs/lab_report/lab1/lab1%20%E5%AE%9E%E9%AA%8C%E6%8A%A5%E5%91%8A.md,所有的實驗報告將會在Github逐步更新

練習1:理解通過make生成執行文件的過程

問題1:操作系統鏡像文件ucore.img是如何一步一步生成的?

進入 /home/moocos/ucore_lab/labcodes_answer/lab1_result 目錄下

執行 make "V=", 觀察生成 ucore.img 的過程

如果當前目錄已有 /bin/ 目錄和 /obj/ 目錄,我們先去執行 make clean ,再執行 make "V=" 觀察 ucore.img 的生成過程。

核心的打印結果如下:

# 構建bin/kernel
+ cc kern/init/init.c
+ cc kern/libs/readline.c
+ cc kern/libs/stdio.c
+ cc kern/debug/kdebug.c
+ cc kern/debug/kmonitor.c
+ cc kern/debug/panic.c
+ cc kern/driver/clock.c
+ cc kern/driver/console.c
+ cc kern/driver/intr.c
+ cc kern/driver/picirq.c
+ cc kern/trap/trap.c
+ cc kern/trap/trapentry.S
+ cc kern/trap/vectors.S
+ cc kern/mm/pmm.c
+ cc libs/printfmt.c
+ cc libs/string.c
+ ld bin/kernel
# 構建sign工具與bin/bootblock
+ cc boot/bootasm.S
+ cc boot/bootmain.c
+ cc tools/sign.c
# 使用gcc編譯器由tools/sign.c生成可執行文件bin/sign
    gcc -Itools/ -g -Wall -O2 -c tools/sign.c -o obj/sign/tools/sign.o
    gcc -g -Wall -O2 obj/sign/tools/sign.o -o bin/sign
# 使用ld命令鏈接/boot/bootasm.o、obj/boot/bootmain.o到obj/bootblock.o
+ ld bin/bootblock
    ld -m    elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o 
    obj/boot/bootmain.o -o obj/bootblock.o
    'obj/bootblock.out' size: 472 bytes
    build 512 bytes boot sector: 'bin/bootblock' success!
# 構建ucore.img
dd if=/dev/zero of=bin/ucore.img count=10000 # 使用dd工具創建一個bin/ucore.img空文件
10000+0 records in
10000+0 records out
5120000 bytes (5.1 MB) copied, 0.0456474 s, 112 MB/s
dd if=bin/bootblock of=bin/ucore.img conv=notrunc # 使用dd工具將文件bin/bootblock寫入bin/ucore.img, 參數conv=notrunc表示不截斷輸出文件
1+0 records in
1+0 records out
512 bytes (512 B) copied, 0.00281044 s, 182 kB/s
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc # 使用dd工具將文件bin/kernel寫入bin/ucore.img起始的1個block後,即bootblock後, 參數seek=1表示從輸出文件開頭跳過1個block開始寫入
138+1 records in
138+1 records out
70775 bytes (71 kB) copied, 0.000473867 s, 149 MB/s

由以上過程可知

  • 編譯16個內核文件,構建出內核bin/kernel
  • 生成 bin/bootblock 引導程序
    • 編譯bootasm.S,bootmain.c,鏈接生成obj/bootblock.o
    • 編譯sign.c生成sign.o工具
    • 使用sign.o工具規範化bootblock.o,生成bin/bootblock引導扇區
  • 生成 ucore.img 虛擬磁盤
    • dd初始化ucore.img5120000 bytes,內容爲0的文件
    • dd拷貝bin/bootblockucore.img第一個扇區
    • dd拷貝bin/kernelucore.img第二個扇區往後的空間

問題2:一個被系統認爲是符合規範的硬盤主引導扇區的特徵是什麼?

根據問題1可知通過sign.c文件的操作使得bootblock.o成爲一個符合規範的引導扇區,因此查看sign.c的內容,如下所示:

#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <sys/stat.h>

int main(int argc, char *argv[]) {
    struct stat st;
    // 輸入狀態判斷
    if (argc != 3) {
        fprintf(stderr, "Usage: <input filename> <output filename>\n");
        return -1;
    }
    // 讀取文件頭
    if (stat(argv[1], &st) != 0) {
        fprintf(stderr, "Error opening file '%s': %s\n", argv[1], strerror(errno));
        return -1;
    }
    // 問題1中輸出的文件大小
    printf("'%s' size: %lld bytes\n", argv[1], (long long)st.st_size);
    // 文件大小超過510字節報錯返回,因爲最後2個字節要用作結束標誌位
    if (st.st_size > 510) {
        fprintf(stderr, "%lld >> 510!!\n", (long long)st.st_size);
        return -1;
    }
    // 多餘位用0填充
    char buf[512];
    memset(buf, 0, sizeof(buf));
    FILE *ifp = fopen(argv[1], "rb");
    int size = fread(buf, 1, st.st_size, ifp);
    // 文件實際大小需和文件頭描述一致
    if (size != st.st_size) {
        fprintf(stderr, "read '%s' error, size is %d.\n", argv[1], size);
        return -1;
    }
    fclose(ifp);
    buf[510] = 0x55;
    buf[511] = 0xAA;
    // 寫入結束位
    FILE *ofp = fopen(argv[2], "wb+");
    size = fwrite(buf, 1, 512, ofp);
    if (size != 512) {
        fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size);
        return -1;
    }
    fclose(ofp);
    printf("build 512 bytes boot sector: '%s' success!\n", argv[2]);
    return 0;
}

由以上代碼可知,硬盤主引導扇區特徵爲:

  • 大小爲512字節,空餘部分用0填充
  • 文件內容不超過510 bytes
  • 最後2 bytes0x55 0xAA

練習2:使用qemu執行並調試lab1中的軟件

  1. 從CPU加電後執行的第一條指令開始,單步跟蹤BIOS的執行。
  2. 在初始化位置0x7c00設置實地址斷點,測試斷點正常。
  3. 從0x7c00開始跟蹤代碼運行,將單步跟蹤反彙編得到的代碼與bootasm.S和 bootblock.asm進行比較。
  4. 自己找一個bootloader或內核中的代碼位置,設置斷點並進行測試。

我們可以先看看 Makefile 文件裏面都需要幹哪些事情。

我們在 /home/moocos/ucore_lab/labcodes_answer/lab1_result 目錄下使用 less Makefile 命令去瀏覽 Makefile 文件中的內容,通過 /lab1-mon 去定位到相應行數的代碼(這裏我們是201行)。

lab1-mon: $(UCOREIMG)
                $(V)$(TERMINAL) -e "$(QEMU) -S -s -d in_asm -D $(BINDIR)/q.log -monitor stdio -hda $< -serial null" -g -monitor stdio -hda $< -serial null"
                $(V)sleep 2
                $(V)$(TERMINAL) -e "gdb -q -x tools/lab1init"

我們可以看到這條命令大概幹了兩件事情:

  • 第一個是讓 qemu 把它執行的指令給記錄下來,放到 q.log 這個地方
  • 第二個是和 gdb 結合來調試正在執行的 Bootloader

我們看看初始化執行指令中都有哪些內容,我們使用如下命令:

less tools/lab1init

會顯示如下內容:

file /bin/kernel
target remote :1234
set architecture i8086
b *0x7c00
continue
x /2i $pc

它大概幹了如下的一些事情:

  • 第一條指令是加載 bin/kernel。(加載符號信息,事實上是ucore的信息)
  • 第二條指令是與 qemu 進行連接,通過這個TRP進行連接
  • 剛開始的時候,BIOS是進入8086的16位實模式方式,一直到0x7c00。在BIOS這個階段,啓動,最後把Bootloader加載進去,把控制權交給Bootloader,那麼Bootloader第一條指令就是在0x7c00處,所以我們在這個地方設置一個斷點,break 0x7c00
  • 然後讓這個系統繼續運行,那麼我們就會看到它會在這個斷點處停下來,那我們可以把相應的這個指令給打印出來。
  • 最後一條指令的意思是把PC(也就是EIP,即指令指針寄存器),它存在當前正在執行這個指令的地址,
    那麼x是顯示的意思,/2i是顯示兩條,i是指令。

我們嘗試用命令去執行一下 bootloader第一條指令看看效果:

make lab1-mon

make_lab1-mon

我們可以看到,qemu 已經啓動起來了。但是它斷下來了,斷在哪裏呢?我們可以看到斷點箭頭指向 0x7c00 處。我們還可以顯示更多的條數信息,比如我們可以執行 x /10i $pc ,可以把當前的10條指令都顯示出來。

(gdb) x /10i $pc
=> 0x7c00:      cli    
   0x7c01:      cld    
   0x7c02:      xor    %ax,%ax
   0x7c04:      mov    %ax,%ds
   0x7c06:      mov    %ax,%es
   0x7c08:      mov    %ax,%ss 
   0x7c0a:      in     $0x64,%al
   0x7c0c:      test   $0x2,%al
   0x7c0e:      jne    0x7c0a
   0x7c10:      mov    $0xd1,%al

而這些指令都在哪裏呢?

我們可以查看 boot/bootasm.S 文件,可以看到,如下圖所示的代碼和我們看到 gdb 裏面的指令是一樣的。

bootasm_code

我們已經斷到 Bootloader 起始的位置,我們接下來可以讓它繼續運行。

continue

可以看到效果:

bootloader_run

這時候我們可以看到 Bootloader 已經加載進來了。

我們修改tools/gdbinit如下:

set architecture i8086
target remote :1234

/home/moocos/ucore_lab/labcodes_answer/lab1_result下執行make debug

gdbinit

  • 此時CS0xF000PC0xFFF0,內存地址爲0xFFFF0
  • 可知,CPU加電後第一條執行位於0xFFFF0,並且第一條指令爲長跳轉指令
  • 可知,BIOS實例存儲在cs:ip0xf000:0xe05b的位置
  • 使用si命令可對BIOS進行單步跟蹤

我們再對 tools/gdbinit 做如下修改:

file obj/bootblock.o
set architecture i8086
target remote :1234
b *0x7c00
continue

/home/moocos/ucore_lab/labcodes_answer/lab1_result下執行make debug

gdbinit

  • 調試發現0x7C00爲主引導程序的入口地址,代碼與bootasm.S一致
  • 使用ni可進行單步調試

我們再對 tools/gdbinit 做如下修改:

file bin/kernel
set architecture i8086
target remote :1234
b kern_init
continue

/home/moocos/ucore_lab/labcodes_answer/lab1_result下執行make debug

gdbinit

  • 在內核入口處增加斷點,可以看到代碼停在kern_init函數
  • 使用ni可進行單步調試

練習3:分析bootloader進入保護模式的過程

事實上,Bootloader 完成了一些最基本的功能,比如 它能夠把80386的保護模式給開啓,使得現在的軟件進入了一個32位的尋址空間,這就是我們的尋址方式發生了變化。爲了做好這一步,它需要幹如下一些事情:

  • 開啓A20
  • 初始化GDT表(全局描述符表)
  • 使能和進入保護模式

爲何開啓A20,以及如何開啓A20

i8086時代,CPU的數據總線是16bit,地址總線是20bit(20根地址總線),寄存器是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就是第21根線,用來控制是否允許對 0x10FFEF 以上的實際內存尋址。稱爲A20 Gate

默認情況下,A20地址線是關閉的(20bit以上的地址線限制爲0),因此在進入保護模式(需要訪問超過1MB的內存空間)前,我們需要開啓A20地址線(20bit以上的地址線可爲0或者1)。具體代碼如下:

seta20.1:
    inb $0x64, %al                                  # Wait for not busy(8042 input buffer empty).
    testb $0x2, %al
    jnz seta20.1

    movb $0xd1, %al                                 # 0xd1 -> port 0x64
    outb %al, $0x64                                 # 0xd1 means: write data to 8042's P2 port

seta20.2:
    inb $0x64, %al                                  # Wait for not busy(8042 input buffer empty).
    testb $0x2, %al
    jnz seta20.2

    movb $0xdf, %al                                 # 0xdf -> port 0x60
    outb %al, $0x60                                 # 0xdf = 11011111, means set P2's A20 bit(the 1 bit) to 1

如何初始化GDT表

首先咱們要引入GDT的概念,GDT到底是什麼呢?

在Protected Mode下,一個重要的必不可少的數據結構就是GDT(Global Descriptor Table)。

爲什麼要有GDT?我們首先考慮一下在Real Mode下的編程模型:

在Real Mode下,我們對一個內存地址的訪問是通過 Segment:Offset 的方式來進行的,其中 Segment 是一個段的Base Address,一個 Segment 的最大長度是64 KB,這是16-bit系統所能表示的最大長度。而 Offset 則是相對於此 Segment Base Address 的偏移量。Base Address+Offset 就是一個內存絕對地址。由此,我們可以看出,一個段具備兩個因素:

  • Base Address
  • Limit(段的最大長度)

而對一個內存地址的訪問,則是需要指出:使用哪個段?以及相對於這個段 Base Address 的 Offset,這個Offset應該小於此段的Limit。當然對於16-bit系統,Limit 不要指定,默認爲最大長度64KB,而 16-bit 的 Offset 也永遠不可能大於此Limit。我們在實際編程的時候,使用16-bit段寄存器CS(Code Segment),DS(Data Segment),SS(Stack Segment)來指定Segment,CPU將段積存器中的數值向左偏移4-bit,放到20-bit的地址線上就成爲20-bit的Base Address。

到了Protected Mode,內存的管理模式分爲兩種,段模式和頁模式,其中頁模式也是基於段模式的。也就是說,Protected Mode的內存管理模式事實上是:純段模式和段頁式。進一步說,段模式是必不可少的,而頁模式則是可選的——如果使用頁模式,則是段頁式;否則這是純段模式。

既然是這樣,我們就先不去考慮頁模式。對於段模式來講,訪問一個內存地址仍然使用Segment:Offset的方式,這是很自然的。由於 Protected Mode運行在32-bit系統上,那麼Segment的兩個因素:Base Address和Limit也都是32位的。

IA-32允許將一個段的Base Address設爲32-bit所能表示的任何值(Limit則可以被設爲32-bit所能表示的,以2^12爲倍數的任何指),而不像 Real Mode 下,一個段的 Base Address 只能是16的倍數(因爲其低4-bit是通過左移運算得來的,只能爲0,從而達到使用16-bit段寄存器表示20-bit Base Address的目的),而一個段的Limit只能爲固定值64 KB。另外,Protected Mode,顧名思義,又爲段模式提供了保護機制,也就說一個段的描述符需要規定對自身的訪問權限(Access)。

所以,在Protected Mode下,對一個段的描述則包括3方面因素:[Base Address, Limit, Access],它們加在一起被放在一個64-bit長的數據結構中,被稱爲段描述符。這種情況下,如果我們直接通過一個64-bit段描述符來引用一個段的時候,就必須使用一個64-bit長的段積存器裝入這個段描述符。但 Intel 爲了保持向後兼容,將段積存器仍然規定爲16-bit(儘管每個段積存器事實上有一個64-bit長的不可見部分,但對於程序員來說,段積存器就是16-bit的),那麼很明顯,我們無法通過16-bit長度的段積存器來直接引用64-bit的段描述符。

怎麼辦?解決的方法就是把這些長度爲64-bit的段描述符放入一個數組中,而將段寄存器中的值作爲下標索引來間接引用(事實上,是將段寄存器中的高13 -bit的內容作爲索引)。這個全局的數組就是GDT。事實上,在GDT中存放的不僅僅是段描述符,還有其它描述符,它們都是64-bit長,我們隨後再討論。

GDT可以被放在內存的任何位置,那麼當程序員通過段寄存器來引用一個段描述符時,CPU必須知道GDT的入口,也就是基地址放在哪裏,所以 Intel的設計者門提供了一個寄存器GDTR用來存放GDT的入口地址,程序員將GDT設定在內存中某個位置之後,可以通過 LGDT 指令將 GDT 的入口地址裝入此積存器,從此以後,CPU 就根據此積存器中的內容作爲 GDT 的入口來訪問GDT了。

GDT是Protected Mode所必須的數據結構,也是唯一的——不應該,也不可能有多個。另外,正如它的名字(Global Descriptor Table)所蘊含的,它是全局可見的,對任何一個任務而言都是這樣。

除了GDT之外,IA-32還允許程序員構建與GDT類似的數據結構,它們被稱作LDT(Local Descriptor Table),但與GDT不同的是,LDT在系統中可以存在多個,並且從LDT的名字可以得知,LDT不是全局可見的,它們只對引用它們的任務可見,每個任務最多可以擁有一個LDT。另外,每一個LDT自身作爲一個段存在,它們的段描述符被放在GDT中。

IA-32爲LDT的入口地址也提供了一個寄存器LDTR,因爲在任何時刻只能有一個任務在運行,所以LDT寄存器全局也只需要有一個。如果一個任務擁有自身的LDT,那麼當它需要引用自身的LDT時,它需要通過LLDT將其LDT的段描述符裝入此寄存器。LLDT指令與LGDT指令不同的時,LGDT指令的操作數是一個32-bit的內存地址,這個內存地址處存放的是一個32-bit GDT的入口地址,以及16-bit的GDT Limit。而LLDT指令的操作數是一個16-bit的選擇子,這個選擇子主要內容是:被裝入的LDT的段描述符在GDT中的索引值——這一點和剛纔所討論的通過段積存器引用段的模式是一樣的。

GDT的結構圖如下:(GDT表相當於一個64bit的數組)

GDT_struct

可以看出這裏所有GDT表項(除了空段)初始化爲全段,此時段偏移量EIP等於物理地址

...
#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)
...
lgdt gdtdesc
...
gdt:
    SEG_NULLASM                                     # null seg
    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:
    .word 0x17                                      # sizeof(gdt) - 1
    .long gdt                                       # address gdt

段選擇子

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

selector

  • 在段選擇子中,其中的INDEX[15:3]是GDT的索引。
  • TI[2:2]用於選擇表格的類型,1是LDT,0是GDT。
  • RPL[1:0]用於選擇請求者的特權級,00最高,11最低。

GDT的訪問

有了上面這些知識,我們可以來看看到底應該怎樣通過GDT來獲取需要訪問的地址了。我們通過這個示意圖來講解:

GDT

  • 根據CPU給的邏輯地址分離出段選擇子。
  • 利用段選擇子查找到對應的段描述符。
  • 將段描述符裏的Base Address和EIP相加而得到線性地址。

如何使能和進入保護模式

開啓A20,初始化gdt後,將控制寄存器CR0PE(bit0)置爲1即可。

movl %cr0, %eax
orl 0x1, %eax
movl %eax, %cr0

bootloader進入保護模式的過程

* bootloader開始運行在實模式,物理地址爲0x7c00,且是16位模式
* bootloader關閉所有中斷,方向標誌位復位,ds,es,ss段寄存器清零
* 打開A20使之能夠使用高位地址線
* 由實模式進入保護模式,使用lgdt指令把GDT描述符表的大小和起始地址存入gdt寄存器,修改寄存器CR0的最低位(orl $CR0_PE_ON, %eax)完成從實模式到保護模式的轉換,使用ljmp指令跳轉到32位指令模式
* 進入保護模式後,設置ds,es,fs,gs,ss段寄存器,堆棧指針,便可以進入c程序bootmain

練習4:分析bootloader加載ELF格式的OS的過程

進入保護模式之後,Bootloader 需要乾的很重要的一件事就是加載 ELF 文件。因爲我們的 kernel(也就是ucore OS)是以 ELF 文件格式存在硬盤上的。

[~/moocos/ucore_lab/labcodes_answer/lab1_result]
moocos-> file bin/kernel
bin/kernel: ELF 32-bit LSB executable, Intel 80386, version 1(SYSV), statically linked, not stripped
  • 定義ELF頭指針,指向0x10000
  • 讀取8個扇區大小的ELF頭到內存地址0x10000
  • 校驗ELF header中的模數,判斷是否爲0x464C457FU
  • 讀取ELF header中的程序段到內存中
  • 跳轉到操作系統入口

  • 定義ELF頭指針,指向0x10000
  • 讀取8個扇區大小的ELF頭到內存地址0x10000
  • 校驗ELF header中的模數,判斷是否爲0x464C457FU
  • 讀取ELF header中的程序段到內存中
  • 跳轉到操作系統入口

Bootloader 如何把 ucore 加載到內存中去呢?它需要完成如下的兩步操作:

  • bootloader如何讀取硬盤扇區的
  • bootloader是如何加載ELF格式的OS

執行完bootasm.S後,系統進入保護模式, 進行bootmain.c開始加載OS

  • 定義ELF頭指針,指向0x10000
  • 讀取8個扇區大小的ELF頭到內存地址0x10000
  • 校驗ELF header中的模數,判斷是否爲0x464C457FU
  • 讀取ELF header中的程序段到內存中
  • 跳轉到操作系統入口

  • bootloader如何讀取硬盤扇區的
  • bootloader是如何加載ELF格式的OS

bootloader如何讀取硬盤扇區的

* bootloader進入保護模式並載入c程序bootmain
* bootmain中readsect函數完成讀取磁盤扇區的工作,函數傳入一個指針和一個uint_32類型secno,函數將secno對應的扇區內容拷貝至指針處
* 調用waitdisk函數等待地址0x1F7中低8、7位變爲0,1,準備好磁盤
* 向0x1F2輸出1,表示讀1個扇區,0x1F3輸出secno低8位,0x1F4輸出secno的8~15位,0x1F5輸出secno的16~23位,0x1F6輸出0xe+secno的24~27位,第四位0表示主盤,第六位1表示LBA模式,0x1F7輸出0x20
* 調用waitdisk函數等待磁盤準備好
* 調用insl函數把磁盤扇區數據讀到指定內存

bootloader是如何加載ELF格式的OS

bootloader通過bootmain函數完成ELF格式OS的加載。

* 調用readseg函數從kernel頭讀取8個扇區得到elfher
* 判斷elfher的成員變量magic是否等於ELF_MAGIC,不等則進入bad死循環
* 相等表明是符合格式的ELF文件,循環調用readseg函數加載每一個程序段
* 調用elfher的入口指針進入OS

練習5:實現函數調用堆棧跟蹤函數

完成kdebug.c中函數print_stackframe的實現

要完成實驗首先必須瞭解函數棧的構建過程

  • ebp爲基址指針寄存器
  • esp爲堆棧指針寄存器(指向棧頂)
  • ebp寄存器處於一個非常重要的地位,該寄存器中存儲着棧中的一個地址(原ebp入棧後的棧頂),從該地址爲基準,向上(棧底方向)能獲取返回地址、參數值,向下(棧頂方向)能獲取函數局部變量值,而該地址處又存儲着上一層函數調用時的ebp
    舉一個實際的例子查看ebpesp兩個寄存器如何構建出完整的函數棧:
    leave等同於movl %ebp, %esppopl %ebp兩條指令
int g(int x) {
    return x + 10;
}

int f(int x) {
    return g(x);
} 

int main(void) {
    return f(20) + 8;
}

print

實現過程如下:

* 使用 read_ebp(), read_eip()函數獲得ebp,eip的值
* 循環:
    1. 輸出ebp,eip的值
    2. 輸出4個參數的值,其中第一個參數的地址爲ebp+8,依次加4得到下一個參數的地址
    3. 更新ebp,eip,其中新的ebp的地址爲ebp,新的eip的地址爲ebp+4,即返回地址
    4. ebp爲0時表明程序返回到了最開始初始化的函數,ebp=0爲循環的退出條件
void print_stackframe(void){  
    uint32_t ebp = read_ebp(), eip = read_eip();
    int i, j;
    for (i = 0; ebp != 0 && i < STACKFRAME_DEPTH; i ++) {
        cprintf("ebp:0x%08x eip:0x%08x args:", ebp, eip);
        // ebp向上移動4個字節爲eip
        uint32_t *args = (uint32_t *)ebp + 2;
        // 再向上每4個字節都爲輸入的參數(這裏只是假設4個參數,做實驗)
        for (j = 0; j < 4; j ++) {
            cprintf("0x%08x ", args[j]);
        }
        cprintf("\n");
        print_debuginfo(eip - 1);
        // ebp指針指向的位置向上一個地址爲上一個函數的eip
        eip = ((uint32_t *)ebp)[1];
        // ebp指針指向的位置存儲的上一個ebp的地址
        ebp = ((uint32_t *)ebp)[0];
    }
}

效果如下:

make_qemu

練習6:完善中斷初始化和處理

爲什麼有中斷?

操作系統需要對計算機系統中的各種外設進行管理,這就需要CPU和外設能夠相互通信才行,CPU速度遠快於外設,若採用通常的輪詢(polling)機制,則太浪費CPU資源了。所以需要操作系統和CPU能夠一起提供某種機制,讓外設在需要操作系統處理外設相關事件的時候,能夠“主動通知”操作系統,即打斷操作系統和應用的正常執行,讓操作系統完成外設的相關處理,然後在恢復操作系統和應用的正常執行。這種機制稱爲中斷

中斷的類型

  • CPU外部設備引起的外部事件如I/O中斷、時鐘中斷、控制檯中斷等是異步產生的(即產生的時刻不確定),與CPU的執行無關,我們稱之爲異步中斷,也稱外部中斷
  • CPU執行指令期間檢測到不正常的或非法的條件(如除零錯、地址訪問越界)所引起的內部事件稱作同步中斷,也稱內部中斷
  • 在程序中使用請求系統服務的系統調用而引發的事件,稱作陷入中斷,也稱軟中斷,系統調用簡稱trap

中斷描述符表(也可簡稱爲保護模式下的中斷向量表)中一個表項佔多少字節?其中哪幾位代表中斷處理代碼的入口?

  • CPU收到中斷時,會查找對應的中斷描述符表(IDT),確定對應的中斷服務例程。
  • IDT是一個8字節的描述符數組,IDT 可以位於內存的任意位置,CPU 通過IDT寄存器(IDTR)的內容來尋址IDT的起始地址。指令LIDTSIDT用來操作IDTR
  • DT的一個表項如下,4個字節分別存儲offset的高位地址、段選擇子和offset低位地址

IDT_Gate

中斷處理過程如下圖所示:

Interrupt

請編程完善kern/trap/trap.c中對中斷向量表進行初始化的函數idt_init。在idt_init函數中,依次對所有中斷入口進行初始化。使用mmu.h中的SETGATE宏,填充idt數組內容。每個中斷的入口由tools/vectors.c生成,使用trap.c中聲明的vectors數組即可。

查看SETGATE宏定義

  • 由代碼看出SETGATE本質是設置生成一個4字節的中斷描述表項
  • gate爲中斷描述符表項對應的數據結構,定義在mmu.hstruct gatedesc
  • istrap標識是中斷還是系統調用,唯一區別在於,中斷會清空IF標誌,不允許被打斷
  • seloff分別爲中斷服務例程的代碼段與偏移量,dpl爲訪問權限
#define SETGATE(gate, istrap, sel, off, dpl) {            \
    (gate).gd_off_15_0 = (uint32_t)(off) & 0xffff;        \
    (gate).gd_ss = (sel);                                \
    (gate).gd_args = 0;                                    \
    (gate).gd_rsv1 = 0;                                    \
    (gate).gd_type = (istrap) ? STS_TG32 : STS_IG32;    \
    (gate).gd_s = 0;                                    \
    (gate).gd_dpl = (dpl);                                \
    (gate).gd_p = 1;                                    \
    (gate).gd_off_31_16 = (uint32_t)(off) >> 16;        \
}

查看vector.S定義的中斷號定義

  • 保護模式下有256箇中斷號0~31是保留的, 用於處理異常和NMI(不可屏蔽中斷); 32~255由用戶定義, 可以是設備中斷或系統調用.
  • 所有的中斷服務例程,最終都是跳到__alltraps進行處理
  • 注意這裏的標號對應的地址爲代碼段偏移量
.text
.globl __alltraps
.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 vector255

由以上可實現idt_init

* 使用SETGATE宏設置每一個idt,均使用中斷門描述符
* 權限均爲內核態權限,設置T_SYSCALL
* 使用陷阱門描述符,權限爲用戶權限,最後調用lidt函數
void idt_init(void){
    extern uintptr_t __vectors[];
    int i;
        for(i = 0 ; i < 256 ; i++) {
            SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
        }
        SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
        lidt(&idt_pd);
}

請編程完善trap.c中的中斷處理函數trap,在對時鐘中斷進行處理的部分填寫trap函數中處理時鐘中斷的部分,使操作系統每遇到100次時鐘中斷後,調用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。

通過之前的分析查看__alltraps所在的trappentry.S文件

  • 壓棧各種需要傳遞給中斷服務例程的信息,形成trapFrame,調用trap函數
  • 注意進入這個函數前,vector.S中已經壓棧了1,2個參數
.text
.globl __alltraps
__alltraps:
    # push registers to build a trap frame
    # therefore make the stack look like a struct trapframe
    pushl %ds
    pushl %es
    pushl %fs
    pushl %gs
    pushal

    # load GD_KDATA into %ds and %es to set up data segments for kernel
    movl $GD_KDATA, %eax
    movw %ax, %ds
    movw %ax, %es

    # push %esp to pass a pointer to the trapframe as an argument to trap()
    pushl %esp

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

    # pop the pushed stack pointer
    popl %esp
  • 最終調用了trap_dispatch根據中斷號將中斷分發給不同的服務例程
    +IRQ_OFFSET32,與之前32~255由用戶定義, 爲設備中斷或系統調用的描述一致.
  • 填充時鐘中斷響應代碼,完成實驗
* 使用kern/driver/clock.c中的變量ticks,每次中斷時加1,達到 TICK_NUM 次後歸零並執行print_ticks
void trap(struct trapframe *tf) {
    // dispatch based on what type of trap occurred
    trap_dispatch(tf);
}

static void trap_dispatch(struct trapframe *tf) {
    char c;

    switch (tf->tf_trapno) {
    case IRQ_OFFSET + IRQ_TIMER:
        /* LAB1 YOUR CODE : STEP 3 */
        /* handle the timer interrupt */
        /* (1) After a timer interrupt, you should record this event using a global variable (increase it), such as ticks in kern/driver/clock.c
         * (2) Every TICK_NUM cycle, you can print some info using a funciton, such as print_ticks().
         * (3) Too Simple? Yes, I think so!
         */
        ticks++;
        if(ticks == TICK_NUM) {
            print_ticks();
            ticks = 0;
        }
        break;
    case IRQ_OFFSET + IRQ_COM1:
        c = cons_getc();
        cprintf("serial [%03d] %c\n", c, c);
        break;
    case IRQ_OFFSET + IRQ_KBD:
        c = cons_getc();
        cprintf("kbd [%03d] %c\n", c, c);
        break;
    //LAB1 CHALLENGE 1 : YOUR CODE you should modify below codes.
    case T_SWITCH_TOU:
    case T_SWITCH_TOK:
        panic("T_SWITCH_** ??\n");
        break;
    case IRQ_OFFSET + IRQ_IDE1:
    case IRQ_OFFSET + IRQ_IDE2:
        /* do nothing */
        break;
    default:
        // in kernel, it must be a mistake
        if ((tf->tf_cs & 3) == 0) {
            print_trapframe(tf);
            panic("unexpected trap in kernel.\n");
        }
    }
}

擴展練習

Challenge1

我們已經在 kern_init 中利用 gdt_init 函數初始化了用戶態的 GDT ,切換的時候只需要設置一下幾個段寄存器爲用戶態寄存器就好了。

在中斷表中有兩個中斷, T_SWITCH_TOUT_SWITCH_TOK ,一個是切換到用戶態,另一個是切換回內核態,顯然是希望我們通過這兩個中斷來進行上下文切換。內核已經爲我們提供了這兩個中段號,我們只需要在 ISR 中設置一下段寄存器。

當然,從用戶態切換到內核態需要另外設置中斷號使其可以從用戶態被中斷。

稍微分析跟蹤一下 ISR 的流程,首先在中斷表中註冊的 vectors 數組中存放着準備參數和跳轉到 __alltraps 函數的幾個指令,在 __alltraps (在 kern/trap/trapentry.S 中定義)函數中,將原來的段寄存器壓棧後作爲參數 struct trapframe *tf 傳遞給 trap_dispatch ,並在其中分別處理。

中斷處理函數在退出的時候會把這些參數全部 pop 回寄存器中,於是我們可以趁它還在棧上的時候修改其值,在退出中斷處理的時候相應的段寄存器就會被更新。

我們這裏只需要在 case T_SWITCH_TOU:case T_SWITCH_TOK: 兩個 case 處添加修改段寄存器的代碼即可:

static void switch_to_user(struct trapframe *tf) {
    if ((tf->tf_cs & 3) == 3) return;
    tf->tf_ds = tf->tf_es = tf->tf_fs = tf->tf_gs = tf->tf_ss = USER_DS;
    tf->tf_cs = USER_CS;
    tf->tf_eflags |= FL_IOPL_3;
}

static void switch_to_kernel(struct trapframe *tf) {
    if ((tf->tf_cs & 3) == 0) return;
    tf->tf_ds = tf->tf_es = tf->tf_fs = tf->tf_gs = tf->tf_ss = KERNEL_DS;
    tf->tf_cs = KERNEL_CS;
    tf->tf_eflags &= ~FL_IOPL_3;
}

這樣的話,只要觸發 T_SWITCH_TOUT_SWITCH_TOK 編號的中斷, CPU 指令流就會通過 ISR 執行到這裏,並進行內核態和用戶態的切換。

這裏有一個坑,在輸出的時候,由於 in out 是高權限指令,切換到用戶態後跑到這兩個指令 CPU 會拋出一般保護性錯誤(即第 13 號中斷)。而源碼中在切換至用戶態之後還會有兩次輸出( lab1_print_cur_statuscprintf ),如果不作處理自然再次導致陷入中斷,控制流再次進入 trap_dispatch 中。但是這次 T_GPLT 未被處理,所以會落到 default 中打印錯誤並退出……於是就遞歸了。

因此爲了能正常地輸出,需要修改 IO 權限位。在 EFLAGS 寄存器中的第 12/13 位控制着 IO 權限。這個域只有在 GDT 中的權限位爲 0 (最高權限)時,通過 iretpopf 指令修改。只有在 IO 權限位大於等於 GDT 中的權限位才能正常使用 in out 指令。我們可以在 trap_dispatch 中通過 trap_frame 中對應位修改 EFLAGS 。

接下來只需要在 kern/init/init.c 中開啓題目開關,然後實現題目要求的兩個函數 lab1_switch_to_userlab1_switch_to_kernel 。需要另外注意保持棧平衡。

* 讓 SS 和 ESP 這兩個寄存器 有機會 POP 出時 更新 SS 和 ESP
*   因爲 從內核態進入中斷 它的特權級沒有改變 是不會 push 進 SS 和 ESP的 但是我們又需要通過 POP SS 和 ESP 去修改它們
*   進入 T_SWITCH_TOU(120) 中斷
*   將原來的棧頂指針還給esp棧底指針
static void lab1_switch_to_user(void) {
    asm volatile (
        "subl $0x08, %%esp\n"
        "int  %[switch_tou]\n"
        "movl %%ebp, %%esp\n"
        :
        : [switch_tou]"N"(T_SWITCH_TOU)
        : "%eax", "%esp", "memory", "cc"
    );
}
*   進入 T_SWITCH_TOK(121) 中斷
*   將原來的棧頂指針還給esp棧底指針
static void lab1_switch_to_kernel(void) {
    asm volatile (
        "int  %[switch_tok]\n"
        "popl %%esp\n"
        :
        : [switch_tok]"N"(T_SWITCH_TOK)
        : "%eax", "%esp", "memory", "cc"
    );
}

根據這張圖 可以看出 內核態和用戶態的轉換 首先是留下 SS 和 ESP 的位置 然後 調用中斷 改中斷棧裏面的內容 最後退出中斷的時候 跳到內核態中 最後將 ebp 賦給 esp 修復 esp 的位置。

pcb

執行 make grade ,結果如下:

make_grade

Challenge2

主要是捕獲擊鍵,然後調用上面寫的兩個函數。

擊鍵也會觸發一箇中斷,對其的處理在 trap_dispatchIRQ_KBD case 處,反正返回的就是 ASCII 碼,直接判斷是不是等於 ‘0’ 或者 ‘3’ 即可。

c = cons_getc();
switch (c) {
 case '0':
    switch_to_kernel(tf);
    print_trapframe(tf);
    break;
 case '3':
    switch_to_user(tf);
    print_trapframe(tf);
    break;
}
cprintf("kbd [%03d] %c\n", c, c);
break;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章