寫作業系統之開發載入器

東小夫發表於2021-10-16

loader功能

功能

loader的功能是:

  1. 從軟盤中把作業系統核心讀取到記憶體中。
  2. 進入保護模式。
  3. 把記憶體中的作業系統核心重新放置到記憶體中。
  4. 執行作業系統核心。

如果理解不了上面的部分語句,先擱置,後面會詳細說明。

流程圖

先看loader的流程圖。

不必全部看懂。我覺得可能對讀者理解後面的內容有幫助,所以先給出這張圖。

image-20211016201910491

Kernel

程式碼

在載入kernel到記憶體中之前,先要有一個核心。我們馬上寫一個。下面的程式碼在檔案kernel.asm中。

[section .text]

global _start

_start:
        mov ah, 0Fh
        mov al, 'C'
        mov [gs:(80 * 20 + 40) * 2], ax
        mov [gs:(80 * 21 + 40) * 2], ax
        mov [gs:(80 * 22 + 80) * 2], ax
        jmp $
        jmp $

上面的程式碼是展示了一個彙編函式的模板,但又不是典型的彙編函式模板。

[section .text]是偽指令,不會被CPU執行,僅僅只是告知程式設計師下面的程式碼是可執行程式碼,提高程式碼的可讀性。

global _start讓函式_start成為全域性函式。

func_name:
	; some code
	ret

這才是彙編函式的標準模板。func_name是函式名,;some coderet都是函式體。ret可以理解為C語言中的return

kernel.asm中,函式名是_start,這固定的,只能是這個名字,由編譯器或連結機制決定(我也不是特別清楚)。

mov [gs:(80 * 20 + 40) * 2], ax,把字元C列印在螢幕的第20行、第40列。

jmp $,可以理解成C程式碼while(1){}

編譯

# 把kernel.asm編譯成elf格式的目標檔案kernel.o。
nasm -o kernel.o -f elf kernel.asm
# 使用聯結器ld把目標檔案kernel.o連線成32位的可執行檔案kernel.bin,並且使用0x30400作為文字段的起始點。
ld -s -Ttext 0x30400  -o kernel.bin  kernel.o -m elf_i386

放入軟盤

# 把boot.bin寫入軟盤a.img
dd if=boot.bin of=a.img bs=512 count=1 conv=notrunc
# 掛載軟盤a.img,以便在下面把loader.bin、kernel.bin寫入軟盤。
sudo mount -o loop a.img /mnt/floppy/
sudo cp loader.bin      /mnt/floppy/ -v
sudo cp kernel.bin      /mnt/floppy/ -v
# 解除安裝軟盤
sudo umount /mnt/floppy

經過上面的一系列步驟,就得到了一個可執行的核心檔案kernel.bin。雖然簡單,但我們寫的這個作業系統無論變得多複雜,都是在這個簡單的核心檔案上慢慢新增功能變成的。

載入核心

核心程式碼在kernel.bin檔案中。kernel.binkernel.asm經過編譯後的二進位制檔案。載入核心,就是把kernel.bin從軟盤中讀取到記憶體中。

從軟盤中讀取資料的思路是:

  1. 根據檔名在軟盤的根目錄中找到目標檔案的第一個FAT項的編號,同時也是在資料區的第一個扇區的編號。
  2. 由於檔案的所有FAT項構成一個單連結串列,變數這個單連結串列,讀取每個FAT項對應的扇區的資料複製到記憶體中。

和讀取引導扇區到記憶體中的方法高度相似,在程式碼上只有很小的差異。在後面不能理解載入核心的程式碼時,請回頭看看《開發引導扇區》。

CPU模式

es:bx

boot.asm中,使用int 13h會把從軟盤中讀取到的資料儲存到es:bx指向的記憶體中。就從es:bx這條指令(稱呼它為指令不嚴謹,我沒見過在彙編程式碼中直接使用這樣的指令)開始講述本節的內容。

es:bx的的值是多少?這個值有什麼意義?

先回答第二個問題,它的值表示一個記憶體地址,實體記憶體地址,不是在高階程式語言例如C語言編寫的程式中出現的記憶體地址。後者是虛擬記憶體地址。

什麼叫實體記憶體地址?先解釋什麼是實體地址。拿軟盤來說。在軟盤的引導扇區的最後兩個位元組儲存0x55AA,那麼,在軟盤中偏移量是510個位元組的儲存空間中,能看到0x55AA。如果510是虛擬地址,在軟盤中偏移量是510個位元組的儲存空間中,可能看不到0x55AA

所以,對“實體地址”和“虛擬地址”的理解是:前者和儲存空間一一對應,如果0x55AA儲存在軟盤的引導扇區中地址為510的儲存空間中,就能在這個地址的儲存空間中找到0x55AA中這兩個位元組的資料。後者和儲存空間不是一一對應的,0x55AA儲存在虛擬地址510的儲存空間中,在軟盤中地址為510的儲存空間找不到0x55AA這兩個位元組的資料。

理解了“實體地址”,再把"軟盤"換成"記憶體",想必非常容易理解“實體記憶體地址”的含義。

用一句什麼話過渡到CPU模式呢?

那麼,es:bx的值究竟應該如何計算呢?計算方法取決於CPU所處的模式。先介紹一下“真實模式”。

真實模式

實際上,我們已經體驗過“真實模式"了。引導扇區程式就執行在真實模式下。

我認為,對這種古老的歷史知識,不必深究。真實模式對我而言,有用的知識點是:

  1. 在這種模式下,實體記憶體地址的計算方法,也就是計算es:bx的值的方法。
  2. 暫存器中的資料最大是16位的,不能是32位的。

賣了不少關子,其實,計算方法只用一句話就能講清楚:
$$
真實模式下的實體記憶體地址 = es * 16 + bx。
$$
例如,要把核心讀取到實體記憶體地址是0x91000的儲存空間中,只需把es的值設定成0x9000,把bx的值設定成0x1000

為什麼這樣做就能表示實體記憶體地址0x91000呢?這涉及到"選擇子"、“地址匯流排”等知識。我認為,不知道這些古老的概念,暫時並不妨礙我們繼續開發自己的作業系統核心。就算花點時間弄明白了,過段時間又忘記了,不忘記,作用似乎也不大,僅僅滿足了自己的好奇心。不如先跳過這種細枝末節、不影響大局的知識點,降低自己的學習難度。以後有時間再弄清楚這種細節。

真實模式下,最多能使用多大的記憶體?

記憶體條可以隨意增加空間,能使用的最大記憶體就取決於計算機能定址多大的記憶體地址。計算機的記憶體定址又受制於暫存器能提供的記憶體地址值和地址匯流排能傳遞的記憶體地址值。

不可避免地要介紹一下地址匯流排。我也只是稍微瞭解一點夠用的知識。

CPU和記憶體之間通過地址匯流排交流,可以這麼簡單粗暴地理解。CPU把記憶體地址,例如0x9000:0x1000通過地址匯流排告知記憶體,要讀取0x9000:0x1000處的資料。

  1. 首先,0x90000x1000這兩個數值,能儲存在16位暫存器esax中。如果超過16位暫存器的數值儲存範圍,CPU就不能讀取0x9000:0x1000處的資料。
  2. 地址匯流排接收到的記憶體地址是0x9000:0x1000計算出來的數值0x91000。如果0x91000超出地址匯流排的儲存範圍,CPU也不能讀取到目標資料。

真實模式下,地址匯流排是20位的,能表示的最大數值是$2^{20}-1$。

計算機定址的最小單位是“位元組”,而不是“bit”。所以,真實模式下,計算機能使用的記憶體最大是$2^{20} - 1 + 1$位元組,即1M

為什麼是$2{20}-1+1$?因為記憶體地址的初始值是0。從0到最大值$2{20}-$1,總計有$2^{20}-1+1$個位元組。

各位的電腦記憶體是多大?遠遠大於1M。真實模式很快就不能滿足需求,所以就出現了“保護模式”。

保護模式

和真實模式對比

保護模式下,

  1. CPU通過暫存器能提供的最大記憶體地址是$2^{32}-1$。
  2. 地址匯流排是32位的,能傳輸的最大記憶體地址也是$2^{32}-1$。
  3. 因此,計算機能使用的最大記憶體是4GB

在真實模式下,任何指令能使用任何合法的記憶體地址指向的記憶體空間。想象一下,A地址儲存小明的程式碼XM,B地址儲存小王的程式碼XW。小明和小王約定好,執行完XM後,跳轉到XW;執行XW後,跳轉到XM。可是,小王和小明吵了一架,偷偷地在XW中把A地址處的程式碼全部擦除,執行完XW後,小明的程式碼就再也不會執行了。

再舉個例子,你一邊用編輯器寫程式碼,一邊用某個音樂播放器聽音樂,音樂播放器偷偷修改了正在執行中的編輯器所佔用的記憶體中的資料,導致你執行自己的程式碼時總是出錯。當然,你肯定沒有遇到過這種奇怪的事情。因為你的電腦的CPU不是執行在真實模式下。

通過兩個例子,應該能夠知道真實模式的弊端。保護模式中的“保護”二字,集中體現在:對指令能使用的記憶體空間做了限制,不能像在真實模式下那樣能隨心所欲地使用任何合法的記憶體地址指向的記憶體空間。

用最簡單的語言描述保護模式:

  1. 計算機能使用的最大記憶體是4GB
  2. 指令只能使用特定的記憶體空間。
  3. 記憶體定址方式不同。

保護模式下,仍然用es:bx這種格式的資料表示記憶體地址,可計算es:bx的值的方法和真實模式下大為不同。

先了解幾組概念。

GDT

GDT的全稱是"Global Description Table",對應的中文術語是“全域性描述符表"。在後面,還會遇到LDT。

全域性描述符表,顧名思義,這個表中的每個項都是一個全域性描述符。

全域性描述符是一段8個位元組的資料。這8個位元組包含三項內容:段界限、段基址、段屬性。

全是陌生概念。不要慌。讓我們一個一個來弄清楚。

在前面,我講過,保護模式下,指令不能訪問任意記憶體空間的指令,能訪問的記憶體空間是被限制的。怎麼實現這種限制呢?

劃定一段記憶體空間,規定這段記憶體空間只有具有某種屬性的指令才能訪問。怎麼劃定一段記憶體空間?非常自然地想到在:指定一個初始值,再指定一個界限值,就能確定一段記憶體空間。然後再用“段屬性”規定訪問這段記憶體空間需要具備的條件。

“初始值”就是“段基址”,“界限值”就是“段界限”。段屬性是什麼?有點難說清楚,後面再說。

在邏輯上,全域性描述符的結構很清晰,可它的實際結構卻非常不規則。先看看全域性描述符的結構示意圖。

image-20211008155314804

怎麼解讀這張圖?

  1. 圖中的資料使用“小端法”儲存。
  2. 全域性描述符佔用8個位元組,即64個bit。
  3. 示意圖中把描述符分為“程式碼段”描述符和“資料段”描述符。是哪種描述符,由段屬性決定。
  4. 段屬性依然很複雜。我們們再擱置一會兒。

介紹描述符結構的終極目的是用程式碼表示描述符,而且是用匯編程式碼表示。

描述符的本質是64個bit,要把一段記憶體空間的"資訊"(初始地址--段基址、這段記憶體空間的長度減去1--段界限、這段記憶體空間的屬性--段屬性)按描述符的結構儲存到這64個bit中。

所謂“結構”,可以理解為:按某種規則解讀一段資料。例如,桌子上按順序放著10支鉛筆,每支紅色鉛筆表示1年,2支紅色鉛筆表示2年,3支紅色鉛筆表示3年,按照這個規定,10支紅色鉛筆表示10年。再規定,前四支鉛筆表示小明的年齡,後六支鉛筆表示小王的年齡。給你10支援按順序排列的鉛筆,你能從中查詢到小明和小王的年齡嗎?這一定是一件非常容易的事。分別看看前4支、後6支鉛筆中紅色鉛筆的數量即可。

不知道這個例子是否恰當。我想表達的意思是:"結構"是一種規定好的規則,例如,前4支鉛筆表示小明的年齡;讀資料,要按這種規則去讀,存資料也要按這種規則去存。小明的年齡只能從前4支鉛筆讀取,也只能用前4支鉛筆儲存。

先給出用C語言表示的描述符結構。

typedef struct{
  			// 對應描述符結構圖中的BYTE0、BYTE1。
        unsigned short seg_limit_below;
  			// 對應描述符結構圖中的BYTE2、BYTE3。
        unsigned short seg_base_below;
  			// 對應描述符結構圖中的BYTE4。
        unsigned char  seg_base_middle;
  			// 對應描述符結構圖中的BYTE5。
        unsigned char seg_attr1;
  			// 對應描述符結構圖中的BYTE6。
        unsigned char seg_limit_high_and_attr2;
  			// 對應描述符結構圖中的BYTE7。
        unsigned char seg_base_high;
}Descriptor;

我當初怎麼都理解不了彙編程式碼表示的描述符結構,直到看到用C語言表示的描述符結構後才豁然開朗,所以先給出C程式碼,希望跟我有同樣困惑的人能和我一樣頓悟。

因為我只是普通的人,當初理解“結構”和程式碼的對應關係著實花了不少時間,所以在這裡寫得比較囉嗦,實際上是把我當時的理解過程全部寫了出來。聰明人請快速跳過這段。

用匯編語言怎麼表示描述符結構?我決定先不照搬之前已經寫好的程式碼,而是和讀者朋友一起現場徒手再寫一次描述符結構的彙編程式碼A。請對照前面的描述符結構圖寫程式碼。過程如下:

  1. 使用匯編程式碼中的巨集來實現描述符結構。
  2. 這個巨集有三個引數,分別是段界限、段基址、段屬性,命名為seg_limit、seg_base、seg_attr。每個引數的長度都是4個位元組。
  3. 段界限1是seg_limit的低16位,程式碼是:seg_limit & 0xFFFF
  4. 段基址1是seg_base的低24位,程式碼是:seg_base & 0xFFFFFF
  5. 屬性是seg_attr的低8位 + seg_limit的高4位 + seg_attr的高4位,程式碼是:(seg_attr & 0xFF) |(( (seg_attr >>8) <<12) | (seg_limit >> 16)<<8)
  6. 段基值2是seg_base的高8位,程式碼是:seg_base>>24

和我以前寫的描述符結構的彙編程式碼B比較一下。

; 三個引數分別是段基址、段界限、段屬性
; 分別用 %1、%2、%3表示上面的三個引數,分別等價於A程式碼中的seg_base、seg_limit、seg_attr。
%macro  Descriptor 3
        dw      %2 & 0ffffh
        dw      %1 & 0ffffh
        db      (%1 >> 16) & 0ffh
        db      %3 & 0ffh
        db      ((%2 >> 16) & 0fh) | (((%3 >> 8) & 0fh) << 4)
        db      (%1 >> 24) & 0ffh
%endmacro

二者的差異在對段基址1、屬性的處理。

彙編程式碼B中對段基址1的處理是:

dw      %1 & 0ffffh
db      (%1 >> 16) & 0ffh

彙編程式碼A中對段基值1的處理是seg_base & 0xFFFFFF。把A中的6個位元組拆分成4個位元組和2個位元組,原因是在nasm彙編中,只存在dwdb這樣的偽指令,而不存在能表示6個位元組的偽指令。二者都能把段基址1儲存到正確的記憶體空間,只是受限於彙編語法把6個位元組拆分成兩部分來處理。

dw、db都是偽指令。

偽指令是指不被處理器直接支援的指令,最終會被翻譯成機器指令被處理器處理。

dw:一個字,兩個位元組。

db:一個位元組。

再比較二者對屬性的處理。

; A程式碼
(seg_attr & 0xFF) |(( (seg_attr >>8) <<12) | (seg_limit >> 16)<<8)
; B程式碼
db      seg_attr & 0ffh
db      ((seg_limit >> 16) & 0fh) | (((seg_attr >> 8) & 0fh) << 4)

