MIT6.828-LAB1 : PC啟動


Lab1

1. 先熟悉PC的物理地址空間

image-20210620120250345

這里其實有很多可以說的,不過先簡單描述一下吧。從0x00000000到0x00100000這1mb的地址空間時機器處於16位的實模式。也就是說這個時候機器的匯編都是16位匯編。這是為了兼容之前的8086處理器。在這1mb里面。有我們常見的bios,這里要做的就是進行一些開機前的檢查,隨后把內核讀取進來,就算開機完成了

2. 追蹤ROM BIOS

這里要求我們利用斷點跟隨一下bios的過程,看一下bios干了什么

這里的調試要利用到兩個終端,一個執行make qemu-gdb 另一個執行make gdb

image-20210620123141845

你會看到gdb會停在這個界面。這里的停在的地址是oxfff0這是通過

oxfooo << 4 + oxfff0 得到的
$cs = oxf000
$pc = 0xfff0

image-20210620124623032

這樣計算地址的方法是還是因為當前在是模式。所以尋址方式是 $cs << 4 + $pc

接下來就到了bios的執行時間。它大概會做下面的事情

首先bios會初始化一些中斷向量表,然后會初始化一些重要設備比如vga等等,然后開機提示信息就回現實(如windows常見的loading圖)在初始化PCI總線和一些重要設備之后,它搜索可引導設備,如軟盤,硬盤驅動器或CD-ROM。 最終,當它找到可啟動磁盤時,BIOS將引導加載程序從磁盤讀取。隨后轉移到引導啟動程序上去

3. The Boot Loader

於PC來說,軟盤,硬盤都可以被划分為一個個大小為512字節的區域,叫做扇區。一個扇區是一次磁盤操作的最小粒度。每一次讀取或者寫入操作都必須是一個或多個扇區。如果一個磁盤是可以被用來啟動操作系統的,就把這個磁盤的第一個扇區叫做啟動扇區。當BIOS找到一個可以啟動的軟盤或硬盤后,它就會把這512字節的啟動扇區加載到內存地址0x7c00~0x7dff這個區域內。

 對於6.828,我們將采用傳統的硬盤啟動機制,這就意味着我們的boot loader程序的大小必須小於512字節。整個boot loader是由一個匯編文件,boot/boot.S,以及一個C語言文件,boot/main.c組成。Boot loader必須完成兩個主要的功能。

  1. 首先,boot loader要把處理器從實模式轉換為32bit的保護模式,因為只有在這種模式下軟件可以訪問超過1MB空間的內容。
  2. 然后,boot loader可以通過使用x86特定的IO指令,直接訪問IDE磁盤設備寄存器,從磁盤中讀取內核。

對於boot loader來說,有一個文件很重要,obj/boot/boot.asm。這個文件是我們真實運行的boot loader程序的反匯編版本。所以我們可以把它和它的源代碼即boot.S以及main.c比較一下。

好下面就去0x7c00這個地址看一下這個啟動扇區都做了什么

我們依次來分析一下boot.S的匯編代碼

 /boot/boot.S:

1 .globl start
2 start:
3   .code16                # Assemble for 16-bit mode
4   cli                    # Disable interrupts

  這幾條指令就是boot.S最開始的幾句,其中cli是boot.S,也是boot loader的第一條指令。這條指令用於把所有的中斷都關閉。因為在BIOS運行期間有可能打開了中斷。此時CPU工作在實模式下。

5  cld                         # String operations increment

  這條指令用於指定之后發生的串處理操作的指針移動方向。在這里現在對它大致了解就夠了。

6  # Set up the important data segment registers (DS, ES, SS).
7  xorw    %ax,%ax             # Segment number zero
8  movw    %ax,%ds             # -> Data Segment
9  movw    %ax,%es             # -> Extra Segment
10 movw    %ax,%ss             # -> Stack Segment

  這幾條命令主要是在把三個段寄存器,ds,es,ss全部清零,因為經歷了BIOS,操作系統不能保證這三個寄存器中存放的是什么數。所以這也是為后面進入保護模式做准備。

11  # Enable A20:
12  #   For backwards compatibility with the earliest PCs, physical
13  #   address line 20 is tied low, so that addresses higher than
14  #   1MB wrap around to zero by default.  This code undoes this.
15 seta20.1:
16  inb     $0x64,%al               # Wait for not busy
17  testb   $0x2,%al
18  jnz     seta20.1

19  movb    $0xd1,%al               # 0xd1 -> port 0x64
20  outb    %al,$0x64

21 seta20.2:
22  inb     $0x64,%al               # Wait for not busy
23  testb   $0x2,%al
24  jnz     seta20.2

25  movb    $0xdf,%al               # 0xdf -> port 0x60
26  outb    %al,$0x60

這部分指令就是在准備把CPU的工作模式從實模式轉換為保護模式。我們可以看到其中的指令包括inb,outb這樣的IO端口命令。所以這些指令都是在對外部設備進行操作。

接下來還是會做一些在進入保護模式之前的准備

27   # Switch from real to protected mode, using a bootstrap GDT
28   # and segment translation that makes virtual addresses 
29   # identical to their physical addresses, so that the 
30   # effective memory map does not change during the switch.
31   lgdt    gdtdesc # 把關於GDT表的一些信息存放到CPU的GDTR寄存器中(包括起始地址+長度
32   movl    %cr0, %eax
33   orl     $CR0_PE_ON, %eax
34   movl    %eax, %cr0

這部分把gdtdesc送入全局映射描述符表寄存器GDTR中。GDT表是處理器工作於實模式下一個非常重要的表。這里的gdtdesc表示了一個標識符,標識這一個內存地址。從這個內存地址開始之后的6個字節分別存放着GDT表的長度和起始地址。

 1 # Bootstrap GDT
 2 .p2align 2                               # force 4 byte alignment
 3 gdt:
 4   SEG_NULL                               # null seg
 5   SEG(STA_X|STA_R, 0x0, 0xffffffff)      # code seg
 6   SEG(STA_W, 0x0, 0xffffffff)            # data seg
 7 
 8 gdtdesc:
 9   .word   0x17                           # sizeof(gdt) - 1
10   .long   gdt                            # address gdt

其中第3行的gdt是一個標識符,標識從這里開始就是GDT表了。可見這個GDT表中包括三個表項(4,5,6行),分別代表三個段,null seg,code seg,data seg。由於xv6其實並沒有使用分段機制,也就是說數據和代碼都是寫在一起的,所以數據段和代碼段的起始地址都是0x0,大小都是0xffffffff=4GB

在第4~6行是調用SEG()子程序來構造GDT表項的。這個子函數定義mmu.h中,形式如下:  

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

gdb表中的每一個表項的結構如下所示

struct gdt_entry_struct {
	limit_low: resb 2
	base_low: resb 2
	base_middle: resb 1
	access: resb 1
	granularity: resb 1
	base_high: resb1
} endstruc

這個表項一共8字節,其中limit_low就是limit的低16位。base_low就是base的低16位,依次類推。

在gdtdesc處就要存放這個GDT表的信息了,其中0x17是這個表的大小-1 = 0x17 = 23,至於為什么不直接存表的大小24,根據查詢是官方規定的。緊接着就是這個表的起始地址gdt。

在load完gdt表之后下面的操作就是進入保護模式之前的最后操作了

32   movl    %cr0, %eax
33   orl     $CR0_PE_ON, %eax
34   movl    %eax, %cr0

這里就是在修改CRO寄存器的值,其中CRO寄存器的bit0是保護模式啟動位,把這一位設置成1代表保護模式啟動。

35  ljmp    $PROT_MODE_CSEG, $protcseg

這里的跳轉就表示跳轉到保護模式。在保護模式就變成了32位地址模式

protcseg:
  # Set up the protected-mode data segment registers
36  movw    $PROT_MODE_DSEG, %ax    # Our data segment selector
37  movw    %ax, %ds                # -> DS: Data Segment
38  movw    %ax, %es                # -> ES: Extra Segment
39  movw    %ax, %fs                # -> FS
40  movw    %ax, %gs                # -> GS
41  movw    %ax, %ss                # -> SS: Stack Segment

