mit6.828 - lab1筆記

toso發表於2024-05-07

1. PC啟動

開啟兩個視窗,在第一個視窗中 make qemu-gdb,會啟動核心,但在執行第一個指令之前停下;
在第二個視窗中make gdb,實時觀察第一個視窗中的執行過程。

image.png

從這裡可以觀察到:

  • IBM PC 在實體地址 0x000ffff0 開始執行, 位於為 ROM BIOS 保留的 64KB 區域的最頂部。
  • PC 的第一個指令執行的是 CS=0xf000 IP=0xfff0
  • 第一條指令是 jmp 指令, 跳轉到分段地址 CS = 0xf000 和 IP = 0xe05b。

image.png

## 為什麼第一個指令在這個位置?
這是因為 8088的BIOS 是“硬連線”的 到實體地址範圍 0x000f0000-0x000fffff, 從而確保BIOS首先獲得對機器的控制
0xffff0 是 BIOS 結束前的 16 個位元組 (0x100000),BIOS做的第一件事是向後jmp 到 BIOS 中較早的位置;

2. bootloader

bootloader 的開始
bootsec 如果磁碟是可啟動的, 第一個扇區稱為 boot sector, 因為這是引導載入程式程式碼所在的位置。

當 BIOS 找到可啟動軟盤或硬碟時, 會將其載入(512位元組)至實體地址的記憶體的0x7c00 0x7dff。然後64KB大小的BOIS的最後一句話即是:
jmp $0x0000,$0x7c00
將控制轉交給了 bootloader

image.png

boot loader 的任務有兩個:

  1. 將處理器從真實模式切換到保護模式。因為真實模式最多隻能訪問1MB的記憶體。
  2. 從硬碟讀取核心,載入到記憶體。bootstrap使用特殊I/O指令,直接訪問IDE磁碟裝置儲存器來讀取。

boot loader 的實現:
一個組合語言原始檔,boot/boot.S
一個 C 原始檔 boot/main.c
反彙編檔案: obj/boot/boot.asm

先看程式碼、然後看反彙編、再除錯,摸清楚 boot loader 的流程

閱讀原始碼

boot/boot.S的內容:

  1. 載入全域性描述符表 GDT
  2. 開啟保護模式:將CR0暫存器的PE_ON位置1
  3. 透過ljmp進入保護模式
  4. 載入各個段描述符
  5. 跳轉至 bootmain.c

boot/bootmain.c的內容

  1. 載入kernel的elf檔案頭:從硬碟1號扇區(第二個扇區)的起始處讀取4KB大小的內容至 0x0010_0000處,並將其視為ELF結構體
  2. 將 kernel 的各個段載入至記憶體

boot/boot.S

boot.S 中有一個令人迷惑的程式碼:

image.png

在即將跳轉到C語言實現的bootmain的時候,居然將 start標號 給了esp,那麼 start 代表了什麼?

image.png

啊,start位於程式碼的一開始的地方,這裡不是應該存程式碼嗎?給了esp,後面棧不得把這下面的程式碼的都給覆蓋了?
稍等下,棧是從高地址向低地址生長的,這裡boot.S的程式碼在ide裡看雖然寫在start下面,但是在記憶體裡是start更高的地方。從 obj/boot.asm 裡來看:

image.png

start 位於 0x7C00,之後的程式碼位於0x7C00之上,而棧則向0x7C00下方生長

image.png

boot/main.c

boot/bootmain.c的內容

  1. 載入kernel的elf檔案頭:從硬碟1號扇區(第二個扇區)的起始處讀取4KB大小的內容至 0x0010_0000處,並將其視為ELF結構體
  2. 將 kernel 的各個段載入至記憶體

image.png

其中的迴圈會逐個將 /obj/kern/kernl 的段載入至對應的實體地址(注意,readseg 的第一個引數是 ph->p_pa),可以透過 objdump -l kernel 檢視:

image.png

最終記憶體檢視如下:

image.png

