作者 | Mit
譯者 | qhwdw ? ? ? ? ? 共計翻譯:117 篇 貢獻時間:212 天
簡介
這個實驗分為三個部分。第一部分主要是為了熟悉使用 x86 組合語言、QEMU x86 模擬器、以及 PC 的加電引導過程。第二部分檢視我們的 6.828 內核的引導載入器,它位於 lab
樹的 boot
目錄中。第三部分深入到我們的名為 JOS 的 6.828 核心模型內部,它在 kernel
目錄中。
軟體安裝
本課程中你需要的檔案和接下來的實驗任務所需要的檔案都是透過使用 Git[1] 版本控制系統來分發的。學習更多關於 Git 的知識,請檢視 Git 使用者手冊[2],或者,如果你熟悉其它的版本控制系統,這個 面向 CS 的 Git 概述[3] 可能對你有幫助。
本課程在 Git 倉庫中的地址是 https://exokernel.scripts.mit.edu/joslab.git 。在你的 Athena 帳戶中安裝檔案,你需要執行如下的命令去克隆課程倉庫。你也可以使用 ssh -X athena.dialup.mit.edu
去登入到一個公共的 Athena 主機。
athena% mkdir ~/6.828
athena% cd ~/6.828
athena% add git
athena% git clone https://exokernel.scripts.mit.edu/joslab.git lab
Cloning into lab...
athena% cd lab
athena%
Git 可以幫你跟蹤程式碼中的變化。比如,如果你完成了一個練習,想在你的進度中打一個檢查點,你可以執行如下的命令去提交你的變更:
athena% git commit -am 'my solution for lab1 exercise 9'
Created commit 60d2135: my solution for lab1 exercise 9
1 files changed, 1 insertions(+), 0 deletions(-)
athena%
你可以使用 git diff
命令跟蹤你的變更。執行 git diff
將顯示你的程式碼自最後一次提交之後的變更,而 git diff origin/lab1
將顯示這個實驗相對於初始程式碼的變更。在這裡,origin/lab1
是為了完成這個作業,從我們的伺服器上下載的初始程式碼在 Git 分支上的名字。
在 Athena 上,我們為你配置了合適的編譯器和模擬器。如果你要去使用它們,請執行 add exokernel
命令。 每次登入 Athena 主機你都必須要執行這個命令(或者你可以將它新增到你的 ~/.environment
檔案中)。如果你在編譯或者執行 qemu
時出現晦澀難懂的錯誤,可以雙擊 “check” 將它新增到你的課程收藏夾中。
如果你使用的是非 Athena 機器,你需要安裝 qemu
和 gcc
,它們在 工具頁面[5] 目錄中。為了以後的實驗需要,我們做了一些 qemu
除錯方面的變更和補丁,因此,你必須構建你自己的工具。如果你的機器使用原生的 ELF 工具鏈(比如,Linux 和大多數 BSD,但不包括 OS X),你可以簡單地從你的包管理器中安裝 gcc
。除此之外,都應該按工具頁面的指導去做。
動手過程
我們為了你便於做實驗,為你使用了不同的 Git 倉庫。做實驗用的倉庫位於一個 SSH 伺服器後面。你可以擁有你自己的實驗倉庫,其他的任何同學都不可訪問你的這個倉庫。為了透過 SSH 伺服器的認證,你必須有一對 RSA 金鑰,並讓伺服器知道你的公鑰。
實驗程式碼同時還帶有一個指令碼,它可以幫你設定如何訪問你的實驗倉庫。在執行這個指令碼之前,你必須在我們的 submission web 介面[6] 上有一個帳戶。在登陸頁面上,輸入你的 Athena 使用者名稱,然後點選 “Mail me my password”。在你的郵箱中將馬上接收到一封包含有你的 6.828
課程密碼的郵件。註意,每次你點選這個按鈕的時候,系統將隨機給你分配一個新密碼。
現在,你已經有了你的 6.828
密碼,在 lab
目錄下,執行如下的命令去配置實踐倉庫:
athena% make handin-prep
Using public key from ~/.ssh/id_rsa:
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD0lnnkoHSi4JDFA ...
Continue? [Y/n] Y
Login to 6.828 submission website.
If you do not have an account yet, sign up at https://exokernel.scripts.mit.edu/submit/
before continuing.
Username: <your Athena username>
Password: <your 6.828 password>
Your public key has been successfully updated.
Setting up hand-in Git repository...
Adding remote repository ssh://josgit@exokernel.mit.edu/joslab.git as 'handin'.
Done! Use 'make handin' to submit your lab code.
athena%
如果你沒有 RSA 金鑰對,這個指令碼可能會詢問你是否生成一個新的金鑰對:
athena% make handin-prep
SSH key file ~/.ssh/id_rsa does not exists, generate one? [Y/n] Y
Generating public/private rsa key pair.
Your identification has been saved in ~/.ssh/id_rsa.
Your public key has been saved in ~/.ssh/id_rsa.pub.
The key fingerprint is:
xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx
The keyʼs randomart image is:
+--[ RSA 2048]----+
| ........ |
| ........ |
+-----------------+
Using public key from ~/.ssh/id_rsa:
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD0lnnkoHSi4JDFA ...
Continue? [Y/n] Y
.....
athena%
當你開始動手做實驗時,在 lab
目錄下,輸入 make handin
去使用 git 做第一次提交。後面將執行 git push handin HEAD
,它將推送當前分支到遠端 handin
倉庫的同名分支上。
athena% git commit -am "ready to submit my lab"
[lab1 c2e3c8b] ready to submit my lab
2 files changed, 18 insertions(+), 2 deletions(-)
athena% make handin
Handin to remote repository using 'git push handin HEAD' ...
Counting objects: 59, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (55/55), done.
Writing objects: 100% (59/59), 49.75 KiB, done.
Total 59 (delta 3), reused 0 (delta 0)
To ssh://josgit@am.csail.mit.edu/joslab.git
* [new branch] HEAD -> lab1
athena%
如果在你的實驗倉庫上產生變化,你將收到一封電子郵件,讓你去確認這個提交。以後,你可能會多次去執行 run make handin
(或者 git push handin
)。對於一個指定實驗的最後提交時間是由相應分支的最新推送(最後一個推送)的時間決定的。
在這個案例中,make handin
執行可能並不正確,你可以使用 Git 命令去嘗試修複這個問題。或者,你可以去執行 make tarball
。它將為你生成一個 tar 檔案,這個檔案可以透過我們的 web 介面[6] 來上傳。make handin
提供了很多特殊說明。
對於實驗 1,你不需要去回答下列的任何一個問題。(儘管你不用自己回答,但是它們對下麵的實驗有幫助)
我們將使用一個評級程式來分級你的解決方案。你可以使用這個評級程式去測試你的解決方案的分級情況。
第一部分:PC 引導
第一個練習的目的是向你介紹 x86 組合語言和 PC 引導過程,你可以使用 QEMU 和 QEMU/GDB 除錯開始你的練習。這部分的實驗你不需要寫任何程式碼,但是,透過這個實驗,你將對 PC 引導過程有了你自己的理解,並且為回答後面的問題做好準備。
從使用 x86 組合語言開始
如果你對 x86 組合語言的使用還不熟悉,透過這個課程,你將很快熟悉它!如果你想學習它,PC 組合語言[7] 這本書是一個很好的開端。希望這本書中有你所需要的一切內容。
警告:很不幸,這本書中的示例是為 NASM 組合語言寫的,而我們使用的是 GNU 組合語言。NASM 使用所謂的 Intel 語法,而 GNU 使用 AT&T; 語法。雖然在語意上是等價的,但是根據你使用的語法不同,至少從錶面上看,彙編檔案的差別還是挺大的。幸運的是,這兩種語法的轉換很簡單,在 Brennan's Guide to Inline Assembly[8] 有詳細的介紹。
練習 1
熟悉在 6.828 參考頁面[9] 上列出的你想去使用的可用組合語言。你不需要現在就去閱讀它們,但是在你閱讀和寫 x86 彙編程式的時候,你可以去參考相關的內容。
我並不推薦你閱讀 Brennan's Guide to Inline Assembly[8] 上的 “語法” 章節。雖然它對 AT&T; 彙編語法描述的很好(並且非常詳細),而且我們在 JOS 中使用的 GNU 彙編就是它。
對於 x86 組合語言程式最終還是需要參考 Intel 的指令集架構,你可以在 6.828 參考頁面[9]上找到它,它有兩個版本:一個是 HTML 版的,是老的 80386 程式員參考手冊[10],它比起最新的手冊更簡短,更易於查詢,但是,它包含了我們的 6.828 上所使用的 x86 處理器的所有特性;而更全面的、更新的、更好的是,來自 Intel 的 IA-32 Intel 架構軟體開發者手冊[11],它涵蓋了我們在課程中所需要的、(並且可能有些是你不感興趣的)大多數處理器的全部特性。另一個差不多的(並且經常是很友好的)一套手冊是 來自 AMD[12] 的。當你為了一個特定的處理器特性或者指令,去查詢最終的解釋時,儲存的最新的 Intel/AMD 架構手冊或者它們的參考就很有用了。
模擬 x86
與在一臺真實的、物理的、個人電腦上引導一個作業系統不同,我們使用程式去如實地模擬一臺完整的 PC:你在模擬器中寫的程式碼,也能夠引導一臺真實的 PC。使用模擬器可以簡化除錯工作;比如,你可以在模擬器中設定斷點,而這在真實的機器中是做不到的。
在 6.828 中,我們將使用 QEMU 模擬器[13],它是一個現代化的並且速度非常快的模擬器。雖然 QEMU 內建的監視功能提供了有限的除錯支援,但是,QEMU 也可以做為 GNU 除錯器[14](GDB) 的遠端除錯標的,我們在這個實驗中將使用它來一步一步完成引導過程。
在開始之前,按照前面 “軟體安裝“ 中在 Athena 主機上描述的步驟,提取實驗 1 的檔案到你自己的目錄中,然後,在 lab
目錄中輸入 make
(如果是 BSD 的系統,是輸入 gmake
)來構建最小的 6.828 引導載入器和用於啟動的核心。(把在這裡我們執行的這些程式碼稱為 ”核心“ 有點誇大,但是,透過這個學期的課程,我們將把這些程式碼充實起來,成為真正的 ”核心“)
athena% cd lab
athena% make
+ as kern/entry.S
+ cc kern/init.c
+ cc kern/console.c
+ cc kern/monitor.c
+ cc kern/printf.c
+ cc lib/printfmt.c
+ cc lib/readline.c
+ cc lib/string.c
+ ld obj/kern/kernel
+ as boot/boot.S
+ cc -Os boot/main.c
+ ld boot/boot
boot block is 414 bytes (max 510)
+ mk obj/kern/kernel.img
(如果你看到有類似 ”undefined reference to `__udivdi3'” 這樣的錯誤,可能是因為你的電腦上沒有 32 位的 “gcc multilib”。如果你執行在 Debian 或者 Ubuntu,你可以嘗試去安裝 “gcc-multilib” 包。)
現在,你可以去執行 QEMU 了,並將上面建立的 obj/kern/kernel.img
檔案提供給它,以作為模擬 PC 的 “虛擬硬碟”,這個虛擬硬碟中包含了我們的引導載入器(obj/boot/boot
) 和我們的核心(obj/kernel
)。
athena% make qemu
執行 QEMU 時需要使用選項去設定硬碟,以及指示串列埠輸出到終端。在 QEMU 視窗中將出現一些文字內容:
Booting from Hard Disk...
6828 decimal is XXX octal!
entering test_backtrace 5
entering test_backtrace 4
entering test_backtrace 3
entering test_backtrace 2
entering test_backtrace 1
entering test_backtrace 0
leaving test_backtrace 0
leaving test_backtrace 1
leaving test_backtrace 2
leaving test_backtrace 3
leaving test_backtrace 4
leaving test_backtrace 5
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.
K>
在 Booting from Hard Disk...
之後的內容,就是由我們的基本 JOS 核心輸出的:K>
是包含在我們的核心中的小型監聽器或者互動式控製程式的提示符。核心輸出的這些行也會出現在你執行 QEMU 的普通 shell 視窗中。這是因為測試和實驗分級的原因,我們配置了 JOS 的核心,使它將控制檯輸出不僅寫入到虛擬的 VGA 顯示器(就是 QEMU 視窗),也寫入到模擬 PC 的虛擬串列埠上,QEMU 會將虛擬串列埠上的資訊轉發到它的標準輸出上。同樣,JOS 核心也將接收來自鍵盤和串列埠的輸入,因此,你既可以從 VGA 顯示視窗中輸入命令,也可以從執行 QEMU 的終端視窗中輸入命令。或者,你可以透過執行 make qemu-nox
來取消虛擬 VGA 的輸出,只使用序列控制檯來輸出。如果你是透過 SSH 撥號連線到 Athena 主機,這樣可能更方便。
在這裡有兩個可以用來監視內核的命令,它們是 help
和 kerninfo
。
K> help
help - display this list of commands
kerninfo - display information about the kernel
K> kerninfo
Special kernel symbols:
entry f010000c (virt) 0010000c (phys)
etext f0101a75 (virt) 00101a75 (phys)
edata f0112300 (virt) 00112300 (phys)
end f0112960 (virt) 00112960 (phys)
Kernel executable memory footprint: 75KB
K>
help
命令的用途很明確,我們將簡短地討論一下 kerninfo
命令輸出的內容。雖然它很簡單,但是,需要重點註意的是,這個核心監視器是 “直接” 執行在模擬 PC 的 “原始(虛擬)硬體” 上的。這意味著你可以去複製 obj/kern/kernel.img
的內容到一個真實硬碟的前幾個扇區,然後將那個硬碟插入到一個真實的 PC 中,開啟這個 PC 的電源,你將在一臺真實的 PC 螢幕上看到和上面在 QEMU 視窗完全一樣的內容。(我們並不推薦你在一臺真實機器上這樣做,因為複製 kernel.img
到硬碟的前幾個扇區將改寫掉那個硬碟上原來的主開機記錄,這將導致這個硬碟上以前的內容丟失!)
PC 的物理地址空間
我們現在將更深入去瞭解 “關於 PC 是如何啟動” 的更多細節。一臺 PC 的物理地址空間是硬編碼為如下的佈局:
+------------------+ 0xFFFFFFFF (4GB)
| 32-bit |
| memory mapped |
| devices |
| |
/\/\/\/\/\/\/\/\/\/\
/\/\/\/\/\/\/\/\/\/\
| |
| Unused |
| |
+------------------+ depends on amount of RAM
| |
| |
| Extended Memory |
| |
| |
+------------------+ 0x00100000 (1MB)
| BIOS ROM |
+------------------+ 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ 0x000C0000 (768KB)
| VGA Display |
+------------------+ 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ 0x00000000
首先,這臺 PC 是基於 16 位的 Intel 8088 處理器,它僅能處理 1 MB 的物理地址。所以,早期 PC 的物理地址空間開始於 0x00000000
,結束於 0x000FFFFF
而不是 0xFFFFFFFF
。被標記為 “低位記憶體” 的區域是早期 PC 唯一可以使用的隨機訪問記憶體(RAM);事實上,更早期的 PC 僅可以配置 16KB、32KB、或者 64KB 的記憶體!
從 0x000A0000
到 0x000FFFFF
的 384 KB 的區域是為特定硬體保留的區域,比如,影片顯示緩衝和儲存在非易失儲存中的韌體。這個保留區域中最重要的部分是基本輸入/輸出系統(BIOS),它位於從 0x000F0000
到 0x000FFFFF
之間的 64KB 大小的區域。在早期的 PC 中,BIOS 在真正的只讀儲存(ROM)中,但是,現在的 PC 的 BIOS 都儲存在可更新的 FLASH 儲存中。BIOS 負責執行基本系統初始化工作,比如,啟用影片卡和檢查已安裝的記憶體數量。這個初始化工作完成之後,BIOS 從相關位置載入作業系統,比如從軟盤、硬碟、CD-ROM、或者網路,然後將機器的控制權傳遞給作業系統。
當 Intel 最終在 80286 和 80386 處理器上 “打破了 1MB 限制” 之後,這兩個處理器各自支援 16MB 和 4GB 物理地址空間,儘管如此,為了確保向下相容現存軟體,PC 架構還是保留著 1 MB 以內物理地址空間的原始佈局。因此,現代 PC 的物理記憶體,在 0x000A0000
和 0x00100000
之間有一個 “黑洞區域”,將記憶體分割為 “低位” 或者 “傳統記憶體” 區域(前 640 KB)和 “擴充套件記憶體”(其它的部分)。除此之外,在 PC 的 32 位物理地址空間頂部之上的一些空間,在全部的物理記憶體上面,現在一般都由 BIOS 保留給 32 位的 PCI 裝置使用。
最新的 x86 處理器可以支援超過 4GB 的物理地址空間,因此,RAM 可以進一步擴充套件到 0xFFFFFFFF
之上。在這種情況下,BIOS 必須在 32 位可定址空間頂部之上的系統 RAM 上,設定第二個 “黑洞區域”,以便於為這些 32 位的裝置對映留下空間。因為 JOS 設計的限制,它僅可以使用 PC 物理記憶體的前 256 MB,因此,我們將假設所有的 PC “僅僅” 擁有 32 位物理地址空間。但是處理複雜的物理地址空間和其它部分的硬體系統,將涉及到許多年前作業系統開發所遇到的實際挑戰之一。
ROM BIOS
在實驗的這一部分中,你將使用 QEMU 的除錯功能去研究 IA-32 相關的計算機是如何引導的。
開啟兩個終端視窗,在其中一個中,輸入 make qemu-gdb
(或者 make qemu-nox-gdb
),這將啟動 QEMU,但是處理器在執行第一個指令之前將停止 QEMU,以等待來自 GDB 的除錯連線。在第二個終端視窗中,從相同的目錄中執行 make
,以及執行 make gdb
。你將看到如下的輸出。
athena% make gdb
GNU gdb (GDB) 6.8-debian
Copyright (C) 2008 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu".
+ target remote localhost:1234
The target architecture is assumed to be i8086
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel
(gdb)
make gdb
的執行標的是一個稱為 .gdbrc
的指令碼,它設定了 GDB 在早期引導期間除錯所用到的 16 位程式碼,並且將它指向到正在監聽的 QEMU 上。
下列行:
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
是 GDB 執行的第一個指令的反彙編。這個輸出包含如下的資訊:
0x000ffff0
開始執行,這個地址位於為 ROM BIOS 保留的 64 KB 區域的頂部。CS = 0xf000
和 IP = 0xfff0
開始執行。jmp
指令,它跳轉段地址 CS = 0xf000
和 IP = 0xe05b
。為什麼 QEMU 是這樣開始的呢?這是因為 Intel 設計的 8088 處理器是這樣做的,這個處理器是 IBM 最早用在他們的 PC 上的處理器。因為在一臺 PC 中,BIOS 是硬編碼在物理地址範圍 0x000f0000-0x000fffff
中的,這樣的設計確保了在機器接通電源或者任何系統重啟之後,BIOS 總是能夠首先控制機器 —— 這是至關重要的,因為機器接通電源之後,在機器的記憶體中沒有處理器可以執行的任何軟體。QEMU 模擬器有它自己的 BIOS,它的位置在處理器的模擬地址空間中。在處理器複位之後,(模擬的)處理器進入了實樣式,然後設定 CS
為 0xf000
、IP
為 0xfff0
,所以,執行開始於那個(CS:IP
)段地址。那麼,段地址 0xf000:fff0
是如何轉到物理地址的呢?
在回答這個問題之前,我們需要瞭解有關實樣式地址的知識。在實樣式(PC 啟動之後就處於實樣式)中,物理地址是根據這個公式去轉換的:物理地址 = 16 * 段地址 + 偏移。因此,當 PC 設定 CS
為 0xf000
、IP
為 0xfff0
之後,物理地址指向到:
16 * 0xf000 + 0xfff0 # in hex multiplication by 16 is
= 0xf0000 + 0xfff0 # easy--just append a 0.
= 0xffff0
0xffff0
是 BIOS (0x100000
) 結束之前的 16 位元組。因此,BIOS 所做的第一件事情是向後 jmp
到 BIOS 中的早期位置就一點也不奇怪了;畢竟只有 16 位元組,還能指望它做些什麼呢?
練習 2
使用 GDB 的
si
(步進指令)指令去跟蹤進入到 ROM BIOS 的更多指令,然後嘗試猜測它可能會做什麼。你可能需要去檢視 Phil Storrs I/O 埠描述[15],以及在 6.828 參考資料頁面[9] 上的其它資料。不需要瞭解所有的細節 —— 只要搞明白 BIOS 首先要做什麼就可以了。
當 BIOS 執行後,它將設定一個中斷描述符表和初始化各種裝置,比如, VGA 顯示。在這時,你在 QEMU 視窗中將出現 Starting SeaBIOS
的資訊。
在初始化 PCI 產品線和 BIOS 知道的所有重要裝置之後,它將搜尋可引導裝置,比如,一個軟盤、硬碟、或者 CD-ROM。最後,當它找到可引導磁碟之後,BIOS 從可引導硬碟上讀取引導載入器,然後將控制權交給它。
第二部分:引導載入器
在 PC 的軟盤和硬碟中,將它們分割成 512 位元組大小的區域,每個區域稱為一個扇區。一個扇區就是磁碟的最小轉存單元:每個讀或寫操作都必須是一個或多個扇區大小,並且按扇區邊界進行對齊。如果磁碟是可引導盤,第一個扇區則為引導扇區,因為,第一個扇區中駐留有引導載入器的程式碼。當 BIOS 找到一個可引導軟盤或者硬碟時,它將 512 位元組的引導扇區載入進物理地址為 0x7c00
到 0x7dff
的記憶體中,然後使用一個 jmp
指令設定 CS:IP
為 0000:7c00
,並傳遞控制權到引導載入器。與 BIOS 載入地址一樣,這些地址是任意的 —— 但是它們對於 PC 來說是固定的,並且是標準化的。
後來,隨著 PC 的技術進步,它們可以從 CD-ROM 中引導,因此,PC 架構師趁機對引導過程進行輕微的調整。最後的結果使現代的 BIOS 從 CD-ROM 中引導的過程更複雜(並且功能更強大)。CD-ROM 使用 2048 位元組大小的扇區,而不是 512 位元組的扇區,並且,BIOS 在傳遞控制權之前,可以從磁碟上載入更大的(不止是一個扇區)引導映象到記憶體中。更多內容,請檢視 “El Torito” 可引導 CD-ROM 格式規範[16]。
不過對於 6.828,我們將使用傳統的硬碟引導機制,意味著我們的引導載入器必須小於 512 位元組。引導載入器是由一個彙編源檔案 boot/boot.S
和一個 C 源檔案 boot/main.c
構成,仔細研究這些源檔案可以讓你徹底理解引導載入器都做了些什麼。引導載入器必須要做兩件主要的事情:
理解了引導載入器原始碼之後,我們來看一下 obj/boot/boot.asm
檔案。這個檔案是在引導載入器編譯過程中,由我們的 GNUmakefile 建立的引導載入器的反彙編檔案。這個反彙編檔案讓我們可以更容易地看到引導載入器程式碼所處的物理記憶體位置,並且也可以更容易地跟蹤在 GDB 中步進的引導載入器發生了什麼事情。同樣的,obj/kern/kernel.asm
檔案中包含了 JOS 內核的一個反彙編,它也經常被用於核心除錯。
你可以使用 b
命令在 GDB 中設定中斷點地址。比如,b *0x7c00
命令在地址 0x7C00
處設定了一個斷點。當處於一個斷點中時,你可以使用 c
和 si
命令去繼續執行:c
命令讓 QEMU 繼續執行,直到下一個斷點為止(或者是你在 GDB 中按下了 Ctrl - C),而 si N
命令是每次步進 N
個指令。
要檢查記憶體中的指令(除了要立即執行的下一個指令之外,因為它是由 GDB 自動輸出的),你可以使用 x/i
命令。這個命令的語法是 x/Ni ADDR
,其中 N
是連線的指令個數,ADDR
是開始反彙編的記憶體地址。
練習 3
檢視 實驗工具指南[17],特別是 GDB 命令的相關章節。即便你熟悉使用 GDB 也要好好看一看,GDB 的一些命令比較難理解,但是它對作業系統的工作很有幫助。
在地址 0x7c00 處設定斷點,它是載入後的引導扇區的位置。繼續執行,直到那個斷點。在 boot/boot.S
中跟蹤程式碼,使用原始碼和反彙編檔案 obj/boot/boot.asm
去保持跟蹤。你也可以使用 GDB 中的 x/i
命令去反彙編引導載入器接下來的指令,比較引導載入器原始碼與在 obj/boot/boot.asm
和 GDB 中的反彙編檔案。
在 boot/main.c
檔案中跟蹤進入 bootmain()
,然後進入 readsect()
。識別 readsect()
中相關的每一個陳述句的準確彙編指令。跟蹤 readsect()
中剩餘的指令,然後傳回到 bootmain()
中,識別 for
迴圈的開始和結束位置,這個迴圈從磁碟上讀取內核的剩餘扇區。找出迴圈結束後運行了什麼程式碼,在這裡設定一個斷點,然後繼續。接下來再走完引導載入器的剩餘工作。
完成之後,就能夠回答下列的問題了:
載入核心
我們現在來進一步檢視引導載入器在 boot/main.c
中的 C 語言部分的詳細細節。在繼續之前,我們先停下來回顧一下 C 語言程式設計的基礎知識。
練習 4
下載 pointers.c[18] 的原始碼,執行它,然後確保你理解了輸出值的來源的所有內容。尤其是,確保你理解了第 1 行和第 6 行的指標地址的來源、第 2 行到第 4 行的值是如何得到的、以及為什麼第 5 行指向的值錶面上看像是錯誤的。
如果你對指標的使用不熟悉,Brian Kernighan 和 Dennis Ritchie(就是大家知道的 “K&R;”)寫的《C Programming Language》是一個非常好的參考書。同學們可以去買這本書(這裡是 Amazon 購買連結[19]),或者在 MIT 的圖書館的 7 個副本[20] 中找到其中一個。在 SIPB Office[21] 也有三個副本可以細讀。
在課程閱讀中,Ted Jensen 寫的教程[22] 可以使用,它大量取用了 K&R; 的內容。
警告:除非你特別精通 C 語言,否則不要跳過這個閱讀練習。如果你沒有真正理解了 C 語言中的指標,在接下來的實驗中你將非常痛苦,最終你將很難理解它們。相信我們;你將不會遇到什麼是 ”最困難的方式“。
要瞭解 boot/main.c
,你需要瞭解一個 ELF 二進位制格式的內容。當你編譯和連結一個 C 程式時,比如,JOS 核心,編譯器將每個 C 源檔案('.c
')轉換為一個包含預期硬體平臺的彙編指令編碼的二進位制格式的物件檔案('.o
'),然後聯結器將所有編譯過的物件檔案組合成一個單個的二進位制映象,比如,obj/kern/kernel
,在本案例中,它就是 ELF 格式的二進位制檔案,它表示是一個 ”可執行和可連結格式“。
關於這個格式的全部資訊可以在 我們的參考頁面[9] 上的 ELF 規範[23] 中找到,但是,你並不需要深入地研究這個格式 的細節。雖然完整的格式是非常強大和複雜的,但是,大多數複雜的部分是為了支援共享庫的動態載入,在我們的課程中,並不需要做這些。
鑒於 6.828 的目的,你可以認為一個 ELF 可執行檔案是一個用於載入資訊的頭檔案,接下來的幾個程式節,根據載入到記憶體中的特定地址的不同,每個都是連續的程式碼塊或資料塊。引導載入器並不修改程式碼或者資料;它載入它們到記憶體,然後開始執行它。
一個 ELF 二進位制檔案使用一個固定長度的 ELF 頭開始,緊接著是一個可變長度的程式頭,列出了每個載入的程式節。C 語言在 inc/elf.h
中定義了這些 ELF 頭。在程式節中我們感興趣的部分有:
.text
:程式的可執行指令。.rodata
:只讀資料,比如,由 C 編譯器生成的 ASCII 字串常量。(然而我們並不需要操心設定硬體去禁止寫入它).data
:保持在程式的初始化資料中的資料節,比如,初始化宣告所需要的全域性變數,比如,像 int x = 5;
。當聯結器計算程式的記憶體佈局的時候,它為未初始化的全域性變數保留一些空間,比如,int x;
,在記憶體中的被稱為 .bss
的節後面會馬上跟著一個 .data
。C 規定 "未初始化的" 全域性變數以一個 0 值開始。因此,在 ELF 二進制中 .bss
中並不儲存內容;而是,聯結器只記錄地址和.bss
節的大小。載入器或者程式自身必須在 .bss
節中寫入 0。
透過輸入如下的命令來檢查在核心中可執行的所有節的名字、大小、以及連結地址的串列:
athena% i386-jos-elf-objdump -h obj/kern/kernel
如果在你的計算機上預設使用的是一個 ELF 工具鏈,比如像大多數現代的 Linux 和 BSD,你可以使用 objdump
來代替 i386-jos-elf-objdump
。
你將看到更多的節,而不僅是上面列出的那幾個,但是,其它的那些節對於我們的實驗標的來說並不重要。其它的那些節中大多數都是為了保留除錯資訊,它們一般包含在程式的可執行檔案中,但是,這些節並不會被程式載入器載入到記憶體中。
我們需要特別註意 .text
節中的 VMA(或者連結地址)和 LMA(或者載入地址)。一個節的載入地址是那個節載入到記憶體中的地址。在 ELF 物件中,它儲存在 ph->p_pa
域(在本案例中,它實際上是物理地址,不過 ELF 規範在這個域的意義方面規定的很模糊)。
一個節的連結地址是這個節打算在記憶體中執行時的地址。聯結器在二進位制程式碼中以變數的方式去編碼這個連結地址,比如,當程式碼需要全域性變數的地址時,如果二進位制程式碼從一個未連結的地址去執行,結果將是無法執行。(它一般是去生成一個不包含任何一個絕對地址的、與位置無關的程式碼。現在的共享庫大量使用的就是這種方法,但這是以效能和複雜性為代價的,所以,我們在 6.828 中不使用這種方法。)
一般情況下,連結和載入地址是一樣的。比如,透過如下的命令去檢視引導載入器的 .text
節:
athena% i386-jos-elf-objdump -h obj/boot/boot.out
BIOS 載入引導扇區到記憶體中的 0x7c00 地址,因此,這就是引導扇區的載入地址。這也是引導扇區的執行地址,因此,它也是連結地址。我們在boot/Makefrag
中透過傳遞 -Ttext 0x7C00
給聯結器來設定連結地址,因此,聯結器將在生成的程式碼中產生正確的記憶體地址。
練習 5
如果你得到一個錯誤的引導載入器連結地址,透過再次跟蹤引導載入器的前幾個指令,你將會發現第一個指令會 “中斷” 或者出錯。然後在
boot/Makefrag
修改連結地址來修複錯誤,執行make clean
,使用make
重新編譯,然後再次跟蹤引導載入器去檢視會發生什麼事情。不要忘了改回正確的連結地址,然後再次make clean
!
我們繼續來看內核的載入和連結地址。與引導載入器不同,這裡有兩個不同的地址:核心告訴引導載入器載入它到記憶體的低位地址(小於 1 MB 的地址),但是它期望在一個高位地址來執行。我們將在下一節中深入研究它是如何實現的。
除了節的資訊之外,在 ELF 頭中還有一個對我們很重要的域,它叫做 e_entry
。這個域保留著程式入口的連結地址:程式的 .text
節中的記憶體地址就是將要被執行的程式的地址。你可以用如下的命令來檢視程式入口連結地址:
athena% i386-jos-elf-objdump -f obj/kern/kernel
你現在應該能夠理解在 boot/main.c
中的最小的 ELF 載入器了。它從硬碟中讀取內核的每個節,並將它們節的載入地址讀入到記憶體中,然後跳轉到內核的入口點。
練習 6
我們可以使用 GDB 的
x
命令去檢查記憶體。GDB 手冊[24] 上講的非常詳細,但是現在,我們知道命令x/Nx ADDR
是輸出地址ADDR
上N
個詞就夠了。(註意在命令中所有的x
都是小寫。)警告:詞的多少並沒有一個普遍的標準。在 GNU 彙編中,一個詞是兩個位元組(在 xorw 中的 'w',它在這個詞中就是 2 個位元組)。
重置機器(退出 QEMU/GDB 然後再次啟動它們)。檢查記憶體中在 0x00100000
地址上的 8 個詞,輸出 BIOS 上的引導載入器入口,然後再次找出引導載器上的內核的入口。為什麼它們不一樣?在第二個斷點上有什麼內容?(你並不用真的在 QEMU 上去回答這個問題,只需要思考就可以。)
第三部分:核心
我們現在開始去更詳細地研究最小的 JOS 核心。(最後你還將寫一些程式碼!)就像引導載入器一樣,核心也是從一些組合語言程式碼設定一些東西開始的,以便於 C 語言程式碼可以正確執行。
使用虛擬記憶體去解決位置依賴問題
前面在你檢查引導載入器的連結和載入地址時,它們是完全一樣的,但是內核的連結地址(可以透過 objdump
來輸出)和它的載入地址之間差別很大。可以回到前面去看一下,以確保你明白我們所討論的內容。(連結核心比引導載入器更複雜,因此,連結和載入地址都在 kern/kernel.ld
的頂部。)
作業系統核心經常連結和執行在高位的虛擬地址,比如,0xf0100000
,為的是給讓使用者程式去使用處理器的虛擬地址空間的低位部分。至於為什麼要這麼安排,在下一個實驗中我們將會知道。
許多機器在 0xf0100000
處並沒有物理地址,因此,我們不能指望在那個位置可以儲存核心。相反,我們使用處理器的記憶體管理硬體去對映虛擬地址 0xf0100000
(核心程式碼打算執行的連結地址)到物理地址 0x00100000
(引導載入器將核心載入到記憶體的物理地址的位置)。透過這種方法,雖然內核的虛擬地址是高位的,離使用者程式的地址空間足夠遠,它將被載入到 PC 的物理記憶體的 1MB 的位置,只處於 BIOS ROM 之上。這種方法要求 PC 至少要多於 1 MB 的物理記憶體(以便於物理地址 0x00100000
可以工作),這在上世紀九十年代以後生產的PC 上應該是沒有問題的。
實際上,在下一個實驗中,我們將對映整個 256 MB 的 PC 的物理地址空間,從物理地址 0x00000000
到 0x0fffffff
,對映到虛擬地址 0xf0000000
到 0xffffffff
。你現在就應該明白了為什麼 JOS 只能使用物理記憶體的前 256 MB 的原因了。
現在,我們只對映前 4 MB 的物理記憶體,它足夠我們的核心啟動並執行。我們透過在 kern/entrypgdir.c
中手工寫入靜態初始化的頁面目錄和頁面表就可以實現。現在,你不需要理解它們是如何工作的詳細細節,只需要達到目的就行了。將上面的 kern/entry.S
檔案中設定 CR0_PG
標誌,記憶體取用就被視為物理地址(嚴格來說,它們是線性地址,但是,在 boot/boot.S
中設定了一個從線性地址到物理地址的對映標識,我們絕對不能改變它)。一旦 CR0_PG
被設定,記憶體取用的就是虛擬地址,這個虛擬地址是透過虛擬地址硬體將物理地址轉換得到的。entry_pgdir
將把從 0x00000000
到 0x00400000
的物理地址範圍轉換在 0xf0000000
到 0xf0400000
的範圍內的虛擬地址。任何不在這兩個範圍之一中的地址都將導致硬體異常,因為,我們還沒有設定中斷去處理這種情況,這種異常將導致 QEMU 去轉儲機器狀態然後退出。(或者如果你沒有在 QEMU 中應用 6.828 專用補丁,將導致 QEMU 無限重啟。)
練習 7
使用 QEMU 和 GDB 去跟蹤進入到 JOS 核心,然後停止在
movl %eax, %cr0
指令處。檢查0x00100000
和0xf0100000
處的記憶體。現在使用GDB 的stepi
命令去單步執行那個指令。再次檢查0x00100000
和0xf0100000
處的記憶體。確保你能理解這時發生的事情。
新對映建立之後的第一個指令是什麼?如果沒有對映到位,它將不能正常工作。在 kern/entry.S
中註釋掉 movl %eax, %cr0
。然後跟蹤它,看看你的猜測是否正確。
格式化控制檯的輸出
大多數人認為像 printf()
這樣的函式是天生就有的,有時甚至認為這是 C 語言的 “原語”。但是在作業系統的核心中,我們需要自己去實現所有的 I/O。
透過閱讀 kern/printf.c
、lib/printfmt.c
、以及 kern/console.c
,確保你理解了它們之間的關係。在後面的實驗中,你將會明白為什麼 printfmt.c
是位於單獨的 lib
目錄中。
練習 8
我們將省略掉一小部分程式碼片斷 —— 這部分程式碼片斷是使用 ”%o" 樣式輸出八進位制數字所需要的。找到它並填充到這個程式碼片斷中。
然後你就能夠回答下列的問題:
☉ 解釋
printf.c
和console.c
之間的介面。尤其是,console.c
出口的函式是什麼?這個函式是如何被printf.c
使用的?☉ 在
console.c
中解釋下列的程式碼:
if (crt_pos >= CRT_SIZE) {
int i;
memcpy(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
crt_buf[i] = 0x0700 | ' ';
crt_pos -= CRT_COLS;
}
☉ 下列的問題你可能需要參考第一節課中的筆記。這些筆記涵蓋了 GCC 在 x86 上的呼叫規則。
一步一步跟蹤下列程式碼的執行:
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
,列出它的兩個引數值。☉ 執行下列程式碼:
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);
輸出是什麼?解釋如何在前面的練習中一步一步實現這個輸出。這是一個 ASCII 表[25],它是一個位元組到字串的對映表。
這個輸出取決於 x86 是小端法這一事實。如果這個 x86 採用大端法格式,你怎麼去設定
i
,以產生相同的輸出?你需要將57616
改變為一個不同值嗎?這是小端法和大端法的描述[26] 和 一個更古怪的描述[27]。
☉ 在下列程式碼中,
y=
會輸出什麼?(註意:這個問題沒有確切值)為什麼會發生這種情況?cprintf("x=%d y=%d", 3);
☉ 假設修改了 GCC 的呼叫規則,以便於按宣告的次序在棧上推送引數,這樣最後的引數就是最後一個推送進去的。那你如何去改變
cprintf
或者它的介面,以便它仍然可以傳遞數量可變的引數?
棧
在本實驗的最後一個練習中,我們將理詳細地解釋在 x86 中 C 語言是如何使用棧的,以及在這個過程中,我們將寫一個新的核心監視函式,這個函式將輸出棧的回溯資訊:一個儲存了指令指標(IP)值的串列,這個串列中有巢狀的 call
指令執行在當前執行點的指標值。
練習 9
搞清楚核心在什麼地方初始化棧,以及棧在記憶體中的準確位置。核心如何為棧保留空間?以及這個保留區域的 “結束” 位置是指向初始化結束後的指標嗎?
x86 棧指標(esp
暫存器)指向當前使用的棧的最低位置。在這個區域中那個位置以下的所有部分都是空閑的。給一個棧推送一個值涉及下移棧指標和棧指標指向的位置中寫入值。從棧中彈出一個值涉及到從棧指標指向的位置讀取值和上移棧指標。在 32 位樣式中,棧中僅能儲存 32 位值,並且 esp
通常分為四部分。各種 x86 指令,比如,call
,是 “硬編碼” 去使用棧指標暫存器的。
相比之下,ebp
(基指標)暫存器,按軟體慣例主要是由棧關聯的。在進入一個 C 函式時,函式的前序程式碼在函式執行期間,通常會透過推送它到棧中來儲存前一個函式的基指標,然後複製當前的 esp
值到 ebp
中。如果一個程式中的所有函式都遵守這個規則,那麼,在程式執行過程中的任何一個給定時間點,透過在 ebp
中儲存的指標鏈和精確確定的函式巢狀呼叫順序是如何到達程式中的這個特定的點,就可以透過棧來跟蹤回溯。這種跟蹤回溯的函式在實踐中非常有用,比如,由於給某個函式傳遞了一個錯誤的引數,導致一個 assert
失敗或者 panic
,但是,你並不能確定是誰傳遞了錯誤的引數。棧的回溯跟蹤可以讓你找到這個惹麻煩的函式。
練習 10
要熟悉 x86 上的 C 呼叫規則,可以在
obj/kern/kernel.asm
檔案中找到函式test_backtrace
的地址,設定一個斷點,然後檢查在核心啟動後,每次呼叫它時發生了什麼。每個遞迴巢狀的test_backtrace
函式在棧上推送了多少個詞(word),這些詞(word)是什麼?
上面的練習可以給你提供關於實現棧跟蹤回溯函式的一些資訊,為實現這個函式,你應該去呼叫 mon_backtrace()
。在 kern/monitor.c
中已經給你提供了這個函式的一個原型。你完全可以在 C 中去使用它,但是,你可能需要在 inc/x86.h
中使用到 read_ebp()
函式。你應該在這個新函式中實現一個到核心監視命令的鉤子,以便於使用者可以與它互動。
這個跟蹤回溯函式將以下麵的格式顯示一個函式呼叫串列:
Stack backtrace:
ebp f0109e58 eip f0100a62 args 00000001 f0109e80 f0109e98 f0100ed2 00000031
ebp f0109ed8 eip f01000d6 args 00000000 00000000 f0100058 f0109f28 00000061
...
輸出的第一行列出了當前執行的函式,名字為 mon_backtrace
,就是它自己,第二行列出了被 mon_backtrace
呼叫的函式,第三行列出了另一個被呼叫的函式,依次類推。你可以輸出所有未完成的棧幀。透過研究 kern/entry.S
,你可以發現,有一個很容易的方法告訴你何時停止。
在每一行中,ebp
表示了那個函式進入棧的基指標:即,棧指標的位置,它就是函式進入之後,函式的前序程式碼設定的基指標。eip
值列出的是函式的傳回指令指標:當函式傳回時,指令地址將控制傳回。傳回指令指標一般指向 call
指令之後的指令(想一想為什麼?)。在 args
之後列出的五個十六進位制值是在問題中傳遞給函式的前五個引數。當然,如果函式呼叫時傳遞的引數少於五個,那麼,在這裡就不會列出全部五個值了。(為什麼跟蹤回溯程式碼不能檢測到呼叫時實際上傳遞了多少個引數?如何去修複這個 “缺陷”?)
下麵是在閱讀 K&R; 的書中的第 5 章中的一些關鍵點,為了接下來的練習和將來的實驗,你應該記住它們。
int *p = (int*)100
,那麼 (int)p + 1
和 (int)(p + 1)
是不同的數字:前一個是 101
,但是第二個是 104
。當在一個指標上加一個整數時,就像第二種情況,這個整數將隱式地與指標所指向的物件相乘。p[i]
的定義與 *(p+i)
定義是相同的,都反映了在記憶體中由 p
指向的第 i
個物件。當物件大於一個位元組時,上面的加法規則可以使這個定義正常工作。&p;[i]
與 (p+i)
是相同的,獲取在記憶體中由 p 指向的第 i
個物件的地址。雖然大多數 C 程式不需要在指標和整數之間轉換,但是作業系統經常做這種轉換。不論何時,當你看到一個涉及記憶體地址的加法時,你要問你自己,你到底是要做一個整數加法還是一個指標加法,以確保做完加法後的值是正確的,而不是相乘後的結果。
練 11
實現一個像上面詳細描述的那樣的跟蹤回溯函式。一定使用與示例中相同的輸出格式,否則,將會引發評級指令碼的識別混亂。在你認為你做的很好的時候,執行
make grade
這個評級指令碼去檢視它的輸出是否是我們的指令碼所期望的結果,如果不是去修改它。你提交了你的實驗 1 程式碼後,我們非常歡迎你將你的跟蹤回溯函式的輸出格式修改成任何一種你喜歡的格式。
在這時,你的跟蹤回溯函式將能夠給你提供導致 mon_backtrace()
被執行的,在棧上呼叫它的函式的地址。但是,在實踐中,你經常希望能夠知道這個地址相關的函式名字。比如,你可能希望知道是哪個有 Bug 的函式導致了你的核心崩潰。
為幫助你實現這個功能,我們提供了 debuginfo_eip()
函式,它在符號表中查詢 eip
,然後傳回那個地址的除錯資訊。這個函式定義在 kern/kdebug.c
檔案中。
練習 12
修改你的棧跟蹤回溯函式,對於每個
eip
,顯示相關的函式名字、源檔案名、以及那個eip
的行號。
在 debuginfo_eip
中,__STAB_*
來自哪裡?這個問題的答案很長;為幫助你找到答案,下麵是你需要做的一些事情:
kern/kernel.ld
檔案中查詢 __STAB_*
i386-jos-elf-objdump -h obj/kern/kernel
i386-jos-elf-objdump -G obj/kern/kernel
i386-jos-elf-gcc -pipe -nostdinc -O2 -fno-builtin -I. -MD -Wall -Wno-format -DJOS_KERNEL -gstabs -c -S kern/init.c, and look at init.s
。透過在 stab_binsearch
中插入呼叫,可以完成在 debuginfo_eip
中透過地址找到行號的功能。
在核心監視中新增一個 backtrace
命令,擴充套件你實現的 mon_backtrace
的功能,透過呼叫 debuginfo_eip
,然後以下麵的格式來輸出每個棧幀行:
K> backtrace
Stack backtrace:
ebp f010ff78 eip f01008ae args 00000001 f010ff8c 00000000 f0110580 00000000
kern/monitor.c:143: monitor+106
ebp f010ffd8 eip f0100193 args 00000000 00001aac 00000660 00000000 00000000
kern/init.c:49: i386_init+59
ebp f010fff8 eip f010003d args 00000000 00000000 0000ffff 10cf9a00 0000ffff
kern/entry.S:70: <unknown>+0
K>
每行都給出了檔案名和在那個檔案中棧幀的 eip
所在的行,緊接著是函式的名字和那個函式的第一個指令到 eip
的偏移量(比如,monitor+106
意味著傳回 eip
是從 monitor
開始之後的 106 個位元組)。
為防止評級指令碼引起混亂,應該將檔案和函式名輸出在單獨的行上。
提示:printf
格式的字串提供一個易用(儘管有些難理解)的方式去輸出非空終止字串,就像在 STABS 表中的這些一樣。printf("%.*s", length, string)
輸出 string
中的最多 length
個字元。查閱 printf
的 man 頁面去搞清楚為什麼這樣工作。
你可以從 backtrace
中找到那些沒有的功能。比如,你或者可能看到一個到 monitor()
的呼叫,但是沒有到 runcmd()
中。這是因為編譯器的行內(in-lines)函式呼叫。其它的最佳化可能導致你看到一些意外的行號。如果你從 GNUMakefile
刪除 -O2
引數,backtraces
可能會更有意義(但是你的核心將執行的更慢)。
到此為止, 在 lab
目錄中的實驗全部完成,使用 git commit
提交你的改變,然後輸入 make handin
去提交你的程式碼。
via: https://sipb.mit.edu/iap/6.828/lab/lab1/
作者:mit[29] 譯者:qhwdw 校對:wxy
本文由 LCTT 原創編譯,Linux中國 榮譽推出