A程式碼和B程式碼對屬性的處理結果是一致的,都符合nasm彙編語法。B程式碼很容易理解。A程式碼有點難懂,展開分析一下。

  1. 在描述符結構圖中,BYTE5、BYTE6中儲存的資料混合了段界限的高4位和全部屬性。
  2. 這個混合體的低8位是屬性的低8位,用seg_attr & 0xFF來獲取,這不應該有疑問。
  3. 混合體的高4位是屬性的高4位。屬性的高4位是seg_attr >>8。混合體總計12位,高4位的前面是低12位,所以,必須把獲取到的屬性的高4位左移12位,因此,最終結果是:(seg_attr >>8) <<12
  4. 混合體的高4位、低8位都已經填充資料,剩餘中間4位用來儲存段界限的高4位。段界限的高4位是seg_limit >> 16。要把這4位儲存在混合體的中間4位,需要跳過混合體的低8位,使用(seg_limit >> 16)<<8實現。
  5. 最後,把混合體的三部分用|運算子拼接起來,就是這樣的:(seg_attr & 0xFF) |(( (seg_attr >>8) <<12) | (seg_limit >> 16)<<8)

gdtptr

GDT儲存在記憶體的一段空間內。CPU要想正確讀取GDT中的描述符,需要先把這段空間的初始地址和空間界限儲存在專門的暫存器gdtptr中。

gdtptr的結構圖如下。

image-20211008153405453

在前面介紹保護模式時,我提到過,要確定一段記憶體空間,至少需要兩個值:這段記憶體空間的初始值和這段記憶體空間的長度。

GDT也儲存在一段記憶體空間中,只需提供空間初始值和空間長度就能找出儲存GDT的這段記憶體空間。gdtptr的設計正是如此:低16位儲存界限,高32位儲存基地址。16位界限 = 空間長度(L) - 1,因為記憶體地址的初始值是0。記憶體的第1個位元組,記憶體地址是0x0;記憶體的第2個位元組,記憶體地址是0x1。GDT的第1個位元組,記憶體地址是基地址+0;GDT的第2個位元組,記憶體地址是基地址+1;GDT的第3個位元組,記憶體地址是基地址+2;GDT的第L個位元組,記憶體地址是基地址+L-1。這就是“16位界限 = 空間長度(L) - 1”的由來。

那麼,要填充到gdtptr中的值怎麼用程式碼表示呢?請看下面。

; 三個引數分別是段基址、段界限、段屬性
; 分別用 %1、%2、%3表示上面的三個引數
%macro  Descriptor 3
        dw      %2 & 0ffffh
        dw      %1 & 0ffffh
        db      (%1 >> 16) & 0ffh
        db      %3 & 0ffh
        db      ((%2 >> 16) & 0fh) | (((%3 >> 8) & 0fh) << 4)
        db      (%1 >> 24) & 0ffh
%endmacro

				LABEL_GDT:      Descriptor  0,  0,      0
        LABLE_GDT_FLAT_X: Descriptor    0,              0ffffffh,                0c9ah
        LABLE_GDT_FLAT_X_16: Descriptor 0,              0ffffffh,                98h
        LABLE_GDT_FLAT_X_162: Descriptor        0,              0ffffffh,                98h
        ;LABLE_GDT_FLAT_X: Descriptor   0,              0FFFFFh,                 0c9ah
        ;LABLE_GDT_FLAT_WR:Descriptor   0,              0fffffh,                 293h
        LABLE_GDT_FLAT_WR_TEST:Descriptor 5242880,              0fffffh,                 0c92h
        LABLE_GDT_FLAT_WR_16:Descriptor 0,              0fffffh,                 0892h
        LABLE_GDT_FLAT_WR:Descriptor    0,              0fffffh,                 0c92h
        LABLE_GDT_VIDEO: Descriptor     0b8000h,                0ffffh,          0f2h

        GdtLen  equ             $ - LABEL_GDT
        GdtPtr  dw      GdtLen - 1
                dd      0

這段程式碼中的GdtPtr儲存的值就是要填充到暫存器gdtptr中的值。

GdtLen equ $ - LABEL_GDT中的$表示當前位置,即GdtLen之前的位置。LABEL_GDT表示從儲存當前二進位制檔案(當前原始檔編譯之後得到的二進位制檔案)的記憶體的初始地址到LABEL_GDT這個位置的位元組偏移量。彙編程式碼中的變數名稱都能理解成這個變數相對於儲存當前二進位制檔案的記憶體空間的初始位置的位元組偏移量。從LABEL_GDTGdtLen,是GDT表的長度。

GdtPtr  dw      GdtLen - 1
                dd      0

GdtPtr的前16位儲存GDT的長度減去1(就是GDT的界限),後32位儲存0(就是GDT的基地址)。這和gdtptr需要的資料結構正好一致。事實上,往gdtptr中儲存的值就是GdtPtr中的資料。完成這個操作的指令是:

[GdtPtr]表示記憶體地址GdtPtr指向的記憶體空間中儲存的資料,而不是指記憶體地址這個值本身。可以把[GdtPtr]理解成指標。
lgdt [GdtPtr]

關於GDT的理論知識,只剩下全域性描述符的指標和選擇子還沒有講解。讓我們看看全域性描述符的程式碼,順便理解一下全域性描述符中的段屬性。

LABEL_GDT:      Descriptor  0,  0,      0
LABLE_GDT_FLAT_X: Descriptor    0,              0ffffffh,                0c9ah
LABLE_GDT_FLAT_X_16: Descriptor 0,              0ffffffh,                98h
LABLE_GDT_FLAT_WR_16:Descriptor 0,              0fffffh,                 0892h
LABLE_GDT_FLAT_WR:Descriptor    0,              0fffffh,                 0c92h
LABLE_GDT_VIDEO: Descriptor     0b8000h,                0ffffh,          0f2h

現在開始解讀這段程式碼。

  1. Descriptor是建立描述符的巨集。彙編函式中的巨集和C語言中的巨集的作用相同。
  2. 注意,我沒有說Descriptor是建立“全域性”描述符的巨集。因為,用這個巨集既能建立全域性描述符,又能建立區域性描述符。
  3. LABEL_GDT。使用巨集Descriptor,三個引數都是0。意思是,這個描述符的描述的記憶體空間的段基址、段界限、段屬性都是0。這是一個空描述符,是GDT的初始位置。把GDT中的第一個描述符設計成空描述符,是為了定位其他描述符時有一個參照系。
  4. LABLE_GDT_FLAT_WR_16。段基址是0,段界限是0fffffh,段屬性是0892h

段界限是0fffffh位元組還是0fffffhG?這由段屬性中的一個屬性決定。下面詳細介紹段屬性。

描述符LABLE_GDT_FLAT_WR_16的屬性值是0892h。這個段屬性是怎麼確定的?又應該怎麼解讀?

首先,屬性值0892h的二進位制形式是:1000 1001 0010。這個二進位制值的儲存方式是“小端法”。把它填入下面的表格中。怎麼填寫?

1000 1001 0010的最右邊開始,依次把值填入兩個表格的第0列、第1列、第2列直至填充到第11列。

表格一

0 1 2 3 4 5 6 7 8 9 10 11
A R C X S DPL DPL P AVL L D/B G
0 1 0 0 1 0 0 1 0 0 0 1

表格二

A W E X S DPL DPL P AVL L D/B G
0 1 0 0 1 0 0 1 0 0 0 1

這兩個表格是段屬性的12個bit拼接在一起形成的,僅僅只有表格的第1列、第2列所儲存的資料的含義不同。

第1列、第2列是RC的那個表格是程式碼段的段屬性表格;RC分別表示是否可讀、是否依從。最關鍵的是X列,表示是否可執行。表格一中的X位上的值是0,這表示這個描述符所描述的記憶體空間中儲存的是“資料”,所以,這種描述符叫“程式碼段”描述符。

兩個表格的第0列到第3列對應描述符結構圖中的TYPE。每個位的含義請看下面的兩張圖。

image-20211008220628916

程式碼段一定是不可寫的,所以程式碼段屬性中沒有“寫”屬性。

資料段一定是可讀的,所以資料段屬性中不必設計一個“讀”屬性。

image-20210223153134430

