安裝環境編譯qemu 1. PC啟動 打開兩個視窗,在第一個視窗中 make qemu-gdb,會啟動內核,但在執行第一個指令之前停下; 在第二個視窗中make gdb,實時觀察第一個視窗中的執行過程。 從這裡可以觀察到: IBM PC 在物理地址 0x000ffff0 開始執行, 位於為 ROM ...
安裝環境編譯qemu
1. PC啟動
打開兩個視窗,在第一個視窗中 make qemu-gdb
,會啟動內核,但在執行第一個指令之前停下;
在第二個視窗中make gdb
,實時觀察第一個視窗中的執行過程。
從這裡可以觀察到:
- IBM PC 在物理地址 0x000ffff0 開始執行, 位於為 ROM BIOS 保留的 64KB 區域的最頂部。
- PC 的第一個指令執行的是 CS=0xf000 IP=0xfff0
- 第一條指令是 jmp 指令, 跳轉到分段地址 CS = 0xf000 和 IP = 0xe05b。
## 為什麼第一個指令在這個位置?
這是因為 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
boot loader 的任務有兩個:
- 將處理器從實模式切換到保護模式。因為實模式最多只能訪問1MB的記憶體。
- 從硬碟讀取內核,載入到記憶體。bootstrap使用特殊I/O指令,直接訪問IDE磁碟設備存儲器來讀取。
boot loader 的實現:
一個彙編語言源文件,boot/boot.S
一個 C 源文件 boot/main.c
反彙編文件: obj/boot/boot.asm
先看代碼、然後看反彙編、再調試,摸清楚 boot loader 的流程
閱讀源碼
boot/boot.S的內容:
- 載入全局描述符表 GDT
- 開啟保護模式:將CR0寄存器的PE_ON位置1
- 通過ljmp進入保護模式
- 載入各個段描述符
- 跳轉至 bootmain.c
boot/bootmain.c的內容
- 載入kernel的elf文件頭:從硬碟1號扇區(第二個扇區)的起始處讀取4KB大小的內容至 0x0010_0000處,並將其視為ELF結構體
- 將 kernel 的各個段載入至記憶體
boot/boot.S
boot.S 中有一個令人迷惑的代碼:
在即將跳轉到C語言實現的bootmain的時候,居然將 start標號 給了esp,那麼 start 代表了什麼?
啊,start位於代碼的一開始的地方,這裡不是應該存代碼嗎?給了esp,後面棧不得把這下麵的代碼的都給覆蓋了?
稍等下,棧是從高地址向低地址生長的,這裡boot.S的代碼在ide里看雖然寫在start下麵,但是在記憶體里是start更高的地方。從 obj/boot.asm 里來看:
start 位於 0x7C00,之後的代碼位於0x7C00之上,而棧則向0x7C00下方生長
boot/main.c
boot/bootmain.c的內容
- 載入kernel的elf文件頭:從硬碟1號扇區(第二個扇區)的起始處讀取4KB大小的內容至 0x0010_0000處,並將其視為ELF結構體
- 將 kernel 的各個段載入至記憶體
其中的迴圈會逐個將 /obj/kern/kernl 的段載入至對應的物理地址(註意,readseg 的第一個參數是 ph->p_pa
),可以通過 objdump -l kernel 查看:
最終記憶體視圖如下:
##### 看反彙編發現了一些有趣的事情:
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
關於數組 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 的虛擬地址
關於頁表的映射和計算方法,見另一個單獨的筆記 "lab1 關於頁表的知識"
關於巨集 RELOC
從代碼中可以看到,在頁表載入之前,所有的符號都需要使用巨集 RELOC ,其含義是將符號的地址減去 0xF000_0000,即,將虛擬地址轉化為真實的物理地址。
這就說明 entry.S 被鏈接到了 0xF000_0000 上。
通過 objdump -h 來看也確實如此
但是對應的makefile是將其指定到 0xf000_0000 上的,可以從 /kern/kernel.ld 中找到
關於 bootstack
把目光回到 entry.S 的代碼,在代碼的最後通過標號 bootstack 和 bootstacktop定義了棧的位置,話說,這裡究竟對應的物理地址是哪裡呢?
可以看到 bootstack 緊鄰 .data 段
通過 readelf -s kernel 查看
結合 objdump -h kernel
確實如此,bootstack 和 .data都位於 0xf010_8000 ,那麼物理地址就是 0x0010_8000
棧頂 bootstacktop 的物理地址則是 0x0011_0000
在記憶體里看呢?
init.c:內核初始化
init.c 中最核心的函數是 i386_init
關於 清空BSS段
edata[]
和 end[]
是在哪裡定義的?這兩個變數看起來指的是bss段的開始和結束。
這種問題當然要去看鏈接腳本了,查看 kern/kernel.ld
顯示輸出的處理
這裡涉及的代碼有
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指令與設備交互,不過多深究了。
關於 printf 的實現
printf 的實現這裡借大佬的說明圖示意:
往控制台寫字元串,本質還是往物理地址0xB8000開始的顯存寫數據
jos 的練習提到 printf 的實現需要補充,具體位於 /lib/printfmt.c : vprintfmt 中
shell的處理
這裡涉及的代碼有
kern:
monitor.h, monitor.c :命令的解析、各種命令的實現
關於monitor的實現
先看看 monitor.h
然後看看 monitor.c
這麼看,只要在 commands[]
中填充 backtrace 的數據就可以補充這個功能了。
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 如何實現:
挺好,那麼現在我們要做的就是實現 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 博客中的圖,簡單示意,不做深究
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
這裡提供了一些內聯彙編,用於讀取各種寄存器的值
完成這一步也是很簡單啦
但是 jos 的練習12上了強度,讓我們列印出這樣的效果:
就是在上面的基礎上,顯示當前棧幀所在的文件和,以及調用在文件的所在函數的第幾行發生。
為了實現這一功能,jos 在kern/kdebug.h 和 kern/kdebug.c 中提供了支持:
可以看到 Eipdebuginfo 用於存儲當前eip的相關信息。這種功能的背後當然需要編譯器的支持,為了方便debug,編譯器可以通過stab將這些信息保存下來,
關於stab
按照 exercise12 的提示,通過 kernel.ld 可以看到.stab和 .stabstr 的相關連接選項
可以看到其中定義了 __STAB_BEGIN__ __STAB__END__ __STABSTR_BEGIN__ __STABSTR_END__
通過 objdump -h obj/kern/kernel 可以看到 stab 表
通過 objdump -G obj/kern/kernel 可以看到stab的內容
其中包含1213項,每項包括
symnum:序號
n_type:類型
n_othor:雜項信息
n_desc:描述信息
n_value:表示地址。特別要註意的是,這裡只有FUN類型的符號的地址是絕對地址,SLINE符號的地址是偏移量,
n_strx:stabstr表中對應的字元串的序號
string:stabstr表中對應的字元串
在 stab.h中有對應的數據結構:
那麼這些信息要怎麼使用呢,看看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返回出來。來簡單看看代碼:
然後來看看要處理的 debuginfo_eip
到現在為止,已經找到了所在文件名、所在函數名、所在函數地址、所在函數名長度、相對函數的偏移
就差所在行號了,找行號的代碼很好寫啊,照著寫就行了,這個函數調用,將範圍改一下,然後類型改成代碼段的行就行了,因為eip只會在代碼段里移動。
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
但是,行號究竟是stab中的哪個成員提供的啊?
觀察一波 objdump -G 的輸出
目測 n_value對應的是SLINE的記憶體地址,而n_desc看著更像行號一些,於是:
補充一下 monitor.c
編譯測試一下:
看著好像成功了,試試評分
收工。