PC硬體以及引導載入器
PC 硬體
本文介紹供 x86 執行的個人計算機(PC)硬體平臺。
PC 是指遵守一定工業標準的計算機,它的目標是使得不同廠家生產的機器都能夠執行一定範圍內的軟體。這些標準隨時時間遷移不斷變化,因此90年代的 PC 與今日的 PC 看起來已是大不相同。
從外觀來看,PC 是一個配置有鍵盤、螢幕和各種裝置的"盒子"。盒子內部則是一塊積體電路——主機板,上面有 CPU 晶片,記憶體晶片,顯示卡晶片,I/O 控制器晶片,以及負責晶片間通訊的匯流排。匯流排會遵守某種標準(如 PCI 或 USB),從而能夠相容不同廠家的裝置。
我們可以把 PC 抽象為三部分:CPU、記憶體和 I/O 裝置。CPU 負責計算,記憶體用於儲存計算用的指令和資料,其他裝置用於實現儲存、通訊等其他功能。
你可以想象主存以一組導線與 CPU 相連線,其中一些是地址線,一些是資料線,還有一些則是控制線。CPU 要從主存讀出一個值,需要向地址線上輸出一系列表示0和1的電壓,並在規定的時間內在 "讀" 線上發出訊號1,接下來再從資料線上的高低電壓中獲取資料。CPU 若要向記憶體中寫入一個值,則向資料線和地址線上寫入合適的值,並在規定時間內在 "寫" 位上發出訊號1.真實的記憶體介面比這複雜的多,但除非你是在追求高效能,否則你不必考慮這麼多的細節。
處理器和記憶體
CPU(中央處理單元,或處理器)其實只是在執行一個非常簡單的迴圈:從一個被稱為『程式計數器』的暫存器中獲取一個記憶體地址,從個地址讀出機器指令,增加程式計數器的值,執行機器指令,不斷反覆。某些機器指令如分支和函式呼叫會改變程式計數器,如果執行機器指令沒有改變程式計數器,這個迴圈就會從程式計數器開始一條一條地執行指令。
如果不能儲存和修改程式資料,那麼執行指令就是毫無意義的。速度最快的資料儲存器是處理器的暫存器組。一個暫存器是處理器內的一個儲存單元,能夠儲存一個字大小的值(按照機器不同,一個字通常會是16,32或者64位)。暫存器內的值能在一個 CPU 週期內被快速地讀寫。
PC 處理器實現了 x86 指令集,該指令集由 Intel 釋出併成為了一種標準,一些產商生產實現了該指令集的處理器。和其他的 PC 標準一樣,這個標準也在不斷更新,但是新的標準是向前相容的。由於 PC 處理器啟動時都是模擬1981年 IBM PC 上使用的晶片 Intel 8088,所以 boot loader 需要作出改變以應對標準的更新。但是,對於 xv6 的絕大部分內容,你只需要關心現代 x86 指令集。
現代 x86 提供了8個32位通用暫存器--%eax, %ebx, %ecx, %edx, %edi, %esi, %ebp, %esp 和一個程式計數器 %eip(instruction pointer)。字首e是指擴充套件的(extended),表示它們是16位暫存器%ax, %bx, %cx, %dx, %di, %si, %bp, %sp 的32位擴充套件。這兩套暫存器其實是相互的別名,例如 %ax 是 %eax 的低位:我們在寫 %ax 的時候也會改變 %eax,反之亦然。前四個暫存器的兩個低8位還有自己的名字:%al, %ah 分別表示 %ax 的低8位和高8位,%bl, %bh, %cl, %ch, %dl, %dh同理。另外,x86 還有8個80位的浮點暫存器,以及一系列特殊用途的暫存器如控制暫存器 %cr0, %cr2, %cr3, %cr4,除錯暫存器 %dr0, %dr1, %dr2, %dr3;段暫存器 %cs, %ds, %es, %fs, %gs, %ss;還有全域性和區域性描述符表的偽暫存器%gdtr, %ldtr。控制暫存器和段暫存器對於任何作業系統都是非常重要的。浮點暫存器和除錯暫存器則沒那麼有意思,並且也沒有在 xv6 中使用。
暫存器非常快但是也非常昂貴。大多數處理器都會提供至多數十個通用暫存器。下一個層次的儲存器是隨機儲存器(RAM)。主存的速度大概比暫存器慢10到100倍,但要便宜得多,所以容量可以更大。主存較慢的一個原因是它不在處理器晶片上。一個 x86 處理器只有十多個暫存器,但今天的 PC 通常有 GB 級的主存。由於暫存器和主存在讀寫速度和大小上的巨大差異,大多數處理器,包括 x86,都在晶片上的快取中儲存了最近使用的主存資料。快取是主存和暫存器在速度和大小上的折衷。現在的 x86 處理器通常有二級快取,第一級較小,讀寫速率接近處理器的時鐘週期,第二級較大,讀寫速率在第一級快取和主存之間。下表顯示了 Intel Core 2 Duo 系統的實際資料:
Intel Core 2 Duo E7200 at 2.53 GHz 備忘:換上真實數字! |儲存器 | 讀寫時間 | 大小 | |-------|--------|-----| |暫存器|0.6ns|64 位元組| |L1快取|0.5ns|64K 位元組| |L2快取|10ns|4M 位元組| |主存|100ns|4G 位元組|
通常 x86 對作業系統隱藏了快取,所以我們只需要考慮暫存器和主存兩種儲存器,不用擔心主存的層次結構引發的差異。
I/O
處理器必須像和主存互動一樣同裝置互動。x86 處理提供了特殊的 in, out 指令來在裝置地址(稱為'I/O 埠') 上讀寫。這兩個指令的硬體實現本質上和讀寫記憶體是相同的。早期的 x86 處理器有一條附加的地址線:0表示從 I/O 埠讀寫,1則表示從主存讀寫。每個硬體裝置會處理它所在 I/O 埠所接收到的讀寫操作。裝置的埠使得軟體可以配置裝置,檢查狀態,使用裝置;例如,軟體可以通過對 I/O 埠的讀寫,使磁碟介面硬體對磁碟扇區進行讀寫。
很多計算機體系結構都沒有單獨的裝置訪問指令,取而代之的是讓裝置擁有固定的記憶體地址,然後通過記憶體讀寫實現裝置讀寫。實際上現代 x86 體系結構就在大多數高速裝置上(如網路、磁碟、顯示卡控制器)使用了該技術,叫做 記憶體對映 I/O。但由於向前相容的原因,in, out 指令仍能使用,而比較老的裝置如 xv6 中使用的 IDE 磁碟控制器仍使用兩個指令。
引導載入器(boot loader)
當 x86 PC 啟動時,它執行的是一個叫 BIOS 的程式。BIOS 存放在非易失儲存器中,BIOS 的作用是在啟動時進行硬體的準備工作,接著把控制權交給作業系統。具體來說,BIOS 會把控制權交給從引導扇區(用於引導的磁碟的第一個512位元組的資料區)載入的程式碼。引導扇區中包含引導載入器——負責核心載入到記憶體中。BIOS 會把引導扇區載入到記憶體 0x7c00 處,接著(通過設定暫存器 %ip)跳轉至該地址。引導載入器開始執行後,處理器處於模擬 Intel 8088 處理器的模式下。而接下來的工作就是把處理器設定為現代的操作模式,並從磁碟中把 xv6 核心載入到記憶體中,然後將控制權交給核心。xv6 引導載入器包括兩個原始檔,一個由16位和32位彙編混合編寫而成(bootasm.S;(8400)),另一個由 C 寫成(bootmain.c;(8500))。
程式碼:彙編載入程式
引導載入器的第一條指令 cli(8412)遮蔽處理器中斷。硬體可以通過中斷觸發中斷處理程式,從而呼叫作業系統的功能。BIOS 作為一個小型作業系統,為了初始化硬體裝置,可能設定了自己的中斷處理程式。但是現在 BIOS 已經沒有了控制權,而是引導載入器正在執行,所以現在還允許中斷不合理也不安全。當 xv6 準備好了後(詳見第3章),它會重新允許中斷。
現在處理器處在模擬 Intel 8088 的真實模式下,有8個16位通用暫存器可用,但實際上處理器傳送給記憶體的是20位的地址。這時,多出來的4位其實是由段暫存器%cs, %ds, %es, %ss提供的。當程式用到一個記憶體地址時,處理器會自動在該地址上加上某個16位段暫存器值的16倍。因此,記憶體引用中其實隱含地使用了段暫存器的值:取指會用到 %cs,讀寫資料會用到 %ds,讀寫棧會用到 %ss。
xv6 假設 x86 指令在做記憶體操作時使用的是虛擬地址,但實際上 x86 指令使用的是邏輯地址(見表 B-1)。邏輯地址由段選擇器和偏移組成,有時又被寫作segmemt:offset。更多時候,段是隱含的,所以程式會直接使用偏移。分段硬體會完成上述處理,從而產生一個線性地址。如果允許分頁硬體工作(見第2章),分頁硬體則會把線性地址翻譯為實體地址;否則處理器直接把線性地址看作實體地址。
引導載入器還沒有允許分頁硬體工作;它通過分段硬體把邏輯地址轉化為線性地址,然後直接作為實體地址使用。xv6 會配置分段硬體,使之不對邏輯地址做任何改變,直接得到線性地址,所以線性地址和邏輯地址是相等的。由於歷史原因我們用虛擬地址這個術語來指程式操作時用的地址。xv6 的虛擬地址等於 X86 的邏輯地址,同樣也等於分段硬體對映的線性地址。等到開啟了分頁後,系統中值得關心的就只有從線性地址到實體地址的對映。
BIOS 完成工作後,%ds, %es, %ss 的值是未知的,所以在遮蔽中斷後,引導載入器的第一個工作就是將 %ax 置零,然後把這個零值拷貝到三個段暫存器中(8415-8418)。
虛擬地址 segment:offset 可能產生21位實體地址,但 Intel 8088 只能向記憶體傳遞20位地址,所以它截斷了地址的最高位:0xffff0 + 0xffff = 0x10ffef,但在8088上虛擬地址 0xffff:0xffff 則是引用實體地址 0x0ffef。早期的軟體依賴硬體來忽略第21位地址位,所以當 Intel 研發出使用超過20位實體地址的處理器時,IBM 就想出了一個技巧來保證相容性。那就是,如果鍵盤控制器輸出埠的第2位是低位,則實體地址的第21位被清零;否則,第21位可以正常使用。引導載入器用 I/O 指令控制埠 0x64 和 0x60 上的鍵盤控制器,使其輸出埠的第2位為高位,來使第21位地址正常工作(8436)。
對於使用記憶體超過65536位元組的程式而言,真實模式的16位暫存器和段暫存器就顯得非常困窘了,顯然更不可能使用超過 1M 位元組的記憶體。x86系列處理器在80286之後就有了保護模式。保護模式下可以使用更多位的地址,並且(80386之後)有了"32位"模式使得暫存器,虛擬地址和大多數的整型運算都從16位變成了32位。xv6 載入程式依次允許了保護模式和32位模式。
在保護模式下,段暫存器儲存著段描述符表的索引(見圖表 B-2)。每一個表項都指定了一個基實體地址,最大虛擬地址(稱為限制),以及該段的許可權位。這些許可權位在保護模式下起著保護作用,核心可以根據它們來保證一個程式只使用屬於自己的記憶體。
xv6 幾乎沒有使用段;取而代之的是第2章講述的分頁。引導載入器將段描述符表 gdt(8482-8485)中的每個段的基址都置零,並讓所有段都有相同的記憶體限制(4G位元組)。該表中有一個空指標表項,一個可執行程式碼的表項,一個數據的表項。程式碼段描述符的標誌位中指示了程式碼只能在32位模式下執行(0660)。正是由於這樣的設定,引導載入器在進入保護模式時,邏輯地址才會直接對映為實體地址。
引導載入器執行 lgdt(8441)指令來把指向 gdt 的指標 gdtdesc(8487-8489)載入到全域性描述符表(GDT)暫存器中。
載入完畢後,引導載入器將 %cr0 中的 CR0_PE 位置為1,從而開啟保護模式。允許保護模式並不會馬上改變處理器把邏輯地址翻譯成實體地址的過程;只有當某個段暫存器載入了一個新的值,然後處理器通過這個值讀取 GDT 的一項從而改變了內部的段設定。我們沒法直接修改 %cs,所以使用了一個 ljmp 指令(8453)。跳轉指令會接著在下一行(8456)執行,但這樣做實際上將 %cs 指向了 gdt 中的一個程式碼描述符表項。該描述符描述了一個32位程式碼段,這樣處理器就切換到了32位模式下。就這樣,引導載入器讓處理器從8088進化到80286,接著進化到了80386。
在32位模式下,引導載入器首先用 SEG_KDATA(8458-8461)初始化了資料段暫存器。邏輯地址現在是直接對映到實體地址的。執行 C 程式碼之前的最後一個步驟是在空閒記憶體中建立一個棧。記憶體 0xa0000 到 0x100000 屬於裝置區,而 xv6 核心則是放在 0x100000 處。引導載入器自己是在 0x7c00 到 0x7d00。本質上來講,記憶體的其他任何部分都能用來存放棧。引導載入器選擇了 0x7c00(在該檔案中即 $start)作為棧頂;棧從此處向下增長,直到 0x0000,不斷遠離引導載入器程式碼。
最後載入器呼叫 C 函式 bootmain(8468)。bootmain 的工作就是載入並執行核心。只有在出錯時該函式才會返回,這時它會向埠 0x8a00(8470-8476)輸出幾個字。在真實硬體中,並沒有裝置連線到該埠,所以這段程式碼相當於什麼也沒有做。如果引導載入器是在 PC 模擬器上執行,那麼埠 0x8a00 則會連線到模擬器並把控制權交還給模擬器本身。無論是否使用模擬器,這段程式碼接下來都會執行一個死迴圈(8477-8478)。而一個真正的引導載入器則應該會嘗試輸出一些除錯資訊。
程式碼:C 載入程式
引導載入器的 C 語言部分 bootmain.c(8500)目的是在磁碟的第二個扇區開頭找到核心程式。如我們在第2章所見,核心是 ELF 格式的二進位制檔案。為了讀取 ELF 頭,bootmain 載入 ELF 檔案的前4096位元組(8514),並將其拷貝到記憶體中 0x10000 處。
下一步要通過 ELF 頭檢查這是否的確是一個 ELF 檔案。bootmain 從磁碟中 ELF 頭之後 off 位元組處讀取扇區的內容,並寫到記憶體中地址 paddr 處。bootmain 呼叫 readseg 將資料從磁碟中載入(8538),並呼叫 stosb 將段的剩餘部分置零(8540)。stosb(0492)使用 x86 指令 rep stosb 來初始化記憶體塊中的每個位元組。
在核心編譯和連結後,我們應該能在虛擬地址 0x80100000 處找到它。因此,函式呼叫指令使用的地址都是 0xf01xxxxx 的形式;你可以在 kernel.asm 中找到類似的例子。這個地址是在 kernel.ld 中設定的。0x80100000 是一個比較高的地址,差不多處在32位地址空間的尾部;至於原因,我們在第2章中對此作出了詳細解釋。當然,實際的實體記憶體中可能並沒有這麼高的地址。一旦核心開始執行,它會開啟分頁硬體來將虛擬地址 0x80100000 對映到實體地址 0x00100000。載入程式執行到現在,分頁機制尚未被開啟。在kernel.ld中指明瞭核心的paddr是0x00100000,也就是說,引導載入器將核心拷貝到的低地址正是分頁硬體最終會對映的實體地址。
引導載入器的最後一項工作是呼叫核心的入口指令,即核心第一條指令的執行地址。在 xv6 中入口指令的地址是 0x10000c:
# objdump -f kernel
kernel: file format elf32-i386
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0010000c
按照慣例,在 entry.S(1036)中定義的 _start 符號即 ELF 入口。由於 xv6 還沒有建立虛擬記憶體,xv6 的入口即 entry(1040)的實體地址。
現實情況
該附錄中談到的引導載入器編譯後大概有470位元組的機器碼,具體大小取決於編譯優化。為了放入比較小的空間中,xv6 引導載入器做了一個簡單的假設:核心放在引導磁碟中從扇區1開始的連續空間中。通常核心就放在普通的檔案系統中,而且可能不是連續的。也有可能核心是通過網路載入的。這種複雜性就使得引導載入器必須要能夠驅動各種磁碟和網路控制器,並能夠解讀不同的檔案系統和網路原型。也就是說,引導載入器本身就已經成為了一個小作業系統。顯然這樣的引導載入器不可能只有512位元組,大多數的 PC 作業系統的引導過程分為2步。首先,一個類似於該附錄介紹的簡單的引導載入器會從一個已知的磁碟位置上把完整的引導載入器載入進來,通常這步會依靠空間許可權更大的 BIOS 來操作磁碟。接下來,這個超過512位元組的完整載入器就有足夠的能力定位、載入並執行核心了。也許在更現代的設計中,會直接用 BIOS 從磁碟中讀取完整的引導載入器(並在保護模式和32位模式下啟動之)。
本文假設在開機後,引導載入器執行前,唯一發生的事即 BIOS 載入引導扇區。但實際上 BIOS 會做相當多的初始化工作來確保現代計算機中結構複雜的硬體能像傳統標準中的 PC 一樣工作。