由於我個人不太懂 AT&T 語法,在完成實驗的過程中遇到了相當大的阻礙,甚至有點懷疑人生,我是否心太大了,妄想在短時間內學懂大清的課程。ucoreOS_lab1 這個實驗前前后后做到了現在才勉強完成,后來又花了兩天時間,寫完了這份9000余字的報告。網上的資料參差不齊,很難有一份適合我這種新手(菜雞)的詳細的實驗過程,無奈只有自己狠下心來,完成了這篇實驗報告,雖然只是一篇小小的實驗報告,卻涵蓋了我是如何一步步摸索這一艱辛的實驗過程,如果文中有不合理之處,歡迎指出,共同學習,共同進步。所有的實驗報告將會在 Github 同步更新,更多內容請移步至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/
練習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.img
為5120000 bytes
,內容為0的文件dd
拷貝bin/bootblock
到ucore.img
第一個扇區dd
拷貝bin/kernel
到ucore.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 bytes
為0x55 0xAA
練習2:使用qemu執行並調試lab1中的軟件
- 從CPU加電后執行的第一條指令開始,單步跟蹤BIOS的執行。
- 在初始化位置0x7c00設置實地址斷點,測試斷點正常。
- 從0x7c00開始跟蹤代碼運行,將單步跟蹤反匯編得到的代碼與bootasm.S和 bootblock.asm進行比較。
- 自己找一個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
我們可以看到,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 里面的指令是一樣的。
我們已經斷到 Bootloader 起始的位置,我們接下來可以讓它繼續運行。
continue
可以看到效果:
這時候我們可以看到 Bootloader 已經加載進來了。
我們修改tools/gdbinit
如下:
set architecture i8086
target remote :1234
在 /home/moocos/ucore_lab/labcodes_answer/lab1_result
下執行make debug
:
- 此時
CS
為0xF000
,PC
為0xFFF0
,內存地址為0xFFFF0
- 可知,
CPU
加電后第一條執行位於0xFFFF0
,並且第一條指令為長跳轉指令 - 可知,BIOS實例存儲在
cs:ip
為0xf000: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
:
- 調試發現
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
:
- 在內核入口處增加斷點,可以看到代碼停在
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表項
(除了空段)初始化為全段,此時段偏移量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. 下面是段選擇子的示意圖:
- 在段選擇子中,其中的INDEX[15:3]是GDT的索引。
- TI[2:2]用於選擇表格的類型,1是LDT,0是GDT。
- RPL[1:0]用於選擇請求者的特權級,00最高,11最低。
GDT的訪問
有了上面這些知識,我們可以來看看到底應該怎樣通過GDT來獲取需要訪問的地址了。我們通過這個示意圖來講解:
- 根據CPU給的邏輯地址分離出段選擇子。
- 利用段選擇子查找到對應的段描述符。
- 將段描述符里的Base Address和EIP相加而得到線性地址。
如何使能和進入保護模式
開啟A20,初始化gdt后,將控制寄存器CR0
的PE(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
值
舉一個實際的例子查看ebp
與esp
兩個寄存器如何構建出完整的函數棧:
leave
等同於movl %ebp, %esp
,popl %ebp
兩條指令
int g(int x) {
return x + 10;
}
int f(int x) {
return g(x);
}
int main(void) {
return f(20) + 8;
}
實現過程如下:
* 使用 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];
}
}
效果如下:
練習6:完善中斷初始化和處理
為什么有中斷?
操作系統需要對計算機系統中的各種外設進行管理,這就需要CPU
和外設能夠相互通信才行,CPU
速度遠快於外設,若采用通常的輪詢(polling)機制
,則太浪費CPU
資源了。所以需要操作系統和CPU
能夠一起提供某種機制,讓外設在需要操作系統處理外設相關事件的時候,能夠“主動通知”操作系統,即打斷操作系統和應用的正常執行,讓操作系統完成外設的相關處理,然后在恢復操作系統和應用的正常執行。這種機制稱為中斷
。
中斷的類型
- 由
CPU
外部設備引起的外部事件如I/O中斷、時鍾中斷、控制台中斷等是異步產生的(即產生的時刻不確定),與CPU
的執行無關,我們稱之為異步中斷
,也稱外部中斷
- 在
CPU
執行指令期間檢測到不正常的或非法的條件(如除零錯、地址訪問越界)所引起的內部事件稱作同步中斷
,也稱內部中斷
- 在程序中使用請求系統服務的系統調用而引發的事件,稱作
陷入中斷
,也稱軟中斷,系統調用
簡稱trap
中斷描述符表(也可簡稱為保護模式下的中斷向量表)中一個表項占多少字節?其中哪幾位代表中斷處理代碼的入口?
- 當
CPU
收到中斷時,會查找對應的中斷描述符表(IDT)
,確定對應的中斷服務例程。 IDT
是一個8字節的描述符數組,IDT 可以位於內存的任意位置,CPU 通過IDT寄存器(IDTR)
的內容來尋址IDT
的起始地址。指令LIDT
和SIDT
用來操作IDTR
。DT
的一個表項如下,4個字節
分別存儲offset
的高位地址、段選擇子和offset
低位地址
中斷處理過程如下圖所示:
請編程完善kern/trap/trap.c中對中斷向量表進行初始化的函數idt_init。在idt_init函數中,依次對所有中斷入口進行初始化。使用mmu.h中的SETGATE宏,填充idt數組內容。每個中斷的入口由tools/vectors.c生成,使用trap.c中聲明的vectors數組即可。
查看SETGATE
宏定義
- 由代碼看出
SETGATE
本質是設置生成一個4字節
的中斷描述表項 gate
為中斷描述符表項對應的數據結構,定義在mmu.h
為struct gatedesc
istrap
標識是中斷還是系統調用,唯一區別在於,中斷會清空IF
標志,不允許被打斷sel
與off
分別為中斷服務例程的代碼段與偏移量,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_OFFSET
為32
,與之前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_TOU
和 T_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_TOU
和 T_SWITCH_TOK
編號的中斷, CPU 指令流就會通過 ISR 執行到這里,並進行內核態和用戶態的切換。
這里有一個坑,在輸出的時候,由於 in
out
是高權限指令,切換到用戶態后跑到這兩個指令 CPU 會拋出一般保護性錯誤(即第 13 號中斷)。而源碼中在切換至用戶態之后還會有兩次輸出( lab1_print_cur_status
和 cprintf
),如果不作處理自然再次導致陷入中斷,控制流再次進入 trap_dispatch
中。但是這次 T_GPLT
未被處理,所以會落到 default 中打印錯誤並退出……於是就遞歸了。
因此為了能正常地輸出,需要修改 IO 權限位。在 EFLAGS 寄存器中的第 12/13 位控制着 IO 權限。這個域只有在 GDT 中的權限位為 0 (最高權限)時,通過 iret
或 popf
指令修改。只有在 IO 權限位大於等於 GDT 中的權限位才能正常使用 in
out
指令。我們可以在 trap_dispatch
中通過 trap_frame
中對應位修改 EFLAGS 。
接下來只需要在 kern/init/init.c 中開啟題目開關,然后實現題目要求的兩個函數 lab1_switch_to_user
和 lab1_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 的位置。
執行 make grade ,結果如下:
Challenge2
主要是捕獲擊鍵,然后調用上面寫的兩個函數。
擊鍵也會觸發一個中斷,對其的處理在 trap_dispatch
的 IRQ_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;