因為規定我們在加載完GDTR寄存器之后必須要重新加載所有的段寄存器。因此下面這些代碼就是在加載段寄存器

隨后我們就要為跳轉到main.c文件中的bootmain函數做准備(因為boot.S的最后一條指令就是call bootmain)

跳轉到main.c文件

在main.c文件做的第一件事就是把內核的第一個頁讀取到內存地址0x10000處。其實第一個頁就是操作系統映射文件到elf。讀取完內核的elf文件。關於elf文件的解釋首先會通過魔數來判斷一下這個內核是否合理。對應下面的代碼

	// read 1st page off disk
	readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);

	// is this a valid ELF?
	if (ELFHDR->e_magic != ELF_MAGIC)
		goto bad;

在elf文件中包含Program Header Table。這個表格存放着程序中所有段的信息。通過這個表我們才能找到要執行的代碼段,數據段等等。所以我們要先獲得這個表。

這條指令就可以完成這一點,首先elf表示elf表的起址,而phoff字段代表Program Header Table距離表頭的偏移量。所以ph可以被指定為Program Header Table表頭。

ph = (struct *Proghdr* *) ((*uint8_t* *) ELFHDR + ELFHDR->e_phoff);

下面的代碼非常重要

	eph = ph + ELFHDR->e_phnum;
	for (; ph < eph; ph++)
		// p_pa is the load address of this segment (as well
		// as the physical address)
		readseg(ph->p_pa, ph->p_memsz, ph->p_offset);

這里的eph表示一共有多少段。這段代碼就是逐段把操作系統內核從硬盤中讀到內存中

而后同樣通過ELFHEADER的

 ((void (*)(void)) (ELFHDR->e_entry))();

  e_entry字段指向的是這個文件的執行入口地址。所以這里相當於開始運行這個文件。也就是內核文件。 自此就把控制權從boot loader轉交給了操作系統的內核。

4. The Kernel

對實驗指導內容的一些翻譯

  在運行boot loader時,boot loader中的鏈接地址(虛擬地址)和加載地址(物理地址)是一樣的。但是當進入到內核程序后,這兩種地址就不再相同了。

  操作系統內核程序在虛擬地址空間通常會被鏈接到一個非常高的虛擬地址空間處,比如0xf0100000,目的就是能夠讓處理器的虛擬地址空間的低地址部分能夠被用戶利用來進行編程。

  但是許多的機器其實並沒有能夠支持0xf0100000這種地址那么大的物理內存,所以我們不能把內核的0xf0100000虛擬地址映射到物理地址0xf0100000的存儲單元處。

  這就造成了一個問題,在我們編程時,我們應該把操作系統放在高地址處,但是在實際的計算機內存中卻沒有那么高的地址,這該怎么辦?

  解決方案就是在虛擬地址空間中,我們還是把操作系統放在高地址處0xf0100000,但是在實際的內存中我們把操作系統存放在一個低的物理地址空間處,如0x00100000。那么當用戶程序想訪問一個操作系統內核的指令時,首先給出的是一個高的虛擬地址,然后計算機中通過某個機構把這個虛擬地址映射為真實的物理地址,這樣就解決了上述的問題。那么這種機構通常是通過分段管理,分頁管理來實現的。

  在這個實驗中,首先是采用分頁管理的方法來實現上面所講述的地址映射。但是設計者實現映射的方式並不是通常計算機所采用的分頁管理機構,而是自己手寫了一個程序lab\kern\entrygdir.c用於進行映射。既然是手寫的,所以它的功能就很有限了,只能夠把虛擬地址空間的地址范圍:0xf0000000 - 0xf0400000,映射到物理地址范圍:0x00000000 - 0x00400000上面。也可以把虛擬地址范圍:0x00000000 - 0x00400000,同樣映射到物理地址范圍:0x00000000~0x00400000上面。任何不再這兩個虛擬地址范圍內的地址都會引起一個硬件異常。雖然只能映射這兩塊很小的空間,但是已經足夠剛啟動程序的時候來使用了。

4.1 Exercise 7

問題1:使用Qemu和GDB去追蹤JOS內核文件,並且停止在movl %eax, %cr0指令前。此時看一下內存地址0x00100000以及0xf0100000處分別存放着什么。然后使用stepi命令執行完這條命令,再次檢查這兩個地址處的內容。確保你真的理解了發生了什么。

問題2: 如果這條指令movl %eax, %cr0並沒有執行,而是被跳過,那么第一個會出現問題的指令是什么?我們可以通過把entry.S的這條語句加上注釋來驗證一下。

對於第一個問題。其實只要在0x100000C這個地方打一個斷點(我們前面其實知道這個地址就是內核的入口地址

image-20210620161032107

然后通過斷點看一下就可以。發現這個時候還是不一樣的

image-20210620161133093

我們這個時候發現就變成了一樣的。說明這個時候已經完成了從物理地址到虛擬地址到映射

第二個問題的答案顯然就是會出現段錯誤。因為這一行代碼注釋之后,就沒有辦法開啟虛擬地址了。不得不說這樣的實驗設計蠻棒的

4.2 Exercise 8

這里要在/lib/printfmt.c這個下做一些改動

搞明白print.c的調用鏈cprintf -> vcprintf -> vprintfmt -> putch -> cputchar

// (unsigned) octal
		case 'o':
			// Replace this with your code.
			// putch('X', putdat);
			// putch('X', putdat);
			// putch('X', putdat);
			num = getuint(&ap,lflag);
			base = 8;
			goto number;
			break;

4.3 Exercise9

判斷一下操作系統內核是從哪條指令開始初始化它的堆棧空間的,以及這個堆棧坐落在內存的哪個地方?內核是如何給它的堆棧保留一塊內存空間的?堆棧指針又是指向這塊被保留的區域的哪一端的呢?

前面有分析到main.c的最后一行代碼是要進入Entry.S.所以直接進入entry.s中

從注釋里面可以看到堆棧指針應該是在這兩行設置的

# Clear the frame pointer register (EBP)
	# so that once we get into debugging C code,
	# stack backtraces will be terminated properly.
	movl	$0x0,%ebp			# nuke frame pointer

	# Set the stack pointer
	movl	$(bootstacktop),%esp

image-20210620164140395

這里通過斷點找到這兩行到底在干什么。這里的esp寄存器就是棧指針寄存器。而ebp寄存器是棧幀的基地址指針

這里把0xf0110000賦給esp這個寄存器。就表示我們的棧是從這個地址開始。那么他的大小為多少那

bootstack:
	.space		KSTKSIZE
	.globl		bootstacktop   

這幾行代碼就為他制定了大小大小為32kb。因此整個棧的地址區間就為 0xf0108000-0xf0110000的范圍。

4.4 Exercise 10

  為了能夠更好的了解在x86上的C程序調用過程的細節,我們首先找到在obj/kern/kern.asm中test_backtrace子程序的地址,設置斷點,並且探討一下在內核啟動后,這個程序被調用時發生了什么。對於這個循環嵌套調用的程序test_backtrace,它一共壓入了多少信息到堆棧之中。並且它們都代表什么含義?

好下面就開始看源碼和打斷點

先看c語言的代碼然后再分析匯編代碼

voidtest_backtrace(int x){    cprintf("entering test_backtrace %d\n", x);    if (x > 0)        test_backtrace(x-1);    else        mon_backtrace(0, 0, 0);    cprintf("leaving test_backtrace %d\n", x);}

可以發現這里是一個遞歸調用的過程,輸入x的表示調用次數

好下面切換到匯編語言。先看一下在函數執行之前的棧指針的一些信息。可以發現這里兩個寄存器的值和我們在調用i386_init之前一摸一樣。

image-20210620171256439

當第一次進入這個函數的時候x=5.這表示我們要執行這個代碼五次

而這個代碼也就是一個簡單的調用。由於下一個問題就是要實現對於調用的trace函數。所以我們在下面進行講解

4.5 Exercise 11

    實現backtrace子程序。來進行堆棧的回溯

  這個函數應該能夠展示出下面這種格式的信息:

  Stack backtrace:

 ebp f0109358 eip f0100a62 args 00000001 f0109e80 f0109e98 f0100ed2 00000031

​ ebp f0109ed8 eip f01000d6 args 00000000 00000000 f0100058 f0109f28 00000061

  這個子程序的功能就是要顯示當前正在執行的程序的棧幀信息。包括當前的ebp寄存器的值,這個寄存器的值代表該子程序的棧幀的最高地址。eip則指的是這個子程序執行完成之后要返回調用它的子程序時,下一個要執行的指令地址。后面的值就是這個子程序接受的來自調用它的子程序傳遞給它的輸入參數。下面這張圖對棧幀做了很好的解釋

  img

根據上圖我們可以很輕松的獲取我們想要的參數。

返回地址 ebp+ 4

參數1 ebp + 8

...........

所以綜上所述,只要我們知道當前運行程序的ebp寄存器的值就可以,之后至於其他的我們都可以根據ebp寄存器的值推導出來。

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
	// Your code here.
	cprintf("Start backtrace\n");
	uint32_t ebp = read_ebp();
	while(ebp){
		uint32_t *stack_frame = (uint32_t *)(ebp);
		cprintf("ebp %08x  eip %08x  args %08x %08x %08x %08x %08x\n",
				ebp,		 /*ebp*/
				stack_frame[1],   /*eip*/
				stack_frame[2],   /*arg1*/
				stack_frame[3],  /*arg2*/
				stack_frame[4],  /*arg3*/
				stack_frame[5],  /*arg4*/
				stack_frame[6]); /*arg5*/
		ebp = stack_frame[0];
	}
	return 0;
}