上面是對描述符結構圖中TYPE的介紹。下面介紹除TYPE外的其他屬性。

  1. SS是0時,描述符是系統段/門描述符。S是1時,描述符是程式碼段/資料段描述符。
  2. DPL。描述符的特權級,值可以是012。數字越小,特權級越高。
  3. P。P是0時,描述符指向的段在記憶體中不存在。P是1時,描述符指向的段在記憶體中存在。
  4. AVL。保留位。我沒用到過。暫時不用關注。
  5. D/B。比較複雜。太瑣碎了。只需知道,它決定段使用的運算元、記憶體地址的位數;或者決定堆疊擴充套件的方向。用到這一位時,查資料即可。畢竟,記住這種細節,用處不大,成本不小。
  6. G。在前面,我提過,段界限除了具體數值,還有一個數值單位。這個單位由G決定。Ggranularity,意思是“粒度”。當G的值是0時,段界限粒度是位元組;當G的粒度是1時,段界限粒度是4kB

描述符LABLE_GDT_FLAT_WR_16的段屬性的G位是1,段界限是``0fffffh * 4kb = 4GB`。

再分析一個描述符LABLE_GDT_FLAT_WR。它的段屬性是0c92h。我想把這個描述符設定成:段界限粒度為4kB,資料段,可讀寫,32位。

把屬性0c92h換算成二進位制形式110010010010,然後填入資料段屬性的表格。

A W E X S DPL DPL P AVL L D/B G
0 1 0 0 1 0 0 1 0 0 1 1

W位是1,表示描述符指向的段是可讀可寫的。S是1,表示描述符是程式碼段或資料段描述符。D/B是1,表示描述符指向的段是32位的。G是1,表示描述符指向的段的段界限的粒度是4kB。和我前面的設想吻合。

對GDT的介紹終於可以結束了。

選擇子

現在可以進一步解釋es:bx了。

在保護模式下,es:bx中的es中儲存的資料叫做“選擇子”。GDT中包含多個描述符,選擇子的作用是從GDT中指定目標選擇子。

可以近似地把GDT理解成C語言中的陣列,陣列的元素的資料型別是描述符,選擇子是陣列的索引。像這樣簡化地理解選擇子,只是為了幫助我們體會選擇子的作用。它的作用是什麼?從GDT或LDT中找出目標描述符。

在前面,我提過,描述符分為全域性描述符和區域性描述符。由全域性描述符組成的描述符表叫GDT,由區域性描述符表組成的描述符表叫LDT。

給出一個選擇子,是從GDT還是LDT中挑選目標描述符呢?這是由選擇子中的某些資料決定的。來了解一下選擇子的結構。

image-20211009094512881

選擇子的前2個bit儲存RPL,對應的中文術語是“請求特權級”。RPLDPL一樣,有三個取值:000111

選擇子的第2個bi儲存TITI是0時,是GDT的選擇子。TI是1時,是LDT的選擇子。

選擇子的剩餘13位儲存描述符索引。這才是目標描述符在描述符表中的索引。描述符索引,本質是目標描述符在描述符表中的位元組偏移量。每個描述符的長度是8個位元組。描述符表能容納的描述符的最大數量是$2^{13}$即8192

gdtptr的低16位儲存GDT的段界限,也就是說,GDT的最大長度是$2{16}$位元組。GDT中描述符的最大數量是$2{13}$,每個描述符的長度是8個位元組,那麼,GDT中所有描述符的長度之和也就是GDT的長度是$2^{13} * 8 = 2^{16}$個位元組。二者是吻合的。

看一看選擇子的彙編程式碼。

SelectFlatX     equ     LABLE_GDT_FLAT_X - LABEL_GDT
SelectFlatX_16  equ     LABLE_GDT_FLAT_X_16 - LABEL_GDT
SelectFlatX_162 equ     LABLE_GDT_FLAT_X_162 - LABEL_GDT
SelectFlatWR    equ     LABLE_GDT_FLAT_WR - LABEL_GDT
SelectFlatWR_TEST       equ     LABLE_GDT_FLAT_WR_TEST - LABEL_GDT
SelectFlatWR_16 equ     LABLE_GDT_FLAT_WR_16 - LABEL_GDT
SelectVideo     equ     LABLE_GDT_VIDEO - LABEL_GDT + 3

程式碼中的第0列(初始值規定為0)是選擇子,例如SelectFlatX。表面上看,選擇子是對應描述符的記憶體地址相對於空描述符的記憶體地址的偏移量。這個偏移量一定是8個位元組的整數倍。

選擇子的前三位分別是CPLTI,後13位是描述符在描述符表中的索引。程式碼中的選擇子是描述符之間的偏移量,理解偏移量和選擇子結構如何吻合,是一個難點。

描述符相對於描述符表的初始地址及空描述符表的地址的偏移量,必定是8個位元組的整數倍。若覺得不能透徹理解,可以舉例子看看。

丟棄偏移量的低3位,相當於把偏移量左移3位(等價於除以8),結果是描述符的數量。也就是說,偏移量的高13位是選擇子對應的描述符相對於描述符陣列的初始位置的偏移量,但這個偏移量的單位不再是位元組,而是描述符。簡單說,偏移量的高13位是描述符在描述符表中的描述符偏移量。描述符偏移量不就是描述符索引麼?

偏移量的高13位是描述符在描述符表中的索引,理由在上面說了。偏移量的低3位是0,正好對應選擇子的低3位。把選擇子的低3位即CPLTI設定成0,從結構和資料的對應看,當然沒問題。可是,選擇子的低3位不會總是0,需要儲存具有特定意義的資料。怎麼辦實現這個目的?對描述符記憶體地址的偏移量進行修改就能實現這個目的。

正如上面的程式碼中的SelectVideo。它的選擇子的計算方式是:LABLE_GDT_VIDEO - LABEL_GDT + 3

SelectVideoTI0CPL3。這表示,SelectVideo是GDT的選擇子,當前特權級是3。

程式碼中的其他選擇子,都是描述符記憶體地址相對於GDT初始地址的原始偏移量,沒有修改低3位儲存的TICPL

定址方式

保護模式下,記憶體地址的定址方式如下圖所示。

image-20211009143022137

  1. es:bx這種形式的記憶體地址叫“邏輯地址”。
  2. es中的值是描述符表的選擇子。根據選擇子在描述符表中選擇它對應的描述符。
  3. 從描述符中獲取這個描述符指向的記憶體空間(根據描述符中包含的段基址和段界限就能劃定一段記憶體空間)。
  4. bx就是在這段記憶體空間中的地址偏移量。而es就是上圖中的SEG

進入保護模式

進入保護模式的程式碼如下。

; 把GdtPtr載入到暫存器gdtptr中。
mov     dword [GdtPtr + 2], BaseOfLoaderPhyAddr + LABEL_GDT
lgdt [GdtPtr]

; 關閉中斷。
; 關閉中斷的目的是,讓進入保護模式的所有操作具有原子性。
; 假如開啟了A20地址線,卻還處在真實模式下,此時會錯誤處理超過了2的20次方的地址。
cli

; 開啟A20地址線。
in al, 92h
or al, 10b
out 92h, al

; 準備切換到保護模式
mov eax, cr0
or eax, 1
mov cr0, eax
; 真正進入保護模式。這句把cs設定為SelectFlatX
jmp dword SelectFlatX:(BaseOfLoaderPhyAddr + LABEL_PM_START)

從真實模式進入保護模式,使用這幾行程式碼就可以了。要理解每行程式碼的含義,又涉及到一些古老的細節知識。我以為,這些東西作用不大,在後續功能開發中不會用到第二次。所以,對進入保護模式的方法,掌握到這個程度就足夠了。

重新放置核心

核心檔案kernel.bin已經從軟盤中讀入記憶體了,為什麼還要重新放置核心呢?因為,這個核心檔案是elf格式的。這種格式的可執行檔案,不僅包含可執行的指令,還包括一些額外資料。

下面,一起來了解ELF檔案。

這種起“過渡”作用的廢話真的需要嗎?不寫這句話,直接介紹elf,又顯得太突兀。

ELF

elf的全稱是excutable load format。linux系統上的目標檔案、可執行檔案都是elf檔案。elf檔案包含elf頭、section頭表、segment、program header table。

一圖勝千言。讓我們看看ELF檔案的結構示意圖。

圖2-4

ELF的知識遠遠不止上面這麼一點,但瞭解上面的知識,足以讓我們理解"重新放置核心"這個操作。好奇心強烈的讀者朋友可以看看下面的補充知識。不看也不會妨礙我們繼續開發自己的作業系統。

複製段

流程

重新放置核心,處理的是kernel.bin。它是ELF格式的可執行檔案。所謂重新放置,就是把segment0segment1segment2等複製到記憶體中的某些位置。具體流程如下:

  1. ELF Header中查詢出program header table在檔案中的偏移量e_phoffprogram header table中的program header的數量e_phnum
  2. 每個program header對應一個segmentprogram header中記錄著segment在檔案中的偏移量p_offset、應該在記憶體中的地址p_vaddr和它的大小p_filesz
  3. 遍歷program header table,把對應的segment複製到 p_vaddr 記憶體地址處。

虛擬碼

重新放置核心的虛擬碼如下。

// kernel.bin在記憶體中的位置。
address_of_kernel;
for(int i = 0; i < ELF_Header.e_phnum; i++ ){
  	// 每個program_header的大小是32位元組。
  	program_header = program_header_table + i * 32;
  	// 複製program_header對應的段。
  	Memcpy(program_header.p_vaddr, program_header.p_offset + address_of_kernel, program_header.p_filesz);
}

彙編程式碼

彙編程式碼如下。

BaseOfKernelPhyAddr     equ     80000h  ; Kernel.BIN 被載入到的位置
; 重新放置核心
InitKernel:
        push eax
        push ecx
        push esi
        ;程式段的個數,e_phnum
        mov cx, [BaseOfKernelPhyAddr + 2CH]
        movzx ecx, cx
        ;程式頭表的記憶體地址
        xor esi, esi
        ; 對應虛擬碼中的program_header_table,也是ELF頭的e_phoff。
        mov esi, [BaseOfKernelPhyAddr + 1CH]
        add esi, BaseOfKernelPhyAddr
.Begin:
        ; program_header的p_filesz。
        mov eax, [esi + 10H]
        push eax
				
        mov eax, BaseOfKernelPhyAddr
        ; [esi + 4H] 是program_header的 p_offset,eax的最終結果是段的記憶體地址。
        add eax, [esi + 4H]
        push eax
        ; [esi + 8H] 是program_header的 p_vaddr,eax的最終結果是段應該被重新放置到的記憶體地址。
        mov eax, [esi + 8H]
        push eax
        ; 呼叫複製函式。
        call Memcpy
        ; 三個引數(每個佔用32位,4個位元組,2個字),佔用6個字,12個位元組
        add esp, 12
        ; ecx是e_phnum。重新複製一個段後,ecx的值應該減去1。
        dec ecx
        ; 當ecx的值是0時,所有的段已經處理完畢。
        cmp ecx, 0
        jz .NoAction
        ; 一個program_header的大小是20H,處理完一個program_header後,
        ; 應該處理下一個program_header。
        add esi, 20H
        jmp .Begin

.NoAction:
        pop esi
        pop ecx
        pop eax

        ret

驗證

重新放置核心後,怎麼知道有沒有把核心中的程式碼段放置到了正確的記憶體位置呢?

有人說,執行一次唄。如果能看到核心執行的效果,就說明是正確的。

我試過,這樣不能驗證核心被正確地重新放置了。核心也就是kernel.bin是一個ELF檔案,除了包含程式碼段,還包含很多CPU不能識別的資料。直接執行kernel.bin,CPU遇到不能識別的資料就不處理,遇到了程式碼段就執行,所以,即使沒有正確地放置核心,也能看到核心執行效果。也許是因為我們目前的核心太簡單。

驗證核心有沒有被正確放置到記憶體中的方法是,使用bochs檢視記憶體中的資料。讓我們一起來驗證一下。

例項分析ELF檔案

用xxd檢視kernel.bin的資料data。

[root@localhost v4]# xxd -u -a -g 1 -c 16 kernel.bin
00000000: 7F 45 4C 46 01 01 01 00 00 00 00 00 00 00 00 00  .ELF............
00000010: 02 00 03 00 01 00 00 00 00 04 03 00 34 00 00 00  ............4...
00000020: 30 04 00 00 00 00 00 00 34 00 20 00 01 00 28 00  0.......4. ...(.
00000030: 03 00 02 00 01 00 00 00 00 00 00 00 00 00 03 00  ................
00000040: 00 00 03 00 1D 04 00 00 1D 04 00 00 05 00 00 00  ................
00000050: 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00000060: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
*
00000400: B4 0F B0 43 65 66 A3 D0 0C 00 00 65 66 A3 70 0D  ...Cef.....ef.p.
00000410: 00 00 65 66 A3 60 0E 00 00 EB FE EB FE 00 2E 73  ..ef.`.........s
00000420: 68 73 74 72 74 61 62 00 2E 74 65 78 74 00 00 00  hstrtab..text...
00000430: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00000440: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00000450: 00 00 00 00 00 00 00 00 0B 00 00 00 01 00 00 00  ................
00000460: 06 00 00 00 00 04 03 00 00 04 00 00 1D 00 00 00  ................
00000470: 00 00 00 00 00 00 00 00 10 00 00 00 00 00 00 00  ................
00000480: 01 00 00 00 03 00 00 00 00 00 00 00 00 00 00 00  ................
00000490: 1D 04 00 00 11 00 00 00 00 00 00 00 00 00 00 00  ................
000004a0: 01 00 00 00 00 00 00 00                          ........

