MIT 6.828 JOS學習筆記10. Lab 1 Part 3: The kernel


Lab 1 Part 3: The kernel

  現在我們將開始具體討論一下JOS內核了。就像boot loader一樣,內核開始的時候也是一些匯編語句,用於設置一些東西,來保證C語言的程序能夠正確的執行。

使用虛擬內存

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

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

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

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

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

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


 

  Exercise 7:

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

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

  解答:

  我們可以首先設置斷點到0x10000C處,因為我們在之前的練習中已經知道了,0x10000C是內核文件的入口地址。  然后我們從這條指令開始一步步運行,直到碰到movl %eax, %cr0指令。在這條指令運行之前,地址0x00100000和地址0xf0100000兩處存儲的內容是:

  

  可見當前這兩地址處的值是不一樣的。

  然后輸入stepi命令(其實就是si命令),再查看兩個位置:

  

  我們會發現兩處存放的值已經一樣了! 可見原本存放在0xf0100000處的內容,已經被映射到0x00100000處了。

  

  第二問需要我們把entry.S文件中的%movl %eax, %cr0這句話注釋掉,重新編譯內核。我們需要先make clean,然后把%movl %eax, %cr0這句話注釋掉,重新編譯。 再次用qemu仿真,並且設置斷點到0x10000C處,開始一步步執行。通過一步步查詢發現了出現錯誤的一句。

  

  其中在0x10002a處的jmp指令,要跳轉的位置是0xf010002C,由於沒有進行分頁管理,此時不會進行虛擬地址到物理地址的轉化。所以報出錯誤,下面是make qemu-gdb這個窗口中出現的信息。

  

  可見你當前訪問的邏輯地址超出內存了。


 

格式化輸出到控制台(屏幕)

  我們經常會在編程時使用到printf子程序,這個子程序是在操作系統的內核中實現的。這一小部分就是要探究一下這種格式化輸出子程序的實現方式。

    通讀kern/printf.c,lib/printfmt.c和kern/console.c三個C語言程序(在Exercise 8的解答中有具體的分析),並且確保你能夠理解他們之間的關系。在后邊的實驗中我們會弄清楚為什么printfmt.c子程序會放在lib文件夾下。

  Exercise 8:http://www.cnblogs.com/fatsheep9146/p/5066690.html


 

    回答下試驗報告中Exercise 8后面的問題:

  1. 解釋一下printf.c和console.c兩個之間的關系。console.c輸出了哪些子函數?這些子函數是怎么被printf.c所利用的?

  答:在Exercise 8的解答中我們已經很具體的分析了兩個文件,在console.c中除了被static修飾符修飾的函數之外,都可以被外部所使用,其中被printf所使用的函數就是cputchar子函數。

  2. 解釋一下console.c文件中,下面這段代碼的含義:

1 if (crt_pos >= CRT_SIZE) {
2        int i;
3        memcpy(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
4        for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
5                crt_buf[i] = 0x0700 | ' ';
6        crt_pos -= CRT_COLS;
7 }

 

  答:首先看下里面的幾個變量:

    crt_buf:這是一個字符數組緩沖區,里面存放着要顯示到屏幕上的字符

    crt_pos:這個表示當前最后一個字符顯示在屏幕上的位置,在介紹這個變量前我們還要知道一些知識,這是我在網上自己查詢的。

      早期的計算機如果想顯示信息給用戶只能通過文字模式,比如當你現在打開電腦時,進入桌面之前,所有的信息都是通過文字顯示在屏幕上的。那么這種模式就叫做文字模式,那么這個console.c源程序中考慮的就是一種非常常見的文字模式,80x25文字模式,即整個屏幕上允許顯示最多25行字符,每行最多顯示80個字符。所以一共代表了80x25個位置。當我們要顯示某個特定字符到屏幕某個位置上面時,我們必須要指定顯示的位置,和顯示字符給屏幕驅動器cga。

    而在console.c文件中,子程序cga_putc(int c)就是完成這項功能,把字符c顯示到屏幕當前顯示的下一個位置。比如當前屏幕中已經顯示了三行數據(0號行,1號行,2號行),並且第三行已經顯示了40個字符,此時執行cga_putc(0x65),那么就會把0x65對應的字符'A'顯示到2號行第41個字符處。所以cga_putc需要兩個變量,crt_buf,這個一個字符數組指針,該字符數組就是當前顯示在屏幕上的所有字符。crt_pos則表示下一個要顯示的字符存放在數組中的位置,其實通過這個值也可以推導出它顯示在屏幕上的位置。比如crt_pos = 85,那么它就應該顯示在第2行(即1號行),第6字符(5號字符)處。所以crt_pos的取值范圍應該是從0~(80*25-1)。

    上面題目中要分析的這段代碼位於cga_putc中,cga_putc的分為三部分,第一部分是根據字符值int c來判斷到底要顯示成什么樣子。而第二部分就是上述代碼。第三部分則是把你決定要顯示的字符顯示到屏幕的指定位置上。咱們具體分析第二部分,

    當crt_pos >= CRT_SIZE,其中CRT_SIZE = 80*25,由於我們知道crt_pos取值范圍是0~(80*25-1),那么這個條件如果成立則說明現在在屏幕上輸出的內容已經超過了一頁。所以此時要把頁面向上滾動一行,即把原來的1~79號行放到現在的0~78行上,然后把79號行換成一行空格(當然並非完全都是空格,0號字符上要顯示你輸入的字符int c)。所以memcpy操作就是把crt_buf字符數組中1~79號行的內容復制到0~78號行的位置上。而緊接着的for循環則是把最后一行,79號行都變成空格。最后還要修改一下crt_pos的值。

 3. 觀察下面的一串代碼:

int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);

  回答下列問題:

    * 當調用cprintf時,fmt指向的是什么內容,ap指向的是什么內容。

    * 按照執行的順序列出所有對cons_putc, va_arg,和vcprintf的調用。對於cons_putc,列出它所有的輸入參數。對於va_arg列出ap在執行完這個函數后的和執行之前的變化。對於vcprintf列出它的兩個輸入參數的值。

  答:

  觀察cprintf函數:

 1 int
 2 cprintf(const char *fmt, ...)
 3 {
 4     va_list ap;
 5     int cnt;
 6 
 7     va_start(ap, fmt);
 8     cnt = vcprintf(fmt, ap);
 9     va_end(ap);
10 
11     return cnt;
12 }
cprintf(const char *fmt, ...)

  回答第一個問題,首先fmt自然指向的是顯示信息的格式字符串,那么在這段代碼中,它指向的就是"x %d, y %x, z %d\n"字符串。而ap是va_list類型的。我們之前已經介紹過,這個類型專門用來處理輸入參數的個數是可變的情況。所以ap會指向所有輸入參數的集合。

  繼續觀察,發現cprint中調用了vcprintf函數,並且把格式字符串fmt,所有的參數列表ap(包含x,y,z)作為輸入參數傳給了vcprintf,然后vcprintf調用在\lib\printfmt.c中的vprintfmt子程序,並且傳遞給它4個參數。第1個參數是一個顯示字符的子程序:這里采用的是printf.c文件中自己定義的putch函數。這個函數可以把字符顯示到屏幕上。然后在傳遞一個值為0的變量的引用給第2個參數。原本第2個參數的含義是一個內存地址,並且第1個參數函數指針所指向的函數應該能夠把字符寫入到第2個參數所指定的地址處。但是由於我們的第1個參數是顯示數據到屏幕。所以這里不需要第2個參數了。所以此時我們把一個變量引用作為第2個參數,是把它當做計數器,記錄顯示了多少字符。第3,4字符的含義沒有變,和cprintf的參數一樣。

  然后進入vprintfmt子程序。這個子程序我們已經分析過。這里就不再贅述了。這個子程序的工作過程就是,不停的分析格式字符串fmt。分析采取的方式是把格式字符串划分成多個部分,每個部分都至多帶有一個待顯示的參數,比如我們這道題中的格式字符串就可以被划分為4個部分:

  "x %d", ", y %x" , ", z %d", "\n"

  然后先分析每個部分中%號前面的字符串,並且直接輸出。比如"x %d"中"x "。然后分析%號后面的內容,比如"x %d"中分析的結果就是要按照10進制顯示一個參數。每當分析完%號后面的內容,程序就會按照分析的結果來進行不同的操作。在分析完"x %d"后,代碼開始執行下面這個分支:

1         case 'd':
2             num = getint(&ap, lflag);                        //根據你的整數類型到底是int,還是long,還是long long,從參數列表ap中取出相應類型的參數
3             if ((long long) num < 0) {                        //如果輸入參數是負數,先輸出一個負號
4                 putch('-', putdat);
5                 num = -(long long) num;
6             }
7             base = 10;
8             goto number;

  這個分支中首先是一個子函數getint,這個子函數的內容如下:

 1 static long long
 2 getint(va_list *ap, int lflag)
 3 {
 4     if (lflag >= 2)
 5         return va_arg(*ap, long long);
 6     else if (lflag)
 7         return va_arg(*ap, long);
 8     else
 9         return va_arg(*ap, int);
10 }

  可見它是根據不同的參數類型,利用va_arg方法從ap參數列表中取出下一個參數,在我們的例子中會執行第9行的代碼。這里對va_arg進行了一次調用,調用前ap中包括x,y,z三個參數的內容:1,3,4。調用完成后只剩下y,z的內容:3,4.

  回到vprintfmt,現在num中存放的是待顯示的值1。下一步先判斷這個待顯示的值是否是負數,如果是負數應該先調用putch函數,顯示一個負號在屏幕上。之后跳轉到number處。

  number處是一個子程序 printnum(putch, putdat, num, base, width, padc),這個子程序會按照指定的進制,以及格式顯示你剛剛取到的參數1。在這個子程序中我們可以看到它會把你取到的參數值(num = 1)按照你所指定的進制(base = 10),一位一位的顯示出來。所以每得到一位的值它都會調用一次putch,把它顯示到屏幕上。另外這句代碼putch(padc, putdat);是為了實現當顯示需要右對齊時,應該先把左邊補上空格。

  所以這樣第1個參數x=1就是顯示在屏幕上了,后面的兩個也是同樣的道理。

  為了能夠真實的運行這段代碼,我們可以找到\lab\kern\monitor.c文件,用vim編輯它,把這兩句指令加在monitor子程序中,如下:

  

  重新編譯整個內核,然后在lab目錄下運行 make qemu 指令,就會打印出結果了:

  

  4. 運行下面的代碼:

 unsigned int i = 0x00646c72;
    cprintf("H%x Wo%s", 57616, &i);

   輸出是什么?解釋一下為什么是這樣的輸出?

   答:

    首先,我們還是采用和上一題一樣的方法,把這兩句代碼加到moniter.c文件中。並且最后得到的運行結果如下:

    

    為什么會輸出這樣的值,首先看下第一個%x,指的是要按照16進制輸出第一個參數,第一個參數的值是57616,它對應的16進制的表示形式為e110,所以前面就變成的He110。

    然后看下一個%s,輸出參數所指向的字符串。參數是&i,是變量i的地址,所以應該輸出的是變量i所在地址處的字符串。

    而在cprintf之前我們把i定義為一個int類型變量,所以現在我們要把它們進行拆分,按照一個字節一個字節來進行輸出。

    由於x86是小端模式,代表字的最高位字節存放在最高位字節地址上。假設i變量的地址為0x00,那么i的4個字節的值存放在0x00,0x01,0x02,0x03四處。由於是小端存儲,所以0x00處存放0x72('r'),0x01處存放0x6c('l'),0x02處存放0x64('d'),0x03處存放0x00('\0').

    所以在cprintf將會從i的地址開始一個字節一個字節遍歷,正好輸出 "World"

  5. 看下面的代碼,在'y='后面會輸出什么?為什么會這樣?

 cprintf("x=%d y=%d", 3);

   答:

    輸出的結果如下

    

    由於y並沒有參數被指定,所以會輸出一個不確定的值。

 

堆棧

  在本實驗的最后一部分,我們將探討一下C語言是如何在x86機器上使用堆棧的。並且我們還會重新編寫一個新的kernel monitor子程序。這個程序可以記錄堆棧的變化軌跡:軌跡是由一系列被保存到堆棧的IP寄存器的值組成的,之所以會產生這一系列被保存的IP寄存器的值,是因為我們執行了一個程序,程序中包括一系列嵌套的call指令。


 

  Exercise 9:

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

  關於這個問題的解答,請看鏈接:http://www.cnblogs.com/fatsheep9146/p/5079177.html


 

  X86堆棧指針寄存器(%esp)指向的是整個堆棧中正在被使用的部分的最低地址。在這個地址之下的更低的地址空間都是還沒有被利用的堆棧空間。當計算機要完成把一個值壓入堆棧的動作時,通常它需要先把堆棧指針寄存器中的值減1(有時候是減4,由機器字長決定),然后把需要壓入的值存放到當前堆棧指針寄存器所指向的新的內存單元。而從堆棧中彈出一個值的操作,則需要計算機首先從堆棧寄存器所指向的內存單元讀取一個數據,然后把堆棧寄存器的值加1(有時候是加4)。在32bit模式下,每一次對堆棧的操作都是以32bit為單位的,所以%esp中的值永遠都是可以被4整除的。

  而ebp寄存器則是記錄每一個程序的棧幀的相關信息的一個非常重要的寄存器。每一個程序在運行時都會分配給它一個棧幀,用於實現存放一些臨時變量,傳遞參數給它調用的子函數等等功能。當現在進入某個子程序時,最先要運行的代碼就是先把之前調用這個子程序的程序的ebp寄存器的值壓入堆棧中保存起來,然后把ebp寄存器的值更新為當前esp寄存器的值。此時就相當於為這個子程序定義了它的ebp寄存器的值,也就是它棧幀的一個邊界。只要所有的程序都遵循這樣的編程規則,那么當我們運行到程序的任意一點時。我們可以通過在堆棧中保存的一系列ebp寄存器的值來回溯,弄清楚是怎樣的一個函數調用序列使我們的程序運行到當前的這個點。


 

  Exercise 10:

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

  解答鏈接:http://www.cnblogs.com/fatsheep9146/p/5079930.html


 

     上述練習已經給了你足夠的信息,讓你能夠實現一個堆棧回溯函數,mon_backtrace。在kern/monitor.c中已經為你聲明了這個函數。你可以用C語言實現它。不僅如此,你還要把它加入到kernel moniter的命令集合中,這樣用戶就可以通過moniter的命令行調用它了。

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

   Stack backtrace:

     ebp f0109358 eip f0100a62 args 00000001 f0109e80  f0109e98 f0100ed2 00000031

     ebp f0109ed8 eip f01000d6 args 00000000 00000000 f0100058 f0109f28 00000061

         ...

  其中第一行 "Stack backtrace"表明現在正在執行的就是mon_backtrace子程序。第二行展示的就是調用mon_backtrace的程序a,第三行展示的就是調用程序a的程序b,依次類推,直到最外層。

  在每一行中,ebp后面的值代表的是被這個函數所使用的ebp寄存器的值,這個值也是這個函數的棧幀的最高地址。而eip后面的值代表的是函數的返回地址。最后的五個列在args之后的16進制的值,是傳遞給這個函數的頭五個輸入參數,當然,有可能輸入參數不到五個。


 

  Exercise 11:

  實現我們在上面詳細說明的backtrace子程序。

  解答:

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

  我們可以看一下下面這張圖,是對棧幀的結構一個非常好的解釋。

  

  從圖中可以看出,當前的內存中包含兩個棧幀,一個是當前棧幀,即被調用者的棧幀;另一個是調用者的棧幀。其中我們這個函數的功能就是要得到當前棧幀ebp寄存器的值,以及調用者棧幀中的返回地址,傳遞給當前棧幀的輸入參數。

  所以根據圖中這些數據的分布位置情況,我們可以知道,寄存器ebp的值是當前棧幀的最高地址,而且這個最高地址對應的內存單元里面存放的值恰好是調用者棧幀的最高地址處。

  調用者的返回地址就存放在ebp+4地址單元處。存放完返回地址,緊挨着的高位地址處存放的就是調用者傳遞給被調者的輸入參數(ebp+8, ebp+12....)。

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

  代碼已經完成,可以到github上具體查看~


 

    到目前為止,你所編寫的backtrace函數應該能夠把導致mon_backtrace()函數執行的所有函數的地址信息打印出來了。但是在實際情況中,你經常會向弄清楚這些地址對應的到底是哪個函數。

    為了達到這個目的,我們已經提供給你一個函數 debuginfo_eip(),這個函數將會標識表(symbol table)中查找eip的值,然后顯示出來關於這個eip的值相關的調試信息。這個函數定義在kern/kdebug.c文件中。


  Exercise 12

  關於這個Exercise,我還沒有完成,我會在后面對這個部分進行補充。

  

 

 

 

 

 

 

 

 

 

    

    

   

 


免責聲明!

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



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