這里的代碼看起來非常簡單。但還是需要有理解的。首先這里的ebp獲取到的是一個指針。因此我們想要獲得到ebp的值的話。需要通過數組訪問,或者直接取值操作。同時需要理解一個非常重要的點。就是下面這幾行匯編代碼

f0100044:	55                   	push   %ebp
f0100045:	89 e5                	mov    %esp,%ebp
f0100047:	56                   	push   %esi
f0100048:	53                   	push   %ebx

這里是test_backtrace遞歸的開始。這里每次都先把ebp入棧。然后再把esp的值賦給ebp。也就是說下一次調用的時候。它入棧的ebp寄存器里就存儲了上一次的esp指針。通過這個就可以找到每一次調用的棧幀的起始地址分別在哪里。這里如果看過csapp第三章的話,這個應該非常好理解

4.6 Exercise 12

這次我們需要修改stack backtrace函數,讓它顯示每一個eip, func_name, source_file_name, line_number。為了幫助實現這些功能,在kern/kdebug.c中已經實現了一個函數debuginfo_eip(),這個函數能夠查找eip的符號表然后返回關於該地址的debug信息。

這里要修改我們之前的mon_backtrace函數。其實改動也不大只需要把debuginfo_eip加進去就好了

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
	// Your code here.
	cprintf("Start backtrace\n");
	uint32_t ebp = read_ebp();
	struct Eipdebuginfo info;
	while(ebp){
		uint32_t *stack_frame = (uint32_t *)(ebp);
		cprintf("ebp %08x  eip %08x  args %08x %08x %08x %08x %08x\n",
				ebp,		 /*ebp*/
				stack_frame[1],   /*eip*/
				stack_frame[2],   /*arg1*/
				stack_frame[3],  /*arg2*/
				stack_frame[4],  /*arg3*/
				stack_frame[5],  /*arg4*/
				stack_frame[6]); /*arg5*/
		uint32_t eip = stack_frame[1];
		debuginfo_eip(eip,&info);
		cprintf("     %s:%d: %.*s+%d\n", info.eip_file, info.eip_line,
				info.eip_fn_namelen, info.eip_fn_name, eip - info.eip_fn_addr);
	
		ebp = stack_frame[0];
	}
	return 0;
}

這樣就可以過掉所有的test了

image-20210620205514098

就可以拿到滿分了over

5. 總結

總的來說lab1對於os的初學者應該還是蠻難吧。我個人感覺還可以,因為真的有太多的參考資料了,英文資料懶得看直接去看別人的翻譯還有博客等等,當然代碼還都是自己寫的了(不過一共也沒幾行代碼的說)因此這里的不過有一些回答我沒寫到博客上,因為網上資料還是超多的。希望lab2好運嘿嘿


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM