MIT6.828-LAB1 : PC啟動

周小倫發表於2021-06-20

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好運嘿嘿

相關文章