操作系統lab1實驗報告
[練習1]
理解通過 make 生成執行文件的過程。(要求在報告中寫出對下述問題的回答)
在此練習中,大家需要通過閱讀代碼來了解:
1. 操作系統鏡像文件 ucore.img 是如何一步一步生成的?(需要比較詳細地解釋 Makefile 中
每一條相關命令和命令參數的含義,以及說明命令導致的結果)
2. 一個被系統認爲是符合規範的硬盤主引導扇區的特徵是什麼?
[練習1.1]
1、生成ucore.img
需要kernel
和bootblock
生成ucore.img
的代碼如下:
$(UCOREIMG): $(kernel) $(bootblock)
$(V)dd if=/dev/zero of=$@ count=10000
$(V)dd if=$(bootblock) of=$@ conv=notrunc
$(V)dd if=$(kernel) of=$@ seek=1 conv=notrunc
$(call create_target,ucore.img)
首先先創建一個大小爲10000字節的塊兒,然後再將bootblock
拷貝過去。
生成ucore.img
需要先生成kernel
和bootblock
2、生成kernel
而生成kernel
的代碼如下:
$(kernel): tools/kernel.ld
$(kernel): $(KOBJS)
@echo "bbbbbbbbbbbbbbbbbbbbbb$(KOBJS)"
@echo + ld $@
$(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)
@$(OBJDUMP) -S $@ > $(call asmfile,kernel)
@$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,kernel)
通過make V=
指令得到執行的具體命令如下:
ld -m elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel obj/kern/init/init.o obj/kern/libs/readline.o obj/kern/libs/stdio.o obj/kern/debug/kdebug.o obj/kern/debug/kmonitor.o obj/kern/debug/panic.o obj/kern/driver/clock.o obj/kern/driver/console.o obj/kern/driver/intr.o obj/kern/driver/picirq.o obj/kern/trap/trap.o obj/kern/trap/trapentry.o obj/kern/trap/vectors.o obj/kern/mm/pmm.o obj/libs/printfmt.o obj/libs/string.o
然後根據其中可以看到,要生成kernel
,需要用GCC編譯器將kern
目錄下所有的.c
文件全部編譯生成的.o
文件的支持。具體如下:
obj/kern/init/init.o
obj/kern/libs/readline.o
obj/kern/libs/stdio.o
obj/kern/debug/kdebug.o
obj/kern/debug/kmonitor.o
obj/kern/debug/panic.o
obj/kern/driver/clock.o
obj/kern/driver/console.o
obj/kern/driver/intr.o
obj/kern/driver/picirq.o
obj/kern/trap/trap.o
obj/kern/trap/trapentry.o
obj/kern/trap/vectors.o
obj/kern/mm/pmm.o
obj/libs/printfmt.o
obj/libs/string.o
3、生成bootblock
而生成bootblock
的代碼如下:
$(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign)
@echo "========================$(call toobj,$(bootfiles))"
@echo + ld $@
$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ -o $(call toobj,bootblock)
@$(OBJDUMP) -S $(call objfile,bootblock) > $(call asmfile,bootblock)
@$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)
@$(call totarget,sign) $(call outfile,bootblock) $(bootblock)
同樣根據make V=
指令打印的結果,得到要生成bootblock
,首先需要生成bootasm.o、bootmain.o、sign
,
下列代碼爲生成bootasm.o、bootmain.o
的代碼,由宏定義批量實現了。
bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))
而實際的命令在make V=
指令結果裏可以看到。
下述是由bootasm.S
生成bootasm.o
的具體命令:
gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o
下述是由bootmain.c
生成bootmain.o
的具體命令
gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o
至於上述命令的具體參數,查閱資料羅列如下:
- -ggdb 生成可供gdb使用的調試信息
- -m32 生成適用於32位環境的代碼
- -gstabs 生成stabs格式的調試信息
- -nostdinc 不使用標準庫
- -fno-stack-protector 不生成用於檢測緩衝區溢出的代碼
- -Os 爲減小代碼大小而進行優化
- -I添加搜索頭文件的路徑
- -fno-builtin 不進行builtin函數的優化
下列代碼爲生成sign
的代碼
$(call add_files_host,tools/sign.c,sign,sign)
$(call create_target_host,sign,sign)
下面是生成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
有了上述的bootasm.o、bootmain.o、sign
。
接下來就可以生成bootblock
了,實際命令如下:
ld -m elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o
參數解釋如下:(不重複解釋)
- -m 模擬爲i386上的連接器
- -N 設置代碼段和數據段均可讀寫
- -e 指定入口
- -Ttext 制定代碼段開始位置
[練習1.2]
一個被系統認爲是符合規範的硬盤主引導扇區的特徵有以下幾點:
- 磁盤主引導扇區只有512字節
- 磁盤最後兩個字節爲0x55AA
- 由不超過466字節的啓動代碼和不超過64字節的硬盤分區表加上兩個字節的結束符組成
[練習2]
- 從 CPU加電後執行的第一條指令開始,單步跟蹤 BIOS的執行。
- 在初始化位置 0x7c00 設置實地址斷點,測試斷點正常。
- 從 0x7c00 開始跟蹤代碼運行,將單步跟蹤反彙編得到的代碼與 bootasm.S和 bootblock.asm進行比較。
- 自己找一個 bootloader或內核中的代碼位置,設置斷點並進行測試
首先通過make qemu
指令運行出等待調試的qemu虛擬機,然後再打開一個終端,通過下述命令連接到qemu
虛擬機:
gdb
target remote 127.0.0.1:1234
進入到調試界面:
輸入si
命令單步調試,
這是另一個終端會打印下一條命令的地址和內容:
然後輸入b*0x7c00
在初始化位置地址0x7c00
設置上斷點,如下:
然後輸入continue
使之繼續運行:
這時成功在0x7c00
處停止運行,然後我們查看此處的反彙編代碼,如下:
對比此時bootasm.S
中的起始代碼,發現確實是一樣的
這裏多次的單步調試就不在截圖贅述了。
[練習3]
分析從bootloader進入保護模式的過程。BIOS 將通過讀取硬盤主引導扇區到內存,並轉跳到對應內存中的位置執行 bootloader。請分析bootloader是如何完成從實模式進入保護模式的
首先我們先分析一下bootloader
:
1、關閉中斷,將各個段寄存器重置
它先將各個寄存器置0
cli # Disable interrupts
cld # String operations increment
xorw %ax, %ax # Segment number zero
movw %ax, %ds # -> Data Segment
movw %ax, %es # -> Extra Segment
movw %ax, %ss # -> Stack Segment
2、開啓A20
然後就是將A20置1,這裏簡單解釋一下A20,當 A20 地址線控制禁止時,則程序就像在 8086 中運行,1MB 以上的地是不可訪問的。而在保護模式下 A20 地址線控制是要打開的,所以需要通過將鍵盤控制器上的A20線置於高電位,使得全部32條地址線可用。
seta20.1:
inb $0x64, %al # 讀取狀態寄存器,等待8042鍵盤控制器閒置
testb $0x2, %al # 判斷輸入緩存是否爲空
jnz seta20.1
movb $0xd1, %al # 0xd1表示寫輸出端口命令,參數隨後通過0x60端口寫入
outb %al, $0x64
seta20.2:
inb $0x64, %al
testb $0x2, %al
jnz seta20.2
movb $0xdf, %al # 通過0x60寫入數據11011111 即將A20置1
outb %al, $0x60
3、加載GDT
表
lgdt gdtdesc
4、將CR0
的第0位置1
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
5、長跳轉到32位代碼段,重裝CS和EIP
ljmp $PROT_MODE_CSEG, $protcseg
6、重裝DS、ES等段寄存器等
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
7、轉到保護模式完成,進入boot主方法
movl $0x0, %ebp
movl $start, %esp
call bootmain
[練習4]
分析bootloader加載ELF格式的OS的過程
1. bootloader如何讀取硬盤扇區的?
2. bootloader是如何加載 ELF格式的 OS?
這裏主要分析是bootmain
函數,
bootmain(void) {
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad;
}
struct proghdr *ph, *eph;
ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
for (; ph < eph; ph ++) {
readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
}
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1);
}
bootloader讀取硬盤扇區
根據上述bootmain
函數分析,首先是由readseg
函數讀取硬盤扇區,而readseg
函數則循環調用了真正讀取硬盤扇區的函數readsect
來每次讀出一個扇區 ,如下,詳細的解釋看代碼中的註釋:
readsect(void *dst, uint32_t secno) {
waitdisk(); // 等待硬盤就緒
// 寫地址0x1f2~0x1f5,0x1f7,發出讀取磁盤的命令
outb(0x1F2, 1);
outb(0x1F3, secno & 0xFF);
outb(0x1F4, (secno >> 8) & 0xFF);
outb(0x1F5, (secno >> 16) & 0xFF);
outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
outb(0x1F7, 0x20);
waitdisk();
insl(0x1F0, dst, SECTSIZE / 4);//讀取一個扇區
}
bootloader加載 ELF格式的 OS
讀取完磁盤之後,開始加載ELF
格式的文件。詳細的解釋看代碼中的註釋。
bootmain(void) {
..........
//首先判斷是不是ELF
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad;
}
struct proghdr *ph, *eph;
//ELF頭部有描述ELF文件應加載到內存什麼位置的描述表,這裏讀取出來將之存入ph
ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
//按照程序頭表的描述,將ELF文件中的數據載入內存
for (; ph < eph; ph ++) {
readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
}
//根據ELF頭表中的入口信息,找到內核的入口並開始運行
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
bad:
..........
}
[練習5]
完成
kdebug.c
中函數print_stackframe
的實現,可以通過函數>print_stackframe
來跟蹤函數調用堆棧中記錄的返回地址。
1、函數堆棧的原理
理解函數堆棧最重要的兩點是:棧的結構,以及EBP
寄存器的作用。
一個函數調用動作可分解爲零到多個 PUSH指令(用於參數入棧)和一個 CALL 指令。CALL 指令內部其實還暗含了一個將返回地址壓棧的動作,這是由硬件完成的。幾乎所有本地編譯器都會在每個函數體之前插入類似如下的彙編指令:
pushl %ebp
movl %esp,%ebp
這兩條彙編指令的含義是:首先將ebp
寄存器入棧,然後將棧頂指針 esp
賦值給 ebp
。
movl %esp %ebp
這條指令表面上看是用esp
覆蓋 ebp
原來的值,其實不然。因爲給 ebp
賦值之前,
原ebp
值已經被壓棧(位於棧頂),而新的ebp
又恰恰指向棧頂。此時ebp
寄存器就已經處於一個
非常重要的地位,該寄存器中存儲着棧中的一個地址(原 ebp
入棧後的棧頂),從該地址爲基準,
向上(棧底方向)能獲取返回地址、參數值,向下(棧頂方向)能獲取函數局部變量值,而該地址
處又存儲着上一層函數調用時的ebp
值。
大概就如同下圖:
現在做一下更完整的解釋:
函數調用大概包括以下幾個步驟:
- 1、參數入棧:將參數從右向左(或從右向左)依次壓入系統棧中。
- 2、返回地址入棧:將當前代碼區調用指令的下一條指令地址壓入棧中,供函數返回時繼續執行。
- 3、代碼區跳轉:處理器從當前代碼區跳轉到被調用函數的入口處。
- 4、棧幀調整
- 4.1保存當前棧幀狀態值,已備後面恢復本棧幀時使用(EBP
入棧)。
- 4.2將當前棧幀切換到新棧幀(將ESP
值裝入EBP
,更新棧幀底部)。
- 4.3給新棧幀分配空間(把ESP
減去所需空間的大小,擡高棧頂)。
而函數返回大概包括以下幾個步驟:
- 1、保存返回值,通常將函數的返回值保存在寄存器EAX
中。
- 2、彈出當前幀,恢復上一個棧幀。
- 2.1在堆棧平衡的基礎上,給ESP加上棧幀的大小,降低棧頂,回收當前棧幀的空間
- 2.2將當前棧幀底部保存的前棧幀EBP值彈入EBP寄存器,恢復出上一個棧幀。
- 2.3將函數返回地址彈給EIP寄存器。
- 3、跳轉:按照函數返回地址跳回母函數中繼續執行。
而由此我們可以直接根據ebp
就能讀取到各個棧幀的地址和值,一般而言,ss:[ebp+4]
處爲返回地址,ss:[ebp+8]
處爲第一個參數值(最後一個入棧的參數值,此處假設其佔用 4 字節內存,對應32位系統),ss:[ebp-4]
處爲第一個局部變量,ss:[ebp]
處爲上一層 ebp
值。
2、print_stackframe
函數的實現
首先我們直接看到print_stackframe
函數的註釋:
void print_stackframe(void) {
/* LAB1 YOUR CODE : STEP 1 */
/* (1) call read_ebp() to get the value of ebp. the type is (uint32_t);
* (2) call read_eip() to get the value of eip. the type is (uint32_t);
* (3) from 0 .. STACKFRAME_DEPTH
* (3.1) printf value of ebp, eip
* (3.2) (uint32_t)calling arguments [0..4] = the contents in address (unit32_t)ebp +2 [0..4]
* (3.3) cprintf("\n");
* (3.4) call print_debuginfo(eip-1) to print the C calling function name and line number, etc.
* (3.5) popup a calling stackframe
* NOTICE: the calling funciton's return addr eip = ss:[ebp+4]
* the calling funciton's ebp = ss:[ebp]
*/
}
這樣我們直接根據註釋以及之前的相關知識就能比較簡單的編寫成程序,如下所示:
void print_stackframe(void) {
uint32_t ebp=read_ebp();//(1) call read_ebp() to get the value of ebp. the type is (uint32_t)
uint32_t eip=read_eip();//(2) call read_eip() to get the value of eip. the type is (uint32_t)
int i;
for(i=0;i<STACKFRAME_DEPTH&&ebp!=0;i++){//(3) from 0 .. STACKFRAME_DEPTH
cprintf("ebp:0x%08x eip:0x%08x ",ebp,eip);//(3.1)printf value of ebp, eip
uint32_t *tmp=(uint32_t *)ebp+2;
cprintf("arg :0x%08x 0x%08x 0x%08x 0x%08x",*(tmp+0),*(tmp+1),*(tmp+2),*(tmp+3));//(3.2)(uint32_t)calling arguments [0..4] = the contents in address (unit32_t)ebp +2 [0..4]
cprintf("\n");//(3.3) cprintf("\n");
print_debuginfo(eip-1);//(3.4) call print_debuginfo(eip-1) to print the C calling function name and line number, etc.
eip=((uint32_t *)ebp)[1];
ebp=((uint32_t *)ebp)[0];//(3.5) popup a calling stackframe
}
}
實驗結果截圖如下:
[練習6]
1.中斷向量表中一個表項佔多少字節?其中哪幾位代表中斷處理代碼的入口?
2.請編程完善kern/trap/trap.c
中對中斷向量表進行初始化的函數idt_init
。在idt_init
函數中,依次對所有中斷入口進行初始化。使用mmu.h
中的SETGATE
宏,填充idt
數組內容。注意除了系統調用中斷(T_SYSCALL)
以外,其它中斷均使用中斷門描述符,權限爲內核態權限;而系統調用中斷使用異常,權限爲陷阱門描述符。每個中斷的入口由tools/vectors.c
生成,使用trap.c
中聲明的vectors
數組即可。
3.請編程完善trap.c
中的中斷處理函數trap
在對時鐘中斷進行處理的部分填寫trap
函數中處理時鐘中斷的部分,使操作系統每遇到100次時鐘中斷後,調用print_ticks
子程序,向屏幕上打印一行文字100 ticks
。
[練習6.1]
中斷描述符表一個表項佔8字節。其中0~15位和48~63位分別爲offset
的低16位和高16位。16~31位爲段選擇子。通過段選擇子獲得段基址,加上段內偏移量即可得到中斷處理代碼的入口。大致如下圖:
[練習6.2]
這裏這裏主要就是實現對中斷向量表的初始化。
註釋如下:
void idt_init(void) {
/* LAB1 YOUR CODE : STEP 2 */
/* (1) Where are the entry addrs of each Interrupt Service Routine (ISR)?
* All ISR's entry addrs are stored in __vectors. where is uintptr_t __vectors[] ?
* __vectors[] is in kern/trap/vector.S which is produced by tools/vector.c
* (try "make" command in lab1, then you will find vector.S in kern/trap DIR)
* You can use "extern uintptr_t __vectors[];" to define this extern variable which will be used later.
* (2) Now you should setup the entries of ISR in Interrupt Description Table (IDT).
* Can you see idt[256] in this file? Yes, it's IDT! you can use SETGATE macro to setup each item of IDT
* (3) After setup the contents of IDT, you will let CPU know where is the IDT by using 'lidt' instruction.
* You don't know the meaning of this instruction? just google it! and check the libs/x86.h to know more.
* Notice: the argument of lidt is idt_pd. try to find it!
*/
}
重點就是兩步
第一步,聲明__vertors[],其中存放着中斷服務程序的入口地址。這個數組生成於vertor.S中。
第二步,填充中斷描述符表IDT。
第三部,加載中斷描述符表。
對應到代碼中如下所示:
void idt_init(void) {
extern uintptr_t __vectors[];//聲明__vertors[]
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);//使用lidt指令加載中斷描述符表
}
這裏的SETGATE
在mmu.h
中有定義,
#define SETGATE(gate, istrap, sel, off, dpl)
簡單解釋一下參數
gate
:爲相應的idt[]
數組內容,處理函數的入口地址
istrap
:系統段設置爲1,中斷門設置爲0
sel
:段選擇子
off
:爲__vectors[]
數組內容
dpl
:設置特權級。這裏中斷都設置爲內核級,即第0級
[練習6.3]
這裏根據指導書查看函數trap_dispatch
,發現print_ticks()
子程序已經被實現了,所以我們直接進行判斷輸出即可,如下(見註釋):
........
........
case IRQ_OFFSET + IRQ_TIMER:
ticks ++; //每一次時鐘信號會使變量ticks加1
if (ticks==TICK_NUM) {//TICK_NUM已經被預定義成了100,每到100便調用print_ticks()函數打印
ticks-=TICK_NUM;
print_ticks();
}
break;
.........
.........
實現之後截圖如下:
然後我摁下了字母a
,如下:
屏幕予以回顯,實驗成功!