哈哈,後來發現這張圖有有挺多的問題:

  1. 0x0010_0000 確實是比較準確的 .text段開頭,但是 0x0010_000~0x0010_7120 是 .text、.rodata、.stab、.stabstr 段組成的可讀,可執行段;相應的, 0x0010_800~0x0011_3000 是.data段和.bss段組成的可讀、可寫段
  2. 這張圖將段的地址寫的太死了,實際上,如果程式碼寫多了些或者寫少了些,都會改變這些地址,比如程式碼寫多了,可讀,可執行段的結尾肯定在0x0010_7120之後。所以這個圖果然還是用 kernel.ld 中的標號(例如 edataend_start)來標註更清晰一些。

image.png


##### 看反彙編發現了一些有趣的事情:
1. 迴圈中,呼叫函式後的遞增操作,在彙編層面會在呼叫之前發生

![image.png](https://pic-bed-1258913394.cos.ap-nanjing.myqcloud.com/20240501213701.png)


2. 呼叫前,呼叫者負責傳參,被調者負責保護現場,還原現場;返回後,呼叫者負責將傳參佔用的空間還原


關於ELF和編譯連結

在開發者完成一個C語言程式程式 xxx.c ,為了讓他跑起來,需要由編譯器將其編譯成 xxx.o 的物件檔案,然後由連結器將所有已經編譯的物件檔案連結成 xxx 可執行檔案。


3. 核心

目的:理解lab1的簡易核心的工作過程

任務:閱讀 /kern 下的程式碼。

lab1的核心功能十分簡單,如上文中執行起來的那樣,他的shell只提供兩個功能,help和kerninfo。
核心相關的程式碼位於 /kern 之下。

entry.S:初始化記憶體對映,設定頁表、棧指標
entrypgdir.c:頁表設計

init.c:初始化shell,初始化終端裝置、啟動shell
console.h, console.c:終端功能的實現
printf.c:列印功能的實現
monitor.h, monitor.c:shell功能的實現

挺好,為了理解 lab1 的核心,接下來就沿著 entry.S 和 init.c 去分析核心。
即,分析entry.S對記憶體對映的處理、init.c 中終端裝置的初始化shell的處理

記憶體對映的處理

關於記憶體的處理,lab1目前沒有記憶體管理,只是用起來了虛擬記憶體,將4MB實體記憶體對映到原位和高處。即:

  • 0x00000000 至 0x00400000 的實體地址 -> 0x00000000 至 0x00400000 的虛擬地址
  • 0x00000000 至 0x00400000 的實體地址 -> 0xf0000000 至 0xf0400000 的虛擬地址
    畢竟這麼大的記憶體已經足夠對映當前核心了。

先來看看怎麼對映的

entry.S:載入頁表

在 boolloader 階段,bootmain 最後透過 ((void (*)(void)) (ELFHDR->e_entry))();
將控制轉交給了 /kern/entry.S,然後來看看entry.S

image.png

關於陣列 entry_pgdir

entry.S 首先讀取了頁表 entry_pgdir,這個變數在 /kern/entrypgdir.c 中定義:

pte_t entry_pgtable[NPTENTRIES];

__attribute__((__aligned__(PGSIZE)))
pde_t entry_pgdir[NPDENTRIES] = {
	// Map VA's [0, 4MB) to PA's [0, 4MB)
	[0]
		= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P,
	// Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
	[KERNBASE>>PDXSHIFT]
		= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W
};

__attribute__((__aligned__(PGSIZE)))
pte_t entry_pgtable[NPTENTRIES] = {
	0x000000 | PTE_P | PTE_W,
	0x001000 | PTE_P | PTE_W,
	0x002000 | PTE_P | PTE_W,
	0x003000 | PTE_P | PTE_W,
	0x004000 | PTE_P | PTE_W,
	0x005000 | PTE_P | PTE_W,
	0x006000 | PTE_P | PTE_W,
	0x007000 | PTE_P | PTE_W,
	0x008000 | PTE_P | PTE_W,
	0x009000 | PTE_P | PTE_W,
	0x00a000 | PTE_P | PTE_W,
	//省略...
}

其中 [0] = ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P, 實現了
0x00000000 至 0x00400000 的實體地址 -> 0x00000000 至 0x00400000 的虛擬地址
[KERNBASE>>PDXSHIFT] = ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W 實現了
0x00000000 至 0x00400000 的實體地址 -> 0xf0000000 至 0xf0400000 的虛擬地址

關於頁表的工作原理,還是等到lab2再學習吧,這裡先放一張圖,說明下目前實體地址和虛擬空間的對映情況:

image.png

關於宏 RELOC

從程式碼中可以看到,在頁表載入之前,所有的符號都需要使用宏 RELOC ,其含義是將符號的地址減去 0xF000_0000,即,將虛擬地址轉化為真實的實體地址。
這就說明 entry.S 被連結到了 0xF000_0000 上。
透過 objdump -h 來看也確實如此

image.png

但是對應的makefile是將其指定到 0xf000_0000 上的,可以從 /kern/kernel.ld 中找到

image.png

關於 bootstack

把目光回到 entry.S 的程式碼,在程式碼的最後透過標號 bootstack 和 bootstacktop定義了棧的位置,話說,這裡究竟對應的實體地址是哪裡呢?

image.png

可以看到 bootstack 緊鄰 .data 段
透過 readelf -s kernel 檢視

image.png

結合 objdump -h kernel

image.png

確實如此,bootstack 和 .data都位於 0xf010_8000 ,那麼實體地址就是 0x0010_8000
棧頂 bootstacktop 的實體地址則是 0x0011_0000。


init.c:核心初始化

init.c 中最核心的函式是 i386_init

image.png

關於 清空BSS段

edata[]end[] 是在哪裡定義的?這兩個變數看起來指的是bss段的開始和結束。
這種問題當然要去看連結指令碼了,檢視 kern/kernel.ld

image.png

顯示輸出的處理

這裡涉及的程式碼有

kern:
	console.h, console.c :涉及終端裝置的初始化
	printf.c :涉及printf的實現
lib:
	printfmt.c:支撐printf的實現
	readline.c:實現從終端讀取
	string.c:涉及字串的處理,支撐printf的實現
inc:
	string.h:涉及字串的處理,支撐printf的實現
關於 cons_init

這裡主要用於初始化終端顯示器的硬體設定,其中程式碼使用匯編,透過in out指令與裝置互動,不過多深究了。
image.png

關於 printf 的實現

printf 的實現這裡借大佬的說明圖示意:

image.png

往控制檯寫字串,本質還是往實體地址0xB8000開始的視訊記憶體寫資料

jos 的練習提到 printf 的實現需要補充,具體位於 /lib/printfmt.c : vprintfmt 中

image.png

image.png

shell的處理

這裡涉及的程式碼有

kern:
	monitor.h, monitor.c :命令的解析、各種命令的實現
關於monitor的實現

先看看 monitor.h

image.png

然後看看 monitor.c

image.png

這麼看,只要在 commands[] 中填充 backtrace 的資料就可以補充這個功能了。

image.png

monitor 是怎麼實現的呢?,比較短,直接放程式碼了

void
monitor(struct Trapframe *tf)
{
	char *buf;

	cprintf("Welcome to the JOS kernel monitor!\n");
	cprintf("Type 'help' for a list of commands.\n");


	while (1) {
		buf = readline("K> ");
		if (buf != NULL)
			if (runcmd(buf, tf) < 0)
				break;
	}
}

本質就是一個迴圈,列印出 K> 然後接受輸入,然後根據輸入執行命令。看起來就像是大一C語言課設的XXX管理系統一樣。
看看 runcmd 如何實現:

image.png

挺好,那麼現在我們要做的就是實現 backtrace。

堆疊

涉及到的程式碼:

kern:
	kdebug.h、kdebug.c:涉及Eipdebuginfo和debuginfo_eip的實現
inc:
	stab.h:涉及Stab表的資料結構
	x86.h:涉及讀取暫存器的內斂彙編

這裡我們迴歸到jos的學習任務,研究關於棧幀的處理。並補充一些函式:
/kern/monitor.c:mon_backtrace
/kern/kdebug.c:debuginfo_eip、stab_binsearch

關於backtrace的實現

關於棧幀
棧幀,就是呼叫函式的時候,處理形參傳遞和實參儲存的資料結構。
在呼叫函式時,呼叫者負責傳遞形參,被調者負責保護現場、恢復現場,最後呼叫者將形參釋放掉。
這之中需要呼叫者和被調者的約定:
比如 函式列表中的引數,是從右至左的順序入棧的之類的。

這裡繼續借用大佬 gatsby123 部落格中的圖,簡單示意,不做深究

image.png

jos的練習11 讓我們完成 mon_backtrace,希望我們將每個棧幀按照這樣的格式輸出:

Stack backtrace:
  ebp f0109e58  eip f0100a62  args 00000001 f0109e80 f0109e98 f0100ed2 00000031
  ebp f0109ed8  eip f01000d6  args 00000000 00000000 f0100058 f0109f28 00000061
  ...

不過好在 jos 已經實現了一些函式,供我們呼叫了,位於 /inc/x86.h
這裡提供了一些內聯彙編,用於讀取各種暫存器的值

image.png

完成這一步也是很簡單啦

image.png

但是 jos 的練習12上了強度,讓我們列印出這樣的效果:

image.png

就是在上面的基礎上,顯示當前棧幀所在的檔案和,以及呼叫在檔案的所在函式的第幾行發生。
為了實現這一功能,jos 在kern/kdebug.h 和 kern/kdebug.c 中提供了支援:

image.png

可以看到 Eipdebuginfo 用於儲存當前eip的相關資訊。這種功能的背後當然需要編譯器的支援,為了方便debug,編譯器可以透過stab將這些資訊儲存下來,

關於stab

按照 exercise12 的提示,透過 kernel.ld 可以看到.stab和 .stabstr 的相關連線選項

image.png

可以看到其中定義了 __STAB_BEGIN__ __STAB__END__ __STABSTR_BEGIN__ __STABSTR_END__

透過 objdump -h obj/kern/kernel 可以看到 stab 表

image.png

透過 objdump -G obj/kern/kernel 可以看到stab的內容

image.png

其中包含1213項,每項包括

symnum:序號
n_type:型別
n_othor:雜項資訊
n_desc:描述資訊
n_value:表示地址。特別要注意的是,這裡只有FUN型別的符號的地址是絕對地址,SLINE符號的地址是偏移量,
n_strx:stabstr表中對應的字串的序號
string:stabstr表中對應的字串

在 stab.h中有對應的資料結構:

image.png
那麼這些資訊要怎麼使用呢,看看kdebug.c


stab_binsearch(stabs, region_left, region_right, type, addr)

某些符號表項型別按指令地址遞增順序排列。 例如,標記函式的 N_FUN 符號表項(n_type == N_FUN 的符號表項)和標記原始檔的 N_SO 符號表項。

給定指令地址後,該函式會查詢包含該地址的 "type "型別的符號表項。

搜尋範圍為[*region_left, *region_right]。

因此,要搜尋一整套 N 個符號表項,可以執行以下操作

// left = 0;
// right = N - 1; /* 最右邊的符號表項 */
// stab_binsearch(stabs, &left, &right, type, addr);

搜尋會修改 *region_left 和 *region_right 以括住 "addr"。 *region_left 指向包含'addr'的匹配符號表項,*region_right 指向下一個符號表項之前。 如果 *region_left > *region_right,則表示 "addr "不包含在任何匹配的符號表項中。

// 例如,給定這些 N_SO 符號表項:
// 索引型別 地址
// 0 SO f0100000
// 13 SO f0100040
// 117 SO f0100176
// 118 SO f0100178
// 555 SO f0100652
// 556 SO f0100654
// 657 SO f0100849
// 此程式碼:
// left = 0, right = 657;
// stab_binsearch(stabs, &left, &right, N_SO, 0xf0100184);
// 將退出設定 left = 118, right = 554.

這裡給出了 stab_binsearch 的使用說明,從函式名可以看出來他是使用二分查詢演算法從stab中查詢addr指定的type型別的符號,然後透過left返回出來。來簡單看看程式碼:

image.png

然後來看看要處理的 debuginfo_eip

image.png

image.png

到現在為止,已經找到了所在檔名、所在函式名、所在函式地址、所在函式名長度、相對函式的偏移
就差所在行號了,找行號的程式碼很好寫啊,照著寫就行了,這個函式呼叫,將範圍改一下,然後型別改成程式碼段的行就行了,因為eip只會在程式碼段裡移動。

stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);

但是,行號究竟是stab中的哪個成員提供的啊?
image.png

觀察一波 objdump -G 的輸出

image.png

目測 n_value對應的是SLINE的記憶體地址,而n_desc看著更像行號一些,於是:

image.png

補充一下 monitor.c

image.png

編譯測試一下:

image.png

看著好像成功了,試試評分

image.png

收工。

相關文章