從上面的資料中找出:program header table在檔案中的偏移量e_phoff、第一個程式碼段在段中的偏移量p_offset、第一個程式碼段被重新放置到記憶體中後的初始地址p_vaddr、程式在記憶體中的入口地址e_entry

  1. e_phoff。在檔案中的偏移量是1CH,長度是4個位元組。
    1. program header table在檔案中的地址就是e_phoff
    2. 在data中找到e_phoff,是34 00 00 00。這是十六進位制數。
    3. 34 00 00 00是十六進位制數,左邊是低記憶體地址,右邊是高記憶體地址。
    4. 把它寫成我們平時的讀寫順序,這個數字應該是0x34
  2. e_entry。在檔案中的偏移量是18H,長度是4個位元組,值是00 04 03 00,換算成0x030400
  3. p_offset。在program header中的偏移量是4H,在檔案中的偏移量是0x34+0x4,長度是4個位元組。
    1. 程式碼段在檔案中的地址就是p_offset
    2. p_offset00 00 00 00,和前面轉換34 00 00 00的方法一樣,00 00 00 000x0
  4. p_vaddr。在段中的偏移量是8H,在檔案中的偏移量是0x34+0x8,長度是4個位元組。
    1. 值是00 00 03 00,換算成0x030000
  5. 程式的入口在檔案中的偏移量是什麼?
    1. ELF檔案沒有直接提供這個資料,但我們可以計算出來。
    2. 先計算程式的入口在段中的偏移量。
    3. 程式的入口在記憶體中的地址是e_entry,段在記憶體中的地址是p_vaddr
    4. 程式的入口是段中的一條指令。這一句非常關鍵。
    5. e_entry - p_vaddr就是程式的入口在段中的偏移量offset
    6. 段在檔案中的偏移量 + 程式的入口在段中的偏移量就是程式的入口在段中的偏移量。
    7. 這個值是:0 + 0x030400 - 0x030000 = 0x400

還需要知道程式碼段的長度p_filesz。它在program header中的偏移量是10H,在檔案中的偏移量是0x34 + 0x10 = 0x44,因而,值是1D 04 00 00,換算成0x041D

在上面寫了這麼多,就是為了得出下列結論:

  1. 第一個程式碼段在檔案中的偏移量是0x0,應該被重新放置到記憶體地址p_vaddr0x30000
  2. 程式的入口的記憶體地址e_entry0x30400,在檔案內的偏移量是0x400
  3. 第一個程式碼段的長度是p_filesz0x041D

我們繼續進行分析ELF檔案。

程式碼段的資料是:

00000000: 7F 45 4C 46 01 01 01 00 00 00 00 00 00 00 00 00  .ELF............
00000010: 02 00 03 00 01 00 00 00 00 04 03 00 34 00 00 00  ............4...
00000020: 30 04 00 00 00 00 00 00 34 00 20 00 01 00 28 00  0.......4. ...(.
00000030: 03 00 02 00 01 00 00 00 00 00 00 00 00 00 03 00  ................
00000040: 00 00 03 00 1D 04 00 00 1D 04 00 00 05 00 00 00  ................
00000050: 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00000060: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
*
00000400: B4 0F B0 43 65 66 A3 D0 0C 00 00 65 66 A3 70 0D  ...Cef.....ef.p.
00000410: 00 00 65 66 A3 60 0E 00 00 EB FE EB FE 00 2E 73  ..ef.`.........s

這是第一個程式碼段中的資料?沒有搞錯嗎?怎麼這個程式碼段包含了ELF Headerprogram header table

分析ELF檔案後,我發現,第一個程式碼段的確包含了ELF Headerprogram header table。可我在《一個作業系統的實現》、《程式設計師的自我修改---連結、裝載與庫》、維基百科中看到的ELF檔案結構示意圖都把segmentELF畫成互不包含的兩個部分。怎麼理解兩類矛盾的現象?是這些書寫錯了嗎?

它們沒有錯。正確的理解應該是,這些資料中的圖是第二個、第三個程式碼段(反正不是第一個程式碼段)和ELF Header、program header table的結構示意圖。

回到“驗證核心中的指令是否正確''這個主題上來。

在data中,從0x4000x41C都是程式碼段中的指令,就是下面這塊資料。

00000400: B4 0F B0 43 65 66 A3 D0 0C 00 00 65 66 A3 70 0D  ...Cef.....ef.p.
00000410: 00 00 65 66 A3 60 0E 00 00 EB FE EB FE -- -- --  ..ef.`.........s

程式在記憶體中的初始地址是0x30400。我們只需對比記憶體0x304000x3041C中的資料是否和上面檔案中0x4000x41C`資料一一相等,就能判斷核心是否被正確地放置到了記憶體中。

請看下面的表格。

表格中的"地址偏移量"是指檔案地址偏移量和記憶體地址偏移量。兩個偏移量相等,只不過地址的基址不同。

檔案地址的基址是0x0,記憶體地址的基址是0x30000

啟動bochs,檢視記憶體地址0x304000x304010x30402中的資料,並填入下面的表格。

(0) Magic breakpoint
Next at t=14812083
(0) [0x000000090312] 0008:0000000000090312 (unk. ctxt): jmpf 0x0008:00030400      ; ea000403000800
<bochs:2> xp /1wx 0x30400
[bochs]:
0x0000000000030400 <bogus+       0>:	0x43b00fb4
<bochs:3> xp /1bx 0x30400
[bochs]:
0x0000000000030400 <bogus+       0>:	0xb4
<bochs:4> xp /1bx 0x30401
[bochs]:
0x0000000000030401 <bogus+       0>:	0x0f
<bochs:5> xp /1bx 0x30402
[bochs]:
0x0000000000030402 <bogus+       0>:	0xb0
<bochs:6> xp /1bx 0x3041D
[bochs]:
0x000000000003041d <bogus+       0>:	0x00
<bochs:7> XP /bx 0x3041C
:7: syntax error at 'XP'
<bochs:8> xp /1bx 0x3041C
[bochs]:
0x000000000003041c <bogus+       0>:	0xfe
地址偏移量 0x400 0x401 0x402 0x41C
檔案中的資料 B4 0F B0 FE
記憶體中的資料 b4 0f b0 fe

表格中的資料都是十六進位制資料,無論是否有沒有0x字首。十六進位制資料中的字母大小寫不敏感。比較下面幾個地址處的資料是否相等。一眼就能看出,檔案中的資料和記憶體中對應位置的資料相等。我只對比了幾個地址的資料。讀者朋友如果不相信這個結果,可以繼續看看其他地址處的資料是否相同。

如果你經過對比後發現記憶體中的第一個段的資料和檔案中的段的資料完全相同,就可以做出判斷了:核心被正確地重新放置到了記憶體中。

留一個疑問。我們對比的是記憶體中的第一個段的資料和檔案中的段的資料,然而,重新放置核心並不是直接從軟盤中的檔案中讀取資料再放置,而是從記憶體的一個位置把核心資料重新放置到另一個位置。我的問題是:對比記憶體中的資料和軟盤中的檔案中的資料,效果等同於對比記憶體中的資料和另一段記憶體中的資料。為什麼二者的效果是等同的?

補充知識

.text是程式碼段,.bss 段儲存未初始化的變數,.rodata段儲存字串或只讀變數,.data段儲存已經初始化了的全域性或區域性靜態變數。它們都是sectionsegment

在可執行檔案中,它們是segment。在目標檔案等非執行檔案中,它們是section

在”kernel"這個小節,編譯kernel.asm產生的kernel.o是目標檔案,kernel.bin是可執行檔案。使用file命令檢視這兩個檔案。

[root@localhost v4]# file kernel.o
kernel.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
[root@localhost v4]# file kernel.bin
kernel.bin: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, stripped

kernel.o 是ELF 32-bit LSB relocatable,kernel.bin是 ELF 32-bit LSB executable。二者都是ELF檔案,但前者是relocatable,後者是executable

只有ELF Header的位置在ELF檔案中是固定的,在開頭。其他元素例如program header table的位置不固定,在ELF Header中記錄了它們的位置、大小等資訊。

想了解更多關於ELF的知識,請去看《一個作業系統的實現》的5.3節和《程式設計師的自我修改》的第3章。

《一個作業系統的實現》中的elf檔案的結構和《程式設計師的自我修改》中的elf檔案的結構不同。另外,像.rodata.bss.text這些,在《一個作業系統的實現》中沒有出現。我不清楚,像.rodata.bss.text這些和section頭表、segment頭表、program header table的關係。

section頭等都挨在一起組成一個表,是這樣嗎?

program header描述一個segment 還是一個segment header?二者是一一對應的關係嗎?

segment 只存在於可執行檔案中嗎?

參考資料

《一個作業系統的實現》

《程式設計師的自我修養---連結、裝載與庫》

程式碼註釋

我不知道怎麼用更好的方式寫剩下的內容,就直接送上新增了詳細註釋的loader.asm吧。

; 暫時不必理會這句的意思。
org	0100h

	jmp	LABEL_START
	nop

	; 下面是 FAT12 磁碟的頭
  BS_OEMName      DB 'YOUR--OS'   ; OEM String, 必須 8 個位元組
  BPB_BytsPerSec  DW 512          ; 每扇區位元組數
  BPB_SecPerClus  DB 1            ; 每簇多少扇區
  BPB_RsvdSecCnt  DW 1            ; Boot 記錄佔用多少扇區
  BPB_NumFATs     DB 2            ; 共有多少 FAT 表
  BPB_RootEntCnt  DW 224          ; 根目錄檔案數最大值
  BPB_TotSec16    DW 2880         ; 邏輯扇區總數
  BPB_Media       DB 0xF0         ; 媒體描述符
  BPB_FATSz16     DW 9            ; 每FAT扇區數
  BPB_SecPerTrk   DW 18           ; 每磁軌扇區數
  BPB_NumHeads    DW 2            ; 磁頭數(面數)
  BPB_HiddSec     DD 0            ; 隱藏扇區數
  BPB_TotSec32    DD 0            ; wTotalSectorCount為0時這個值記錄扇區數
  BS_DrvNum       DB 0            ; 中斷 13 的驅動器號
  BS_Reserved1    DB 0            ; 未使用
  BS_BootSig      DB 29h          ; 擴充套件引導標記 (29h)
  BS_VolID        DD 0            ; 卷序列號
  BS_VolLab       DB 'YOUR--OS.02'; 卷標, 必須 11 個位元組
  BS_FileSysType  DB 'FAT12   '   ; 檔案系統型別, 必須 8個位元組

; 三個引數分別是段基址、段界限、段屬性
; 分別用 %1、%2、%3表示上面的三個引數
%macro	Descriptor 3
	dw	%2 & 0ffffh
	dw	%1 & 0ffffh
	db	(%1 >> 16) & 0ffh
	db	%3 & 0ffh
	db	((%2 >> 16) & 0fh) | (((%3 >> 8) & 0fh) << 4)
	db	(%1 >> 24) & 0ffh
%endmacro

	; 描述符和選擇子的詳細講解請看本問的”GDT”、"選擇子"小節。
	; 空描述符,也是GDT的地址。
	LABEL_GDT:	Descriptor  0,	0,	0
	; 程式碼段描述符
	LABLE_GDT_FLAT_X: Descriptor	0,		0ffffffh,		 0c9ah
	; 資料段描述符
	LABLE_GDT_FLAT_WR:Descriptor	0,	        0fffffh,	         0c92h
	; 視訊記憶體段描述符
	LABLE_GDT_VIDEO: Descriptor	0b8000h,		0ffffh,		 0f2h

	; GDT地址,在下面載入到gdtptr暫存器。
	; GDT的長度。
	GdtLen	equ		$ - LABEL_GDT
	GdtPtr	dw	GdtLen - 1
		dd	BaseOfLoaderPhyAddr + LABEL_GDT
	; 程式碼段的選擇子
	SelectFlatX	equ	LABLE_GDT_FLAT_X - LABEL_GDT
	; 資料段的選擇子
	SelectFlatWR	equ	LABLE_GDT_FLAT_WR - LABEL_GDT
	; 視訊記憶體段的選擇子
	SelectVideo	equ	LABLE_GDT_VIDEO - LABEL_GDT + 3


LABEL_START:
	; 列印X
	mov ax, 0B800h
	mov gs, ax
	mov ah, 0Ch
	mov al, 'X'
	mov [gs:(80 * 16 + 20)*2], ax

	; 下面的程式碼把kernel.bin從軟盤中載入到記憶體中,和從軟盤中讀取loader.bin相似,
	; 請看上一篇文章《開發引導扇區》中的程式碼註釋。
	mov ax, BaseOfKernel
	mov es, ax
	mov ax, 0x9000
	mov ds, ax

	; 復位軟碟機
	mov  ah, 00h
	mov  dl, 0
	int 13h
	mov ax,	FirstSectorOfRootDirectory
	mov cl, 1
	
	mov bx, OffSetOfLoader
	call ReadSector
	mov cx, 4
	mov bx, (80 * 18 + 40) * 2
	mov di, OffSetOfLoader
SEARCH_FILE_IN_ROOT_DIRECTORY:
	cmp cx, 0
	jz FILE_NOT_FOUND
	push cx
	mov si, LoaderBinFileName
	mov cx, LoaderBinFileNameLength
	mov dx, 0
COMPARE_FILENAME:
	;cmp [es:si], [ds:di]
	;cmp [si], [di]
	lodsb
	cmp al, byte [es:di]
	jnz FILENAME_DIFFIERENT
	dec cx

	inc di
	inc dx
	
	cmp dx, LoaderBinFileNameLength
	jz FILE_FOUND
	jmp COMPARE_FILENAME		
FILENAME_DIFFIERENT:
	mov al, 'E'
        mov ah, 0Ch
        mov [gs:bx], ax
	add bx, 160

	pop cx		; 在迴圈中,cx會自動減少嗎?
	cmp cx, 0
	dec cx
	jz FILE_NOT_FOUND
	;;;;;xchg bx, bx
	and di, 0xFFE0	; 低5位設定為0,其餘位數保持原狀。回到正在遍歷的根目錄項的初始位置
	add di, 32	; 增加一個根目錄項的大小
	jmp SEARCH_FILE_IN_ROOT_DIRECTORY
FILE_FOUND:
	mov al, 'S'
	mov ah, 0Ah
	mov [gs:(80 * 23 + 35) *2], ax
	;;;;xchg bx, bx
	; 修改段地址和偏移量後,獲取的第一個簇號錯了 
	; 獲取檔案的第一個簇的簇號
	and di, 0xFFE0  ; 低5位設定為0,其餘位數保持原狀。回到正在遍歷的根目錄項的初始位置; 獲取檔案的第一個簇的簇號
	add di, 0x1A
	mov si, di
	mov ax, BaseOfKernel
	push ds
	mov ds, ax
	;;;;xchg bx, bx
	lodsw
	pop ds	
	push ax
	;;;;xchg bx, bx	
	; call GetFATEntry
	mov bx, OffSetOfLoader
	; 獲取到檔案的第一個簇號後,開始讀取檔案
READ_FILE:
	;;;;xchg bx, bx
	push bx
	; push ax
	; 簇號就是FAT項的編號,把FAT項的編號換算成位元組數
	;;push bx
	;mov dx, 0
	;mov bx, 3
	;mul bx
	;mov bx, 2
	;div bx			; 商在ax中,餘數在dx中
	;mov [FATEntryIsInt], dx
	;
	;; 用位元組數計算出FAT項在軟盤中的扇區號
	;mov dx, 0
	;mov bx, 512
	;div bx			; 商在ax中,餘數在dx中。商是扇區偏移量,餘數是在扇區內的位元組偏移量
	
	; 簇號就是FAT項的編號,同時也是檔案塊在資料區的扇區號。
	; 用簇號計算出目標扇區在軟盤中的的扇區號。
	add ax, 19
	add ax, 14
	sub ax, 2
		
	; 讀取一個扇區的資料 start
	; add ax, SectorNumberOfFAT1
	mov cl, 1
	pop bx	
	call ReadSector
	;;;;xchg bx, bx
        add bx, 512
	; 讀取一個扇區的資料 end
	
	;jmp READ_FILE_OVER
		
	pop ax
	push bx
	call GetFATEntry
	pop bx
	push ax
	cmp ax, 0xFF8
	; 注意了,ax >= 0xFF8 時跳轉,使用jc 而不是jz。昨天,一定是在這裡弄錯了,導致浪費幾個小時除錯。
	;jz READ_FILE_OVER	
	;jc READ_FILE_OVER	
	jnb READ_FILE_OVER	
	
	;mov al, 'A'
	;inc al
	;mov ah, 0Ah
	;mov [gs:(80 * 23 + 36) *2], ax	
	;;;;;xchg bx, bx	
	jmp READ_FILE
	
FILE_NOT_FOUND:
        mov al, 'N'
        mov ah, 0Ah
        mov [gs:(80 * 23 + 36) *2], ax
	jmp OVER

READ_FILE_OVER:
	;xchg bx, bx
	;mov al, 'O'
	;mov ah, 0Dh
	;mov [gs:(80 * 23 + 33) * 2], ax
	; 開啟保護模式 start
	;cli
	;mov dx, BaseOfLoaderPhyAddr + LABEL_PM_START ;;xchg bx, bx	
	lgdt [GdtPtr]	
	
	cli
	
	 in al, 92h
	or al, 10b
	out 92h, al

	mov eax, cr0
	or eax, 1
	mov cr0, eax
	;xchg bx, bx
	; 真正進入保護模式。這句把cs設定為SelectFlatX	
	;jmp dword SelectFlatX:(BaseOfLoaderPhyAddr + 100h + LABEL_PM_START)
	;jmp dword SelectFlatX:dx
	jmp dword SelectFlatX:(BaseOfLoaderPhyAddr + LABEL_PM_START)
	; 開啟保護模式 end


	; 在記憶體中重新放置核心
	;call InitKernel

	
	;;;xchg bx, bx
	;jmp BaseOfKernel:73h
	;jmp BaseOfKernel:61h
	;jmp BaseOfKernel2:400h
	;jmp BaseOfKernel:60h
	;jmp BaseOfKernel:0
	;jmp BaseOfKernel:OffSetOfLoader	
	;jmp BaseOfKernel2:0x30400
	;jmp BaseOfKernel:OffSetOfLoader
	;jmp BaseOfKernel:40h
	;jmp OVER

OVER:

	jmp $



BootMessage:	db	"Hello,World OS!"
;BootMessageLength:	db	$ - BootMessage
; 長度,需要使用 equ 
BootMessageLength	equ	$ - BootMessage

FirstSectorOfRootDirectory	equ	19
SectorNumberOfTrack	equ	18
SectorNumberOfFAT1	equ	1

;LoaderBinFileName:	db	"KERNEL  BIN"
LoaderBinFileName:	db	"KERNEL  BIN"
LoaderBinFileNameLength	equ	$ - LoaderBinFileName	; 中間兩個空格

FATEntryIsInt	equ 0		; FAT項的位元組偏移量是不是整數個位元組:0,不是;1,是。
BytesOfSector	equ	512	; 每個扇區包含的位元組數量
; 根據FAT項的編號獲取這個FAT項的值
GetFATEntry:
	; 用FAT項的編號計算出這個FAT項的位元組偏移量 start
	; mov cx, 3
	; mul cx
	; mov cx, 2
	;div cx		; 商在al中,餘數在ah中	; 
	push ax
	MOV ah, 00h
	mov dl, 0
	int 13h
	
	pop ax	
	mov dx, 0
	mov bx, 3
	mul bx
	mov bx, 2
	div bx
	; 用FAT項的編號計算出這個FAT項的位元組偏移量 end
	mov [FATEntryIsInt], dx
	; 用位元組偏移量計算出扇區偏移量 start
	mov dx, 0
	; and ax, 0000000011111111b  ; 不知道這句的意圖是啥,忘記得太快了!
	; mov dword ax, al ; 錯誤用法
	; mov cx, [BytesOfSector]
	mov cx, 512
	div cx
	; push dx
	add ax, SectorNumberOfFAT1	; ax 是在FAT1區域的偏移。要把它轉化為在軟盤中的扇區號,需加上FAT1對軟盤的偏移量。
	; mov ah, 00h

	; mov dl, 0
	; int 13h
	; 用位元組偏移量計算出扇區偏移量 end
	; mov dword ax, al
	; add ax,1
	mov cl, 2 
	mov bx, 0
	push es
	push dx
	push ax
	mov ax, BaseOfFATEntry
	mov es, ax
	pop ax
	; 用扇區偏移量計算出在某柱面某磁軌的扇區偏移量,可以直接呼叫ReadSector
	call ReadSector
	;pop es
	;;;;;;xchg bx, bx
	;pop ax
	;mov ax, [es:bx]
	pop dx
	add bx, dx
	mov ax, [es:bx]
	pop es
	; 根據FAT項偏移量是否佔用整數個位元組來計算FAT項的值
	cmp byte [FATEntryIsInt], 0
	jz FATEntry_Is_Int
	shr ax, 4	
FATEntry_Is_Int:
	and ax, 0x0FFF
	ret

; 讀取扇區
ReadSector:
	push ax
	push bp
	push bx
	mov bp, sp
	sub sp, 2
	mov byte [bp-2], cl
	; push al	; error: invalid combination of opcode and operands
	;push cx
	; mov bx, SectorNumberOfTrack
	
	; ax 儲存在軟盤中的扇區號
	mov bl, SectorNumberOfTrack	; 一個磁軌包含的扇區數
	div bl	; 商在al中,餘數在ah中
	mov ch, al
	shr ch, 1	; ch 是柱面號
	mov dh, al
	and dh, 1	; dh 是磁頭號
	mov dl, 0	; 驅動器號,0表示A盤
	inc ah
	mov cl, ah
	;add cl, 1	; cl 是起始扇區號
	; pop al		; al 是要讀的扇區數量
	mov al, [bp-2]
	add sp, 2
	mov ah, 02h	; 讀軟盤
	pop bx
	
	;mov bx, BaseOfKernel	; 讓es:bx指向BaseOfKernel
	;mov ax, cs
	;mov es, ax
	;;;;;;xchg bx, bx
	int 13h
	;pop cx
	;;;;;;xchg bx, bx
	; pop bx
	pop bp
	pop ax
	ret	


;
;        mov ch, 0
;        mov cl, 1
;        mov dh, 0
;        mov dl, 0
;        mov al, 1                       ; 要讀的扇區數量
;        mov ah, 02h                     ; 讀軟盤
;        mov bx, BaseOfKernel            ; 讓es:bx指向BaseOfKernel
;        int 13h                         ; int 13h 中斷
;        ret



; 讀取扇區
ReadSector2:
	mov ch, 0
	mov cl, 2
	mov dh, 1
	mov dl, 0
	mov al, 1			; 要讀的扇區數量
	mov ah,	02h			; 讀軟盤
	mov bx,	BaseOfKernel		; 讓es:bx指向BaseOfKernel
	int 13h				; int 13h 中斷
	ret

BaseOfKernel	equ	0x8000
BaseOfKernel2	equ	0x6000
BaseOfKernel3	equ	0x0
OffSetOfLoader	equ	0x0
BaseOfFATEntry	equ	0x1000
BaseOfLoader    equ     0x9000


BaseOfLoaderPhyAddr	equ	BaseOfLoader * 10h	; LOADER.BIN 被載入到的位置 ---- 實體地址 (= BaseOfLoader * 10h)

[SECTION .s32]

ALIGN	32

[BITS	32]

LABEL_PM_START:
	; 初始化一些暫存器
	mov ax, SelectFlatWR
	mov ds, ax
	mov es, ax
	mov fs, ax
	mov ss, ax
	mov ax, SelectVideo
	mov gs, ax
	
	mov gs, ax
	mov al, 'K'
	mov ah, 0Ah
	mov [gs:(80 * 19 + 25) * 2], ax
	; 重新放置核心
	call InitKernel
	;xchg bx, bx	

	;mov gs, ax
	mov al, 'G'
	mov ah, 0Ah
	mov [gs:(80 * 19 + 20) * 2], ax
	
	xchg bx, bx


	;jmp 0x30400
	; 開始執行核心程式碼。
	jmp SelectFlatX:0x30400
	; 等同於while(1){}
	jmp $
	jmp $
	jmp $
	jmp $



; 重新放置核心
InitKernel:
	push eax
	push ecx
	push esi
	;xchg bx, bx
	;程式段的個數
	;mov cx, word ptr ds:0x802c
	mov cx, [BaseOfKernelPhyAddr + 2CH]
	movzx ecx, cx
	;程式頭表的記憶體地址
	xor esi, esi
	mov esi, [BaseOfKernelPhyAddr + 1CH]
	add esi, BaseOfKernelPhyAddr
	;xchg bx, bx

.Begin:
	mov eax, [esi + 10H]
	push eax

	mov eax, BaseOfKernelPhyAddr
	add eax, [esi + 4H]
	push eax
	mov eax, [esi + 8H]
	push eax
	call Memcpy
	;xchg bx, bx
	; 三個引數(每個佔用32位,4個位元組,2個字),佔用6個字,12個位元組
	add esp, 12
	dec ecx
	cmp ecx, 0
	jz .NoAction
	add esi, 20H
	jmp .Begin

.NoAction:
	;xchg bx, bx
	pop esi
	pop ecx
	pop eax

	ret


; Memcpy(p_vaddr, p_off, p_size)
Memcpy:
	push ebp
	mov ebp, esp
	push eax
	push ecx
	push esi
	push edi
	;mov bp, sp
	;mov di, [bp + 4]        ; p_vaddr,即 dst
	;mov si, [bp + 8]        ; p_off,即 src
	;mov cx, [bp + 12]	; 程式頭的個數,即p_size

	;mov di, [bp + 8]        ; p_vaddr,即 dst
	;mov si, [bp + 12]        ; p_off,即 src
	;mov cx, [bp + 16]       ; 程式頭的個數,即p_size

	mov edi, [ebp + 8]        ; p_vaddr,即 dst
	mov esi, [ebp + 12]        ; p_off,即 src
	mov ecx, [ebp + 16]       ; 程式頭的個數,即p_size
	push es

	; 在32位模式下,這兩步操作不需要。而且,我沒有找到把大運算元賦值給小儲存單元的指令。
	; mov es, edi
	; mov edi, 0

.1:
	mov byte al, [ds:esi]
	mov [es:edi], al

	inc esi
	inc edi
	dec ecx

	cmp ecx, 0
	jz .2
	jmp .1

.2:
	pop es
	mov eax, [ebp + 8]

	pop edi
	pop esi
	pop ecx
	pop eax
	pop ebp

	ret



BaseOfKernelPhyAddr	equ	80000h	; Kernel.BIN 被載入到的位置 ---- 實體地址 中的段基址部分

相關文章