OS 啓動 Boot Loader 彙編實現細節
書接上文,BIOS 啓動計算機,檢查和準備硬件後,接下來會將控制權傳遞給 Boot Loader。Boot Loader 負責將操作系統從磁盤加載到內存中。接下來講解 Boot Loader 的實現細節。
在 JOS 中 Boot Loader 分爲了彙編和 C 語言兩部分來實現的,這章節講解彙編部分 boot/boot.S
,下一章講解 C 語言部分boot/main.c
。
爲什麼拆分爲兩部分?
簡單來說無法全部用 C 語言來實現,例如將處理器從實模式切換到保護模式。這個過程需要直接操作處理器的控制寄存器,這些操作在高級語言中通常是不支持的,因此需要使用匯編語言來完成。
此外在系統啓動時,硬件處於一個未知的狀態,需要進行初始化。這部分工作通常包括設置中斷,初始化內存,檢測硬件設備等,這些工作需要直接操作硬件,而彙編語言可以直接操作硬件,因此通常使用匯編語言來完成。
當處理器切換到保護模式後,引導加載器需要加載內核到內存,並跳轉到內核的入口點開始執行。這部分工作可以使用高級語言(如 C 語言)來完成,因爲高級語言更易於編寫和維護,同時也可以利用高級語言的各種特性,如函數、結構體等。
總的來說,使用匯編和 C 語言兩部分來實現引導加載器,既可以利用匯編語言直接操作硬件的優勢,又可以利用高級語言易於編寫和維護的優勢。
主引導記錄(MBR)
主引導記錄(Master Boot Record,MBR)是位於硬盤驅動器的第一個扇區(即硬盤的第一個物理扇區,通常是第 0 柱面,第 0 磁頭,第 1 扇區)的一段引導代碼。它的大小爲 512 字節,主要包含以下兩部分內容:
-
引導加載器(Boot Loader):這是一段小程序,它的任務是加載並執行操作系統的引導程序。引導加載器的大小通常非常小,因爲它需要適應 MBR 的大小限制。在引導加載器中,通常會包含一些基本的硬件初始化代碼,以及加載操作系統引導程序的代碼。
-
分區表(Partition Table):分區表是硬盤分區信息的列表。它描述了硬盤上的各個分區的位置和大小。分區表通常位於 MBR 的最後,佔用 64 字節,可以描述最多 4 個分區。
當計算機啓動時,BIOS 會首先讀取硬盤的 MBR,並執行其中的引導加載器代碼。引導加載器會根據分區表的信息,找到操作系統的引導程序所在的位置,然後加載並執行它,從而啓動操作系統。
需要注意的是,由於 MBR 的大小限制,引導加載器通常只能完成最基本的加載任務。對於一些複雜的操作系統,可能需要使用更復雜的引導加載器。在這種情況下,MBR 中的引導加載器的任務就是加載這個更復雜的引導加載器,這個更復雜的引導加載器通常被存儲在硬盤的其他位置。
此外,MBR 也有一些侷限性。例如,它只能支持最大 2TB 的硬盤,只能創建最多 4 個主分區等。爲了解決這些問題,現代的計算機系統通常使用 GUID 分區表(GPT)來代替 MBR。
JOS 中 BIOS 最後會將 512 字節的引導扇區加載到物理地址 0x7c00
到 0x7dff
的內存中,其中包含了 Boot Loader 程序。隨後使用指令 jmp 將 CS:IP
設置爲 0000:7c00
並將控制權傳遞給 Boot Loader,最後 Boot Loader 負責將 OS 從磁盤加載到內存中。
爲什麼是 512 字節?
這個大小是由歷史原因決定的,因爲在早期的硬盤中,512 字節是一個很合適的大小,既可以滿足存儲引導加載器的需要,又不會浪費太多的空間。
PC 是以扇區位單位進行讀取的數據的,一個扇區是 512 字節。爲什麼 PC 是以扇區爲單位從磁盤中讀取數據,主要有兩個原因:
-
兼容性:由於歷史原因,很多操作系統和硬件設備都假設硬盤的扇區大小爲 512 字節。如果改變這個大小,可能會導致一些軟件和硬件設備無法正常工作。
-
效率:硬盤的讀寫操作有一定的開銷,如果每次只讀寫很小的數據,這個開銷就會變得很大。因此,硬盤通常會一次讀寫一個扇區的數據,這樣可以提高數據的讀寫效率。
在現代的硬盤中,扇區的大小可能會大於 512 字節,例如 4096 字節。但是爲了兼容老的軟件和硬件設備,這些硬盤通常會提供一個 512 字節扇區的模擬模式。
啓用 A20
Boot Loader 拿到控制權後會先執行啓用 A20 線,使得可以訪問超過 1MB 的內存地址。
下面這段代碼是用於啓用 A20 線的。A20 線是計算機內存的一個地址線,它可以訪問的內存地址超過 1MB。在早期的 PC 機中,爲了保持向後兼容性,物理地址線 20 默認被拉低,這意味着所有超過 1MB 的內存地址都會迴繞到零,也就是說,它們會被映射到內存的開始位置。
"拉低" 在硬件設計中通常指的是將某個電平設置爲低電平(通常是 0V),這在數字邏輯中通常表示邏輯 "0"。"A20 線被拉低" 意味着 A20 地址線被禁用,這限制了 CPU 訪問的物理內存地址範圍。
在早期的 PC 機中,爲了保持與 IBM PC 和 IBM PC/XT 的向後兼容性,A20 線在啓動時默認被禁用(或者說被 "拉低")。這意味着,儘管 CPU 的地址總線有 21 根線(A0-A20),可以尋址 2^21(即 2MB)的內存空間,但由於 A20 線被禁用,實際上只能訪問到 1MB 的內存空間。
啓用 A20 線(或者說 "拉高"A20 線)可以讓 CPU 訪問超過 1MB 的內存空間。這在運行需要大量內存的現代操作系統時是必要的。
然而,隨着計算機硬件的發展,內存容量已經遠遠超過了 1MB,因此需要解除這一限制,以便能夠訪問更多的內存。這就是爲什麼需要啓用 A20 線。
seta20.1:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.1
movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64
seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2
movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60
這段代碼的目的就是解除這一限制,啓用 A20 線,使得可以訪問超過 1MB 的內存地址。具體來說,代碼首先等待鍵盤控制器(端口 0x64)不忙,然後向鍵盤控制器發送命令 0xd1,這個命令的作用是告訴鍵盤控制器接下來要寫入的數據是一個命令字節。然後代碼再次等待鍵盤控制器不忙,最後向鍵盤控制器的數據端口(端口 0x60)寫入命令 0xdf,這個命令的作用是啓用 A20 線。
在早期的 IBM PC 兼容機中,鍵盤控制器(通常是 Intel 8042 芯片)不僅負責處理鍵盤輸入,還負責處理一些系統級別的功能,其中之一就是控制 A20 線的狀態。這是因爲在設計這些系統時,人們需要找到一種方法來禁用 A20 線以保持與舊的 8086 處理器的兼容性,而鍵盤控制器恰好有一些未使用的輸出線,所以人們選擇了使用鍵盤控制器來控制 A20 線。
在這段代碼中,首先通過讀取端口 0x64 的狀態,等待鍵盤控制器不忙。然後向端口 0x64 寫入 0xd1,這是一個特殊的命令,告訴鍵盤控制器接下來要寫入的數據是一個命令字節,而不是普通的數據。然後再次等待鍵盤控制器不忙,最後向數據端口 0x60 寫入 0xdf,這個命令的作用是啓用 A20 線。
這樣做的原因是,鍵盤控制器的某個輸出線被連接到了 A20 線的門控,當這個輸出線被設置爲高電平時,A20 線就會被啓用,允許 CPU 訪問超過 1MB 的內存地址。而 0xdf 這個命令就是用來設置這個輸出線爲高電平的。
16 位實模式切換到 32 位保護模式
A20 線開啓後接下來將 16 位實模式切換到 32 位保護模式,在 JOS 操作系統中,處理器從 16 位模式切換到 32 位模式的過程發生在引導加載器(boot loader)的早期階段。這個過程通常在引導加載器的彙編語言部分中完成。
切換到 32 位模式的關鍵步驟是設置和啓用保護模式。這是通過設置控制寄存器 CR0 的 PE(保護使能)位來實現的。當 PE 位被設置爲 1 時,處理器就會進入保護模式,此時可以執行 32 位代碼。
以上就是處理器從 16 位模式切換到 32 位模式的過程。下面是具體的切換代碼。
# 通過使用引導GDT(全局描述符表)和段翻譯,
# 從實模式切換到保護模式,使虛擬地址與物理地址相同,
# 以確保在切換期間內存映射保持不變。
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
# 跳轉到下一條指令,但在32位代碼段中執行。
# 這將使處理器切換到32位模式。
ljmp $PROT_MODE_CSEG, $protcseg
這段代碼描述的是從 16 位實模式切換到 32 位保護模式的過程。在 x86 架構的早期,處理器在啓動時會處於 16 位的實模式,這種模式下,處理器可以直接訪問物理內存,但是隻能訪問到 1MB 的內存空間。爲了能夠訪問更多的內存和提供更好的內存保護機制,處理器需要切換到 32 位的保護模式。
切換到保護模式的過程如下:
-
lgdt gdtdesc
:這條指令用於加載全局描述符表(GDT)。GDT 是一種數據結構,它定義了各種不同的內存段,包括它們的基地址、限制和訪問權限等信息。在切換到保護模式之前,需要先設置好 GDT。 -
movl %cr0, %eax
和orl $CR0_PE_ON, %eax
:這兩條指令用於修改控制寄存器 CR0 的 PE(保護使能)位,將其設置爲 1。CR0 是一個 32 位的寄存器,它的第 0 位是 PE 位,當 PE 位被設置爲 1 時,處理器會切換到保護模式。 -
movl %eax, %cr0
:這條指令將修改後的 CR0 值寫回 CR0 寄存器,完成模式切換。 -
ljmp $PROT_MODE_CSEG, $protcseg
:這條指令執行一個長跳轉,跳轉到標籤protcseg
指向的地址,並且在跳轉後,處理器會開始在 32 位代碼段中執行代碼。這條指令的執行會導致處理器更新代碼段寄存器 CS,從而使處理器開始執行 32 位代碼。
這樣,處理器就從 16 位實模式切換到了 32 位保護模式。
設置堆棧指針並調用 C 語言函數。
下面這段代碼是在設置保護模式下的數據段寄存器,並設置堆棧指針,然後調用 C 語言函數bootmain
。
protcseg:
# 設置保護模式下的數據段寄存器
movw $PROT_MODE_DSEG, %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment
# 設置堆棧指針並調用C語言函數。
movl $start, %esp
call bootmain
# 如果 bootmain 返回(它不應該返回),則進入循環。
spin:
jmp spin
首先,movw $PROT_MODE_DSEG, %ax
將PROT_MODE_DSEG
(保護模式下的數據段選擇器)的值移動到寄存器%ax
中。然後,movw %ax, %ds
等指令將%ax
中的值複製到各個段寄存器(DS、ES、FS、GS 和 SS)中。這樣做是爲了在保護模式下設置正確的數據和堆棧段。
在 x86 架構中,DS、ES、FS、GS、SS 是段寄存器,它們在保護模式下用於存儲段選擇器,用於指定內存訪問的段。
-
DS(Data Segment):數據段寄存器,通常用於存儲操作數和結果的內存段的段選擇器。
-
ES(Extra Segment):附加段寄存器,通常用於字符串和其他數據塊操作的目標地址的段選擇器。
-
FS、GS:這是在 80386 中新增的兩個段寄存器,主要用於操作系統,可以用於存儲任何段選擇器。
-
SS(Stack Segment):堆棧段寄存器,用於存儲堆棧的段選擇器。
在這段代碼中,所有這些段寄存器都被設置爲同一個值(PROT_MODE_DSEG
),這是因爲在這個特定的上下文中,所有的內存訪問都應該在同一個內存段中進行。這樣做可以簡化內存管理,因爲處理器不需要在不同的內存段之間切換。
接着,movl $start, %esp
將堆棧指針%esp
設置爲start
的地址。這是爲了在調用bootmain
函數之前設置正確的堆棧。
在 x86 架構中,%esp
寄存器是堆棧指針寄存器 (Stack Pointer Register)。它的主要作用是指向當前的棧頂。當我們在程序中調用函數、保存臨時變量或者保存 CPU 的狀態時,這些信息通常會被壓入棧中,而%esp
寄存器就是用來追蹤當前棧頂位置的。
例如,當我們調用一個函數時,返回地址通常會被壓入棧中,然後%esp
寄存器的值會減小(在 x86 架構中,棧是向下增長的),以指向新的棧頂。當函數返回時,返回地址會從棧中彈出,%esp
寄存器的值會增大,以指向新的棧頂。
因此,%esp
寄存器在函數調用、異常處理以及任務切換等操作中都起着非常重要的作用。
然後,call bootmain
調用bootmain
函數。這個函數應該包含了引導加載器的主要邏輯,例如加載內核到內存,然後跳轉到內核的入口點。
最後,如果bootmain
函數返回(實際上它不應該返回),代碼會進入一個無限循環spin
。這是一個安全措施,防止執行未定義的指令。如果bootmain
函數意外返回,CPU 將會在這個無限循環中停止,而不是繼續執行可能存在的隨機指令。
全局描述表 GDT
下面這段代碼定義了一個全局描述符表(Global Descriptor Table,簡稱 GDT)。GDT 是 x86 架構中用於實現內存保護和分段內存管理的重要數據結構。每個段描述符定義了一個段的屬性,如基地址、限制和訪問權限等。
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
gdt:
SEG_NULL # null seg
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg
SEG(STA_W, 0x0, 0xffffffff) # data seg
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt
在這段代碼中,定義了一個包含三個段描述符的 GDT:
-
第一個描述符是一個空描述符(
SEG_NULL
),在 x86 架構中,第一個描述符必須是空描述符。 -
第二個描述符定義了一個代碼段(
SEG(STA_X|STA_R, 0x0, 0xffffffff)
)。STA_X
和STA_R
表示這個段是可讀的並且可執行的。0x0
和0xffffffff
分別是這個段的基地址和限制,表示這個段覆蓋了整個 4GB 的地址空間。 -
第三個描述符定義了一個數據段(
SEG(STA_W, 0x0, 0xffffffff)
)。STA_W
表示這個段是可寫的。同樣,這個段也覆蓋了整個 4GB 的地址空間。
gdtdesc
定義了一個 GDT 描述符,它包含了 GDT 的大小和地址。在切換到保護模式之前,處理器需要知道 GDT 的位置和大小,這就是通過加載這個 GDT 描述符來實現的。因爲在保護模式下,處理器通過段選擇器和偏移量來訪問內存,其中段選擇器是一個 16 位的值,它的高 13 位是索引,用於在 GDT 中查找對應的段描述符。在這裏,0x17
是 GDT 的大小減 1(因爲 GDT 的大小是以字節爲單位,所以需要減 1),gdt
是 GDT 的地址。
+------------------------+
| GDT Header |
+------------------------+
| Segment Descriptor 1 | +----------------------+
+------------------------+ | Segment Descriptor 2 |
| Segment Descriptor 2 | +----------------------+
+------------------------+ | Segment Descriptor 3 |
| Segment Descriptor 3 | +----------------------+
| ... | | ... |
+------------------------+ +----------------------+
| Segment Descriptor n | | Segment Descriptor n |
+------------------------+ +----------------------+
上面的文本圖形化展示了全局描述符表(GDT)的基本結構。GDT 是用於在 x86 架構中進行內存分段和保護的重要數據結構。以下是各個部分的解釋:
-
GDT Header: GDT 的開頭包含一個簡單的頭部,其中包括 GDT 的大小等信息。
-
Segment Descriptor 1, 2, 3, ..., n: GDT 包含一系列段描述符,每個描述符對應一個內存段。每個段描述符包含了關於該段的信息,如基地址、段限制、訪問權限等。這些描述符以數組的形式存在,可以根據需要添加更多的描述符。
每個段描述符的結構大致如下:
+------------------------+------------------------+
| Base Address | Segment Limit | <-- 64 bits
+------------------------+------------------------+
| G | D/B | 0 | AVL | P | Limit | AVL | S | Type | <-- 32 bits
+------------------------+--------+-------+-------+
-
Base Address: 段的基地址,指示段在內存中的起始位置。
-
Segment Limit: 段的限制,指示段的大小。
-
G (Granularity): 指示段限制的單位,如果爲 1,表示以 4KB 爲單位;如果爲 0,表示以字節爲單位。
-
D/B (Default/Big): 當爲 1 時表示 32 位操作模式,當爲 0 時表示 16 位操作模式。
-
AVL (Available): 可由系統或程序自由使用的位。
-
P (Present): 表示段是否在內存中存在。
-
S (System/Segment): 如果爲 0,表示是系統段;如果爲 1,表示是代碼或數據段。
-
Type: 指示段的類型,如代碼段、數據段等。
以上圖示僅爲簡化的表示,實際 GDT 可能會更復雜,包括特權級、段的類型、權限等更多信息。這裏的圖示主要用於概述 GDT 的基本結構。
保護模式下的尋址方式
在 x86 架構中,當處理器運行在保護模式下時,內存訪問不再是直接通過物理地址,而是通過一個叫做 "段選擇器" 的值加上一個偏移量來完成的。這種方式提供了更好的內存保護和更大的內存訪問範圍。
段選擇器是一個 16 位的值,它的高 13 位是索引,用於在全局描述符表(GDT)中查找對應的段描述符,剩餘的 3 位就被用來表示請求者的特權級別和選擇使用的表,爲訪問控制和隔離提供了更多的靈活性。段描述符包含了段的基地址、限制和訪問權限等信息。
假設我們有一個段選擇器,它的值爲0x1234
,那麼它的二進制表示爲0001 0010 0011 0100
。其中,高 13 位(0001 0010 0011 0
)是索引,用於在 GDT 中查找對應的段描述符。
在 GDT 中,每個段描述符佔用 8 個字節,所以我們可以通過索引乘以 8 來計算段描述符在 GDT 中的偏移量。在這個例子中,索引的值爲0x123
(十進制的 291),所以段描述符在 GDT 中的偏移量爲0x123 * 8 = 0x918
。
然後,處理器會將這個偏移量加上 GDT 的基地址,得到段描述符在內存中的物理地址。處理器會從這個地址處讀取 8 個字節的數據,得到段描述符的內容。
最後,處理器會根據段描述符的內容和偏移量來訪問內存。例如,如果偏移量爲0x5678
,那麼處理器會訪問的物理地址就是段的基地址加上0x5678
。
總結
整體來說,Boot Loader 是在啓動過程中的關鍵步驟,它設置了 CPU 的運行環境,從實模式切換到保護模式,並準備好跳轉到高級語言編寫的引導程序。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/c-QRzDrF7h1JOdT8jdsOaA