Caffeinated 6.828:實驗 1:PC 的引導過程
簡介
這個實驗分為三個部分。第一部分主要是為了熟悉使用 x86 彙編語言、QEMU x86 模擬器、以及 PC 的加電引導過程。第二部分查看我們的 6.828 內核的引導載入器,它位於 lab
樹的 boot
目錄中。第三部分深入到我們的名為 JOS 的 6.828 內核模型內部,它在 kernel
目錄中。
軟體安裝
本課程中你需要的文件和接下來的實驗任務所需要的文件都是通過使用 Git 版本控制系統來分發的。學習更多關於 Git 的知識,請查看 Git 用戶手冊,或者,如果你熟悉其它的版本控制系統,這個 面向 CS 的 Git 概述 可能對你有幫助。
本課程在 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
,它們在 工具頁面 目錄中。為了以後的實驗需要,我們做了一些 qemu
調試方面的變更和補丁,因此,你必須構建你自己的工具。如果你的機器使用原生的 ELF 工具鏈(比如,Linux 和大多數 BSD,但不包括 OS X),你可以簡單地從你的包管理器中安裝 gcc
。除此之外,都應該按工具頁面的指導去做。
動手過程
我們為了你便於做實驗,為你使用了不同的 Git 倉庫。做實驗用的倉庫位於一個 SSH 伺服器後面。你可以擁有你自己的實驗倉庫,其他的任何同學都不可訪問你的這個倉庫。為了通過 SSH 伺服器的認證,你必須有一對 RSA 密鑰,並讓伺服器知道你的公鑰。
實驗代碼同時還帶有一個腳本,它可以幫你設置如何訪問你的實驗倉庫。在運行這個腳本之前,你必須在我們的 submission web 界面 上有一個帳戶。在登陸頁面上,輸入你的 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 界面 來上傳。make handin
提供了很多特殊說明。
對於實驗 1,你不需要去回答下列的任何一個問題。(儘管你不用自己回答,但是它們對下面的實驗有幫助)
我們將使用一個評級程序來分級你的解決方案。你可以使用這個評級程序去測試你的解決方案的分級情況。
第一部分:PC 引導
第一個練習的目的是向你介紹 x86 彙編語言和 PC 引導過程,你可以使用 QEMU 和 QEMU/GDB 調試開始你的練習。這部分的實驗你不需要寫任何代碼,但是,通過這個實驗,你將對 PC 引導過程有了你自己的理解,並且為回答後面的問題做好準備。
從使用 x86 彙編語言開始
如果你對 x86 彙編語言的使用還不熟悉,通過這個課程,你將很快熟悉它!如果你想學習它,PC 彙編語言 這本書是一個很好的開端。希望這本書中有你所需要的一切內容。
警告:很不幸,這本書中的示例是為 NASM 彙編語言寫的,而我們使用的是 GNU 彙編語言。NASM 使用所謂的 Intel 語法,而 GNU 使用 AT&T 語法。雖然在語義上是等價的,但是根據你使用的語法不同,至少從表面上看,彙編文件的差別還是挺大的。幸運的是,這兩種語法的轉換很簡單,在 Brennan's Guide to Inline Assembly 有詳細的介紹。
練習 1
熟悉在 6.828 參考頁面 上列出的你想去使用的可用彙編語言。你不需要現在就去閱讀它們,但是在你閱讀和寫 x86 彙編程序的時候,你可以去參考相關的內容。
我並不推薦你閱讀 Brennan's Guide to Inline Assembly 上的 「語法」 章節。雖然它對 AT&T 彙編語法描述的很好(並且非常詳細),而且我們在 JOS 中使用的 GNU 彙編就是它。
對於 x86 彙編語言程序最終還是需要參考 Intel 的指令集架構,你可以在 6.828 參考頁面 上找到它,它有兩個版本:一個是 HTML 版的,是老的 80386 程序員參考手冊,它比起最新的手冊更簡短,更易於查找,但是,它包含了我們的 6.828 上所使用的 x86 處理器的所有特性;而更全面的、更新的、更好的是,來自 Intel 的 IA-32 Intel 架構軟體開發者手冊,它涵蓋了我們在課程中所需要的、(並且可能有些是你不感興趣的)大多數處理器的全部特性。另一個差不多的(並且經常是很友好的)一套手冊是 來自 AMD 的。當你為了一個特定的處理器特性或者指令,去查找最終的解釋時,保存的最新的 Intel/AMD 架構手冊或者它們的參考就很有用了。
模擬 x86
與在一台真實的、物理的、個人電腦上引導一個操作系統不同,我們使用程序去如實地模擬一台完整的 PC:你在模擬器中寫的代碼,也能夠引導一台真實的 PC。使用模擬器可以簡化調試工作;比如,你可以在模擬器中設置斷點,而這在真實的機器中是做不到的。
在 6.828 中,我們將使用 QEMU 模擬器,它是一個現代化的並且速度非常快的模擬器。雖然 QEMU 內置的監視功能提供了有限的調試支持,但是,QEMU 也可以做為 GNU 調試器 (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 運行的第一個指令的反彙編。這個輸出包含如下的信息:
- IBM PC 從物理地址
0x000ffff0
開始運行,這個地址位於為 ROM BIOS 保留的 64 KB 區域的頂部。 - PC 使用
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 埠描述,以及在 6.828 參考資料頁面 上的其它資料。不需要了解所有的細節 —— 只要搞明白 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 格式規範。
不過對於 6.828,我們將使用傳統的硬碟引導機制,意味著我們的引導載入器必須小於 512 位元組。引導載入器是由一個彙編源文件 boot/boot.S
和一個 C 源文件 boot/main.c
構成,仔細研究這些源文件可以讓你徹底理解引導載入器都做了些什麼。引導載入器必須要做兩件主要的事情:
- 第一、引導載入器將處理器從實模式切換到 32 位保護模式,因為只有在 32 位保護模式中,軟體才能夠訪問處理器中 1 MB 以上的物理地址空間。關於保護模式將在 PC 彙編語言 的 1.2.7 和 1.2.8 節中詳細描述,更詳細的內容請參閱 Intel 架構手冊。在這裡,你只要理解在保護模式中段地址(段基地址:偏移量)與物理地址轉換的差別就可以了,並且轉換後的偏移是 32 位而不是 16 位。
- 第二、引導載入器通過 x86 的專用 I/O 指令直接訪問 IDE 磁碟設備寄存器,從硬碟中讀取內核。如果你想去更好地了解在這裡說的專用 I/O 指令,請查看 6.828 參考頁面 上的 「IDE 硬碟控制器」 章節。你不用學習太多的專用設備編程方面的內容:在實踐中,寫設備驅動程序是操作系統開發中的非常重要的部分,但是,從概念或者架構的角度看,它也是最讓人乏味的部分。
理解了引導載入器源代碼之後,我們來看一下 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
查看 實驗工具指南,特別是 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
循環的開始和結束位置,這個循環從磁碟上讀取內核的剩餘扇區。找出循環結束後運行了什麼代碼,在這裡設置一個斷點,然後繼續。接下來再走完引導載入器的剩餘工作。
完成之後,就能夠回答下列的問題了:
- 處理器開始運行 32 代碼時指向到什麼地方?從 16 位模式切換到 32 位模式的真實原因是什麼?
- 引導載入器執行的最後一個指令是什麼,內核載入之後的第一個指令是什麼?
- 內核的第一個指令在哪裡?
- 為從硬碟上獲取完整的內核,引導載入器如何決定有多少扇區必須被讀入?在哪裡能找到這些信息?
載入內核
我們現在來進一步查看引導載入器在 boot/main.c
中的 C 語言部分的詳細細節。在繼續之前,我們先停下來回顧一下 C 語言編程的基礎知識。
練習 4
下載 pointers.c 的源代碼,運行它,然後確保你理解了輸出值的來源的所有內容。尤其是,確保你理解了第 1 行和第 6 行的指針地址的來源、第 2 行到第 4 行的值是如何得到的、以及為什麼第 5 行指向的值表面上看像是錯誤的。
如果你對指針的使用不熟悉,Brian Kernighan 和 Dennis Ritchie(就是大家知道的 「K&R」)寫的《C Programming Language》是一個非常好的參考書。同學們可以去買這本書(這裡是 Amazon 購買鏈接),或者在 MIT 的圖書館的 7 個副本 中找到其中一個。在 SIPB Office 也有三個副本可以細讀。
在課程閱讀中,Ted Jensen 寫的教程 可以使用,它大量引用了 K&R 的內容。
警告:除非你特別精通 C 語言,否則不要跳過這個閱讀練習。如果你沒有真正理解了 C 語言中的指針,在接下來的實驗中你將非常痛苦,最終你將很難理解它們。相信我們;你將不會遇到什麼是 」最困難的方式「。
要了解 boot/main.c
,你需要了解一個 ELF 二進位格式的內容。當你編譯和鏈接一個 C 程序時,比如,JOS 內核,編譯器將每個 C 源文件('.c
')轉換為一個包含預期硬體平台的彙編指令編碼的二進位格式的對象文件('.o
'),然後鏈接器將所有編譯過的對象文件組合成一個單個的二進位鏡像,比如,obj/kern/kernel
,在本案例中,它就是 ELF 格式的二進位文件,它表示是一個 」可運行和可鏈接格式「。
關於這個格式的全部信息可以在 我們的參考頁面 上的 ELF 規範 中找到,但是,你並不需要深入地研究這個格式 的細節。雖然完整的格式是非常強大和複雜的,但是,大多數複雜的部分是為了支持共享庫的動態載入,在我們的課程中,並不需要做這些。
鑒於 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 手冊 上講的非常詳細,但是現在,我們知道命令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 %dn", 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 表,它是一個位元組到字元串的映射表。
這個輸出取決於 x86 是小端法這一事實。如果這個 x86 採用大端法格式,你怎麼去設置
i
,以產生相同的輸出?你需要將57616
改變為一個不同值嗎?
- 在下列代碼中,
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/
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive